Files
temp/features/steps/changeset_repository_coverage_steps.py
freemo 0ca1303927 feat(sandbox): add checkpoint and rollback hooks
Introduce a lightweight checkpoint/rollback system for sandbox state
during plan execute and apply flows.  CheckpointManager snapshots
the sandbox working directory before each phase and can restore it
on failure, giving the execution engine a reliable undo mechanism.

Key changes:
- SandboxCheckpoint model, Checkpointable protocol, and
  CheckpointManager in infrastructure/sandbox/checkpoint.py
- PlanExecutor gains optional checkpoint_manager with pre/post
  execute hooks and automatic rollback on failure
- PlanApplyService gains optional checkpoint_manager with pre-apply
  checkpoint and rollback helper
- 12 BDD scenarios (features/sandbox_checkpoints.feature)
- 5 Robot Framework smoke tests (robot/sandbox_checkpoint_smoke.robot)
- ASV benchmarks for creation, rollback, and listing operations
- Reference documentation in docs/reference/sandbox.md

ISSUES CLOSED: #183
2026-02-27 23:08:55 +00:00

1073 lines
40 KiB
Python

"""Step definitions for changeset repository coverage feature tests."""
from __future__ import annotations
import json
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.change import (
ChangeEntry,
ChangeOperation,
ToolInvocation,
)
from cleveragents.infrastructure.database.changeset_repository import (
ChangeSetEntryRepository,
SqliteChangeSetStore,
ToolInvocationRepository,
)
from cleveragents.infrastructure.database.models import (
Base,
ChangeSetEntryModel,
ToolInvocationModel,
)
# ------ Helpers ------
def _make_session_factory() -> tuple[Callable[[], Session], Session, Engine]:
"""Create an in-memory SQLite engine + shared session factory with schema.
Uses a single shared session so that flush() data is visible across
all repository calls within the same scenario. Without this,
short-lived sessions returned by sessionmaker() may be garbage-
collected, causing the StaticPool's reset_on_return to discard
uncommitted INSERTs.
"""
engine = create_engine(
"sqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(engine)
session: Session = sessionmaker(bind=engine)()
return lambda: session, session, engine
def _make_change_entry(
plan_id: str = "plan-cov-1",
operation: ChangeOperation = ChangeOperation.CREATE,
path: str = "src/cov.py",
) -> ChangeEntry:
"""Create a ChangeEntry for testing."""
before_hash = "aaa111" if operation != ChangeOperation.CREATE else None
after_hash = "bbb222" if operation != ChangeOperation.DELETE else None
return ChangeEntry(
plan_id=plan_id,
resource_id="res-cov-001",
tool_name="file-write",
operation=operation,
path=path,
before_hash=before_hash,
after_hash=after_hash,
)
class _BrokenSession:
"""Stub session that raises OperationalError on mutating ops."""
def add(self, _obj: Any) -> None:
"""Raise OperationalError."""
raise OperationalError("simulated", {}, None)
def flush(self) -> None:
"""Raise OperationalError."""
raise OperationalError("simulated", {}, None)
def rollback(self) -> None:
"""No-op rollback."""
class _BrokenQuerySession:
"""Stub session that raises OperationalError on query."""
def query(self, _model: Any) -> _BrokenQuerySession:
"""Return self to chain."""
raise OperationalError("simulated", {}, None)
class _BrokenDeleteSession:
"""Stub session that raises OperationalError on query-based delete."""
class _FakeQuery:
"""Fake query that raises on delete."""
def filter_by(self, **_kwargs: Any) -> _BrokenDeleteSession._FakeQuery:
"""Return self."""
return self
def delete(self) -> None:
"""Raise OperationalError."""
raise OperationalError("simulated", {}, None)
def query(self, _model: Any) -> _BrokenDeleteSession._FakeQuery:
"""Return a fake query."""
return self._FakeQuery()
def rollback(self) -> None:
"""No-op rollback."""
# ------ ChangeSetEntryRepository validation steps ------
@given("I have a coverage ChangeSetEntryRepository")
def step_create_coverage_entry_repo(context: Context) -> None:
"""Create a ChangeSetEntryRepository with real in-memory session."""
factory, session, engine = _make_session_factory()
context.cov_engine = engine
context.cov_entry_repo = ChangeSetEntryRepository(factory)
context._cleanup_handlers.append(lambda: session.close())
context._cleanup_handlers.append(lambda: engine.dispose())
@when("I try to coverage-save an entry with empty changeset_id")
def step_save_entry_empty_changeset_id(context: Context) -> None:
"""Attempt to save entry with empty changeset_id."""
entry = _make_change_entry()
try:
context.cov_entry_repo.save_entry("", entry)
context.cov_raised = None
except (ValueError, TypeError, DatabaseError) as exc:
context.cov_raised = exc
@when("I try to coverage-save a non-ChangeEntry object")
def step_save_entry_non_change_entry(context: Context) -> None:
"""Attempt to save a non-ChangeEntry object."""
try:
context.cov_entry_repo.save_entry("cs-valid", "not-an-entry") # type: ignore[arg-type]
context.cov_raised = None
except (ValueError, TypeError, DatabaseError) as exc:
context.cov_raised = exc
@when("I try to coverage-get entries for empty changeset_id")
def step_get_entries_empty_changeset_id(context: Context) -> None:
"""Attempt to get entries with empty changeset_id."""
try:
context.cov_entry_repo.get_entries_for_changeset("")
context.cov_raised = None
except (ValueError, DatabaseError) as exc:
context.cov_raised = exc
@when("I try to coverage-get entries for empty plan_id")
def step_get_entries_empty_plan_id(context: Context) -> None:
"""Attempt to get entries with empty plan_id."""
try:
context.cov_entry_repo.get_entries_for_plan("")
context.cov_raised = None
except (ValueError, DatabaseError) as exc:
context.cov_raised = exc
@when("I try to coverage-delete entries for empty changeset_id")
def step_delete_entries_empty_changeset_id(context: Context) -> None:
"""Attempt to delete entries with empty changeset_id."""
try:
context.cov_entry_repo.delete_for_changeset("")
context.cov_raised = None
except (ValueError, DatabaseError) as exc:
context.cov_raised = exc
@when("I try to coverage-delete entries for empty plan_id")
def step_delete_entries_empty_plan_id(context: Context) -> None:
"""Attempt to delete entries for empty plan_id."""
try:
context.cov_entry_repo.delete_for_plan("")
context.cov_raised = None
except (ValueError, DatabaseError) as exc:
context.cov_raised = exc
# ------ ChangeSetEntryRepository database error steps ------
@given("I have a coverage ChangeSetEntryRepository with a broken session")
def step_create_broken_entry_repo(context: Context) -> None:
"""Create a ChangeSetEntryRepository with a broken session factory."""
context.cov_entry_repo = ChangeSetEntryRepository(lambda: _BrokenSession()) # type: ignore[return-value]
@when("I try to coverage-save an entry that triggers an OperationalError")
def step_save_entry_operational_error(context: Context) -> None:
"""Attempt to save an entry that triggers OperationalError."""
entry = _make_change_entry()
try:
context.cov_entry_repo.save_entry("cs-broken", entry)
context.cov_raised = None
except (DatabaseError, OperationalError) as exc:
context.cov_raised = exc
@given("I have a coverage ChangeSetEntryRepository with a broken query session")
def step_create_broken_query_entry_repo(context: Context) -> None:
"""Create a ChangeSetEntryRepository with broken query session."""
context.cov_entry_repo = ChangeSetEntryRepository(lambda: _BrokenQuerySession()) # type: ignore[return-value]
@when('I try to coverage-get entries for changeset "{changeset_id}"')
def step_get_entries_broken_changeset(context: Context, changeset_id: str) -> None:
"""Attempt to get entries with broken query session."""
try:
context.cov_entry_repo.get_entries_for_changeset(changeset_id)
context.cov_raised = None
except (DatabaseError, OperationalError) as exc:
context.cov_raised = exc
@when('I try to coverage-get entries for plan "{plan_id}"')
def step_get_entries_broken_plan(context: Context, plan_id: str) -> None:
"""Attempt to get entries for plan with broken session."""
try:
context.cov_entry_repo.get_entries_for_plan(plan_id)
context.cov_raised = None
except (DatabaseError, OperationalError) as exc:
context.cov_raised = exc
@given("I have a coverage ChangeSetEntryRepository with a broken delete session")
def step_create_broken_delete_entry_repo(context: Context) -> None:
"""Create a ChangeSetEntryRepository with broken delete session."""
context.cov_entry_repo = ChangeSetEntryRepository(lambda: _BrokenDeleteSession()) # type: ignore[return-value]
@when('I try to coverage-delete entries for changeset "{changeset_id}"')
def step_delete_entries_broken_changeset(context: Context, changeset_id: str) -> None:
"""Attempt to delete entries for changeset with broken session."""
try:
context.cov_entry_repo.delete_for_changeset(changeset_id)
context.cov_raised = None
except (DatabaseError, OperationalError) as exc:
context.cov_raised = exc
@when('I try to coverage-delete entries for plan "{plan_id}"')
def step_delete_entries_broken_plan(context: Context, plan_id: str) -> None:
"""Attempt to delete entries for plan with broken session."""
try:
context.cov_entry_repo.delete_for_plan(plan_id)
context.cov_raised = None
except (DatabaseError, OperationalError) as exc:
context.cov_raised = exc
# ------ ChangeSetEntryRepository _to_domain steps ------
@given("I have a coverage ChangeSetEntryModel row with null hashes and modes")
def step_create_row_null_hashes(context: Context) -> None:
"""Create a ChangeSetEntryModel with null hashes and modes."""
context.cov_row = ChangeSetEntryModel(
entry_id="01ENTRY0000000000000000001",
changeset_id="01CSET00000000000000000001",
plan_id="01PLAN00000000000000000001",
resource_id="01RES000000000000000000001",
tool_name="file-write",
operation="modify",
path="src/test.py",
before_hash=None,
after_hash=None,
before_mode=None,
after_mode=None,
timestamp=datetime.now(UTC).isoformat(),
)
@given("I have a coverage ChangeSetEntryModel row with hashes and modes")
def step_create_row_with_hashes(context: Context) -> None:
"""Create a ChangeSetEntryModel with populated hashes and modes."""
context.cov_row = ChangeSetEntryModel(
entry_id="01ENTRY0000000000000000002",
changeset_id="01CSET00000000000000000002",
plan_id="01PLAN00000000000000000002",
resource_id="01RES000000000000000000002",
tool_name="file-write",
operation="modify",
path="src/test.py",
before_hash="abc123",
after_hash="def456",
before_mode=33188,
after_mode=33261,
timestamp=datetime.now(UTC).isoformat(),
)
@given("I have a coverage ChangeSetEntryModel row with empty string hashes")
def step_create_row_empty_string_hashes(context: Context) -> None:
"""Create a ChangeSetEntryModel with empty string hashes."""
context.cov_row = ChangeSetEntryModel(
entry_id="01ENTRY0000000000000000003",
changeset_id="01CSET00000000000000000003",
plan_id="01PLAN00000000000000000003",
resource_id="01RES000000000000000000003",
tool_name="file-write",
operation="modify",
path="src/test.py",
before_hash="",
after_hash="",
before_mode=None,
after_mode=None,
timestamp=datetime.now(UTC).isoformat(),
)
@when("I convert the coverage row to a domain ChangeEntry")
def step_convert_row_to_domain_entry(context: Context) -> None:
"""Convert the model row to a domain ChangeEntry."""
context.cov_domain_entry = ChangeSetEntryRepository._to_domain(context.cov_row)
@then("the domain entry before_hash should be None")
def step_assert_before_hash_none(context: Context) -> None:
"""Assert before_hash is None."""
assert context.cov_domain_entry.before_hash is None, (
f"Expected before_hash=None, got {context.cov_domain_entry.before_hash!r}"
)
@then("the domain entry after_hash should be None")
def step_assert_after_hash_none(context: Context) -> None:
"""Assert after_hash is None."""
assert context.cov_domain_entry.after_hash is None, (
f"Expected after_hash=None, got {context.cov_domain_entry.after_hash!r}"
)
@then("the domain entry before_mode should be None")
def step_assert_before_mode_none(context: Context) -> None:
"""Assert before_mode is None."""
assert context.cov_domain_entry.before_mode is None, (
f"Expected before_mode=None, got {context.cov_domain_entry.before_mode!r}"
)
@then("the domain entry after_mode should be None")
def step_assert_after_mode_none(context: Context) -> None:
"""Assert after_mode is None."""
assert context.cov_domain_entry.after_mode is None, (
f"Expected after_mode=None, got {context.cov_domain_entry.after_mode!r}"
)
@then('the domain entry before_hash should be "{expected}"')
def step_assert_before_hash_value(context: Context, expected: str) -> None:
"""Assert before_hash equals expected value."""
assert context.cov_domain_entry.before_hash == expected, (
f"Expected before_hash={expected!r}, got {context.cov_domain_entry.before_hash!r}"
)
@then('the domain entry after_hash should be "{expected}"')
def step_assert_after_hash_value(context: Context, expected: str) -> None:
"""Assert after_hash equals expected value."""
assert context.cov_domain_entry.after_hash == expected, (
f"Expected after_hash={expected!r}, got {context.cov_domain_entry.after_hash!r}"
)
@then("the domain entry before_mode should be {expected:d}")
def step_assert_before_mode_value(context: Context, expected: int) -> None:
"""Assert before_mode equals expected value."""
assert context.cov_domain_entry.before_mode == expected, (
f"Expected before_mode={expected}, got {context.cov_domain_entry.before_mode!r}"
)
@then("the domain entry after_mode should be {expected:d}")
def step_assert_after_mode_value(context: Context, expected: int) -> None:
"""Assert after_mode equals expected value."""
assert context.cov_domain_entry.after_mode == expected, (
f"Expected after_mode={expected}, got {context.cov_domain_entry.after_mode!r}"
)
# ------ ToolInvocationRepository validation steps ------
@given("I have a coverage ToolInvocationRepository")
def step_create_coverage_inv_repo(context: Context) -> None:
"""Create a ToolInvocationRepository with real in-memory session."""
factory, session, engine = _make_session_factory()
context.cov_engine = engine
context.cov_inv_repo = ToolInvocationRepository(factory)
context._cleanup_handlers.append(lambda: session.close())
context._cleanup_handlers.append(lambda: engine.dispose())
@when("I try to coverage-save a non-ToolInvocation object")
def step_save_non_tool_invocation(context: Context) -> None:
"""Attempt to save a non-ToolInvocation object."""
try:
context.cov_inv_repo.save_invocation("not-an-invocation") # type: ignore[arg-type]
context.cov_raised = None
except (ValueError, TypeError, DatabaseError) as exc:
context.cov_raised = exc
@when("I try to coverage-get invocations for empty plan_id")
def step_get_invocations_empty_plan_id(context: Context) -> None:
"""Attempt to get invocations with empty plan_id."""
try:
context.cov_inv_repo.get_invocations_for_plan("")
context.cov_raised = None
except (ValueError, DatabaseError) as exc:
context.cov_raised = exc
@when("I try to coverage-delete invocations for empty plan_id")
def step_delete_invocations_empty_plan_id(context: Context) -> None:
"""Attempt to delete invocations for empty plan_id."""
try:
context.cov_inv_repo.delete_for_plan("")
context.cov_raised = None
except (ValueError, DatabaseError) as exc:
context.cov_raised = exc
# ------ ToolInvocationRepository database error steps ------
@given("I have a coverage ToolInvocationRepository with a broken session")
def step_create_broken_inv_repo(context: Context) -> None:
"""Create a ToolInvocationRepository with a broken session factory."""
context.cov_inv_repo = ToolInvocationRepository(lambda: _BrokenSession()) # type: ignore[return-value]
@when("I try to coverage-save an invocation that triggers an OperationalError")
def step_save_invocation_operational_error(context: Context) -> None:
"""Attempt to save invocation that triggers OperationalError."""
inv = ToolInvocation(
plan_id="plan-broken",
tool_name="file-write",
arguments={"path": "test.py"},
success=True,
)
try:
context.cov_inv_repo.save_invocation(inv, changeset_id="cs-broken")
context.cov_raised = None
except (DatabaseError, OperationalError) as exc:
context.cov_raised = exc
@given("I have a coverage ToolInvocationRepository with a broken query session")
def step_create_broken_query_inv_repo(context: Context) -> None:
"""Create a ToolInvocationRepository with broken query session."""
context.cov_inv_repo = ToolInvocationRepository(lambda: _BrokenQuerySession()) # type: ignore[return-value]
@when('I try to coverage-get invocations for plan "{plan_id}"')
def step_get_invocations_broken_plan(context: Context, plan_id: str) -> None:
"""Attempt to get invocations with broken query session."""
try:
context.cov_inv_repo.get_invocations_for_plan(plan_id)
context.cov_raised = None
except (DatabaseError, OperationalError) as exc:
context.cov_raised = exc
@given("I have a coverage ToolInvocationRepository with a broken delete session")
def step_create_broken_delete_inv_repo(context: Context) -> None:
"""Create a ToolInvocationRepository with broken delete session."""
context.cov_inv_repo = ToolInvocationRepository(lambda: _BrokenDeleteSession()) # type: ignore[return-value]
@when('I try to coverage-delete invocations for plan "{plan_id}"')
def step_delete_invocations_broken_plan(context: Context, plan_id: str) -> None:
"""Attempt to delete invocations with broken session."""
try:
context.cov_inv_repo.delete_for_plan(plan_id)
context.cov_raised = None
except (DatabaseError, OperationalError) as exc:
context.cov_raised = exc
# ------ ToolInvocationRepository save with optional fields ------
@given("I have a coverage ToolInvocationRepository with real session")
def step_create_real_inv_repo(context: Context) -> None:
"""Create a ToolInvocationRepository with real in-memory session."""
factory, session, engine = _make_session_factory()
context.cov_engine = engine
context.cov_session_factory = factory
context.cov_inv_repo = ToolInvocationRepository(factory)
context._cleanup_handlers.append(lambda: session.close())
context._cleanup_handlers.append(lambda: engine.dispose())
@when("I coverage-save an invocation with result, completed_at, and provider_metadata")
def step_save_invocation_all_optional(context: Context) -> None:
"""Save invocation with all optional fields populated."""
context.cov_plan_id = "plan-all-opts"
inv = ToolInvocation(
plan_id=context.cov_plan_id,
tool_name="file-write",
skill_name="code-skill",
arguments={"path": "test.py", "content": "hello"},
result={"status": "ok", "bytes_written": 5},
error=None,
success=True,
duration_ms=42.5,
completed_at=datetime.now(UTC),
change_ids=["chg-1", "chg-2"],
sequence_number=3,
sandbox_path="/tmp/sandbox",
resource_refs=["res-1"],
provider_metadata={"model": "gpt-4", "latency_ms": 100},
)
context.cov_inv_repo.save_invocation(inv, changeset_id="cs-all-opts")
# Commit the session
session = context.cov_session_factory()
session.commit()
@then("the coverage-saved invocation should round-trip with all fields intact")
def step_assert_roundtrip_all_fields(context: Context) -> None:
"""Assert round-trip preserves all optional fields."""
invocations = context.cov_inv_repo.get_invocations_for_plan(context.cov_plan_id)
assert len(invocations) == 1, f"Expected 1 invocation, got {len(invocations)}"
inv = invocations[0]
assert inv.result is not None, "Expected result to not be None"
assert inv.result["status"] == "ok", (
f"Expected result status 'ok', got {inv.result}"
)
assert inv.completed_at is not None, "Expected completed_at to not be None"
assert inv.provider_metadata is not None, (
"Expected provider_metadata to not be None"
)
assert inv.provider_metadata["model"] == "gpt-4", (
f"Expected provider_metadata model 'gpt-4', got {inv.provider_metadata}"
)
assert inv.skill_name == "code-skill", (
f"Expected skill_name 'code-skill', got {inv.skill_name}"
)
assert inv.duration_ms == 42.5, f"Expected duration_ms 42.5, got {inv.duration_ms}"
assert inv.sequence_number == 3, (
f"Expected sequence_number 3, got {inv.sequence_number}"
)
assert inv.sandbox_path == "/tmp/sandbox", (
f"Expected sandbox_path, got {inv.sandbox_path}"
)
assert inv.change_ids == ["chg-1", "chg-2"], (
f"Expected change_ids, got {inv.change_ids}"
)
assert inv.resource_refs == ["res-1"], (
f"Expected resource_refs, got {inv.resource_refs}"
)
@when(
"I coverage-save an invocation with no result, no completed_at, and no provider_metadata"
)
def step_save_invocation_none_optional(context: Context) -> None:
"""Save invocation with all optional fields as None."""
context.cov_plan_id = "plan-no-opts"
inv = ToolInvocation(
plan_id=context.cov_plan_id,
tool_name="file-read",
arguments={},
result=None,
success=True,
completed_at=None,
provider_metadata=None,
)
context.cov_inv_repo.save_invocation(inv, changeset_id="cs-no-opts")
session = context.cov_session_factory()
session.commit()
@then("the coverage-saved invocation should round-trip with None optional fields")
def step_assert_roundtrip_none_fields(context: Context) -> None:
"""Assert round-trip preserves None optional fields."""
invocations = context.cov_inv_repo.get_invocations_for_plan(context.cov_plan_id)
assert len(invocations) == 1, f"Expected 1 invocation, got {len(invocations)}"
inv = invocations[0]
assert inv.result is None, f"Expected result=None, got {inv.result!r}"
assert inv.completed_at is None, (
f"Expected completed_at=None, got {inv.completed_at!r}"
)
assert inv.provider_metadata is None, (
f"Expected provider_metadata=None, got {inv.provider_metadata!r}"
)
@when("I coverage-save an invocation without changeset_id")
def step_save_invocation_no_changeset(context: Context) -> None:
"""Save invocation without changeset_id."""
context.cov_plan_id = "plan-no-cs"
inv = ToolInvocation(
plan_id=context.cov_plan_id,
tool_name="file-write",
arguments={"path": "test.py"},
success=True,
)
context.cov_inv_repo.save_invocation(inv, changeset_id=None)
session = context.cov_session_factory()
session.commit()
@then("the coverage-saved invocation for plan should be retrievable")
def step_assert_invocation_retrievable(context: Context) -> None:
"""Assert invocation is retrievable without changeset_id."""
invocations = context.cov_inv_repo.get_invocations_for_plan(context.cov_plan_id)
assert len(invocations) == 1, f"Expected 1 invocation, got {len(invocations)}"
# ------ ToolInvocationRepository _to_domain steps ------
@given("I have a coverage ToolInvocationModel row with null JSON fields")
def step_create_inv_row_null_json(context: Context) -> None:
"""Create a ToolInvocationModel with null JSON fields."""
context.cov_inv_row = ToolInvocationModel(
invocation_id="01INV000000000000000000001",
changeset_id=None,
plan_id="01PLAN00000000000000000001",
tool_name="file-write",
skill_name=None,
arguments_json=None,
result_json=None,
error=None,
success=True,
duration_ms=10.0,
started_at=datetime.now(UTC).isoformat(),
completed_at=None,
change_ids_json=None,
sequence_number=1,
sandbox_path=None,
resource_refs_json=None,
provider_metadata_json=None,
)
@given("I have a coverage ToolInvocationModel row with populated JSON fields")
def step_create_inv_row_populated_json(context: Context) -> None:
"""Create a ToolInvocationModel with populated JSON fields."""
context.cov_inv_row = ToolInvocationModel(
invocation_id="01INV000000000000000000002",
changeset_id="01CSET00000000000000000001",
plan_id="01PLAN00000000000000000002",
tool_name="file-write",
skill_name="code-skill",
arguments_json=json.dumps({"path": "test.py"}),
result_json=json.dumps({"status": "ok"}),
error=None,
success=True,
duration_ms=55.5,
started_at=datetime.now(UTC).isoformat(),
completed_at=datetime.now(UTC).isoformat(),
change_ids_json=json.dumps(["chg-1", "chg-2"]),
sequence_number=5,
sandbox_path="/tmp/sandbox",
resource_refs_json=json.dumps(["res-1"]),
provider_metadata_json=json.dumps({"model": "gpt-4"}),
)
@given("I have a coverage ToolInvocationModel row with null numeric fields")
def step_create_inv_row_null_numerics(context: Context) -> None:
"""Create a ToolInvocationModel with null numeric fields."""
context.cov_inv_row = ToolInvocationModel(
invocation_id="01INV000000000000000000003",
changeset_id=None,
plan_id="01PLAN00000000000000000003",
tool_name="file-read",
skill_name=None,
arguments_json=json.dumps({}),
result_json=None,
error=None,
success=True,
duration_ms=None,
started_at=datetime.now(UTC).isoformat(),
completed_at=None,
change_ids_json=json.dumps([]),
sequence_number=None,
sandbox_path=None,
resource_refs_json=json.dumps([]),
provider_metadata_json=None,
)
@given("I have a coverage ToolInvocationModel row with null completed_at")
def step_create_inv_row_null_completed(context: Context) -> None:
"""Create a ToolInvocationModel with null completed_at."""
context.cov_inv_row = ToolInvocationModel(
invocation_id="01INV000000000000000000004",
changeset_id=None,
plan_id="01PLAN00000000000000000004",
tool_name="file-write",
skill_name=None,
arguments_json=json.dumps({}),
result_json=None,
error=None,
success=True,
duration_ms=10.0,
started_at=datetime.now(UTC).isoformat(),
completed_at=None,
change_ids_json=json.dumps([]),
sequence_number=0,
sandbox_path=None,
resource_refs_json=json.dumps([]),
provider_metadata_json=None,
)
@given("I have a coverage ToolInvocationModel row with populated completed_at")
def step_create_inv_row_populated_completed(context: Context) -> None:
"""Create a ToolInvocationModel with populated completed_at."""
context.cov_inv_row = ToolInvocationModel(
invocation_id="01INV000000000000000000005",
changeset_id=None,
plan_id="01PLAN00000000000000000005",
tool_name="file-write",
skill_name=None,
arguments_json=json.dumps({}),
result_json=None,
error=None,
success=True,
duration_ms=10.0,
started_at=datetime.now(UTC).isoformat(),
completed_at=datetime.now(UTC).isoformat(),
change_ids_json=json.dumps([]),
sequence_number=0,
sandbox_path=None,
resource_refs_json=json.dumps([]),
provider_metadata_json=None,
)
@when("I convert the coverage row to a domain ToolInvocation")
def step_convert_row_to_domain_invocation(context: Context) -> None:
"""Convert the model row to a domain ToolInvocation."""
context.cov_domain_inv = ToolInvocationRepository._to_domain(context.cov_inv_row)
@then("the domain invocation arguments should be an empty dict")
def step_assert_inv_args_empty(context: Context) -> None:
"""Assert arguments is empty dict."""
assert context.cov_domain_inv.arguments == {}, (
f"Expected empty dict, got {context.cov_domain_inv.arguments!r}"
)
@then("the domain invocation result should be None")
def step_assert_inv_result_none(context: Context) -> None:
"""Assert result is None."""
assert context.cov_domain_inv.result is None, (
f"Expected result=None, got {context.cov_domain_inv.result!r}"
)
@then("the domain invocation change_ids should be an empty list")
def step_assert_inv_change_ids_empty(context: Context) -> None:
"""Assert change_ids is empty list."""
assert context.cov_domain_inv.change_ids == [], (
f"Expected empty list, got {context.cov_domain_inv.change_ids!r}"
)
@then("the domain invocation resource_refs should be an empty list")
def step_assert_inv_resource_refs_empty(context: Context) -> None:
"""Assert resource_refs is empty list."""
assert context.cov_domain_inv.resource_refs == [], (
f"Expected empty list, got {context.cov_domain_inv.resource_refs!r}"
)
@then("the domain invocation provider_metadata should be None")
def step_assert_inv_provider_meta_none(context: Context) -> None:
"""Assert provider_metadata is None."""
assert context.cov_domain_inv.provider_metadata is None, (
f"Expected None, got {context.cov_domain_inv.provider_metadata!r}"
)
@then('the domain invocation arguments should have key "{key}"')
def step_assert_inv_args_has_key(context: Context, key: str) -> None:
"""Assert arguments dict has given key."""
assert key in context.cov_domain_inv.arguments, (
f"Expected key '{key}' in arguments, got {context.cov_domain_inv.arguments!r}"
)
@then('the domain invocation result should have key "{key}"')
def step_assert_inv_result_has_key(context: Context, key: str) -> None:
"""Assert result dict has given key."""
assert context.cov_domain_inv.result is not None, "Expected result to not be None"
assert key in context.cov_domain_inv.result, (
f"Expected key '{key}' in result, got {context.cov_domain_inv.result!r}"
)
@then("the domain invocation change_ids should have {count:d} entries")
def step_assert_inv_change_ids_count(context: Context, count: int) -> None:
"""Assert change_ids has expected number of entries."""
assert len(context.cov_domain_inv.change_ids) == count, (
f"Expected {count} change_ids, got {len(context.cov_domain_inv.change_ids)}"
)
@then("the domain invocation resource_refs should have {count:d} entry")
def step_assert_inv_resource_refs_count(context: Context, count: int) -> None:
"""Assert resource_refs has expected count."""
assert len(context.cov_domain_inv.resource_refs) == count, (
f"Expected {count} resource_refs, got {len(context.cov_domain_inv.resource_refs)}"
)
@then('the domain invocation provider_metadata should have key "{key}"')
def step_assert_inv_provider_meta_has_key(context: Context, key: str) -> None:
"""Assert provider_metadata dict has given key."""
assert context.cov_domain_inv.provider_metadata is not None, (
"Expected provider_metadata to not be None"
)
assert key in context.cov_domain_inv.provider_metadata, (
f"Expected key '{key}' in provider_metadata, got {context.cov_domain_inv.provider_metadata!r}"
)
@then("the domain invocation duration_ms should be 0.0")
def step_assert_inv_duration_zero(context: Context) -> None:
"""Assert duration_ms is 0.0."""
assert context.cov_domain_inv.duration_ms == 0.0, (
f"Expected duration_ms=0.0, got {context.cov_domain_inv.duration_ms}"
)
@then("the domain invocation sequence_number should be 0")
def step_assert_inv_seq_zero(context: Context) -> None:
"""Assert sequence_number is 0."""
assert context.cov_domain_inv.sequence_number == 0, (
f"Expected sequence_number=0, got {context.cov_domain_inv.sequence_number}"
)
@then("the domain invocation completed_at should be None")
def step_assert_inv_completed_none(context: Context) -> None:
"""Assert completed_at is None."""
assert context.cov_domain_inv.completed_at is None, (
f"Expected completed_at=None, got {context.cov_domain_inv.completed_at!r}"
)
@then("the domain invocation completed_at should be a datetime")
def step_assert_inv_completed_datetime(context: Context) -> None:
"""Assert completed_at is a datetime."""
assert isinstance(context.cov_domain_inv.completed_at, datetime), (
f"Expected datetime, got {type(context.cov_domain_inv.completed_at)}"
)
# ------ SqliteChangeSetStore steps ------
@given("I have a coverage sqlite changeset store")
def step_create_coverage_store(context: Context) -> None:
"""Create a SqliteChangeSetStore backed by in-memory SQLite."""
factory, session, engine = _make_session_factory()
context.cov_engine = engine
context.cov_session_factory = factory
context.cov_store = SqliteChangeSetStore(factory)
context._cleanup_handlers.append(lambda: session.close())
context._cleanup_handlers.append(lambda: engine.dispose())
@when("I coverage-get a changeset with empty string")
def step_get_changeset_empty(context: Context) -> None:
"""Get a changeset with empty string ID."""
context.cov_get_result = context.cov_store.get("")
@when('I coverage-get a changeset with id "{changeset_id}"')
def step_get_changeset_by_id(context: Context, changeset_id: str) -> None:
"""Get a changeset by specific ID."""
context.cov_get_result = context.cov_store.get(changeset_id)
@when('I coverage-start a changeset for plan "{plan_id}"')
def step_start_changeset_coverage(context: Context, plan_id: str) -> None:
"""Start a changeset for a plan."""
context.cov_changeset_id = context.cov_store.start(plan_id)
context.cov_plan_id = plan_id
@when("I coverage-get the started changeset")
def step_get_started_changeset(context: Context) -> None:
"""Get the changeset that was just started."""
context.cov_get_result = context.cov_store.get(context.cov_changeset_id)
@when("I coverage-record a create entry in the started changeset")
def step_record_entry_coverage(context: Context) -> None:
"""Record a create entry in the started changeset."""
entry = _make_change_entry(
plan_id=context.cov_plan_id,
operation=ChangeOperation.CREATE,
path="src/new_cov.py",
)
context.cov_store.record(context.cov_changeset_id, entry)
# Commit the session so the entry is visible
session = context.cov_session_factory()
session.commit()
@when("I try to coverage-record an entry with empty changeset_id")
def step_record_empty_changeset_id(context: Context) -> None:
"""Attempt to record entry with empty changeset_id."""
entry = _make_change_entry()
try:
context.cov_store.record("", entry)
context.cov_raised = None
except ValueError as exc:
context.cov_raised = exc
@when("I coverage-get_for_plan with empty plan_id")
def step_get_for_plan_empty(context: Context) -> None:
"""Get changesets for empty plan_id."""
context.cov_for_plan_result = context.cov_store.get_for_plan("")
@when('I coverage-get_for_plan with plan_id "{plan_id}"')
def step_get_for_plan_by_id(context: Context, plan_id: str) -> None:
"""Get changesets for specific plan_id."""
context.cov_for_plan_result = context.cov_store.get_for_plan(plan_id)
@when("I coverage-summarize changeset with empty string")
def step_summarize_empty(context: Context) -> None:
"""Summarize changeset with empty string ID."""
context.cov_summarize_result = context.cov_store.summarize("")
@when("I coverage-summarize the started changeset")
def step_summarize_started(context: Context) -> None:
"""Summarize the changeset that was started."""
context.cov_summarize_result = context.cov_store.summarize(context.cov_changeset_id)
@when("I try to coverage-delete_for_plan with empty plan_id")
def step_delete_for_plan_empty(context: Context) -> None:
"""Attempt to delete for empty plan_id."""
try:
context.cov_store.delete_for_plan("")
context.cov_raised = None
except ValueError as exc:
context.cov_raised = exc
@when('I coverage-delete_for_plan "{plan_id}"')
def step_delete_for_plan_coverage(context: Context, plan_id: str) -> None:
"""Delete all entries for a plan."""
context.cov_delete_count = context.cov_store.delete_for_plan(plan_id)
@then("the coverage-get result should be None")
def step_assert_get_result_none(context: Context) -> None:
"""Assert get result is None."""
assert context.cov_get_result is None, (
f"Expected None, got {context.cov_get_result!r}"
)
@then("the coverage-get result should be a SpecChangeSet with {count:d} entries")
def step_assert_get_result_spec_changeset(context: Context, count: int) -> None:
"""Assert get result is a SpecChangeSet with expected entries."""
from cleveragents.domain.models.core.change import SpecChangeSet
assert context.cov_get_result is not None, "Expected SpecChangeSet, got None"
assert isinstance(context.cov_get_result, SpecChangeSet), (
f"Expected SpecChangeSet, got {type(context.cov_get_result)}"
)
assert len(context.cov_get_result.entries) == count, (
f"Expected {count} entries, got {len(context.cov_get_result.entries)}"
)
@then('the coverage-get result plan_id should be "{expected}"')
def step_assert_get_result_plan_id(context: Context, expected: str) -> None:
"""Assert get result plan_id matches expected."""
assert context.cov_get_result.plan_id == expected, (
f"Expected plan_id={expected!r}, got {context.cov_get_result.plan_id!r}"
)
@then("the coverage-get_for_plan result should be an empty list")
def step_assert_for_plan_empty(context: Context) -> None:
"""Assert get_for_plan result is empty list."""
assert context.cov_for_plan_result == [], (
f"Expected empty list, got {context.cov_for_plan_result!r}"
)
@then("the coverage-summarize result should be an empty dict")
def step_assert_summarize_empty(context: Context) -> None:
"""Assert summarize result is empty dict."""
assert context.cov_summarize_result == {}, (
f"Expected empty dict, got {context.cov_summarize_result!r}"
)
@then("the coverage-summarize result total should be {count:d}")
def step_assert_summarize_total(context: Context, count: int) -> None:
"""Assert summarize result total matches expected."""
assert context.cov_summarize_result.get("total") == count, (
f"Expected total={count}, got {context.cov_summarize_result!r}"
)
@then("the coverage-delete count should be {count:d}")
def step_assert_delete_count(context: Context, count: int) -> None:
"""Assert delete count matches expected."""
assert context.cov_delete_count == count, (
f"Expected delete count={count}, got {context.cov_delete_count}"
)
# ------ Shared assertion steps ------
@then('a coverage ValueError should be raised with message "{message}"')
def step_assert_coverage_value_error(context: Context, message: str) -> None:
"""Assert a ValueError was raised with expected message."""
assert isinstance(context.cov_raised, ValueError), (
f"Expected ValueError, got {type(context.cov_raised).__name__}: {context.cov_raised!r}"
)
assert message in str(context.cov_raised), (
f"Expected message containing '{message}', got '{context.cov_raised}'"
)
@then('a coverage TypeError should be raised with message "{message}"')
def step_assert_coverage_type_error(context: Context, message: str) -> None:
"""Assert a TypeError was raised with expected message."""
assert isinstance(context.cov_raised, TypeError), (
f"Expected TypeError, got {type(context.cov_raised).__name__}: {context.cov_raised!r}"
)
assert message in str(context.cov_raised), (
f"Expected message containing '{message}', got '{context.cov_raised}'"
)
@then('a coverage DatabaseError should be raised with message "{message}"')
def step_assert_coverage_db_error(context: Context, message: str) -> None:
"""Assert a DatabaseError was raised with expected message."""
assert isinstance(context.cov_raised, (DatabaseError, OperationalError)), (
f"Expected DatabaseError or OperationalError, got {type(context.cov_raised).__name__}: {context.cov_raised!r}"
)
assert message in str(context.cov_raised), (
f"Expected message containing '{message}', got '{context.cov_raised}'"
)