454 lines
16 KiB
Python
454 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
from types import MethodType
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.agents.context_analysis import ContextAnalysisAgent
|
|
from cleveragents.application.services.context_service import ContextService
|
|
from cleveragents.config.settings import Settings
|
|
from cleveragents.domain.models.core import Plan, Project
|
|
|
|
|
|
def _ensure_temp_project(context) -> None:
|
|
if hasattr(context, "project") and hasattr(context, "temp_dir_path"):
|
|
return
|
|
temp_dir = Path(tempfile.mkdtemp(prefix="context-analysis-"))
|
|
context.temp_dir_path = temp_dir
|
|
if hasattr(context, "add_cleanup"):
|
|
context.add_cleanup(shutil.rmtree, temp_dir, True)
|
|
context.project = Project(id=1, name="context-analysis-project", path=temp_dir)
|
|
|
|
|
|
def _ensure_context_service(context) -> None:
|
|
if hasattr(context, "context_service"):
|
|
return
|
|
_ensure_temp_project(context)
|
|
settings = Settings()
|
|
unit_of_work = MagicMock()
|
|
|
|
transaction_manager = unit_of_work.transaction.return_value
|
|
transaction_context = MagicMock()
|
|
transaction_context.plans = MagicMock()
|
|
plan = Plan(
|
|
id=42,
|
|
project_id=context.project.id or 1,
|
|
name="analysis-plan",
|
|
prompt="Analyze context for coverage",
|
|
current=True,
|
|
)
|
|
transaction_context.plans.get_current_for_project.return_value = plan
|
|
transaction_manager.__enter__.return_value = transaction_context
|
|
transaction_manager.__exit__.return_value = False
|
|
|
|
service = ContextService(settings=settings, unit_of_work=unit_of_work)
|
|
context.plan_metadata = plan
|
|
context.context_files: list[Path] = []
|
|
context.created_files: dict[str, Path] = {}
|
|
|
|
def list_files_stub(
|
|
self: ContextService, project: Project | None = None
|
|
) -> list[str]:
|
|
return [str(path) for path in context.context_files]
|
|
|
|
service.list_files = MethodType(list_files_stub, service)
|
|
context.context_service = service
|
|
|
|
|
|
def _create_file(context, filename: str, content: str) -> Path:
|
|
_ensure_temp_project(context)
|
|
file_path = context.temp_dir_path / filename
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
file_path.write_text(content.strip("\n"), encoding="utf-8")
|
|
context.created_files[filename] = file_path
|
|
return file_path
|
|
|
|
|
|
def _add_file_to_context(context, filename: str) -> Path:
|
|
if filename not in context.created_files:
|
|
raise AssertionError(f"File '{filename}' has not been created yet")
|
|
file_path = context.created_files[filename]
|
|
if file_path not in context.context_files:
|
|
context.context_files.append(file_path)
|
|
return file_path
|
|
|
|
|
|
@given("I have initialized a CleverAgents project")
|
|
def step_init_project(context):
|
|
_ensure_temp_project(context)
|
|
|
|
|
|
@given("I have a context service with LangGraph integration")
|
|
def step_context_service(context):
|
|
_ensure_context_service(context)
|
|
|
|
|
|
@given("LangSmith tracing is enabled for context analysis metadata")
|
|
def step_enable_langsmith(context):
|
|
_ensure_context_service(context)
|
|
context.context_service.settings.langsmith_enabled = True
|
|
context.context_service.settings.langsmith_project = "behave-context-coverage"
|
|
context.context_service.settings.langsmith_api_key = "behave-context-api-key"
|
|
|
|
|
|
@given("the current plan has no context files")
|
|
def step_no_context_files(context):
|
|
_ensure_context_service(context)
|
|
context.context_files.clear()
|
|
|
|
|
|
@given('I have a Python file "{filename}" with content:')
|
|
def step_create_file(context, filename):
|
|
_ensure_context_service(context)
|
|
_create_file(context, filename, context.text)
|
|
|
|
|
|
@given('I have added "{filename}" to the LangGraph context')
|
|
def step_add_file(context, filename):
|
|
_ensure_context_service(context)
|
|
_add_file_to_context(context, filename)
|
|
|
|
|
|
@given("I have added a non-existent file path to the analysis")
|
|
def step_add_missing_file(context):
|
|
_ensure_context_service(context)
|
|
missing_path = context.temp_dir_path / "missing_file.py"
|
|
context.created_files["missing_file.py"] = missing_path
|
|
context.context_files.append(missing_path)
|
|
context.nonexistent_path = missing_path
|
|
|
|
|
|
@when("I analyze the context")
|
|
def step_analyze_context(context):
|
|
_ensure_context_service(context)
|
|
context.analysis_result = context.context_service.analyze_context(context.project)
|
|
|
|
|
|
@when("I analyze the context asynchronously")
|
|
def step_analyze_context_async(context):
|
|
_ensure_context_service(context)
|
|
context.async_analysis_result = asyncio.run(
|
|
context.context_service.analyze_context_async(context.project)
|
|
)
|
|
|
|
|
|
@when("I analyze the context with the non-existent path")
|
|
def step_analyze_nonexistent(context):
|
|
_ensure_context_service(context)
|
|
context.nonexistent_result = context.context_service.analyze_context(
|
|
context.project
|
|
)
|
|
|
|
|
|
@when("I stream the context analysis")
|
|
def step_stream_context(context):
|
|
_ensure_context_service(context)
|
|
context.streaming_events = list(
|
|
context.context_service.analyze_context_streaming(context.project)
|
|
)
|
|
|
|
|
|
@when("I stream the context analysis asynchronously")
|
|
def step_stream_context_async(context):
|
|
_ensure_context_service(context)
|
|
|
|
async def _collect_events() -> list[dict[str, Any]]:
|
|
events: list[dict[str, Any]] = []
|
|
async for event in context.context_service.analyze_context_streaming_async(
|
|
context.project
|
|
):
|
|
events.append(event)
|
|
return events
|
|
|
|
context.async_streaming_events = asyncio.run(_collect_events())
|
|
|
|
|
|
@when("I get the context summary")
|
|
def step_get_summary(context):
|
|
_ensure_context_service(context)
|
|
context.summary_text = context.context_service.get_context_summary(context.project)
|
|
|
|
|
|
@when("I get the context dependencies")
|
|
def step_get_dependencies(context):
|
|
_ensure_context_service(context)
|
|
context.dependency_map = context.context_service.get_context_dependencies(
|
|
context.project
|
|
)
|
|
|
|
|
|
@when("I get relevant files with threshold {value:f}")
|
|
def step_get_relevant_files(context, value: float):
|
|
_ensure_context_service(context)
|
|
context.relevant_files = context.context_service.get_relevant_files(
|
|
context.project, threshold=value
|
|
)
|
|
|
|
|
|
@when('I prepare the LangGraph analysis config for run "{run_name}" in "{mode}" mode')
|
|
def step_prepare_langsmith_config(context, run_name: str, mode: str):
|
|
_ensure_context_service(context)
|
|
context.langsmith_config = context.context_service._prepare_analysis_config(
|
|
context.project,
|
|
run_name=run_name,
|
|
file_paths=[str(path) for path in context.context_files],
|
|
mode=mode,
|
|
)
|
|
|
|
|
|
@when("I request a context analysis agent")
|
|
def step_request_context_agent(context):
|
|
_ensure_context_service(context)
|
|
context.requested_agent = context.context_service._get_context_agent()
|
|
|
|
|
|
@then("the analysis result should have empty documents")
|
|
def step_assert_empty_documents(context):
|
|
docs = context.analysis_result["documents"]
|
|
assert len(docs) == 0, "Expected no documents in the analysis result"
|
|
|
|
|
|
@then("the analysis summary should indicate no files to analyze")
|
|
def step_assert_empty_summary(context):
|
|
summary = context.analysis_result["summary"]
|
|
assert "no context files" in summary.lower(), summary
|
|
|
|
|
|
@then("there should be no error in the analysis")
|
|
def step_assert_no_error(context):
|
|
assert context.analysis_result["error"] in (None, ""), context.analysis_result[
|
|
"error"
|
|
]
|
|
|
|
|
|
@then("the analysis result should have {count:d} document")
|
|
@then("the analysis result should have {count:d} documents")
|
|
def step_assert_document_count(context, count: int):
|
|
docs = context.analysis_result["documents"]
|
|
assert len(docs) == count, f"Expected {count} documents, got {len(docs)}"
|
|
|
|
|
|
@then('the dependencies for "{filename}" should include "{token}"')
|
|
def step_assert_dependencies(context, filename: str, token: str):
|
|
file_path = str(context.created_files[filename])
|
|
dependencies = context.analysis_result["dependencies"].get(file_path, [])
|
|
assert any(token in entry for entry in dependencies), dependencies
|
|
|
|
|
|
@then('the relevance score for "{filename}" should be between {low:f} and {high:f}')
|
|
def step_assert_relevance_range(context, filename: str, low: float, high: float):
|
|
file_path = str(context.created_files[filename])
|
|
score = context.analysis_result["relevance_scores"].get(file_path)
|
|
assert score is not None, f"No score for {filename}"
|
|
assert low <= score <= high, f"Score {score} not in range [{low}, {high}]"
|
|
|
|
|
|
@then("the summary should not be empty")
|
|
def step_assert_summary_not_empty(context):
|
|
assert context.analysis_result["summary"].strip(), "Summary is empty"
|
|
|
|
|
|
@then("the summary should be a non-empty string")
|
|
def step_assert_summary_string(context):
|
|
assert context.summary_text.strip(), "Summary text is empty"
|
|
|
|
|
|
@then("the summary should not indicate an error")
|
|
def step_assert_summary_no_error(context):
|
|
assert "failed" not in context.summary_text.lower(), context.summary_text
|
|
|
|
|
|
@then("the dependencies should include entries for both files")
|
|
def step_assert_dependencies_two(context):
|
|
dep_map = context.analysis_result["dependencies"]
|
|
assert len(dep_map.keys()) >= 2, dep_map
|
|
|
|
|
|
@then("the relevance scores should have entries for both files")
|
|
def step_assert_scores_two(context):
|
|
relevance = context.analysis_result["relevance_scores"]
|
|
assert len(relevance.keys()) >= 2, relevance
|
|
|
|
|
|
@then('the dependencies dict should have an entry for "{filename}"')
|
|
def step_assert_dependency_entry(context, filename: str):
|
|
file_path = str(context.created_files[filename])
|
|
deps = context.dependency_map.get(file_path, [])
|
|
assert deps, f"No dependencies recorded for {filename}"
|
|
|
|
|
|
@then("the dependencies should be a non-empty list")
|
|
def step_assert_dependency_list(context):
|
|
assert all(context.dependency_map.values()), context.dependency_map
|
|
|
|
|
|
@then("the result should include both files with scores")
|
|
def step_assert_relevant_files(context):
|
|
paths = {Path(path) for path, _ in context.relevant_files}
|
|
expected = {path for path in context.context_files}
|
|
assert expected.issubset(paths), f"Missing files in scores: {expected - paths}"
|
|
|
|
|
|
@then("all LangGraph scores should be between {low:f} and {high:f}")
|
|
def step_assert_all_scores(context, low: float, high: float):
|
|
for _, score in context.relevant_files:
|
|
assert low <= score <= high, f"Score {score} outside [{low}, {high}]"
|
|
|
|
|
|
@then("I should receive multiple events")
|
|
def step_assert_multiple_events(context):
|
|
assert len(context.streaming_events) >= 2, context.streaming_events
|
|
|
|
|
|
@then("the events should include node execution results")
|
|
def step_assert_node_events(context):
|
|
node_names = {
|
|
"load_files",
|
|
"analyze_dependencies",
|
|
"chunk_documents",
|
|
"score_relevance",
|
|
"summarize_context",
|
|
}
|
|
assert any(
|
|
isinstance(event, dict) and node_names.intersection(event.keys())
|
|
for event in context.streaming_events
|
|
), context.streaming_events
|
|
|
|
|
|
@then("the streaming output should report no files to analyze")
|
|
def step_assert_streaming_no_files(context):
|
|
assert context.streaming_events == [
|
|
{"type": "complete", "summary": "No context files to analyze"}
|
|
], context.streaming_events
|
|
|
|
|
|
@then("the async analysis result should have empty documents")
|
|
def step_assert_async_empty_documents(context):
|
|
docs = context.async_analysis_result["documents"]
|
|
assert not docs, f"Expected no async documents, got {len(docs)} entries"
|
|
|
|
|
|
@then("the async analysis summary should indicate no files to analyze")
|
|
def step_assert_async_empty_summary(context):
|
|
summary = context.async_analysis_result["summary"].lower()
|
|
assert "no context files" in summary, summary
|
|
|
|
|
|
@then("the async result should have documents")
|
|
def step_assert_async_documents(context):
|
|
assert context.async_analysis_result["documents"], "Expected async documents"
|
|
|
|
|
|
@then("the async result should have a summary")
|
|
def step_assert_async_summary(context):
|
|
assert context.async_analysis_result["summary"].strip(), "Async summary empty"
|
|
|
|
|
|
@then("there should be no error in the async result")
|
|
def step_assert_async_no_error(context):
|
|
assert context.async_analysis_result["error"] in (None, "")
|
|
|
|
|
|
@then("the analysis should complete without raising an exception")
|
|
def step_assert_nonexistent_completed(context):
|
|
assert context.nonexistent_result is not None
|
|
|
|
|
|
@then("the error field should contain file not found information")
|
|
def step_assert_nonexistent_error(context):
|
|
error = context.nonexistent_result["error"]
|
|
assert error and "file not found" in error.lower(), error
|
|
|
|
|
|
@then("I should receive a ContextAnalysisAgent instance")
|
|
def step_assert_agent_instance(context):
|
|
assert isinstance(context.requested_agent, ContextAnalysisAgent)
|
|
|
|
|
|
@then("the agent should have the standard workflow nodes")
|
|
def step_assert_agent_nodes(context):
|
|
expected = {
|
|
"load_files",
|
|
"analyze_dependencies",
|
|
"chunk_documents",
|
|
"score_relevance",
|
|
"summarize_context",
|
|
}
|
|
actual = set(context.requested_agent.graph.nodes.keys())
|
|
assert expected.issubset(actual), actual
|
|
|
|
|
|
@then("the summary should not indicate an error in the dependency run")
|
|
def step_assert_dependency_summary(context):
|
|
assert context.analysis_result["error"] in (None, "")
|
|
|
|
|
|
@then('the dependencies should be a non-empty list for "{filename}"')
|
|
def step_assert_dependencies_specific(context, filename: str):
|
|
file_path = str(context.created_files[filename])
|
|
deps = context.analysis_result["dependencies"].get(file_path, [])
|
|
assert deps, f"Empty dependency list for {filename}"
|
|
|
|
|
|
@then("I should receive node execution details in streaming events")
|
|
def step_assert_streaming_details(context):
|
|
node_names = {
|
|
"load_files",
|
|
"analyze_dependencies",
|
|
"chunk_documents",
|
|
"score_relevance",
|
|
"summarize_context",
|
|
}
|
|
matched = [
|
|
event
|
|
for event in context.streaming_events
|
|
if node_names.intersection(event.keys())
|
|
]
|
|
assert matched, context.streaming_events
|
|
|
|
|
|
@then("the LangSmith config should include the current project metadata")
|
|
def step_assert_langsmith_metadata(context):
|
|
config = getattr(context, "langsmith_config", {})
|
|
metadata = config.get("metadata", {})
|
|
tags = config.get("tags", [])
|
|
assert metadata.get("project_id") == context.project.id, metadata
|
|
assert metadata.get("plan_id") == context.plan_metadata.id, metadata
|
|
assert metadata.get("service") == "ContextService", metadata
|
|
assert any(tag.startswith("project:") for tag in tags), tags
|
|
|
|
|
|
@then("the LangSmith config should record {count:d} context file path")
|
|
def step_assert_langsmith_count(context, count: int):
|
|
config = getattr(context, "langsmith_config", {})
|
|
metadata = config.get("metadata", {})
|
|
assert metadata.get("context_file_count") == count, metadata
|
|
|
|
|
|
@then("the async streaming events should include node execution results")
|
|
def step_assert_async_streaming_events(context):
|
|
node_names = {
|
|
"load_files",
|
|
"analyze_dependencies",
|
|
"chunk_documents",
|
|
"score_relevance",
|
|
"summarize_context",
|
|
}
|
|
assert any(
|
|
isinstance(event, dict) and node_names.intersection(event.keys())
|
|
for event in context.async_streaming_events
|
|
), context.async_streaming_events
|
|
|
|
|
|
@then("the async streaming output should report no files to analyze")
|
|
def step_assert_async_streaming_no_files(context):
|
|
assert context.async_streaming_events == [
|
|
{"type": "complete", "summary": "No context files to analyze"}
|
|
], context.async_streaming_events
|