Files
CoreRasurae cdbe504b2c
CI / push-validation (pull_request) Successful in 36s
CI / helm (pull_request) Successful in 44s
CI / build (pull_request) Successful in 48s
CI / lint (pull_request) Successful in 1m10s
CI / typecheck (pull_request) Successful in 1m21s
CI / security (pull_request) Successful in 1m20s
CI / quality (pull_request) Successful in 1m26s
CI / integration_tests (pull_request) Successful in 7m53s
CI / unit_tests (pull_request) Failing after 9m56s
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / status-check (pull_request) Failing after 4s
fix(test): use shared in-memory SQLite URI in _ensure_memory_database_url
Replace sqlite:///:memory: with sqlite:///file::memory:?cache=shared to
ensure all SQLAlchemy connections within the same process share a single
in-memory database. This prevents isolation issues where SQLChatMessageHistory
and EntityStore would each create separate in-memory databases, avoiding
unexpected errors in CI parallel execution.
2026-05-23 12:20:11 +01:00

3546 lines
126 KiB
Python

"""Step definitions for plan service coverage tests."""
import asyncio
import contextlib
import os
import tempfile
from collections.abc import Callable, Iterator
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
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.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 (
Actor,
Change,
OperationType,
Plan,
PlanStatus,
Project,
ProjectSettings,
)
from cleveragents.domain.models.core import (
Context as PlanContext,
)
from cleveragents.domain.providers.ai_provider import (
ActorInvocationContext,
ProviderResponse,
)
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
from cleveragents.providers.registry import ProviderType
class _NoProviderRegistry:
"""Stub provider registry that always raises."""
def create_ai_provider(self, *args: Any, **kwargs: Any) -> None:
raise ValueError("No AI provider configured")
@given("I have a temporary test directory for plan service")
def step_create_temp_dir_plan_service(context: Context) -> None:
"""Create a temporary directory for testing."""
context.temp_dir = Path(tempfile.mkdtemp())
@given("I have a Unit of Work instance for plan testing")
def step_create_unit_of_work_plan(context: Context) -> None:
"""Create a Unit of Work instance for testing."""
from cleveragents.infrastructure.database.engine_cache import MEMORY_ENGINES
from cleveragents.infrastructure.database.models import Base
# Create a unique in-memory database URL for this test
database_url = "sqlite:///:memory:"
# Create a shared engine for in-memory database
if database_url not in MEMORY_ENGINES:
engine = create_engine(database_url, connect_args={"check_same_thread": False})
MEMORY_ENGINES[database_url] = engine
else:
engine = MEMORY_ENGINES[database_url]
# Create schema directly — much faster than 25 Alembic migrations
Base.metadata.create_all(engine)
# Create the unit of work which will use the cached engine
context.unit_of_work = UnitOfWork(database_url=database_url)
@given("I have a PlanService instance")
def step_create_plan_service(context: Context) -> None:
"""Create a PlanService instance."""
os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
settings = Settings()
context.plan_service = PlanService(
settings=settings, unit_of_work=context.unit_of_work, ai_provider=None
)
@given("I have a lightweight plan service for actor testing")
def step_create_lightweight_plan_service(context: Context) -> None:
"""Create a lightweight PlanService using in-memory DB for actor resolution tests.
This avoids the overhead of file-based DB creation, temp directories, and
project initialization — none of which are needed for testing actor/provider
resolution logic.
"""
if not hasattr(context, "unit_of_work"):
step_create_unit_of_work_plan(context)
if not hasattr(context, "temp_dir"):
context.temp_dir = Path(tempfile.mkdtemp(prefix="plan_actor_"))
settings = Settings()
context.plan_service = PlanService(
settings=settings, unit_of_work=context.unit_of_work, ai_provider=None
)
@given("I have a plan service with stub provider registry")
def step_plan_service_with_stub_registry(context: Context) -> None:
"""Create a PlanService that uses a stub provider registry for overrides."""
os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
if not hasattr(context, "unit_of_work"):
step_create_unit_of_work_plan(context)
if not hasattr(context, "temp_dir"):
context.temp_dir = Path(tempfile.mkdtemp(prefix="plan_service_stub_"))
settings = Settings()
context.stub_provider_requests = cast(list[dict[str, Any]], [])
class _StubProvider:
def __init__(self, provider_name: str, model_name: str) -> None:
self.name = provider_name
self.model_id = model_name
def generate_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> ProviderResponse:
change = Change(
id=None,
plan_id=plan.id or 0,
file_path="stub/generated_file.py",
operation=OperationType.CREATE,
original_content=None,
new_content="# generated by stub\n",
new_path=None,
applied=False,
created_at=datetime.now(),
applied_at=None,
)
return ProviderResponse(
changes=[change],
model_used=self.model_id,
token_count=42,
error_message=None,
)
class _StubRegistry:
def create_ai_provider(
self,
provider_type: str | ProviderType | None = None,
model_id: str | None = None,
max_retries: int = 3,
) -> _StubProvider:
if isinstance(provider_type, ProviderType):
provider_name = provider_type.value
else:
provider_name = (provider_type or "openai").lower()
model_name = model_id or "gpt-4o"
context.stub_provider_requests.append(
{"provider": provider_name, "model": model_name}
)
return _StubProvider(provider_name, model_name)
context.stub_registry = _StubRegistry()
context.plan_service = PlanService(
settings=settings,
unit_of_work=context.unit_of_work,
ai_provider=None,
provider_registry=context.stub_registry,
)
project = Project(
id=None,
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-gpt",
),
)
with context.unit_of_work.transaction() as ctx:
context.project = ctx.projects.create(project)
context.test_project = context.project
@given("I have a plan service with a streaming stub provider")
def step_plan_service_streaming_stub(context: Context) -> None:
"""Create a PlanService that streams provider events for testing."""
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
if not hasattr(context, "unit_of_work"):
step_create_unit_of_work_plan(context)
if not hasattr(context, "temp_dir"):
context.temp_dir = Path(tempfile.mkdtemp(prefix="plan_service_streaming_"))
settings = Settings()
class _StreamingStubProvider:
def __init__(self) -> None:
self.name = "stub-stream"
self.model_id = "stub-model"
self.token_count = 321
def _build_change(self, plan: Plan) -> Change:
return Change(
id=None,
plan_id=plan.id or 0,
file_path="stream/generated_file.py",
operation=OperationType.CREATE,
original_content=None,
new_content="# streamed change\n",
new_path=None,
applied=False,
created_at=datetime.now(),
applied_at=None,
)
def generate_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> ProviderResponse:
change = self._build_change(plan)
return ProviderResponse(
changes=[change],
model_used=self.model_id,
token_count=self.token_count,
error_message=None,
)
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[dict[str, object]]:
response = self.generate_changes(
project,
plan,
contexts,
actor_context=actor_context,
progress_callback=progress_callback,
)
for node in (
"load_context",
"analyze_requirements",
"generate_plan",
"validate",
):
payload: dict[str, object] = {}
if node == "generate_plan":
payload = {
"status": "completed",
"change_count": len(response.changes),
}
elif node == "validate":
payload = {"status": "completed"}
yield {node: payload}
yield {"__end__": {"response": response}}
context.streaming_stub_provider = _StreamingStubProvider()
context.plan_service = PlanService(
settings=settings,
unit_of_work=context.unit_of_work,
ai_provider=context.streaming_stub_provider,
)
project = Project(
id=None,
name="streaming-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-stream",
),
)
with context.unit_of_work.transaction() as ctx:
context.test_project = ctx.projects.create(project)
def _setup_custom_streaming_plan_service(
context: Context,
provider_factory: Callable[[], object],
*,
project_name: str,
) -> None:
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
if not hasattr(context, "unit_of_work"):
step_create_unit_of_work_plan(context)
if not hasattr(context, "temp_dir"):
context.temp_dir = Path(
tempfile.mkdtemp(prefix="plan_service_streaming_custom_")
)
settings = Settings()
provider = provider_factory()
context.plan_service = PlanService(
settings=settings,
unit_of_work=context.unit_of_work,
ai_provider=provider,
)
project = Project(
id=None,
name=project_name,
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=getattr(provider, "model_id", "custom-stream"),
),
)
with context.unit_of_work.transaction() as ctx:
context.test_project = ctx.projects.create(project)
@given("I have a plan service with an incomplete streaming provider")
def step_streaming_provider_incomplete(context: Context) -> None:
class _IncompleteStreamProvider:
def __init__(self) -> None:
self.name = "incomplete-stream"
self.model_id = "stub-incomplete"
def generate_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> ProviderResponse:
return ProviderResponse(
changes=[],
model_used=self.model_id,
token_count=0,
error_message=None,
)
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[object]:
yield "corrupted-event"
_setup_custom_streaming_plan_service(
context,
lambda: _IncompleteStreamProvider(),
project_name="incomplete-streaming-project",
)
@given("I have a plan service with a dict streaming provider")
def step_streaming_provider_dict(context: Context) -> None:
class _DictPayloadStreamProvider:
def __init__(self) -> None:
self.name = "dict-stream"
self.model_id = "dict-model"
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[dict[str, object]]:
yield {"generate_plan": {"status": "running"}}
payload = {
"changes": [
{
"plan_id": plan.id or 0,
"file_path": "dict_payload.py",
"operation": "create",
"original_content": None,
"new_content": "print('dict payload')",
"applied": False,
}
],
"model_used": self.model_id,
"token_count": "not-a-number",
}
yield {"__end__": payload}
_setup_custom_streaming_plan_service(
context,
lambda: _DictPayloadStreamProvider(),
project_name="dict-streaming-project",
)
@given("I have a plan service with a string error streaming provider")
def step_streaming_provider_string_error(context: Context) -> None:
class _StringErrorStreamProvider:
def __init__(self) -> None:
self.name = "string-error-stream"
self.model_id = "string-model"
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[dict[str, object]]:
yield {"__end__": "stub failure"}
_setup_custom_streaming_plan_service(
context,
lambda: _StringErrorStreamProvider(),
project_name="string-error-project",
)
@given("I have a plan service with a non-list change streaming provider")
def step_streaming_provider_non_list(context: Context) -> None:
class _NonListChangeStreamProvider:
def __init__(self) -> None:
self.name = "non-list-stream"
self.model_id = "non-list-model"
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[dict[str, object]]:
payload = {
"changes": {"file_path": "bogus.py"},
"model_used": self.model_id,
"token_count": 1,
}
yield {"__end__": payload}
_setup_custom_streaming_plan_service(
context,
lambda: _NonListChangeStreamProvider(),
project_name="non-list-stream-project",
)
@given("I have a plan service with an invalid change entry streaming provider")
def step_streaming_provider_invalid_entry(context: Context) -> None:
class _InvalidEntryStreamProvider:
def __init__(self) -> None:
self.name = "invalid-entry-stream"
self.model_id = "invalid-entry-model"
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[dict[str, object]]:
payload = {
"changes": [123],
"model_used": self.model_id,
"token_count": 1,
}
yield {"__end__": payload}
_setup_custom_streaming_plan_service(
context,
lambda: _InvalidEntryStreamProvider(),
project_name="invalid-entry-stream-project",
)
@given("I have a plan service with a non-python streaming provider")
def step_streaming_provider_non_python(context: Context) -> None:
class _NonPythonStreamProvider:
def __init__(self) -> None:
self.name = "non-python-stream"
self.model_id = "non-python-model"
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[dict[str, object]]:
change = Change(
id=None,
plan_id=plan.id or 0,
file_path="notes.txt",
operation=OperationType.CREATE,
original_content=None,
new_content="plain text change",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
response = ProviderResponse(
changes=[change],
model_used=self.model_id,
token_count=5,
error_message=None,
)
yield {"__end__": response}
_setup_custom_streaming_plan_service(
context,
lambda: _NonPythonStreamProvider(),
project_name="non-python-stream-project",
)
@given("I have a plan service with a syntax error streaming provider")
def step_streaming_provider_syntax_error(context: Context) -> None:
class _SyntaxErrorStreamProvider:
def __init__(self) -> None:
self.name = "syntax-error-stream"
self.model_id = "syntax-model"
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[dict[str, object]]:
change = Change(
id=None,
plan_id=plan.id or 0,
file_path="bad.py",
operation=OperationType.CREATE,
original_content=None,
new_content="def broken(",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
response = ProviderResponse(
changes=[change],
model_used=self.model_id,
token_count=0,
error_message=None,
)
yield {"__end__": response}
_setup_custom_streaming_plan_service(
context,
lambda: _SyntaxErrorStreamProvider(),
project_name="syntax-error-stream-project",
)
@given("mock provider mode is forced")
def step_force_mock_provider_mode(context: Context) -> None:
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
if not hasattr(context, "env_vars_to_clean"):
context.env_vars_to_clean = []
if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")
if hasattr(context, "plan_service"):
context.plan_service.ai_provider = None
context.provider_resolution = None
@given("the plan service uses a nameless AI provider")
def step_set_nameless_ai_provider(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
class _NamelessProvider:
pass
context.plan_service.ai_provider = _NamelessProvider()
@given("I stub the provider registry factory for lazy initialization")
def step_stub_provider_registry_lazy(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
# Ensure mock-provider forcing doesn't short-circuit registry creation
original_mock_flag = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "false"
# Force registry path by clearing any injected provider
context.plan_service.ai_provider = None
class _LazyProvider:
def __init__(self, provider_name: str, model_name: str) -> None:
self.name = provider_name
self.model_id = model_name
class _LazyRegistry:
def __init__(self) -> None:
self.requests: list[dict[str, str]] = []
def create_ai_provider(
self,
provider_type: str | ProviderType | None = None,
model_id: str | None = None,
max_retries: int = 3,
) -> _LazyProvider:
if isinstance(provider_type, ProviderType):
provider_name = provider_type.value
else:
provider_name = (provider_type or "openai").lower()
model_name = (model_id or "gpt-4o").lower()
self.requests.append({"provider": provider_name, "model": model_name})
return _LazyProvider(provider_name, model_name)
lazy_registry = _LazyRegistry()
patcher = patch(
"cleveragents.application.services.plan_service.get_provider_registry",
autospec=True,
return_value=lazy_registry,
)
patcher.start()
cleanup_handlers = getattr(context, "_cleanup_handlers", None)
if cleanup_handlers is None:
cleanup_handlers = []
context._cleanup_handlers = cleanup_handlers
cleanup_handlers.append(patcher.stop)
# Restore mock flag after scenario
cleanup_handlers.append(
lambda: (
os.environ.__setitem__(
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
)
if original_mock_flag is not None
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
)
)
context.lazy_registry = lazy_registry
context.plan_service._provider_registry = None
@then('the lazy registry should memoize provider "{provider}" and model "{model}"')
def step_assert_lazy_registry(context: Context, provider: str, model: str) -> None:
registry = getattr(context, "lazy_registry", None)
assert registry is not None, "Lazy registry stub was not configured"
expected_provider = provider.lower()
expected_model = model.lower()
assert registry.requests == [
{"provider": expected_provider, "model": expected_model}
], "Lazy registry did not capture the override request"
assert context.plan_service._provider_registry is registry
resolution = getattr(context, "provider_resolution", None)
assert resolution is not None, "Provider resolution result missing"
provider_instance, resolved_name, resolved_model = resolution[:3]
assert resolved_name == expected_provider
assert resolved_model == model
assert provider_instance.name == expected_provider
assert provider_instance.model_id == expected_model
@given("the plan service has a cached provider registry instance")
def step_cached_provider_registry(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
cached_registry = SimpleNamespace(name="cached-registry")
context.plan_service._provider_registry = cached_registry
context.cached_registry = cached_registry
@when("I fetch the provider registry from the plan service")
def step_fetch_cached_provider_registry(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
try:
context.registry_result = context.plan_service._get_provider_registry()
context.exception = None
except Exception as exc:
context.registry_result = None
context.exception = exc
@then("the cached provider registry should be reused")
def step_assert_cached_provider_registry(context: Context) -> None:
cached = getattr(context, "cached_registry", None)
result = getattr(context, "registry_result", None)
assert cached is not None, "Cached registry was not configured"
assert result is cached
assert getattr(context, "exception", None) is None
@given('the plan service actor lookup raises ValidationError "{message}"')
def step_actor_lookup_validation_error(context: Context, message: str) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
class _ActorServiceStub:
def get_actor(self, actor_name: str) -> None:
raise ValidationError(message)
def get_default_actor(self) -> None:
raise ValidationError(message)
def ensure_default_mock_actor(self) -> None:
raise ValidationError(message)
context.plan_service.actor_service = _ActorServiceStub()
@given("the plan service actor lookup returns no actor and mock provisioning fails")
def step_actor_lookup_returns_none(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
class _ActorServiceStub:
def get_actor(self, actor_name: str | None) -> None:
return None
def get_default_actor(self) -> None:
return None
def ensure_default_mock_actor(self) -> None:
raise RuntimeError("no mock actor available")
context.plan_service.actor_service = _ActorServiceStub()
@given(
'the plan service actor lookup returns actor "{actor_name}" with provider "{provider}" and model "{model}"'
)
def step_actor_lookup_returns_actor(
context: Context, actor_name: str, provider: str, model: str
) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
actor = Actor(
id=None,
name=actor_name,
provider=provider,
model=model,
config_blob={},
config_hash=Actor.compute_hash({}),
graph_descriptor=None,
unsafe=False,
is_built_in=False,
is_default=False,
)
class _ActorServiceStub:
def get_actor(self, name: str) -> Actor:
return actor
def get_default_actor(self) -> Actor:
return actor
def ensure_default_mock_actor(self) -> Actor:
return actor
context.plan_service.actor_service = _ActorServiceStub()
@given(
'the plan service actor lookup returns actor "{actor_name}" with provider "{provider}" and no model'
)
def step_actor_lookup_returns_actor_without_model(
context: Context, actor_name: str, provider: str
) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
actor = Actor(
id=None,
name=actor_name,
provider=provider,
model="placeholder",
config_blob={},
config_hash=Actor.compute_hash({}),
graph_descriptor=None,
unsafe=False,
is_built_in=False,
is_default=False,
)
object.__setattr__(actor, "model", None)
class _ActorServiceStub:
def get_actor(self, name: str) -> Actor:
return actor
def get_default_actor(self) -> Actor:
return actor
def ensure_default_mock_actor(self) -> Actor:
return actor
context.plan_service.actor_service = _ActorServiceStub()
@when('I try to resolve actor "{actor_name}"')
def step_try_resolve_actor(context: Context, actor_name: str) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
if actor_name == "ghost/actor":
context.plan_service.actor_service = None
try:
context.actor_resolution = context.plan_service._resolve_actor(actor_name)
context.exception = None
except Exception as exc:
context.actor_resolution = None
context.exception = exc
@then('the actor resolution should return actor "{actor_name}" from source "{source}"')
def step_assert_actor_resolution(
context: Context, actor_name: str, source: str
) -> None:
resolution = getattr(context, "actor_resolution", None)
assert resolution is not None, "Actor resolution result missing"
actor, actor_source = resolution
assert actor.name == actor_name
assert actor_source == source
@then('the PlanError should include actor detail "{actor_name}"')
def step_assert_plan_error_actor_detail(context: Context, actor_name: str) -> None:
exc = getattr(context, "exception", None)
assert isinstance(exc, PlanError), "Expected PlanError to be raised"
details = getattr(exc, "details", {}) or {}
assert details.get("actor", "") == actor_name
@given('the plan service AI provider is "{provider_name}" with model "{model_name}"')
def step_set_plan_service_ai_provider(
context: Context, provider_name: str, model_name: str
) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
class _Provider:
def __init__(self, name: str, model_id: str) -> None:
self.name = name
self.model_id = model_id
context.plan_service.ai_provider = _Provider(provider_name, model_name)
@given('the plan service AI provider has name "{provider_name}" without model id')
def step_set_plan_service_ai_provider_name_only(
context: Context, provider_name: str
) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
class _ProviderNameOnly:
def __init__(self, name: str) -> None:
self.name = name
context.plan_service.ai_provider = _ProviderNameOnly(provider_name)
@given("the plan service AI provider has no identifier attributes")
def step_set_anonymous_ai_provider(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
if not hasattr(context, "env_vars_to_clean"):
context.env_vars_to_clean = []
if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")
class _AnonymousProvider:
"""Provider stub without name/model_id attributes."""
pass
context.plan_service.ai_provider = _AnonymousProvider()
context.provider_resolution = None
@given("the plan service uses a delayed mock provider")
def step_set_delayed_mock_provider(context: Context) -> None:
if not hasattr(context, "unit_of_work"):
step_create_unit_of_work_plan(context)
settings = Settings()
class _DelayedProvider:
def __init__(self) -> None:
self.name = "delayed-provider"
self.model_id = "delayed-model"
class _DelayedPlanService(PlanService):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._provider_toggle = False
self._late_provider = kwargs.get("ai_provider") or _DelayedProvider()
@property
def ai_provider(self): # type: ignore[override]
if not getattr(self, "_provider_toggle", False):
self._provider_toggle = True
return None
return self._late_provider
@ai_provider.setter
def ai_provider(self, value): # type: ignore[override]
self._late_provider = value
def _use_mock_provider(self) -> bool:
return True
context.plan_service = _DelayedPlanService(
settings=settings, unit_of_work=context.unit_of_work, ai_provider=None
)
@given("the plan service actor provider resolution will fail")
def step_force_actor_provider_resolution_failure(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
context.plan_service.actor_service = SimpleNamespace(name="stub-actor-service")
patcher = patch.object(
context.plan_service,
"_resolve_ai_provider_for_actor",
side_effect=PlanError(message="actor resolution failed"),
)
patcher.start()
cleanup_handlers = getattr(context, "_cleanup_handlers", None)
if cleanup_handlers is None:
cleanup_handlers = []
context._cleanup_handlers = cleanup_handlers
cleanup_handlers.append(patcher.stop)
@when('I resolve the AI provider for actor "{actor_name}"')
def step_resolve_ai_provider_for_actor(context: Context, actor_name: str) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
try:
context.provider_resolution = (
context.plan_service._resolve_ai_provider_for_actor(actor_name)
)
context.exception = None
except Exception as exc:
context.provider_resolution = None
context.exception = exc
@then("the provider resolution should use fallback names")
def step_assert_provider_resolution_fallback(context: Context) -> None:
resolution = getattr(context, "provider_resolution", None)
assert resolution is not None, "Provider resolution result missing"
provider_instance, provider_name, model_name, actor, *_ = resolution
assert provider_name == "mock-provider"
actor_model = getattr(actor, "model", None)
expected_model = actor_model or provider_name
assert model_name == expected_model
assert not hasattr(provider_instance, "name")
assert not hasattr(provider_instance, "model_id")
@then(
'the nameless provider resolution should return provider "{provider}" and model "{model}"'
)
def step_assert_nameless_provider_resolution(
context: Context, provider: str, model: str
) -> None:
resolution = getattr(context, "provider_resolution", None)
assert resolution is not None, "Provider resolution result missing"
provider_instance, provider_name, model_name = resolution[:3]
assert provider_name == provider
assert model_name == model
assert not hasattr(provider_instance, "name")
assert not hasattr(provider_instance, "model_id")
@when(
'I resolve provider for actor "{actor_name}" with model override "{model_override}"'
)
def step_resolve_provider_with_override(
context: Context, actor_name: str, model_override: str
) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
try:
context.provider_resolution = (
context.plan_service._resolve_ai_provider_for_actor(
actor_name, model=model_override
)
)
context.exception = None
except Exception as exc:
context.provider_resolution = None
context.exception = exc
@when('I resolve provider for actor "{actor_name}"')
def step_resolve_provider_for_actor(context: Context, actor_name: str) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
try:
context.provider_resolution = (
context.plan_service._resolve_ai_provider_for_actor(actor_name)
)
context.exception = None
except Exception as exc:
context.provider_resolution = None
context.exception = exc
@when("I try to resolve a provider for the plan service")
def step_resolve_provider_for_plan_service(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
try:
context.provider_resolution = (
context.plan_service._resolve_ai_provider_for_actor(None)
)
context.exception = None
except Exception as exc:
context.provider_resolution = None
context.exception = exc
@then('the provider resolution should return provider "{provider}" and model "{model}"')
def step_assert_provider_resolution(
context: Context, provider: str, model: str
) -> None:
resolution = getattr(context, "provider_resolution", None)
assert resolution is not None, "Provider resolution result missing"
provider_instance, provider_name, model_name = resolution[:3]
assert provider_name == provider
assert model_name == model
assert getattr(provider_instance, "name", provider_name) == provider_name
assert getattr(provider_instance, "model_id", model_name) == model_name
@then('the lazy registry should record actor provider "{provider}" and model "{model}"')
def step_assert_lazy_registry_actor(
context: Context, provider: str, model: str
) -> None:
registry = getattr(context, "lazy_registry", None)
assert registry is not None, "Lazy registry stub was not configured"
expected = {"provider": provider.lower(), "model": model.lower()}
assert registry.requests == [expected], (
"Lazy registry did not capture the actor request"
)
resolution = getattr(context, "provider_resolution", None)
assert resolution is not None, "Provider resolution result missing"
provider_instance, provider_name, model_name, *_ = resolution
assert provider_name == expected["provider"]
assert model_name == model
assert getattr(provider_instance, "name", provider_name) == expected["provider"]
assert getattr(provider_instance, "model_id", model_name) == model
@given('the provider registry raises ValueError "{message}" for actor providers')
def step_provider_registry_raises_for_actor(context: Context, message: str) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
class _Registry:
def create_ai_provider(self, *args: Any, **kwargs: Any) -> None:
raise ValueError(message)
original_mock_flag = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "false"
cleanup_handlers = getattr(context, "_cleanup_handlers", None)
if cleanup_handlers is None:
cleanup_handlers = []
context._cleanup_handlers = cleanup_handlers
cleanup_handlers.append(
lambda: (
os.environ.__setitem__(
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
)
if original_mock_flag is not None
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
)
)
context.plan_service._provider_registry = _Registry()
context.plan_service.ai_provider = None
@given(
'the provider registry factory raises ValueError "{message}" for actor providers'
)
def step_provider_registry_factory_raises_for_actor(
context: Context, message: str
) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
class _Registry:
def create_ai_provider(self, *args: Any, **kwargs: Any) -> None:
raise ValueError(message)
original_mock_flag = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "false"
patcher = patch(
"cleveragents.application.services.plan_service.get_provider_registry",
autospec=True,
return_value=_Registry(),
)
patcher.start()
cleanup_handlers = getattr(context, "_cleanup_handlers", None)
if cleanup_handlers is None:
cleanup_handlers = []
context._cleanup_handlers = cleanup_handlers
cleanup_handlers.append(patcher.stop)
cleanup_handlers.append(
lambda: (
os.environ.__setitem__(
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
)
if original_mock_flag is not None
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
)
)
context.plan_service.ai_provider = None
context.plan_service._provider_registry = None
@given(
'the provider registry returns provider "{provider_name}" with model "{model_name}"'
)
def step_provider_registry_returns_provider(
context: Context, provider_name: str, model_name: str
) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
original_mock_flag = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "false"
cleanup_handlers = getattr(context, "_cleanup_handlers", None)
if cleanup_handlers is None:
cleanup_handlers = []
context._cleanup_handlers = cleanup_handlers
cleanup_handlers.append(
lambda: (
os.environ.__setitem__(
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
)
if original_mock_flag is not None
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
)
)
class _Provider:
def __init__(self, name: str, model_id: str) -> None:
self.name = name
self.model_id = model_id
class _Registry:
def __init__(self) -> None:
self.requests: list[dict[str, Any]] = []
def create_ai_provider(
self,
provider_type: str | ProviderType | None = None,
model_id: str | None = None,
max_retries: int = 3,
) -> _Provider:
self.requests.append({"provider": provider_type, "model": model_id})
return _Provider(provider_name, model_name)
registry = _Registry()
context.plan_service.ai_provider = None
context.plan_service._provider_registry = registry
context.provider_registry_requests = registry.requests
@given("I have a plan service with an unrecoverable streaming provider")
def step_plan_service_unrecoverable_stream(context: Context) -> None:
class _UnrecoverableStreamProvider:
def __init__(self) -> None:
self.name = "fatal-stream"
self.model_id = "fatal-model"
def generate_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> ProviderResponse:
change = Change(
id=None,
plan_id=plan.id or 0,
file_path="fatal.py",
operation=OperationType.CREATE,
original_content=None,
new_content="```python\nif True print('oops')\n```",
new_path=None,
applied=False,
created_at=datetime.now(),
applied_at=None,
)
return ProviderResponse(
changes=[change],
model_used=self.model_id,
token_count=0,
error_message=None,
)
def stream_changes(
self,
project: Project,
plan: Plan,
contexts: list[PlanContext],
actor_context: ActorInvocationContext | None = None,
progress_callback: Callable[[int], None] | None = None,
) -> Iterator[object]:
response = self.generate_changes(
project,
plan,
contexts,
actor_context=actor_context,
progress_callback=progress_callback,
)
yield {"generate_plan": {"status": "completed", "change_count": 1}}
yield {"__end__": {"response": response}}
_setup_custom_streaming_plan_service(
context,
lambda: _UnrecoverableStreamProvider(),
project_name="unrecoverable-stream-project",
)
patcher = patch(
"cleveragents.application.services.plan_service.ast.parse",
autospec=True,
side_effect=SyntaxError("invalid python"),
)
patcher.start()
cleanup_handlers = getattr(context, "_cleanup_handlers", None)
if cleanup_handlers is None:
cleanup_handlers = []
context._cleanup_handlers = cleanup_handlers
cleanup_handlers.append(patcher.stop)
@when('I stream plan generation with prompt "{prompt}"')
def step_stream_plan_generation(context: Context, prompt: str) -> None:
"""Run the streaming plan generation helper and capture events."""
async def _collect() -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
async for event in context.plan_service.generate_plan_streaming(
project=context.test_project,
description=prompt,
):
events.append(event)
return events
context.stream_events = asyncio.run(_collect())
context.partial_stream_events = context.stream_events
context.exception = None
@when('I try to stream plan generation with prompt "{prompt}"')
def step_try_stream_plan_generation(context: Context, prompt: str) -> None:
"""Capture streaming events even when the generator raises an error."""
async def _collect() -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
stream = context.plan_service.generate_plan_streaming(
project=context.test_project,
description=prompt,
)
try:
async for event in stream:
events.append(event)
except Exception:
context.partial_stream_events = events
raise
return events
try:
context.stream_events = asyncio.run(_collect())
context.partial_stream_events = context.stream_events
context.exception = None
except Exception as exc:
if not hasattr(context, "partial_stream_events"):
context.partial_stream_events = []
context.stream_events = context.partial_stream_events
context.exception = exc
@then('the streaming events should include nodes "{node_list}"')
def step_assert_stream_nodes(context: Context, node_list: str) -> None:
"""Verify that the streamed events contained the expected nodes."""
assert hasattr(context, "stream_events"), "Streaming events were not captured"
expected = [node.strip() for node in node_list.split(",") if node.strip()]
actual = [next(iter(event.keys())) for event in context.stream_events if event]
for node in expected:
assert node in actual, f"Expected streaming event '{node}' not found"
@then("the streamed plan should persist token count {expected:d}")
def step_assert_stream_token_count(context: Context, expected: int) -> None:
"""Ensure token counts from streaming providers are stored on the plan."""
with context.unit_of_work.transaction() as ctx:
plan = ctx.plans.get_current_for_project(context.test_project.id)
assert plan is not None, "Plan was not persisted"
assert plan.token_count == expected, (
f"Expected token count {expected}, got {plan.token_count}"
)
@then('the streaming failure events should include an error with message "{message}"')
def step_assert_stream_failure_event(context: Context, message: str) -> None:
"""Verify that streaming failures emit a descriptive error event."""
events = getattr(context, "partial_stream_events", [])
failure_payload: dict[str, Any] | None = None
for event in events:
if not isinstance(event, dict):
continue
payload = event.get("generate_plan")
if isinstance(payload, dict) and payload.get("status") == "failed":
failure_payload = payload
break
assert failure_payload is not None, "Expected a failure event in the stream"
assert failure_payload.get("error") == message, (
f"Expected failure message '{message}', got '{failure_payload.get('error')}'"
)
@then(
"the streaming result should include the non-python change without validation errors"
)
def step_assert_non_python_stream(context: Context) -> None:
"""Ensure non-Python changes stream without triggering validation errors."""
events = getattr(context, "stream_events", [])
assert events, "Streaming events were not captured"
end_event = events[-1]
assert isinstance(end_event, dict) and "__end__" in end_event, (
"Final streaming event did not include end payload"
)
payload = end_event["__end__"]
changes = payload.get("changes") or []
assert len(changes) == 1, "Expected exactly one streamed change"
change = changes[0]
assert change.file_path.endswith("notes.txt")
assert change.new_content == "plain text change"
@when('I build the plan with actor "{actor_name}"')
def step_build_plan_with_actor(context: Context, actor_name: str) -> None:
"""Build the current plan using the specified actor selection."""
if "/" in actor_name:
provider, model = actor_name.split("/", 1)
else:
provider, model = actor_name, "default-model"
actor_service = context.plan_service.actor_service
# Actors used in plan service tests must be local/ namespace since
# is_built_in was removed from upsert_actor (issue #10923).
actor_id = f"local/{actor_name.replace('/', '-')}"
actor_service.upsert_actor(
name=actor_id,
provider=provider,
model=model,
config_blob={"provider": provider, "model": model},
graph_descriptor=None,
unsafe=False,
set_default=True,
)
context.changes = context.plan_service.build_plan(
project=context.test_project,
actor=actor_id,
)
@then(
'the stub provider registry should record provider "{provider}" and model "{model}"'
)
def step_assert_stub_provider_requests(
context: Context, provider: str, model: str
) -> None:
"""Verify the stub registry saw the requested provider/model overrides."""
requests = getattr(context, "stub_provider_requests", None)
assert requests, "No provider override requests were recorded"
last_request = requests[-1]
assert last_request["provider"] == provider, (
f"Expected provider '{provider}', got '{last_request['provider']}'"
)
assert last_request["model"] == model, (
f"Expected model '{model}', got '{last_request['model']}'"
)
@given("I have a plan service without providers configured")
def step_plan_service_without_providers(context: Context) -> None:
"""Create a PlanService that will raise when resolving providers."""
original_mock_flag = os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
cleanup_handlers = getattr(context, "_cleanup_handlers", None)
if cleanup_handlers is None:
cleanup_handlers = []
context._cleanup_handlers = cleanup_handlers
cleanup_handlers.append(
lambda: (
os.environ.__setitem__(
"CLEVERAGENTS_TESTING_USE_MOCK_AI", original_mock_flag
)
if original_mock_flag is not None
else os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
)
)
if not hasattr(context, "unit_of_work"):
step_create_unit_of_work_plan(context)
if not hasattr(context, "temp_dir"):
context.temp_dir = Path(tempfile.mkdtemp(prefix="plan_service_no_provider_"))
settings = Settings()
context.plan_service = PlanService(
settings=settings,
unit_of_work=context.unit_of_work,
ai_provider=None,
provider_registry=_NoProviderRegistry(),
)
# Provision a default actor so provider resolution reaches the registry path
context.plan_service.actor_service.ensure_default_mock_actor(force=True)
project = Project(
id=None,
name="no-provider-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="noop",
),
)
with context.unit_of_work.transaction() as ctx:
context.project = ctx.projects.create(project)
context.test_project = context.project
@given("I have an unsaved project without ID")
def step_create_unsaved_project(context: Context) -> None:
"""Create an unsaved project without ID."""
context.project = Project(
id=None,
name="test_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="mock-gpt",
),
)
@when("I try to create a plan for the unsaved project")
def step_try_create_plan_unsaved(context: Context) -> None:
"""Try to create a plan for unsaved project."""
try:
context.plan_service.create_plan(context.project, "Test prompt", "test-plan")
context.exception = None
except Exception as e:
context.exception = e
@then('a ValidationError should be raised with message "{message}"')
def step_check_validation_error(context: Context, message: str) -> None:
"""Check that a ValidationError was raised with specific message."""
assert context.exception is not None
assert isinstance(context.exception, ValidationError)
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."""
with context.unit_of_work.transaction() as ctx:
project = Project(
id=None,
name="test_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="mock-gpt",
),
)
context.project = ctx.projects.create(project)
@when("I try to build the plan for the project")
def step_try_build_plan_no_current(context: Context) -> None:
"""Try to build plan when no current plan exists."""
try:
context.plan_service.build_plan(context.project)
context.exception = None
except Exception as e:
context.exception = e
@then('a PlanError should be raised with message "{message}"')
def step_check_plan_error(context: Context, message: str) -> None:
"""Check that a PlanError was raised with specific message."""
assert context.exception is not None, "No exception captured"
assert isinstance(context.exception, PlanError), (
f"Unexpected exception type {type(context.exception).__name__}"
)
actual_message = str(getattr(context.exception, "message", "")) or str(
context.exception
)
assert message in actual_message, f"Expected '{message}' in '{actual_message}'"
@then('a PlanError should be raised containing "{message}"')
def step_check_plan_error_contains(context: Context, message: str) -> None:
"""Check that a PlanError message contains specific text."""
assert context.exception is not None
assert isinstance(context.exception, PlanError)
assert message in str(context.exception.message)
@then("the PlanError details should include provider diagnostics")
def step_plan_error_details_include_provider_diagnostics(context: Context) -> None:
"""Ensure provider diagnostics are present in the captured PlanError details."""
assert context.exception is not None, "No exception captured"
assert isinstance(context.exception, PlanError), (
f"Expected PlanError but got {type(context.exception).__name__}"
)
details = getattr(context.exception, "details", {}) or {}
required_keys = {
"configured_providers",
"expected_env_vars",
"requested_provider",
"requested_model",
}
missing = sorted(key for key in required_keys if key not in details)
assert not missing, f"Missing provider diagnostics keys: {', '.join(missing)}"
@given("I configured an auto-debug plan with a disappearing plan ID")
def step_configure_auto_debug_disappearing_plan(context: Context) -> None:
"""Set up a plan service whose current plan loses its ID mid-run."""
assert hasattr(context, "temp_dir"), (
"Temporary directory setup is required before configuring auto-debug"
)
class FlickeringPlan:
def __init__(self, plan_id: int) -> None:
self._plan_id = plan_id
self._first_access = True
self.prompt = "Investigate flaky build"
self.name = "auto-debug-plan"
self.status = PlanStatus.PENDING
self.current = True
self.updated_at = datetime.now()
self.result = None
@property
def id(self) -> int | None:
if self._first_access:
self._first_access = False
return self._plan_id
return None
class FakePlansRepository:
def __init__(self, plan):
self.plan = plan
def get_current_for_project(self, project_id: int):
return self.plan
class FakeContextsRepository:
def __init__(self) -> None:
self.called = False
def get_for_plan(self, plan_id: int):
self.called = True
return []
class FakeDebugAttemptsRepository:
def __init__(self) -> None:
self.records: list = []
def add(self, attempt):
attempt.id = len(self.records) + 1
self.records.append(attempt)
return attempt
def get(self, attempt_id: int):
return None
def update(self, attempt):
self.updated_attempt = attempt
class FakeUnitOfWork:
def __init__(self, contexts: list[SimpleNamespace]) -> None:
self._contexts = contexts
def transaction(self):
outer = self
class _Transaction:
def __enter__(self_inner):
if not outer._contexts:
raise AssertionError("No fake transaction contexts remaining")
return outer._contexts.pop(0)
def __exit__(self_inner, exc_type, exc_val, exc_tb):
return False
return _Transaction()
flicker_plan = FlickeringPlan(plan_id=101)
contexts_repo = FakeContextsRepository()
debug_repo = FakeDebugAttemptsRepository()
fake_contexts = [
SimpleNamespace(plans=FakePlansRepository(flicker_plan)),
SimpleNamespace(contexts=contexts_repo),
SimpleNamespace(debug_attempts=debug_repo),
]
context.auto_debug_context_repo = contexts_repo
context.auto_debug_debug_repo = debug_repo
context.plan_service = PlanService(
settings=Settings(),
unit_of_work=FakeUnitOfWork(fake_contexts),
ai_provider=None,
)
context.project = Project(
id=1,
name="auto-debug-project",
path=context.temp_dir,
created_at=datetime.now(),
updated_at=datetime.now(),
current_plan_id=1,
settings=ProjectSettings(
auto_build=False,
auto_apply=False,
confirm_apply=True,
max_context_size=50 * 1024 * 1024,
default_model="mock-gpt",
),
)
@when("I run auto-debug build with missing plan context")
def step_run_auto_debug_missing_context(context: Context) -> None:
"""Trigger auto-debug build to exercise empty context handling."""
context.plan_service.build_plan = MagicMock(
side_effect=RuntimeError("Plan build failed")
)
captured_state: dict[str, object] = {}
class DummyAutoDebugAgent:
def __init__(self, **_kwargs):
pass
def invoke(self, debug_state, config=None):
captured_state["state"] = debug_state
captured_state["config"] = config
return {"result": {"success": False, "fix": None}}
with (
patch.object(
context.plan_service, "_prepare_langsmith_config", return_value={}
),
patch(
"cleveragents.agents.AutoDebugAgent",
DummyAutoDebugAgent,
),
):
context.auto_debug_result = context.plan_service.auto_debug_build(
context.project, max_attempts=1
)
context.auto_debug_captured_state = captured_state.get("state")
@then("the auto-debug build should fall back to an empty context")
def step_assert_auto_debug_empty_context(context: Context) -> None:
"""Verify empty code context and debug attempt tracking."""
result = getattr(context, "auto_debug_result", None)
assert result is not None, "Auto-debug result was not captured"
success, changes, error = result
assert success is False
assert changes == []
assert error == "Plan build failed"
contexts_repo = getattr(context, "auto_debug_context_repo", None)
assert contexts_repo is not None, "Contexts repository tracking missing"
assert contexts_repo.called is False
debug_state = getattr(context, "auto_debug_captured_state", None)
assert debug_state is not None, "Debug state was not recorded"
assert debug_state.get("code_context") == ""
@given("I have a saved project with current plan missing ID")
def step_create_project_plan_no_id(context: Context) -> None:
"""Create a project with current plan that has no ID."""
with context.unit_of_work.transaction() as ctx:
project = Project(
id=None,
name="test_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="mock-gpt",
),
)
context.project = ctx.projects.create(project)
# Create a plan that will be saved
plan = Plan(
id=None,
project_id=context.project.id,
name="test-plan",
prompt="Test prompt",
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,
)
created_plan = ctx.plans.create(plan)
# Update project to have current plan ID
context.project.current_plan_id = created_plan.id
ctx.projects.update(context.project)
# Store the created plan for mocking
context.created_plan_with_id = created_plan
@when("I try to build the plan with invalid ID")
def step_try_build_plan_invalid_id(context: Context) -> None:
"""Try to build plan with invalid ID."""
from unittest.mock import patch
# Mock get_current_for_project to return a plan without ID
def mock_get_current(project_id):
# Return the plan but with ID set to None
plan = context.created_plan_with_id
plan.id = None
return plan
try:
# Patch the repository method at the class level
with patch.object(
context.unit_of_work.transaction().__enter__().plans.__class__,
"get_current_for_project",
side_effect=mock_get_current,
):
context.plan_service.build_plan(context.project)
context.exception = None
except Exception as e:
context.exception = e
@given("I have a saved project with valid current plan")
def step_create_project_with_valid_plan(context: Context) -> None:
"""Create a project with valid current plan."""
with context.unit_of_work.transaction() as ctx:
project = Project(
id=None,
name="test_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="mock-gpt",
),
)
context.project = ctx.projects.create(project)
plan = Plan(
id=None,
project_id=context.project.id,
name="test-plan",
prompt="Test prompt",
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,
)
context.current_plan = ctx.plans.create(plan)
if context.project.id and context.current_plan.id:
ctx.plans.set_current(context.project.id, context.current_plan.id)
@given("I have a plan service without AI provider")
def step_create_plan_service_no_provider(context: Context) -> None:
"""Create a plan service without AI provider."""
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
if not hasattr(context, "env_vars_to_clean"):
context.env_vars_to_clean = []
if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")
settings = Settings()
context.plan_service = PlanService(
settings=settings,
unit_of_work=context.unit_of_work,
ai_provider=None,
provider_registry=_NoProviderRegistry(),
)
@when("I try to build the plan without AI provider")
def step_try_build_without_provider(context: Context) -> None:
"""Try to build plan without AI provider."""
try:
context.plan_service.build_plan(context.project)
context.exception = None
except Exception as e:
context.exception = e
@when("I get pending changes for the project")
def step_get_pending_changes(context: Context) -> None:
"""Get pending changes for the project."""
context.pending_changes = context.plan_service.get_pending_changes(context.project)
@then("the pending changes list should be empty")
def step_check_pending_changes_empty(context: Context) -> None:
"""Check that pending changes list is empty."""
assert context.pending_changes == []
@given("I have a saved project with current plan")
def step_create_project_with_current_plan(context: Context) -> None:
"""Create a project with current plan."""
step_create_project_with_valid_plan(context)
@given("the current plan has applied and unapplied changes")
def step_add_mixed_changes(context: Context) -> None:
"""Add both applied and unapplied changes to the plan."""
with context.unit_of_work.transaction() as ctx:
# Add applied change
applied_change = Change(
id=None,
plan_id=context.current_plan.id,
file_path="applied.py",
operation=OperationType.CREATE,
original_content=None,
new_content="applied content",
new_path=None,
applied=True,
applied_at=datetime.now(),
created_at=datetime.now(),
)
ctx.changes.add(applied_change)
# Add unapplied changes
for i in range(2):
unapplied_change = Change(
id=None,
plan_id=context.current_plan.id,
file_path=f"unapplied_{i}.py",
operation=OperationType.CREATE,
original_content=None,
new_content=f"unapplied content {i}",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
ctx.changes.add(unapplied_change)
@then("I should get only the unapplied changes")
def step_check_only_unapplied(context: Context) -> None:
"""Check that only unapplied changes are returned."""
assert len(context.pending_changes) == 2
for change in context.pending_changes:
assert not change.applied
assert "unapplied" in change.file_path
@when("I try to apply changes without current plan")
def step_try_apply_without_plan(context: Context) -> None:
"""Try to apply changes without current plan."""
try:
context.plan_service.apply_changes(context.project)
context.exception = None
except Exception as e:
context.exception = e
@given("the plan has a pending CREATE change")
def step_add_create_change(context: Context) -> None:
"""Add a pending CREATE change to the plan."""
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path="new_file.py",
operation=OperationType.CREATE,
original_content=None,
new_content="# New file content\nprint('Hello')",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
context.create_change = ctx.changes.add(change)
@when("I apply the plan changes")
def step_apply_changes(context: Context) -> None:
"""Apply the changes."""
context.applied_count = context.plan_service.apply_changes(context.project)
@then("the file should be created with correct content")
def step_check_file_created(context: Context) -> None:
"""Check that file was created with correct content."""
file_path = context.project.path / "new_file.py"
assert file_path.exists()
assert file_path.read_text() == "# New file content\nprint('Hello')"
@then("the change should be marked as applied in plan service")
def step_check_change_applied(context: Context) -> None:
"""Check that change was marked as applied."""
assert context.applied_count == 1
@given("the plan has a pending MODIFY change for existing file")
def step_add_modify_change(context: Context) -> None:
"""Add a pending MODIFY change for existing file."""
# Create the file first
file_path = context.project.path / "existing.py"
file_path.write_text("# Original content")
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path="existing.py",
operation=OperationType.MODIFY,
original_content="# Original content",
new_content="# Modified content\nprint('Modified')",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
context.modify_change = ctx.changes.add(change)
@then("the file should be modified with new content")
def step_check_file_modified(context: Context) -> None:
"""Check that file was modified with new content."""
file_path = context.project.path / "existing.py"
assert file_path.exists()
assert file_path.read_text() == "# Modified content\nprint('Modified')"
@given("the plan has a pending DELETE change for existing file")
def step_add_delete_change(context: Context) -> None:
"""Add a pending DELETE change for existing file."""
# Create the file first
file_path = context.project.path / "to_delete.py"
file_path.write_text("# To be deleted")
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path="to_delete.py",
operation=OperationType.DELETE,
original_content="# To be deleted",
new_content=None,
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
context.delete_change = ctx.changes.add(change)
@then("the file should be deleted")
def step_check_file_deleted(context: Context) -> None:
"""Check that file was deleted."""
file_path = context.project.path / "to_delete.py"
assert not file_path.exists()
@given("the plan has a pending MOVE change for existing file")
def step_add_move_change(context: Context) -> None:
"""Add a pending MOVE change for existing file."""
# Create the file first
file_path = context.project.path / "old_location.py"
file_path.write_text("# File to move")
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path="old_location.py",
operation=OperationType.MOVE,
original_content="# File to move",
new_content="# File to move",
new_path="new_location.py",
applied=False,
applied_at=None,
created_at=datetime.now(),
)
context.move_change = ctx.changes.add(change)
@then("the file should be moved to new location")
def step_check_file_moved(context: Context) -> None:
"""Check that file was moved to new location."""
old_path = context.project.path / "old_location.py"
new_path = context.project.path / "new_location.py"
assert not old_path.exists()
assert new_path.exists()
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 a pending MOVE change to an absolute path")
def step_add_absolute_move_change(context: Context) -> None:
"""Add a MOVE change whose destination is an absolute path."""
assert getattr(context, "project", None) is not None, "Project is required"
assert getattr(context, "current_plan", None) is not None, (
"Current plan is required"
)
source_path = context.project.path / "absolute_source.py"
source_path.write_text("# Absolute move source")
absolute_target = (context.project.path / "absolute_target.py").resolve()
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path="absolute_source.py",
operation=OperationType.MOVE,
original_content="# Absolute move source",
new_content="# Absolute move source",
new_path=str(absolute_target),
applied=False,
applied_at=None,
created_at=datetime.now(),
)
ctx.changes.add(change)
context.absolute_move_paths = (source_path, absolute_target)
@then("the absolute destination should exist and the source should be removed")
def step_check_absolute_move_destination(context: Context) -> None:
"""Ensure MOVE changes create files at absolute targets."""
paths = getattr(context, "absolute_move_paths", None)
assert paths is not None, "Absolute move paths were not recorded"
source_path, absolute_target = paths
assert not source_path.exists(), "Source file still exists after MOVE"
destination_path = Path(absolute_target)
assert destination_path.exists(), "Absolute destination file missing"
assert destination_path.read_text() == "# Absolute move source"
assert getattr(context, "applied_count", 0) == 1
@given("the plan has changes with absolute file paths")
def step_add_absolute_path_changes(context: Context) -> None:
"""Add changes with absolute file paths."""
abs_path = context.temp_dir / "absolute_file.py"
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path=str(abs_path), # Absolute path
operation=OperationType.CREATE,
original_content=None,
new_content="# Absolute path content",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
ctx.changes.add(change)
@then("the files should be created at absolute paths")
def step_check_absolute_paths(context: Context) -> None:
"""Check that files were created at absolute paths."""
abs_path = context.temp_dir / "absolute_file.py"
assert abs_path.exists()
assert abs_path.read_text() == "# Absolute path content"
@then("the plan changes should be marked as applied")
def step_check_changes_marked_applied(context: Context) -> None:
"""Check that changes were marked as applied."""
assert context.applied_count > 0
@then("the changes should be marked as applied")
def step_check_changes_applied(context: Context) -> None:
"""Check that changes were marked as applied."""
assert context.applied_count > 0
@given("the plan has a change that will fail on apply")
def step_add_failing_change(context: Context) -> None:
"""Add a change that will fail when applied."""
with context.unit_of_work.transaction() as ctx:
# Create a file then simulate permission denial via mock
test_file = context.temp_dir / "readonly_file.py"
test_file.write_text("original content")
# Try to modify a read-only file - this should fail
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path=str(test_file), # Existing readonly file
operation=OperationType.MODIFY, # Try to modify it
original_content="original content",
new_content="# New content that will fail to write",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
ctx.changes.add(change)
# Store path for the mock in the apply step
context.readonly_file = str(test_file)
@when("I try to apply the changes")
def step_try_apply_changes(context: Context) -> None:
"""Try to apply changes, simulating PermissionError for readonly targets."""
target = getattr(context, "readonly_file", None)
original_write_text = Path.write_text
def guarded_write_text(self, *args, **kwargs):
if target and str(self) == target:
raise PermissionError(f"[Errno 13] Permission denied: '{self}'")
return original_write_text(self, *args, **kwargs)
try:
with (
patch.object(Path, "write_text", guarded_write_text)
if target
else contextlib.nullcontext()
):
result = context.plan_service.apply_changes(context.project)
context.exception = None
context.apply_result = result
except Exception as e:
context.exception = e
@then("a PlanError should be raised with file operation details")
def step_check_plan_error_with_details(context: Context) -> None:
"""Check that PlanError was raised with file operation details."""
assert context.exception is not None, (
f"Expected an exception but got result: {getattr(context, 'apply_result', 'none')}"
)
assert isinstance(context.exception, PlanError), (
f"Expected PlanError but got {type(context.exception).__name__}: {context.exception}"
)
assert "Failed to apply change" in str(context.exception.message)
@given("I have a saved project")
def step_create_saved_project(context: Context) -> None:
"""Create a saved project."""
with context.unit_of_work.transaction() as ctx:
project = Project(
id=None,
name="test_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="mock-gpt",
),
)
context.project = ctx.projects.create(project)
@when("I create a plan with minimal prompt")
def step_create_plan_minimal_prompt(context: Context) -> None:
"""Create a plan with minimal prompt."""
# Use a single character prompt - the service should generate a name from it
context.created_plan = context.plan_service.create_plan(context.project, "x", None)
@then("the plan name should be generated from prompt")
def step_check_generated_name(context: Context) -> None:
"""Check that plan name was generated from the prompt."""
# With prompt "x", the name should be "x"
assert context.created_plan.name == "x"
@when("I create a plan with very long prompt")
def step_create_plan_long_prompt(context: Context) -> None:
"""Create a plan with very long prompt."""
long_prompt = "This is a very long prompt with many words that should be truncated"
context.created_plan = context.plan_service.create_plan(
context.project, long_prompt, None
)
@then("the plan name should be truncated to first three words")
def step_check_truncated_name(context: Context) -> None:
"""Check that plan name was truncated."""
assert context.created_plan.name == "this_is_a"
@given("the plan has a MOVE change for non-existent file")
def step_add_move_nonexistent(context: Context) -> None:
"""Add a MOVE change for non-existent file."""
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path="nonexistent.py",
operation=OperationType.MOVE,
original_content=None,
new_content=None,
new_path="new_location.py",
applied=False,
applied_at=None,
created_at=datetime.now(),
)
ctx.changes.add(change)
@then("the MOVE operation should handle missing source gracefully")
def step_check_move_handles_missing(context: Context) -> None:
"""Check that MOVE handles missing source file."""
# The operation should complete without error even if source doesn't exist
assert context.applied_count == 1
# New file should not exist since source didn't exist
new_path = context.project.path / "new_location.py"
assert not new_path.exists()
@given("the plan has a DELETE change for non-existent file")
def step_add_delete_nonexistent(context: Context) -> None:
"""Add a DELETE change for non-existent file."""
with context.unit_of_work.transaction() as ctx:
change = Change(
id=None,
plan_id=context.current_plan.id,
file_path="already_deleted.py",
operation=OperationType.DELETE,
original_content=None,
new_content=None,
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
ctx.changes.add(change)
@then("the DELETE operation should handle missing file gracefully")
def step_check_delete_handles_missing(context: Context) -> None:
"""Check that DELETE handles missing file."""
# The operation should complete without error even if file doesn't exist
assert context.applied_count == 1
@given("I have a plan service with mock AI provider")
def step_create_plan_service_with_mock_provider(context: Context) -> None:
"""Create a plan service with mock AI provider."""
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
if not hasattr(context, "env_vars_to_clean"):
context.env_vars_to_clean = []
if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")
mock_provider = MagicMock()
# Mock the generate_changes method
def generate_changes_with_callback(
project,
plan,
contexts,
actor_context: ActorInvocationContext | None = None,
progress_callback=None,
):
if progress_callback:
# Simulate progress updates
for i in range(0, 101, 20):
progress_callback(i)
# Return mock response
return ProviderResponse(
changes=[
Change(
id=None,
plan_id=plan.id,
file_path="generated.py",
operation=OperationType.CREATE,
original_content=None,
new_content="# Generated content",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
],
model_used="mock-gpt",
token_count=100,
)
mock_provider.generate_changes = generate_changes_with_callback
settings = Settings()
context.plan_service = PlanService(
settings=settings, unit_of_work=context.unit_of_work, ai_provider=mock_provider
)
context.mock_provider = mock_provider
@when("I build the plan with progress callback")
def step_build_with_progress_callback(context: Context) -> None:
"""Build plan with progress callback."""
context.progress_values = []
def progress_callback(value):
context.progress_values.append(value)
context.changes = context.plan_service.build_plan(
context.project, progress_callback=progress_callback
)
@then("the progress callback should be called with values from 0 to 100")
def step_check_progress_callback(context: Context) -> None:
"""Check that progress callback was called correctly."""
assert len(context.progress_values) > 0
assert 0 in context.progress_values
assert 100 in context.progress_values
# Check that values are in ascending order
for i in range(1, len(context.progress_values)):
assert context.progress_values[i] >= context.progress_values[i - 1]
@given("the plan has 3 applied and 2 unapplied changes")
def step_add_specific_mixed_changes(context: Context) -> None:
"""Add specific number of applied and unapplied changes."""
with context.unit_of_work.transaction() as ctx:
# Add 3 applied changes
for i in range(3):
applied_change = Change(
id=None,
plan_id=context.current_plan.id,
file_path=f"applied_{i}.py",
operation=OperationType.CREATE,
original_content=None,
new_content=f"applied content {i}",
new_path=None,
applied=True,
applied_at=datetime.now(),
created_at=datetime.now(),
)
ctx.changes.add(applied_change)
# Add 2 unapplied changes
for i in range(2):
unapplied_change = Change(
id=None,
plan_id=context.current_plan.id,
file_path=f"pending_{i}.py",
operation=OperationType.CREATE,
original_content=None,
new_content=f"pending content {i}",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
ctx.changes.add(unapplied_change)
@then("I should get exactly 2 pending changes")
def step_check_exactly_two_pending(context: Context) -> None:
"""Check that exactly 2 pending changes are returned."""
assert len(context.pending_changes) == 2
@then("the pending changes should not include applied ones")
def step_check_no_applied_in_pending(context: Context) -> None:
"""Check that pending changes don't include applied ones."""
for change in context.pending_changes:
assert not change.applied
assert "pending" in change.file_path
assert "applied" not in change.file_path
def _ensure_memory_database_url(context: Context) -> None:
"""Ensure plan service uses an in-memory database for memory tests."""
try:
if (
context.plan_service.settings.database_url
!= "sqlite:///file::memory:?cache=shared"
):
context.plan_service.settings.database_url = (
"sqlite:///file::memory:?cache=shared"
)
except AttributeError:
context.plan_service.settings = Settings(
database_url="sqlite:///file::memory:?cache=shared"
)
def _request_memory_service_for_session(
context: Context,
session_id: str,
persistent: bool,
max_messages: int | None,
) -> MemoryService:
if persistent:
_ensure_memory_database_url(context)
service = context.plan_service.get_memory_service(
session_id,
persistent=persistent,
max_messages=max_messages,
)
context.last_memory_service = service
context.last_memory_session = session_id
return service
@when(
'I request an in-memory memory service for session "{session_id}" with max messages {max_messages:d}'
)
def step_request_in_memory_memory_service(
context: Context, session_id: str, max_messages: int
) -> None:
service = _request_memory_service_for_session(
context, session_id, persistent=False, max_messages=max_messages
)
assert isinstance(service, MemoryService)
@when(
'I request a persistent memory service for session "{session_id}" with max messages {max_messages:d}'
)
def step_request_persistent_memory_service(
context: Context, session_id: str, max_messages: int
) -> None:
service = _request_memory_service_for_session(
context, session_id, persistent=True, max_messages=max_messages
)
assert isinstance(service, MemoryService)
@when(
'I request an in-memory memory service for session "{session_id}" without changing message limit'
)
def step_request_in_memory_without_limit(context: Context, session_id: str) -> None:
service = _request_memory_service_for_session(
context, session_id, persistent=False, max_messages=None
)
assert isinstance(service, MemoryService)
@then(
'the session "{session_id}" memory should be in-memory with max messages {max_messages:d}'
)
def step_verify_in_memory_with_max(
context: Context, session_id: str, max_messages: int
) -> None:
service = context.plan_service._memory_services.get(session_id)
assert service is not None
assert isinstance(service, MemoryService)
assert service.connection_string is None
assert service.max_messages == max_messages
context.last_memory_service = service
@then(
'the session "{session_id}" memory should be persistent with max messages {max_messages:d}'
)
def step_verify_persistent_with_max(
context: Context, session_id: str, max_messages: int
) -> None:
service = context.plan_service._memory_services.get(session_id)
assert service is not None
assert isinstance(service, MemoryService)
assert service.connection_string == context.plan_service.settings.database_url
assert service.max_messages == max_messages
context.last_memory_service = service
@then(
'the session "{session_id}" memory should be in-memory without a connection string'
)
def step_verify_in_memory_without_connection(context: Context, session_id: str) -> None:
service = context.plan_service._memory_services.get(session_id)
assert service is not None
assert isinstance(service, MemoryService)
assert service.connection_string is None
@when('I request conversation memory for session "{session_id}" with custom keys')
def step_request_conversation_memory_custom(context: Context, session_id: str) -> None:
adapter = context.plan_service.get_conversation_memory(
session_id,
persistent=False,
memory_key="chat_history",
input_key="prompt",
output_key="response",
return_messages=False,
max_messages=4,
)
context.conversation_adapter = adapter
context.last_memory_session = session_id
@then("the conversation adapter should expose the custom keys")
def step_verify_conversation_adapter_keys(context: Context) -> None:
adapter = getattr(context, "conversation_adapter", None)
assert isinstance(adapter, ConversationBufferMemoryAdapter)
assert adapter.memory_variables == ["chat_history"]
assert adapter.input_key == "prompt"
assert adapter.output_key == "response"
assert adapter.return_messages is False
@then("the conversation adapter should share the existing memory service")
def step_verify_conversation_adapter_service(context: Context) -> None:
session_id = getattr(context, "last_memory_session", None)
assert session_id is not None
service = context.plan_service._memory_services.get(session_id)
assert service is not None
adapter = context.conversation_adapter
assert adapter._message_history is service.message_history # type: ignore[attr-defined]
@given('I stored a chat message in session "{session_id}"')
def step_store_chat_message(context: Context, session_id: str) -> None:
service = _request_memory_service_for_session(
context, session_id, persistent=False, max_messages=None
)
service.add_user_message("hello memory branch")
assert len(service.message_history.messages) == 1
context.stored_memory_session = session_id
context.stored_memory_service = service
@when('I clear the session "{session_id}" memory with forget history')
def step_clear_memory_with_forget(context: Context, session_id: str) -> None:
assert getattr(context, "stored_memory_session", None) == session_id
context.plan_service.clear_memory(session_id, forget_history=True)
context.cleared_memory_service = context.stored_memory_service
@when('I clear the session "{session_id}" memory without forgetting history')
def step_clear_memory_without_forget(context: Context, session_id: str) -> None:
assert getattr(context, "stored_memory_session", None) == session_id
context.plan_service.clear_memory(session_id)
context.cleared_memory_service = context.stored_memory_service
@then('the session "{session_id}" memory should be removed and emptied')
def step_verify_memory_cleared(context: Context, session_id: str) -> None:
assert session_id not in context.plan_service._memory_services
service = getattr(context, "cleared_memory_service", None)
assert isinstance(service, MemoryService)
assert service.message_history.messages == []
@then('the session "{session_id}" memory should retain its stored messages')
def step_verify_memory_retained(context: Context, session_id: str) -> None:
service = getattr(context, "cleared_memory_service", None)
assert isinstance(service, MemoryService), "Memory service reference missing"
assert service is getattr(context, "stored_memory_service", None)
assert session_id not in context.plan_service._memory_services
messages = service.message_history.messages
assert messages, "Message history should remain intact"
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
@given("LangSmith integration is enabled for plan service")
def step_enable_langsmith_integration(context: Context) -> None:
"""Reconfigure the plan service with LangSmith support enabled."""
if not hasattr(context, "unit_of_work"):
step_create_unit_of_work_plan(context)
settings = Settings()
settings.langsmith_enabled = True
settings.langsmith_project = "plan-service-tests"
settings.langsmith_api_key = "plan-service-api-key"
settings.langsmith_tags = ["global-langsmith"]
settings.langsmith_user_id = "plan-service-user"
original_builder = Settings.build_langsmith_config
def _forward_build_config(
self: Settings, *, tags=None, metadata=None, run_name=None
):
return original_builder(
self,
tags=tags,
metadata=metadata,
run_name=run_name,
)
builder_patch = patch.object(
Settings,
"build_langsmith_config",
autospec=True,
side_effect=_forward_build_config,
)
builder_mock = builder_patch.start()
cleanup = getattr(context, "add_cleanup", None)
if callable(cleanup):
cleanup(builder_patch.stop)
else: # pragma: no cover - behave contexts should supply add_cleanup
context._langsmith_builder_patch = builder_patch # type: ignore[attr-defined]
context.langsmith_builder_mock = builder_mock
context.plan_service = PlanService(
settings=settings,
unit_of_work=context.unit_of_work,
ai_provider=None,
)
@when(
'I prepare a LangSmith config for project "{project_name}" and plan "{plan_name}"'
)
def step_prepare_langsmith_config(
context: Context, project_name: str, plan_name: str
) -> None:
"""Build and prepare a LangSmith config to capture metadata and tags."""
project = Project(
id=101,
name=project_name,
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="langsmith",
),
)
plan = Plan(
id=202,
project_id=project.id,
name=plan_name,
prompt="LangSmith coverage",
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,
)
config = context.plan_service._prepare_langsmith_config(
project,
plan,
run_name="coverage",
tags=["custom-tag"],
metadata={"env": "test"},
)
context.langsmith_project = project
context.langsmith_plan = plan
context.langsmith_prepared_config = config
builder_mock = getattr(context, "langsmith_builder_mock", None)
if builder_mock is not None:
context.langsmith_builder_call = builder_mock.call_args
@when('I prepare a LangSmith config without plan metadata for project "{project_name}"')
def step_prepare_langsmith_config_without_plan(
context: Context, project_name: str
) -> None:
"""Prepare a LangSmith config when plan metadata is unavailable."""
assert hasattr(context, "plan_service"), "PlanService is not configured"
settings = context.plan_service.settings
settings.langsmith_tags = []
settings.langsmith_user_id = None
project = Project(
id=None,
name=project_name,
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="langsmith",
),
)
config = context.plan_service._prepare_langsmith_config(
project,
None,
run_name="coverage-minimal",
tags=None,
metadata=None,
)
context.langsmith_project = project
context.langsmith_plan = None
context.langsmith_prepared_config = config
builder_mock = getattr(context, "langsmith_builder_mock", None)
if builder_mock is not None:
context.langsmith_builder_call = builder_mock.call_args
@then(
'the LangSmith builder should receive metadata for project "{project_name}" and plan "{plan_name}"'
)
def step_assert_langsmith_builder_metadata(
context: Context, project_name: str, plan_name: str
) -> None:
"""Verify the builder received merged metadata and tags."""
call = getattr(context, "langsmith_builder_call", None)
assert call is not None, "LangSmith builder was not invoked"
_, kwargs = call
metadata = kwargs.get("metadata", {})
tags = kwargs.get("tags", [])
project = getattr(context, "langsmith_project", None)
plan = getattr(context, "langsmith_plan", None)
assert metadata.get("project_name") == project_name
assert metadata.get("plan_name") == plan_name
assert metadata.get("env") == "test"
assert metadata.get("project_id") == getattr(project, "id", None)
assert metadata.get("plan_id") == getattr(plan, "id", None)
assert metadata.get("user_id") == "plan-service-user"
assert "service:plan" in tags
ctr_project = getattr(project, "id", None)
assert f"project:{ctr_project}" in tags
assert f"plan:{getattr(plan, 'id', None)}" in tags
assert "global-langsmith" in tags
assert "custom-tag" in tags
@then(
'the LangSmith builder should receive only base metadata for project "{project_name}"'
)
def step_assert_langsmith_builder_base_metadata(
context: Context, project_name: str
) -> None:
"""Ensure only project metadata is provided when no plan is available."""
call = getattr(context, "langsmith_builder_call", None)
assert call is not None, "LangSmith builder was not invoked"
_, kwargs = call
metadata = kwargs.get("metadata", {})
tags = kwargs.get("tags", [])
assert metadata.get("project_name") == project_name
assert metadata.get("project_id") is None
extra_metadata_keys = set(metadata.keys()) - {"project_id", "project_name"}
assert not extra_metadata_keys, (
f"Unexpected metadata keys present: {sorted(extra_metadata_keys)}"
)
assert tags == ["service:plan"], f"Expected only base service tag, got {tags}"
@then("the prepared LangSmith config should include a generated thread id")
def step_assert_langsmith_thread_id(context: Context) -> None:
config = getattr(context, "langsmith_prepared_config", None)
assert config, "LangSmith config was not prepared"
configurable = config.get("configurable")
assert isinstance(configurable, dict)
thread_id = configurable.get("thread_id")
assert isinstance(thread_id, str)
assert thread_id.startswith("plan-service-")
@given("I have a plan service with a failing AI provider")
def step_plan_service_with_failing_provider(context: Context) -> None:
"""Configure the plan service to surface provider failures."""
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
if not hasattr(context, "env_vars_to_clean"):
context.env_vars_to_clean = []
if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")
failing_provider = MagicMock()
failing_provider.generate_changes.return_value = ProviderResponse(
changes=[],
model_used="mock-gpt",
token_count=0,
error_message="mock provider failure",
)
context.plan_service = PlanService(
settings=Settings(),
unit_of_work=context.unit_of_work,
ai_provider=failing_provider,
)
context.mock_provider = failing_provider
@when("I attempt to build the plan and the provider returns an error")
def step_build_plan_with_provider_error(context: Context) -> None:
"""Invoke build_plan and capture the provider error."""
try:
context.plan_service.build_plan(context.project)
context.exception = None
except Exception as exc: # pragma: no cover - defensive
context.exception = exc
@when("I run the auto debug build for the project")
def step_run_auto_debug_without_plan(context: Context) -> None:
"""Run auto_debug_build while capturing exceptions."""
try:
context.auto_debug_result = context.plan_service.auto_debug_build(
context.project
)
context.exception = None
except Exception as exc:
context.auto_debug_result = None
context.exception = exc
@given("auto debug retries should run with a failing build pipeline")
def step_force_auto_debug_failure(context: Context) -> None:
"""Mark that auto_debug_build should simulate failing build attempts."""
context.force_auto_debug_failure = True
@when("I run the auto debug build with max attempts {attempts:d}")
def step_run_auto_debug_with_attempts(context: Context, attempts: int) -> None:
"""Execute auto_debug_build, optionally forcing failures for coverage."""
def failing_build(
self: PlanService,
project: Project,
progress_callback: Callable[[int], None] | None = None,
) -> list[Change]:
raise RuntimeError("Simulated auto debug failure")
agent_path = "cleveragents.agents.AutoDebugAgent"
try:
if getattr(context, "force_auto_debug_failure", False):
with (
patch.object(PlanService, "build_plan", side_effect=failing_build),
patch(agent_path) as agent_cls,
):
agent_instance = MagicMock()
agent_instance.invoke.return_value = {
"result": {"success": False, "fix": {}},
}
agent_cls.return_value = agent_instance
context.auto_debug_result = context.plan_service.auto_debug_build(
context.project, max_attempts=attempts
)
else:
context.auto_debug_result = context.plan_service.auto_debug_build(
context.project, max_attempts=attempts
)
context.exception = None
except Exception as exc:
context.auto_debug_result = None
context.exception = exc
@when("I run the auto debug build with a retrying success")
def step_run_auto_debug_retry_success(context: Context) -> None:
assert hasattr(context, "plan_service"), "PlanService is not configured"
current_plan = getattr(context, "current_plan", None)
assert current_plan is not None, "Current plan is not configured"
call_count = {"count": 0}
def build_plan_side_effect(
self: PlanService,
project: Project,
progress_callback: Callable[[int], None] | None = None,
) -> list[Change]:
call_count["count"] += 1
if call_count["count"] == 1:
raise RuntimeError("first failure")
return [
Change(
id=None,
plan_id=current_plan.id or 0,
file_path="after_retry.py",
operation=OperationType.CREATE,
original_content=None,
new_content="# retry success",
new_path=None,
applied=False,
applied_at=None,
created_at=datetime.now(),
)
]
agent_path = "cleveragents.agents.AutoDebugAgent"
with (
patch.object(PlanService, "build_plan", side_effect=build_plan_side_effect),
patch(agent_path) as agent_cls,
):
agent_instance = MagicMock()
agent_instance.invoke.return_value = {"result": {"success": False, "fix": {}}}
agent_cls.return_value = agent_instance
context.auto_debug_result = context.plan_service.auto_debug_build(
context.project, max_attempts=2
)
with context.unit_of_work.transaction() as ctx:
context.latest_debug_attempts = ctx.debug_attempts.get_for_plan(current_plan.id)
@then('the auto debug result should indicate failure with error message "{message}"')
def step_assert_auto_debug_failure(context: Context, message: str) -> None:
"""Validate that auto_debug_build returned a failure tuple."""
result = getattr(context, "auto_debug_result", None)
assert result is not None, "Auto debug result was not captured"
success, changes, error_message = result
assert success is False
assert isinstance(changes, list)
assert isinstance(error_message, str)
assert message in error_message
@then("the auto debug attempt should be marked successful after retry")
def step_assert_auto_debug_retry_success(context: Context) -> None:
result = getattr(context, "auto_debug_result", None)
assert result is not None, "Auto debug result was not captured"
success, changes, error_message = result
assert success is True
assert isinstance(changes, list)
assert error_message is None
attempts = getattr(context, "latest_debug_attempts", []) or []
assert attempts, "No debug attempts were recorded"
assert any(attempt.success for attempt in attempts), (
"Debug attempt was not marked successful"
)
@when("I try to stream plan generation without an AI provider")
def step_stream_generate_without_provider(context: Context) -> None:
"""Attempt to consume the streaming generator without configuring an AI provider."""
import asyncio
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
if not hasattr(context, "env_vars_to_clean"):
context.env_vars_to_clean = []
if "CLEVERAGENTS_TESTING_USE_MOCK_AI" not in context.env_vars_to_clean:
context.env_vars_to_clean.append("CLEVERAGENTS_TESTING_USE_MOCK_AI")
# Ensure the plan service cannot resolve a provider regardless of environment
context.plan_service.ai_provider = None
context.plan_service._provider_registry = _NoProviderRegistry()
async def _consume_stream() -> None:
generator = context.plan_service.generate_plan_streaming(
context.project,
description="Stream coverage scenario",
)
await generator.__anext__()
try:
asyncio.run(_consume_stream())
context.exception = None
except Exception as exc:
context.exception = exc