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