Chore: Got code coverage up to 98%
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user