"""Step definitions for decision_persistence.feature. Tests the DecisionRepository CRUD operations, JSON round-trips, tree traversal, correction workflows, and constraint enforcement. """ from __future__ import annotations from datetime import UTC, datetime from typing import Any from behave import given, then, when # type: ignore[import-untyped] from behave.runner import Context from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from ulid import ULID from cleveragents.domain.models.core.action import Action, ActionState from cleveragents.domain.models.core.decision import ( ArtifactRef, ContextSnapshot, Decision, DecisionType, ResourceRef, ) from cleveragents.domain.models.core.plan import ( NamespacedName, Plan, PlanIdentity, PlanPhase, PlanTimestamps, ProcessingState, ) from cleveragents.infrastructure.database.models import Base from cleveragents.infrastructure.database.repositories import ( ActionRepository, DecisionRepository, DuplicateDecisionError, LifecyclePlanRepository, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _PLAN_ID = "01HV000000000000000000DP01" def _setup_db(context: Context) -> None: """Create an in-memory SQLite DB and attach repos.""" engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(engine) sm = sessionmaker(bind=engine) session = sm() context._dp_engine = engine context._dp_session = session context._dp_factory = lambda: session context._dp_repo = DecisionRepository(session_factory=context._dp_factory) context._dp_action_repo = ActionRepository(session_factory=context._dp_factory) context._dp_plan_repo = LifecyclePlanRepository( session_factory=context._dp_factory, ) def _make_decision( plan_id: str = _PLAN_ID, parent_decision_id: str | None = None, sequence_number: int = 0, decision_type: DecisionType = DecisionType.PROMPT_DEFINITION, question: str = "What approach should we take?", chosen_option: str = "Build a REST API", confidence_score: float | None = None, context_snapshot: ContextSnapshot | None = None, alternatives_considered: list[str] | None = None, artifacts_produced: list[ArtifactRef] | None = None, downstream_decision_ids: list[str] | None = None, downstream_plan_ids: list[str] | None = None, rationale: str = "", is_correction: bool = False, corrects_decision_id: str | None = None, correction_reason: str | None = None, superseded_by: str | None = None, ) -> Decision: kwargs: dict[str, Any] = { "plan_id": plan_id, "sequence_number": sequence_number, "decision_type": decision_type, "question": question, "chosen_option": chosen_option, "rationale": rationale, "is_correction": is_correction, } if parent_decision_id is not None: kwargs["parent_decision_id"] = parent_decision_id if confidence_score is not None: kwargs["confidence_score"] = confidence_score if context_snapshot is not None: kwargs["context_snapshot"] = context_snapshot if alternatives_considered is not None: kwargs["alternatives_considered"] = alternatives_considered if artifacts_produced is not None: kwargs["artifacts_produced"] = artifacts_produced if downstream_decision_ids is not None: kwargs["downstream_decision_ids"] = downstream_decision_ids if downstream_plan_ids is not None: kwargs["downstream_plan_ids"] = downstream_plan_ids if corrects_decision_id is not None: kwargs["corrects_decision_id"] = corrects_decision_id if correction_reason is not None: kwargs["correction_reason"] = correction_reason if superseded_by is not None: kwargs["superseded_by"] = superseded_by return Decision(**kwargs) def _create_prerequisite_action(context: Context) -> None: ns = NamespacedName.parse("local/decision-action") action = Action( namespaced_name=ns, description="Prerequisite action for decision tests", definition_of_done="Done", strategy_actor="local/s", execution_actor="local/e", state=ActionState.AVAILABLE, created_at=datetime(2026, 1, 1, tzinfo=UTC), updated_at=datetime(2026, 1, 1, tzinfo=UTC), ) context._dp_action_repo.create(action) context._dp_session.commit() def _create_prerequisite_plan(context: Context) -> None: now = datetime(2026, 3, 1, tzinfo=UTC) plan = Plan( identity=PlanIdentity(plan_id=_PLAN_ID, attempt=1), namespaced_name=NamespacedName(namespace="local", name="decision-plan"), action_name="local/decision-action", description="Decision test plan", definition_of_done="All assertions pass", phase=PlanPhase.STRATEGIZE, processing_state=ProcessingState.PROCESSING, strategy_actor="local/s", execution_actor="local/e", timestamps=PlanTimestamps(created_at=now, updated_at=now), created_by="test-decision", tags=[], reusable=True, read_only=False, ) context._dp_plan_repo.create(plan) context._dp_session.commit() # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a fresh in-memory decision persistence database") def step_fresh_db(context: Context) -> None: _setup_db(context) @given('a prerequisite action "{action_name}" exists for decisions') def step_prereq_action(context: Context, action_name: str) -> None: _create_prerequisite_action(context) @given("a prerequisite plan exists for decisions") def step_prereq_plan(context: Context) -> None: _create_prerequisite_plan(context) # --------------------------------------------------------------------------- # Create and retrieve # --------------------------------------------------------------------------- @given("a new root decision with sequence {seq:d}") def step_new_root(context: Context, seq: int) -> None: context._dp_decision = _make_decision(sequence_number=seq) @given("a persisted root decision") def step_persisted_root(context: Context) -> None: d = _make_decision() context._dp_repo.create(d) context._dp_session.commit() context._dp_root_decision = d context._dp_root_id = d.decision_id @given('a new child decision of type "{dtype}" with sequence {seq:d}') def step_new_child(context: Context, dtype: str, seq: int) -> None: context._dp_decision = _make_decision( parent_decision_id=context._dp_root_id, sequence_number=seq, decision_type=DecisionType(dtype), ) @given("a new root decision with all fields populated") def step_new_full_root(context: Context) -> None: context._dp_decision = _make_decision( question="Full question?", chosen_option="Full option", confidence_score=0.85, rationale="Thorough analysis", alternatives_considered=["A", "B"], context_snapshot=ContextSnapshot( hot_context_hash="sha256:full", hot_context_ref="store://full", relevant_resources=[ResourceRef(resource_id=str(ULID()))], actor_state_ref="checkpoint://full", ), ) @when("I persist the decision via the decision repository") def step_persist_decision(context: Context) -> None: context._dp_repo.create(context._dp_decision) context._dp_session.commit() @then("I can retrieve the decision by its ID") def step_retrieve_by_id(context: Context) -> None: d = context._dp_repo.get(context._dp_decision.decision_id) assert d is not None, "Decision not found" context._dp_retrieved = d @then('the persisted decision type should be "{dtype}"') def step_check_type(context: Context, dtype: str) -> None: assert str(context._dp_retrieved.decision_type) == dtype @then("the persisted decision should be a root") def step_check_is_root(context: Context) -> None: assert context._dp_retrieved.is_root @then("the persisted decision should not be a root") def step_check_not_root(context: Context) -> None: assert not context._dp_retrieved.is_root @then("the persisted decision parent should match the root") def step_check_parent(context: Context) -> None: assert context._dp_retrieved.parent_decision_id == context._dp_root_id @then("the persisted decision question should match") def step_check_question(context: Context) -> None: assert context._dp_retrieved.question == context._dp_decision.question @then("the persisted decision chosen_option should match") def step_check_chosen(context: Context) -> None: assert context._dp_retrieved.chosen_option == context._dp_decision.chosen_option @then("the persisted decision confidence_score should be {score}") def step_check_confidence(context: Context, score: str) -> None: expected = float(score) assert context._dp_retrieved.confidence_score == expected, ( f"Expected {expected}, got {context._dp_retrieved.confidence_score}" ) @then("the persisted decision rationale should match") def step_check_rationale(context: Context) -> None: assert context._dp_retrieved.rationale == context._dp_decision.rationale # --------------------------------------------------------------------------- # JSON round-trip # --------------------------------------------------------------------------- @given("a new decision with {count:d} alternatives") def step_new_with_alts(context: Context, count: int) -> None: alts = [f"Alternative {i}" for i in range(count)] context._dp_decision = _make_decision(alternatives_considered=alts) @then("the persisted decision should have {count:d} alternatives") def step_check_alt_count(context: Context, count: int) -> None: assert len(context._dp_retrieved.alternatives_considered) == count @given("a new decision with a populated context snapshot") def step_new_with_snapshot(context: Context) -> None: snap = ContextSnapshot( hot_context_hash="sha256:persist-test", hot_context_ref="store://persist-test", relevant_resources=[ ResourceRef(resource_id=str(ULID()), path="src/main.py"), ResourceRef(resource_id=str(ULID())), ], actor_state_ref="checkpoint://persist", ) context._dp_decision = _make_decision(context_snapshot=snap) @then('the persisted decision context hash should be "{expected}"') def step_check_ctx_hash(context: Context, expected: str) -> None: assert context._dp_retrieved.context_snapshot.hot_context_hash == expected @then("the persisted decision context should have {count:d} resources") def step_check_ctx_resources(context: Context, count: int) -> None: assert len(context._dp_retrieved.context_snapshot.relevant_resources) == count @given("a new decision with {count:d} artifacts") def step_new_with_artifacts(context: Context, count: int) -> None: arts = [ ArtifactRef(artifact_path=f"src/file_{i}.py", artifact_type="file") for i in range(count) ] context._dp_decision = _make_decision(artifacts_produced=arts) @then("the persisted decision should have {count:d} artifacts") def step_check_artifact_count(context: Context, count: int) -> None: assert len(context._dp_retrieved.artifacts_produced) == count @given("a new decision with downstream IDs") def step_new_with_downstream(context: Context) -> None: context._dp_decision = _make_decision( downstream_decision_ids=[str(ULID()), str(ULID())], downstream_plan_ids=[str(ULID())], ) @then("the persisted decision should have downstream decision IDs") def step_check_downstream_decisions(context: Context) -> None: assert len(context._dp_retrieved.downstream_decision_ids) > 0 @then("the persisted decision should have downstream plan IDs") def step_check_downstream_plans(context: Context) -> None: assert len(context._dp_retrieved.downstream_plan_ids) > 0 # --------------------------------------------------------------------------- # Get by plan # --------------------------------------------------------------------------- @given("{count:d} persisted decisions for the same plan") def step_n_decisions(context: Context, count: int) -> None: for i in range(count): dt = DecisionType.PROMPT_DEFINITION if i == 0 else DecisionType.STRATEGY_CHOICE parent = None if i == 0 else context._dp_first_id d = _make_decision( sequence_number=i, decision_type=dt, parent_decision_id=parent, ) context._dp_repo.create(d) if i == 0: context._dp_first_id = d.decision_id context._dp_session.commit() @when("I get decisions by plan ID") def step_get_by_plan(context: Context) -> None: context._dp_plan_decisions = context._dp_repo.get_by_plan(_PLAN_ID) @then("I should get {count:d} decisions in sequence order") def step_check_plan_decisions(context: Context, count: int) -> None: decisions = context._dp_plan_decisions assert len(decisions) == count, f"Expected {count}, got {len(decisions)}" for i in range(len(decisions) - 1): assert decisions[i].sequence_number <= decisions[i + 1].sequence_number # --------------------------------------------------------------------------- # Get tree # --------------------------------------------------------------------------- @given("a persisted decision tree with 3 levels") def step_decision_tree(context: Context) -> None: # Root (level 0) root = _make_decision(sequence_number=0) context._dp_repo.create(root) context._dp_tree_root_id = root.decision_id # Level 1 — two children child1 = _make_decision( parent_decision_id=root.decision_id, sequence_number=1, decision_type=DecisionType.STRATEGY_CHOICE, ) context._dp_repo.create(child1) child2 = _make_decision( parent_decision_id=root.decision_id, sequence_number=2, decision_type=DecisionType.STRATEGY_CHOICE, ) context._dp_repo.create(child2) # Level 2 — one grandchild under child1 grandchild = _make_decision( parent_decision_id=child1.decision_id, sequence_number=3, decision_type=DecisionType.IMPLEMENTATION_CHOICE, ) context._dp_repo.create(grandchild) context._dp_tree_leaf_id = grandchild.decision_id context._dp_session.commit() @when("I get the decision tree from the root") def step_get_tree(context: Context) -> None: context._dp_tree = context._dp_repo.get_tree(context._dp_tree_root_id) @then("the tree should have {count:d} decisions") def step_check_tree_count(context: Context, count: int) -> None: assert len(context._dp_tree) == count, ( f"Expected {count}, got {len(context._dp_tree)}" ) @then("the first decision in the tree should be the root") def step_check_tree_root(context: Context) -> None: assert context._dp_tree[0].decision_id == context._dp_tree_root_id # --------------------------------------------------------------------------- # Get path to root # --------------------------------------------------------------------------- @when("I get the path from a leaf to root") def step_get_path(context: Context) -> None: context._dp_path = context._dp_repo.get_path_to_root( context._dp_tree_leaf_id, ) @then("the path should start with the leaf") def step_check_path_leaf(context: Context) -> None: assert context._dp_path[0].decision_id == context._dp_tree_leaf_id @then("the path should end with the root") def step_check_path_root(context: Context) -> None: assert context._dp_path[-1].decision_id == context._dp_tree_root_id # --------------------------------------------------------------------------- # Correction and superseded # --------------------------------------------------------------------------- @given("a correction decision that corrects the root") def step_correction_decision(context: Context) -> None: context._dp_decision = _make_decision( parent_decision_id=context._dp_root_id, sequence_number=1, decision_type=DecisionType.STRATEGY_CHOICE, is_correction=True, corrects_decision_id=context._dp_root_id, correction_reason="Root was suboptimal", ) @then("the persisted decision is_correction should be true") def step_check_is_correction(context: Context) -> None: assert context._dp_retrieved.is_correction @when("I update the root decision superseded_by to a new ULID") def step_update_superseded(context: Context) -> None: new_id = str(ULID()) context._dp_superseding_id = new_id context._dp_repo.update_superseded_by(context._dp_root_id, new_id) context._dp_session.commit() @then("the root decision should be marked as superseded") def step_check_superseded(context: Context) -> None: d = context._dp_repo.get(context._dp_root_id) assert d is not None assert d.is_superseded assert d.superseded_by == context._dp_superseding_id @given("a persisted root decision that is superseded") def step_superseded_root(context: Context) -> None: d = _make_decision(superseded_by=str(ULID())) context._dp_repo.create(d) context._dp_session.commit() context._dp_root_id = d.decision_id @when("I get superseded decisions for the plan") def step_get_superseded(context: Context) -> None: context._dp_superseded = context._dp_repo.get_superseded(_PLAN_ID) @then("I should get {count:d} superseded decision") def step_check_superseded_count(context: Context, count: int) -> None: assert len(context._dp_superseded) == count # --------------------------------------------------------------------------- # List by type # --------------------------------------------------------------------------- @given('{count:d} persisted decisions of type "{dtype}"') def step_n_typed_decisions(context: Context, count: int, dtype: str) -> None: dt = DecisionType(dtype) for i in range(count): # Use sequence offset to avoid collision with any prior root seq = 10 + i parent = None if dt != DecisionType.PROMPT_DEFINITION: # Need a root first if not hasattr(context, "_dp_type_root_id"): root = _make_decision(sequence_number=9) context._dp_repo.create(root) context._dp_session.commit() context._dp_type_root_id = root.decision_id parent = context._dp_type_root_id d = _make_decision( parent_decision_id=parent, sequence_number=seq, decision_type=dt, ) context._dp_repo.create(d) context._dp_session.commit() @given('{count:d} persisted decision of type "{dtype}"') def step_one_typed_decision(context: Context, count: int, dtype: str) -> None: step_n_typed_decisions(context, count, dtype) @when('I list decisions by type "{dtype}"') def step_list_by_type(context: Context, dtype: str) -> None: context._dp_typed = context._dp_repo.list_by_type(_PLAN_ID, dtype) @then("I should get {count:d} decisions of that type") def step_check_typed_count(context: Context, count: int) -> None: assert len(context._dp_typed) == count, ( f"Expected {count}, got {len(context._dp_typed)}" ) # --------------------------------------------------------------------------- # Delete # --------------------------------------------------------------------------- @when("I delete the root decision") def step_delete_root(context: Context) -> None: context._dp_delete_result = context._dp_repo.delete(context._dp_root_id) context._dp_session.commit() @then("the root decision should no longer exist") def step_check_deleted(context: Context) -> None: assert context._dp_delete_result is True d = context._dp_repo.get(context._dp_root_id) assert d is None @when("I try to delete a non-existent decision") def step_delete_missing(context: Context) -> None: context._dp_delete_result = context._dp_repo.delete(str(ULID())) @then("the decision delete result should be false") def step_check_delete_false(context: Context) -> None: assert context._dp_delete_result is False # --------------------------------------------------------------------------- # Duplicate detection # --------------------------------------------------------------------------- @when("I try to persist the same decision again") def step_persist_duplicate(context: Context) -> None: try: context._dp_repo.create(context._dp_root_decision) context._dp_session.commit() context._dp_dup_error = None except DuplicateDecisionError as exc: context._dp_session.rollback() context._dp_dup_error = exc @then("a DuplicateDecisionError should be raised") def step_check_duplicate(context: Context) -> None: assert context._dp_dup_error is not None assert isinstance(context._dp_dup_error, DuplicateDecisionError)