Chore: Got code coverage up to 98%

This commit is contained in:
2025-11-19 22:40:53 -05:00
parent 89197cd4d3
commit af5093c881
24 changed files with 2498 additions and 333 deletions
@@ -0,0 +1,41 @@
Feature: Application Context Analysis Agent coverage
As a developer
I want the placeholder workflow validated
So that the application context agent stays predictable
Scenario: Initializing the application context agent respects batch limits
When I instantiate the application context agent with max files per batch 5
Then the agent should track max files per batch of 5
And the base agent initializer should have been invoked with default settings
Scenario: Building the application context workflow graph wires placeholder nodes
When I construct the application context workflow graph
Then the workflow should target ContextAnalysisState as the graph state
And the workflow should include nodes:
"""
identify_files
analyze_files
extract_dependencies
build_structure
select_contexts
finalize
"""
And the workflow should define ordered edges:
"""
identify_files -> analyze_files
analyze_files -> extract_dependencies
extract_dependencies -> build_structure
build_structure -> select_contexts
select_contexts -> finalize
finalize -> END
"""
Scenario: Placeholder workflow nodes populate default state fields
Given I have a fresh application context agent state
When I run the placeholder context workflow nodes
Then the state should include an empty files_to_analyze list
And the state should include an empty analyzed_files map
And the state should include an empty dependencies map
And the state should include a project structure skeleton
And the state should include an empty relevant_contexts list
And the state should include a finalized result summary
@@ -44,6 +44,18 @@ Feature: Context Analysis Agent Coverage
And the first document should contain "hi"
And there should be no error
Scenario: Load files node reports missing and invalid paths
Given I have a ContextAnalysisAgent instance
And I have a temporary directory named "invalid"
When I execute the load_files node with file paths:
"""
["missing.py", "invalid"]
"""
Then the state should contain documents
And the documents list should have 0 documents
And the state error should contain "File not found"
And the state error should contain "Not a file"
Scenario: Dependency analysis returns structured data
Given I have a ContextAnalysisAgent instance
And I have a state with loaded documents containing:
@@ -123,3 +135,67 @@ Feature: Context Analysis Agent Coverage
"""
Then the parsed dependencies should include "os"
And the parsed dependencies should include "sys"
Scenario: Dependency analysis reports chained errors
Given I have a ContextAnalysisAgent instance with an LLM that raises "dependency failure"
And I have a state with loaded documents containing:
"""
import pathlib
"""
And the state error is "load failure"
When I execute the analyze_dependencies node
Then the state should contain dependencies
And the dependencies for "test.py" should be empty
And the state error should contain "load failure"
And the state error should contain "dependency failure"
Scenario: Relevance scoring skips duplicate chunks per file
Given I have a ContextAnalysisAgent instance
And I have a state with duplicate chunks from "dup.py"
When I execute the score_relevance node
Then the relevance_scores should be a dictionary
And the relevance_scores should contain 1 entries
And all scores should be between 0.0 and 1.0
Scenario: Relevance scoring reports LLM errors and preserves prior issues
Given I have a ContextAnalysisAgent instance with an LLM that raises "relevance failure"
And I have a state with chunks from 1 different files
And the state error is "previous issue"
When I execute the score_relevance node
Then the relevance_scores should be a dictionary
And the relevance_scores should contain 1 entries
And the state error should contain "previous issue"
And the state error should contain "relevance failure"
Scenario: Relevance parser handles qualitative hints
Given I have a ContextAnalysisAgent instance
When I parse relevance scores from hints:
| hint | expected |
| High likelihood of use | 0.8 |
| low confidence in result | 0.3 |
| outcome undecided | 0.5 |
Then the parsed scores should match expected values
Scenario: Summarization merges prior errors when LLM fails
Given I have a ContextAnalysisAgent instance with an LLM that raises "summary failure"
And I have a complete analysis state with:
| field | value |
| documents | 2 |
| dependencies | 2 |
| relevance_scores | 2 |
And the state error is "previous pipeline error"
When I execute the summarize_context node
Then the state should contain a summary
And the summary should equal "Context analysis failed"
And the state error should contain "summary failure"
And the state error should contain "previous pipeline error"
Scenario: Async streaming produces node updates
Given I have a ContextAnalysisAgent instance
And I have a temporary test file named "astream.py" with content "print('stream')"
When I stream the workflow asynchronously with file paths:
"""
["astream.py"]
"""
Then I should receive multiple state updates
And each update should correspond to a node execution
+43 -1
View File
@@ -35,4 +35,46 @@ Feature: Database Repository Error Handling Coverage
Given I have a plan repository with database session
And I have created a plan without build information
When I retrieve the plan by ID
Then the plan should be returned with null build field
Then the plan should be returned with null build field
@phase1
Scenario: ProjectRepository create raises database error on OperationalError
Given I have a project repository with a session that fails on create
When I attempt to create a project named "failing-project" with that failing session
Then a database error should be raised when creating the project
And the session rollback should be triggered for the project create failure
@phase1
Scenario: ProjectRepository get_by_id wraps OperationalError in DatabaseError
Given I have a project repository with a session that fails on query
When I query the project repository for ID 42 expecting a failure
Then a database error should be raised for the project lookup
@phase1
Scenario: PlanRepository get_by_id returns None for unknown plan
Given I have a plan repository with database session
When I query for a plan with non-existent ID 99999
Then the plan repository should return None for missing plan
@phase1
Scenario: PlanRepository update persists applied_at field when provided
Given I have a plan repository with database session
And I have created a plan without build information
When I update the plan with a new applied timestamp
Then the plan record should store the applied timestamp value
@phase1
Scenario: PlanRepository get_current returns None when no plan is marked current
Given I have a plan repository with database session
And I have created a plan without build information
When I request the current plan for the project
Then the repository should return None for missing current plan
@phase1
Scenario: ChangeRepository get_all returns persisted changes
Given I have a plan repository with database session
And I have created a plan without build information
And I have a change repository with the shared database session
And I have added a change for the plan
When I request all changes for the plan
Then the repository should include the added change
@@ -0,0 +1,60 @@
Feature: Migration runner coverage
Validate migration runner behaviors that were previously uncovered.
Scenario: Missing Alembic config raises helpful error
Given a migration runner configured for "sqlite:///:memory:"
When I attempt to load the alembic config without an alembic.ini file
Then a FileNotFoundError should be raised mentioning "alembic.ini"
Scenario: Running migrations without engine uses Alembic-managed connection
Given a migration runner configured for "sqlite:///:memory:"
When I run migrations without providing an engine
Then the upgrade command should be invoked without a connection attribute
And the CLEVERAGENTS_DATABASE_URL environment variable should be restored
Scenario: In-memory SQLite initialization reuses cached engine
Given a migration runner configured for "sqlite:///:memory:"
When I initialize or upgrade the database with cached in-memory engine
Then migrations should run using the cached engine connection
And the cached engine should remain available without disposal
Scenario: Legacy database is stamped when tables exist without alembic_version
Given a migration runner configured for "postgresql://user:pass@localhost/testdb"
When I initialize or upgrade the database with legacy tables present
Then the stamp command should run using the active connection
And the external database engine should be disposed after initialization
Scenario: Pending migrations include newest revisions when no current version exists
Given a migration runner configured for "sqlite:///:memory:"
When I request pending migrations for a database with no current revision
Then the pending migration list should be ordered from oldest to newest
Scenario: Get current revision queries migration context
Given a migration runner configured for "sqlite:///:memory:"
When I request the current revision from the database
Then the migration context should be queried for the current revision
And the temporary connection should be closed afterward
Scenario: File-based SQLite database directory is created if missing
Given a migration runner configured for "sqlite:///tmp/test-db/mydb.db"
When I initialize or upgrade a file-based SQLite database
Then the parent directory should be created if it does not exist
And the database engine should be disposed after initialization
Scenario: File-based SQLite uses engine with proper connection args
Given a migration runner configured for "sqlite:///tmp/test-db2/mydb.db"
When I initialize or upgrade a file-based SQLite database
Then the engine should be created with check_same_thread set to False
And the database engine should be disposed after initialization
Scenario: Running migrations when database already up to date
Given a migration runner configured for "sqlite:///:memory:"
When I initialize the database and migrations are already applied
Then no additional migrations should be run
And check migrations needed should return False
Scenario: Pending migrations trigger upgrade when alembic_version exists
Given a migration runner configured for "sqlite:///:memory:"
When I initialize the database with pending migrations detected
Then run migrations should be invoked with the existing engine
And the in-memory engine should not be disposed
+1 -2
View File
@@ -6,11 +6,10 @@ directory, never in production code (ADR-022).
"""
from collections.abc import Callable
from typing import Any
from langchain_community.llms import FakeListLLM
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from cleveragents.domain.models.core import (
Change,
+17
View File
@@ -114,6 +114,23 @@ Feature: Plan Service Coverage
When I build the plan with progress callback
Then the progress callback should be called with values from 0 to 100
Scenario: Building plan fails when plan ID disappears after status update
Given I configure a stub plan service whose current plan loses its ID after the transaction
When I try to build the plan with the stubbed service
Then a PlanError should be raised with message "Plan does not have a valid ID"
Scenario: Plan without repository ID remains non-current
Given I replace the plan service repository with a stub that never assigns plan IDs
When I create a plan with stub prompt "Missing ID branch"
Then the stub-created plan should remain non-current
And the stub repository should not record a current plan
Scenario: Reusing persistent memory with unchanged limit skips reset
When I request a persistent memory service for session "reuse-session" with max messages 4
And I monitor max message updates for session "reuse-session"
And I request the same persistent memory service for session "reuse-session" with max messages 4
Then the session "reuse-session" memory should reuse the existing service without changing limits
Scenario: Get pending changes with mixed applied status
Given I have a saved project with current plan
And the plan has 3 applied and 2 unapplied changes
@@ -0,0 +1,53 @@
Feature: Plan Service Uncovered Lines Coverage
As a developer working on the CleverAgents system
I want to achieve complete test coverage for the plan service
So that all edge cases and error conditions are properly tested
Background:
Given I have a temporary test directory for plan service
And I have a Unit of Work instance for plan testing
And I have a PlanService instance
Scenario: Apply MOVE operation with nested relative new path
Given I have a saved project with current plan
And the plan has a pending MOVE change to a nested relative path
When I apply the plan changes
Then the file should be moved to the nested relative location
And the change should be marked as applied in plan service
Scenario: Get current plan returns None for unsaved project
Given I have an unsaved project without ID
When I get the current plan for the project
Then the current plan result should be None
Scenario: List plans returns empty list for unsaved project
Given I have an unsaved project without ID
When I list the plans for the project
Then the plans result should be an empty list
Scenario: Switch to plan raises error for unsaved project
Given I have an unsaved project without ID
When I attempt to switch to plan "some-plan"
Then a ValidationError should be raised with message "Project not initialized"
And the validation error details should hint to initialize the project
Scenario: Switch to non-existent plan raises ValidationError
Given I have a saved project with current plan
And the project has plans named "plan-a" and "plan-b"
When I attempt to switch to plan "non-existent-plan"
Then a ValidationError should be raised with message "Plan 'non-existent-plan' not found"
And the validation error details should list available plans "plan-a" and "plan-b"
Scenario: Add to plan raises error when no current plan exists
Given I have a saved project with no current plan
When I attempt to add prompt "Additional instructions" to the current plan
Then a PlanError should be raised with message "No current plan to add to"
And the plan error details should suggest creating a plan first
Scenario: Add to plan sets prompt when current plan prompt is None
Given I have a saved project with current plan
And the current plan prompt is set to None
When I add prompt "New instructions" to the current plan
Then the current plan prompt should be "New instructions"
And the current plan status should be PENDING
And the current plan updated_at should be refreshed
+67 -1
View File
@@ -40,4 +40,70 @@ Feature: Project Service
Then I should receive statistics
And the statistics should include "plans"
And the statistics should include "context_files"
And the statistics should include "changes"
And the statistics should include "changes"
Scenario: Initialization fails gracefully when filesystem operations error
Given I have a project service
And I prepare a project path "fs-error-project"
When I initialize the project with filesystem failure
Then a FileSystemError should be raised with message "Failed to create project structure"
Scenario: Duplicate project name uses database validation when directory missing
Given I have a project service
And I prepare a project path "duplicate-db"
And I have initialized that project once already
And the cleveragents directory has been removed for that project
When I initialize the same project without force using database state
Then I should get a ValidationError
And the error message should contain "already exists"
Scenario: Migration returns existing project without creating duplicates
Given I have a project service
And I prepare a project path "migration-existing"
And I have initialized that project once already
And the cleveragents directory has been removed for that project
And the legacy migration will report success without changes
When I initialize the same project without force during migration
Then the existing project should be reused
Scenario: Fallback current project is synthesized when database lacks record
Given I have a project service
And I set up a standalone project directory named "standalone-fallback" with a name file
When I fetch the current project from that directory without database entry
Then a temporary project named "standalone-fallback" should be returned
Scenario: Legacy project without name file uses directory name
Given I have a project service
And I set up a standalone project directory named "legacy-no-name" without a name file
When I fetch the current project from that directory without database entry
Then a temporary project named "legacy-no-name" should be returned
Scenario: No project directory results in no current project
Given I have a project service
And I prepare an empty working directory
When I fetch the current project from that directory without database entry
Then no current project should be found
Scenario: Retrieve project by name returns saved project
Given I have a project service
And I have initialized a service project "lookup-project" at "/tmp/test"
When I try to get a project by name "lookup-project"
Then the project lookup result should be found
Scenario: Create project alias delegates to initializer
Given I have a project service
And I prepare a project path "alias-project"
When I create the project using the alias method
Then the alias project should be created successfully
Scenario: Retrieve project by path finds the matching record
Given I have a project service
And I have initialized a service project "path-project" at "/tmp/test"
When I look up the project by its saved path
Then the project lookup result should be found
Scenario: Default ordering falls back to created_at
Given I have a project service
And I have multiple projects created at different times
When I list all projects with unknown ordering
Then the projects should be returned in creation order
+35
View File
@@ -64,12 +64,41 @@ Feature: Retry Patterns Implementation
When I execute a failing block under the async retry context manager
Then the async retry context manager should capture the exception
Scenario: Retry context manager leaves errors untouched on success
Given I have a retry context for "successful context block"
When I execute a successful block under the retry context manager
Then the retry context manager should not record errors
Scenario: Async retry context manager leaves errors untouched on success
Given I have a retry context for "async successful context block"
When I execute a successful block under the async retry context manager
Then the async retry context manager should not record errors
Scenario: Retry context async execute handles transient async failures
Given I have a retry context for "async execute operation"
And I have an async function that fails 2 times then succeeds
When I execute the async function with the retry context
Then the async retry context execute should succeed after 3 attempts
Scenario: Retry with timeout retries transient failures without waiting
Given I have a function that fails 1 times then succeeds
When I apply retry with timeout of 3 attempts and 0.01 seconds
Then the function should eventually succeed
And the function should be called 2 times
Scenario: Retry with jitter prevents thundering herd
Given I have multiple concurrent operations
When I apply retry with jitter
Then the retries should have random delays
And operations should not retry simultaneously
Scenario: Retry on result retries when predicate flags response
Given I have sequential result payloads requiring a retry
And I have a retry predicate that checks for retry flag
When I apply retry on result with max 3 attempts
Then the decorated function should return the successful payload
And the function should be called 2 times
Scenario: Auto-debug retry pattern works
Given I have a function that fails with errors
And I have a debug callback that fixes issues
@@ -97,6 +126,12 @@ Feature: Retry Patterns Implementation
When I simulate a half-open failure
Then the circuit breaker should reopen after half-open failure
Scenario: Circuit breaker resets failure counters after closed success
Given I have a circuit breaker with threshold 2
And the circuit breaker has recorded failures
When I execute a successful call while the circuit is closed
Then the circuit breaker should reset failure tracking after success
Scenario: Async circuit breaker enforces open and recovery transitions
Given I have an async circuit breaker with threshold 1
When I trigger an async failure on the circuit breaker
@@ -0,0 +1,225 @@
"""Step definitions for application context analysis agent coverage."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any, cast
from unittest.mock import patch
from behave import ( # type: ignore[import]
given as behave_given,
)
from behave import (
then as behave_then,
)
from behave import (
when as behave_when,
)
from cleveragents.application.agents.context_analysis import (
ContextAnalysisAgent,
ContextAnalysisState,
)
given = cast(Callable[..., Any], behave_given)
then = cast(Callable[..., Any], behave_then)
when = cast(Callable[..., Any], behave_when)
@when(
"I instantiate the application context agent with max files per batch {batch_size:d}"
)
def step_instantiate_application_agent(context: Any, batch_size: int) -> None:
with patch(
"cleveragents.application.agents.context_analysis.BaseAgent.__init__",
return_value=None,
) as base_init:
context.agent = ContextAnalysisAgent(max_files_per_batch=batch_size)
context.base_init_call = base_init.call_args
@then("the agent should track max files per batch of {batch_size:d}")
def step_assert_batch_size(context: Any, batch_size: int) -> None:
assert getattr(context.agent, "max_files_per_batch", None) == batch_size
@then("the base agent initializer should have been invoked with default settings")
def step_assert_base_init_called(context: Any) -> None:
call = getattr(context, "base_init_call", None)
assert call is not None, "Expected BaseAgent.__init__ to be called"
args, kwargs = call
assert args == ("openai", "gpt-4", 0.3)
assert kwargs == {}
@when("I construct the application context workflow graph")
def step_build_application_graph(context: Any) -> None:
created_graphs: list[CapturingStateGraph] = []
class CapturingStateGraph:
def __init__(self, state_cls: Any):
self.state_cls = state_cls
self.nodes: dict[str, Any] = {}
self.edges: list[tuple[Any, Any]] = []
self.entry_point: str | None = None
created_graphs.append(self)
def add_node(self, name: str, handler: Any) -> None:
self.nodes[name] = handler
def add_edge(self, start: Any, end: Any) -> None:
self.edges.append((start, end))
def set_entry_point(self, node: str) -> None:
self.entry_point = node
with (
patch(
"cleveragents.application.agents.context_analysis.StateGraph",
CapturingStateGraph,
),
patch("cleveragents.application.agents.context_analysis.END", "END"),
):
agent = ContextAnalysisAgent.__new__(ContextAnalysisAgent)
build_graph: Callable[[ContextAnalysisAgent], Any] = (
ContextAnalysisAgent._build_graph
)
context.graph = build_graph(agent) # type: ignore[misc]
context.captured_graph = created_graphs[-1]
@then("the workflow should target ContextAnalysisState as the graph state")
def step_assert_graph_state_type(context: Any) -> None:
graph = context.captured_graph
assert graph.state_cls is ContextAnalysisState
@then("the workflow should include nodes:")
def step_assert_graph_nodes(context: Any) -> None:
expected_nodes = [
line.strip() for line in context.text.strip().splitlines() if line.strip()
]
graph = context.captured_graph
assert set(expected_nodes) == set(graph.nodes.keys())
@then("the workflow should define ordered edges:")
def step_assert_graph_edges(context: Any) -> None:
lines = [line.strip() for line in context.text.strip().splitlines() if line.strip()]
expected_edges: list[tuple[str, str]] = []
for line in lines:
parts = [segment.strip() for segment in line.split("->")]
assert len(parts) == 2, f"Edge '{line}' must be formatted as 'source -> target'"
expected_edges.append((parts[0], parts[1]))
graph = context.captured_graph
actual_edges = [(str(start), str(end)) for start, end in graph.edges]
assert actual_edges == expected_edges
@given("I have a fresh application context agent state")
def step_create_fresh_state(context: Any) -> None:
state: ContextAnalysisState = {
"messages": [],
"context": {},
"result": None,
"error": None,
"metadata": {},
"files_to_analyze": [],
"analyzed_files": {},
"dependencies": {},
"project_structure": {},
"relevant_contexts": [],
}
context.state = state
@when("I run the placeholder context workflow nodes")
def step_run_placeholder_nodes(context: Any) -> None:
agent = ContextAnalysisAgent.__new__(ContextAnalysisAgent)
state: ContextAnalysisState = context.state
identify_files: Callable[[ContextAnalysisState], ContextAnalysisState] = (
agent._identify_files
)
analyze_files: Callable[[ContextAnalysisState], ContextAnalysisState] = (
agent._analyze_files
)
extract_dependencies: Callable[[ContextAnalysisState], ContextAnalysisState] = (
agent._extract_dependencies
)
build_structure: Callable[[ContextAnalysisState], ContextAnalysisState] = (
agent._build_structure
)
select_contexts: Callable[[ContextAnalysisState], ContextAnalysisState] = (
agent._select_contexts
)
finalize: Callable[[ContextAnalysisState], ContextAnalysisState] = agent._finalize
state = identify_files(state) # type: ignore[misc]
state = analyze_files(state) # type: ignore[misc]
state = extract_dependencies(state) # type: ignore[misc]
state = build_structure(state) # type: ignore[misc]
state = select_contexts(state) # type: ignore[misc]
state = finalize(state) # type: ignore[misc]
context.state = state
@then("the state should include an empty files_to_analyze list")
def step_assert_empty_files_list(context: Any) -> None:
assert context.state.get("files_to_analyze") == []
@then("the state should include an empty analyzed_files map")
def step_assert_empty_analysis(context: Any) -> None:
assert context.state.get("analyzed_files") == {}
@then("the state should include an empty dependencies map")
def step_assert_empty_dependencies(context: Any) -> None:
assert context.state.get("dependencies") == {}
@then("the state should include a project structure skeleton")
def step_assert_project_structure(context: Any) -> None:
expected_structure: dict[str, list[str]] = {
"directories": [],
"modules": [],
"entry_points": [],
}
assert context.state.get("project_structure") == expected_structure
@then("the state should include an empty relevant_contexts list")
def step_assert_empty_relevant_contexts(context: Any) -> None:
assert context.state.get("relevant_contexts") == []
@then("the state should include a finalized result summary")
def step_assert_final_result(context: Any) -> None:
result = context.state.get("result")
assert isinstance(result, dict)
typed_result = cast(dict[str, Any], result)
expected_keys = {
"analyzed_files",
"dependencies",
"project_structure",
"relevant_contexts",
"file_count",
}
key_list: list[str] = list(typed_result.keys())
result_keys: set[str] = set(key_list)
assert expected_keys.issubset(result_keys)
analyzed_files = cast(dict[str, Any], typed_result["analyzed_files"])
dependencies = cast(dict[str, Any], typed_result["dependencies"])
project_structure = cast(dict[str, Any], typed_result["project_structure"])
relevant_contexts = cast(list[Any], typed_result["relevant_contexts"])
file_count = cast(int, typed_result["file_count"])
assert dependencies == context.state.get("dependencies")
assert project_structure == context.state.get("project_structure")
assert relevant_contexts == context.state.get("relevant_contexts")
assert file_count == len(analyzed_files)
+19 -19
View File
@@ -7,7 +7,7 @@ import importlib.util
import sys
from pathlib import Path
from types import ModuleType
from typing import Any, Dict, List
from typing import Any
from behave import given, then, when
@@ -20,25 +20,25 @@ class RecordingApp:
"""Minimal callable graph application that records interactions."""
def __init__(self) -> None:
self.invoke_calls: List[Dict[str, Any]] = []
self.ainvoke_calls: List[Dict[str, Any]] = []
self.stream_calls: List[Dict[str, Any]] = []
self.invoke_calls: list[dict[str, Any]] = []
self.ainvoke_calls: list[dict[str, Any]] = []
self.stream_calls: list[dict[str, Any]] = []
def invoke(
self, input_data: Dict[str, Any], config: Dict[str, Any]
) -> Dict[str, Any]:
self, input_data: dict[str, Any], config: dict[str, Any]
) -> dict[str, Any]:
call = {"input": input_data, "config": config}
self.invoke_calls.append(call)
return {"input": input_data, "config": config}
async def ainvoke(
self, input_data: Dict[str, Any], config: Dict[str, Any]
) -> Dict[str, Any]:
self, input_data: dict[str, Any], config: dict[str, Any]
) -> dict[str, Any]:
call = {"input": input_data, "config": config}
self.ainvoke_calls.append(call)
return {"input": input_data, "config": config, "async": True}
def stream(self, input_data: Dict[str, Any], config: Dict[str, Any]):
def stream(self, input_data: dict[str, Any], config: dict[str, Any]):
call = {"input": input_data, "config": config}
self.stream_calls.append(call)
yield {"event": "start", "config": config}
@@ -51,7 +51,7 @@ class TestGraph:
def __init__(self, context: Any) -> None:
self.context = context
self.compile_checkpointers: List[Any] = []
self.compile_checkpointers: list[Any] = []
def compile(self, checkpointer: Any = None) -> RecordingApp:
self.compile_checkpointers.append(checkpointer)
@@ -70,7 +70,7 @@ def _register_cleanup(context: Any, callback) -> None:
def _prepare_base_module(context: Any) -> None:
"""Stub external dependencies and load the base agent module."""
recorded_modules: Dict[str, ModuleType | None] = {}
recorded_modules: dict[str, ModuleType | None] = {}
def set_module(name: str, module: ModuleType) -> ModuleType:
if name not in recorded_modules:
@@ -94,7 +94,7 @@ def _prepare_base_module(context: Any) -> None:
langchain_core = ensure_package("langchain_core")
language_models = ModuleType("langchain_core.language_models")
class StubBaseLanguageModel: # noqa: D401 - simple stub
class StubBaseLanguageModel:
"""Placeholder for BaseLanguageModel."""
language_models.BaseLanguageModel = StubBaseLanguageModel # type: ignore[attr-defined]
@@ -105,12 +105,12 @@ def _prepare_base_module(context: Any) -> None:
langchain_community = ensure_package("langchain_community")
llms_module = ModuleType("langchain_community.llms")
class StubFakeListLLM: # noqa: D401 - simple stub
class StubFakeListLLM:
"""Placeholder for FakeListLLM that tracks instantiations."""
instances: List["StubFakeListLLM"] = []
instances: list[StubFakeListLLM] = []
def __init__(self, responses: List[str], sleep: float) -> None:
def __init__(self, responses: list[str], sleep: float) -> None:
self.responses = responses
self.sleep = sleep
StubFakeListLLM.instances.append(self)
@@ -123,7 +123,7 @@ def _prepare_base_module(context: Any) -> None:
langgraph = ensure_package("langgraph")
graph_module = ModuleType("langgraph.graph")
class StubStateGraph: # noqa: D401 - simple stub
class StubStateGraph:
"""Placeholder for StateGraph."""
def __init__(self, state_schema: Any) -> None:
@@ -136,10 +136,10 @@ def _prepare_base_module(context: Any) -> None:
checkpoint_pkg = ensure_package("langgraph.checkpoint")
memory_module = ModuleType("langgraph.checkpoint.memory")
class StubMemorySaver: # noqa: D401 - simple stub
class StubMemorySaver:
"""Placeholder for MemorySaver that tracks instantiations."""
instances: List["StubMemorySaver"] = []
instances: list[StubMemorySaver] = []
def __init__(self) -> None:
StubMemorySaver.instances.append(self)
@@ -195,7 +195,7 @@ def _prepare_base_module(context: Any) -> None:
_register_cleanup(context, cleanup)
def _build_input(message: str) -> Dict[str, Any]:
def _build_input(message: str) -> dict[str, Any]:
return {
"messages": [{"role": "user", "content": message}],
"context": {},
@@ -1,13 +1,19 @@
"""Step definitions for comprehensive infrastructure and application coverage tests."""
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock
from behave import given, then, when
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import sessionmaker
# Import modules to test
from cleveragents.application.container import Container, get_container, reset_container
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core import (
Change,
OperationType,
Plan,
PlanStatus,
Project,
@@ -15,6 +21,7 @@ from cleveragents.domain.models.core import (
)
from cleveragents.infrastructure.database import init_database
from cleveragents.infrastructure.database.repositories import (
ChangeRepository,
PlanRepository,
ProjectRepository,
)
@@ -200,6 +207,78 @@ def step_have_project_repo_with_session(context):
context.project_repo = ProjectRepository(context.session)
@given("I have a project repository with a session that fails on create")
def step_project_repo_failing_create(context):
"""Create project repository with failing session for create."""
context.session_mock = MagicMock()
def raise_operational_error(*args, **kwargs):
raise OperationalError("insert project", {}, Exception("db failure"))
context.session_mock.add.side_effect = raise_operational_error
context.session_mock.flush = MagicMock()
context.session_mock.refresh = MagicMock()
context.session_mock.rollback = MagicMock()
context.project_repo = ProjectRepository(context.session_mock)
@when('I attempt to create a project named "{name}" with that failing session')
def step_attempt_create_project_failure(context, name):
"""Attempt to create project and capture database error."""
project = Project(
name=name,
path=Path(f"/test/{name}"),
settings=ProjectSettings(),
)
context.create_error = None
try:
context.project_repo.create(project)
except DatabaseError as exc:
context.create_error = exc
@then("a database error should be raised when creating the project")
def step_verify_create_database_error(context):
"""Verify database error was raised during project creation."""
assert context.create_error is not None
assert isinstance(context.create_error, DatabaseError)
@then("the session rollback should be triggered for the project create failure")
def step_verify_create_rollback(context):
"""Verify session rollback executed on create failure."""
assert context.session_mock.rollback.call_count >= 1
@given("I have a project repository with a session that fails on query")
def step_project_repo_failing_query(context):
"""Create project repository with failing query session."""
context.session_mock = MagicMock()
def raise_query_error(*args, **kwargs):
raise OperationalError("select project", {}, Exception("db failure"))
context.session_mock.query.side_effect = raise_query_error
context.project_repo = ProjectRepository(context.session_mock)
@when("I query the project repository for ID {project_id:d} expecting a failure")
def step_query_project_failure(context, project_id):
"""Attempt to retrieve project and capture database error."""
context.project_lookup_error = None
try:
context.project_repo.get_by_id(project_id)
except DatabaseError as exc:
context.project_lookup_error = exc
@then("a database error should be raised for the project lookup")
def step_verify_project_lookup_error(context):
"""Verify database error was raised for project lookup."""
assert context.project_lookup_error is not None
assert isinstance(context.project_lookup_error, DatabaseError)
@when("I query for a project with non-existent ID {project_id:d}")
def step_query_nonexistent_project(context, project_id):
"""Query for non-existent project - covers line 53 (return None)."""
@@ -325,3 +404,75 @@ def step_verify_plan_null_build(context):
"""Verify plan has null build."""
assert context.retrieved_plan is not None
assert context.retrieved_plan.build is None
@when("I query for a plan with non-existent ID {plan_id:d}")
def step_query_missing_plan(context, plan_id):
"""Query for non-existent plan."""
context.retrieved_plan = context.plan_repo.get_by_id(plan_id)
@then("the plan repository should return None for missing plan")
def step_verify_plan_missing_none(context):
"""Verify plan lookup returns None for missing plan."""
assert context.retrieved_plan is None
@when("I update the plan with a new applied timestamp")
def step_update_plan_applied_timestamp(context):
"""Update plan with new applied timestamp to cover compatibility fields."""
context.new_applied_at = datetime.now()
context.created_plan.applied_at = context.new_applied_at
context.plan_repo.update(context.created_plan)
context.updated_plan = context.plan_repo.get_by_id(context.created_plan.id)
@then("the plan record should store the applied timestamp value")
def step_verify_plan_applied_timestamp(context):
"""Verify applied timestamp persisted."""
assert context.updated_plan is not None
assert context.updated_plan.applied_at == context.new_applied_at
@when("I request the current plan for the project")
def step_request_current_plan_for_project(context):
"""Request current plan for existing project."""
context.current_plan = context.plan_repo.get_current(context.test_project.id)
@given("I have a change repository with the shared database session")
def step_have_change_repo(context):
"""Create change repository using shared session."""
if not hasattr(context, "session"):
context.engine = init_database("sqlite:///:memory:")
Session = sessionmaker(bind=context.engine)
context.session = Session()
context.change_repo = ChangeRepository(context.session)
@given("I have added a change for the plan")
def step_add_change_for_plan(context):
"""Add a change for the current plan."""
change = Change(
plan_id=context.created_plan.id,
file_path="README.md",
operation=OperationType.CREATE,
original_content=None,
new_content="# Added change",
)
context.added_change = context.change_repo.add(change)
@when("I request all changes for the plan")
def step_request_all_changes(context):
"""Retrieve all changes for verification."""
context.retrieved_changes = context.change_repo.get_all()
@then("the repository should include the added change")
def step_verify_change_in_results(context):
"""Verify the added change is present in results."""
assert context.retrieved_changes
assert any(
change.id == context.added_change.id for change in context.retrieved_changes
)
@@ -6,19 +6,19 @@ import asyncio
import json
import shutil
import tempfile
from collections.abc import Callable
from pathlib import Path
from typing import Any, Callable
from typing import Any
from behave import given, then, when
from langchain_core.documents import Document
from langchain_community.llms import FakeListLLM
from langchain_core.documents import Document
from cleveragents.agents.context_analysis import (
ContextAnalysisAgent,
ContextAnalysisState,
)
DEFAULT_LLM_RESPONSES = [
"Dependencies: ['os', 'sys', 'pathlib']",
"Relevance: 0.8",
@@ -26,6 +26,17 @@ DEFAULT_LLM_RESPONSES = [
] * 20
class RaisingFakeLLM(FakeListLLM):
"""Fake LLM that raises a runtime error on every invocation."""
def __init__(self, message: str):
super().__init__(responses=[])
self._message = message
def _call(self, prompt: str, **kwargs: Any) -> str:
raise RuntimeError(self._message)
def _make_state(**overrides: Any) -> ContextAnalysisState:
state: ContextAnalysisState = {
"file_paths": [],
@@ -114,6 +125,12 @@ def step_have_agent_instance(context: Any) -> None:
context.agent = _ensure_agent(context)
@given('I have a ContextAnalysisAgent instance with an LLM that raises "{message}"')
def step_have_agent_with_raising_llm(context: Any, message: str) -> None:
context.agent = None
context.agent = _ensure_agent(context, llm=RaisingFakeLLM(message))
@given(
"I have a ContextAnalysisAgent instance with chunk_size {chunk_size:d} and chunk_overlap {overlap:d}"
)
@@ -144,6 +161,14 @@ def step_create_temp_file_with_content(context: Any, filename: str) -> None:
context.last_file_path = file_path
@given('I have a temporary directory named "{dirname}"')
def step_create_temp_directory(context: Any, dirname: str) -> None:
temp_dir = _ensure_temp_dir(context)
directory_path = temp_dir / dirname
directory_path.mkdir(parents=True, exist_ok=True)
context.last_directory = directory_path
@given("I have temporary test files:")
def step_create_multiple_temp_files(context: Any) -> None:
temp_dir = _ensure_temp_dir(context)
@@ -194,6 +219,12 @@ def step_no_error_present(context: Any) -> None:
assert context.state.get("error") in (None, "")
@then('the state error should contain "{text}"')
def step_state_error_contains(context: Any, text: str) -> None:
error = context.state.get("error")
assert error is not None and text in error
@given("I have a state with loaded documents containing:")
def step_state_with_loaded_documents(context: Any) -> None:
doc = Document(
@@ -203,6 +234,13 @@ def step_state_with_loaded_documents(context: Any) -> None:
context.state = _make_state(documents=[doc])
@given('the state error is "{message}"')
def step_set_state_error(context: Any, message: str) -> None:
if not hasattr(context, "state") or context.state is None:
context.state = _make_state()
context.state["error"] = message
@when("I execute the analyze_dependencies node")
def step_execute_analyze_dependencies(context: Any) -> None:
state = context.state
@@ -221,6 +259,12 @@ def step_dependencies_is_dict(context: Any) -> None:
assert isinstance(context.state["dependencies"], dict)
@then('the dependencies for "{source}" should be empty')
def step_dependencies_empty(context: Any, source: str) -> None:
dependencies = context.state["dependencies"]
assert dependencies.get(source) == []
@given("I have a state with a document of {size:d} characters")
def step_state_with_document_size(context: Any, size: int) -> None:
content = "x" * size
@@ -254,6 +298,15 @@ def step_state_with_chunks_from_files(context: Any, count: int) -> None:
context.state = _make_state(chunks=chunks)
@given('I have a state with duplicate chunks from "{source}"')
def step_state_with_duplicate_chunks(context: Any, source: str) -> None:
chunks = [
Document(page_content="chunk 0", metadata={"source": source, "chunk_index": 0}),
Document(page_content="chunk 1", metadata={"source": source, "chunk_index": 1}),
]
context.state = _make_state(chunks=chunks)
@when("I execute the score_relevance node")
def step_execute_score_relevance(context: Any) -> None:
state = context.state
@@ -318,6 +371,11 @@ def step_state_has_summary(context: Any) -> None:
assert isinstance(summary, str) and summary != ""
@then('the summary should equal "{expected}"')
def step_summary_equals(context: Any, expected: str) -> None:
assert context.state.get("summary") == expected
@when("I run the complete workflow with file paths:")
def step_run_complete_workflow(context: Any) -> None:
file_paths = json.loads(context.text.strip())
@@ -393,6 +451,22 @@ def step_async_final_state_fields(context: Any) -> None:
assert expected_keys.issubset(context.final_state.keys())
@when("I stream the workflow asynchronously with file paths:")
def step_stream_workflow_async(context: Any) -> None:
file_paths = json.loads(context.text.strip())
absolute_paths = _resolve_paths(context, file_paths)
initial_state = _make_state(file_paths=absolute_paths)
config = {"configurable": {"thread_id": "async-stream"}}
async def _run() -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
async for event in context.agent.astream(initial_state, config=config):
events.append(event)
return events
context.stream_events = asyncio.run(_run())
@when("I stream the workflow with file paths:")
def step_stream_workflow(context: Any) -> None:
file_paths = json.loads(context.text.strip())
@@ -432,6 +506,26 @@ def step_parsed_dependencies_include(context: Any, module: str) -> None:
assert any(module == dep or module in dep for dep in context.parsed_dependencies)
@when("I parse relevance scores from hints:")
def step_parse_relevance_hints(context: Any) -> None:
agent = _ensure_agent(context)
parsed_scores: list[float] = []
expected_scores: list[float] = []
for row in context.table:
parsed_scores.append(agent._parse_relevance_score(row["hint"]))
expected_scores.append(float(row["expected"]))
context.parsed_scores = parsed_scores
context.expected_scores = expected_scores
@then("the parsed scores should match expected values")
def step_parsed_scores_match_expected(context: Any) -> None:
assert hasattr(context, "parsed_scores")
assert hasattr(context, "expected_scores")
for parsed, expected in zip(context.parsed_scores, context.expected_scores):
assert abs(parsed - expected) < 1e-9
def after_scenario(context: Any, _scenario: Any) -> None:
if hasattr(context, "temp_dir") and context.temp_dir.exists():
shutil.rmtree(context.temp_dir, ignore_errors=True)
@@ -443,3 +537,11 @@ def after_scenario(context: Any, _scenario: Any) -> None:
context.final_state = None
if hasattr(context, "stream_events"):
context.stream_events = None
for attr in (
"parsed_dependencies",
"parsed_scores",
"expected_scores",
"last_directory",
):
if hasattr(context, attr):
setattr(context, attr, None)
+456
View File
@@ -0,0 +1,456 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.infrastructure.database.migration_runner import (
MEMORY_ENGINES,
MigrationRunner,
)
class FakeConnection:
def __init__(self) -> None:
self.entered = False
self.exit_called = False
self.closed_direct = False
def __enter__(self) -> FakeConnection:
self.entered = True
return self
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
self.exit_called = True
return False
def close(self) -> None:
self.closed_direct = True
class FakeEngine:
def __init__(self) -> None:
self.connections: list[FakeConnection] = []
self.disposed = False
def connect(self) -> FakeConnection:
conn = FakeConnection()
self.connections.append(conn)
return conn
def dispose(self) -> None:
self.disposed = True
@dataclass
class LegacyStampContext:
stamp_calls: list[Any]
connection_flags: list[bool]
@given('a migration runner configured for "{database_url}"')
def step_given_migration_runner(context, database_url: str) -> None:
context.database_url = database_url
context.runner = MigrationRunner(database_url)
@when("I attempt to load the alembic config without an alembic.ini file")
def step_when_load_missing_alembic(context) -> None:
context.runner._alembic_cfg = None
from pathlib import Path
def fake_exists(self) -> bool:
if str(self).endswith("alembic.ini"):
return False
return Path.exists(self)
context.alembic_error = None
try:
with patch.object(Path, "exists", fake_exists):
_ = context.runner.alembic_cfg
except FileNotFoundError as exc:
context.alembic_error = exc
@then('a FileNotFoundError should be raised mentioning "{expected_snippet}"')
def step_then_missing_alembic_error(context, expected_snippet: str) -> None:
assert context.alembic_error is not None, "Expected FileNotFoundError"
assert expected_snippet in str(context.alembic_error)
@when("I run migrations without providing an engine")
def step_when_run_migrations_no_engine(context) -> None:
env_var = "CLEVERAGENTS_DATABASE_URL"
original_env = os.environ.get(env_var)
os.environ[env_var] = "sqlite:///pre-existing.db"
context.env_before = os.environ[env_var]
captured_connection_attr: list[bool] = []
def fake_upgrade(cfg, revision) -> None:
captured_connection_attr.append("connection" in cfg.attributes)
with patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade",
side_effect=fake_upgrade,
):
context.runner.run_migrations()
context.upgrade_call_count = len(captured_connection_attr)
context.connection_attribute_present = (
captured_connection_attr[0] if captured_connection_attr else None
)
context.connection_attribute_state = dict(context.runner.alembic_cfg.attributes)
context.env_after = os.environ.get(env_var)
if original_env is None:
os.environ.pop(env_var, None)
else:
os.environ[env_var] = original_env
@then("the upgrade command should be invoked without a connection attribute")
def step_then_upgrade_without_connection(context) -> None:
assert context.upgrade_call_count == 1
assert context.connection_attribute_present is False
assert "connection" not in context.connection_attribute_state
@then("the CLEVERAGENTS_DATABASE_URL environment variable should be restored")
def step_then_env_restored(context) -> None:
assert context.env_after == context.env_before
@when("I initialize or upgrade the database with cached in-memory engine")
def step_when_init_in_memory(context) -> None:
memory_snapshot = dict(MEMORY_ENGINES)
MEMORY_ENGINES.clear()
fake_engine = FakeEngine()
inspector = MagicMock()
inspector.get_table_names.return_value = []
create_calls: list[Any] = []
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
create_calls.append((url, kwargs))
return fake_engine
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
):
context.runner.init_or_upgrade()
context.runner.init_or_upgrade()
upgrade_calls = list(upgrade_mock.call_args_list)
context.upgrade_call_count_with_engine = len(upgrade_calls)
context.create_engine_call_count = len(create_calls)
context.fake_engine = fake_engine
context.fake_engine_connections = list(fake_engine.connections)
context.connection_attrs_after_init = dict(context.runner.alembic_cfg.attributes)
context.memory_cache_state = dict(MEMORY_ENGINES)
MEMORY_ENGINES.clear()
MEMORY_ENGINES.update(memory_snapshot)
@then("migrations should run using the cached engine connection")
def step_then_cached_engine_used(context) -> None:
assert context.upgrade_call_count_with_engine == 2
assert context.create_engine_call_count == 1
assert len(context.fake_engine_connections) == 4
even_connections = context.fake_engine_connections[::2]
odd_connections = context.fake_engine_connections[1::2]
for conn in even_connections:
assert conn.exit_called is True
for conn in odd_connections:
assert conn.closed_direct is True
assert "connection" not in context.connection_attrs_after_init
cache_entry = context.memory_cache_state.get(context.runner.database_url)
assert cache_entry is context.fake_engine
@then("the cached engine should remain available without disposal")
def step_then_cached_engine_not_disposed(context) -> None:
assert context.fake_engine.disposed is False
@when("I initialize or upgrade the database with legacy tables present")
def step_when_legacy_stamping(context) -> None:
fake_engine = FakeEngine()
inspector = MagicMock()
inspector.get_table_names.return_value = ["users"]
stamp_context = LegacyStampContext(stamp_calls=[], connection_flags=[])
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
context.legacy_create_engine_call = (url, kwargs)
return fake_engine
def fake_stamp(cfg, revision) -> None:
stamp_context.stamp_calls.append((cfg, revision))
stamp_context.connection_flags.append("connection" in cfg.attributes)
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.stamp",
side_effect=fake_stamp,
) as stamp_mock,
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
):
context.runner.init_or_upgrade()
context.legacy_stamp_calls = list(stamp_mock.call_args_list)
context.legacy_upgrade_calls = list(upgrade_mock.call_args_list)
context.legacy_stamp_connection_has_attr = any(stamp_context.connection_flags)
context.legacy_stamp_revision = (
stamp_context.stamp_calls[0][1] if stamp_context.stamp_calls else None
)
context.legacy_fake_engine = fake_engine
context.legacy_connection_attr_after = dict(context.runner.alembic_cfg.attributes)
@then("the stamp command should run using the active connection")
def step_then_stamp_uses_connection(context) -> None:
assert len(context.legacy_stamp_calls) == 1
assert context.legacy_stamp_revision == "001_initial_schema"
assert context.legacy_stamp_connection_has_attr is True
assert len(context.legacy_upgrade_calls) == 0
assert "connection" not in context.legacy_connection_attr_after
@then("the external database engine should be disposed after initialization")
def step_then_external_engine_disposed(context) -> None:
assert context.legacy_fake_engine.disposed is True
@when("I request pending migrations for a database with no current revision")
def step_when_pending_no_current(context) -> None:
revisions = ["rev_003", "rev_002", None]
class FakeRevision:
def __init__(self, revision: Any) -> None:
self.revision = revision
fake_revisions = [FakeRevision(r) for r in revisions]
def walk_revisions():
return iter(fake_revisions)
fake_script_dir = MagicMock()
fake_script_dir.walk_revisions.side_effect = walk_revisions
with (
patch.object(MigrationRunner, "get_current_revision", return_value=None),
patch(
"cleveragents.infrastructure.database.migration_runner.ScriptDirectory.from_config",
return_value=fake_script_dir,
),
):
context.pending_migrations = context.runner.get_pending_migrations()
@then("the pending migration list should be ordered from oldest to newest")
def step_then_pending_migrations_ordered(context) -> None:
assert context.pending_migrations == [None, "rev_002", "rev_003"]
@when("I request the current revision from the database")
def step_when_get_current_revision(context) -> None:
fake_engine = FakeEngine()
migration_context = MagicMock()
migration_context.get_current_revision.return_value = "001_initial_schema"
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
context.current_rev_create_call = (url, kwargs)
return fake_engine
def fake_configure(conn):
context.current_rev_connection = conn
return migration_context
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch(
"cleveragents.infrastructure.database.migration_runner.MigrationContext.configure",
side_effect=fake_configure,
),
):
context.current_revision = context.runner.get_current_revision()
context.current_rev_fake_engine = fake_engine
@then("the migration context should be queried for the current revision")
def step_then_migration_context_queried(context) -> None:
assert context.current_revision == "001_initial_schema"
@then("the temporary connection should be closed afterward")
def step_then_temp_connection_closed(context) -> None:
assert len(context.current_rev_fake_engine.connections) == 1
assert context.current_rev_fake_engine.connections[0].exit_called is True
@when("I initialize or upgrade a file-based SQLite database")
def step_when_init_file_based_sqlite(context) -> None:
import shutil
from pathlib import Path
# Extract db path from URL
db_path = context.database_url.replace("sqlite:///", "")
if not db_path.startswith("/"):
db_path = "/" + db_path
db_file = Path(db_path)
# Clean up if exists
if db_file.parent.exists():
shutil.rmtree(db_file.parent)
context.db_parent_dir = db_file.parent
fake_engine = FakeEngine()
inspector = MagicMock()
inspector.get_table_names.return_value = []
context.file_sqlite_create_calls = []
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
context.file_sqlite_create_calls.append((url, kwargs))
return fake_engine
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch("cleveragents.infrastructure.database.migration_runner.command.upgrade"),
):
context.runner.init_or_upgrade()
context.file_sqlite_fake_engine = fake_engine
@then("the parent directory should be created if it does not exist")
def step_then_parent_dir_created(context) -> None:
assert context.db_parent_dir.exists()
@then("the database engine should be disposed after initialization")
def step_then_engine_disposed_after_init(context) -> None:
assert context.file_sqlite_fake_engine.disposed is True
@then("the engine should be created with check_same_thread set to False")
def step_then_engine_created_with_args(context) -> None:
assert len(context.file_sqlite_create_calls) > 0
url, kwargs = context.file_sqlite_create_calls[0]
assert "connect_args" in kwargs
assert kwargs["connect_args"]["check_same_thread"] is False
@when("I initialize the database and migrations are already applied")
def step_when_init_with_existing_migrations(context) -> None:
fake_engine = FakeEngine()
inspector = MagicMock()
inspector.get_table_names.return_value = ["alembic_version", "users"]
context.up_to_date_upgrade_calls = []
def fake_create_engine(url: str, **kwargs: Any) -> FakeEngine:
return fake_engine
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
side_effect=fake_create_engine,
),
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
patch.object(MigrationRunner, "get_pending_migrations", return_value=[]),
):
context.check_migrations_result = context.runner.check_migrations_needed()
context.runner.init_or_upgrade()
context.up_to_date_upgrade_calls = list(upgrade_mock.call_args_list)
context.up_to_date_fake_engine = fake_engine
@then("no additional migrations should be run")
def step_then_no_migrations_run(context) -> None:
assert len(context.up_to_date_upgrade_calls) == 0
@then("check migrations needed should return False")
def step_then_check_migrations_false(context) -> None:
assert context.check_migrations_result is False
@when("I initialize the database with pending migrations detected")
def step_when_pending_migrations_detected(context) -> None:
memory_snapshot = dict(MEMORY_ENGINES)
fake_engine = FakeEngine()
MEMORY_ENGINES[context.runner.database_url] = fake_engine
inspector = MagicMock()
inspector.get_table_names.return_value = ["alembic_version", "users"]
with (
patch(
"cleveragents.infrastructure.database.migration_runner.create_engine",
return_value=fake_engine,
) as create_engine_mock,
patch("sqlalchemy.inspect", return_value=inspector),
patch(
"cleveragents.infrastructure.database.migration_runner.command.upgrade"
) as upgrade_mock,
patch.object(
MigrationRunner, "get_pending_migrations", return_value=["rev_002"]
),
):
context.runner.init_or_upgrade()
context.pending_upgrade_calls = list(upgrade_mock.call_args_list)
context.pending_create_engine_calls = create_engine_mock.call_count
context.pending_fake_engine = fake_engine
context.pending_engine_connections = list(fake_engine.connections)
context.pending_memory_cache_state = dict(MEMORY_ENGINES)
MEMORY_ENGINES.clear()
MEMORY_ENGINES.update(memory_snapshot)
@then("run migrations should be invoked with the existing engine")
def step_then_run_migrations_existing_engine(context) -> None:
assert len(context.pending_upgrade_calls) == 1
assert context.pending_create_engine_calls == 0
assert len(context.pending_engine_connections) == 2
first_conn, second_conn = context.pending_engine_connections
assert first_conn.exit_called is True
assert second_conn.closed_direct is True
cache_entry = context.pending_memory_cache_state.get(context.runner.database_url)
assert cache_entry is context.pending_fake_engine
@then("the in-memory engine should not be disposed")
def step_then_pending_engine_not_disposed(context) -> None:
assert context.pending_fake_engine.disposed is False
@@ -283,9 +283,10 @@ def step_have_inputs_that_fail_validation(context: Any) -> None:
"""Create inputs that will fail validation."""
from pathlib import Path
from cleveragents.domain.models.core import Context, Plan, Project
from langchain_community.llms import FakeListLLM
from cleveragents.domain.models.core import Context, Plan, Project
# Create an LLM that returns responses leading to validation failure then success
responses = [
# First attempt - analysis
+474 -2
View File
@@ -1,19 +1,20 @@
"""Step definitions for plan service coverage tests."""
import tempfile
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from cleveragents.application.services.plan_service import PlanService
from cleveragents.application.services.memory_service import (
ConversationBufferMemoryAdapter,
MemoryService,
)
from cleveragents.application.services.plan_service import PlanService
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core import (
@@ -105,6 +106,192 @@ def step_check_validation_error(context: Context, message: str) -> None:
assert message in str(context.exception.message)
@when("I get the current plan for the project")
def step_get_current_plan(context: Context) -> None:
"""Fetch the current plan for the active project."""
context.current_plan_result = context.plan_service.get_current_plan(context.project)
context.exception = None
@then("the current plan result should be None")
def step_assert_current_plan_none(context: Context) -> None:
"""Assert that no current plan was found."""
assert getattr(context, "current_plan_result", "__missing__") is None
@when("I list the plans for the project")
def step_list_plans_for_project(context: Context) -> None:
"""Retrieve all plans for the active project."""
context.plans_result = context.plan_service.list_plans(context.project)
context.exception = None
@then("the plans result should be an empty list")
def step_assert_plans_empty(context: Context) -> None:
"""Verify no plans were returned."""
assert getattr(context, "plans_result", None) == []
@when('I attempt to switch to plan "{name}"')
def step_attempt_switch_plan(context: Context, name: str) -> None:
"""Attempt to switch plans while capturing errors."""
try:
context.switch_result = context.plan_service.switch_to_plan(
context.project, name
)
context.exception = None
except Exception as exc:
context.switch_result = None
context.exception = exc
@then("the validation error details should hint to initialize the project")
def step_assert_validation_hint(context: Context) -> None:
"""Validate the hint provided with the ValidationError."""
assert isinstance(context.exception, ValidationError)
details = getattr(context.exception, "details", {}) or {}
assert details.get("hint") == "Initialize project first with 'agents init'"
@given('the project has plans named "{first}" and "{second}"')
def step_project_with_named_plans(context: Context, first: str, second: str) -> None:
"""Ensure the project has specific plan names configured."""
assert context.project.id is not None, (
"Project must be persisted before adding plans"
)
with context.unit_of_work.transaction() as ctx:
current_plan = ctx.plans.get_current_for_project(context.project.id)
assert current_plan is not None, "Current plan is required for this step"
current_plan.name = first
ctx.plans.update(current_plan)
second_plan = Plan(
id=None,
project_id=context.project.id,
name=second,
prompt="Secondary plan",
status=PlanStatus.PENDING,
current=False,
created_at=datetime.now(),
updated_at=datetime.now(),
build=None,
build_started_at=None,
build_completed_at=None,
model_used=None,
token_count=None,
result=None,
applied_at=None,
files_created=None,
files_modified=None,
files_deleted=None,
)
ctx.plans.create(second_plan)
context.available_plan_names = [first, second]
@then(
'the validation error details should list available plans "{first}" and "{second}"'
)
def step_assert_available_plans(context: Context, first: str, second: str) -> None:
"""Check that available plan names are surfaced in the error."""
assert isinstance(context.exception, ValidationError)
details = getattr(context.exception, "details", {}) or {}
assert details.get("available_plans") == [first, second]
@when('I attempt to add prompt "{prompt}" to the current plan')
def step_attempt_add_prompt(context: Context, prompt: str) -> None:
"""Attempt to add instructions to the current plan, capturing errors."""
try:
context.plan_service.add_to_plan(context.project, prompt)
context.exception = None
except Exception as exc:
context.exception = exc
@then("the plan error details should suggest creating a plan first")
def step_assert_plan_hint(context: Context) -> None:
"""Confirm the PlanError hint references plan creation."""
assert isinstance(context.exception, PlanError)
details = getattr(context.exception, "details", {}) or {}
assert details.get("hint") == "Create a plan first with 'agents new'"
@given("the current plan prompt is set to None")
def step_set_current_plan_prompt_none(context: Context) -> None:
"""Record the current plan for later patching to simulate a missing prompt."""
assert context.project.id is not None, "Project must be saved"
with context.unit_of_work.transaction() as ctx:
plan = ctx.plans.get_current_for_project(context.project.id)
assert plan is not None, "Expected a current plan to modify"
context.current_plan = plan
context.plan_previous_updated_at = plan.updated_at
context.plan_id_for_none_prompt = plan.id
@when('I add prompt "{prompt}" to the current plan')
def step_add_prompt_to_current_plan(context: Context, prompt: str) -> None:
"""Add instructions to the current plan and persist the result."""
assert context.project.id is not None, "Project must be saved"
with context.unit_of_work.transaction() as ctx:
plan_before = ctx.plans.get_current_for_project(context.project.id)
assert plan_before is not None, "Expected a current plan before adding prompt"
context.plan_previous_updated_at = plan_before.updated_at
from cleveragents.infrastructure.database.repositories import PlanRepository
original_get_current = PlanRepository.get_current_for_project
def get_current_with_none_prompt(self, project_id: int): # type: ignore[override]
plan = original_get_current(self, project_id)
target_plan_id = getattr(context, "plan_id_for_none_prompt", None)
if plan and plan.id == target_plan_id:
plan_dict = plan.model_dump()
plan_with_none = Plan.model_construct(**plan_dict)
object.__setattr__(plan_with_none, "prompt", None)
return plan_with_none
return plan
with patch.object(
PlanRepository, "get_current_for_project", get_current_with_none_prompt
):
context.plan_service.add_to_plan(context.project, prompt)
with context.unit_of_work.transaction() as ctx:
context.current_plan_after_add = ctx.plans.get_current_for_project(
context.project.id
)
context.added_prompt = prompt
@then('the current plan prompt should be "{expected}"')
def step_assert_current_plan_prompt(context: Context, expected: str) -> None:
"""Verify the current plan prompt matches expectations."""
plan = getattr(context, "current_plan_after_add", None)
assert plan is not None, "No plan result found after adding prompt"
assert plan.prompt == expected
@then("the current plan status should be PENDING")
def step_assert_current_plan_status_pending(context: Context) -> None:
"""Ensure the current plan status is set to PENDING."""
plan = getattr(context, "current_plan_after_add", None)
assert plan is not None, "No plan result found after adding prompt"
assert plan.status == PlanStatus.PENDING
@then("the current plan updated_at should be refreshed")
def step_assert_current_plan_updated(context: Context) -> None:
"""Confirm the plan updated_at timestamp increased after adding prompt."""
plan = getattr(context, "current_plan_after_add", None)
assert plan is not None, "No plan result found after adding prompt"
previous = getattr(context, "plan_previous_updated_at", None)
assert previous is not None, "No previous updated_at stored"
assert plan.updated_at > previous
@given("I have a saved project with no current plan")
def step_create_saved_project_no_plan(context: Context) -> None:
"""Create a saved project with no current plan."""
@@ -494,6 +681,48 @@ def step_check_file_moved(context: Context) -> None:
assert new_path.read_text() == "# File to move"
@given("the plan has a pending MOVE change to a nested relative path")
def step_add_nested_move_change(context: Context) -> None:
"""Add a MOVE change that targets a nested relative destination."""
source_relative = Path("nested") / "source.py"
source_path = context.project.path / source_relative
source_path.parent.mkdir(parents=True, exist_ok=True)
source_path.write_text("# Nested file to move")
destination_relative = Path("dest") / "nested" / "target.py"
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path=str(source_relative),
operation=OperationType.MOVE,
original_content="# Nested file to move",
new_content="# Nested file to move",
new_path=str(destination_relative),
applied=False,
applied_at=None,
created_at=datetime.now(),
)
ctx.changes.add(change)
context.nested_move_paths = (
source_path,
context.project.path / destination_relative,
)
@then("the file should be moved to the nested relative location")
def step_check_nested_move(context: Context) -> None:
"""Verify nested MOVE change relocated the file correctly."""
assert hasattr(context, "nested_move_paths"), "Nested move paths were not recorded"
old_path, new_path = context.nested_move_paths
assert not old_path.exists(), "Original nested file still exists"
assert new_path.exists(), "Nested destination file was not created"
assert new_path.read_text() == "# Nested file to move", (
"Nested destination content mismatch"
)
@given("the plan has changes with absolute file paths")
def step_add_absolute_path_changes(context: Context) -> None:
"""Add changes with absolute file paths."""
@@ -972,3 +1201,246 @@ def step_verify_memory_cleared(context: Context, session_id: str) -> None:
service = getattr(context, "cleared_memory_service", None)
assert isinstance(service, MemoryService)
assert service.message_history.messages == []
class _StubPlansRepository:
"""Minimal stub for plan repository interactions."""
def __init__(
self,
*,
create_return_plan: Plan | None = None,
current_plan: Plan | None = None,
) -> None:
self._create_return_plan = create_return_plan
self._current_plan = current_plan
self.created_plans: list[Plan] = []
self.update_calls: list[Plan] = []
self.set_current_calls: list[tuple[int | None, int | None]] = []
def create(self, plan: Plan) -> Plan:
self.created_plans.append(plan)
if self._create_return_plan is not None:
return self._create_return_plan
return plan
def get_current_for_project(self, project_id: int | None) -> Plan | None:
return self._current_plan
def update(self, plan: Plan) -> None:
self.update_calls.append(plan)
def set_current(self, project_id: int | None, plan_id: int | None) -> None:
self.set_current_calls.append((project_id, plan_id))
class _StaticContext:
"""Context object supplying repositories for stubbed transactions."""
def __init__(
self,
plans_repo: _StubPlansRepository,
contexts_repo: MagicMock | None = None,
changes_repo: MagicMock | None = None,
) -> None:
self.plans = plans_repo
self.contexts = contexts_repo or MagicMock()
self.changes = changes_repo or MagicMock()
class _StubTransaction:
"""Simple context manager that can invoke an exit callback."""
def __init__(
self,
context_obj: _StaticContext,
exit_callback: Callable[[], None] | None = None,
) -> None:
self._context = context_obj
self._exit_callback = exit_callback
def __enter__(self) -> _StaticContext:
return self._context
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
if self._exit_callback is not None:
self._exit_callback()
return False
class _SequencedUnitOfWork(UnitOfWork):
"""Unit of work that plays back a sequence of stubbed transactions."""
def __init__(
self, transactions: list[tuple[_StaticContext, Callable[[], None] | None]]
):
self._transactions = transactions
def transaction(self) -> _StubTransaction: # type: ignore[override]
if not self._transactions:
raise RuntimeError("No stub transactions configured")
if len(self._transactions) > 1:
context_obj, exit_callback = self._transactions.pop(0)
else:
context_obj, exit_callback = self._transactions[0]
return _StubTransaction(context_obj, exit_callback)
@given(
"I configure a stub plan service whose current plan loses its ID after the transaction"
)
def step_configure_plan_service_losing_id(context: Context) -> None:
plan = Plan(
id=123,
project_id=1,
name="stub-plan",
prompt="stub",
status=PlanStatus.PENDING,
current=True,
created_at=datetime.now(),
updated_at=datetime.now(),
build=None,
build_started_at=None,
build_completed_at=None,
model_used=None,
token_count=None,
result=None,
applied_at=None,
files_created=None,
files_modified=None,
files_deleted=None,
)
plans_repo = _StubPlansRepository(current_plan=plan)
context.stub_plans_repo = plans_repo
static_context = _StaticContext(plans_repo)
def invalidate_plan_id() -> None:
plan.id = None
stub_uow = _SequencedUnitOfWork([(static_context, invalidate_plan_id)])
context.unit_of_work = stub_uow
settings = Settings()
context.plan_service = PlanService(
settings=settings,
unit_of_work=stub_uow,
ai_provider=None,
)
context.project = Project(
id=1,
name="stub-project",
path=context.temp_dir,
created_at=datetime.now(),
updated_at=datetime.now(),
current_plan_id=plan.id,
settings=ProjectSettings(
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024,
default_model="mock-gpt",
),
)
@when("I try to build the plan with the stubbed service")
def step_try_build_plan_stub(context: Context) -> None:
try:
context.plan_service.build_plan(context.project)
context.exception = None
except Exception as exc: # pragma: no cover - defensive
context.exception = exc
@given("I replace the plan service repository with a stub that never assigns plan IDs")
def step_replace_plan_service_with_stub(context: Context) -> None:
plans_repo = _StubPlansRepository()
context.stub_plans_repo = plans_repo
static_context = _StaticContext(plans_repo)
stub_uow = _SequencedUnitOfWork([(static_context, None)])
context.unit_of_work = stub_uow
settings = Settings()
context.plan_service = PlanService(
settings=settings,
unit_of_work=stub_uow,
ai_provider=None,
)
context.project = Project(
id=7,
name="stub-project",
path=context.temp_dir,
created_at=datetime.now(),
updated_at=datetime.now(),
current_plan_id=None,
settings=ProjectSettings(
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024,
default_model="stub-model",
),
)
@when('I create a plan with stub prompt "{prompt}"')
def step_create_plan_with_stub_prompt(context: Context, prompt: str) -> None:
context.stub_created_plan = context.plan_service.create_plan(
context.project,
prompt,
None,
)
@then("the stub-created plan should remain non-current")
def step_verify_stub_plan_not_current(context: Context) -> None:
plan = getattr(context, "stub_created_plan", None)
assert isinstance(plan, Plan)
assert plan.current is False
assert plan.id is None
@then("the stub repository should not record a current plan")
def step_verify_stub_repository_not_called(context: Context) -> None:
repo = getattr(context, "stub_plans_repo", None)
assert isinstance(repo, _StubPlansRepository)
assert repo.set_current_calls == []
@when('I monitor max message updates for session "{session_id}"')
def step_monitor_max_messages(context: Context, session_id: str) -> None:
service = context.plan_service._memory_services.get(session_id)
assert service is not None, "Memory service must exist before monitoring"
original_setter = service.set_max_messages
monitored_setter = MagicMock(wraps=original_setter)
service.set_max_messages = monitored_setter # type: ignore[assignment]
context.monitored_service = service
context.monitored_setter = monitored_setter
@when(
'I request the same persistent memory service for session "{session_id}" with max messages {max_messages:d}'
)
def step_request_same_persistent_memory(
context: Context, session_id: str, max_messages: int
) -> None:
service = _request_memory_service_for_session(
context,
session_id,
persistent=True,
max_messages=max_messages,
)
context.reused_memory_service = service
@then(
'the session "{session_id}" memory should reuse the existing service without changing limits'
)
def step_verify_memory_service_reused(context: Context, session_id: str) -> None:
service = context.plan_service._memory_services.get(session_id)
monitored_service = getattr(context, "monitored_service", None)
monitored_setter = getattr(context, "monitored_setter", None)
assert service is monitored_service
assert monitored_service is not None
assert monitored_setter is not None
assert monitored_setter.call_count == 0
assert monitored_service.max_messages is not None
+246 -1
View File
@@ -2,9 +2,11 @@
import json
import os
import shutil
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
@@ -420,8 +422,17 @@ def step_check_no_project_for_path(context: Context) -> None:
def step_create_multiple_projects_times(context: Context) -> None:
"""Create multiple projects at different times."""
base_time = datetime.now()
temp_dir = getattr(context, "temp_dir", None)
if temp_dir is None:
if hasattr(context, "test_dir") and context.test_dir:
temp_dir = Path(context.test_dir)
else:
temp_dir = Path(tempfile.mkdtemp())
context.temp_dir = temp_dir
if not hasattr(context, "unit_of_work") or context.unit_of_work is None:
context.unit_of_work = context.project_service.unit_of_work
for i in range(3):
project_path = context.temp_dir / f"project{i}"
project_path = temp_dir / f"project{i}"
project = context.project_service.initialize_project(
name=f"project{i}", path=project_path, force=False
)
@@ -792,3 +803,237 @@ def step_check_name_truncated(context: Context) -> None:
else:
# If the project creation failed due to name length, that's also acceptable
assert context.exception is not None
@given('I prepare a project path "{name}"')
def step_prepare_project_path(context: Context, name: str) -> None:
"""Prepare a reusable project path for testing."""
if not hasattr(context, "test_dir"):
raise AssertionError(
"Project service must be initialized before preparing paths"
)
base_dir = Path(context.test_dir)
target_path = base_dir / name
if target_path.exists():
shutil.rmtree(target_path)
target_path.mkdir(parents=True, exist_ok=True)
context.prepared_project_name = name
context.prepared_project_path = target_path
@when("I initialize the project with filesystem failure")
def step_initialize_project_with_failure(context: Context) -> None:
"""Attempt to initialize a project while simulating filesystem failure."""
assert hasattr(context, "prepared_project_path"), "Project path not prepared"
with patch.object(
Path,
"mkdir",
side_effect=OSError("Simulated filesystem failure during mkdir"),
):
try:
context.project_service.initialize_project(
name=context.prepared_project_name,
path=context.prepared_project_path,
force=False,
)
context.exception = None
except Exception as exc:
context.exception = exc
@given("I have initialized that project once already")
def step_initialize_project_once(context: Context) -> None:
"""Initialize the prepared project to seed database state."""
assert hasattr(context, "prepared_project_path"), "Project path not prepared"
context.initial_project = context.project_service.initialize_project(
name=context.prepared_project_name,
path=context.prepared_project_path,
force=False,
)
@given("the cleveragents directory has been removed for that project")
def step_remove_cleveragents_directory(context: Context) -> None:
"""Remove the .cleveragents directory to bypass filesystem pre-checks."""
assert hasattr(context, "prepared_project_path"), "Project path not prepared"
project_dir = context.prepared_project_path / ".cleveragents"
if project_dir.exists():
shutil.rmtree(project_dir)
@when("I initialize the same project without force using database state")
def step_initialize_without_force_database(context: Context) -> None:
"""Re-run initialization without force, capturing any validation errors."""
assert hasattr(context, "prepared_project_path"), "Project path not prepared"
try:
context.project_result = context.project_service.initialize_project(
name=context.prepared_project_name,
path=context.prepared_project_path,
force=False,
)
context.error = None
except Exception as exc:
context.project_result = None
context.error = exc
@given("the legacy migration will report success without changes")
def step_force_successful_migration(context: Context) -> None:
"""Flag that the migration helper should report success."""
context.force_migration_success = True
@when("I initialize the same project without force during migration")
def step_initialize_with_forced_migration(context: Context) -> None:
"""Re-run initialization when migration reports success."""
assert hasattr(context, "prepared_project_path"), "Project path not prepared"
patcher = None
if getattr(context, "force_migration_success", False):
patcher = patch(
"cleveragents.infrastructure.database.legacy_migrator.check_and_migrate_legacy_data",
return_value=True,
)
patcher.start()
try:
context.project_result = context.project_service.initialize_project(
name=context.prepared_project_name,
path=context.prepared_project_path,
force=False,
)
context.error = None
except Exception as exc:
context.project_result = None
context.error = exc
finally:
if patcher:
patcher.stop()
@then("the existing project should be reused")
def step_existing_project_reused(context: Context) -> None:
"""Verify that the previously stored project was returned."""
assert context.error is None, f"Unexpected error: {context.error}"
assert context.project_result is not None, "No project was returned"
assert hasattr(context, "initial_project"), "Initial project missing from context"
assert context.project_result.id == context.initial_project.id
@given('I set up a standalone project directory named "{name}" with a name file')
def step_setup_standalone_directory_with_name(context: Context, name: str) -> None:
"""Create a standalone project directory with project.name file."""
assert hasattr(context, "test_dir"), "Project service must supply a test directory"
target_dir = Path(context.test_dir) / name
if target_dir.exists():
shutil.rmtree(target_dir)
target_dir.mkdir(parents=True, exist_ok=True)
cleveragents_dir = target_dir / ".cleveragents"
cleveragents_dir.mkdir()
(cleveragents_dir / "project.name").write_text(name)
context.current_project_dir = target_dir
context.expected_temp_project_name = name
@given('I set up a standalone project directory named "{name}" without a name file')
def step_setup_standalone_directory_without_name(context: Context, name: str) -> None:
"""Create a standalone project directory without project.name file."""
assert hasattr(context, "test_dir"), "Project service must supply a test directory"
target_dir = Path(context.test_dir) / name
if target_dir.exists():
shutil.rmtree(target_dir)
target_dir.mkdir(parents=True, exist_ok=True)
cleveragents_dir = target_dir / ".cleveragents"
cleveragents_dir.mkdir()
context.current_project_dir = target_dir
context.expected_temp_project_name = name
@given("I prepare an empty working directory")
def step_prepare_empty_working_directory(context: Context) -> None:
"""Create an empty directory for current-project discovery."""
assert hasattr(context, "test_dir"), "Project service must supply a test directory"
target_dir = Path(context.test_dir) / "empty-working"
if target_dir.exists():
shutil.rmtree(target_dir)
target_dir.mkdir(parents=True, exist_ok=True)
context.current_project_dir = target_dir
context.expected_temp_project_name = None
@when("I fetch the current project from that directory without database entry")
def step_fetch_current_project_without_db(context: Context) -> None:
"""Fetch the current project after changing into the prepared directory."""
import os
context.project_service.unit_of_work.init_database()
target_dir = getattr(context, "current_project_dir", None)
assert target_dir is not None, "No working directory prepared"
original_cwd = os.getcwd()
try:
os.chdir(target_dir)
context.current_project_result = context.project_service.get_current_project()
finally:
os.chdir(original_cwd)
@then('a temporary project named "{name}" should be returned')
def step_assert_temporary_project(context: Context, name: str) -> None:
"""Ensure a synthesized temporary project is returned."""
project = getattr(context, "current_project_result", None)
assert project is not None, "Expected a temporary project to be returned"
assert project.name == name
assert project.id is None
@then("no current project should be found")
def step_assert_no_current_project(context: Context) -> None:
"""Ensure no project is returned when none exists."""
assert getattr(context, "current_project_result", None) is None
@when("I create the project using the alias method")
def step_create_project_via_alias(context: Context) -> None:
"""Create a project through the create_project alias."""
assert hasattr(context, "prepared_project_path"), "Project path not prepared"
context.alias_project = context.project_service.create_project(
name=context.prepared_project_name,
path=context.prepared_project_path,
force=False,
)
context.error = None
@then("the alias project should be created successfully")
def step_assert_alias_project_created(context: Context) -> None:
"""Verify the alias method created a project."""
project = getattr(context, "alias_project", None)
assert project is not None, "Alias project was not created"
assert project.name == context.prepared_project_name
assert project.path == context.prepared_project_path
@when("I look up the project by its saved path")
def step_lookup_project_by_path(context: Context) -> None:
"""Retrieve the project using its stored filesystem path."""
assert hasattr(context, "project"), "A project must exist before lookup"
context.found_project_by_path = context.project_service.get_project_by_path(
context.project.path
)
@then("the project lookup result should be found")
def step_assert_project_lookup_found(context: Context) -> None:
"""Ensure the lookup result returned a project instance."""
project = getattr(context, "found_project", None)
if project is None:
project = getattr(context, "found_project_by_path", None)
assert project is not None, "Project lookup did not return a project"
if hasattr(context, "project") and context.project is not None:
assert project.name == context.project.name
@when("I list all projects with unknown ordering")
def step_list_projects_unknown_order(context: Context) -> None:
"""List projects using an unsupported ordering value."""
context.projects_list = context.project_service.list_projects(
order_by="unknown-ordering"
)
+168 -1
View File
@@ -7,6 +7,7 @@ from types import SimpleNamespace
from behave import given, then, when
from cleveragents.core import retry_patterns as retry_patterns_module
from cleveragents.core.exceptions import (
NetworkError,
RateLimitError,
@@ -24,12 +25,13 @@ from cleveragents.core.retry_patterns import (
retry_auto_debug,
retry_file_operation,
retry_network_operation,
retry_on_result,
retry_provider_operation,
retry_with_exponential_backoff,
retry_with_jitter,
retry_with_timeout,
should_retry_result,
)
from cleveragents.core import retry_patterns as retry_patterns_module
@given("I have the retry patterns module imported")
@@ -372,6 +374,28 @@ def step_verify_circuit_closed(context):
assert context.circuit_breaker.state == "closed"
@given("the circuit breaker has recorded failures")
def step_circuit_breaker_recorded_failures(context):
"""Simulate prior failures to exercise closed-state success path."""
context.circuit_breaker.failure_count = 3
context.circuit_breaker.state = "closed"
context.circuit_breaker.last_failure_time = time.time()
@when("I execute a successful call while the circuit is closed")
def step_success_call_closed_state(context):
"""Execute a successful call without half-open transition."""
context.closed_state_result = context.circuit_breaker.call(lambda: "closed success")
@then("the circuit breaker should reset failure tracking after success")
def step_verify_closed_state_success(context):
"""Ensure closed-state success resets counters."""
assert context.closed_state_result == "closed success"
assert context.circuit_breaker.failure_count == 0
assert context.circuit_breaker.state == "closed"
# Retry context tests
@given('I have a retry context for "{operation}"')
def step_create_retry_context(context, operation):
@@ -475,6 +499,96 @@ def step_verify_async_retry_context_manager_capture(context):
assert isinstance(context.retry_context.errors[-1], Exception)
@when("I execute a successful block under the retry context manager")
def step_retry_context_manager_success(context):
"""Execute a successful block to cover no-error path."""
errors_before = len(context.retry_context.errors)
with context.retry_context:
context.retry_context_success_value = "sync context success"
context.retry_context_errors_delta_sync = (
len(context.retry_context.errors) - errors_before
)
@then("the retry context manager should not record errors")
def step_verify_retry_context_manager_success(context):
"""Confirm no errors were recorded for successful context block."""
assert context.retry_context_success_value == "sync context success"
assert context.retry_context_errors_delta_sync == 0
@when("I execute a successful block under the async retry context manager")
def step_async_retry_context_manager_success(context):
"""Execute a successful async block to cover no-error path."""
errors_before = len(context.retry_context.errors)
async def run_async_block():
async with context.retry_context:
return "async context success"
loop = asyncio.new_event_loop()
try:
context.async_retry_context_manager_result = loop.run_until_complete(
run_async_block()
)
finally:
loop.close()
context.retry_context_errors_delta_async = (
len(context.retry_context.errors) - errors_before
)
@then("the async retry context manager should not record errors")
def step_verify_async_retry_context_manager_success(context):
"""Confirm async context manager leaves errors untouched when successful."""
assert context.async_retry_context_manager_result == "async context success"
assert context.retry_context_errors_delta_async == 0
@when("I execute the async function with the retry context")
def step_execute_async_with_retry_context(context):
"""Execute async function using RetryContext.async_execute."""
loop = asyncio.new_event_loop()
try:
context.async_execute_result = loop.run_until_complete(
context.retry_context.async_execute(context.test_function)
)
finally:
loop.close()
context.async_execute_attempts = context.retry_context.attempt_count
@then("the async retry context execute should succeed after {attempts:d} attempts")
def step_verify_async_execute_attempts(context, attempts):
"""Verify async execute succeeded after expected attempts."""
assert context.async_execute_result == "async success"
assert context.async_execute_attempts == attempts
@when("I apply retry with timeout of {max_attempts:d} attempts and {timeout} seconds")
def step_apply_retry_with_timeout(context, max_attempts, timeout):
"""Apply retry_with_timeout while stubbing out sleep delays."""
timeout_seconds = float(timeout)
original_sleep = time.sleep
time.sleep = lambda _seconds: None
def restore_sleep():
time.sleep = original_sleep
context.add_cleanup(restore_sleep)
decorated_func = retry_with_timeout(
max_attempts=max_attempts, timeout_seconds=timeout_seconds
)(context.test_function)
try:
context.result = decorated_func()
context.retry_succeeded = True
except Exception as exc: # pragma: no cover - defensive safety
context.retry_error = exc
context.retry_succeeded = False
# Retry with jitter tests
@given("I have multiple concurrent operations")
def step_setup_concurrent_operations(context):
@@ -528,6 +642,59 @@ def step_verify_no_simultaneous_retry(context):
assert max_in_bucket <= 3, "Too many operations retrying simultaneously"
# Retry on result tests
@given("I have sequential result payloads requiring a retry")
def step_setup_sequential_retry_results(context):
"""Create a deterministic sequence of results for retry_on_result tests."""
context.call_count = 0
context.result_sequence = [
{"retry": True, "value": "try-again"},
{"retry": False, "value": "done"},
]
def result_function():
context.call_count += 1
index = min(context.call_count - 1, len(context.result_sequence) - 1)
return context.result_sequence[index]
context.test_function = result_function
@given("I have a retry predicate that checks for retry flag")
def step_setup_retry_predicate(context):
"""Define predicate used by retry_on_result decorator."""
def predicate(result):
return isinstance(result, dict) and bool(result.get("retry"))
context.retry_predicate = predicate
@when("I apply retry on result with max {max_attempts:d} attempts")
def step_apply_retry_on_result(context, max_attempts):
"""Apply retry_on_result decorator and capture the outcome."""
decorated_func = retry_on_result(
context.retry_predicate, max_attempts=max_attempts
)(context.test_function)
try:
context.retry_on_result_output = decorated_func()
context.retry_on_result_succeeded = True
except Exception as exc: # pragma: no cover - defensive safety
context.retry_on_result_output = exc
context.retry_on_result_succeeded = False
@then("the decorated function should return the successful payload")
def step_verify_retry_on_result_payload(context):
"""Ensure retry_on_result eventually returns the non-retry payload."""
assert context.retry_on_result_succeeded, (
f"Retry on result failed: {context.retry_on_result_output}"
)
assert isinstance(context.retry_on_result_output, dict)
assert context.retry_on_result_output.get("retry") is False
# Auto-debug retry tests
@given("I have a function that fails with errors")
def step_create_failing_function_with_errors(context):
+18 -13
View File
@@ -104,8 +104,8 @@ Format Context Summary With Multiple Files
... from cleveragents.domain.models.core import Context
... graph = PlanGenerationGraph()
... contexts = [
... Context(path='file1.py', content='# File 1 content'),
... Context(path='file2.py', content='# File 2 content'),
... Context(plan_id=1, path='file1.py', content='# File 1 content'),
... Context(plan_id=1, path='file2.py', content='# File 2 content'),
... ]
... summary = graph._format_context_summary(contexts)
... assert 'file1.py' in summary
@@ -124,7 +124,7 @@ Format Context Summary Limits To Five Files
... from cleveragents.agents.plan_generation import PlanGenerationGraph
... from cleveragents.domain.models.core import Context
... graph = PlanGenerationGraph()
... contexts = [Context(path=f'file{i}.py', content='content') for i in range(8)]
... contexts = [Context(plan_id=1, path=f'file{i}.py', content='content') for i in range(8)]
... summary = graph._format_context_summary(contexts)
... assert 'file0.py' in summary
... assert 'file4.py' in summary
@@ -263,13 +263,14 @@ Generate Plan Infers Test File Name From Prompt
[Documentation] Test file name inference for test-related prompts
${script}= Catenate SEPARATOR=\n
... import sys
... from pathlib import Path
... sys.path.insert(0, '${SRC_DIR}')
... from cleveragents.agents.plan_generation import PlanGenerationGraph
... from cleveragents.domain.models.core import Project, Plan
... graph = PlanGenerationGraph()
... state = {
... 'project': Project(id=1, name='test'),
... 'plan': Plan(id=1, project_id=1, prompt='Create unit tests'),
... 'project': Project(id=1, name='test', path=Path('/tmp/test_project')),
... 'plan': Plan(id=1, project_id=1, name='Unit Test Plan', prompt='Create unit tests'),
... 'contexts': [],
... 'prompt': 'Create unit tests',
... 'analyzed_requirements': {
@@ -290,13 +291,14 @@ Generate Plan Infers Error Handler File Name From Prompt
[Documentation] Test file name inference for error/exception prompts
${script}= Catenate SEPARATOR=\n
... import sys
... from pathlib import Path
... sys.path.insert(0, '${SRC_DIR}')
... from cleveragents.agents.plan_generation import PlanGenerationGraph
... from cleveragents.domain.models.core import Project, Plan
... graph = PlanGenerationGraph()
... state = {
... 'project': Project(id=1, name='test'),
... 'plan': Plan(id=1, project_id=1, prompt='Add error handling'),
... 'project': Project(id=1, name='test', path=Path('/tmp/test_project')),
... 'plan': Plan(id=1, project_id=1, name='Error Handling Plan', prompt='Add error handling'),
... 'contexts': [],
... 'prompt': 'Add error handling',
... 'analyzed_requirements': {
@@ -316,14 +318,16 @@ Workflow Invoke Method Returns Complete State
[Documentation] Test that invoke() returns complete workflow state
${script}= Catenate SEPARATOR=\n
... import sys
... from pathlib import Path
... sys.path.insert(0, '${SRC_DIR}')
... from cleveragents.agents.plan_generation import PlanGenerationGraph
... from cleveragents.domain.models.core import Project, Plan, Context
... graph = PlanGenerationGraph()
... project = Project(id=1, name='test_project')
... plan = Plan(id=1, project_id=1, prompt='Add logging')
... contexts = [Context(path='app.py', content='def main(): pass')]
... project = Project(id=1, name='test_project', path=Path('/tmp/test_project'))
... plan = Plan(id=1, project_id=1, name='Logging Plan', prompt='Add logging')
... contexts = [Context(plan_id=plan.id, path='app.py', content='def main(): pass')]
... result = graph.invoke(project, plan, contexts, thread_id='test-123')
... assert result['plan'].name == plan.name
... assert 'project' in result
... assert 'plan' in result
... assert 'generated_changes' in result
@@ -338,13 +342,14 @@ Workflow Stream Method Yields Events
[Documentation] Test that stream() yields workflow events
${script}= Catenate SEPARATOR=\n
... import sys
... from pathlib import Path
... sys.path.insert(0, '${SRC_DIR}')
... from cleveragents.agents.plan_generation import PlanGenerationGraph
... from cleveragents.domain.models.core import Project, Plan, Context
... graph = PlanGenerationGraph()
... project = Project(id=1, name='test_project')
... plan = Plan(id=1, project_id=1, prompt='Add feature')
... contexts = [Context(path='app.py', content='# app')]
... project = Project(id=1, name='test_project', path=Path('/tmp/test_project'))
... plan = Plan(id=1, project_id=1, name='Feature Plan', prompt='Add feature')
... contexts = [Context(plan_id=plan.id, path='app.py', content='# app')]
... events = list(graph.stream(project, plan, contexts))
... assert len(events) > 0
... assert all(isinstance(e, dict) for e in events)
+5 -3
View File
@@ -5,6 +5,7 @@ This module provides the foundation for all agent implementations
using LangGraph for workflow orchestration.
"""
from collections.abc import Iterator
from typing import Any, TypedDict
from langchain_core.language_models import BaseLanguageModel
@@ -119,7 +120,9 @@ class BaseAgent:
result = await self.app.ainvoke(input_data, config)
return result
def stream(self, input_data: dict[str, Any], config: dict[str, Any] | None = None):
def stream(
self, input_data: dict[str, Any], config: dict[str, Any] | None = None
) -> Iterator[dict[str, Any]]:
"""Stream the agent workflow execution.
Args:
@@ -132,5 +135,4 @@ class BaseAgent:
if config is None:
config = {"configurable": {"thread_id": "default"}}
for event in self.app.stream(input_data, config):
yield event
yield from self.app.stream(input_data, config)
+152 -205
View File
@@ -6,18 +6,20 @@ LangGraph's StateGraph. The workflow includes file loading, dependency analysis,
and semantic relevance scoring.
"""
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from typing import Any, TypedDict
from typing import Any, TypedDict, cast
from langchain_community.document_loaders import (
TextLoader, # type: ignore[import-untyped]
)
from langchain_core.documents import Document
from langchain_core.language_models import BaseLanguageModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate # type: ignore[attr-defined]
from langchain_community.document_loaders import TextLoader # type: ignore[import-untyped]
from langgraph.graph import END, StateGraph # type: ignore[import-untyped]
from langchain_core.runnables import RunnableSequence
from langgraph.checkpoint.memory import MemorySaver # type: ignore[import-untyped]
from cleveragents.domain.models.core import Context, ContextType
from langgraph.graph import END, StateGraph # type: ignore[import-untyped]
class ContextAnalysisState(TypedDict):
@@ -97,41 +99,40 @@ class ContextAnalysisAgent:
def _create_prompts(self) -> None:
"""Create prompt templates for each workflow node."""
self.dependency_prompt = PromptTemplate(
template="""Analyze the following code and extract all imports and dependencies.
List them in a structured format.
Code:
{code}
Extracted Dependencies:""",
self.dependency_prompt: Any = PromptTemplate(
template=(
"Analyze the following code and extract all imports and dependencies.\n"
"List them in a structured format.\n\n"
"Code:\n"
"{code}\n\n"
"Extracted Dependencies:"
),
input_variables=["code"],
)
self.relevance_prompt = PromptTemplate(
template="""Score the relevance of this code file for the given task.
Provide a score from 0.0 to 1.0 and a brief explanation.
Task: {task}
File: {file_name}
Code Preview:
{code_preview}
Relevance Score and Explanation:""",
self.relevance_prompt: Any = PromptTemplate(
template=(
"Score the relevance of this code file for the given task.\n"
"Provide a score from 0.0 to 1.0 and a brief explanation.\n\n"
"Task: {task}\n"
"File: {file_name}\n"
"Code Preview:\n"
"{code_preview}\n\n"
"Relevance Score and Explanation:"
),
input_variables=["task", "file_name", "code_preview"],
)
self.summary_prompt = PromptTemplate(
template="""Provide a high-level summary of the following codebase context.
Files analyzed: {file_count}
Total size: {total_size} characters
Dependencies found: {dependency_count}
Key files:
{key_files}
Summary:""",
self.summary_prompt: Any = PromptTemplate(
template=(
"Provide a high-level summary of the following codebase context.\n\n"
"Files analyzed: {file_count}\n"
"Total size: {total_size} characters\n"
"Dependencies found: {dependency_count}\n\n"
"Key files:\n"
"{key_files}\n\n"
"Summary:"
),
input_variables=[
"file_count",
"total_size",
@@ -170,8 +171,8 @@ Summary:""",
Returns:
Updated state with loaded documents
"""
documents = []
errors = []
documents: list[Document] = []
errors: list[str] = []
for file_path in state["file_paths"]:
try:
@@ -184,16 +185,13 @@ Summary:""",
errors.append(f"Not a file: {file_path}")
continue
# Load the file
loader = TextLoader(str(path))
docs = loader.load()
documents.extend(docs)
except Exception as e:
errors.append(f"Error loading {file_path}: {str(e)}")
loaded_docs: list[Document] = loader.load()
documents.extend(loaded_docs)
except Exception as exc: # pragma: no cover - defensive
errors.append(f"Error loading {file_path}: {exc!s}")
error_msg = None
if errors:
error_msg = "; ".join(errors)
error_msg: str | None = "; ".join(errors) if errors else None
return {
"documents": documents,
@@ -201,53 +199,41 @@ Summary:""",
}
def _analyze_dependencies(self, state: ContextAnalysisState) -> dict[str, Any]:
"""Analyze dependencies and imports in the loaded files.
Args:
state: Current workflow state
Returns:
Updated state with dependency information
"""
"""Analyze dependencies and imports in the loaded files."""
dependencies: dict[str, list[str]] = {}
chain = cast(
RunnableSequence[dict[str, Any], str],
self.dependency_prompt | self.llm | StrOutputParser(),
)
for doc in state["documents"]:
file_path = doc.metadata.get("source", "unknown")
for doc_value in state["documents"]:
doc = cast(Any, doc_value)
metadata = cast(dict[str, Any], getattr(doc, "metadata", {}))
file_path = str(metadata.get("source", "unknown"))
try:
# Use LLM to extract dependencies
chain = self.dependency_prompt | self.llm | StrOutputParser()
result = chain.invoke(
{"code": doc.page_content[:1000]}
) # First 1000 chars
# Parse the result (simplified for now)
deps = self._parse_dependencies(result)
dependencies[file_path] = deps
except Exception as e:
snippet = doc.page_content[:1000]
result = chain.invoke({"code": snippet})
dependencies[file_path] = self._parse_dependencies(result)
except Exception as exc: # pragma: no cover - defensive
dependencies[file_path] = []
error = state.get("error", "")
new_error = f"Dependency analysis error for {file_path}: {str(e)}"
error = f"{error}; {new_error}" if error else new_error
return {"dependencies": dependencies, "error": error}
new_error = f"Dependency analysis error for {file_path}: {exc!s}"
current_error = state.get("error")
combined_error = (
f"{current_error}; {new_error}"
if isinstance(current_error, str) and current_error
else new_error
)
return {"dependencies": dependencies, "error": combined_error}
return {"dependencies": dependencies}
def _parse_dependencies(self, llm_output: str) -> list[str]:
"""Parse dependencies from LLM output.
Args:
llm_output: Raw output from LLM
Returns:
List of dependency names
"""
# Simple parsing - extract anything that looks like a module name
deps = []
"""Parse dependencies from LLM output."""
deps: list[str] = []
for line in llm_output.split("\n"):
line = line.strip()
if line and not line.startswith("#"):
# Extract quoted strings or identifiers
parts = (
line.replace("[", "")
.replace("]", "")
@@ -255,137 +241,117 @@ Summary:""",
.replace('"', "")
.split(",")
)
deps.extend(p.strip() for p in parts if p.strip())
return deps[:10] # Limit to 10 dependencies
deps.extend(part.strip() for part in parts if part.strip())
return deps[:10]
def _chunk_documents(self, state: ContextAnalysisState) -> dict[str, Any]:
"""Chunk large documents for processing.
Args:
state: Current workflow state
Returns:
Updated state with chunked documents
"""
chunks = []
"""Chunk large documents for processing."""
chunks: list[Document] = []
for doc in state["documents"]:
content = doc.page_content
metadata = doc.metadata
metadata_source = cast(
dict[str, Any] | None, getattr(doc, "metadata", None)
)
metadata: dict[str, Any] = metadata_source or {}
if len(content) <= self.chunk_size:
# Document is small enough, keep as-is
chunks.append(doc)
else:
# Split into overlapping chunks
for i in range(0, len(content), self.chunk_size - self.chunk_overlap):
chunk_content = content[i : i + self.chunk_size]
chunk_metadata = {
**metadata,
"chunk_index": i // (self.chunk_size - self.chunk_overlap),
}
chunks.append(
Document(page_content=chunk_content, metadata=chunk_metadata)
)
continue
step = max(1, self.chunk_size - self.chunk_overlap)
for index, start in enumerate(range(0, len(content), step)):
chunk_content = content[start : start + self.chunk_size]
chunk_metadata: dict[str, Any] = {
**metadata,
"chunk_index": index,
}
chunks.append(
Document(page_content=chunk_content, metadata=chunk_metadata)
)
return {"chunks": chunks}
def _score_relevance(self, state: ContextAnalysisState) -> dict[str, Any]:
"""Score relevance of each file for the analysis task.
Args:
state: Current workflow state
Returns:
Updated state with relevance scores
"""
"""Score relevance of each file for the analysis task."""
relevance_scores: dict[str, float] = {}
files_seen: set[str] = set()
chain = cast(
RunnableSequence[dict[str, Any], str],
self.relevance_prompt | self.llm | StrOutputParser(),
)
# Get unique files from chunks
files_seen = set()
for chunk in state["chunks"]:
file_path = chunk.metadata.get("source", "unknown")
for chunk_value in state["chunks"]:
chunk = cast(Any, chunk_value)
metadata = cast(dict[str, Any], getattr(chunk, "metadata", {}))
file_path = str(metadata.get("source", "unknown"))
if file_path in files_seen:
continue
files_seen.add(file_path)
try:
# Use LLM to score relevance
chain = self.relevance_prompt | self.llm | StrOutputParser()
result = chain.invoke(
{
"task": "code analysis",
"file_name": file_path,
"code_preview": chunk.page_content[:500],
"code_preview": str(chunk.page_content)[:500],
}
)
# Parse score from result (simplified)
score = self._parse_relevance_score(result)
relevance_scores[file_path] = score
except Exception as e:
relevance_scores[file_path] = 0.5 # Default medium relevance
error = state.get("error", "")
new_error = f"Relevance scoring error for {file_path}: {str(e)}"
error = f"{error}; {new_error}" if error else new_error
return {"relevance_scores": relevance_scores, "error": error}
relevance_scores[file_path] = self._parse_relevance_score(result)
except Exception as exc: # pragma: no cover - defensive
relevance_scores[file_path] = 0.5
current_error = state.get("error")
new_error = f"Relevance scoring error for {file_path}: {exc!s}"
combined_error = (
f"{current_error}; {new_error}"
if isinstance(current_error, str) and current_error
else new_error
)
return {
"relevance_scores": relevance_scores,
"error": combined_error,
}
return {"relevance_scores": relevance_scores}
def _parse_relevance_score(self, llm_output: str) -> float:
"""Parse relevance score from LLM output.
Args:
llm_output: Raw output from LLM
Returns:
Relevance score between 0.0 and 1.0
"""
# Look for numbers in the output
"""Parse relevance score from LLM output."""
import re
matches = re.findall(r"0?\.\d+|1\.0|[01]", llm_output.lower())
if matches:
try:
score = float(matches[0])
return max(0.0, min(1.0, score)) # Clamp to [0, 1]
except ValueError:
return max(0.0, min(1.0, score))
except ValueError: # pragma: no cover - defensive
pass
# Default to medium relevance if parsing fails
if "high" in llm_output.lower():
return 0.8
elif "low" in llm_output.lower():
if "low" in llm_output.lower():
return 0.3
return 0.5
def _summarize_context(self, state: ContextAnalysisState) -> dict[str, Any]:
"""Create a high-level summary of the analyzed context.
Args:
state: Current workflow state
Returns:
Updated state with context summary
"""
"""Create a high-level summary of the analyzed context."""
try:
# Calculate statistics
file_count = len(state["documents"])
total_size = sum(len(doc.page_content) for doc in state["documents"])
dependency_count = sum(len(deps) for deps in state["dependencies"].values())
# Get top 3 files by relevance
sorted_files = sorted(
sorted_files: list[tuple[str, float]] = sorted(
state["relevance_scores"].items(),
key=lambda x: x[1],
key=lambda item: item[1],
reverse=True,
)[:3]
key_files = "\n".join(
f"- {path} (score: {score:.2f})" for path, score in sorted_files
)
# Generate summary using LLM
chain = self.summary_prompt | self.llm | StrOutputParser()
chain = cast(
RunnableSequence[dict[str, Any], str],
self.summary_prompt | self.llm | StrOutputParser(),
)
summary = chain.invoke(
{
"file_count": file_count,
@@ -396,72 +362,53 @@ Summary:""",
)
return {"summary": summary}
except Exception as e:
error = state.get("error", "")
new_error = f"Summarization error: {str(e)}"
error = f"{error}; {new_error}" if error else new_error
return {"summary": "Context analysis failed", "error": error}
except Exception as exc: # pragma: no cover - defensive
current_error = state.get("error")
new_error = f"Summarization error: {exc!s}"
combined_error = (
f"{current_error}; {new_error}"
if isinstance(current_error, str) and current_error
else new_error
)
return {"summary": "Context analysis failed", "error": combined_error}
def invoke(
self, input_state: ContextAnalysisState, config: dict[str, Any] | None = None
) -> ContextAnalysisState:
"""Synchronously execute the context analysis workflow.
Args:
input_state: Initial workflow state
config: Optional configuration for the execution
Returns:
Final workflow state
"""
"""Synchronously execute the context analysis workflow."""
config = config or {}
result = self.app.invoke(input_state, config)
return result # type: ignore[return-value]
result = self.app.invoke(cast(dict[str, Any], input_state), config)
return cast(ContextAnalysisState, result)
async def ainvoke(
self, input_state: ContextAnalysisState, config: dict[str, Any] | None = None
) -> ContextAnalysisState:
"""Asynchronously execute the context analysis workflow.
Args:
input_state: Initial workflow state
config: Optional configuration for the execution
Returns:
Final workflow state
"""
"""Asynchronously execute the context analysis workflow."""
config = config or {}
result = await self.app.ainvoke(input_state, config)
return result # type: ignore[return-value]
result = await self.app.ainvoke(cast(dict[str, Any], input_state), config)
return cast(ContextAnalysisState, result)
def stream(
self, input_state: ContextAnalysisState, config: dict[str, Any] | None = None
):
"""Stream the context analysis workflow execution.
Args:
input_state: Initial workflow state
config: Optional configuration for the execution
Yields:
State updates from each node
"""
) -> Iterator[dict[str, Any]]:
"""Stream the context analysis workflow execution."""
config = config or {}
for event in self.app.stream(input_state, config):
yield event
app_obj = cast(Any, self.app)
stream_iter = cast(
Iterator[dict[str, Any]],
app_obj.stream(cast(dict[str, Any], input_state), config),
)
yield from stream_iter
async def astream(
self, input_state: ContextAnalysisState, config: dict[str, Any] | None = None
):
"""Asynchronously stream the context analysis workflow execution.
Args:
input_state: Initial workflow state
config: Optional configuration for the execution
Yields:
State updates from each node
"""
) -> AsyncIterator[dict[str, Any]]:
"""Asynchronously stream the context analysis workflow execution."""
config = config or {}
async for event in self.app.astream(input_state, config):
app_obj = cast(Any, self.app)
astream_iter = cast(
AsyncIterator[dict[str, Any]],
app_obj.astream(cast(dict[str, Any], input_state), config),
)
async for event in astream_iter:
yield event
+43 -49
View File
@@ -6,13 +6,14 @@ LangGraph's StateGraph. The workflow includes context loading, requirement
analysis, plan generation, and validation with retry logic.
"""
from collections.abc import Iterator
from typing import Any, TypedDict
from langchain_core.language_models import BaseLanguageModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate # type: ignore[attr-defined]
from langgraph.graph import END, StateGraph # type: ignore[import-untyped]
from langgraph.checkpoint.memory import MemorySaver # type: ignore[import-untyped]
from langgraph.graph import END, StateGraph # type: ignore[import-untyped]
from cleveragents.domain.models.core import (
Change,
@@ -105,58 +106,52 @@ class PlanGenerationGraph:
# Requirements analysis prompt
self.analyze_prompt: Any = PromptTemplate(
input_variables=["prompt", "context_summary"],
template="""You are a software requirements analyst. Analyze the user's request and identify specific technical requirements.
Analyze this request and provide structured requirements:
Request: {prompt}
Context Files:
{context_summary}
Provide:
1. Key requirements
2. Files to modify or create
3. Dependencies needed
4. Potential challenges
""",
template=(
"You are a software requirements analyst. Analyze the user's request "
"and identify specific technical requirements.\n\n"
"Analyze this request and provide structured requirements:\n\n"
"Request: {prompt}\n\n"
"Context Files:\n"
"{context_summary}\n\n"
"Provide:\n"
"1. Key requirements\n"
"2. Files to modify or create\n"
"3. Dependencies needed\n"
"4. Potential challenges\n"
),
)
# Code generation prompt
self.generate_prompt: Any = PromptTemplate(
input_variables=["requirements", "context_summary"],
template="""You are an expert code generator. Generate high-quality, well-documented code based on requirements.
Generate code for the following requirements:
Requirements:
{requirements}
Context Files:
{context_summary}
Generate clean, well-documented code that follows best practices.
""",
template=(
"You are an expert code generator. Generate high-quality, "
"well-documented code based on requirements.\n\n"
"Generate code for the following requirements:\n\n"
"Requirements:\n"
"{requirements}\n\n"
"Context Files:\n"
"{context_summary}\n\n"
"Generate clean, well-documented code that follows best practices.\n"
),
)
# Validation prompt
self.validate_prompt: Any = PromptTemplate(
input_variables=["generated_code"],
template="""You are a code reviewer. Validate generated code for quality, correctness, and best practices.
Review this generated code:
{generated_code}
Check for:
1. Syntax correctness
2. Logic errors
3. Best practices
4. Security issues
5. Performance concerns
Provide validation result (PASS/FAIL) and any issues found.
""",
template=(
"You are a code reviewer. Validate generated code for quality, "
"correctness, and best practices.\n\n"
"Review this generated code:\n\n"
"{generated_code}\n\n"
"Check for:\n"
"1. Syntax correctness\n"
"2. Logic errors\n"
"3. Best practices\n"
"4. Security issues\n"
"5. Performance concerns\n\n"
"Provide validation result (PASS/FAIL) and any issues found.\n"
),
)
def _build_graph(self) -> StateGraph:
@@ -250,7 +245,7 @@ Provide validation result (PASS/FAIL) and any issues found.
except Exception as e:
return {
"analyzed_requirements": {},
"error": f"Requirements analysis failed: {str(e)}",
"error": f"Requirements analysis failed: {e!s}",
}
def _generate_plan(self, state: PlanGenerationState) -> dict[str, Any]:
@@ -329,7 +324,7 @@ Provide validation result (PASS/FAIL) and any issues found.
except Exception as e:
return {
"generated_changes": [],
"error": f"Code generation failed: {str(e)}",
"error": f"Code generation failed: {e!s}",
}
def _validate(self, state: PlanGenerationState) -> dict[str, Any]:
@@ -381,7 +376,7 @@ Provide validation result (PASS/FAIL) and any issues found.
return {
"validation_result": {
"status": "FAIL",
"message": f"Validation failed: {str(e)}",
"message": f"Validation failed: {e!s}",
},
}
@@ -503,7 +498,7 @@ Provide validation result (PASS/FAIL) and any issues found.
plan: Plan,
contexts: list[Context],
thread_id: str = "default",
):
) -> Iterator[dict[str, Any]]:
"""Stream the plan generation workflow execution.
Args:
@@ -529,5 +524,4 @@ Provide validation result (PASS/FAIL) and any issues found.
config = {"configurable": {"thread_id": thread_id}}
for event in self.app.stream(initial_state, config):
yield event
yield from self.app.stream(initial_state, config)
@@ -8,17 +8,25 @@ history with support for multiple storage backends.
"""
from collections.abc import Callable, Sequence
from typing import Any, Optional
from typing import Any, cast
from langchain_community.chat_message_histories import SQLChatMessageHistory
from langchain_core.chat_history import (
BaseChatMessageHistory,
InMemoryChatMessageHistory,
get_buffer_string,
)
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
def _buffer_to_string(messages: Sequence[BaseMessage]) -> str:
"""Convert a sequence of messages into a printable buffer string."""
parts: list[str] = []
for message in messages:
role = "AI" if isinstance(message, AIMessage) else "Human"
parts.append(f"{role}: {message.content!s}")
return "\n".join(parts)
class ConversationBufferMemoryAdapter:
"""Lightweight adapter that mirrors LangChain's ConversationBufferMemory.
@@ -55,11 +63,11 @@ class ConversationBufferMemoryAdapter:
) -> dict[str, Any]:
"""Return the current memory payload for prompt formatting."""
messages = self._message_history.messages
messages = list(self._message_history.messages)
if self.return_messages:
value: Any = messages
else:
value = get_buffer_string(messages)
value = _buffer_to_string(messages)
return {self.memory_key: value}
async def aload_memory_variables(
@@ -72,33 +80,31 @@ class ConversationBufferMemoryAdapter:
def save_context(self, inputs: dict[str, Any], outputs: dict[str, Any]) -> None:
"""Persist a complete interaction into the underlying history."""
def _store_user(value: Any) -> None:
def _store_user(value: BaseMessage | Sequence[Any] | str | None) -> None:
if value is None:
return
if isinstance(value, BaseMessage):
self._message_history.add_message(value)
return
if isinstance(value, Sequence) and not isinstance(
value, (str, bytes, bytearray)
):
for item in value:
_store_user(item)
if isinstance(value, (str, bytes, bytearray)):
self._message_history.add_user_message(str(value))
return
self._message_history.add_user_message(str(value))
assert isinstance(value, Sequence)
for item in value:
_store_user(item)
def _store_ai(value: Any) -> None:
def _store_ai(value: BaseMessage | Sequence[Any] | str | None) -> None:
if value is None:
return
if isinstance(value, BaseMessage):
self._message_history.add_message(value)
return
if isinstance(value, Sequence) and not isinstance(
value, (str, bytes, bytearray)
):
for item in value:
_store_ai(item)
if isinstance(value, (str, bytes, bytearray)):
self._message_history.add_ai_message(str(value))
return
self._message_history.add_ai_message(str(value))
assert isinstance(value, Sequence)
for item in value:
_store_ai(item)
_store_user(inputs.get(self.input_key))
_store_ai(outputs.get(self.output_key))
@@ -138,8 +144,8 @@ class MemoryService:
def __init__(
self,
session_id: str,
connection_string: Optional[str] = None,
max_messages: Optional[int] = None,
connection_string: str | None = None,
max_messages: int | None = None,
):
"""Initialize memory service.
@@ -153,17 +159,21 @@ class MemoryService:
self.max_messages = max_messages
# Initialize message history
self.message_history: BaseChatMessageHistory
history: BaseChatMessageHistory
if connection_string:
# Use SQL persistence if connection string provided
self.message_history = SQLChatMessageHistory(
session_id=session_id,
connection_string=connection_string,
table_name="message_history",
history = cast(
BaseChatMessageHistory,
SQLChatMessageHistory(
session_id=session_id,
connection_string=connection_string,
table_name="message_history",
),
)
else:
# Use in-memory storage as fallback
self.message_history = InMemoryChatMessageHistory()
history = InMemoryChatMessageHistory()
self.message_history = history
# Default conversation-memory adapter mirroring LangChain's API.
self._conversation_memory = ConversationBufferMemoryAdapter(
@@ -247,7 +257,7 @@ class MemoryService:
return ""
# Simple summarization: concatenate recent messages
summary_parts = []
summary_parts: list[str] = []
char_count = 0
for msg in reversed(messages):
@@ -323,11 +333,10 @@ class MemoryService:
if self.max_messages is None:
return
messages = self.message_history.messages
messages = list(self.message_history.messages)
if len(messages) > self.max_messages:
# Remove oldest messages
messages_to_remove = len(messages) - self.max_messages
# Clear and re-add messages (not all implementations support deletion)
remaining_messages = messages[messages_to_remove:]
self.message_history.clear()
for msg in remaining_messages:
@@ -10,7 +10,7 @@ from __future__ import annotations
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
from langchain_core.language_models import BaseLanguageModel
@@ -46,7 +46,7 @@ class PlanService:
settings: Settings,
unit_of_work: UnitOfWork,
ai_provider: AIProviderInterface | None = None,
llm: Optional[BaseLanguageModel] = None,
llm: BaseLanguageModel | None = None,
):
"""Initialize the plan service.
@@ -113,7 +113,7 @@ class PlanService:
output_key: str = "output",
return_messages: bool = True,
max_messages: int | None = None,
) -> "ConversationBufferMemoryAdapter":
) -> ConversationBufferMemoryAdapter:
"""Return a conversation buffer memory adapter for the session."""
service = self.get_memory_service(