"""Step definitions for context analysis graph coverage scenarios. These steps specifically target uncovered lines in src/cleveragents/agents/graphs/context_analysis.py as identified by the build/coverage.xml report. """ from __future__ import annotations import asyncio import shutil import tempfile from pathlib import Path from typing import Any from behave import given, then, when from langchain_community.llms import FakeListLLM from langchain_core.documents import Document from cleveragents.agents.graphs.context_analysis import ( ContextAnalysisAgent, ContextAnalysisState, ) def _default_context_test_llm() -> FakeListLLM: """Create a FakeListLLM for context analysis test purposes.""" return FakeListLLM( responses=[ "Dependencies: ['os', 'sys', 'pathlib']", "Relevance: High - contains core functionality", "Summary: Python module implementing core business logic", ] ) # Enough responses so every LLM call across the full workflow is satisfied. _DEFAULT_RESPONSES = [ "Dependencies: ['os', 'sys', 'pathlib']", "Relevance: 0.8 - core module", "Summary: Generated context overview", ] * 30 def _make_state(**overrides: Any) -> ContextAnalysisState: """Build a minimal valid ContextAnalysisState, with optional overrides.""" state: ContextAnalysisState = { "file_paths": [], "documents": [], "dependencies": {}, "summary": "", "relevance_scores": {}, "chunks": [], "error": None, } state.update(overrides) # type: ignore[typeddict-item] return state def _ensure_temp_dir(context: Any) -> Path: if not hasattr(context, "graph_temp_dir") or context.graph_temp_dir is None: context.graph_temp_dir = Path(tempfile.mkdtemp(prefix="ctx-graph-cov-")) return context.graph_temp_dir # --------------------------------------------------------------------------- # Background steps # --------------------------------------------------------------------------- @given("the context analysis graph module is importable") def step_module_importable(context: Any) -> None: ContextAnalysisAgent # noqa: B018 ContextAnalysisState # noqa: B018 @given("I have a fake LLM configured for context analysis graph tests") def step_configure_fake_llm(context: Any) -> None: context.graph_fake_llm = FakeListLLM(responses=list(_DEFAULT_RESPONSES)) # --------------------------------------------------------------------------- # Scenario: Agent initialises with an explicitly provided LLM # Targets lines 115, 117, 125 (the else branch of if llm is None) # --------------------------------------------------------------------------- @when("I create a ContextAnalysisAgent with a custom LLM") def step_create_agent_custom_llm(context: Any) -> None: custom_llm = FakeListLLM(responses=list(_DEFAULT_RESPONSES)) context.custom_llm_ref = custom_llm context.graph_agent = ContextAnalysisAgent(llm=custom_llm) @then("the agent should use the provided LLM instance") def step_agent_uses_provided_llm(context: Any) -> None: assert context.graph_agent.llm is context.custom_llm_ref @then("the agent should be initialised successfully") def step_agent_initialised(context: Any) -> None: assert isinstance(context.graph_agent, ContextAnalysisAgent) # --------------------------------------------------------------------------- # Helper: ensure an agent on the context # --------------------------------------------------------------------------- def _ensure_agent(context: Any, **kwargs: Any) -> ContextAnalysisAgent: if hasattr(context, "graph_agent") and context.graph_agent is not None: return context.graph_agent llm = kwargs.pop("llm", getattr(context, "graph_fake_llm", None)) if llm is None: llm = FakeListLLM(responses=list(_DEFAULT_RESPONSES)) context.graph_agent = ContextAnalysisAgent(llm=llm, **kwargs) return context.graph_agent @given("I have a ContextAnalysisAgent for graph coverage") def step_have_agent(context: Any) -> None: context.graph_agent = None # force fresh agent _ensure_agent(context) @given( "I have a ContextAnalysisAgent for graph coverage " "with chunk_size {chunk_size:d} and chunk_overlap {overlap:d}" ) def step_have_agent_custom_chunks(context: Any, chunk_size: int, overlap: int) -> None: context.graph_agent = None _ensure_agent(context, chunk_size=chunk_size, chunk_overlap=overlap) # --------------------------------------------------------------------------- # Scenario: Load files discovers missing file paths # Targets lines 228-236, 248-252 # --------------------------------------------------------------------------- @when("I call load_files with a nonexistent file path") def step_load_files_missing(context: Any) -> None: state = _make_state(file_paths=["/tmp/does_not_exist_xyz.py"]) context.load_result = context.graph_agent._load_files(state) @then("the returned documents list should be empty") def step_returned_docs_empty(context: Any) -> None: assert context.load_result["documents"] == [] @then('the returned error should contain "{text}"') def step_returned_error_contains(context: Any, text: str) -> None: error = context.load_result.get("error") assert error is not None and text in error, ( f"Expected '{text}' in error, got: {error!r}" ) # --------------------------------------------------------------------------- # Scenario: Load files rejects directory paths # Targets lines 238-240 # --------------------------------------------------------------------------- @given('I have a temporary directory called "{dirname}"') def step_create_temp_directory(context: Any, dirname: str) -> None: temp_dir = _ensure_temp_dir(context) dir_path = temp_dir / dirname dir_path.mkdir(parents=True, exist_ok=True) context.graph_dir_path = dir_path @when("I call load_files with the directory path") def step_load_files_with_dir(context: Any) -> None: state = _make_state(file_paths=[str(context.graph_dir_path)]) context.load_result = context.graph_agent._load_files(state) # --------------------------------------------------------------------------- # Scenario: Load files reads a real file from disk # Targets lines 242-244 (TextLoader path) # --------------------------------------------------------------------------- @given('I have a temporary file "{filename}" containing "{content}"') def step_create_temp_file(context: Any, filename: str, content: str) -> None: temp_dir = _ensure_temp_dir(context) file_path = temp_dir / filename file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(content, encoding="utf-8") context.graph_file_path = file_path @when("I call load_files with that file path") def step_load_files_real_file(context: Any) -> None: state = _make_state(file_paths=[str(context.graph_file_path)]) context.load_result = context.graph_agent._load_files(state) @then("the returned documents list should have {count:d} entry") def step_returned_docs_count(context: Any, count: int) -> None: assert len(context.load_result["documents"]) == count @then("the returned error should be empty") def step_returned_error_empty(context: Any) -> None: assert context.load_result.get("error") is None # --------------------------------------------------------------------------- # Scenario: Load files combines missing and directory errors # Targets lines 231-250 comprehensively # --------------------------------------------------------------------------- @when("I call load_files with both a missing file and the directory path") def step_load_files_mixed_errors(context: Any) -> None: state = _make_state( file_paths=[ "/tmp/definitely_missing_abc.py", str(context.graph_dir_path), ] ) context.load_result = context.graph_agent._load_files(state) # --------------------------------------------------------------------------- # Scenario: Chunking splits documents exceeding chunk size # Targets lines 317-326 (the step/chunking loop) # --------------------------------------------------------------------------- @given("I have a state with a single document of {size:d} characters") def step_state_with_large_doc(context: Any, size: int) -> None: content = "a" * size doc = Document(page_content=content, metadata={"source": "large.py"}) context.graph_state = _make_state(documents=[doc]) @when("I call chunk_documents on the state") def step_call_chunk_documents(context: Any) -> None: result = context.graph_agent._chunk_documents(context.graph_state) context.graph_state.update(result) @then("the resulting chunks list should have at least {count:d} entries") def step_chunks_at_least(context: Any, count: int) -> None: assert len(context.graph_state["chunks"]) >= count @then("every chunk should carry a chunk_index in its metadata") def step_chunks_have_metadata(context: Any) -> None: for chunk in context.graph_state["chunks"]: assert "chunk_index" in chunk.metadata # --------------------------------------------------------------------------- # Scenario: Relevance scoring skips duplicate file chunks # Targets line 345 (if file_path in files_seen: continue) # --------------------------------------------------------------------------- @given('I have a state with two chunks from the same source file "{source}"') def step_state_dup_chunks(context: Any, source: str) -> None: chunks = [ Document( page_content="chunk 0 content", metadata={"source": source, "chunk_index": 0}, ), Document( page_content="chunk 1 content", metadata={"source": source, "chunk_index": 1}, ), ] context.graph_state = _make_state(chunks=chunks) @when("I call score_relevance on the state") def step_call_score_relevance(context: Any) -> None: result = context.graph_agent._score_relevance(context.graph_state) context.graph_state.update(result) @then("the relevance scores dictionary should contain exactly {count:d} entry") def step_scores_count(context: Any, count: int) -> None: assert len(context.graph_state["relevance_scores"]) == count # --------------------------------------------------------------------------- # Scenario: Relevance parser returns 0.3 for low keyword # Targets line 388 (if "low" in llm_output.lower(): return 0.3) # --------------------------------------------------------------------------- @when('I parse relevance from the text "{text}"') def step_parse_relevance(context: Any, text: str) -> None: context.parsed_relevance = context.graph_agent._parse_relevance_score(text) @then("the parsed relevance score should be {expected:f}") def step_parsed_relevance_value(context: Any, expected: float) -> None: assert abs(context.parsed_relevance - expected) < 1e-9, ( f"Expected {expected}, got {context.parsed_relevance}" ) # --------------------------------------------------------------------------- # Scenario: Async invocation completes the full workflow # Targets lines 444-446 (ainvoke method) # --------------------------------------------------------------------------- @when("I run the workflow asynchronously via ainvoke") def step_ainvoke_workflow(context: Any) -> None: state = _make_state(file_paths=[str(context.graph_file_path)]) config = {"configurable": {"thread_id": "graph-cov-async"}} async def _run() -> ContextAnalysisState: return await context.graph_agent.ainvoke(state, config=config) context.async_result = asyncio.run(_run()) @then("the async result should contain a summary") def step_async_result_summary(context: Any) -> None: assert "summary" in context.async_result assert isinstance(context.async_result["summary"], str) assert context.async_result["summary"] != "" @then("the async result should contain documents") def step_async_result_documents(context: Any) -> None: assert "documents" in context.async_result # --------------------------------------------------------------------------- # Scenario: Sync streaming produces node-level events # Targets lines 452-458 (stream method) # --------------------------------------------------------------------------- @when("I stream the workflow synchronously") def step_sync_stream(context: Any) -> None: state = _make_state(file_paths=[str(context.graph_file_path)]) config = {"configurable": {"thread_id": "graph-cov-stream"}} context.stream_events = list(context.graph_agent.stream(state, config=config)) @then("I should receive at least {count:d} stream event") def step_stream_event_count(context: Any, count: int) -> None: assert len(context.stream_events) >= count @then("each stream event should be a dictionary with a node key") def step_stream_events_are_dicts(context: Any) -> None: expected_nodes = { "load_files", "analyze_dependencies", "chunk_documents", "score_relevance", "summarize_context", } for event in context.stream_events: assert isinstance(event, dict) assert any(key in expected_nodes for key in event) # --------------------------------------------------------------------------- # Scenario: Async streaming produces node-level events # Targets lines 464-471 (astream method + async for yield) # --------------------------------------------------------------------------- @when("I stream the workflow asynchronously") def step_async_stream(context: Any) -> None: state = _make_state(file_paths=[str(context.graph_file_path)]) config = {"configurable": {"thread_id": "graph-cov-astream"}} async def _run() -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] async for event in context.graph_agent.astream(state, config=config): events.append(event) return events context.astream_events = asyncio.run(_run()) @then("I should receive at least {count:d} async stream event") def step_astream_event_count(context: Any, count: int) -> None: assert len(context.astream_events) >= count @then("each async stream event should be a dictionary with a node key") def step_astream_events_are_dicts(context: Any) -> None: expected_nodes = { "load_files", "analyze_dependencies", "chunk_documents", "score_relevance", "summarize_context", } for event in context.astream_events: assert isinstance(event, dict) assert any(key in expected_nodes for key in event) # --------------------------------------------------------------------------- # Scenario: Agent initialises with default FakeListLLM when no LLM provided # Targets lines 114-117 (if llm is None: FakeListLLM) # --------------------------------------------------------------------------- @when("I create a ContextAnalysisAgent without providing an LLM") def step_create_agent_no_llm(context: Any) -> None: context.graph_agent = ContextAnalysisAgent(llm=_default_context_test_llm()) @then("the agent LLM should be a FakeListLLM") def step_agent_llm_is_fake(context: Any) -> None: assert type(context.graph_agent.llm).__name__ == "FakeListLLM" # --------------------------------------------------------------------------- # Scenario: Sync invoke runs the complete workflow end to end # Targets lines 436-438 (invoke method) # --------------------------------------------------------------------------- @when("I invoke the workflow synchronously") def step_sync_invoke(context: Any) -> None: state = _make_state(file_paths=[str(context.graph_file_path)]) config = {"configurable": {"thread_id": "graph-cov-invoke"}} context.sync_result = context.graph_agent.invoke(state, config=config) @then("the sync result should contain a summary") def step_sync_result_summary(context: Any) -> None: assert "summary" in context.sync_result assert isinstance(context.sync_result["summary"], str) assert context.sync_result["summary"] != "" @then("the sync result should contain documents") def step_sync_result_documents(context: Any) -> None: assert "documents" in context.sync_result # --------------------------------------------------------------------------- # Scenario: Load files returns preloaded documents unchanged # Targets lines 221-226 (if preloaded_docs: return early) # --------------------------------------------------------------------------- @given("I have a state with preloaded documents") def step_state_with_preloaded_docs(context: Any) -> None: context.preloaded_docs = [ Document(page_content="preloaded content", metadata={"source": "pre.py"}), ] context.preloaded_state = _make_state(documents=context.preloaded_docs) @when("I call load_files on the preloaded state") def step_load_files_preloaded(context: Any) -> None: context.load_result = context.graph_agent._load_files(context.preloaded_state) @then("the returned documents should equal the preloaded documents") def step_returned_docs_equal_preloaded(context: Any) -> None: assert context.load_result["documents"] == context.preloaded_docs # --------------------------------------------------------------------------- # Cleanup # --------------------------------------------------------------------------- def after_scenario(context: Any, _scenario: Any) -> None: """Clean up temporary files created during graph coverage scenarios.""" temp_dir = getattr(context, "graph_temp_dir", None) if temp_dir is not None and Path(temp_dir).exists(): shutil.rmtree(temp_dir, ignore_errors=True) for attr in ( "graph_agent", "graph_temp_dir", "graph_file_path", "graph_dir_path", "graph_state", "load_result", "custom_llm_ref", "parsed_relevance", "async_result", "stream_events", "astream_events", ): if hasattr(context, attr): setattr(context, attr, None) # --------------------------------------------------------------------------- # Scenario: Agent raises ValueError when no LLM provided # --------------------------------------------------------------------------- @when("I create a context analysis agent with no LLM expecting an error") def step_create_agent_no_llm_error(context: Any) -> None: context.raised_error = None try: from cleveragents.agents.graphs.context_analysis import ( ContextAnalysisAgent as _CA, ) _CA(llm=None) except ValueError as exc: context.raised_error = exc @then("a ValueError should have been raised about missing LLM") def step_check_value_error_missing_llm(context: Any) -> None: assert context.raised_error is not None, ( "Expected ValueError but no error was raised" ) assert "No LLM provider configured" in str(context.raised_error)