Files
cleveragents-core/features/steps/decision_recording_steps.py
T
HAL9000 241c2602b0
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 1m1s
CI / helm (pull_request) Successful in 36s
CI / typecheck (pull_request) Successful in 1m11s
CI / push-validation (pull_request) Successful in 34s
CI / build (pull_request) Successful in 53s
CI / security (pull_request) Successful in 1m17s
CI / quality (pull_request) Successful in 1m23s
CI / benchmark-regression (pull_request) Failing after 1m14s
CI / integration_tests (pull_request) Successful in 5m14s
CI / e2e_tests (pull_request) Successful in 5m22s
CI / unit_tests (pull_request) Successful in 9m0s
CI / docker (pull_request) Successful in 1m51s
CI / coverage (pull_request) Successful in 11m11s
CI / status-check (push) Blocked by required conditions
CI / benchmark-publish (push) Has started running
CI / benchmark-regression (push) Has been skipped
CI / status-check (pull_request) Successful in 4s
CI / lint (push) Successful in 1m17s
CI / push-validation (push) Successful in 35s
CI / build (push) Successful in 54s
CI / quality (push) Successful in 1m29s
CI / helm (push) Successful in 47s
CI / security (push) Successful in 1m33s
CI / typecheck (push) Successful in 1m38s
CI / integration_tests (push) Successful in 4m25s
CI / e2e_tests (push) Failing after 5m25s
CI / unit_tests (push) Successful in 7m18s
CI / coverage (push) Has started running
CI / docker (push) Has started running
fix(quality-gates): resolve ruff formatting and missing type annotations
Fix two lint blockers identified in PR review:

1. Collapsible list comprehension format (ruff RUF015): Collapse the
   "projects" list comprehension in _build_strategize_context_snapshot()
   from 3 lines to a single line within the 88-character limit.

2. Missing type annotations on 13 new BDD step functions: All existing
   step functions use explicit `context: Context` and `-> None` return
   type annotations per project style. The 13 new step functions added
   for issue #9056 were missing these declarations, causing lint
   warning RUF012 (missing type annotations on function arguments).

Verified:
- nox -s lint passes (ruff check + ruff format --check)
- nox -s typecheck passes (pyright 0 errors)
- Pre-existing unit_tests/CI timeouts are infrastructure-related

ISSUES CLOSED: #9056
2026-05-08 05:14:03 +00:00

1251 lines
44 KiB
Python

"""Step definitions for decision_recording.feature.
All ``Then`` step texts are prefixed with ``dsvc`` (decision-service) to
avoid collisions with the existing ``decision_model_steps.py`` which
defines similar assertion patterns for the domain model layer.
"""
from __future__ import annotations
import contextlib
import os
import re
import tempfile
from behave import given, then, when
from behave.runner import Context
from ulid import ULID
from cleveragents.application.services.decision_service import (
DecisionNotFoundError,
DecisionService,
DuplicateDecisionError,
SequenceConflictError,
SnapshotStore,
)
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.decision import (
ArtifactRef,
ContextSnapshot,
Decision,
DecisionType,
)
def _resolve_plan_id(context: Context, symbolic_id: str) -> str:
"""Map a human-readable plan name to a stable ULID.
The Decision model requires plan_id to be a valid 26-char ULID.
Feature files use friendly names like ``"P1"``; this helper
lazily generates and caches a ULID for each symbolic name.
"""
registry: dict[str, str] = getattr(context, "_plan_id_registry", {})
if symbolic_id not in registry:
registry[symbolic_id] = str(ULID())
context._plan_id_registry = registry
return registry[symbolic_id]
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a decision service")
def step_decision_service(context: Context) -> None:
context.decision_service = DecisionService()
context.recorded_decisions = []
context.decision_error = None
context.decision_result = None
context.decision_list = None
context.tree_result = None
context.path_result = None
context.superseded_result = None
context.snapshot_result = None
context.snapshots_dict = None
context.hash_query_result = None
context.delete_result = None
context.explicit_snapshot = None
context._plan_id_registry = {}
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
@given('a context snapshot with hash "{ctx_hash}" and ref "{ctx_ref}"')
def step_explicit_snapshot(context: Context, ctx_hash: str, ctx_ref: str) -> None:
context.explicit_snapshot = ContextSnapshot(
hot_context_hash=ctx_hash,
hot_context_ref=ctx_ref,
)
@given('a DuplicateDecisionError for decision "{decision_id}"')
def step_duplicate_error(context: Context, decision_id: str) -> None:
context.decision_error = DuplicateDecisionError(decision_id)
@given('a SequenceConflictError for plan "{plan_id}" sequence {seq:d}')
def step_sequence_error(context: Context, plan_id: str, seq: int) -> None:
context.decision_error = SequenceConflictError(plan_id, seq)
@given("a standalone snapshot store")
def step_standalone_snapshot_store(context: Context) -> None:
context.standalone_store = SnapshotStore()
# ---------------------------------------------------------------------------
# When — Recording
# ---------------------------------------------------------------------------
@when(
'I record a prompt_definition decision for plan "{plan_id}" '
'with question "{question}"'
)
def step_record_root(context: Context, plan_id: str, question: str) -> None:
svc = context.decision_service
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.PROMPT_DEFINITION,
question=question,
chosen_option=question,
)
context.decision_result = d
context.recorded_decisions.append(d)
@when(
'I record a strategy_choice decision for plan "{plan_id}" '
'with question "{question}"'
)
def step_record_strategy(context: Context, plan_id: str, question: str) -> None:
svc = context.decision_service
parent_id = None
if context.recorded_decisions:
parent_id = context.recorded_decisions[0].decision_id
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.STRATEGY_CHOICE,
question=question,
chosen_option=f"Chosen: {question}",
parent_decision_id=parent_id,
)
context.decision_result = d
context.recorded_decisions.append(d)
@when(
'I record an implementation_choice decision for plan "{plan_id}" '
'with question "{question}"'
)
def step_record_impl(context: Context, plan_id: str, question: str) -> None:
svc = context.decision_service
parent_id = None
if context.recorded_decisions:
parent_id = context.recorded_decisions[0].decision_id
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question=question,
chosen_option=f"Chosen: {question}",
parent_decision_id=parent_id,
)
context.decision_result = d
context.recorded_decisions.append(d)
@when(
'I record a strategy_choice decision for plan "{plan_id}" '
"with the explicit snapshot"
)
def step_record_with_snapshot(context: Context, plan_id: str) -> None:
svc = context.decision_service
parent_id = None
if context.recorded_decisions:
parent_id = context.recorded_decisions[0].decision_id
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.STRATEGY_CHOICE,
question="Snapshot question",
chosen_option="Snapshot choice",
parent_decision_id=parent_id,
context_snapshot=context.explicit_snapshot,
)
context.decision_result = d
context.recorded_decisions.append(d)
@when(
'I record a strategy_choice decision for plan "{plan_id}" '
"with the same explicit snapshot"
)
def step_record_with_same_snapshot(context: Context, plan_id: str) -> None:
svc = context.decision_service
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.STRATEGY_CHOICE,
question="Another snapshot question",
chosen_option="Another choice",
context_snapshot=context.explicit_snapshot,
)
context.decision_result = d
context.recorded_decisions.append(d)
@when(
'I record a strategy_choice decision for plan "{plan_id}" with confidence {score:g}'
)
def step_record_with_confidence(context: Context, plan_id: str, score: float) -> None:
svc = context.decision_service
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.STRATEGY_CHOICE,
question="Confidence test",
chosen_option="Chosen",
confidence_score=score,
)
context.decision_result = d
context.recorded_decisions.append(d)
@when(
'I record a correction decision for plan "{plan_id}" correcting the first decision'
)
def step_record_correction(context: Context, plan_id: str) -> None:
svc = context.decision_service
first = context.recorded_decisions[0]
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.STRATEGY_CHOICE,
question="Correction question",
chosen_option="Corrected choice",
parent_decision_id=first.parent_decision_id,
is_correction=True,
corrects_decision_id=first.decision_id,
correction_reason="Fixed mistake",
)
context.decision_result = d
context.recorded_decisions.append(d)
@when(
'I record a strategy_choice decision for plan "{plan_id}" '
'with alternatives "{a1}" "{a2}" "{a3}"'
)
def step_record_with_alternatives(
context: Context, plan_id: str, a1: str, a2: str, a3: str
) -> None:
svc = context.decision_service
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.STRATEGY_CHOICE,
question="Alternatives test",
chosen_option="Chosen",
alternatives_considered=[a1, a2, a3],
)
context.decision_result = d
context.recorded_decisions.append(d)
@when('I record an implementation_choice decision for plan "{plan_id}" with artifacts')
def step_record_with_artifacts(context: Context, plan_id: str) -> None:
svc = context.decision_service
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question="Artifact test",
chosen_option="Chosen",
artifacts_produced=[
ArtifactRef(artifact_path="src/main.py", artifact_type="file"),
ArtifactRef(artifact_path="tests/test_main.py", artifact_type="file"),
],
)
context.decision_result = d
context.recorded_decisions.append(d)
@when('I record a decision for plan "{plan_id}" with string type "{dtype}"')
def step_record_string_type(context: Context, plan_id: str, dtype: str) -> None:
svc = context.decision_service
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=dtype,
question="String type test",
chosen_option="Chosen",
)
context.decision_result = d
context.recorded_decisions.append(d)
# --- Recording errors ---
@when("I try to record a decision with empty plan_id")
def step_try_empty_plan_id(context: Context) -> None:
try:
context.decision_service.record_decision(
plan_id="",
decision_type=DecisionType.STRATEGY_CHOICE,
question="Q",
chosen_option="A",
)
context.decision_error = None
except ValidationError as exc:
context.decision_error = exc
@when("I try to record a decision with empty question")
def step_try_empty_question(context: Context) -> None:
try:
context.decision_service.record_decision(
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
decision_type=DecisionType.STRATEGY_CHOICE,
question="",
chosen_option="A",
)
context.decision_error = None
except ValidationError as exc:
context.decision_error = exc
@when("I try to record a decision with empty chosen_option")
def step_try_empty_chosen(context: Context) -> None:
try:
context.decision_service.record_decision(
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
decision_type=DecisionType.STRATEGY_CHOICE,
question="Q",
chosen_option="",
)
context.decision_error = None
except ValidationError as exc:
context.decision_error = exc
@when('I try to record a decision with invalid type "{dtype}"')
def step_try_invalid_type(context: Context, dtype: str) -> None:
try:
context.decision_service.record_decision(
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
decision_type=dtype,
question="Q",
chosen_option="A",
)
context.decision_error = None
except (ValueError, ValidationError) as exc:
context.decision_error = exc
@when("I try to record a decision with confidence {score:g}")
def step_try_invalid_confidence(context: Context, score: float) -> None:
import pydantic
try:
context.decision_service.record_decision(
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
decision_type=DecisionType.STRATEGY_CHOICE,
question="Q",
chosen_option="A",
confidence_score=score,
)
context.decision_error = None
except (ValidationError, pydantic.ValidationError) as exc:
context.decision_error = exc
# ---------------------------------------------------------------------------
# When — Retrieval
# ---------------------------------------------------------------------------
@when("I get the decision by its ID")
def step_get_by_id(context: Context) -> None:
d = context.decision_result
context.decision_result = context.decision_service.get_decision(d.decision_id)
@when('I try to get decision "{decision_id}"')
def step_try_get(context: Context, decision_id: str) -> None:
try:
context.decision_service.get_decision(decision_id)
context.decision_error = None
except DecisionNotFoundError as exc:
context.decision_error = exc
@when('I list decisions for plan "{plan_id}"')
def step_list_decisions(context: Context, plan_id: str) -> None:
context.decision_list = context.decision_service.list_decisions(
_resolve_plan_id(context, plan_id)
)
@when('I filter decisions for plan "{plan_id}" by type "{dtype}"')
def step_list_by_type(context: Context, plan_id: str, dtype: str) -> None:
context.decision_list = context.decision_service.list_by_type(
_resolve_plan_id(context, plan_id), dtype
)
# ---------------------------------------------------------------------------
# When — Tree operations
# ---------------------------------------------------------------------------
@when(
'I record a strategy_choice decision for plan "{plan_id}" '
'with parent as child "{question}"'
)
def step_record_child(context: Context, plan_id: str, question: str) -> None:
svc = context.decision_service
parent = context.recorded_decisions[0]
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.STRATEGY_CHOICE,
question=question,
chosen_option=f"Chosen: {question}",
parent_decision_id=parent.decision_id,
)
context.decision_result = d
context.recorded_decisions.append(d)
@when(
'I record an implementation_choice decision for plan "{plan_id}" '
'with second parent as child "{question}"'
)
def step_record_grandchild(context: Context, plan_id: str, question: str) -> None:
svc = context.decision_service
# Use the second recorded decision (index 1) as parent
parent = context.recorded_decisions[1]
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
question=question,
chosen_option=f"Chosen: {question}",
parent_decision_id=parent.decision_id,
)
context.decision_result = d
context.recorded_decisions.append(d)
@when('I get the tree for plan "{plan_id}"')
def step_get_tree(context: Context, plan_id: str) -> None:
context.tree_result = context.decision_service.get_tree(
_resolve_plan_id(context, plan_id)
)
@when("I get the path to root from the last decision")
def step_get_path(context: Context) -> None:
last = context.recorded_decisions[-1]
context.path_result = context.decision_service.get_path_to_root(last.decision_id)
@when('I try to get path to root for decision "{decision_id}"')
def step_try_path(context: Context, decision_id: str) -> None:
try:
context.decision_service.get_path_to_root(decision_id)
context.decision_error = None
except DecisionNotFoundError as exc:
context.decision_error = exc
# ---------------------------------------------------------------------------
# When — Superseded
# ---------------------------------------------------------------------------
@when("I mark the first decision as superseded by the second")
def step_mark_superseded(context: Context) -> None:
first = context.recorded_decisions[0]
second = context.recorded_decisions[1]
context.decision_result = context.decision_service.mark_superseded(
first.decision_id, second.decision_id
)
# Update stored reference
context.recorded_decisions[0] = context.decision_result
@when('I get superseded decisions for plan "{plan_id}"')
def step_get_superseded(context: Context, plan_id: str) -> None:
context.superseded_result = context.decision_service.get_superseded(
_resolve_plan_id(context, plan_id)
)
@when('I try to mark decision "{decision_id}" as superseded')
def step_try_supersede(context: Context, decision_id: str) -> None:
try:
context.decision_service.mark_superseded(
decision_id, "01FAKE0000000000000000000"
)
context.decision_error = None
except DecisionNotFoundError as exc:
context.decision_error = exc
# ---------------------------------------------------------------------------
# When — Delete
# ---------------------------------------------------------------------------
@when("I delete the recorded decision")
def step_delete(context: Context) -> None:
d = context.decision_result
context.delete_result = context.decision_service.delete_decision(d.decision_id)
context.deleted_decision_id = d.decision_id
@when('I try to delete decision "{decision_id}"')
def step_try_delete(context: Context, decision_id: str) -> None:
try:
context.decision_service.delete_decision(decision_id)
context.decision_error = None
except DecisionNotFoundError as exc:
context.decision_error = exc
# ---------------------------------------------------------------------------
# When — Snapshots
# ---------------------------------------------------------------------------
@when("I get the snapshot for the recorded decision")
def step_get_snapshot(context: Context) -> None:
d = context.decision_result
context.snapshot_result = context.decision_service.get_snapshot(d.decision_id)
@when("I get the snapshot for the deleted decision")
def step_get_snapshot_deleted(context: Context) -> None:
context.snapshot_result = context.decision_service.get_snapshot(
context.deleted_decision_id
)
@when('I get snapshots for plan "{plan_id}"')
def step_get_plan_snapshots(context: Context, plan_id: str) -> None:
context.snapshots_dict = context.decision_service.get_snapshots_for_plan(
_resolve_plan_id(context, plan_id)
)
@when('I query the snapshot store by hash "{ctx_hash}"')
def step_query_hash(context: Context, ctx_hash: str) -> None:
context.hash_query_result = context.decision_service.snapshots.get_by_hash(ctx_hash)
# --- Standalone snapshot store ---
@when('I remove snapshot for decision "{decision_id}"')
def step_standalone_remove(context: Context, decision_id: str) -> None:
context.snapshot_remove_result = context.standalone_store.remove(decision_id)
@when('I get snapshot for decision "{decision_id}"')
def step_standalone_get(context: Context, decision_id: str) -> None:
context.standalone_snapshot = context.standalone_store.get(decision_id)
@when('I query the standalone store by hash "{ctx_hash}"')
def step_standalone_hash(context: Context, ctx_hash: str) -> None:
context.standalone_hash_result = context.standalone_store.get_by_hash(ctx_hash)
# ---------------------------------------------------------------------------
# Then — Recording assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then("the dsvc decision should be recorded successfully")
def step_recorded(context: Context) -> None:
assert context.decision_result is not None
@then("the dsvc decision sequence number should be {seq:d}")
def step_seq_number(context: Context, seq: int) -> None:
assert context.decision_result.sequence_number == seq
@then('the dsvc decision type should be "{dtype}"')
def step_decision_type(context: Context, dtype: str) -> None:
assert str(context.decision_result.decision_type) == dtype
@then("the dsvc decision should be a root decision")
def step_is_root(context: Context) -> None:
assert context.decision_result.is_root
@then("the dsvc decision should have a valid ULID as decision_id")
def step_valid_ulid(context: Context) -> None:
ulid_re = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
assert ulid_re.match(context.decision_result.decision_id)
@then("the dsvc decision context snapshot hash should not be empty")
def step_snapshot_hash_not_empty(context: Context) -> None:
assert context.decision_result.context_snapshot.hot_context_hash != ""
@then('the dsvc plan "{plan_id}" should have {count:d} decisions')
def step_plan_count(context: Context, plan_id: str, count: int) -> None:
assert (
context.decision_service.count_decisions(_resolve_plan_id(context, plan_id))
== count
)
@then("the dsvc decisions should have sequence numbers {nums}")
def step_seq_numbers(context: Context, nums: str) -> None:
expected = [int(n) for n in nums.split()]
actual = [d.sequence_number for d in context.recorded_decisions]
assert actual == expected, f"Expected {expected}, got {actual}"
@then('the dsvc decision context snapshot hash should be "{expected}"')
def step_snapshot_hash_exact(context: Context, expected: str) -> None:
assert context.decision_result.context_snapshot.hot_context_hash == expected
@then('the dsvc decision context snapshot ref should be "{expected}"')
def step_snapshot_ref_exact(context: Context, expected: str) -> None:
assert context.decision_result.context_snapshot.hot_context_ref == expected
@then("the dsvc decision confidence score should be {score:g}")
def step_confidence(context: Context, score: float) -> None:
assert context.decision_result.confidence_score == score
@then("the dsvc correction decision is_correction should be true")
def step_is_correction(context: Context) -> None:
assert context.decision_result.is_correction
@then(
"the dsvc correction decision corrects_decision_id should match the first decision"
)
def step_corrects_first(context: Context) -> None:
first = context.recorded_decisions[0]
assert context.decision_result.corrects_decision_id == first.decision_id
@then("the dsvc decision should have {count:d} alternatives considered")
def step_alternatives_count(context: Context, count: int) -> None:
assert len(context.decision_result.alternatives_considered) == count
@then("the dsvc decision should have {count:d} artifacts produced")
def step_artifacts_count(context: Context, count: int) -> None:
assert len(context.decision_result.artifacts_produced) == count
@then('the dsvc decision context snapshot hash should start with "{prefix}"')
def step_snapshot_hash_prefix(context: Context, prefix: str) -> None:
assert context.decision_result.context_snapshot.hot_context_hash.startswith(prefix)
# ---------------------------------------------------------------------------
# Then — Validation errors (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then("a dsvc validation error should be raised")
def step_validation_error(context: Context) -> None:
import pydantic
assert context.decision_error is not None
assert isinstance(
context.decision_error, (ValidationError, ValueError, pydantic.ValidationError)
)
@then('the dsvc error should mention "{text}"')
def step_error_mention(context: Context, text: str) -> None:
assert text in str(context.decision_error)
@then("a dsvc value error should be raised")
def step_value_error(context: Context) -> None:
assert context.decision_error is not None
assert isinstance(context.decision_error, ValueError)
@then("a dsvc decision not found error should be raised")
def step_not_found_error(context: Context) -> None:
assert context.decision_error is not None
assert isinstance(context.decision_error, DecisionNotFoundError)
# ---------------------------------------------------------------------------
# Then — Retrieval assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then("the dsvc retrieved decision should match the recorded decision")
def step_match_recorded(context: Context) -> None:
recorded = context.recorded_decisions[-1]
assert context.decision_result.decision_id == recorded.decision_id
@then("the dsvc decision list should have {count:d} entries")
def step_list_count(context: Context, count: int) -> None:
assert len(context.decision_list) == count
@then("the dsvc decision list should be ordered by sequence number")
def step_list_ordered(context: Context) -> None:
seqs = [d.sequence_number for d in context.decision_list]
assert seqs == sorted(seqs)
# ---------------------------------------------------------------------------
# Then — Tree assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then("the dsvc tree should have {count:d} decisions")
def step_tree_count(context: Context, count: int) -> None:
assert len(context.tree_result) == count
@then("the dsvc first tree decision should be a root decision")
def step_tree_root(context: Context) -> None:
assert context.tree_result[0].is_root
@then("the dsvc tree should be in BFS level order")
def step_tree_bfs_order(context: Context) -> None:
"""Verify that all nodes at depth *d* appear before any node at depth *d+1*.
We compute each node's depth by walking ``parent_decision_id`` links,
then assert the depth sequence is non-decreasing.
"""
tree: list[Decision] = context.tree_result
if len(tree) <= 1:
return # trivially ordered
# Build a lookup: decision_id -> Decision
by_id = {d.decision_id: d for d in tree}
def _depth(node: Decision) -> int:
depth = 0
current = node
while current.parent_decision_id is not None:
parent = by_id.get(current.parent_decision_id)
if parent is None:
break
depth += 1
current = parent
return depth
depths = [_depth(d) for d in tree]
for i in range(1, len(depths)):
assert depths[i] >= depths[i - 1], (
f"BFS order violated at index {i}: "
f"depth {depths[i]} follows depth {depths[i - 1]}. "
f"Depths: {depths}"
)
@then("the dsvc path should have {count:d} decisions")
def step_path_count(context: Context, count: int) -> None:
assert len(context.path_result) == count
@then("the dsvc first path decision should be the last recorded decision")
def step_path_first(context: Context) -> None:
last = context.recorded_decisions[-1]
assert context.path_result[0].decision_id == last.decision_id
@then("the dsvc last path decision should be the root")
def step_path_last_root(context: Context) -> None:
assert context.path_result[-1].is_root
# ---------------------------------------------------------------------------
# Then — Superseded assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then("the dsvc first decision should be superseded")
def step_first_superseded(context: Context) -> None:
assert context.recorded_decisions[0].is_superseded
@then("the dsvc first decision superseded_by should match the second decision")
def step_superseded_by(context: Context) -> None:
first = context.recorded_decisions[0]
second = context.recorded_decisions[1]
assert first.superseded_by == second.decision_id
@then("the dsvc superseded list should have {count:d} entry")
def step_superseded_count(context: Context, count: int) -> None:
assert len(context.superseded_result) == count
# ---------------------------------------------------------------------------
# Then — Delete assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then("the dsvc decision should be deleted")
def step_deleted(context: Context) -> None:
assert context.delete_result is True
# ---------------------------------------------------------------------------
# Then — Snapshot assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then("the dsvc snapshot should not be None")
def step_snapshot_not_none(context: Context) -> None:
assert context.snapshot_result is not None
@then("the dsvc snapshot should be None")
def step_snapshot_none(context: Context) -> None:
assert context.snapshot_result is None
@then("the dsvc snapshots dict should have {count:d} entries")
def step_snapshots_dict_count(context: Context, count: int) -> None:
assert len(context.snapshots_dict) == count
@then("the dsvc hash query should return {count:d} decision IDs")
def step_hash_count(context: Context, count: int) -> None:
assert len(context.hash_query_result) == count
# ---------------------------------------------------------------------------
# Then — Statistics assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then('the dsvc decision count for plan "{plan_id}" should be {count:d}')
def step_count(context: Context, plan_id: str, count: int) -> None:
assert (
context.decision_service.count_decisions(_resolve_plan_id(context, plan_id))
== count
)
@then('the dsvc next sequence for plan "{plan_id}" should be {seq:d}')
def step_next_seq(context: Context, plan_id: str, seq: int) -> None:
assert (
context.decision_service.get_next_sequence(_resolve_plan_id(context, plan_id))
== seq
)
# ---------------------------------------------------------------------------
# Then — Error object assertions (dsvc-prefixed)
# ---------------------------------------------------------------------------
@then('the dsvc duplicate error decision_id should be "{decision_id}"')
def step_dup_id(context: Context, decision_id: str) -> None:
assert context.decision_error.decision_id == decision_id
@then('the dsvc duplicate error message should contain "{text}"')
def step_dup_msg(context: Context, text: str) -> None:
assert text in str(context.decision_error)
@then('the dsvc sequence error plan_id should be "{plan_id}"')
def step_seq_err_plan(context: Context, plan_id: str) -> None:
assert context.decision_error.plan_id == plan_id
@then("the dsvc sequence error sequence_number should be {seq:d}")
def step_seq_err_num(context: Context, seq: int) -> None:
assert context.decision_error.sequence_number == seq
@then('the dsvc sequence error message should contain "{text}"')
def step_seq_err_msg(context: Context, text: str) -> None:
assert text in str(context.decision_error)
# --- Standalone snapshot store (dsvc-prefixed) ---
@then("the dsvc snapshot remove result should be False")
def step_standalone_remove_false(context: Context) -> None:
assert context.snapshot_remove_result is False
@then("the dsvc standalone snapshot should be None")
def step_standalone_none(context: Context) -> None:
assert context.standalone_snapshot is None
@then("the dsvc standalone hash query should return {count:d} decision IDs")
def step_standalone_hash_count(context: Context, count: int) -> None:
assert len(context.standalone_hash_result) == count
# ---------------------------------------------------------------------------
# Edge-case coverage steps
# ---------------------------------------------------------------------------
@given("a context snapshot with no hash")
def step_snapshot_no_hash(context: Context) -> None:
context.explicit_snapshot = ContextSnapshot(
hot_context_hash="",
hot_context_ref="",
)
@when('I store the hashless snapshot for decision "{decision_id}"')
def step_store_hashless(context: Context, decision_id: str) -> None:
context.standalone_store.store(decision_id, context.explicit_snapshot)
@when('I get standalone snapshot for decision "{decision_id}"')
def step_get_standalone_snapshot(context: Context, decision_id: str) -> None:
context.standalone_retrieved = context.standalone_store.get(decision_id)
@when('I store the explicit snapshot for decision "{decision_id}"')
def step_store_explicit(context: Context, decision_id: str) -> None:
context.standalone_store.store(decision_id, context.explicit_snapshot)
@then("the dsvc standalone retrieved snapshot should not be None")
def step_standalone_retrieved_not_none(context: Context) -> None:
assert context.standalone_retrieved is not None
@then("the dsvc standalone retrieved snapshot hash should be empty")
def step_standalone_retrieved_hash_empty(context: Context) -> None:
assert context.standalone_retrieved.hot_context_hash == ""
@then("the dsvc snapshot remove result should be True")
def step_standalone_remove_true(context: Context) -> None:
assert context.snapshot_remove_result is True
@then(
'the dsvc standalone hash query for "{ctx_hash}" '
"should return {count:d} decision IDs"
)
def step_standalone_hash_query_count(
context: Context, ctx_hash: str, count: int
) -> None:
result = context.standalone_store.get_by_hash(ctx_hash)
assert len(result) == count, f"Expected {count}, got {len(result)}"
@when('I record a decision for plan "{plan_id}" with a forced non-root parent')
def step_record_forced_non_root(context: Context, plan_id: str) -> None:
"""Record a decision with a fake parent_decision_id so it's not a root."""
svc = context.decision_service
d = svc.record_decision(
plan_id=_resolve_plan_id(context, plan_id),
decision_type=DecisionType.STRATEGY_CHOICE,
question="Non-root decision",
chosen_option="Chosen",
parent_decision_id=str(ULID()),
)
context.decision_result = d
context.recorded_decisions.append(d)
@when("I try to record a decision with whitespace plan_id")
def step_try_whitespace_plan_id(context: Context) -> None:
try:
context.decision_service.record_decision(
plan_id=" ",
decision_type=DecisionType.STRATEGY_CHOICE,
question="Q",
chosen_option="A",
)
context.decision_error = None
except ValidationError as exc:
context.decision_error = exc
@when("I try to record a decision with whitespace question")
def step_try_whitespace_question(context: Context) -> None:
try:
context.decision_service.record_decision(
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
decision_type=DecisionType.STRATEGY_CHOICE,
question=" ",
chosen_option="A",
)
context.decision_error = None
except ValidationError as exc:
context.decision_error = exc
@when("I try to record a decision with whitespace chosen_option")
def step_try_whitespace_chosen(context: Context) -> None:
try:
context.decision_service.record_decision(
plan_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
decision_type=DecisionType.STRATEGY_CHOICE,
question="Q",
chosen_option=" ",
)
context.decision_error = None
except ValidationError as exc:
context.decision_error = exc
@when("I list snapshots for decisions with missing entries")
def step_list_snapshots_missing(context: Context) -> None:
"""Call list_for_plan with a Decision whose snapshot is not in the store."""
fake_decision = Decision(
plan_id=str(ULID()),
decision_type=DecisionType.STRATEGY_CHOICE,
sequence_number=0,
question="ghost",
chosen_option="ghost",
)
context.standalone_plan_snapshots = context.standalone_store.list_for_plan(
[fake_decision]
)
@then("the dsvc standalone plan snapshots should have {count:d} entries")
def step_standalone_plan_snapshots_count(context: Context, count: int) -> None:
assert len(context.standalone_plan_snapshots) == count
# ---------------------------------------------------------------------------
# Persisted-mode (database-backed) steps
# ---------------------------------------------------------------------------
@given("a persisted decision service")
def step_persisted_decision_service(context: Context) -> None:
"""Create a DecisionService backed by a real SQLite database via UnitOfWork."""
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
db_path = tempfile.mktemp(suffix=".db", prefix="dsvc_persisted_")
context._dsvc_db_path = db_path
uow = UnitOfWork(f"sqlite:///{db_path}")
uow.init_database()
context.decision_service = DecisionService(unit_of_work=uow)
context.recorded_decisions = []
context.decision_error = None
context.decision_result = None
context.decision_list = None
context.tree_result = None
context.path_result = None
context.superseded_result = None
context.snapshot_result = None
context.snapshots_dict = None
context.hash_query_result = None
context.delete_result = None
context.explicit_snapshot = None
context._plan_id_registry = {}
# Register cleanup to remove temp DB file
def _cleanup_db() -> None:
for suffix in ("", "-journal", "-wal", "-shm"):
with contextlib.suppress(OSError):
os.unlink(db_path + suffix)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_cleanup_db)
@when("I recreate the decision service with the same database")
def step_recreate_service_same_db(context: Context) -> None:
"""Destroy the current DecisionService and create a fresh one against the same DB.
This simulates a service restart: the in-memory sequence counters
are lost and must be rehydrated from the database.
"""
from cleveragents.infrastructure.database.unit_of_work import UnitOfWork
db_path = context._dsvc_db_path
uow = UnitOfWork(f"sqlite:///{db_path}")
uow.init_database()
context.decision_service = DecisionService(unit_of_work=uow)
# Preserve _plan_id_registry so symbolic IDs ("P1") still resolve
# to the same ULIDs used before the restart.
context.recorded_decisions = []
context.decision_error = None
context.decision_result = None
@when("I try to store a duplicate of the recorded decision")
def step_try_store_duplicate(context: Context) -> None:
"""Attempt to store a decision with the same ID that already exists."""
try:
decision = context.decision_result
context.decision_service._store_decision(decision)
context.decision_error = None
except DuplicateDecisionError as exc:
context.decision_error = exc
@then("a dsvc duplicate decision error should be raised")
def step_duplicate_error_raised(context: Context) -> None:
assert context.decision_error is not None
assert isinstance(context.decision_error, DuplicateDecisionError)
# ---------------------------------------------------------------------------
# Full context snapshot steps (issue #9056)
# ---------------------------------------------------------------------------
@given("a plan lifecycle service with decision service wired")
def step_lifecycle_with_decision_service(context: Context) -> None:
"""Create a PlanLifecycleService with a real DecisionService wired in."""
from cleveragents.application.services.decision_service import DecisionService
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.config.settings import Settings
Settings._instance = None
settings = Settings()
context.snapshot_decision_service = DecisionService()
context.snapshot_lifecycle_service = PlanLifecycleService(
settings=settings,
decision_service=context.snapshot_decision_service,
)
context.snapshot_plan = None
context.snapshot_decision = None
context.snapshot_action_name = None
@given('an action "{action_name}" for strategize snapshot test')
def step_create_action_for_snapshot_test(context: Context, action_name: str) -> None:
"""Create an action for the strategize snapshot test."""
context.snapshot_action_name = action_name
context.snapshot_lifecycle_service.create_action(
name=action_name,
description=f"Action {action_name} for snapshot test",
definition_of_done="Snapshot test done",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
)
@given('a plan created from "{action_name}" with project "{project_name}"')
def step_create_plan_with_project(
context: Context, action_name: str, project_name: str
) -> None:
"""Create a plan from the given action with a project link."""
from cleveragents.domain.models.core.plan import ProjectLink
context.snapshot_plan = context.snapshot_lifecycle_service.use_action(
action_name=action_name,
project_links=[ProjectLink(project_name=project_name)],
)
@given('a plan created from "{action_name}" without projects')
def step_create_plan_without_projects(context: Context, action_name: str) -> None:
"""Create a plan from the given action without any project links."""
context.snapshot_plan = context.snapshot_lifecycle_service.use_action(
action_name=action_name,
project_links=[],
)
@when("I start strategize for the snapshot test plan")
def step_start_strategize_snapshot_test(context: Context) -> None:
"""Start strategize and capture the recorded decision."""
plan_id = context.snapshot_plan.identity.plan_id
context.snapshot_lifecycle_service.start_strategize(plan_id)
decisions = context.snapshot_decision_service.list_decisions(plan_id)
assert len(decisions) >= 1, f"Expected at least 1 decision, got {len(decisions)}"
strategy_decisions = [
d for d in decisions if d.decision_type.value == "strategy_choice"
]
assert len(strategy_decisions) >= 1, "Expected at least 1 strategy_choice decision"
context.snapshot_decision = strategy_decisions[0]
@then("the strategize decision should have a non-empty hot_context_hash")
def step_check_snapshot_hash_not_empty(context: Context) -> None:
"""Verify the context snapshot hash is not empty."""
snapshot = context.snapshot_decision.context_snapshot
assert snapshot.hot_context_hash, "hot_context_hash should not be empty"
@then("the strategize decision should have a non-empty hot_context_ref")
def step_check_snapshot_ref_not_empty(context: Context) -> None:
"""Verify the context snapshot ref is not empty."""
snapshot = context.snapshot_decision.context_snapshot
assert snapshot.hot_context_ref, "hot_context_ref should not be empty"
@then('the strategize decision hot_context_ref should start with "{prefix}"')
def step_check_snapshot_ref_prefix(context: Context, prefix: str) -> None:
"""Verify the context snapshot ref starts with the expected prefix."""
snapshot = context.snapshot_decision.context_snapshot
assert snapshot.hot_context_ref.startswith(prefix), (
f"hot_context_ref should start with {prefix!r}"
)
@then("the strategize decision should have a non-empty actor_state_ref")
def step_check_snapshot_actor_ref_not_empty(context: Context) -> None:
"""Verify the actor_state_ref is not empty."""
snapshot = context.snapshot_decision.context_snapshot
assert snapshot.actor_state_ref, "actor_state_ref should not be empty"
@then("the strategize decision should have relevant_resources populated")
def step_check_snapshot_resources_populated(context: Context) -> None:
"""Verify relevant_resources is not empty."""
snapshot = context.snapshot_decision.context_snapshot
assert len(snapshot.relevant_resources) > 0, (
"relevant_resources should not be empty"
)
@then('the strategize decision hot_context_hash should start with "{prefix}"')
def step_check_snapshot_hash_prefix(context: Context, prefix: str) -> None:
"""Verify the context snapshot hash starts with the expected prefix."""
snapshot = context.snapshot_decision.context_snapshot
assert snapshot.hot_context_hash.startswith(prefix), (
f"hot_context_hash should start with {prefix!r}"
)
@then("the strategize decision hot_context_hash should be {length:d} characters long")
def step_check_snapshot_hash_length(context: Context, length: int) -> None:
"""Verify the context snapshot hash has the expected length."""
snapshot = context.snapshot_decision.context_snapshot
actual_length = len(snapshot.hot_context_hash)
assert actual_length == length, (
f"hot_context_hash length should be {length}, got {actual_length}"
)
@then("the strategize decision should have empty relevant_resources")
def step_check_snapshot_resources_empty(context: Context) -> None:
"""Verify relevant_resources is empty."""
snapshot = context.snapshot_decision.context_snapshot
assert len(snapshot.relevant_resources) == 0, (
f"relevant_resources should be empty, got {snapshot.relevant_resources}"
)