From c9abb45adf0cfd0cfe55fa1db2f491b1a3b5790b Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Fri, 27 Feb 2026 18:37:20 +0000 Subject: [PATCH] test(coverage): add Behave BDD scenarios for 9 under-covered modules Added 246 new BDD scenarios across 9 feature files to improve unit test coverage for modules that were either entirely untested or had significant coverage gaps: - lock_service_coverage.feature (27 scenarios): validation branches, TTL boundaries, re-entrant acquisition, rollback on exceptions - plan_apply_service_coverage.feature (54 scenarios): operation labels, diff rendering (plain/rich/json), artifact building, validation gate, changeset resolution and cleanup - plan_executor_coverage.feature (51 scenarios): step parsing, execute actor integration, strategize/execute guards, stub retry/recovery, decision tree construction - skill_cli_coverage_r3.feature (22 scenarios): tools refresh, list/show JSON fallback, capability summary errors, remove confirmation - changeset_repository_coverage.feature (39 scenarios): entry/tool repos validation, database error wrapping, domain conversion, SQLite store CRUD operations - repositories_coverage.feature (20 scenarios): get_by_name/namespace errors, list_available filters, delete with ActionInUseError, plan update with invariants/processing_state/error_details - sandbox_copy_on_write_coverage.feature (12 scenarios): create OSError wrapping, get_path state transitions, commit edge cases, rollback errors, cleanup with missing paths - bridge_coverage.feature (8 scenarios): __del__ suppression, async task cancellation, execute_graph message type handling, stream config, state checkpointer - plan_cli_coverage.feature (13 scenarios): legacy apply/list/cd paths, use-action with estimation/invariant actors, lifecycle-apply guards, status errors, error recovery display All 246 scenarios (1105 steps) pass. Step definitions use unique prefixes to prevent ambiguous step conflicts with existing tests. ISSUES CLOSED: #467 --- features/bridge_coverage.feature | 48 + .../changeset_repository_coverage.feature | 241 +++ features/lock_service_coverage.feature | 175 +++ features/plan_apply_service_coverage.feature | 377 +++++ features/plan_cli_coverage.feature | 123 ++ features/plan_executor_coverage.feature | 478 ++++-- features/repositories_coverage.feature | 232 +++ .../sandbox_copy_on_write_coverage.feature | 146 ++ features/skill_cli_coverage_r3.feature | 248 +++ features/steps/bridge_coverage_steps.py | 355 +++++ .../changeset_repository_coverage_steps.py | 1055 +++++++++++++ features/steps/lock_service_coverage_steps.py | 569 +++++++ .../plan_apply_service_coverage_steps.py | 1181 ++++++++++++++ features/steps/plan_cli_coverage_steps.py | 512 ++++++ .../steps/plan_executor_coverage_steps.py | 1372 +++++++++++------ features/steps/repositories_coverage_steps.py | 737 +++++++++ .../sandbox_copy_on_write_coverage_steps.py | 428 +++++ features/steps/skill_cli_coverage_r3_steps.py | 603 ++++++++ 18 files changed, 8311 insertions(+), 569 deletions(-) create mode 100644 features/bridge_coverage.feature create mode 100644 features/changeset_repository_coverage.feature create mode 100644 features/lock_service_coverage.feature create mode 100644 features/plan_apply_service_coverage.feature create mode 100644 features/plan_cli_coverage.feature create mode 100644 features/repositories_coverage.feature create mode 100644 features/sandbox_copy_on_write_coverage.feature create mode 100644 features/skill_cli_coverage_r3.feature create mode 100644 features/steps/bridge_coverage_steps.py create mode 100644 features/steps/changeset_repository_coverage_steps.py create mode 100644 features/steps/lock_service_coverage_steps.py create mode 100644 features/steps/plan_apply_service_coverage_steps.py create mode 100644 features/steps/plan_cli_coverage_steps.py create mode 100644 features/steps/repositories_coverage_steps.py create mode 100644 features/steps/sandbox_copy_on_write_coverage_steps.py create mode 100644 features/steps/skill_cli_coverage_r3_steps.py diff --git a/features/bridge_coverage.feature b/features/bridge_coverage.feature new file mode 100644 index 00000000..a7036483 --- /dev/null +++ b/features/bridge_coverage.feature @@ -0,0 +1,48 @@ +Feature: RxPyLangGraphBridge uncovered lines and branches + As a developer + I want targeted tests for missed lines and branches in bridge.py + So that line-rate and branch-rate approach 1.0 + + # Targets: lines 68, 201-204 and branches 39→38, 67→68, 182→184, 260→258 + + Scenario: __del__ suppresses AttributeError when cleanup_tasks is unavailable + Given a bridge with its cleanup_tasks method removed + When __del__ is invoked on the crippled bridge + Then no exception should propagate from __del__ + + Scenario: cleanup_tasks_async cancels tasks that are still running + Given a bridge with a long-running async task + When I run cleanup_tasks_async on the bridge + Then the running task should be cancelled + And the bridge active tasks set should be empty + + Scenario: Graph executor processes a string message through execute_graph + Given a bridge with a mock graph that returns messages + When I run the graph executor with a string content message + Then the executor result should contain the graph output + And the result metadata should include graph execution history + + Scenario: Graph executor processes a dict message through execute_graph + Given a bridge with a mock graph that returns messages + When I run the graph executor with a dict content message + Then the executor result should contain the graph output + + Scenario: Graph executor processes a non-str non-dict message through execute_graph + Given a bridge with a mock graph that returns messages + When I run the graph executor with a numeric content message + Then the executor result should contain the graph output + + Scenario: Graph executor falls back to state dict when messages list is empty + Given a bridge with a mock graph that returns empty messages + When I run the graph executor with a string content message + Then the executor result should be the full state dict + + Scenario: create_graph_stream returns StreamConfig for a known graph + Given a bridge that owns a registered graph named "demo" + When I call create_graph_stream with "demo" + Then the returned StreamConfig name should be "graph_demo" + + Scenario: _create_state_checkpointer succeeds for a valid graph name + Given a bridge that owns a registered graph named "ckpt_graph" + When I build a state checkpointer for "ckpt_graph" + Then the checkpointer operator should be returned without error diff --git a/features/changeset_repository_coverage.feature b/features/changeset_repository_coverage.feature new file mode 100644 index 00000000..3e4d1616 --- /dev/null +++ b/features/changeset_repository_coverage.feature @@ -0,0 +1,241 @@ +@unit +Feature: ChangeSet repository edge cases and error coverage + As a developer maintaining changeset persistence + I want all error paths, validation, and edge cases covered + So that the changeset repository is reliable and well-tested + + # ------ ChangeSetEntryRepository validation ------ + + Scenario: ChangeSetEntryRepository save_entry rejects empty changeset_id + Given I have a coverage ChangeSetEntryRepository + When I try to coverage-save an entry with empty changeset_id + Then a coverage ValueError should be raised with message "changeset_id must not be empty" + + Scenario: ChangeSetEntryRepository save_entry rejects non-ChangeEntry + Given I have a coverage ChangeSetEntryRepository + When I try to coverage-save a non-ChangeEntry object + Then a coverage TypeError should be raised with message "entry must be a ChangeEntry instance" + + Scenario: ChangeSetEntryRepository get_entries_for_changeset rejects empty changeset_id + Given I have a coverage ChangeSetEntryRepository + When I try to coverage-get entries for empty changeset_id + Then a coverage ValueError should be raised with message "changeset_id must not be empty" + + Scenario: ChangeSetEntryRepository get_entries_for_plan rejects empty plan_id + Given I have a coverage ChangeSetEntryRepository + When I try to coverage-get entries for empty plan_id + Then a coverage ValueError should be raised with message "plan_id must not be empty" + + Scenario: ChangeSetEntryRepository delete_for_changeset rejects empty changeset_id + Given I have a coverage ChangeSetEntryRepository + When I try to coverage-delete entries for empty changeset_id + Then a coverage ValueError should be raised with message "changeset_id must not be empty" + + Scenario: ChangeSetEntryRepository delete_for_plan rejects empty plan_id + Given I have a coverage ChangeSetEntryRepository + When I try to coverage-delete entries for empty plan_id + Then a coverage ValueError should be raised with message "plan_id must not be empty" + + # ------ ChangeSetEntryRepository database errors ------ + + Scenario: ChangeSetEntryRepository save_entry raises DatabaseError on OperationalError + Given I have a coverage ChangeSetEntryRepository with a broken session + When I try to coverage-save an entry that triggers an OperationalError + Then a coverage DatabaseError should be raised with message "Failed to save changeset entry" + + Scenario: ChangeSetEntryRepository get_entries_for_changeset raises DatabaseError + Given I have a coverage ChangeSetEntryRepository with a broken query session + When I try to coverage-get entries for changeset "cs-broken" + Then a coverage DatabaseError should be raised with message "Failed to get entries" + + Scenario: ChangeSetEntryRepository get_entries_for_plan raises DatabaseError + Given I have a coverage ChangeSetEntryRepository with a broken query session + When I try to coverage-get entries for plan "plan-broken" + Then a coverage DatabaseError should be raised with message "Failed to get entries for plan" + + Scenario: ChangeSetEntryRepository delete_for_changeset raises DatabaseError + Given I have a coverage ChangeSetEntryRepository with a broken delete session + When I try to coverage-delete entries for changeset "cs-broken" + Then a coverage DatabaseError should be raised with message "Failed to delete entries" + + Scenario: ChangeSetEntryRepository delete_for_plan raises DatabaseError + Given I have a coverage ChangeSetEntryRepository with a broken delete session + When I try to coverage-delete entries for plan "plan-broken" + Then a coverage DatabaseError should be raised with message "Failed to delete entries" + + # ------ ChangeSetEntryRepository _to_domain edge cases ------ + + Scenario: ChangeSetEntryRepository _to_domain with null hashes and modes + Given I have a coverage ChangeSetEntryModel row with null hashes and modes + When I convert the coverage row to a domain ChangeEntry + Then the domain entry before_hash should be None + And the domain entry after_hash should be None + And the domain entry before_mode should be None + And the domain entry after_mode should be None + + Scenario: ChangeSetEntryRepository _to_domain with populated hashes and modes + Given I have a coverage ChangeSetEntryModel row with hashes and modes + When I convert the coverage row to a domain ChangeEntry + Then the domain entry before_hash should be "abc123" + And the domain entry after_hash should be "def456" + And the domain entry before_mode should be 33188 + And the domain entry after_mode should be 33261 + + Scenario: ChangeSetEntryRepository _to_domain with empty string hashes + Given I have a coverage ChangeSetEntryModel row with empty string hashes + When I convert the coverage row to a domain ChangeEntry + Then the domain entry before_hash should be None + And the domain entry after_hash should be None + + # ------ ToolInvocationRepository validation ------ + + Scenario: ToolInvocationRepository save_invocation rejects non-ToolInvocation + Given I have a coverage ToolInvocationRepository + When I try to coverage-save a non-ToolInvocation object + Then a coverage TypeError should be raised with message "invocation must be a ToolInvocation instance" + + Scenario: ToolInvocationRepository get_invocations_for_plan rejects empty plan_id + Given I have a coverage ToolInvocationRepository + When I try to coverage-get invocations for empty plan_id + Then a coverage ValueError should be raised with message "plan_id must not be empty" + + Scenario: ToolInvocationRepository delete_for_plan rejects empty plan_id + Given I have a coverage ToolInvocationRepository + When I try to coverage-delete invocations for empty plan_id + Then a coverage ValueError should be raised with message "plan_id must not be empty" + + # ------ ToolInvocationRepository database errors ------ + + Scenario: ToolInvocationRepository save_invocation raises DatabaseError on OperationalError + Given I have a coverage ToolInvocationRepository with a broken session + When I try to coverage-save an invocation that triggers an OperationalError + Then a coverage DatabaseError should be raised with message "Failed to save tool invocation" + + Scenario: ToolInvocationRepository get_invocations_for_plan raises DatabaseError + Given I have a coverage ToolInvocationRepository with a broken query session + When I try to coverage-get invocations for plan "plan-broken" + Then a coverage DatabaseError should be raised with message "Failed to get invocations" + + Scenario: ToolInvocationRepository delete_for_plan raises DatabaseError + Given I have a coverage ToolInvocationRepository with a broken delete session + When I try to coverage-delete invocations for plan "plan-broken" + Then a coverage DatabaseError should be raised with message "Failed to delete invocations" + + # ------ ToolInvocationRepository save_invocation optional fields ------ + + Scenario: ToolInvocationRepository saves invocation with all optional fields populated + Given I have a coverage ToolInvocationRepository with real session + When I coverage-save an invocation with result, completed_at, and provider_metadata + Then the coverage-saved invocation should round-trip with all fields intact + + Scenario: ToolInvocationRepository saves invocation with all optional fields as None + Given I have a coverage ToolInvocationRepository with real session + When I coverage-save an invocation with no result, no completed_at, and no provider_metadata + Then the coverage-saved invocation should round-trip with None optional fields + + Scenario: ToolInvocationRepository saves invocation with changeset_id as None + Given I have a coverage ToolInvocationRepository with real session + When I coverage-save an invocation without changeset_id + Then the coverage-saved invocation for plan should be retrievable + + # ------ ToolInvocationRepository _to_domain edge cases ------ + + Scenario: ToolInvocationRepository _to_domain with null JSON fields + Given I have a coverage ToolInvocationModel row with null JSON fields + When I convert the coverage row to a domain ToolInvocation + Then the domain invocation arguments should be an empty dict + And the domain invocation result should be None + And the domain invocation change_ids should be an empty list + And the domain invocation resource_refs should be an empty list + And the domain invocation provider_metadata should be None + + Scenario: ToolInvocationRepository _to_domain with populated JSON fields + Given I have a coverage ToolInvocationModel row with populated JSON fields + When I convert the coverage row to a domain ToolInvocation + Then the domain invocation arguments should have key "path" + And the domain invocation result should have key "status" + And the domain invocation change_ids should have 2 entries + And the domain invocation resource_refs should have 1 entry + And the domain invocation provider_metadata should have key "model" + + Scenario: ToolInvocationRepository _to_domain with null duration_ms and sequence_number + Given I have a coverage ToolInvocationModel row with null numeric fields + When I convert the coverage row to a domain ToolInvocation + Then the domain invocation duration_ms should be 0.0 + And the domain invocation sequence_number should be 0 + + Scenario: ToolInvocationRepository _to_domain with null completed_at + Given I have a coverage ToolInvocationModel row with null completed_at + When I convert the coverage row to a domain ToolInvocation + Then the domain invocation completed_at should be None + + Scenario: ToolInvocationRepository _to_domain with populated completed_at + Given I have a coverage ToolInvocationModel row with populated completed_at + When I convert the coverage row to a domain ToolInvocation + Then the domain invocation completed_at should be a datetime + + # ------ SqliteChangeSetStore edge cases ------ + + Scenario: SqliteChangeSetStore get returns None for empty changeset_id + Given I have a coverage sqlite changeset store + When I coverage-get a changeset with empty string + Then the coverage-get result should be None + + Scenario: SqliteChangeSetStore get returns empty SpecChangeSet for known ID without entries + Given I have a coverage sqlite changeset store + When I coverage-start a changeset for plan "plan-known" + And I coverage-get the started changeset + Then the coverage-get result should be a SpecChangeSet with 0 entries + And the coverage-get result plan_id should be "plan-known" + + Scenario: SqliteChangeSetStore get returns None for unknown ID not in plan_map + Given I have a coverage sqlite changeset store + When I coverage-get a changeset with id "totally-unknown-id" + Then the coverage-get result should be None + + Scenario: SqliteChangeSetStore get returns SpecChangeSet with entries + Given I have a coverage sqlite changeset store + When I coverage-start a changeset for plan "plan-with-entries" + And I coverage-record a create entry in the started changeset + And I coverage-get the started changeset + Then the coverage-get result should be a SpecChangeSet with 1 entries + And the coverage-get result plan_id should be "plan-with-entries" + + Scenario: SqliteChangeSetStore record rejects empty changeset_id + Given I have a coverage sqlite changeset store + When I try to coverage-record an entry with empty changeset_id + Then a coverage ValueError should be raised with message "changeset_id must not be empty" + + Scenario: SqliteChangeSetStore get_for_plan returns empty list for empty plan_id + Given I have a coverage sqlite changeset store + When I coverage-get_for_plan with empty plan_id + Then the coverage-get_for_plan result should be an empty list + + Scenario: SqliteChangeSetStore get_for_plan returns empty list when no entries exist + Given I have a coverage sqlite changeset store + When I coverage-get_for_plan with plan_id "nonexistent-plan" + Then the coverage-get_for_plan result should be an empty list + + Scenario: SqliteChangeSetStore summarize returns empty dict for empty changeset_id + Given I have a coverage sqlite changeset store + When I coverage-summarize changeset with empty string + Then the coverage-summarize result should be an empty dict + + Scenario: SqliteChangeSetStore summarize returns summary for valid changeset + Given I have a coverage sqlite changeset store + When I coverage-start a changeset for plan "plan-sum" + And I coverage-record a create entry in the started changeset + And I coverage-summarize the started changeset + Then the coverage-summarize result total should be 1 + + Scenario: SqliteChangeSetStore delete_for_plan rejects empty plan_id + Given I have a coverage sqlite changeset store + When I try to coverage-delete_for_plan with empty plan_id + Then a coverage ValueError should be raised with message "plan_id must not be empty" + + Scenario: SqliteChangeSetStore delete_for_plan returns count + Given I have a coverage sqlite changeset store + When I coverage-start a changeset for plan "plan-del-cov" + And I coverage-record a create entry in the started changeset + And I coverage-delete_for_plan "plan-del-cov" + Then the coverage-delete count should be 1 diff --git a/features/lock_service_coverage.feature b/features/lock_service_coverage.feature new file mode 100644 index 00000000..1d38540a --- /dev/null +++ b/features/lock_service_coverage.feature @@ -0,0 +1,175 @@ +Feature: LockService coverage – uncovered branches and edge cases + As a developer + I want full coverage of every branch in lock_service.py + So that no edge case is left untested + + Background: + Given I have a fresh lock service with in-memory database + + # ------------------------------------------------------------------ + # Validation: release method + # ------------------------------------------------------------------ + + Scenario: Release rejects empty resource_type + When I try to release with an empty resource_type + Then a lock_service validation error should be raised with message containing "resource_type must not be empty" + + Scenario: Release rejects invalid resource_type + When I try to release with resource_type "widget" owner "o" resource_id "r1" + Then a lock_service validation error should be raised with message containing "resource_type must be one of" + + Scenario: Release rejects empty resource_id + When I try to release with an empty resource_id + Then a lock_service validation error should be raised with message containing "resource_id must not be empty" + + # ------------------------------------------------------------------ + # Validation: renew method + # ------------------------------------------------------------------ + + Scenario: Renew rejects empty resource_type + When I try to renew with an empty resource_type + Then a lock_service validation error should be raised with message containing "resource_type must not be empty" + + Scenario: Renew rejects invalid resource_type + When I try to renew with resource_type "widget" owner "o" resource_id "r1" + Then a lock_service validation error should be raised with message containing "resource_type must be one of" + + Scenario: Renew rejects empty resource_id + When I try to renew with an empty resource_id + Then a lock_service validation error should be raised with message containing "resource_id must not be empty" + + Scenario: Renew rejects TTL below minimum + When I try to renew with TTL 2 owner "o" resource_type "plan" resource_id "r1" + Then a lock_service validation error should be raised with message containing "ttl_seconds must be >=" + + Scenario: Renew rejects TTL above maximum + When I try to renew with TTL 7200 owner "o" resource_type "plan" resource_id "r1" + Then a lock_service validation error should be raised with message containing "ttl_seconds must be <=" + + # ------------------------------------------------------------------ + # Validation: is_locked method + # ------------------------------------------------------------------ + + Scenario: is_locked rejects empty resource_type + When I try to check is_locked with an empty resource_type + Then a lock_service validation error should be raised with message containing "resource_type must not be empty" + + Scenario: is_locked rejects invalid resource_type + When I try to check is_locked with resource_type "widget" resource_id "r1" + Then a lock_service validation error should be raised with message containing "resource_type must be one of" + + Scenario: is_locked rejects empty resource_id + When I try to check is_locked with an empty resource_id + Then a lock_service validation error should be raised with message containing "resource_id must not be empty" + + # ------------------------------------------------------------------ + # TTL boundary values on acquire + # ------------------------------------------------------------------ + + Scenario: Acquire with explicit minimum TTL succeeds + When I acquire a lock with TTL 5 owner "owner-min" resource_type "plan" resource_id "p-min" + Then the lock_service result should be true + + Scenario: Acquire with explicit maximum TTL succeeds + When I acquire a lock with TTL 3600 owner "owner-max" resource_type "plan" resource_id "p-max" + Then the lock_service result should be true + + Scenario: Acquire with custom valid TTL succeeds + When I acquire a lock with TTL 120 owner "owner-cust" resource_type "project" resource_id "proj-cust" + Then the lock_service result should be true + + # ------------------------------------------------------------------ + # Re-entrant acquisition on project resource + # ------------------------------------------------------------------ + + Scenario: Same owner re-acquires a project lock + Given owner "owner-a" already holds a project lock on "proj-re" + When I acquire a lock with TTL 300 owner "owner-a" resource_type "project" resource_id "proj-re" + Then the lock_service result should be true + + # ------------------------------------------------------------------ + # Release by wrong owner returns False + # ------------------------------------------------------------------ + + Scenario: Release returns false when owner does not match + Given owner "owner-a" already holds a plan lock on "plan-wrong" + When I try to release with resource_type "plan" owner "wrong-owner" resource_id "plan-wrong" + Then the lock_service result should be false + + # ------------------------------------------------------------------ + # is_locked on expired lock returns False + # ------------------------------------------------------------------ + + Scenario: is_locked returns false for an expired lock + Given an expired lock exists for owner "old" on plan "plan-exp" + When I check is_locked for resource_type "plan" resource_id "plan-exp" + Then the lock_service result should be false + + # ------------------------------------------------------------------ + # Zero-count paths (no log emitted) + # ------------------------------------------------------------------ + + Scenario: release_all_for_owner returns zero when owner has no locks + When I release all locks for owner_id "ghost" + Then the lock_service integer result should be 0 + + Scenario: cleanup_expired returns zero when no expired locks exist + When I run cleanup of expired locks + Then the lock_service integer result should be 0 + + Scenario: count_stale_locks returns zero when no stale locks exist + When I count stale locks via the service + Then the lock_service integer result should be 0 + + # ------------------------------------------------------------------ + # Exception rollback paths (mocked session) + # ------------------------------------------------------------------ + + Scenario: Acquire rolls back on unexpected exception + Given a lock service whose session raises an unexpected error on execute + When I try to acquire and expect a RuntimeError + Then a RuntimeError should have been raised + And the session should have been rolled back + + Scenario: Release rolls back on unexpected exception + Given a lock service whose session raises an unexpected error on execute + When I try to release and expect a RuntimeError + Then a RuntimeError should have been raised + And the session should have been rolled back + + Scenario: Renew rolls back on unexpected exception + Given a lock service whose session raises an unexpected error on execute + When I try to renew and expect a RuntimeError + Then a RuntimeError should have been raised + And the session should have been rolled back + + Scenario: release_all_for_owner rolls back on unexpected exception + Given a lock service whose session raises an unexpected error on execute + When I try to release_all_for_owner and expect a RuntimeError + Then a RuntimeError should have been raised + And the session should have been rolled back + + Scenario: cleanup_expired rolls back on unexpected exception + Given a lock service whose session raises an unexpected error on execute + When I try to cleanup_expired and expect a RuntimeError + Then a RuntimeError should have been raised + And the session should have been rolled back + + # ------------------------------------------------------------------ + # Acquire conflict rollback path + # ------------------------------------------------------------------ + + Scenario: Acquire rolls back the session on LockConflictError + Given a lock service with a real database and owner "holder" holds plan "plan-conf" + When I try to acquire plan "plan-conf" as "intruder" and capture the conflict error + Then a LockConflictError should have been raised + And the conflict owner should be "holder" + + # ------------------------------------------------------------------ + # Renew LockExpiredError rollback path + # ------------------------------------------------------------------ + + Scenario: Renew rolls back the session on LockExpiredError + Given a lock service with a real database and an expired lock for "exp-owner" on plan "plan-exp-r" + When I try to renew plan "plan-exp-r" as "exp-owner" and capture the expired error + Then a LockExpiredError should have been raised diff --git a/features/plan_apply_service_coverage.feature b/features/plan_apply_service_coverage.feature new file mode 100644 index 00000000..64429e73 --- /dev/null +++ b/features/plan_apply_service_coverage.feature @@ -0,0 +1,377 @@ +Feature: PlanApplyService full coverage + As a developer + I want comprehensive tests for PlanApplyService + So that all code paths, edge cases, and error branches are covered + + # ====================================================================== + # Helper function coverage: _operation_label + # ====================================================================== + + Scenario: operation label returns correct label for "create" + When pas_cov I call _operation_label with "create" + Then pas_cov the label should be "new file" + + Scenario: operation label returns correct label for "modify" + When pas_cov I call _operation_label with "modify" + Then pas_cov the label should be "modified" + + Scenario: operation label returns correct label for "delete" + When pas_cov I call _operation_label with "delete" + Then pas_cov the label should be "deleted" + + Scenario: operation label returns correct label for "rename" + When pas_cov I call _operation_label with "rename" + Then pas_cov the label should be "renamed" + + Scenario: operation label returns the raw op for unknown operations + When pas_cov I call _operation_label with "unknown_op" + Then pas_cov the label should be "unknown_op" + + # ====================================================================== + # Helper function coverage: _render_diff_plain + # ====================================================================== + + Scenario: render diff plain with empty changeset + Given pas_cov a SpecChangeSet with no entries + When pas_cov I render diff plain + Then pas_cov the plain diff should be "No changes in changeset." + + Scenario: render diff plain with entries having both hashes + Given pas_cov a SpecChangeSet with a modify entry having both hashes + When pas_cov I render diff plain + Then pas_cov the plain diff should contain "ChangeSet:" + And pas_cov the plain diff should contain "Total changes: 1" + And pas_cov the plain diff should contain "--- a/src/app.py" + And pas_cov the plain diff should contain "+++ b/src/app.py" + And pas_cov the plain diff should contain "@@ modified @@" + And pas_cov the plain diff should contain "- hash: abcdef012345..." + And pas_cov the plain diff should contain "+ hash: 123456abcdef..." + + Scenario: render diff plain with create entry missing before_hash + Given pas_cov a SpecChangeSet with a create entry having only after_hash + When pas_cov I render diff plain + Then pas_cov the plain diff should contain "@@ new file @@" + And pas_cov the plain diff should contain "+ hash:" + And pas_cov the plain diff should not contain "- hash:" + + Scenario: render diff plain with delete entry missing after_hash + Given pas_cov a SpecChangeSet with a delete entry having only before_hash + When pas_cov I render diff plain + Then pas_cov the plain diff should contain "@@ deleted @@" + And pas_cov the plain diff should contain "- hash:" + And pas_cov the plain diff should not contain "+ hash:" + + # ====================================================================== + # Helper function coverage: _render_diff_rich + # ====================================================================== + + Scenario: render diff rich with empty changeset + Given pas_cov a SpecChangeSet with no entries + When pas_cov I render diff rich + Then pas_cov the rich diff should contain "[yellow]No changes in changeset.[/yellow]" + + Scenario: render diff rich with known operation colors + Given pas_cov a SpecChangeSet with a modify entry having both hashes + When pas_cov I render diff rich + Then pas_cov the rich diff should contain "[yellow]modified[/yellow]" + And pas_cov the rich diff should contain "[bold]ChangeSet:[/bold]" + And pas_cov the rich diff should contain "[bold]Total changes:[/bold] 1" + And pas_cov the rich diff should contain "[red]- abcdef012345...[/red]" + And pas_cov the rich diff should contain "[green]+ 123456abcdef...[/green]" + + Scenario: render diff rich with unknown operation uses white color + Given pas_cov a SpecChangeSet with an entry having operation "rename" + When pas_cov I render diff rich + Then pas_cov the rich diff should contain "[cyan]renamed[/cyan]" + + Scenario: render diff rich with create entry hides before_hash + Given pas_cov a SpecChangeSet with a create entry having only after_hash + When pas_cov I render diff rich + Then pas_cov the rich diff should contain "[green]" + And pas_cov the rich diff should not contain "[red]- " + + # ====================================================================== + # Helper function coverage: _render_diff_json + # ====================================================================== + + Scenario: render diff json returns correct structure + Given pas_cov a SpecChangeSet with a modify entry having both hashes + When pas_cov I render diff json + Then pas_cov the json diff should have key "changeset_id" + And pas_cov the json diff should have key "plan_id" + And pas_cov the json diff should have key "total_changes" with value 1 + And pas_cov the json diff should have key "summary" + And pas_cov the json diff should have key "entries" + And pas_cov the json diff entries should have length 1 + + Scenario: render diff json entry contains all expected fields + Given pas_cov a SpecChangeSet with a modify entry having both hashes + When pas_cov I render diff json + Then pas_cov the json diff first entry should have "path" equal to "src/app.py" + And pas_cov the json diff first entry should have "operation" equal to "modify" + And pas_cov the json diff first entry should have key "timestamp" + And pas_cov the json diff first entry should have key "resource_id" + And pas_cov the json diff first entry should have key "tool_name" + + # ====================================================================== + # Helper function coverage: _build_artifacts_dict + # ====================================================================== + + Scenario: build artifacts dict with changeset present + Given pas_cov a mock plan with changeset_id "CS001" + And pas_cov a SpecChangeSet with a modify entry having both hashes + When pas_cov I build artifacts dict + Then pas_cov the artifacts dict should have key "plan_id" + And pas_cov the artifacts dict should have key "changeset_summary" + And pas_cov the artifacts dict files_changed list should have length 1 + + Scenario: build artifacts dict with no changeset + Given pas_cov a mock plan with changeset_id "CS001" + When pas_cov I build artifacts dict with no changeset + Then pas_cov the artifacts dict changeset_summary should be null + And pas_cov the artifacts dict files_changed list should have length 0 + + Scenario: build artifacts dict with validation summary + Given pas_cov a mock plan with changeset_id "CS001" and validation summary + When pas_cov I build artifacts dict with no changeset + Then pas_cov the artifacts dict should have key "validation_summary" + + Scenario: build artifacts dict with apply summary in error_details + Given pas_cov a mock plan with apply summary in error_details + When pas_cov I build artifacts dict with no changeset + Then pas_cov the artifacts dict should have key "apply_summary" + And pas_cov the artifacts dict apply_summary files_changed should be "5" + + # ====================================================================== + # Service initialization + # ====================================================================== + + Scenario: PlanApplyService raises ValidationError when lifecycle is None + When pas_cov I create PlanApplyService with None lifecycle service + Then pas_cov a ValidationError should be raised + + Scenario: PlanApplyService initializes with valid lifecycle and no store + Given pas_cov a mock lifecycle service + When pas_cov I create PlanApplyService with valid lifecycle and no store + Then pas_cov the service should be created successfully + + Scenario: PlanApplyService initializes with valid lifecycle and a store + Given pas_cov a mock lifecycle service + And pas_cov a mock changeset store + When pas_cov I create PlanApplyService with valid lifecycle and store + Then pas_cov the service should be created successfully + + # ====================================================================== + # diff method + # ====================================================================== + + Scenario: diff raises PlanError when plan has no changeset + Given pas_cov a service with a plan that has no changeset_id + When pas_cov I call diff on the service + Then pas_cov a PlanError should be raised with message containing "no ChangeSet" + + Scenario: diff returns rich format by default + Given pas_cov a service with a plan that has a changeset with entries + When pas_cov I call diff with format "rich" + Then pas_cov the diff result should contain "[bold]ChangeSet:[/bold]" + + Scenario: diff returns plain format + Given pas_cov a service with a plan that has a changeset with entries + When pas_cov I call diff with format "plain" + Then pas_cov the diff result should contain "ChangeSet:" + And pas_cov the diff result should not contain "[bold]" + + Scenario: diff returns json format + Given pas_cov a service with a plan that has a changeset with entries + When pas_cov I call diff with format "json" + Then pas_cov the diff result should be valid JSON + + Scenario: diff returns yaml format + Given pas_cov a service with a plan that has a changeset with entries + When pas_cov I call diff with format "yaml" + Then pas_cov the diff result should contain "changeset_id:" + + # ====================================================================== + # artifacts method + # ====================================================================== + + Scenario: artifacts returns json format output + Given pas_cov a service with a plan that has a changeset with entries + When pas_cov I call artifacts with format "json" + Then pas_cov the artifacts result should contain "plan_id" + + Scenario: artifacts returns plain format output + Given pas_cov a service with a plan that has a changeset with entries + When pas_cov I call artifacts with format "plain" + Then pas_cov the artifacts result should be a non-empty string + + Scenario: artifacts falls back to json for rich format + Given pas_cov a service with a plan that has a changeset with entries + When pas_cov I call artifacts with format "rich" + Then pas_cov the artifacts result should contain "plan_id" + + # ====================================================================== + # persist_apply_summary + # ====================================================================== + + Scenario: persist apply summary stores metadata on plan with no prior error_details + Given pas_cov a service with a plan that has no error_details + When pas_cov I persist apply summary with 3 files and 2 validations + Then pas_cov the plan error_details should contain "apply_files_changed" with value "3" + And pas_cov the plan error_details should contain "apply_validations_run" with value "2" + And pas_cov the plan error_details should contain key "apply_completed_at" + And pas_cov the lifecycle _commit_plan should have been called + + Scenario: persist apply summary preserves existing error_details + Given pas_cov a service with a plan that has existing error_details + When pas_cov I persist apply summary with 1 files and 0 validations + Then pas_cov the plan error_details should contain "existing_key" with value "existing_value" + And pas_cov the plan error_details should contain "apply_files_changed" with value "1" + + # ====================================================================== + # handle_merge_failure + # ====================================================================== + + Scenario: handle merge failure stores conflict details with no prior error_details + Given pas_cov a service with a plan that has no error_details + When pas_cov I handle merge failure with conflict "file.py has conflicts" + Then pas_cov the lifecycle fail_apply should have been called + And pas_cov the returned plan should reflect errored state + + Scenario: handle merge failure preserves existing error_details + Given pas_cov a service with a plan that has existing error_details + When pas_cov I handle merge failure with conflict "merge.txt conflict" + Then pas_cov the committed plan error_details should contain "existing_key" + And pas_cov the committed plan error_details should contain "merge_conflict" + And pas_cov the committed plan error_details should contain "sandbox_rollback" + + # ====================================================================== + # guard_empty_changeset + # ====================================================================== + + Scenario: guard passes when changeset has entries + Given pas_cov a service with a plan that has a changeset with entries + When pas_cov I call guard_empty_changeset + Then pas_cov the guard should return true + + Scenario: guard passes when changeset is empty but allow_empty is true + Given pas_cov a service with a plan that has an empty changeset + When pas_cov I call guard_empty_changeset with allow_empty true + Then pas_cov the guard should return true + + Scenario: guard raises PlanError when changeset is empty + Given pas_cov a service with a plan that has an empty changeset + When pas_cov I call guard_empty_changeset + Then pas_cov a PlanError should be raised with message containing "empty ChangeSet" + + Scenario: guard raises PlanError when changeset is None + Given pas_cov a service with a plan that has no changeset_id + When pas_cov I call guard_empty_changeset + Then pas_cov a PlanError should be raised with message containing "empty ChangeSet" + + # ====================================================================== + # _extract_validation_counts + # ====================================================================== + + Scenario: extract validation counts from None returns zeros + When pas_cov I extract validation counts from None + Then pas_cov the counts should be 0 passed 0 failed 0 total + + Scenario: extract validation counts from full summary + When pas_cov I extract validation counts from full summary 3 passed 1 failed 6 total + Then pas_cov the counts should be 3 passed 1 failed 6 total + + Scenario: extract validation counts computes total when missing + When pas_cov I extract validation counts from partial summary 2 passed 1 failed + Then pas_cov the counts should be 2 passed 1 failed 3 total + + # ====================================================================== + # _resolve_changeset + # ====================================================================== + + Scenario: resolve changeset returns None when plan has no changeset_id + Given pas_cov a service with a plan that has no changeset_id + When pas_cov I call _resolve_changeset + Then pas_cov the resolved changeset should be None + + Scenario: resolve changeset returns from store when available + Given pas_cov a service with a plan that has changeset_id and store returns a changeset + When pas_cov I call _resolve_changeset + Then pas_cov the resolved changeset should have entries + + Scenario: resolve changeset falls back to empty stub when store returns None + Given pas_cov a service with a plan that has changeset_id but store returns None + When pas_cov I call _resolve_changeset + Then pas_cov the resolved changeset should have no entries + And pas_cov the resolved changeset should have the plan changeset_id + + Scenario: resolve changeset falls back to empty stub when no store configured + Given pas_cov a service with a plan that has changeset_id and no store + When pas_cov I call _resolve_changeset + Then pas_cov the resolved changeset should have no entries + + # ====================================================================== + # cleanup_changeset + # ====================================================================== + + Scenario: cleanup changeset raises ValidationError for empty plan_id + Given pas_cov a service with a mock lifecycle + When pas_cov I call cleanup_changeset with empty plan_id + Then pas_cov a ValidationError should be raised + + Scenario: cleanup changeset delegates to store delete_for_plan + Given pas_cov a service with a store that has delete_for_plan returning 5 + When pas_cov I call cleanup_changeset with plan_id "01PLANTEST000000000000001" + Then pas_cov cleanup should return 5 + + Scenario: cleanup changeset returns 0 when store is None + Given pas_cov a service with no changeset store + When pas_cov I call cleanup_changeset with plan_id "01PLANTEST000000000000001" + Then pas_cov cleanup should return 0 + + Scenario: cleanup changeset returns 0 when store has no delete_for_plan + Given pas_cov a service with a store that lacks delete_for_plan + When pas_cov I call cleanup_changeset with plan_id "01PLANTEST000000000000001" + Then pas_cov cleanup should return 0 + + # ====================================================================== + # apply_with_validation_gate: constrain_apply raises exception branch + # ====================================================================== + + Scenario: apply with validation gate handles constrain_apply exception + Given pas_cov a service where constrain_apply raises PlanError + And pas_cov the plan has failed validations + When pas_cov I call apply_with_validation_gate + Then pas_cov the result outcome should be "constrained" + And pas_cov the result message should contain "Apply refused" + + # ====================================================================== + # apply_with_validation_gate: complete_apply raises exception branch + # ====================================================================== + + Scenario: apply with validation gate handles complete_apply exception + Given pas_cov a service where complete_apply raises PlanError + And pas_cov the plan has passing validations and entries + When pas_cov I call apply_with_validation_gate + Then pas_cov the result outcome should be "applied" + And pas_cov the result message should contain "applied successfully" + + # ====================================================================== + # ApplyOutcome and ApplyResult model + # ====================================================================== + + Scenario: ApplyOutcome enum has all expected values + Then pas_cov ApplyOutcome should have value "applied" + And pas_cov ApplyOutcome should have value "constrained" + And pas_cov ApplyOutcome should have value "already_applied" + And pas_cov ApplyOutcome should have value "blocked_empty" + + Scenario: ApplyResult model validates and strips whitespace + When pas_cov I create an ApplyResult with whitespace in message + Then pas_cov the ApplyResult message should be stripped + + Scenario: OP_COLORS dict has expected keys + Then pas_cov OP_COLORS should map "create" to "green" + And pas_cov OP_COLORS should map "modify" to "yellow" + And pas_cov OP_COLORS should map "delete" to "red" + And pas_cov OP_COLORS should map "rename" to "cyan" diff --git a/features/plan_cli_coverage.feature b/features/plan_cli_coverage.feature new file mode 100644 index 00000000..0b9297fb --- /dev/null +++ b/features/plan_cli_coverage.feature @@ -0,0 +1,123 @@ +Feature: Plan CLI uncovered lines and branches coverage + As a developer + I want to exercise the uncovered lines and branches in plan.py + So that code coverage gaps at lines 80, 818, 827-828, 999, 1017-1035, + 1314-1315, 1391-1392, 1573-1578, 1594-1596, 1700-1701, 1739 are closed + + # =================================================================== + # Legacy apply command — Progress block and PlanError handler + # =================================================================== + + Scenario: Legacy apply succeeds through Progress spinner block + Given a plan-cov CLI runner + And a mocked legacy container for plan-cov apply that succeeds + When I invoke plan-cov legacy apply with --yes + Then the plan-cov command should exit normally + And the plan-cov output should contain "Successfully applied" + + Scenario: Legacy apply raises PlanError + Given a plan-cov CLI runner + And a mocked legacy container for plan-cov apply that raises PlanError + When I invoke plan-cov legacy apply with --yes + Then the plan-cov command should abort + And the plan-cov output should contain "Apply Error" + + # =================================================================== + # Legacy list command — plan.current=False branch + # =================================================================== + + Scenario: Legacy list shows plans with current=False + Given a plan-cov CLI runner + And a mocked legacy container for plan-cov list with non-current plans + When I invoke plan-cov legacy list + Then the plan-cov command should exit normally + And the plan-cov output should contain "Plans" + + # =================================================================== + # Legacy cd command — full body coverage + # =================================================================== + + Scenario: Legacy cd switches to an existing plan + Given a plan-cov CLI runner + And a mocked legacy container for plan-cov cd that succeeds + When I invoke plan-cov legacy cd "my-plan" + Then the plan-cov command should exit normally + And the plan-cov output should contain "Switched to plan" + + Scenario: Legacy cd raises CleverAgentsError + Given a plan-cov CLI runner + And a mocked legacy container for plan-cov cd that raises CleverAgentsError + When I invoke plan-cov legacy cd "bad-plan" + Then the plan-cov command should abort + And the plan-cov output should contain "Error" + + # =================================================================== + # use command — invariant_actor and invariants + # =================================================================== + + Scenario: Use action with estimation-actor and invariant-actor overrides + Given a plan-cov CLI runner + And a mocked lifecycle service for plan-cov use action + When I invoke plan-cov use with estimation-actor "openai/gpt-4" and invariant-actor "openai/gpt-4" + Then the plan-cov command should exit normally + And the plan-cov created plan should have estimation_actor set + + Scenario: Use action with --invariant flags builds plan invariants + Given a plan-cov CLI runner + And a mocked lifecycle service for plan-cov use action + When I invoke plan-cov use with invariant "No new warnings" + Then the plan-cov command should exit normally + + # =================================================================== + # lifecycle-apply — no plan_id auto-discovery branches + # =================================================================== + + Scenario: lifecycle-apply without plan_id when no plans are ready + Given a plan-cov CLI runner + And a mocked lifecycle service for plan-cov that returns no execute-complete plans + When I invoke plan-cov lifecycle-apply without plan_id + Then the plan-cov command should abort + And the plan-cov output should contain "No plans ready for apply" + + Scenario: lifecycle-apply without plan_id when multiple plans are ready + Given a plan-cov CLI runner + And a mocked lifecycle service for plan-cov that returns multiple execute-complete plans + When I invoke plan-cov lifecycle-apply without plan_id + Then the plan-cov command should abort + And the plan-cov output should contain "Multiple plans ready" + + Scenario: lifecycle-apply rejects a read-only plan + Given a plan-cov CLI runner + And a mocked lifecycle service for plan-cov that returns a read-only plan + When I invoke plan-cov lifecycle-apply with plan_id "01ARZ3NDEKTSV4RRFFQ69G5FAA" + Then the plan-cov command should abort + And the plan-cov output should contain "read-only" + + # =================================================================== + # status command — CleverAgentsError handler + # =================================================================== + + Scenario: status command raises CleverAgentsError + Given a plan-cov CLI runner + And a mocked lifecycle service for plan-cov status that raises CleverAgentsError + When I invoke plan-cov status "01ARZ3NDEKTSV4RRFFQ69G5FAA" + Then the plan-cov command should abort + And the plan-cov output should contain "Error" + + # =================================================================== + # errors command — no error_category branch + # =================================================================== + + Scenario: errors command on plan with no error recovery data + Given a plan-cov CLI runner + And a mocked lifecycle service for plan-cov errors with no error_category + When I invoke plan-cov errors "01ARZ3NDEKTSV4RRFFQ69G5FAA" with format "json" + Then the plan-cov command should exit normally + And the plan-cov output should contain "plan_id" + + Scenario: errors command on plan with empty error_details in rich format + Given a plan-cov CLI runner + And a mocked lifecycle service for plan-cov errors with empty details + When I invoke plan-cov errors "01ARZ3NDEKTSV4RRFFQ69G5FAA" + Then the plan-cov command should exit normally + And the plan-cov output should contain "No error recovery records" diff --git a/features/plan_executor_coverage.feature b/features/plan_executor_coverage.feature index 62a1be00..d9e2a9b7 100644 --- a/features/plan_executor_coverage.feature +++ b/features/plan_executor_coverage.feature @@ -1,158 +1,394 @@ -Feature: PlanExecutor coverage for uncovered lines +Feature: PlanExecutor comprehensive coverage As a developer - I want to test uncovered paths in plan_executor.py - So that lines 144, 233-235, 273, 483, 490 are covered + I want thorough test coverage of plan_executor.py + So that all branches and edge cases are exercised - # --- StrategizeStubActor._parse_steps empty input (line 144) --- + # ------------------------------------------------------------------ + # StrategizeStubActor._parse_steps parsing branches + # ------------------------------------------------------------------ - Scenario: Parse steps returns default when input is empty string - Given a StrategizeStubActor instance for coverage - When I call _parse_steps with an empty string - Then the parsed steps should be the default fallback + Scenario: Parse steps with dash bullet prefix + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from "- First item\n- Second item" + Then the cov2 parsed steps count should be "2" + And the cov2 parsed step at index "0" should be "First item" + And the cov2 parsed step at index "1" should be "Second item" - Scenario: Parse steps returns default when input is whitespace only - Given a StrategizeStubActor instance for coverage - When I call _parse_steps with whitespace only - Then the parsed steps should be the default fallback + Scenario: Parse steps with asterisk bullet prefix + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from "* Alpha\n* Beta" + Then the cov2 parsed steps count should be "2" + And the cov2 parsed step at index "0" should be "Alpha" + And the cov2 parsed step at index "1" should be "Beta" - # --- StrategizeStubActor.execute happy path --- + Scenario: Parse steps with bullet character prefix + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from "\u2022 Uno\n\u2022 Dos" + Then the cov2 parsed steps count should be "2" + And the cov2 parsed step at index "0" should be "Uno" + And the cov2 parsed step at index "1" should be "Dos" - Scenario: Strategize stub actor produces decisions from definition of done - Given a StrategizeStubActor instance for coverage - When I execute the strategize stub with a valid plan and definition of done - Then the strategize result should contain decisions matching the steps + Scenario: Parse steps with dot-numbered items + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from "1. Do alpha\n2. Do beta\n10. Do gamma" + Then the cov2 parsed steps count should be "3" + And the cov2 parsed step at index "0" should be "Do alpha" + And the cov2 parsed step at index "1" should be "Do beta" + And the cov2 parsed step at index "2" should be "Do gamma" - Scenario: Strategize stub actor handles None definition of done - Given a StrategizeStubActor instance for coverage - When I execute the strategize stub with a None definition of done - Then the strategize result should contain the default decision + Scenario: Parse steps with paren-numbered items + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from "1) Task one\n2) Task two" + Then the cov2 parsed steps count should be "2" + And the cov2 parsed step at index "0" should be "Task one" + And the cov2 parsed step at index "1" should be "Task two" - Scenario: Strategize stub actor rejects empty plan_id - Given a StrategizeStubActor instance for coverage - When I execute the strategize stub with an empty plan_id - Then a plan executor ValidationError should be raised containing "plan_id" + Scenario: Parse steps skips blank lines in input + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from "Line A\n\n\nLine B" + Then the cov2 parsed steps count should be "2" + And the cov2 parsed step at index "0" should be "Line A" + And the cov2 parsed step at index "1" should be "Line B" - Scenario: Strategize stub actor processes invariants - Given a StrategizeStubActor instance for coverage - When I execute the strategize stub with invariants - Then the strategize result should contain invariant records + Scenario: Parse steps with prefix that leaves empty content returns default + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from "- " + Then the cov2 parsed steps should be the default objective - Scenario: Strategize stub actor invokes stream callback - Given a StrategizeStubActor instance for coverage - When I execute the strategize stub with a stream callback - Then the stream callback should have been called with strategize events + Scenario: Parse steps with empty string returns default + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from an empty string + Then the cov2 parsed steps should be the default objective - # --- ExecuteStubActor happy path --- + Scenario: Parse steps with whitespace-only returns default + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from " \n\t " + Then the cov2 parsed steps should be the default objective - Scenario: Execute stub actor produces a changeset - Given an ExecuteStubActor instance for coverage - When I execute the execute stub with decisions - Then the execute result should contain a changeset id + Scenario: Parse steps with plain text lines + Given a fresh StrategizeStubActor for cov2 + When I cov2 parse steps from "Just a sentence" + Then the cov2 parsed steps count should be "1" + And the cov2 parsed step at index "0" should be "Just a sentence" - Scenario: Execute stub actor rejects empty plan_id - Given an ExecuteStubActor instance for coverage - When I execute the execute stub with an empty plan_id - Then a plan executor ValidationError should be raised containing "plan_id" + # ------------------------------------------------------------------ + # StrategizeStubActor.execute - validation and edge cases + # ------------------------------------------------------------------ - # --- PlanExecutor construction --- + Scenario: Strategize stub rejects empty plan_id + Given a fresh StrategizeStubActor for cov2 + When I cov2 execute strategize with an empty plan_id + Then a cov2 ValidationError should be raised with message containing "plan_id" - Scenario: PlanExecutor rejects None lifecycle service - When I construct a PlanExecutor with None lifecycle service - Then a plan executor ValidationError should be raised containing "lifecycle_service" + Scenario: Strategize stub with None definition of done produces default decision + Given a fresh StrategizeStubActor for cov2 + When I cov2 execute strategize with plan_id "PLAN01" and no definition + Then the cov2 strategize result should have "1" decisions + And the cov2 first decision text should be "Complete the plan objectives" - Scenario: PlanExecutor has_runtime is False without execution context - Given a fresh mock lifecycle for plan executor coverage - When I construct a PlanExecutor without execution context - Then plan executor has_runtime should be False + Scenario: Strategize stub with multi-line definition produces correct decisions + Given a fresh StrategizeStubActor for cov2 + When I cov2 execute strategize with plan_id "PLAN02" and definition "Step A\nStep B\nStep C" + Then the cov2 strategize result should have "3" decisions + And the cov2 root decision should have no parent + And the cov2 non-root decisions should reference root as parent - Scenario: PlanExecutor changeset_store is None without execution context - Given a fresh mock lifecycle for plan executor coverage - When I construct a PlanExecutor without execution context - Then plan executor changeset_store should be None + Scenario: Strategize stub with invariants produces invariant records + Given a fresh StrategizeStubActor for cov2 + When I cov2 execute strategize with invariants + Then the cov2 strategize result should have "2" invariant records + And each cov2 invariant record should have enforced set to true - # --- PlanExecutor.run_strategize happy path --- + Scenario: Strategize stub emits all stream events + Given a fresh StrategizeStubActor for cov2 + And a cov2 stream event collector + When I cov2 execute strategize with stream callback + Then the cov2 stream events should include "strategize_started" + And the cov2 stream events should include "strategize_decisions" + And the cov2 stream events should include "strategize_complete" - Scenario: PlanExecutor run_strategize succeeds for a plan in Strategize phase - Given a mock lifecycle with a plan in Strategize phase for executor coverage - And a PlanExecutor using that lifecycle for coverage - When I call run_strategize on the PlanExecutor for coverage - Then the strategize result should be returned successfully - And the lifecycle should have called start_strategize for coverage - And the lifecycle should have called complete_strategize for coverage + Scenario: Strategize stub without stream callback does not error + Given a fresh StrategizeStubActor for cov2 + When I cov2 execute strategize with plan_id "PLAN03" and definition "Do thing" without callback + Then the cov2 strategize result should have "1" decisions - # --- PlanExecutor.run_strategize exception path (line 273) --- + # ------------------------------------------------------------------ + # ExecuteStubActor - tool_runner, sandbox_root, stream, read_only + # ------------------------------------------------------------------ - Scenario: PlanExecutor run_strategize handles exception and calls fail_strategize - Given a mock lifecycle with a plan in Strategize phase that will fail during execute - And a PlanExecutor using that failing lifecycle for coverage - When I call run_strategize expecting an exception for coverage - Then the lifecycle should have called fail_strategize for coverage + Scenario: Execute stub rejects empty plan_id + Given a fresh ExecuteStubActor for cov2 + When I cov2 execute execute-stub with empty plan_id + Then a cov2 ValidationError should be raised with message containing "plan_id" - # --- PlanExecutor.run_strategize wrong phase --- + Scenario: Execute stub with tool_runner counts discovered tools + Given a fresh ExecuteStubActor for cov2 + And a cov2 mock tool runner that discovers 3 tools + When I cov2 execute execute-stub with tool_runner and 2 decisions + Then the cov2 execute result tool_calls_count should be "6" - Scenario: PlanExecutor run_strategize rejects plan not in Strategize phase - Given a mock lifecycle with a plan in Execute phase for executor coverage - And a PlanExecutor using that lifecycle for coverage - When I call run_strategize expecting a PlanError for coverage - Then a PlanError should be raised about wrong phase for coverage + Scenario: Execute stub with sandbox_root populates sandbox_refs + Given a fresh ExecuteStubActor for cov2 + When I cov2 execute execute-stub with sandbox_root "/tmp/sandbox" + Then the cov2 execute result sandbox_refs should contain "/tmp/sandbox" - # --- PlanExecutor._guard_execute: decision_root_id is None (lines 233-235) --- + Scenario: Execute stub without sandbox_root has empty sandbox_refs + Given a fresh ExecuteStubActor for cov2 + When I cov2 execute execute-stub without sandbox_root + Then the cov2 execute result sandbox_refs should be empty - Scenario: Guard execute rejects plan with no decision root id - Given a mock lifecycle with an execute-phase plan missing decision_root_id - And a PlanExecutor using that lifecycle for coverage - When I call guard_execute for the plan for coverage - Then a PlanError should be raised about missing decision tree + Scenario: Execute stub emits all stream events for each decision + Given a fresh ExecuteStubActor for cov2 + And a cov2 stream event collector + When I cov2 execute execute-stub with 2 decisions and stream callback + Then the cov2 stream events should include "execute_started" + And the cov2 stream events should include "execute_step" + And the cov2 stream events should include "execute_complete" - Scenario: Guard execute rejects plan not in Execute phase - Given a mock lifecycle with a plan in Strategize phase for guard coverage - And a PlanExecutor using that lifecycle for coverage - When I call guard_execute expecting wrong phase for coverage - Then a PlanError should be raised about not in Execute phase + Scenario: Execute stub with read_only passes to changeset capture + Given a fresh ExecuteStubActor for cov2 + When I cov2 execute execute-stub with read_only true + Then the cov2 execute result should have a valid changeset_id - Scenario: Guard execute rejects plan not in queued state - Given a mock lifecycle with an execute-phase plan in processing state for coverage - And a PlanExecutor using that lifecycle for coverage - When I call guard_execute expecting wrong state for coverage - Then a PlanError should be raised about not queued + # ------------------------------------------------------------------ + # PlanExecutor.__init__ - validation + # ------------------------------------------------------------------ - # --- PlanExecutor._run_execute_with_stub happy path --- + Scenario: PlanExecutor rejects None lifecycle_service + When I cov2 construct PlanExecutor with None lifecycle + Then a cov2 ValidationError should be raised with message containing "lifecycle_service" - Scenario: Run execute with stub succeeds for a properly configured plan - Given a mock lifecycle with a fully configured execute-phase plan for coverage - And a PlanExecutor using that lifecycle without runtime for coverage - When I call run_execute on the PlanExecutor for coverage - Then the execute result should be returned successfully - And the lifecycle should have called start_execute for coverage - And the lifecycle should have called complete_execute for coverage + Scenario: PlanExecutor initialises with valid lifecycle + Given a cov2 mock lifecycle service + When I cov2 construct PlanExecutor with that lifecycle + Then the cov2 PlanExecutor should be initialised without error - # --- PlanExecutor._run_execute_with_stub exception path (lines 483, 490) --- + # ------------------------------------------------------------------ + # PlanExecutor properties + # ------------------------------------------------------------------ - Scenario: Run execute with stub handles exception and calls fail_execute - Given a mock lifecycle with an execute-phase plan that will fail during stub execute - And a PlanExecutor using that failing stub lifecycle for coverage - When I call run_execute expecting an exception from stub for coverage - Then the lifecycle should have called fail_execute for coverage + Scenario: has_runtime is False without execution context + Given a cov2 mock lifecycle service + And a cov2 PlanExecutor without execution context + Then the cov2 executor has_runtime should be "False" - # --- PlanExecutor.run_execute validation --- + Scenario: has_runtime is True with execution context + Given a cov2 mock lifecycle service + And a cov2 mock execution context + And a cov2 PlanExecutor with execution context + Then the cov2 executor has_runtime should be "True" - Scenario: PlanExecutor run_execute rejects empty plan_id - Given a fresh mock lifecycle for plan executor coverage - And a PlanExecutor using that lifecycle for coverage - When I call run_execute with an empty plan_id for coverage - Then a plan executor ValidationError should be raised containing "plan_id" + Scenario: changeset_store is None without execution context + Given a cov2 mock lifecycle service + And a cov2 PlanExecutor without execution context + Then the cov2 executor changeset_store should be None - # --- PlanExecutor._build_decisions --- + Scenario: changeset_store returns store from execution context + Given a cov2 mock lifecycle service + And a cov2 mock execution context with changeset store + And a cov2 PlanExecutor with that execution context + Then the cov2 executor changeset_store should be the mock store - Scenario: Build decisions creates decisions from plan definition of done - Given a mock lifecycle with a fully configured execute-phase plan for coverage - And a PlanExecutor using that lifecycle without runtime for coverage - When I call build_decisions for the plan for coverage - Then the decisions should match the plan definition of done steps + Scenario: execution_context property returns None without context + Given a cov2 mock lifecycle service + And a cov2 PlanExecutor without execution context + Then the cov2 executor execution_context should be None - # --- PlanExecutor.execution_context property --- + Scenario: execution_context property returns context when set + Given a cov2 mock lifecycle service + And a cov2 mock execution context + And a cov2 PlanExecutor with execution context + Then the cov2 executor execution_context should not be None - Scenario: PlanExecutor execution_context returns None without context - Given a fresh mock lifecycle for plan executor coverage - When I construct a PlanExecutor without execution context - Then plan executor execution_context should be None + # ------------------------------------------------------------------ + # PlanExecutor.run_strategize - guards and happy path + # ------------------------------------------------------------------ + + Scenario: run_strategize rejects empty plan_id + Given a cov2 mock lifecycle service + And a cov2 PlanExecutor without execution context + When I cov2 call run_strategize with empty plan_id + Then a cov2 ValidationError should be raised with message containing "plan_id" + + Scenario: run_strategize rejects plan not in Strategize phase + Given a cov2 mock lifecycle service + And a cov2 plan in Execute phase + And a cov2 PlanExecutor without execution context + When I cov2 call run_strategize + Then a cov2 PlanError should be raised containing "not in Strategize phase" + + Scenario: run_strategize happy path succeeds + Given a cov2 mock lifecycle service + And a cov2 plan in Strategize phase with definition "Build feature\nAdd tests" + And a cov2 PlanExecutor without execution context + When I cov2 call run_strategize + Then the cov2 strategize run result should be a StrategizeResult + And the cov2 lifecycle should have called start_strategize + And the cov2 lifecycle should have called complete_strategize + And the cov2 lifecycle should have called _commit_plan + + Scenario: run_strategize sets decision_root_id on execution context + Given a cov2 mock lifecycle service + And a cov2 mock execution context + And a cov2 plan in Strategize phase with definition "Do work" + And a cov2 PlanExecutor with execution context + When I cov2 call run_strategize + Then the cov2 execution context decision_root_id should be set + + # ------------------------------------------------------------------ + # PlanExecutor.run_strategize - exception path + # ------------------------------------------------------------------ + + Scenario: run_strategize calls fail_strategize on exception + Given a cov2 mock lifecycle service + And a cov2 plan in Strategize phase with definition "Do work" + And a cov2 PlanExecutor with failing strategize actor + When I cov2 call run_strategize expecting exception + Then a cov2 RuntimeError should have been raised + And the cov2 lifecycle should have called fail_strategize + And the cov2 plan error_details should contain exception_type + + Scenario: run_strategize records error via error_recovery service + Given a cov2 mock lifecycle service + And a cov2 plan in Strategize phase with definition "Do work" + And a cov2 mock error recovery service + And a cov2 PlanExecutor with error recovery and failing strategize actor + When I cov2 call run_strategize expecting exception + Then the cov2 error recovery should have recorded a strategize error + + # ------------------------------------------------------------------ + # PlanExecutor._guard_execute - all branches + # ------------------------------------------------------------------ + + Scenario: guard_execute rejects plan not in Execute phase + Given a cov2 mock lifecycle service + And a cov2 plan in Strategize phase with definition "Do work" + And a cov2 PlanExecutor without execution context + When I cov2 call guard_execute + Then a cov2 PlanError should be raised containing "not in Execute phase" + + Scenario: guard_execute rejects plan not in queued state + Given a cov2 mock lifecycle service + And a cov2 plan in Execute phase but Processing state + And a cov2 PlanExecutor without execution context + When I cov2 call guard_execute + Then a cov2 PlanError should be raised containing "not queued" + + Scenario: guard_execute rejects plan with no decision_root_id + Given a cov2 mock lifecycle service + And a cov2 plan in Execute-Queued state without decision root + And a cov2 PlanExecutor without execution context + When I cov2 call guard_execute + Then a cov2 PlanError should be raised containing "no decision tree" + + # ------------------------------------------------------------------ + # PlanExecutor.run_execute - routing + # ------------------------------------------------------------------ + + Scenario: run_execute rejects empty plan_id + Given a cov2 mock lifecycle service + And a cov2 PlanExecutor without execution context + When I cov2 call run_execute with empty plan_id + Then a cov2 ValidationError should be raised with message containing "plan_id" + + Scenario: run_execute routes to stub when no execution context + Given a cov2 mock lifecycle service + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor without execution context + When I cov2 call run_execute + Then the cov2 execute run result should be an ExecuteResult + And the cov2 lifecycle should have called complete_execute + + # ------------------------------------------------------------------ + # PlanExecutor._run_execute_with_runtime + # ------------------------------------------------------------------ + + Scenario: run_execute routes to runtime when execution context is set + Given a cov2 mock lifecycle service + And a cov2 mock execution context + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor with execution context and tool runner + When I cov2 call run_execute + Then the cov2 execute run result should be a RuntimeExecuteResult + And the cov2 lifecycle should have called complete_execute + + Scenario: run_execute runtime mode raises PlanError when no ToolRunner + Given a cov2 mock lifecycle service + And a cov2 mock execution context + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor with execution context but no tool runner + When I cov2 call run_execute expecting exception + Then a cov2 PlanError should be raised containing "ToolRunner" + + Scenario: run_execute runtime mode calls fail_execute on exception + Given a cov2 mock lifecycle service + And a cov2 mock execution context + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor with execution context and tool runner and failing runtime + When I cov2 call run_execute expecting exception + Then a cov2 RuntimeError should have been raised + And the cov2 lifecycle should have called fail_execute + + # ------------------------------------------------------------------ + # PlanExecutor._run_execute_with_stub - error recovery / retry + # ------------------------------------------------------------------ + + Scenario: run_execute stub calls fail_execute on exception without recovery + Given a cov2 mock lifecycle service + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor with failing execute actor and no recovery + When I cov2 call run_execute expecting exception + Then a cov2 RuntimeError should have been raised + And the cov2 lifecycle should have called fail_execute + + Scenario: run_execute stub retries once with error recovery then succeeds + Given a cov2 mock lifecycle service + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor with execute actor that fails once then succeeds + When I cov2 call run_execute + Then the cov2 execute run result should be an ExecuteResult + And the cov2 error recovery should have recorded an execute error + + Scenario: run_execute stub exhausts retries with error recovery and fails + Given a cov2 mock lifecycle service + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor with always-failing execute actor and exhausted retries + When I cov2 call run_execute expecting exception + Then a cov2 RuntimeError should have been raised + And the cov2 lifecycle should have called fail_execute + And the cov2 error recovery should have recorded an execute error + + Scenario: run_execute stub with error recovery records error and no retry + Given a cov2 mock lifecycle service + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor with failing execute actor and recovery denying retry + When I cov2 call run_execute expecting exception + Then a cov2 RuntimeError should have been raised + And the cov2 error recovery should have recorded an execute error + And the cov2 lifecycle should have called fail_execute + + # ------------------------------------------------------------------ + # PlanExecutor._build_decisions + # ------------------------------------------------------------------ + + Scenario: _build_decisions creates decisions from plan definition_of_done + Given a cov2 mock lifecycle service + And a cov2 plan in Execute-Queued state with decision root and definition + And a cov2 PlanExecutor without execution context + When I cov2 call build_decisions + Then the cov2 built decisions should have "2" entries + And the cov2 first built decision should use the plan decision_root_id + And the cov2 second built decision should have root as parent + + # ------------------------------------------------------------------ + # Result model edge cases + # ------------------------------------------------------------------ + + Scenario: StrategyDecision model validates sequence is non-negative + When I cov2 create a StrategyDecision with negative sequence + Then a cov2 validation error should be raised for the model + + Scenario: ExecuteResult defaults are correct + When I cov2 create an ExecuteResult with minimal fields + Then the cov2 ExecuteResult tool_calls_count should default to "0" + And the cov2 ExecuteResult sandbox_refs should default to empty list diff --git a/features/repositories_coverage.feature b/features/repositories_coverage.feature new file mode 100644 index 00000000..6a04da7d --- /dev/null +++ b/features/repositories_coverage.feature @@ -0,0 +1,232 @@ +@unit @repository @coverage +Feature: Repository coverage for ActionRepository and LifecyclePlanRepository + Exercise uncovered lines and branches in repositories.py to boost + line-rate and branch-rate for ActionRepository and LifecyclePlanRepository. + + Background: + Given a repos-cov in-memory database with lifecycle schema + And a repos-cov action repository + And a repos-cov lifecycle plan repository + + # --------------------------------------------------------------------------- + # ActionRepository.get_by_name – DatabaseError on OperationalError + # --------------------------------------------------------------------------- + + @action @error_handling + Scenario: ActionRepository.get_by_name raises DatabaseError on OperationalError + Given a repos-cov action repository with a session that raises OperationalError on query + When repos-cov get_by_name is called with "local/broken-action" + Then repos-cov a DatabaseError should be raised containing "Failed to get action by name" + + # --------------------------------------------------------------------------- + # ActionRepository.get_by_name – success path returns domain object + # --------------------------------------------------------------------------- + + @action + Scenario: ActionRepository.get_by_name returns domain object for existing action + Given repos-cov an available action "local/findable" exists + When repos-cov get_by_name is called with "local/findable" + Then repos-cov the returned action should have name "local/findable" + + # --------------------------------------------------------------------------- + # ActionRepository.get_by_namespace – with state filter returning results + # --------------------------------------------------------------------------- + + @action + Scenario: ActionRepository.get_by_namespace with state filter returns matching actions + Given repos-cov an action "local/active-one" exists with state "available" + And repos-cov an action "local/archived-one" exists with state "archived" + When repos-cov get_by_namespace is called with namespace "local" and state "available" + Then repos-cov exactly 1 action should be in the result list + And repos-cov the result list should contain action named "local/active-one" + + # --------------------------------------------------------------------------- + # ActionRepository.get_by_namespace – DatabaseError on OperationalError + # --------------------------------------------------------------------------- + + @action @error_handling + Scenario: ActionRepository.get_by_namespace raises DatabaseError on OperationalError + Given a repos-cov action repository with a session that raises OperationalError on query + When repos-cov get_by_namespace is called with namespace "local" expecting error + Then repos-cov a DatabaseError should be raised containing "Failed to list actions in namespace" + + # --------------------------------------------------------------------------- + # ActionRepository.list_available – without namespace filter + # --------------------------------------------------------------------------- + + @action + Scenario: ActionRepository.list_available returns all available actions without namespace + Given repos-cov an action "ns1/act-a" exists with state "available" + And repos-cov an action "ns2/act-b" exists with state "available" + And repos-cov an action "ns1/act-c" exists with state "archived" + When repos-cov list_available is called without a namespace filter + Then repos-cov exactly 2 actions should be in the result list + + # --------------------------------------------------------------------------- + # ActionRepository.list_available – with namespace filter + # --------------------------------------------------------------------------- + + @action + Scenario: ActionRepository.list_available returns available actions filtered by namespace + Given repos-cov an action "nsx/avail-1" exists with state "available" + And repos-cov an action "nsy/avail-2" exists with state "available" + When repos-cov list_available is called with namespace "nsx" + Then repos-cov exactly 1 action should be in the result list + + # --------------------------------------------------------------------------- + # ActionRepository.list_available – DatabaseError on OperationalError + # --------------------------------------------------------------------------- + + @action @error_handling + Scenario: ActionRepository.list_available raises DatabaseError on OperationalError + Given a repos-cov action repository with a session that raises OperationalError on query + When repos-cov list_available is called and an error is expected + Then repos-cov a DatabaseError should be raised containing "Failed to list available actions" + + # --------------------------------------------------------------------------- + # ActionRepository.update – with arguments having min_value and max_value + # --------------------------------------------------------------------------- + + @action @update + Scenario: ActionRepository.update persists arguments with min_value and max_value + Given repos-cov an available action "local/minmax-action" exists + And repos-cov the action has arguments with min_value and max_value set + When repos-cov the action is updated via the repository + Then repos-cov the update should succeed + And repos-cov the retrieved action arguments should have min_value and max_value + + # --------------------------------------------------------------------------- + # ActionRepository.delete – ActionInUseError when plans reference the action + # --------------------------------------------------------------------------- + + @action @delete @error_handling + Scenario: ActionRepository.delete raises ActionInUseError when plans reference the action + Given repos-cov an available action "local/in-use-action" exists + And a repos-cov lifecycle plan references action "local/in-use-action" + When repos-cov delete is called for action "local/in-use-action" + Then repos-cov an ActionInUseError should be raised for "local/in-use-action" + + # --------------------------------------------------------------------------- + # ActionRepository.delete – successful deletion when no plans reference it + # --------------------------------------------------------------------------- + + @action @delete + Scenario: ActionRepository.delete succeeds and returns True for unreferenced action + Given repos-cov an available action "local/deletable" exists + When repos-cov delete is called for action "local/deletable" + Then repos-cov the delete result should be True + And repos-cov get_by_id for "local/deletable" should return None + + # --------------------------------------------------------------------------- + # ActionRepository.delete – returns False for non-existent action + # --------------------------------------------------------------------------- + + @action @delete + Scenario: ActionRepository.delete returns False for non-existent action + When repos-cov delete is called for action "local/ghost" + Then repos-cov the delete result should be False + + # --------------------------------------------------------------------------- + # DuplicatePlanError instantiation + # --------------------------------------------------------------------------- + + @plan @error_class + Scenario: DuplicatePlanError stores plan_id and message + When repos-cov a DuplicatePlanError is created for plan_id "01HGZ6FE0A00000000TESTPLAN" + Then repos-cov the error message should contain "01HGZ6FE0A00000000TESTPLAN" + And repos-cov the error plan_id attribute should be "01HGZ6FE0A00000000TESTPLAN" + + # --------------------------------------------------------------------------- + # LifecyclePlanRepository.get_by_name – success path + # --------------------------------------------------------------------------- + + @plan + Scenario: LifecyclePlanRepository.get_by_name returns domain object for existing plan + Given repos-cov an available action "local/plan-target" exists + And a repos-cov lifecycle plan linked to "local/plan-target" with name "local/my-plan" has been persisted + When repos-cov plan get_by_name is called with "local/my-plan" + Then repos-cov the returned plan should not be None + + # --------------------------------------------------------------------------- + # LifecyclePlanRepository.get_by_name – returns None for missing + # --------------------------------------------------------------------------- + + @plan + Scenario: LifecyclePlanRepository.get_by_name returns None for non-existent plan + When repos-cov plan get_by_name is called with "local/no-such-plan" + Then repos-cov the returned plan should be None + + # --------------------------------------------------------------------------- + # LifecyclePlanRepository.get_by_name – DatabaseError on OperationalError + # --------------------------------------------------------------------------- + + @plan @error_handling + Scenario: LifecyclePlanRepository.get_by_name raises DatabaseError on OperationalError + Given a repos-cov plan repository with a session that raises OperationalError on query + When repos-cov plan get_by_name is called with "local/error-plan" expecting error + Then repos-cov a DatabaseError should be raised containing "Failed to get plan by name" + + # --------------------------------------------------------------------------- + # LifecyclePlanRepository.update – full update with project_links, arguments, invariants + # --------------------------------------------------------------------------- + + @plan @update + Scenario: LifecyclePlanRepository.update persists project links, arguments, and invariants + Given repos-cov an available action "local/plan-upd-action" exists + And a repos-cov lifecycle plan linked to "local/plan-upd-action" with name "local/upd-plan" has been persisted + And repos-cov the plan has project_links, arguments, and invariants with text attribute + When repos-cov the plan is updated via the repository + Then repos-cov the plan update should succeed + And repos-cov the retrieved plan should have project links + And repos-cov the retrieved plan should have arguments + And repos-cov the retrieved plan should have invariants + + # --------------------------------------------------------------------------- + # LifecyclePlanRepository.update – invariants as plain strings (no .text attr) + # --------------------------------------------------------------------------- + + @plan @update + Scenario: LifecyclePlanRepository.update handles invariants as plain strings + Given repos-cov an available action "local/plan-str-inv" exists + And a repos-cov lifecycle plan linked to "local/plan-str-inv" with name "local/str-inv-plan" has been persisted + And repos-cov the plan has invariants as plain strings without text attribute + When repos-cov the plan is updated via the repository + Then repos-cov the plan update should succeed + And repos-cov the retrieved plan should have invariants + + # --------------------------------------------------------------------------- + # LifecyclePlanRepository.update – invariant source with .value attribute + # --------------------------------------------------------------------------- + + @plan @update + Scenario: LifecyclePlanRepository.update handles invariant source with .value enum + Given repos-cov an available action "local/plan-enum-inv" exists + And a repos-cov lifecycle plan linked to "local/plan-enum-inv" with name "local/enum-inv-plan" has been persisted + And repos-cov the plan has invariants with InvariantSource enum values + When repos-cov the plan is updated via the repository + Then repos-cov the plan update should succeed + + # --------------------------------------------------------------------------- + # LifecyclePlanRepository.update – processing_state without .value (plain str) + # --------------------------------------------------------------------------- + + @plan @update + Scenario: LifecyclePlanRepository.update handles processing_state as plain string + Given repos-cov an available action "local/plan-str-state" exists + And a repos-cov lifecycle plan linked to "local/plan-str-state" with name "local/str-state-plan" has been persisted + And repos-cov the plan processing_state is set to a plain string "processing" + When repos-cov the plan is updated via the repository + Then repos-cov the plan update should succeed + + # --------------------------------------------------------------------------- + # LifecyclePlanRepository.update – error_details and changeset_id fields + # --------------------------------------------------------------------------- + + @plan @update + Scenario: LifecyclePlanRepository.update persists error_details and changeset_id + Given repos-cov an available action "local/plan-err-detail" exists + And a repos-cov lifecycle plan linked to "local/plan-err-detail" with name "local/err-detail-plan" has been persisted + And repos-cov the plan has error_message, error_details, and changeset_id set + When repos-cov the plan is updated via the repository + Then repos-cov the plan update should succeed + And repos-cov the retrieved plan should have the error_message set diff --git a/features/sandbox_copy_on_write_coverage.feature b/features/sandbox_copy_on_write_coverage.feature new file mode 100644 index 00000000..64843ee4 --- /dev/null +++ b/features/sandbox_copy_on_write_coverage.feature @@ -0,0 +1,146 @@ +Feature: CopyOnWriteSandbox uncovered lines and branches + As a developer + I want to exercise every uncovered code path in copy_on_write.py + So that line and branch coverage reaches 100% + + # ------------------------------------------------------------------ + # create() — OSError from shutil.copytree wraps into SandboxCreationError + # Covers lines 143-145 + # ------------------------------------------------------------------ + + Scenario: create wraps OSError from copytree into SandboxCreationError + Given a cowcov test directory is initialised + And shutil.copytree is patched to raise OSError in cowcov + When a cowcov sandbox create is attempted for plan "plan-oserr" + Then a cowcov SandboxCreationError should be raised + And the cowcov error message should contain "Failed to copy directory" + And the cowcov sandbox status should be "errored" + + # ------------------------------------------------------------------ + # get_path() — second call when already ACTIVE (line 196 False branch) + # Covers missing branch 196 → 199 + # ------------------------------------------------------------------ + + Scenario: get_path called twice stays in ACTIVE status + Given a cowcov test directory is initialised + And a cowcov sandbox is created for plan "plan-active" + When cowcov get_path is called with "file1.txt" + And cowcov get_path is called with "file2.txt" + Then the cowcov sandbox status should be "active" + And both cowcov resolved paths should be valid + + # ------------------------------------------------------------------ + # get_path() — _sandbox_path is None with ACTIVE status + # Covers line 200 and branch 196 False + 199 True + # ------------------------------------------------------------------ + + Scenario: get_path raises SandboxStateError when ACTIVE but sandbox_path is None + Given a cowcov sandbox forced to ACTIVE with sandbox_path None + When cowcov get_path is attempted with "file.txt" + Then a cowcov SandboxStateError should be raised with message "Sandbox path not set" + + # ------------------------------------------------------------------ + # get_path() — _sandbox_path is None with CREATED status + # Covers line 200 and branch 199 True + # ------------------------------------------------------------------ + + Scenario: get_path raises SandboxStateError when CREATED but sandbox_path is None + Given a cowcov sandbox forced to CREATED with sandbox_path None + When cowcov get_path is attempted with "data.csv" + Then a cowcov SandboxStateError should be raised with message "Sandbox path not set" + + # ------------------------------------------------------------------ + # commit() — _sandbox_path is None + # Covers line 230 + # ------------------------------------------------------------------ + + Scenario: commit raises SandboxStateError when sandbox_path is None + Given a cowcov sandbox forced to ACTIVE with sandbox_path None + When cowcov commit is attempted + Then a cowcov SandboxStateError should be raised with message "Sandbox path not set" + + # ------------------------------------------------------------------ + # commit() — dst_dir is empty (branch 242 False → 244) + # Covers missing branch at line 242 + # ------------------------------------------------------------------ + + Scenario: commit skips makedirs when dst_dir is empty string + Given a cowcov test directory is initialised + And a cowcov sandbox is created for plan "plan-dstdir" + And cowcov _compute_diff is patched to return a changed root file + And cowcov os.path.dirname is patched to return empty string + And cowcov shutil.copy2 is patched to no-op + When cowcov commit is attempted + Then the cowcov commit should succeed + And cowcov os.makedirs should not have been called + + # ------------------------------------------------------------------ + # commit() — deleted file not present in original (branch 249 False) + # Covers missing branch at line 249 → back to 247 + # ------------------------------------------------------------------ + + Scenario: commit handles deletion when file already missing from original + Given a cowcov test directory is initialised + And a cowcov sandbox is created and activated for plan "plan-delmiss" + When the cowcov file "existing.txt" is deleted from both sandbox and original + And cowcov commit is attempted + Then the cowcov commit should succeed + + # ------------------------------------------------------------------ + # commit() — OSError during sync wraps into SandboxCommitError + # Covers lines 252-254 + # ------------------------------------------------------------------ + + Scenario: commit wraps OSError during file sync into SandboxCommitError + Given a cowcov test directory is initialised + And a cowcov sandbox is created and activated for plan "plan-syncerr" + And a cowcov file "existing.txt" is modified in the sandbox + And cowcov shutil.copy2 is patched to raise OSError + When cowcov commit is attempted + Then a cowcov SandboxCommitError should be raised + And the cowcov sandbox status should be "errored" + + # ------------------------------------------------------------------ + # rollback() — _sandbox_path is None + # Covers line 297 + # ------------------------------------------------------------------ + + Scenario: rollback raises SandboxStateError when sandbox_path is None + Given a cowcov sandbox forced to ACTIVE with sandbox_path None + When cowcov rollback is attempted + Then a cowcov SandboxStateError should be raised with message "Sandbox path not set" + + # ------------------------------------------------------------------ + # rollback() — OSError during re-copy wraps into SandboxRollbackError + # Covers lines 311-313 + # ------------------------------------------------------------------ + + Scenario: rollback wraps OSError from copytree into SandboxRollbackError + Given a cowcov test directory is initialised + And a cowcov sandbox is created and activated for plan "plan-rbfail" + And cowcov shutil.copytree is patched to raise OSError for rollback + When cowcov rollback is attempted + Then a cowcov SandboxRollbackError should be raised + And the cowcov sandbox status should be "errored" + + # ------------------------------------------------------------------ + # cleanup() — _sandbox_path is None (branch 341 False → 347) + # Covers missing branch at line 341 + # ------------------------------------------------------------------ + + Scenario: cleanup succeeds when sandbox_path was never set + Given a cowcov sandbox in PENDING state with sandbox_path None + When cowcov cleanup is called + Then the cowcov sandbox status should be "cleaned_up" + + # ------------------------------------------------------------------ + # cleanup() — parent directory already gone (branch 344 False → 347) + # Covers missing branch at line 344 + # ------------------------------------------------------------------ + + Scenario: cleanup succeeds when parent directory is already removed + Given a cowcov test directory is initialised + And a cowcov sandbox is created for plan "plan-cleanmissing" + And the cowcov sandbox parent directory is manually removed + When cowcov cleanup is called + Then the cowcov sandbox status should be "cleaned_up" diff --git a/features/skill_cli_coverage_r3.feature b/features/skill_cli_coverage_r3.feature new file mode 100644 index 00000000..15a4c232 --- /dev/null +++ b/features/skill_cli_coverage_r3.feature @@ -0,0 +1,248 @@ +Feature: Skill CLI coverage round 3 + Cover remaining uncovered branches in src/cleveragents/cli/commands/skill.py: + tools --refresh flag, list/show non-rich capability error fallback, + refresh with agent_skills_paths discovery, refresh errors, long + description truncation, and the tools ValueError path. + + Background: + Given r3skill- a fresh skill CLI service + + # ------------------------------------------------------------------- + # tools --refresh flag with agent_skills_paths configured (lines 815-838) + # ------------------------------------------------------------------- + + Scenario: Tools command with --refresh and configured agent_skills paths + Given r3skill- a registered skill "local/refreshable" with tool_refs + And r3skill- agent_skills_paths config resolves to a valid path with discovered skills + When r3skill- I invoke tools "local/refreshable" with --refresh + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "Refreshed" + And r3skill- the output should contain "agent skill" + + Scenario: Tools command with --refresh and empty agent_skills_paths + Given r3skill- a registered skill "local/no-agent-paths" with tool_refs + And r3skill- agent_skills_paths config resolves to empty string + When r3skill- I invoke tools "local/no-agent-paths" with --refresh + Then r3skill- the CLI exit code should be 0 + + Scenario: Tools command with --refresh and discovery errors + Given r3skill- a registered skill "local/disc-errors" with tool_refs + And r3skill- agent_skills_paths config resolves with discovery errors + When r3skill- I invoke tools "local/disc-errors" with --refresh + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "Warning" + + # ------------------------------------------------------------------- + # tools ValueError path (lines 887-889) + # ------------------------------------------------------------------- + + Scenario: Tools command when resolve_tools raises ValueError + Given r3skill- a registered skill "local/val-err-tools" with tool_refs + And r3skill- resolve_tools will raise ValueError for "local/val-err-tools" + When r3skill- I invoke tools "local/val-err-tools" in rich format + Then r3skill- the CLI exit code should not be 0 + And r3skill- the output should contain "Resolution error" + + # ------------------------------------------------------------------- + # list non-rich format: capability summary ValueError fallback (lines 667-669) + # ------------------------------------------------------------------- + + Scenario: List with JSON format falls back when capability summary raises ValueError + Given r3skill- a registered skill "local/cap-fallback" with tool_refs + And r3skill- compute_capability_summary will raise ValueError + When r3skill- I invoke list in format "json" + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should be valid JSON + And r3skill- the JSON list first item should have key "tool_count" + And r3skill- the JSON list first item "capability_summary" should be null + + # ------------------------------------------------------------------- + # show non-rich format: capability summary ValueError fallback (lines 764-766) + # ------------------------------------------------------------------- + + Scenario: Show with JSON format falls back when capability summary raises ValueError + Given r3skill- a registered skill "local/show-cap-err" with tool_refs + And r3skill- compute_capability_summary will raise ValueError + When r3skill- I invoke show "local/show-cap-err" in format "json" + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should be valid JSON + And r3skill- the JSON output key "capability_summary" should be null + + # ------------------------------------------------------------------- + # list rich format: long description truncation (lines 696-700) + # ------------------------------------------------------------------- + + Scenario: List in rich format truncates long descriptions + Given r3skill- a registered skill "local/long-desc" with a description longer than 50 chars + When r3skill- I invoke list in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the long description in the output should be truncated + + # ------------------------------------------------------------------- + # refresh: agent_skills_paths discovery (lines 953-980) + # ------------------------------------------------------------------- + + Scenario: Refresh single skill with agent_skills_paths discovery + Given r3skill- a registered skill "local/refresh-agent" with tool_refs + And r3skill- agent_skills_paths config resolves to a valid path with discovered skills + When r3skill- I invoke refresh "local/refresh-agent" in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "Agent Skills" + And r3skill- the output should contain "Discovered" + + Scenario: Refresh all skills with agent_skills_paths and discovery errors + Given r3skill- a registered skill "local/refresh-err" with tool_refs + And r3skill- agent_skills_paths config resolves with discovery errors + When r3skill- I invoke refresh --all in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "Warning" + + # ------------------------------------------------------------------- + # refresh: single skill resolve_tools ValueError (lines 1017-1026, 1044-1046) + # ------------------------------------------------------------------- + + Scenario: Refresh single skill with ValueError during resolve_tools + Given r3skill- a registered skill "local/refresh-fail" with tool_refs + And r3skill- resolve_tools will raise ValueError for "local/refresh-fail" + When r3skill- I invoke refresh "local/refresh-fail" in rich format + Then r3skill- the CLI exit code should not be 0 + And r3skill- the output should contain "Refresh failed" + + # ------------------------------------------------------------------- + # refresh: all skills with one skill ValueError (lines 1017-1026 in loop) + # ------------------------------------------------------------------- + + Scenario: Refresh all skills where one skill has resolve_tools ValueError + Given r3skill- a registered skill "local/good-skill" with tool_refs + And r3skill- a registered skill "local/bad-skill" with tool_refs + And r3skill- resolve_tools will raise ValueError for "local/bad-skill" + When r3skill- I invoke refresh --all in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "Errors" + + # ------------------------------------------------------------------- + # refresh: non-rich format with mcp_errors (lines 1035-1036) + # ------------------------------------------------------------------- + + Scenario: Refresh all in JSON format with mcp errors + Given r3skill- a registered skill "local/ok-skill" with tool_refs + And r3skill- a registered skill "local/err-skill" with tool_refs + And r3skill- resolve_tools will raise ValueError for "local/err-skill" + When r3skill- I invoke refresh --all in format "json" + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should be valid JSON + And r3skill- the JSON output should have key "errors" + + # ------------------------------------------------------------------- + # refresh: general Exception catch (lines 1106-1108) + # ------------------------------------------------------------------- + + Scenario: Refresh raises unexpected Exception + Given r3skill- a registered skill "local/exc-skill" with tool_refs + And r3skill- list_skills will raise RuntimeError + When r3skill- I invoke refresh --all in rich format + Then r3skill- the CLI exit code should not be 0 + And r3skill- the output should contain "Refresh error" + + # ------------------------------------------------------------------- + # refresh: agent_skills_paths empty path (lines 961-962 false branch) + # ------------------------------------------------------------------- + + Scenario: Refresh with empty agent_skills_paths skips discovery + Given r3skill- a registered skill "local/no-discovery" with tool_refs + And r3skill- agent_skills_paths config resolves to empty string + When r3skill- I invoke refresh "local/no-discovery" in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should not contain "Discovered" + + # ------------------------------------------------------------------- + # refresh: non-rich single skill (lines 1029-1037) + # ------------------------------------------------------------------- + + Scenario: Refresh single skill with JSON format returns structured output + Given r3skill- a registered skill "local/json-refresh" with tool_refs + When r3skill- I invoke refresh "local/json-refresh" in format "json" + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should be valid JSON + And r3skill- the JSON output should have key "refreshed" + + # ------------------------------------------------------------------- + # _print_skill_detail: both timestamps None (lines 253-254) + # ------------------------------------------------------------------- + + Scenario: Show skill in rich format with no timestamps shows unknown + Given r3skill- a registered skill "local/no-ts" with tool_refs + And r3skill- timestamps for "local/no-ts" are removed + When r3skill- I invoke show "local/no-ts" in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "unknown" + + # ------------------------------------------------------------------- + # _print_skill_registered: include present AND registered (line 159 true) + # ------------------------------------------------------------------- + + Scenario: Add skill with includes where included skill IS registered + Given r3skill- a registered skill "local/base-inc" with tool_refs + And r3skill- a temp YAML for "local/parent-inc" that includes "local/base-inc" + When r3skill- I invoke add with the temp config in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "registered" + And r3skill- the output should contain "Includes" + + # ------------------------------------------------------------------- + # _print_skill_update_changes: old_skill with includes/mcp changed + # ------------------------------------------------------------------- + + Scenario: Update skill changes includes and MCP servers shows diff + Given r3skill- a temp YAML for "local/upd-diff" with tool_refs and MCP + And r3skill- the skill "local/upd-diff" is pre-registered via add + And r3skill- a second temp YAML for "local/upd-diff" with different includes and MCP + When r3skill- I invoke add with the second config and --update in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "Changes" + And r3skill- the output should contain "Includes Changed" + And r3skill- the output should contain "MCP Servers Changed" + + # ------------------------------------------------------------------- + # _print_skill_tools: inline tool with writes and checkpointable + # ------------------------------------------------------------------- + + Scenario: Tools command with inline tool having writes and checkpointable + Given r3skill- a registered skill "local/inline-caps" with inline tools having capabilities + When r3skill- I invoke tools "local/inline-caps" in rich format + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "custom" + + # ------------------------------------------------------------------- + # remove: confirm prompt with yes (line 553 true branch via input) + # ------------------------------------------------------------------- + + Scenario: Remove skill without --yes and confirm with y + Given r3skill- a registered skill "local/confirm-rm" with tool_refs + When r3skill- I invoke remove "local/confirm-rm" without --yes and confirm + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should contain "Skill Removed" + + # ------------------------------------------------------------------- + # list non-rich: capability summary KeyError fallback + # ------------------------------------------------------------------- + + Scenario: List with JSON format falls back when capability summary raises KeyError + Given r3skill- a registered skill "local/key-err-list" with tool_refs + And r3skill- compute_capability_summary will raise KeyError + When r3skill- I invoke list in format "json" + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should be valid JSON + And r3skill- the JSON list first item "capability_summary" should be null + + # ------------------------------------------------------------------- + # show non-rich: capability summary KeyError fallback + # ------------------------------------------------------------------- + + Scenario: Show with JSON format falls back when capability summary raises KeyError + Given r3skill- a registered skill "local/key-err-show" with tool_refs + And r3skill- compute_capability_summary will raise KeyError + When r3skill- I invoke show "local/key-err-show" in format "json" + Then r3skill- the CLI exit code should be 0 + And r3skill- the output should be valid JSON + And r3skill- the JSON output key "capability_summary" should be null diff --git a/features/steps/bridge_coverage_steps.py b/features/steps/bridge_coverage_steps.py new file mode 100644 index 00000000..c32ec733 --- /dev/null +++ b/features/steps/bridge_coverage_steps.py @@ -0,0 +1,355 @@ +"""Steps targeting uncovered lines and branches in bridge.py.""" + +from __future__ import annotations + +import asyncio +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.langgraph.bridge import RxPyLangGraphBridge +from cleveragents.langgraph.state import GraphState +from cleveragents.reactive.stream_router import ( + ReactiveStreamRouter, + StreamMessage, +) + +# ------ helpers ------ + + +def _make_bridge() -> tuple[RxPyLangGraphBridge, MagicMock]: + """Create a bridge backed by a mocked stream router.""" + scheduler = MagicMock() + router = ReactiveStreamRouter(scheduler=scheduler) + router.agents = {"agent": MagicMock()} + bridge = RxPyLangGraphBridge(router) + return bridge, scheduler + + +def _make_mock_graph( + name: str, + *, + messages: list[dict[str, Any]] | None = None, + state_dict: dict[str, Any] | None = None, +) -> MagicMock: + """Build a mock LangGraph with a controllable execute coroutine.""" + graph = MagicMock() + graph.name = name + + gs = MagicMock(spec=GraphState) + gs.messages = messages if messages is not None else [] + gs.to_dict = MagicMock(return_value=state_dict or {"messages": gs.messages}) + + graph.execute = AsyncMock(return_value=gs) + graph.get_execution_history = MagicMock(return_value=["step1"]) + graph.state_manager = MagicMock() + graph.state_manager.checkpoint_dir = None + graph.nodes = {} + return graph + + +# ------ Scenario: __del__ suppresses AttributeError ------ + + +@given("a bridge with its cleanup_tasks method removed") +def step_bridge_without_cleanup(context: Context) -> None: + """Create a bridge then sabotage it so __del__ hits AttributeError.""" + bridge, _ = _make_bridge() + # Delete _active_tasks so cleanup_tasks() raises AttributeError internally; + # __del__ wraps cleanup_tasks in contextlib.suppress(AttributeError). + del bridge._active_tasks + context.bridge = bridge + + +@when("__del__ is invoked on the crippled bridge") +def step_invoke_del(context: Context) -> None: + """Call __del__ explicitly; the suppress(AttributeError) path should fire.""" + context.del_error = None + try: + context.bridge.__del__() + except Exception as exc: + context.del_error = exc + + +@then("no exception should propagate from __del__") +def step_no_del_exception(context: Context) -> None: + """Verify __del__ swallowed the AttributeError.""" + assert context.del_error is None, ( + f"Expected no error from __del__, got {context.del_error!r}" + ) + + +# ------ Scenario: cleanup_tasks_async cancels running tasks ------ + + +@given("a bridge with a long-running async task") +def step_bridge_with_long_task(context: Context) -> None: + """Create a bridge and schedule an async task that sleeps for a long time.""" + bridge, _ = _make_bridge() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + async def _long_sleep() -> None: + await asyncio.sleep(999) + + task = loop.create_task(_long_sleep()) + bridge._active_tasks.add(task) + context.bridge = bridge + context.task = task + context.loop = loop + + +@when("I run cleanup_tasks_async on the bridge") +def step_run_cleanup_async(context: Context) -> None: + """Await cleanup_tasks_async so it cancels the not-done task.""" + context.loop.run_until_complete(context.bridge.cleanup_tasks_async(timeout=0.1)) + + +@then("the running task should be cancelled") +def step_assert_task_cancelled(context: Context) -> None: + """Confirm the long-running task was cancelled.""" + assert context.task.cancelled() or context.task.done(), ( + f"Expected task to be cancelled or done, state={context.task._state}" + ) + context.loop.close() + asyncio.set_event_loop(asyncio.new_event_loop()) + + +@then("the bridge active tasks set should be empty") +def step_assert_active_tasks_empty(context: Context) -> None: + """Confirm _active_tasks was cleared.""" + assert len(context.bridge._active_tasks) == 0, ( + f"Expected empty _active_tasks, got {len(context.bridge._active_tasks)}" + ) + + +# ------ Scenario: Graph executor with string content (lines 201-204) ------ + + +@given("a bridge with a mock graph that returns messages") +def step_bridge_with_graph_messages(context: Context) -> None: + """Set up a bridge with a mock graph whose execute returns messages.""" + bridge, _ = _make_bridge() + mock_graph = _make_mock_graph( + "test_exec", + messages=[{"content": "response-ok"}], + state_dict={"messages": [{"content": "response-ok"}]}, + ) + bridge.graphs["test_exec"] = mock_graph + context.bridge = bridge + context.mock_graph = mock_graph + + +@when("I run the graph executor with a string content message") +def step_exec_string_message(context: Context) -> None: + """Push a string StreamMessage through the graph executor and await it.""" + bridge = context.bridge + executor_op = bridge._create_graph_executor({"graph": "test_exec"}) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + results: list[Any] = [] + errors: list[Any] = [] + msg = StreamMessage(content="hello world", metadata={"origin": "test"}) + + import rx # type: ignore + + rx.just(msg).pipe(executor_op).subscribe( + on_next=lambda x: results.append(x), + on_error=lambda e: errors.append(e), + ) + + # Drain event loop so the scheduled coroutine completes + pending = list(bridge._active_tasks) + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + else: + loop.run_until_complete(asyncio.sleep(0.05)) + + context.exec_results = results + context.exec_errors = errors + context.loop = loop + + +@then("the executor result should contain the graph output") +def step_assert_executor_result(context: Context) -> None: + """Verify the executor produced a StreamMessage with expected content.""" + assert len(context.exec_errors) == 0, ( + f"Executor raised errors: {context.exec_errors}" + ) + assert len(context.exec_results) > 0, "Executor produced no results" + result_msg = context.exec_results[0] + assert isinstance(result_msg, StreamMessage), ( + f"Expected StreamMessage, got {type(result_msg).__name__}" + ) + if hasattr(context, "loop") and not context.loop.is_closed(): + context.loop.close() + asyncio.set_event_loop(asyncio.new_event_loop()) + + +@then("the result metadata should include graph execution history") +def step_assert_metadata_history(context: Context) -> None: + """Check that the result metadata carries execution_history.""" + result_msg = context.exec_results[0] + assert "execution_history" in result_msg.metadata, ( + f"Missing 'execution_history' in metadata keys: {list(result_msg.metadata)}" + ) + assert "graph" in result_msg.metadata, ( + f"Missing 'graph' in metadata keys: {list(result_msg.metadata)}" + ) + + +@when("I run the graph executor with a dict content message") +def step_exec_dict_message(context: Context) -> None: + """Push a dict StreamMessage through the graph executor and await it.""" + bridge = context.bridge + executor_op = bridge._create_graph_executor({"graph": "test_exec"}) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + results: list[Any] = [] + errors: list[Any] = [] + msg = StreamMessage( + content={"messages": [{"role": "user", "content": "hi"}]}, + metadata={}, + ) + + import rx # type: ignore + + rx.just(msg).pipe(executor_op).subscribe( + on_next=lambda x: results.append(x), + on_error=lambda e: errors.append(e), + ) + + pending = list(bridge._active_tasks) + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + else: + loop.run_until_complete(asyncio.sleep(0.05)) + + context.exec_results = results + context.exec_errors = errors + context.loop = loop + + +@when("I run the graph executor with a numeric content message") +def step_exec_numeric_message(context: Context) -> None: + """Push a non-str/non-dict StreamMessage through the executor.""" + bridge = context.bridge + executor_op = bridge._create_graph_executor({"graph": "test_exec"}) + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + results: list[Any] = [] + errors: list[Any] = [] + msg = StreamMessage(content=42, metadata={}) + + import rx # type: ignore + + rx.just(msg).pipe(executor_op).subscribe( + on_next=lambda x: results.append(x), + on_error=lambda e: errors.append(e), + ) + + pending = list(bridge._active_tasks) + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + else: + loop.run_until_complete(asyncio.sleep(0.05)) + + context.exec_results = results + context.exec_errors = errors + context.loop = loop + + +# ------ Scenario: Graph executor with empty messages ------ + + +@given("a bridge with a mock graph that returns empty messages") +def step_bridge_with_graph_empty_messages(context: Context) -> None: + """Set up a bridge with a mock graph whose execute returns no messages.""" + bridge, _ = _make_bridge() + mock_graph = _make_mock_graph( + "test_exec", + messages=[], + state_dict={"data": "fallback-state"}, + ) + bridge.graphs["test_exec"] = mock_graph + context.bridge = bridge + context.mock_graph = mock_graph + + +@then("the executor result should be the full state dict") +def step_assert_state_dict_fallback(context: Context) -> None: + """Verify the executor returns the full state dict when messages are empty.""" + assert len(context.exec_errors) == 0, ( + f"Executor raised errors: {context.exec_errors}" + ) + assert len(context.exec_results) > 0, "Executor produced no results" + result_msg = context.exec_results[0] + assert isinstance(result_msg, StreamMessage), ( + f"Expected StreamMessage, got {type(result_msg).__name__}" + ) + # Content should be the full state dict since messages was empty + assert result_msg.content == {"data": "fallback-state"}, ( + f"Expected fallback state dict, got {result_msg.content!r}" + ) + if hasattr(context, "loop") and not context.loop.is_closed(): + context.loop.close() + asyncio.set_event_loop(asyncio.new_event_loop()) + + +# ------ Scenario: create_graph_stream with known graph (branch 182→184) ------ + + +@given('a bridge that owns a registered graph named "{name}"') +def step_bridge_with_named_graph(context: Context, name: str) -> None: + """Create a bridge and insert a mock graph under the given name.""" + bridge, _ = _make_bridge() + mock_graph = _make_mock_graph(name) + bridge.graphs[name] = mock_graph + context.bridge = bridge + + +@when('I call create_graph_stream with "{name}"') +def step_call_create_graph_stream(context: Context, name: str) -> None: + """Invoke create_graph_stream for the given graph name.""" + context.stream_config = context.bridge.create_graph_stream(name) + + +@then('the returned StreamConfig name should be "{expected}"') +def step_assert_stream_config_name(context: Context, expected: str) -> None: + """Verify the StreamConfig has the expected name.""" + assert context.stream_config.name == expected, ( + f"Expected name '{expected}', got '{context.stream_config.name}'" + ) + + +# ------ Scenario: _create_state_checkpointer valid path (branch 260→258) ------ + + +@when('I build a state checkpointer for "{name}"') +def step_build_checkpointer(context: Context, name: str) -> None: + """Call _create_state_checkpointer with a valid graph name.""" + context.checkpointer_error = None + try: + context.checkpointer_op = context.bridge._create_state_checkpointer( + {"graph": name} + ) + except Exception as exc: + context.checkpointer_error = exc + context.checkpointer_op = None + + +@then("the checkpointer operator should be returned without error") +def step_assert_checkpointer_ok(context: Context) -> None: + """Verify the checkpointer operator was created successfully.""" + assert context.checkpointer_error is None, ( + f"Expected no error, got {context.checkpointer_error!r}" + ) + assert context.checkpointer_op is not None, "Expected a valid operator, got None" diff --git a/features/steps/changeset_repository_coverage_steps.py b/features/steps/changeset_repository_coverage_steps.py new file mode 100644 index 00000000..4abdeec8 --- /dev/null +++ b/features/steps/changeset_repository_coverage_steps.py @@ -0,0 +1,1055 @@ +"""Step definitions for changeset repository coverage feature tests.""" + +from __future__ import annotations + +import json +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.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[sessionmaker[Session], Any]: + """Create an in-memory SQLite engine and session factory.""" + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(engine) + factory: sessionmaker[Session] = sessionmaker(bind=engine) + return factory, 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, engine = _make_session_factory() + context.cov_engine = engine + context.cov_entry_repo = ChangeSetEntryRepository(factory) + + +@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, engine = _make_session_factory() + context.cov_engine = engine + context.cov_inv_repo = ToolInvocationRepository(factory) + + +@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, engine = _make_session_factory() + context.cov_engine = engine + context.cov_session_factory = factory + context.cov_inv_repo = ToolInvocationRepository(factory) + + +@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, engine = _make_session_factory() + context.cov_engine = engine + context.cov_session_factory = factory + context.cov_store = SqliteChangeSetStore(factory) + + +@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}'" + ) diff --git a/features/steps/lock_service_coverage_steps.py b/features/steps/lock_service_coverage_steps.py new file mode 100644 index 00000000..4a154180 --- /dev/null +++ b/features/steps/lock_service_coverage_steps.py @@ -0,0 +1,569 @@ +"""Step definitions for lock_service_coverage.feature - uncovered branches.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any + +from behave import given, then, when +from behave.runner import Context +from sqlalchemy import create_engine, event +from sqlalchemy.orm import Session, sessionmaker + +from cleveragents.application.services.lock_service import LockService +from cleveragents.core.exceptions import ( + LockConflictError, + LockExpiredError, + ValidationError, +) +from cleveragents.infrastructure.database.models import Base, LockModel + +# ------------------------------------------------------------------ +# Helper: build a real in-memory LockService +# ------------------------------------------------------------------ + + +def _make_lock_service(context: Context) -> LockService: + """Create a LockService backed by an in-memory SQLite database.""" + engine = create_engine( + "sqlite:///:memory:", + echo=False, + future=True, + connect_args={"check_same_thread": False}, + ) + + @event.listens_for(engine, "connect") + def _fk(dbapi_conn: Any, _rec: Any) -> None: + cursor = dbapi_conn.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + Base.metadata.create_all(engine) + factory: sessionmaker[Session] = sessionmaker( + bind=engine, + expire_on_commit=False, + autoflush=False, + autocommit=False, + class_=Session, + ) + context.cov_engine = engine + context.cov_session_factory = factory + return LockService(session_factory=factory) + + +# ------------------------------------------------------------------ +# Stub session that blows up on execute (for rollback tests) +# ------------------------------------------------------------------ + + +class _StubResult: + """Minimal stand-in for a SQLAlchemy result object.""" + + rowcount: int = 0 + + def scalar_one_or_none(self) -> None: + """Return None.""" + return None + + def scalar_one(self) -> int: + """Return 0.""" + return 0 + + +class _ExplodingSession: + """Session stub that raises RuntimeError on execute.""" + + def __init__(self) -> None: + """Initialise tracking flags.""" + self.rolled_back: bool = False + self.committed: bool = False + + def execute(self, stmt: Any) -> Any: + """Raise RuntimeError unconditionally.""" + raise RuntimeError("boom") + + def add(self, obj: Any) -> None: + """No-op.""" + + def commit(self) -> None: + """Record commit.""" + self.committed = True + + def rollback(self) -> None: + """Record rollback.""" + self.rolled_back = True + + +# ------------------------------------------------------------------ +# Background +# ------------------------------------------------------------------ + + +@given("I have a fresh lock service with in-memory database") +def step_fresh_lock_service(context: Context) -> None: + """Create a fresh in-memory LockService for coverage scenarios.""" + context.cov_svc = _make_lock_service(context) + context.cov_error = None + context.cov_result = None + context.cov_session = None + + +# ------------------------------------------------------------------ +# Validation: release +# ------------------------------------------------------------------ + + +@when( + 'I try to release with resource_type "{rtype}" owner "{owner}" resource_id "{rid}"' +) +def step_try_release_validation( + context: Context, rtype: str, owner: str, rid: str +) -> None: + """Attempt release with potentially invalid params.""" + try: + context.cov_result = context.cov_svc.release( + owner_id=owner, + resource_type=rtype, + resource_id=rid, + ) + except ValidationError as exc: + context.cov_error = exc + + +@when("I try to release with an empty resource_type") +def step_try_release_empty_rtype(context: Context) -> None: + """Attempt release with empty resource_type.""" + try: + context.cov_svc.release(owner_id="o", resource_type="", resource_id="r1") + except ValidationError as exc: + context.cov_error = exc + + +@when("I try to release with an empty resource_id") +def step_try_release_empty_rid(context: Context) -> None: + """Attempt release with empty resource_id.""" + try: + context.cov_svc.release(owner_id="o", resource_type="plan", resource_id="") + except ValidationError as exc: + context.cov_error = exc + + +# ------------------------------------------------------------------ +# Validation: renew +# ------------------------------------------------------------------ + + +@when('I try to renew with resource_type "{rtype}" owner "{owner}" resource_id "{rid}"') +def step_try_renew_validation( + context: Context, rtype: str, owner: str, rid: str +) -> None: + """Attempt renew with potentially invalid params.""" + try: + context.cov_svc.renew( + owner_id=owner, + resource_type=rtype, + resource_id=rid, + ) + except ValidationError as exc: + context.cov_error = exc + + +@when( + 'I try to renew with TTL {ttl:d} owner "{owner}" resource_type "{rtype}" resource_id "{rid}"' +) +def step_try_renew_bad_ttl( + context: Context, ttl: int, owner: str, rtype: str, rid: str +) -> None: + """Attempt renew with out-of-range TTL.""" + try: + context.cov_svc.renew( + owner_id=owner, + resource_type=rtype, + resource_id=rid, + ttl_seconds=ttl, + ) + except ValidationError as exc: + context.cov_error = exc + + +@when("I try to renew with an empty resource_type") +def step_try_renew_empty_rtype(context: Context) -> None: + """Attempt renew with empty resource_type.""" + try: + context.cov_svc.renew(owner_id="o", resource_type="", resource_id="r1") + except ValidationError as exc: + context.cov_error = exc + + +@when("I try to renew with an empty resource_id") +def step_try_renew_empty_rid(context: Context) -> None: + """Attempt renew with empty resource_id.""" + try: + context.cov_svc.renew(owner_id="o", resource_type="plan", resource_id="") + except ValidationError as exc: + context.cov_error = exc + + +# ------------------------------------------------------------------ +# Validation: is_locked +# ------------------------------------------------------------------ + + +@when('I try to check is_locked with resource_type "{rtype}" resource_id "{rid}"') +def step_try_is_locked_validation(context: Context, rtype: str, rid: str) -> None: + """Attempt is_locked with potentially invalid params.""" + try: + context.cov_result = context.cov_svc.is_locked( + resource_type=rtype, + resource_id=rid, + ) + except ValidationError as exc: + context.cov_error = exc + + +@when("I try to check is_locked with an empty resource_type") +def step_try_is_locked_empty_rtype(context: Context) -> None: + """Attempt is_locked with empty resource_type.""" + try: + context.cov_svc.is_locked(resource_type="", resource_id="r1") + except ValidationError as exc: + context.cov_error = exc + + +@when("I try to check is_locked with an empty resource_id") +def step_try_is_locked_empty_rid(context: Context) -> None: + """Attempt is_locked with empty resource_id.""" + try: + context.cov_svc.is_locked(resource_type="plan", resource_id="") + except ValidationError as exc: + context.cov_error = exc + + +# ------------------------------------------------------------------ +# Validation assertion +# ------------------------------------------------------------------ + + +@then( + 'a lock_service validation error should be raised with message containing "{fragment}"' +) +def step_assert_validation_error_message(context: Context, fragment: str) -> None: + """Assert a ValidationError was raised whose message contains fragment.""" + assert isinstance(context.cov_error, ValidationError), ( + f"Expected ValidationError, got {type(context.cov_error).__name__}: {context.cov_error}" + ) + assert fragment in str(context.cov_error), ( + f"Expected '{fragment}' in '{context.cov_error}'" + ) + + +# ------------------------------------------------------------------ +# TTL boundary / custom TTL acquire +# ------------------------------------------------------------------ + + +@when( + 'I acquire a lock with TTL {ttl:d} owner "{owner}" resource_type "{rtype}" resource_id "{rid}"' +) +def step_acquire_with_ttl( + context: Context, ttl: int, owner: str, rtype: str, rid: str +) -> None: + """Acquire a lock with an explicit TTL.""" + context.cov_result = context.cov_svc.acquire( + owner_id=owner, + resource_type=rtype, + resource_id=rid, + ttl_seconds=ttl, + ) + + +@then("the lock_service result should be true") +def step_result_true(context: Context) -> None: + """Assert the result is True.""" + assert context.cov_result is True, f"Expected True, got {context.cov_result!r}" + + +@then("the lock_service result should be false") +def step_result_false(context: Context) -> None: + """Assert the result is False.""" + assert context.cov_result is False, f"Expected False, got {context.cov_result!r}" + + +# ------------------------------------------------------------------ +# Re-entrant project lock +# ------------------------------------------------------------------ + + +@given('owner "{owner}" already holds a project lock on "{rid}"') +def step_owner_holds_project_lock(context: Context, owner: str, rid: str) -> None: + """Pre-acquire a project lock.""" + context.cov_svc.acquire( + owner_id=owner, + resource_type="project", + resource_id=rid, + ) + + +@given('owner "{owner}" already holds a plan lock on "{rid}"') +def step_owner_holds_plan_lock(context: Context, owner: str, rid: str) -> None: + """Pre-acquire a plan lock.""" + context.cov_svc.acquire( + owner_id=owner, + resource_type="plan", + resource_id=rid, + ) + + +# ------------------------------------------------------------------ +# is_locked on expired lock +# ------------------------------------------------------------------ + + +@given('an expired lock exists for owner "{owner}" on plan "{plan_id}"') +def step_insert_expired_lock(context: Context, owner: str, plan_id: str) -> None: + """Insert an already-expired lock directly into the database.""" + now = datetime.now(tz=UTC) + expired_at = (now - timedelta(seconds=60)).isoformat() + acquired_at = (now - timedelta(seconds=120)).isoformat() + + session: Session = context.cov_session_factory() + lock = LockModel( + owner_id=owner, + resource_type="plan", + resource_id=plan_id, + acquired_at=acquired_at, + expires_at=expired_at, + ) + session.add(lock) + session.commit() + session.close() + + +@when('I check is_locked for resource_type "{rtype}" resource_id "{rid}"') +def step_check_is_locked_cov(context: Context, rtype: str, rid: str) -> None: + """Check is_locked on a resource.""" + context.cov_result = context.cov_svc.is_locked( + resource_type=rtype, + resource_id=rid, + ) + + +# ------------------------------------------------------------------ +# Zero-count paths +# ------------------------------------------------------------------ + + +@when('I release all locks for owner_id "{owner}"') +def step_release_all_cov(context: Context, owner: str) -> None: + """Release all locks for an owner via the coverage service.""" + context.cov_result = context.cov_svc.release_all_for_owner(owner_id=owner) + + +@when("I run cleanup of expired locks") +def step_cleanup_expired_cov(context: Context) -> None: + """Run cleanup_expired via the coverage service.""" + context.cov_result = context.cov_svc.cleanup_expired() + + +@when("I count stale locks via the service") +def step_count_stale_cov(context: Context) -> None: + """Count stale locks via the coverage service.""" + context.cov_result = context.cov_svc.count_stale_locks() + + +@then("the lock_service integer result should be {expected:d}") +def step_assert_int_result(context: Context, expected: int) -> None: + """Assert the result equals an expected integer.""" + assert context.cov_result == expected, ( + f"Expected {expected}, got {context.cov_result!r}" + ) + + +# ------------------------------------------------------------------ +# Exception rollback paths (mocked session) +# ------------------------------------------------------------------ + + +@given("a lock service whose session raises an unexpected error on execute") +def step_exploding_session_service(context: Context) -> None: + """Build a LockService whose session factory returns an exploding session.""" + exploding = _ExplodingSession() + context.cov_session = exploding + context.cov_svc = LockService(session_factory=lambda: exploding) # type: ignore[arg-type] + context.cov_error = None + context.cov_result = None + + +@when("I try to acquire and expect a RuntimeError") +def step_try_acquire_runtime_error(context: Context) -> None: + """Attempt acquire expecting a RuntimeError from the stub session.""" + try: + context.cov_svc.acquire( + owner_id="owner-x", + resource_type="plan", + resource_id="res-x", + ) + except RuntimeError as exc: + context.cov_error = exc + + +@when("I try to release and expect a RuntimeError") +def step_try_release_runtime_error(context: Context) -> None: + """Attempt release expecting a RuntimeError from the stub session.""" + try: + context.cov_svc.release( + owner_id="owner-x", + resource_type="plan", + resource_id="res-x", + ) + except RuntimeError as exc: + context.cov_error = exc + + +@when("I try to renew and expect a RuntimeError") +def step_try_renew_runtime_error(context: Context) -> None: + """Attempt renew expecting a RuntimeError from the stub session.""" + try: + context.cov_svc.renew( + owner_id="owner-x", + resource_type="plan", + resource_id="res-x", + ) + except RuntimeError as exc: + context.cov_error = exc + + +@when("I try to release_all_for_owner and expect a RuntimeError") +def step_try_release_all_runtime_error(context: Context) -> None: + """Attempt release_all_for_owner expecting RuntimeError.""" + try: + context.cov_svc.release_all_for_owner(owner_id="owner-x") + except RuntimeError as exc: + context.cov_error = exc + + +@when("I try to cleanup_expired and expect a RuntimeError") +def step_try_cleanup_runtime_error(context: Context) -> None: + """Attempt cleanup_expired expecting RuntimeError.""" + try: + context.cov_svc.cleanup_expired() + except RuntimeError as exc: + context.cov_error = exc + + +@then("a RuntimeError should have been raised") +def step_assert_runtime_error(context: Context) -> None: + """Assert that a RuntimeError was captured.""" + assert isinstance(context.cov_error, RuntimeError), ( + f"Expected RuntimeError, got {type(context.cov_error).__name__}: {context.cov_error}" + ) + + +@then("the session should have been rolled back") +def step_assert_session_rolled_back(context: Context) -> None: + """Assert the stub session's rollback method was called.""" + assert context.cov_session is not None, "No stub session on context" + assert context.cov_session.rolled_back is True, ( + "Expected session.rollback() to have been called" + ) + + +# ------------------------------------------------------------------ +# Acquire conflict rollback path (real DB) +# ------------------------------------------------------------------ + + +@given('a lock service with a real database and owner "{owner}" holds plan "{plan_id}"') +def step_real_db_with_holder(context: Context, owner: str, plan_id: str) -> None: + """Build a real-DB lock service and pre-acquire a lock.""" + context.cov_svc = _make_lock_service(context) + context.cov_error = None + context.cov_result = None + context.cov_svc.acquire( + owner_id=owner, + resource_type="plan", + resource_id=plan_id, + ) + + +@when('I try to acquire plan "{plan_id}" as "{owner}" and capture the conflict error') +def step_try_acquire_conflict(context: Context, plan_id: str, owner: str) -> None: + """Attempt acquire expecting a LockConflictError.""" + try: + context.cov_svc.acquire( + owner_id=owner, + resource_type="plan", + resource_id=plan_id, + ) + except LockConflictError as exc: + context.cov_error = exc + + +@then("a LockConflictError should have been raised") +def step_assert_lock_conflict(context: Context) -> None: + """Assert that a LockConflictError was captured.""" + assert isinstance(context.cov_error, LockConflictError), ( + f"Expected LockConflictError, got {type(context.cov_error).__name__}" + ) + + +@then('the conflict owner should be "{expected}"') +def step_assert_conflict_owner(context: Context, expected: str) -> None: + """Assert the conflict error reports the expected owner.""" + err = context.cov_error + assert isinstance(err, LockConflictError), "Not a LockConflictError" + assert err.owner_id == expected, ( + f"Expected conflict owner '{expected}', got '{err.owner_id}'" + ) + + +# ------------------------------------------------------------------ +# Renew LockExpiredError rollback path (real DB) +# ------------------------------------------------------------------ + + +@given( + 'a lock service with a real database and an expired lock for "{owner}" on plan "{plan_id}"' +) +def step_real_db_with_expired_lock(context: Context, owner: str, plan_id: str) -> None: + """Build a real-DB lock service and insert an expired lock.""" + context.cov_svc = _make_lock_service(context) + context.cov_error = None + context.cov_result = None + + now = datetime.now(tz=UTC) + expired_at = (now - timedelta(seconds=60)).isoformat() + acquired_at = (now - timedelta(seconds=120)).isoformat() + + session: Session = context.cov_session_factory() + lock = LockModel( + owner_id=owner, + resource_type="plan", + resource_id=plan_id, + acquired_at=acquired_at, + expires_at=expired_at, + ) + session.add(lock) + session.commit() + session.close() + + +@when('I try to renew plan "{plan_id}" as "{owner}" and capture the expired error') +def step_try_renew_expired_cov(context: Context, plan_id: str, owner: str) -> None: + """Attempt renew expecting a LockExpiredError.""" + try: + context.cov_svc.renew( + owner_id=owner, + resource_type="plan", + resource_id=plan_id, + ) + except LockExpiredError as exc: + context.cov_error = exc + + +@then("a LockExpiredError should have been raised") +def step_assert_lock_expired(context: Context) -> None: + """Assert that a LockExpiredError was captured.""" + assert isinstance(context.cov_error, LockExpiredError), ( + f"Expected LockExpiredError, got {type(context.cov_error).__name__}" + ) diff --git a/features/steps/plan_apply_service_coverage_steps.py b/features/steps/plan_apply_service_coverage_steps.py new file mode 100644 index 00000000..97ed33d3 --- /dev/null +++ b/features/steps/plan_apply_service_coverage_steps.py @@ -0,0 +1,1181 @@ +"""Step definitions for plan_apply_service_coverage feature. + +Covers all code paths in plan_apply_service.py including helper functions, +service methods, edge cases, and error branches. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock + +from behave import given, then, when # type: ignore[import-untyped] +from behave.runner import Context # type: ignore[import-untyped] + +from cleveragents.application.services.plan_apply_service import ( + _OP_COLORS, + ApplyOutcome, + ApplyResult, + PlanApplyService, + _build_artifacts_dict, + _operation_label, + _render_diff_json, + _render_diff_plain, + _render_diff_rich, +) +from cleveragents.core.exceptions import PlanError, ValidationError +from cleveragents.domain.models.core.change import ( + ChangeEntry, + ChangeOperation, + SpecChangeSet, +) +from cleveragents.domain.models.core.plan import ( + PlanPhase, + PlanTimestamps, + ProcessingState, +) + +__all__: list[str] = [] + +# Plan ID constant for tests +_PLAN_ID = "01PLANTEST000000000000001" +_CHANGESET_ID = "01CSTEST0000000000000001" + + +# ---------------------------------------------------------------------- +# Stub / helper factories +# ---------------------------------------------------------------------- + + +class _StubPhase: + """Stub for PlanPhase with a .value attribute.""" + + def __init__(self, phase: PlanPhase) -> None: + """Initialize with the real enum member.""" + self._phase = phase + self.value = phase.value + + def __eq__(self, other: object) -> bool: + """Compare against PlanPhase or another _StubPhase.""" + if isinstance(other, PlanPhase): + return self._phase == other + if isinstance(other, _StubPhase): + return self._phase == other._phase + return NotImplemented + + +class _StubState: + """Stub for ProcessingState with a .value attribute.""" + + def __init__(self, state: ProcessingState) -> None: + """Initialize with the real enum member.""" + self._state = state + self.value = state.value + + def __eq__(self, other: object) -> bool: + """Compare against ProcessingState or another _StubState.""" + if isinstance(other, ProcessingState): + return self._state == other + if isinstance(other, _StubState): + return self._state == other._state + return NotImplemented + + +def _make_mock_plan( + *, + plan_id: str = _PLAN_ID, + phase: PlanPhase = PlanPhase.EXECUTE, + state: ProcessingState = ProcessingState.COMPLETE, + changeset_id: str | None = _CHANGESET_ID, + validation_summary: dict[str, Any] | None = None, + is_terminal: bool = False, + error_details: dict[str, str] | None = None, + sandbox_refs: list[str] | None = None, +) -> MagicMock: + """Create a mock Plan object with sensible defaults.""" + plan = MagicMock() + plan.identity.plan_id = plan_id + plan.phase = _StubPhase(phase) + plan.processing_state = _StubState(state) + plan.changeset_id = changeset_id + plan.validation_summary = validation_summary + plan.is_terminal = is_terminal + plan.error_details = error_details + plan.timestamps = PlanTimestamps() + plan.sandbox_refs = sandbox_refs or ["sandbox-ref-1"] + return plan + + +def _make_changeset( + plan_id: str = _PLAN_ID, + changeset_id: str = _CHANGESET_ID, + entries: list[ChangeEntry] | None = None, +) -> SpecChangeSet: + """Create a SpecChangeSet with optional entries.""" + cs = SpecChangeSet(changeset_id=changeset_id, plan_id=plan_id) + if entries: + for entry in entries: + cs.add_change(entry) + return cs + + +def _make_modify_entry( + plan_id: str = _PLAN_ID, + path: str = "src/app.py", + before_hash: str | None = "abcdef0123456789", + after_hash: str | None = "123456abcdefghij", +) -> ChangeEntry: + """Create a modify ChangeEntry.""" + return ChangeEntry( + plan_id=plan_id, + resource_id="RES001", + tool_name="builtin/test-tool", + operation=ChangeOperation.MODIFY, + path=path, + before_hash=before_hash, + after_hash=after_hash, + ) + + +def _make_create_entry( + plan_id: str = _PLAN_ID, + path: str = "src/new_file.py", + after_hash: str | None = "aaaaaa1111111111", +) -> ChangeEntry: + """Create a create ChangeEntry.""" + return ChangeEntry( + plan_id=plan_id, + resource_id="RES002", + tool_name="builtin/test-tool", + operation=ChangeOperation.CREATE, + path=path, + before_hash=None, + after_hash=after_hash, + ) + + +def _make_delete_entry( + plan_id: str = _PLAN_ID, + path: str = "src/old_file.py", + before_hash: str | None = "bbbbbb2222222222", +) -> ChangeEntry: + """Create a delete ChangeEntry.""" + return ChangeEntry( + plan_id=plan_id, + resource_id="RES003", + tool_name="builtin/test-tool", + operation=ChangeOperation.DELETE, + path=path, + before_hash=before_hash, + after_hash=None, + ) + + +def _make_rename_entry( + plan_id: str = _PLAN_ID, + path: str = "src/renamed.py", +) -> ChangeEntry: + """Create a rename ChangeEntry.""" + return ChangeEntry( + plan_id=plan_id, + resource_id="RES004", + tool_name="builtin/test-tool", + operation=ChangeOperation.RENAME, + path=path, + before_hash=None, + after_hash=None, + ) + + +def _make_lifecycle_mock() -> MagicMock: + """Create a mock PlanLifecycleService.""" + lifecycle = MagicMock() + lifecycle._commit_plan = MagicMock() + lifecycle.complete_apply = MagicMock() + lifecycle.constrain_apply = MagicMock() + lifecycle.fail_apply = MagicMock() + return lifecycle + + +def _make_service( + lifecycle: MagicMock | None = None, + plan: MagicMock | None = None, + changeset_store: Any | None = None, +) -> PlanApplyService: + """Create a PlanApplyService with mocked dependencies.""" + lc = lifecycle or _make_lifecycle_mock() + if plan is not None: + lc.get_plan.return_value = plan + svc = PlanApplyService( + lifecycle_service=lc, + changeset_store=changeset_store, + ) + return svc + + +# ====================================================================== +# _operation_label steps +# ====================================================================== + + +@when('pas_cov I call _operation_label with "{op}"') +def step_call_operation_label(context: Context, op: str) -> None: + """Call _operation_label with the given operation string.""" + context.pas_label_result = _operation_label(op) + + +@then('pas_cov the label should be "{expected}"') +def step_label_should_be(context: Context, expected: str) -> None: + """Assert _operation_label returned the expected string.""" + assert context.pas_label_result == expected, ( + f"Expected label '{expected}', got '{context.pas_label_result}'" + ) + + +# ====================================================================== +# SpecChangeSet construction steps (shared) +# ====================================================================== + + +@given("pas_cov a SpecChangeSet with no entries") +def step_changeset_no_entries(context: Context) -> None: + """Create an empty SpecChangeSet.""" + context.pas_changeset = _make_changeset() + + +@given("pas_cov a SpecChangeSet with a modify entry having both hashes") +def step_changeset_modify_both_hashes(context: Context) -> None: + """Create a SpecChangeSet with one modify entry that has both hashes.""" + entry = _make_modify_entry() + context.pas_changeset = _make_changeset(entries=[entry]) + + +@given("pas_cov a SpecChangeSet with a create entry having only after_hash") +def step_changeset_create_after_hash(context: Context) -> None: + """Create a SpecChangeSet with one create entry that has only after_hash.""" + entry = _make_create_entry() + context.pas_changeset = _make_changeset(entries=[entry]) + + +@given("pas_cov a SpecChangeSet with a delete entry having only before_hash") +def step_changeset_delete_before_hash(context: Context) -> None: + """Create a SpecChangeSet with one delete entry that has only before_hash.""" + entry = _make_delete_entry() + context.pas_changeset = _make_changeset(entries=[entry]) + + +@given('pas_cov a SpecChangeSet with an entry having operation "{op}"') +def step_changeset_with_operation(context: Context, op: str) -> None: + """Create a SpecChangeSet with an entry having the given operation.""" + if op == "rename": + entry = _make_rename_entry() + elif op == "create": + entry = _make_create_entry() + elif op == "delete": + entry = _make_delete_entry() + else: + entry = _make_modify_entry() + context.pas_changeset = _make_changeset(entries=[entry]) + + +# ====================================================================== +# _render_diff_plain steps +# ====================================================================== + + +@when("pas_cov I render diff plain") +def step_render_diff_plain(context: Context) -> None: + """Call _render_diff_plain with the context changeset.""" + context.pas_diff_result = _render_diff_plain(context.pas_changeset) + + +@then('pas_cov the plain diff should be "{expected}"') +def step_plain_diff_exact(context: Context, expected: str) -> None: + """Assert the plain diff output matches exactly.""" + assert context.pas_diff_result == expected, ( + f"Expected '{expected}', got '{context.pas_diff_result}'" + ) + + +@then('pas_cov the plain diff should contain "{text}"') +def step_plain_diff_contains(context: Context, text: str) -> None: + """Assert the plain diff output contains the given text.""" + assert text in context.pas_diff_result, ( + f"Expected plain diff to contain '{text}', got:\n{context.pas_diff_result}" + ) + + +@then('pas_cov the plain diff should not contain "{text}"') +def step_plain_diff_not_contains(context: Context, text: str) -> None: + """Assert the plain diff output does not contain the given text.""" + assert text not in context.pas_diff_result, ( + f"Expected plain diff NOT to contain '{text}', got:\n{context.pas_diff_result}" + ) + + +# ====================================================================== +# _render_diff_rich steps +# ====================================================================== + + +@when("pas_cov I render diff rich") +def step_render_diff_rich(context: Context) -> None: + """Call _render_diff_rich with the context changeset.""" + context.pas_diff_result = _render_diff_rich(context.pas_changeset) + + +@then('pas_cov the rich diff should contain "{text}"') +def step_rich_diff_contains(context: Context, text: str) -> None: + """Assert the rich diff output contains the given text.""" + assert text in context.pas_diff_result, ( + f"Expected rich diff to contain '{text}', got:\n{context.pas_diff_result}" + ) + + +@then('pas_cov the rich diff should not contain "{text}"') +def step_rich_diff_not_contains(context: Context, text: str) -> None: + """Assert the rich diff output does not contain the given text.""" + assert text not in context.pas_diff_result, ( + f"Expected rich diff NOT to contain '{text}', got:\n{context.pas_diff_result}" + ) + + +# ====================================================================== +# _render_diff_json steps +# ====================================================================== + + +@when("pas_cov I render diff json") +def step_render_diff_json(context: Context) -> None: + """Call _render_diff_json with the context changeset.""" + context.pas_json_result = _render_diff_json(context.pas_changeset) + + +@then('pas_cov the json diff should have key "{key}"') +def step_json_diff_has_key(context: Context, key: str) -> None: + """Assert the JSON diff dict has the given key.""" + assert key in context.pas_json_result, ( + f"Expected JSON diff to have key '{key}', keys: {list(context.pas_json_result.keys())}" + ) + + +@then('pas_cov the json diff should have key "{key}" with value {value}') +def step_json_diff_key_value(context: Context, key: str, value: str) -> None: + """Assert the JSON diff dict has the given key with the given value.""" + assert key in context.pas_json_result, f"Expected key '{key}' in JSON diff" + actual = context.pas_json_result[key] + expected = int(value) if value.isdigit() else value + assert actual == expected, f"Expected '{key}' to be {expected}, got {actual}" + + +@then("pas_cov the json diff entries should have length {n}") +def step_json_diff_entries_length(context: Context, n: str) -> None: + """Assert the JSON diff entries list has the given length.""" + entries = context.pas_json_result["entries"] + assert len(entries) == int(n), f"Expected {n} entries, got {len(entries)}" + + +@then('pas_cov the json diff first entry should have "{key}" equal to "{value}"') +def step_json_diff_first_entry_value(context: Context, key: str, value: str) -> None: + """Assert the first entry in JSON diff has the given key/value.""" + entry = context.pas_json_result["entries"][0] + assert entry[key] == value, ( + f"Expected entry['{key}'] == '{value}', got '{entry[key]}'" + ) + + +@then('pas_cov the json diff first entry should have key "{key}"') +def step_json_diff_first_entry_has_key(context: Context, key: str) -> None: + """Assert the first entry in JSON diff has the given key.""" + entry = context.pas_json_result["entries"][0] + assert key in entry, ( + f"Expected entry to have key '{key}', keys: {list(entry.keys())}" + ) + + +# ====================================================================== +# _build_artifacts_dict steps +# ====================================================================== + + +@given('pas_cov a mock plan with changeset_id "{cs_id}"') +def step_mock_plan_with_changeset_id(context: Context, cs_id: str) -> None: + """Create a mock plan with the given changeset_id.""" + context.pas_plan = _make_mock_plan(changeset_id=cs_id) + + +@given('pas_cov a mock plan with changeset_id "{cs_id}" and validation summary') +def step_mock_plan_with_validation(context: Context, cs_id: str) -> None: + """Create a mock plan with changeset_id and validation summary.""" + context.pas_plan = _make_mock_plan( + changeset_id=cs_id, + validation_summary={"total": 3, "required_passed": 2, "required_failed": 1}, + ) + + +@given("pas_cov a mock plan with apply summary in error_details") +def step_mock_plan_with_apply_summary(context: Context) -> None: + """Create a mock plan with apply summary in error_details.""" + context.pas_plan = _make_mock_plan( + changeset_id=_CHANGESET_ID, + validation_summary=None, + error_details={ + "apply_files_changed": "5", + "apply_validations_run": "3", + }, + ) + + +@when("pas_cov I build artifacts dict") +def step_build_artifacts_dict(context: Context) -> None: + """Call _build_artifacts_dict with plan and changeset.""" + context.pas_artifacts = _build_artifacts_dict( + context.pas_plan, + context.pas_changeset, + ) + + +@when("pas_cov I build artifacts dict with no changeset") +def step_build_artifacts_dict_no_changeset(context: Context) -> None: + """Call _build_artifacts_dict with plan and None changeset.""" + context.pas_artifacts = _build_artifacts_dict(context.pas_plan, None) + + +@then('pas_cov the artifacts dict should have key "{key}"') +def step_artifacts_dict_has_key(context: Context, key: str) -> None: + """Assert artifacts dict contains the given key.""" + assert key in context.pas_artifacts, ( + f"Expected artifacts to have key '{key}', keys: {list(context.pas_artifacts.keys())}" + ) + + +@then("pas_cov the artifacts dict files_changed list should have length {n}") +def step_artifacts_files_changed_length(context: Context, n: str) -> None: + """Assert the files_changed list has the expected length.""" + fc = context.pas_artifacts["files_changed"] + assert len(fc) == int(n), f"Expected files_changed length {n}, got {len(fc)}" + + +@then("pas_cov the artifacts dict changeset_summary should be null") +def step_artifacts_changeset_summary_null(context: Context) -> None: + """Assert changeset_summary is None in artifacts dict.""" + assert context.pas_artifacts["changeset_summary"] is None, ( + f"Expected changeset_summary to be None, got {context.pas_artifacts['changeset_summary']}" + ) + + +@then('pas_cov the artifacts dict apply_summary files_changed should be "{value}"') +def step_artifacts_apply_summary_files(context: Context, value: str) -> None: + """Assert apply_summary.files_changed has the expected value.""" + summary = context.pas_artifacts["apply_summary"] + assert summary["files_changed"] == value, ( + f"Expected files_changed '{value}', got '{summary['files_changed']}'" + ) + + +# ====================================================================== +# PlanApplyService initialization steps +# ====================================================================== + + +@when("pas_cov I create PlanApplyService with None lifecycle service") +def step_create_service_none_lifecycle(context: Context) -> None: + """Attempt to create PlanApplyService with None lifecycle.""" + context.pas_error = None + try: + PlanApplyService(lifecycle_service=None) # type: ignore[arg-type] + except ValidationError as exc: + context.pas_error = exc + + +@then("pas_cov a ValidationError should be raised") +def step_validation_error_raised(context: Context) -> None: + """Assert that a ValidationError was raised.""" + assert context.pas_error is not None, "Expected ValidationError but none was raised" + assert isinstance(context.pas_error, ValidationError), ( + f"Expected ValidationError, got {type(context.pas_error).__name__}" + ) + + +@given("pas_cov a mock lifecycle service") +def step_mock_lifecycle(context: Context) -> None: + """Create a mock lifecycle service on context.""" + context.pas_lifecycle = _make_lifecycle_mock() + + +@given("pas_cov a mock changeset store") +def step_mock_changeset_store(context: Context) -> None: + """Create a mock changeset store on context.""" + context.pas_store = MagicMock() + + +@when("pas_cov I create PlanApplyService with valid lifecycle and no store") +def step_create_service_no_store(context: Context) -> None: + """Create PlanApplyService with valid lifecycle and no store.""" + context.pas_service = PlanApplyService( + lifecycle_service=context.pas_lifecycle, + changeset_store=None, + ) + + +@when("pas_cov I create PlanApplyService with valid lifecycle and store") +def step_create_service_with_store(context: Context) -> None: + """Create PlanApplyService with valid lifecycle and store.""" + context.pas_service = PlanApplyService( + lifecycle_service=context.pas_lifecycle, + changeset_store=context.pas_store, + ) + + +@then("pas_cov the service should be created successfully") +def step_service_created(context: Context) -> None: + """Assert the PlanApplyService instance was created.""" + assert context.pas_service is not None, "Service should not be None" + assert isinstance(context.pas_service, PlanApplyService), ( + f"Expected PlanApplyService, got {type(context.pas_service).__name__}" + ) + + +# ====================================================================== +# diff method steps +# ====================================================================== + + +@given("pas_cov a service with a plan that has no changeset_id") +def step_service_plan_no_changeset(context: Context) -> None: + """Create a service with a plan whose changeset_id is None.""" + plan = _make_mock_plan(changeset_id=None) + context.pas_plan = plan + context.pas_service = _make_service(plan=plan) + + +@given("pas_cov a service with a plan that has a changeset with entries") +def step_service_plan_with_changeset_entries(context: Context) -> None: + """Create a service with a plan whose changeset has entries in the store.""" + plan = _make_mock_plan(changeset_id=_CHANGESET_ID) + context.pas_plan = plan + cs = _make_changeset(entries=[_make_modify_entry()]) + store = MagicMock() + store.get.return_value = cs + context.pas_changeset = cs + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=store, + ) + + +@given("pas_cov a service with a plan that has an empty changeset") +def step_service_plan_empty_changeset(context: Context) -> None: + """Create a service with a plan whose changeset has no entries.""" + plan = _make_mock_plan(changeset_id=_CHANGESET_ID) + context.pas_plan = plan + cs = _make_changeset() + store = MagicMock() + store.get.return_value = cs + context.pas_changeset = cs + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=store, + ) + + +@when("pas_cov I call diff on the service") +def step_call_diff_default(context: Context) -> None: + """Call diff on the service with default format.""" + context.pas_error = None + try: + context.pas_diff_result = context.pas_service.diff(_PLAN_ID) + except PlanError as exc: + context.pas_error = exc + + +@when('pas_cov I call diff with format "{fmt}"') +def step_call_diff_format(context: Context, fmt: str) -> None: + """Call diff on the service with specified format.""" + context.pas_error = None + try: + context.pas_diff_result = context.pas_service.diff(_PLAN_ID, fmt=fmt) + except PlanError as exc: + context.pas_error = exc + + +@then('pas_cov a PlanError should be raised with message containing "{text}"') +def step_plan_error_raised_with_message(context: Context, text: str) -> None: + """Assert a PlanError was raised with the given text in its message.""" + assert context.pas_error is not None, "Expected PlanError but none was raised" + assert isinstance(context.pas_error, PlanError), ( + f"Expected PlanError, got {type(context.pas_error).__name__}" + ) + assert text in str(context.pas_error), ( + f"Expected message to contain '{text}', got: {context.pas_error}" + ) + + +@then('pas_cov the diff result should contain "{text}"') +def step_diff_result_contains(context: Context, text: str) -> None: + """Assert the diff result contains the given text.""" + assert text in context.pas_diff_result, ( + f"Expected diff result to contain '{text}', got:\n{context.pas_diff_result}" + ) + + +@then('pas_cov the diff result should not contain "{text}"') +def step_diff_result_not_contains(context: Context, text: str) -> None: + """Assert the diff result does not contain the given text.""" + assert text not in context.pas_diff_result, ( + f"Expected diff result NOT to contain '{text}'" + ) + + +@then("pas_cov the diff result should be valid JSON") +def step_diff_result_is_json(context: Context) -> None: + """Assert the diff result is valid JSON.""" + try: + parsed = json.loads(context.pas_diff_result) + assert isinstance(parsed, dict), "Expected JSON object" + except json.JSONDecodeError as exc: + assert False, f"Diff result is not valid JSON: {exc}" # noqa: B011 + + +# ====================================================================== +# artifacts method steps +# ====================================================================== + + +@when('pas_cov I call artifacts with format "{fmt}"') +def step_call_artifacts_format(context: Context, fmt: str) -> None: + """Call artifacts on the service with specified format.""" + context.pas_artifacts_result = context.pas_service.artifacts(_PLAN_ID, fmt=fmt) + + +@then('pas_cov the artifacts result should contain "{text}"') +def step_artifacts_result_contains(context: Context, text: str) -> None: + """Assert artifacts result contains the given text.""" + assert text in context.pas_artifacts_result, ( + f"Expected artifacts to contain '{text}', got:\n{context.pas_artifacts_result}" + ) + + +@then("pas_cov the artifacts result should be a non-empty string") +def step_artifacts_result_non_empty(context: Context) -> None: + """Assert artifacts result is a non-empty string.""" + assert isinstance(context.pas_artifacts_result, str), "Expected string" + assert len(context.pas_artifacts_result) > 0, "Expected non-empty string" + + +# ====================================================================== +# persist_apply_summary steps +# ====================================================================== + + +@given("pas_cov a service with a plan that has no error_details") +def step_service_plan_no_error_details(context: Context) -> None: + """Create a service with a plan that has no error_details.""" + plan = _make_mock_plan(error_details=None) + context.pas_plan = plan + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + lifecycle.fail_apply.return_value = _make_mock_plan( + state=ProcessingState.ERRORED, is_terminal=True + ) + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=None, + ) + + +@given("pas_cov a service with a plan that has existing error_details") +def step_service_plan_existing_error_details(context: Context) -> None: + """Create a service with a plan that has pre-existing error_details.""" + plan = _make_mock_plan( + error_details={"existing_key": "existing_value"}, + ) + context.pas_plan = plan + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + lifecycle.fail_apply.return_value = _make_mock_plan( + state=ProcessingState.ERRORED, is_terminal=True + ) + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=None, + ) + + +@when( + "pas_cov I persist apply summary with {files} files and {validations} validations" +) +def step_persist_apply_summary(context: Context, files: str, validations: str) -> None: + """Call persist_apply_summary on the service.""" + context.pas_returned_plan = context.pas_service.persist_apply_summary( + plan_id=_PLAN_ID, + files_changed=int(files), + validations_run=int(validations), + ) + + +@then('pas_cov the plan error_details should contain "{key}" with value "{value}"') +def step_plan_error_details_value(context: Context, key: str, value: str) -> None: + """Assert the plan error_details has the given key with expected value.""" + details = context.pas_plan.error_details + assert details is not None, "error_details should not be None" + assert key in details, f"Expected key '{key}' in error_details, got: {details}" + assert details[key] == value, ( + f"Expected error_details['{key}'] == '{value}', got '{details[key]}'" + ) + + +@then('pas_cov the plan error_details should contain key "{key}"') +def step_plan_error_details_has_key(context: Context, key: str) -> None: + """Assert the plan error_details has the given key.""" + details = context.pas_plan.error_details + assert details is not None, "error_details should not be None" + assert key in details, ( + f"Expected key '{key}' in error_details, keys: {list(details.keys())}" + ) + + +@then("pas_cov the lifecycle _commit_plan should have been called") +def step_commit_plan_called(context: Context) -> None: + """Assert lifecycle._commit_plan was called.""" + context.pas_lifecycle._commit_plan.assert_called() + + +# ====================================================================== +# handle_merge_failure steps +# ====================================================================== + + +@when('pas_cov I handle merge failure with conflict "{details}"') +def step_handle_merge_failure(context: Context, details: str) -> None: + """Call handle_merge_failure on the service.""" + context.pas_returned_plan = context.pas_service.handle_merge_failure( + plan_id=_PLAN_ID, + conflict_details=details, + ) + + +@then("pas_cov the lifecycle fail_apply should have been called") +def step_fail_apply_called(context: Context) -> None: + """Assert lifecycle.fail_apply was called.""" + context.pas_lifecycle.fail_apply.assert_called() + + +@then("pas_cov the returned plan should reflect errored state") +def step_returned_plan_errored(context: Context) -> None: + """Assert the returned plan is in errored state.""" + assert context.pas_returned_plan is not None, "Returned plan should not be None" + + +@then('pas_cov the committed plan error_details should contain "{key}"') +def step_committed_error_details_contains(context: Context, key: str) -> None: + """Assert the plan error_details set before commit contains the key.""" + # The error_details were set on the mock plan directly + details = context.pas_plan.error_details + assert details is not None, "error_details should not be None" + assert key in details, ( + f"Expected key '{key}' in committed error_details, keys: {list(details.keys())}" + ) + + +# ====================================================================== +# guard_empty_changeset steps +# ====================================================================== + + +@when("pas_cov I call guard_empty_changeset") +def step_call_guard(context: Context) -> None: + """Call guard_empty_changeset on the service.""" + context.pas_error = None + try: + context.pas_guard_result = context.pas_service.guard_empty_changeset(_PLAN_ID) + except PlanError as exc: + context.pas_error = exc + + +@when("pas_cov I call guard_empty_changeset with allow_empty true") +def step_call_guard_allow_empty(context: Context) -> None: + """Call guard_empty_changeset with allow_empty=True.""" + context.pas_error = None + try: + context.pas_guard_result = context.pas_service.guard_empty_changeset( + _PLAN_ID, allow_empty=True + ) + except PlanError as exc: + context.pas_error = exc + + +@then("pas_cov the guard should return true") +def step_guard_returns_true(context: Context) -> None: + """Assert the guard returned True.""" + assert context.pas_guard_result is True, ( + f"Expected guard to return True, got {context.pas_guard_result}" + ) + + +# ====================================================================== +# _extract_validation_counts steps +# ====================================================================== + + +@when("pas_cov I extract validation counts from None") +def step_extract_counts_none(context: Context) -> None: + """Call _extract_validation_counts with None.""" + context.pas_counts = PlanApplyService._extract_validation_counts(None) + + +@when( + "pas_cov I extract validation counts from full summary {p} passed {f} failed {t} total" +) +def step_extract_counts_full(context: Context, p: str, f: str, t: str) -> None: + """Call _extract_validation_counts with a full summary dict.""" + vs = { + "required_passed": int(p), + "required_failed": int(f), + "total": int(t), + } + context.pas_counts = PlanApplyService._extract_validation_counts(vs) + + +@when("pas_cov I extract validation counts from partial summary {p} passed {f} failed") +def step_extract_counts_no_total(context: Context, p: str, f: str) -> None: + """Call _extract_validation_counts with no total key.""" + vs = { + "required_passed": int(p), + "required_failed": int(f), + } + context.pas_counts = PlanApplyService._extract_validation_counts(vs) + + +@then("pas_cov the counts should be {p} passed {f} failed {t} total") +def step_assert_counts(context: Context, p: str, f: str, t: str) -> None: + """Assert the extracted validation counts match.""" + expected = (int(p), int(f), int(t)) + assert context.pas_counts == expected, ( + f"Expected counts {expected}, got {context.pas_counts}" + ) + + +# ====================================================================== +# _resolve_changeset steps +# ====================================================================== + + +@when("pas_cov I call _resolve_changeset") +def step_call_resolve_changeset(context: Context) -> None: + """Call _resolve_changeset on the service.""" + plan = context.pas_plan + context.pas_resolved_cs = context.pas_service._resolve_changeset(plan) + + +@then("pas_cov the resolved changeset should be None") +def step_resolved_cs_none(context: Context) -> None: + """Assert _resolve_changeset returned None.""" + assert context.pas_resolved_cs is None, ( + f"Expected None, got {context.pas_resolved_cs}" + ) + + +@then("pas_cov the resolved changeset should have entries") +def step_resolved_cs_has_entries(context: Context) -> None: + """Assert _resolve_changeset returned a changeset with entries.""" + assert context.pas_resolved_cs is not None, "Resolved changeset should not be None" + assert len(context.pas_resolved_cs.entries) > 0, ( + f"Expected entries, got {len(context.pas_resolved_cs.entries)}" + ) + + +@then("pas_cov the resolved changeset should have no entries") +def step_resolved_cs_no_entries(context: Context) -> None: + """Assert _resolve_changeset returned a changeset with no entries.""" + assert context.pas_resolved_cs is not None, "Resolved changeset should not be None" + assert len(context.pas_resolved_cs.entries) == 0, ( + f"Expected 0 entries, got {len(context.pas_resolved_cs.entries)}" + ) + + +@then("pas_cov the resolved changeset should have the plan changeset_id") +def step_resolved_cs_has_changeset_id(context: Context) -> None: + """Assert the resolved changeset has the plan's changeset_id.""" + assert context.pas_resolved_cs.changeset_id == context.pas_plan.changeset_id, ( + f"Expected changeset_id '{context.pas_plan.changeset_id}', " + f"got '{context.pas_resolved_cs.changeset_id}'" + ) + + +@given( + "pas_cov a service with a plan that has changeset_id and store returns a changeset" +) +def step_service_store_returns_changeset(context: Context) -> None: + """Create a service where the store returns a populated changeset.""" + plan = _make_mock_plan(changeset_id=_CHANGESET_ID) + context.pas_plan = plan + cs = _make_changeset(entries=[_make_modify_entry()]) + store = MagicMock() + store.get.return_value = cs + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=store, + ) + + +@given("pas_cov a service with a plan that has changeset_id but store returns None") +def step_service_store_returns_none(context: Context) -> None: + """Create a service where the store returns None (miss).""" + plan = _make_mock_plan(changeset_id=_CHANGESET_ID) + context.pas_plan = plan + store = MagicMock() + store.get.return_value = None + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=store, + ) + + +@given("pas_cov a service with a plan that has changeset_id and no store") +def step_service_no_store(context: Context) -> None: + """Create a service with no changeset store configured.""" + plan = _make_mock_plan(changeset_id=_CHANGESET_ID) + context.pas_plan = plan + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=None, + ) + + +# ====================================================================== +# cleanup_changeset steps +# ====================================================================== + + +@given("pas_cov a service with a mock lifecycle") +def step_service_mock_lifecycle(context: Context) -> None: + """Create a basic service with a mock lifecycle.""" + lifecycle = _make_lifecycle_mock() + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=None, + ) + + +@when("pas_cov I call cleanup_changeset with empty plan_id") +def step_cleanup_empty_plan_id(context: Context) -> None: + """Call cleanup_changeset with empty plan_id.""" + context.pas_error = None + try: + context.pas_service.cleanup_changeset("") + except ValidationError as exc: + context.pas_error = exc + + +@given("pas_cov a service with a store that has delete_for_plan returning {n}") +def step_service_store_with_delete(context: Context, n: str) -> None: + """Create a service whose store.delete_for_plan returns n.""" + store = MagicMock() + store.delete_for_plan.return_value = int(n) + lifecycle = _make_lifecycle_mock() + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=store, + ) + + +@when('pas_cov I call cleanup_changeset with plan_id "{plan_id}"') +def step_cleanup_with_plan_id(context: Context, plan_id: str) -> None: + """Call cleanup_changeset with the given plan_id.""" + context.pas_cleanup_result = context.pas_service.cleanup_changeset(plan_id) + + +@then("pas_cov cleanup should return {n}") +def step_cleanup_returns(context: Context, n: str) -> None: + """Assert cleanup_changeset returned the expected count.""" + assert context.pas_cleanup_result == int(n), ( + f"Expected cleanup to return {n}, got {context.pas_cleanup_result}" + ) + + +@given("pas_cov a service with no changeset store") +def step_service_no_changeset_store(context: Context) -> None: + """Create a service with changeset_store=None.""" + lifecycle = _make_lifecycle_mock() + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=None, + ) + + +@given("pas_cov a service with a store that lacks delete_for_plan") +def step_service_store_no_delete(context: Context) -> None: + """Create a service whose store has no delete_for_plan attribute.""" + + class _StubStore: + """Stub store without delete_for_plan method.""" + + def get(self, changeset_id: str) -> None: + """Return None for any lookup.""" + return None + + lifecycle = _make_lifecycle_mock() + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=_StubStore(), + ) + + +# ====================================================================== +# apply_with_validation_gate: exception branches +# ====================================================================== + + +@given("pas_cov a service where constrain_apply raises PlanError") +def step_service_constrain_raises(context: Context) -> None: + """Create a service where constrain_apply raises PlanError.""" + plan = _make_mock_plan( + changeset_id=_CHANGESET_ID, + is_terminal=False, + ) + context.pas_plan = plan + cs = _make_changeset(entries=[_make_modify_entry()]) + store = MagicMock() + store.get.return_value = cs + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + lifecycle.constrain_apply.side_effect = PlanError("Not in Apply phase") + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=store, + ) + + +@given("pas_cov the plan has failed validations") +def step_plan_failed_validations(context: Context) -> None: + """Set the plan's validation_summary to have failed validations.""" + context.pas_plan.validation_summary = { + "total": 3, + "required_passed": 1, + "required_failed": 2, + } + + +@given("pas_cov a service where complete_apply raises PlanError") +def step_service_complete_raises(context: Context) -> None: + """Create a service where complete_apply raises PlanError.""" + plan = _make_mock_plan( + changeset_id=_CHANGESET_ID, + is_terminal=False, + ) + context.pas_plan = plan + cs = _make_changeset(entries=[_make_modify_entry()]) + store = MagicMock() + store.get.return_value = cs + lifecycle = _make_lifecycle_mock() + lifecycle.get_plan.return_value = plan + lifecycle.complete_apply.side_effect = PlanError("Not in Apply phase") + context.pas_lifecycle = lifecycle + context.pas_service = PlanApplyService( + lifecycle_service=lifecycle, + changeset_store=store, + ) + + +@given("pas_cov the plan has passing validations and entries") +def step_plan_passing_validations(context: Context) -> None: + """Set the plan's validation_summary to all passing.""" + context.pas_plan.validation_summary = { + "total": 2, + "required_passed": 2, + "required_failed": 0, + } + + +@when("pas_cov I call apply_with_validation_gate") +def step_call_apply_gate(context: Context) -> None: + """Call apply_with_validation_gate on the service.""" + context.pas_apply_result = context.pas_service.apply_with_validation_gate( + plan_id=_PLAN_ID, + ) + + +@then('pas_cov the result outcome should be "{outcome}"') +def step_result_outcome(context: Context, outcome: str) -> None: + """Assert the apply result outcome matches.""" + expected = ApplyOutcome(outcome) + assert context.pas_apply_result.outcome == expected, ( + f"Expected outcome '{outcome}', got '{context.pas_apply_result.outcome}'" + ) + + +@then('pas_cov the result message should contain "{text}"') +def step_result_message_contains(context: Context, text: str) -> None: + """Assert the apply result message contains the given text.""" + assert text in context.pas_apply_result.message, ( + f"Expected message to contain '{text}', got: {context.pas_apply_result.message}" + ) + + +# ====================================================================== +# ApplyOutcome and ApplyResult model steps +# ====================================================================== + + +@then('pas_cov ApplyOutcome should have value "{value}"') +def step_apply_outcome_value(context: Context, value: str) -> None: + """Assert ApplyOutcome enum contains the given value.""" + outcome = ApplyOutcome(value) + assert outcome.value == value, ( + f"Expected ApplyOutcome value '{value}', got '{outcome.value}'" + ) + + +@when("pas_cov I create an ApplyResult with whitespace in message") +def step_create_apply_result_whitespace(context: Context) -> None: + """Create an ApplyResult with whitespace in message field.""" + context.pas_apply_result_model = ApplyResult( + outcome=ApplyOutcome.APPLIED, + plan_id=_PLAN_ID, + message=" hello world ", + ) + + +@then("pas_cov the ApplyResult message should be stripped") +def step_apply_result_stripped(context: Context) -> None: + """Assert the ApplyResult message had whitespace stripped.""" + assert context.pas_apply_result_model.message == "hello world", ( + f"Expected 'hello world', got '{context.pas_apply_result_model.message}'" + ) + + +# ====================================================================== +# _OP_COLORS steps +# ====================================================================== + + +@then('pas_cov OP_COLORS should map "{key}" to "{value}"') +def step_op_colors_map(context: Context, key: str, value: str) -> None: + """Assert _OP_COLORS maps the key to the expected value.""" + assert key in _OP_COLORS, ( + f"Expected key '{key}' in _OP_COLORS, keys: {list(_OP_COLORS.keys())}" + ) + assert _OP_COLORS[key] == value, ( + f"Expected _OP_COLORS['{key}'] == '{value}', got '{_OP_COLORS[key]}'" + ) diff --git a/features/steps/plan_cli_coverage_steps.py b/features/steps/plan_cli_coverage_steps.py new file mode 100644 index 00000000..d0d0334d --- /dev/null +++ b/features/steps/plan_cli_coverage_steps.py @@ -0,0 +1,512 @@ +"""Step definitions for plan_cli_coverage.feature. + +Covers uncovered lines/branches in cleveragents/cli/commands/plan.py: +- Lines 818, 827-828: legacy apply Progress block and PlanError handler +- Branch 999: legacy list when plan.current is False +- Lines 1017-1035: legacy cd command full body +- Lines 1314-1315: invariant_actor parameter in use_action +- Lines 1391-1392: building PlanInvariant list from --invariant flags +- Lines 1573-1578: lifecycle-apply auto-discovery with 0 plans +- Lines 1594-1596: lifecycle-apply read-only guard +- Lines 1700-1701: status CleverAgentsError handler +- Branch 1738: errors command when has_error_recovery is False +""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.cli.commands.plan import app as plan_app +from cleveragents.core.exceptions import CleverAgentsError, PlanError +from cleveragents.domain.models.core.plan import ( + NamespacedName, + Plan, + PlanIdentity, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) + +_ULID_A = "01ARZ3NDEKTSV4RRFFQ69G5FAA" +_ULID_B = "01ARZ3NDEKTSV4RRFFQ69G5FAB" + +_PATCH_CONTAINER = "cleveragents.application.container.get_container" +_PATCH_GET_LIFECYCLE = "cleveragents.cli.commands.plan._get_lifecycle_service" +_PATCH_GET_PROJECT = "cleveragents.cli.commands.plan._get_current_project" + + +def _make_lifecycle_plan( + *, + plan_id: str = _ULID_A, + name: str = "local/test-plan", + phase: PlanPhase = PlanPhase.STRATEGIZE, + processing_state: ProcessingState = ProcessingState.QUEUED, + project_links: list[ProjectLink] | None = None, + error_message: str | None = None, + error_details: dict[str, str] | None = None, + read_only: bool = False, +) -> Plan: + """Build a real Plan domain object for testing.""" + return Plan( + identity=PlanIdentity(plan_id=plan_id), + namespaced_name=NamespacedName.parse(name), + action_name="local/test-action", + description="Coverage test plan", + definition_of_done=None, + strategy_actor=None, + execution_actor=None, + phase=phase, + processing_state=processing_state, + project_links=project_links or [], + timestamps=PlanTimestamps( + created_at=datetime(2025, 6, 15, 10, 0, 0), + updated_at=datetime(2025, 6, 15, 11, 0, 0), + ), + error_message=error_message, + error_details=error_details, + reusable=True, + read_only=read_only, + created_by=None, + ) + + +def _make_legacy_project() -> SimpleNamespace: + """Build a minimal project stub for legacy commands.""" + return SimpleNamespace(name="test-project", path="/tmp/test") + + +def _make_legacy_plan( + *, + name: str = "my-plan", + status: str = "active", + current: bool = True, +) -> SimpleNamespace: + """Build a minimal legacy plan stub.""" + return SimpleNamespace( + name=name, + status=status, + current=current, + created_at=datetime(2025, 1, 1), + prompt="do stuff", + ) + + +# ---------------------------------------------------------------------- +# Given — CLI runner +# ---------------------------------------------------------------------- + + +@given("a plan-cov CLI runner") +def step_plan_cov_cli_runner(context: Context) -> None: + """Initialise a CliRunner and cleanup list.""" + context.plan_cov_runner = CliRunner() + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + + +# ---------------------------------------------------------------------- +# Given — Legacy apply mocks +# ---------------------------------------------------------------------- + + +@given("a mocked legacy container for plan-cov apply that succeeds") +def step_mock_apply_success(context: Context) -> None: + """Set up mocks so legacy apply command succeeds through Progress block.""" + context.plan_cov_apply_changes_return = 1 + context.plan_cov_apply_changes_side_effect = None + + +@given("a mocked legacy container for plan-cov apply that raises PlanError") +def step_mock_apply_plan_error(context: Context) -> None: + """Set up mocks so legacy apply raises PlanError during apply_changes.""" + context.plan_cov_apply_changes_return = None + context.plan_cov_apply_changes_side_effect = PlanError("Sandbox conflict") + + +# ---------------------------------------------------------------------- +# Given — Legacy list mocks +# ---------------------------------------------------------------------- + + +@given("a mocked legacy container for plan-cov list with non-current plans") +def step_mock_list_non_current(context: Context) -> None: + """Mark that legacy list should produce plans with current=False.""" + context.plan_cov_list_plans = [ + _make_legacy_plan(name="alpha", current=False), + _make_legacy_plan(name="beta", current=False), + ] + + +# ---------------------------------------------------------------------- +# Given — Legacy cd mocks +# ---------------------------------------------------------------------- + + +@given("a mocked legacy container for plan-cov cd that succeeds") +def step_mock_cd_success(context: Context) -> None: + """Mark that legacy cd should succeed.""" + context.plan_cov_cd_return = _make_legacy_plan(name="my-plan") + context.plan_cov_cd_side_effect = None + + +@given("a mocked legacy container for plan-cov cd that raises CleverAgentsError") +def step_mock_cd_error(context: Context) -> None: + """Mark that legacy cd should raise CleverAgentsError.""" + context.plan_cov_cd_return = None + context.plan_cov_cd_side_effect = CleverAgentsError("not found") + + +# ---------------------------------------------------------------------- +# Given — Lifecycle service for use_action +# ---------------------------------------------------------------------- + + +@given("a mocked lifecycle service for plan-cov use action") +def step_mock_lifecycle_for_use(context: Context) -> None: + """Set up lifecycle service mock for the use command.""" + mock_service = MagicMock() + mock_action = MagicMock() + mock_action.namespaced_name = "local/test-action" + mock_service.get_action_by_name.return_value = mock_action + mock_plan = _make_lifecycle_plan() + mock_service.use_action.return_value = mock_plan + + p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service) + p1.start() + context._cleanup_handlers.append(p1.stop) + context.plan_cov_service = mock_service + context.plan_cov_plan = mock_plan + + +# ---------------------------------------------------------------------- +# Given — Lifecycle service for lifecycle-apply +# ---------------------------------------------------------------------- + + +@given("a mocked lifecycle service for plan-cov that returns no execute-complete plans") +def step_mock_lifecycle_apply_no_plans(context: Context) -> None: + """Set up lifecycle service that returns no complete plans in Execute phase.""" + mock_service = MagicMock() + mock_service.list_plans.return_value = [] + + p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service) + p1.start() + context._cleanup_handlers.append(p1.stop) + + +@given( + "a mocked lifecycle service for plan-cov that returns multiple execute-complete plans" +) +def step_mock_lifecycle_apply_multiple_plans(context: Context) -> None: + """Set up lifecycle service that returns multiple complete Execute plans.""" + mock_service = MagicMock() + plan_a = _make_lifecycle_plan( + plan_id=_ULID_A, + name="local/plan-a", + phase=PlanPhase.EXECUTE, + processing_state=ProcessingState.COMPLETE, + ) + plan_b = _make_lifecycle_plan( + plan_id=_ULID_B, + name="local/plan-b", + phase=PlanPhase.EXECUTE, + processing_state=ProcessingState.COMPLETE, + ) + mock_service.list_plans.return_value = [plan_a, plan_b] + + p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service) + p1.start() + context._cleanup_handlers.append(p1.stop) + + +@given("a mocked lifecycle service for plan-cov that returns a read-only plan") +def step_mock_lifecycle_apply_readonly(context: Context) -> None: + """Set up lifecycle service that returns a read-only plan.""" + mock_service = MagicMock() + ro_plan = _make_lifecycle_plan( + plan_id=_ULID_A, + phase=PlanPhase.EXECUTE, + processing_state=ProcessingState.COMPLETE, + read_only=True, + ) + mock_service.get_plan.return_value = ro_plan + + p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service) + p1.start() + context._cleanup_handlers.append(p1.stop) + + +# ---------------------------------------------------------------------- +# Given — Lifecycle service for status command error +# ---------------------------------------------------------------------- + + +@given("a mocked lifecycle service for plan-cov status that raises CleverAgentsError") +def step_mock_lifecycle_status_error(context: Context) -> None: + """Set up lifecycle service that raises CleverAgentsError on get_plan.""" + mock_service = MagicMock() + mock_service.get_plan.side_effect = CleverAgentsError("service unavailable") + + p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service) + p1.start() + context._cleanup_handlers.append(p1.stop) + + +# ---------------------------------------------------------------------- +# Given — Lifecycle service for errors command +# ---------------------------------------------------------------------- + + +@given("a mocked lifecycle service for plan-cov errors with no error_category") +def step_mock_lifecycle_errors_no_category(context: Context) -> None: + """Set up lifecycle service returning a plan with error_details lacking error_category.""" + mock_service = MagicMock() + plan = _make_lifecycle_plan( + plan_id=_ULID_A, + phase=PlanPhase.EXECUTE, + processing_state=ProcessingState.ERRORED, + error_message="Something went wrong", + error_details={"error_phase": "execute"}, + ) + mock_service.get_plan.return_value = plan + + p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service) + p1.start() + context._cleanup_handlers.append(p1.stop) + + +@given("a mocked lifecycle service for plan-cov errors with empty details") +def step_mock_lifecycle_errors_empty_details(context: Context) -> None: + """Set up lifecycle service returning a plan with no error_details or error_message.""" + mock_service = MagicMock() + plan = _make_lifecycle_plan( + plan_id=_ULID_A, + phase=PlanPhase.EXECUTE, + processing_state=ProcessingState.QUEUED, + error_message=None, + error_details=None, + ) + mock_service.get_plan.return_value = plan + + p1 = patch(_PATCH_GET_LIFECYCLE, return_value=mock_service) + p1.start() + context._cleanup_handlers.append(p1.stop) + + +# ---------------------------------------------------------------------- +# When — Legacy apply +# ---------------------------------------------------------------------- + + +@when("I invoke plan-cov legacy apply with --yes") +def step_invoke_legacy_apply(context: Context) -> None: + """Invoke the legacy apply command with auto-confirm.""" + mock_container = MagicMock() + mock_plan_service = MagicMock() + mock_plan_service.get_pending_changes.return_value = [ + SimpleNamespace(operation="modify", file_path="a.py"), + ] + if context.plan_cov_apply_changes_side_effect: + mock_plan_service.apply_changes.side_effect = ( + context.plan_cov_apply_changes_side_effect + ) + else: + mock_plan_service.apply_changes.return_value = ( + context.plan_cov_apply_changes_return + ) + mock_container.plan_service.return_value = mock_plan_service + + project = _make_legacy_project() + + with ( + patch(_PATCH_CONTAINER, return_value=mock_container), + patch(_PATCH_GET_PROJECT, return_value=project), + ): + context.plan_cov_result = context.plan_cov_runner.invoke( + plan_app, ["apply", "--yes"] + ) + + +# ---------------------------------------------------------------------- +# When — Legacy list +# ---------------------------------------------------------------------- + + +@when("I invoke plan-cov legacy list") +def step_invoke_legacy_list(context: Context) -> None: + """Invoke the legacy list command in rich format.""" + mock_container = MagicMock() + mock_plan_service = MagicMock() + mock_plan_service.list_plans.return_value = context.plan_cov_list_plans + mock_container.plan_service.return_value = mock_plan_service + + project = _make_legacy_project() + + with ( + patch(_PATCH_CONTAINER, return_value=mock_container), + patch(_PATCH_GET_PROJECT, return_value=project), + ): + context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["list"]) + + +# ---------------------------------------------------------------------- +# When — Legacy cd +# ---------------------------------------------------------------------- + + +@when('I invoke plan-cov legacy cd "{name}"') +def step_invoke_legacy_cd(context: Context, name: str) -> None: + """Invoke the legacy cd command with a plan name.""" + mock_container = MagicMock() + mock_plan_service = MagicMock() + if context.plan_cov_cd_side_effect: + mock_plan_service.switch_to_plan.side_effect = context.plan_cov_cd_side_effect + else: + mock_plan_service.switch_to_plan.return_value = context.plan_cov_cd_return + mock_container.plan_service.return_value = mock_plan_service + + project = _make_legacy_project() + + with ( + patch(_PATCH_CONTAINER, return_value=mock_container), + patch(_PATCH_GET_PROJECT, return_value=project), + ): + context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["cd", name]) + + +# ---------------------------------------------------------------------- +# When — use action with actor overrides +# ---------------------------------------------------------------------- + + +@when('I invoke plan-cov use with estimation-actor "{est}" and invariant-actor "{inv}"') +def step_invoke_use_actor_overrides(context: Context, est: str, inv: str) -> None: + """Invoke use command with estimation-actor and invariant-actor.""" + context.plan_cov_result = context.plan_cov_runner.invoke( + plan_app, + [ + "use", + "local/test-action", + "--project", + "proj-1", + "--estimation-actor", + est, + "--invariant-actor", + inv, + ], + ) + + +@when('I invoke plan-cov use with invariant "{text}"') +def step_invoke_use_with_invariant(context: Context, text: str) -> None: + """Invoke use command with --invariant flag.""" + context.plan_cov_result = context.plan_cov_runner.invoke( + plan_app, + [ + "use", + "local/test-action", + "--project", + "proj-1", + "--invariant", + text, + ], + ) + + +# ---------------------------------------------------------------------- +# When — lifecycle-apply +# ---------------------------------------------------------------------- + + +@when("I invoke plan-cov lifecycle-apply without plan_id") +def step_invoke_lifecycle_apply_no_id(context: Context) -> None: + """Invoke lifecycle-apply without specifying a plan ID.""" + context.plan_cov_result = context.plan_cov_runner.invoke( + plan_app, ["lifecycle-apply"] + ) + + +@when('I invoke plan-cov lifecycle-apply with plan_id "{pid}"') +def step_invoke_lifecycle_apply_with_id(context: Context, pid: str) -> None: + """Invoke lifecycle-apply with an explicit plan ID.""" + context.plan_cov_result = context.plan_cov_runner.invoke( + plan_app, ["lifecycle-apply", pid] + ) + + +# ---------------------------------------------------------------------- +# When — status +# ---------------------------------------------------------------------- + + +@when('I invoke plan-cov status "{pid}"') +def step_invoke_status(context: Context, pid: str) -> None: + """Invoke the status command with a plan ID.""" + context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["status", pid]) + + +# ---------------------------------------------------------------------- +# When — errors +# ---------------------------------------------------------------------- + + +@when('I invoke plan-cov errors "{pid}" with format "{fmt}"') +def step_invoke_errors_json(context: Context, pid: str, fmt: str) -> None: + """Invoke the errors command with a given format.""" + context.plan_cov_result = context.plan_cov_runner.invoke( + plan_app, ["errors", pid, "--format", fmt] + ) + + +@when('I invoke plan-cov errors "{pid}"') +def step_invoke_errors_rich(context: Context, pid: str) -> None: + """Invoke the errors command in default rich format.""" + context.plan_cov_result = context.plan_cov_runner.invoke(plan_app, ["errors", pid]) + + +# ---------------------------------------------------------------------- +# Then — result assertions +# ---------------------------------------------------------------------- + + +@then("the plan-cov command should exit normally") +def step_assert_exit_ok(context: Context) -> None: + """Assert the CLI exited with code 0.""" + assert context.plan_cov_result.exit_code == 0, ( + f"Expected exit_code=0, got {context.plan_cov_result.exit_code}. " + f"Output: {context.plan_cov_result.output}" + ) + + +@then("the plan-cov command should abort") +def step_assert_abort(context: Context) -> None: + """Assert the CLI exited with a non-zero code (abort).""" + assert context.plan_cov_result.exit_code != 0, ( + f"Expected non-zero exit_code, got {context.plan_cov_result.exit_code}. " + f"Output: {context.plan_cov_result.output}" + ) + + +@then('the plan-cov output should contain "{text}"') +def step_assert_output_contains(context: Context, text: str) -> None: + """Assert the CLI output contains the expected text.""" + assert text in context.plan_cov_result.output, ( + f"Expected output to contain '{text}'. " + f"Actual output: {context.plan_cov_result.output}" + ) + + +@then("the plan-cov created plan should have estimation_actor set") +def step_assert_estimation_actor(context: Context) -> None: + """Assert the plan was updated with estimation_actor.""" + plan = context.plan_cov_plan + assert plan.estimation_actor == "openai/gpt-4", ( + f"Expected estimation_actor='openai/gpt-4', got {plan.estimation_actor}" + ) diff --git a/features/steps/plan_executor_coverage_steps.py b/features/steps/plan_executor_coverage_steps.py index 43d998a7..2b05f545 100644 --- a/features/steps/plan_executor_coverage_steps.py +++ b/features/steps/plan_executor_coverage_steps.py @@ -1,12 +1,15 @@ -"""Step definitions for plan_executor.py coverage tests. +"""Step definitions for plan_executor_coverage.feature. -Targets uncovered lines: 144, 233-235, 273, 483, 490. +Comprehensive coverage of plan_executor.py including _parse_steps +branches, ExecuteStubActor with tool_runner/sandbox/stream/read_only, +PlanExecutor runtime execution path, error recovery retry logic, +and result model edge cases. """ from __future__ import annotations from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from behave import given, then, when from behave.runner import Context @@ -28,26 +31,32 @@ from cleveragents.domain.models.core.plan import ( ProcessingState, ) -# --------------------------------------------------------------------------- +# ---------------------------------------------------------------------- +# Constants +# ---------------------------------------------------------------------- + +COV2_PLAN_ID = "01KCOV2PLANID000000000PLAN" +COV2_ROOT_ID = "01KCOV2ROOTID000000000ROOT" + + +# ---------------------------------------------------------------------- # Helpers -# --------------------------------------------------------------------------- - -PLAN_ID = "01HABCDE12345678901234PLAN" +# ---------------------------------------------------------------------- -def _make_mock_plan( +def _cov2_make_plan( *, phase: PlanPhase = PlanPhase.STRATEGIZE, state: ProcessingState = ProcessingState.QUEUED, definition_of_done: str | None = "Step one\nStep two", - decision_root_id: str | None = "01HROOT000000000000000ROOT", + decision_root_id: str | None = COV2_ROOT_ID, invariants: list[PlanInvariant] | None = None, + read_only: bool = False, ) -> MagicMock: """Build a mock plan object with sensible defaults.""" plan = MagicMock() plan.phase = phase plan.state = state - plan.processing_state = state plan.definition_of_done = definition_of_done plan.decision_root_id = decision_root_id plan.invariants = invariants or [] @@ -55,11 +64,12 @@ def _make_mock_plan( plan.changeset_id = None plan.sandbox_refs = [] plan.error_details = None + plan.read_only = read_only return plan -def _make_lifecycle(plan: Any | None = None) -> MagicMock: - """Build a mock lifecycle service returning *plan* on get_plan.""" +def _cov2_make_lifecycle(plan: Any | None = None) -> MagicMock: + """Build a mock lifecycle service.""" lcs = MagicMock() if plan is not None: lcs.get_plan.return_value = plan @@ -73,540 +83,1006 @@ def _make_lifecycle(plan: Any | None = None) -> MagicMock: return lcs -# --------------------------------------------------------------------------- -# StrategizeStubActor._parse_steps (line 144) -# --------------------------------------------------------------------------- +def _cov2_make_decisions(count: int = 2) -> list[StrategyDecision]: + """Build a list of StrategyDecision instances.""" + root_id = "01KCOV2DEC000000000000DEC0" + decisions: list[StrategyDecision] = [] + for i in range(count): + decisions.append( + StrategyDecision( + decision_id=root_id if i == 0 else f"01KCOV2DEC00000000000{i:05d}", + step_text=f"Step {i + 1}", + sequence=i, + parent_id=root_id if i > 0 else None, + ) + ) + return decisions -@given("a StrategizeStubActor instance for coverage") -def step_given_strategize_stub(context: Context) -> None: - context.strategize_actor = StrategizeStubActor() +# ---------------------------------------------------------------------- +# StrategizeStubActor._parse_steps +# ---------------------------------------------------------------------- -@when("I call _parse_steps with an empty string") -def step_parse_steps_empty(context: Context) -> None: - context.parsed_steps = context.strategize_actor._parse_steps("") +@given("a fresh StrategizeStubActor for cov2") +def step_cov2_given_strategize_actor(context: Context) -> None: + """Create a fresh StrategizeStubActor.""" + context.cov2_actor = StrategizeStubActor() -@when("I call _parse_steps with whitespace only") -def step_parse_steps_whitespace(context: Context) -> None: - context.parsed_steps = context.strategize_actor._parse_steps(" \n\t ") +@when('I cov2 parse steps from "{text}"') +def step_cov2_parse_steps(context: Context, text: str) -> None: + """Parse steps from the given text.""" + raw = text.encode("utf-8").decode("unicode_escape") + context.cov2_parsed = StrategizeStubActor._parse_steps(raw) -@then("the parsed steps should be the default fallback") -def step_check_default_steps(context: Context) -> None: - assert context.parsed_steps == ["Complete the plan objectives"], ( - f"Expected default fallback, got {context.parsed_steps}" +@when("I cov2 parse steps from an empty string") +def step_cov2_parse_steps_empty(context: Context) -> None: + """Parse steps from an empty string.""" + context.cov2_parsed = StrategizeStubActor._parse_steps("") + + +@then('the cov2 parsed steps count should be "{n}"') +def step_cov2_check_parsed_count(context: Context, n: str) -> None: + """Verify parsed steps list has expected count.""" + assert len(context.cov2_parsed) == int(n), ( + f"Expected {n} steps, got {len(context.cov2_parsed)}: {context.cov2_parsed}" ) -# --------------------------------------------------------------------------- -# StrategizeStubActor.execute happy paths -# --------------------------------------------------------------------------- +@then('the cov2 parsed step at index "{idx}" should be "{expected}"') +def step_cov2_check_parsed_at_index(context: Context, idx: str, expected: str) -> None: + """Verify a specific parsed step by index.""" + actual = context.cov2_parsed[int(idx)] + assert actual == expected, f"Expected '{expected}' at index {idx}, got '{actual}'" -@when("I execute the strategize stub with a valid plan and definition of done") -def step_exec_strategize_valid(context: Context) -> None: - context.strategize_result = context.strategize_actor.execute( - plan_id=PLAN_ID, - definition_of_done="First step\nSecond step", +@then("the cov2 parsed steps should be the default objective") +def step_cov2_check_parsed_default(context: Context) -> None: + """Verify parsed steps are the default fallback.""" + assert context.cov2_parsed == ["Complete the plan objectives"], ( + f"Expected default, got {context.cov2_parsed}" ) -@then("the strategize result should contain decisions matching the steps") -def step_check_strategize_decisions(context: Context) -> None: - result: StrategizeResult = context.strategize_result - assert len(result.decisions) == 2 - assert result.decisions[0].step_text == "First step" - assert result.decisions[1].step_text == "Second step" - assert result.decision_root_id == result.decisions[0].decision_id +# ---------------------------------------------------------------------- +# StrategizeStubActor.execute +# ---------------------------------------------------------------------- -@when("I execute the strategize stub with a None definition of done") -def step_exec_strategize_none_dod(context: Context) -> None: - context.strategize_result = context.strategize_actor.execute( - plan_id=PLAN_ID, - definition_of_done=None, - ) - - -@then("the strategize result should contain the default decision") -def step_check_default_decision(context: Context) -> None: - result: StrategizeResult = context.strategize_result - assert len(result.decisions) == 1 - assert result.decisions[0].step_text == "Complete the plan objectives" - - -@when("I execute the strategize stub with an empty plan_id") -def step_exec_strategize_empty_plan_id(context: Context) -> None: +@when('I cov2 execute strategize with plan_id "{pid}" and definition "{defn}"') +def step_cov2_exec_strategize(context: Context, pid: str, defn: str) -> None: + """Execute strategize with given plan_id and definition.""" + raw_defn = defn.replace("\\n", "\n") try: - context.strategize_actor.execute(plan_id="", definition_of_done="x") - context.raised_exception = None + context.cov2_strat_result = context.cov2_actor.execute( + plan_id=pid, definition_of_done=raw_defn + ) + context.cov2_raised = None except Exception as exc: - context.raised_exception = exc + context.cov2_raised = exc -@then('a plan executor ValidationError should be raised containing "{text}"') -def step_check_validation_error(context: Context, text: str) -> None: - assert context.raised_exception is not None, ( - "Expected an exception but none was raised" - ) - assert isinstance(context.raised_exception, ValidationError), ( - f"Expected ValidationError, got {type(context.raised_exception).__name__}" - ) - assert text in str(context.raised_exception), ( - f"Expected '{text}' in '{context.raised_exception}'" +@when("I cov2 execute strategize with an empty plan_id") +def step_cov2_exec_strategize_empty_pid(context: Context) -> None: + """Execute strategize with empty plan_id.""" + try: + context.cov2_actor.execute(plan_id="", definition_of_done="x") + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc + + +@when('I cov2 execute strategize with plan_id "{pid}" and no definition') +def step_cov2_exec_strategize_no_defn(context: Context, pid: str) -> None: + """Execute strategize with None definition_of_done.""" + context.cov2_strat_result = context.cov2_actor.execute( + plan_id=pid, definition_of_done=None ) + context.cov2_raised = None -@when("I execute the strategize stub with invariants") -def step_exec_strategize_with_invariants(context: Context) -> None: +@when( + 'I cov2 execute strategize with plan_id "{pid}" and definition "{defn}" without callback' +) +def step_cov2_exec_strategize_no_cb(context: Context, pid: str, defn: str) -> None: + """Execute strategize without a stream callback.""" + raw_defn = defn.replace("\\n", "\n") + context.cov2_strat_result = context.cov2_actor.execute( + plan_id=pid, definition_of_done=raw_defn, stream_callback=None + ) + context.cov2_raised = None + + +@when("I cov2 execute strategize with invariants") +def step_cov2_exec_strategize_invariants(context: Context) -> None: + """Execute strategize with invariant records.""" invariants = [ PlanInvariant(text="Must be safe", source=InvariantSource.PLAN), PlanInvariant(text="Must be fast", source=InvariantSource.PROJECT), ] - context.strategize_result = context.strategize_actor.execute( - plan_id=PLAN_ID, - definition_of_done="Do it", - invariants=invariants, + context.cov2_strat_result = context.cov2_actor.execute( + plan_id=COV2_PLAN_ID, definition_of_done="Do it", invariants=invariants ) + context.cov2_raised = None -@then("the strategize result should contain invariant records") -def step_check_invariant_records(context: Context) -> None: - result: StrategizeResult = context.strategize_result - assert len(result.invariant_records) == 2 - assert result.invariant_records[0]["text"] == "Must be safe" - assert result.invariant_records[1]["source"] == "project" +@given("a cov2 stream event collector") +def step_cov2_given_stream_collector(context: Context) -> None: + """Create a stream event collector list.""" + context.cov2_stream_events = [] -@when("I execute the strategize stub with a stream callback") -def step_exec_strategize_with_callback(context: Context) -> None: - context.stream_events = [] +@when("I cov2 execute strategize with stream callback") +def step_cov2_exec_strategize_with_cb(context: Context) -> None: + """Execute strategize with a stream callback.""" - def _cb(event: str, data: dict) -> None: - context.stream_events.append((event, data)) + def _cb(event: str, data: dict[str, Any]) -> None: + context.cov2_stream_events.append((event, data)) - context.strategize_result = context.strategize_actor.execute( - plan_id=PLAN_ID, + context.cov2_strat_result = context.cov2_actor.execute( + plan_id=COV2_PLAN_ID, definition_of_done="A step", stream_callback=_cb, ) + context.cov2_raised = None -@then("the stream callback should have been called with strategize events") -def step_check_stream_events(context: Context) -> None: - event_names = [e[0] for e in context.stream_events] - assert "strategize_started" in event_names - assert "strategize_decisions" in event_names - assert "strategize_complete" in event_names - - -# --------------------------------------------------------------------------- -# ExecuteStubActor -# --------------------------------------------------------------------------- - - -@given("an ExecuteStubActor instance for coverage") -def step_given_execute_stub(context: Context) -> None: - context.execute_actor = ExecuteStubActor() - - -@when("I execute the execute stub with decisions") -def step_exec_execute_stub(context: Context) -> None: - decisions = [ - StrategyDecision( - decision_id="01HDEC0000000000000000DEC0", - step_text="Do the thing", - sequence=0, - ), - ] - context.execute_result = context.execute_actor.execute( - plan_id=PLAN_ID, - decisions=decisions, +@then('a cov2 ValidationError should be raised with message containing "{text}"') +def step_cov2_check_validation_error(context: Context, text: str) -> None: + """Verify a ValidationError was raised containing the expected text.""" + assert context.cov2_raised is not None, "Expected an exception but none was raised" + assert isinstance(context.cov2_raised, ValidationError), ( + f"Expected ValidationError, got {type(context.cov2_raised).__name__}" + ) + assert text in str(context.cov2_raised), ( + f"Expected '{text}' in '{context.cov2_raised}'" ) -@then("the execute result should contain a changeset id") -def step_check_execute_changeset(context: Context) -> None: - result: ExecuteResult = context.execute_result - assert result.changeset_id is not None - assert len(result.changeset_id) > 0 +@then('the cov2 strategize result should have "{n}" decisions') +def step_cov2_check_decision_count(context: Context, n: str) -> None: + """Verify the strategize result decision count.""" + assert len(context.cov2_strat_result.decisions) == int(n), ( + f"Expected {n} decisions, got {len(context.cov2_strat_result.decisions)}" + ) -@when("I execute the execute stub with an empty plan_id") -def step_exec_execute_stub_empty_plan_id(context: Context) -> None: +@then('the cov2 first decision text should be "{text}"') +def step_cov2_check_first_decision_text(context: Context, text: str) -> None: + """Verify the first decision step_text.""" + actual = context.cov2_strat_result.decisions[0].step_text + assert actual == text, f"Expected '{text}', got '{actual}'" + + +@then("the cov2 root decision should have no parent") +def step_cov2_check_root_no_parent(context: Context) -> None: + """Verify the root decision has no parent_id.""" + root = context.cov2_strat_result.decisions[0] + assert root.parent_id is None, f"Expected None parent, got {root.parent_id}" + + +@then("the cov2 non-root decisions should reference root as parent") +def step_cov2_check_children_parent(context: Context) -> None: + """Verify non-root decisions reference the root decision id.""" + root_id = context.cov2_strat_result.decision_root_id + for d in context.cov2_strat_result.decisions[1:]: + assert d.parent_id == root_id, ( + f"Decision {d.decision_id} parent={d.parent_id}, expected {root_id}" + ) + + +@then('the cov2 strategize result should have "{n}" invariant records') +def step_cov2_check_invariant_count(context: Context, n: str) -> None: + """Verify the number of invariant records.""" + assert len(context.cov2_strat_result.invariant_records) == int(n), ( + f"Expected {n} invariant records, got {len(context.cov2_strat_result.invariant_records)}" + ) + + +@then("each cov2 invariant record should have enforced set to true") +def step_cov2_check_invariant_enforced(context: Context) -> None: + """Verify each invariant record is enforced.""" + for rec in context.cov2_strat_result.invariant_records: + assert rec["enforced"] is True, f"Expected enforced=True, got {rec}" + + +@then('the cov2 stream events should include "{event_name}"') +def step_cov2_check_stream_event(context: Context, event_name: str) -> None: + """Verify a specific event was emitted to the stream collector.""" + names = [e[0] for e in context.cov2_stream_events] + assert event_name in names, f"Expected '{event_name}' in {names}" + + +# ---------------------------------------------------------------------- +# ExecuteStubActor +# ---------------------------------------------------------------------- + + +@given("a fresh ExecuteStubActor for cov2") +def step_cov2_given_execute_actor(context: Context) -> None: + """Create a fresh ExecuteStubActor.""" + context.cov2_exec_actor = ExecuteStubActor() + + +@when("I cov2 execute execute-stub with empty plan_id") +def step_cov2_exec_execute_empty_pid(context: Context) -> None: + """Execute the execute stub with an empty plan_id.""" try: - context.execute_actor.execute(plan_id="", decisions=[]) - context.raised_exception = None + context.cov2_exec_actor.execute(plan_id="", decisions=[]) + context.cov2_raised = None except Exception as exc: - context.raised_exception = exc + context.cov2_raised = exc -# --------------------------------------------------------------------------- -# PlanExecutor construction -# --------------------------------------------------------------------------- +@given("a cov2 mock tool runner that discovers 3 tools") +def step_cov2_given_tool_runner(context: Context) -> None: + """Create a mock ToolRunner whose discover() returns 3 items.""" + runner = MagicMock() + runner.discover.return_value = [MagicMock(), MagicMock(), MagicMock()] + context.cov2_tool_runner = runner -@when("I construct a PlanExecutor with None lifecycle service") -def step_construct_none_lifecycle(context: Context) -> None: +@when("I cov2 execute execute-stub with tool_runner and 2 decisions") +def step_cov2_exec_with_tool_runner(context: Context) -> None: + """Execute the execute stub with a tool_runner and 2 decisions.""" + decisions = _cov2_make_decisions(2) + context.cov2_exec_result = context.cov2_exec_actor.execute( + plan_id=COV2_PLAN_ID, + decisions=decisions, + tool_runner=context.cov2_tool_runner, + ) + context.cov2_raised = None + + +@then('the cov2 execute result tool_calls_count should be "{n}"') +def step_cov2_check_tool_calls(context: Context, n: str) -> None: + """Verify the tool_calls_count on the execute result.""" + assert context.cov2_exec_result.tool_calls_count == int(n), ( + f"Expected {n}, got {context.cov2_exec_result.tool_calls_count}" + ) + + +@when('I cov2 execute execute-stub with sandbox_root "{path}"') +def step_cov2_exec_with_sandbox(context: Context, path: str) -> None: + """Execute the execute stub with a sandbox_root.""" + decisions = _cov2_make_decisions(1) + context.cov2_exec_result = context.cov2_exec_actor.execute( + plan_id=COV2_PLAN_ID, decisions=decisions, sandbox_root=path + ) + context.cov2_raised = None + + +@then('the cov2 execute result sandbox_refs should contain "{path}"') +def step_cov2_check_sandbox_refs(context: Context, path: str) -> None: + """Verify sandbox_refs contains the expected path.""" + assert path in context.cov2_exec_result.sandbox_refs, ( + f"Expected '{path}' in {context.cov2_exec_result.sandbox_refs}" + ) + + +@when("I cov2 execute execute-stub without sandbox_root") +def step_cov2_exec_no_sandbox(context: Context) -> None: + """Execute the execute stub without sandbox_root.""" + decisions = _cov2_make_decisions(1) + context.cov2_exec_result = context.cov2_exec_actor.execute( + plan_id=COV2_PLAN_ID, decisions=decisions, sandbox_root=None + ) + context.cov2_raised = None + + +@then("the cov2 execute result sandbox_refs should be empty") +def step_cov2_check_sandbox_empty(context: Context) -> None: + """Verify sandbox_refs is empty.""" + assert context.cov2_exec_result.sandbox_refs == [], ( + f"Expected empty, got {context.cov2_exec_result.sandbox_refs}" + ) + + +@when("I cov2 execute execute-stub with 2 decisions and stream callback") +def step_cov2_exec_with_stream(context: Context) -> None: + """Execute the execute stub with stream callback and 2 decisions.""" + decisions = _cov2_make_decisions(2) + + def _cb(event: str, data: dict[str, Any]) -> None: + context.cov2_stream_events.append((event, data)) + + context.cov2_exec_result = context.cov2_exec_actor.execute( + plan_id=COV2_PLAN_ID, decisions=decisions, stream_callback=_cb + ) + context.cov2_raised = None + + +@when("I cov2 execute execute-stub with read_only true") +def step_cov2_exec_read_only(context: Context) -> None: + """Execute the execute stub with read_only=True.""" + decisions = _cov2_make_decisions(1) + context.cov2_exec_result = context.cov2_exec_actor.execute( + plan_id=COV2_PLAN_ID, decisions=decisions, read_only=True + ) + context.cov2_raised = None + + +@then("the cov2 execute result should have a valid changeset_id") +def step_cov2_check_exec_changeset_id(context: Context) -> None: + """Verify the execute result has a non-empty changeset_id.""" + assert context.cov2_exec_result.changeset_id, ( + f"Expected non-empty changeset_id, got '{context.cov2_exec_result.changeset_id}'" + ) + + +# ---------------------------------------------------------------------- +# PlanExecutor.__init__ +# ---------------------------------------------------------------------- + + +@when("I cov2 construct PlanExecutor with None lifecycle") +def step_cov2_construct_none_lifecycle(context: Context) -> None: + """Attempt to construct PlanExecutor with None lifecycle.""" try: PlanExecutor(lifecycle_service=None) - context.raised_exception = None + context.cov2_raised = None except Exception as exc: - context.raised_exception = exc + context.cov2_raised = exc -@given("a fresh mock lifecycle for plan executor coverage") -def step_given_fresh_mock_lifecycle(context: Context) -> None: - context.lifecycle = _make_lifecycle() +@given("a cov2 mock lifecycle service") +def step_cov2_given_lifecycle(context: Context) -> None: + """Create a mock lifecycle service.""" + context.cov2_lifecycle = _cov2_make_lifecycle() + context.cov2_plan_id = COV2_PLAN_ID -@when("I construct a PlanExecutor without execution context") -def step_construct_no_ctx(context: Context) -> None: - context.executor = PlanExecutor( - lifecycle_service=context.lifecycle, +@when("I cov2 construct PlanExecutor with that lifecycle") +def step_cov2_construct_with_lifecycle(context: Context) -> None: + """Construct PlanExecutor with the mock lifecycle.""" + try: + context.cov2_executor = PlanExecutor(lifecycle_service=context.cov2_lifecycle) + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc + + +@then("the cov2 PlanExecutor should be initialised without error") +def step_cov2_check_init_ok(context: Context) -> None: + """Verify no error on construction.""" + assert context.cov2_raised is None, f"Unexpected error: {context.cov2_raised}" + assert context.cov2_executor is not None + + +# ---------------------------------------------------------------------- +# PlanExecutor properties +# ---------------------------------------------------------------------- + + +@given("a cov2 PlanExecutor without execution context") +def step_cov2_given_executor_no_ctx(context: Context) -> None: + """Create a PlanExecutor without execution context.""" + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, execution_context=None + ) + + +@given("a cov2 mock execution context") +def step_cov2_given_exec_ctx(context: Context) -> None: + """Create a mock PlanExecutionContext.""" + from cleveragents.application.services.plan_execution_context import ( + PlanExecutionContext, + ) + + context.cov2_exec_ctx = PlanExecutionContext(plan_id=COV2_PLAN_ID) + + +@given("a cov2 PlanExecutor with execution context") +def step_cov2_given_executor_with_ctx(context: Context) -> None: + """Create a PlanExecutor with execution context.""" + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=context.cov2_exec_ctx, + ) + + +@given("a cov2 mock execution context with changeset store") +def step_cov2_given_exec_ctx_store(context: Context) -> None: + """Create a mock execution context with a changeset store.""" + context.cov2_mock_store = MagicMock() + context.cov2_exec_ctx = MagicMock() + context.cov2_exec_ctx.changeset_store = context.cov2_mock_store + context.cov2_exec_ctx.decision_root_id = None + + +@given("a cov2 PlanExecutor with that execution context") +def step_cov2_given_executor_with_mock_ctx(context: Context) -> None: + """Create a PlanExecutor with the mock execution context.""" + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=context.cov2_exec_ctx, + ) + + +@then('the cov2 executor has_runtime should be "{val}"') +def step_cov2_check_has_runtime(context: Context, val: str) -> None: + """Verify has_runtime property.""" + expected = val == "True" + assert context.cov2_executor.has_runtime is expected, ( + f"Expected has_runtime={expected}, got {context.cov2_executor.has_runtime}" + ) + + +@then("the cov2 executor changeset_store should be None") +def step_cov2_check_changeset_store_none(context: Context) -> None: + """Verify changeset_store is None.""" + assert context.cov2_executor.changeset_store is None + + +@then("the cov2 executor changeset_store should be the mock store") +def step_cov2_check_changeset_store_mock(context: Context) -> None: + """Verify changeset_store is the mock store.""" + assert context.cov2_executor.changeset_store is context.cov2_mock_store + + +@then("the cov2 executor execution_context should be None") +def step_cov2_check_exec_ctx_none(context: Context) -> None: + """Verify execution_context is None.""" + assert context.cov2_executor.execution_context is None + + +@then("the cov2 executor execution_context should not be None") +def step_cov2_check_exec_ctx_not_none(context: Context) -> None: + """Verify execution_context is not None.""" + assert context.cov2_executor.execution_context is not None + + +# ---------------------------------------------------------------------- +# PlanExecutor.run_strategize - guards and happy path +# ---------------------------------------------------------------------- + + +@when("I cov2 call run_strategize with empty plan_id") +def step_cov2_run_strategize_empty(context: Context) -> None: + """Call run_strategize with empty plan_id.""" + try: + context.cov2_executor.run_strategize("") + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc + + +@given("a cov2 plan in Execute phase") +def step_cov2_given_plan_execute(context: Context) -> None: + """Set up a plan in Execute phase.""" + plan = _cov2_make_plan(phase=PlanPhase.EXECUTE) + context.cov2_lifecycle.get_plan.return_value = plan + context.cov2_mock_plan = plan + + +@given('a cov2 plan in Strategize phase with definition "{defn}"') +def step_cov2_given_plan_strategize(context: Context, defn: str) -> None: + """Set up a plan in Strategize phase with given definition.""" + raw = defn.replace("\\n", "\n") + plan = _cov2_make_plan( + phase=PlanPhase.STRATEGIZE, + definition_of_done=raw, + ) + context.cov2_lifecycle.get_plan.return_value = plan + context.cov2_mock_plan = plan + + +@when("I cov2 call run_strategize") +def step_cov2_run_strategize(context: Context) -> None: + """Call run_strategize.""" + try: + context.cov2_strat_run_result = context.cov2_executor.run_strategize( + context.cov2_plan_id + ) + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc + + +@then('a cov2 PlanError should be raised containing "{text}"') +def step_cov2_check_plan_error(context: Context, text: str) -> None: + """Verify a PlanError was raised containing the expected text.""" + assert context.cov2_raised is not None, "Expected PlanError but none was raised" + assert isinstance(context.cov2_raised, PlanError), ( + f"Expected PlanError, got {type(context.cov2_raised).__name__}: {context.cov2_raised}" + ) + assert text in str(context.cov2_raised), ( + f"Expected '{text}' in '{context.cov2_raised}'" + ) + + +@then("the cov2 strategize run result should be a StrategizeResult") +def step_cov2_check_strat_result_type(context: Context) -> None: + """Verify the result is a StrategizeResult.""" + assert isinstance(context.cov2_strat_run_result, StrategizeResult), ( + f"Expected StrategizeResult, got {type(context.cov2_strat_run_result).__name__}" + ) + + +@then("the cov2 lifecycle should have called start_strategize") +def step_cov2_check_start_strategize(context: Context) -> None: + """Verify start_strategize was called.""" + context.cov2_lifecycle.start_strategize.assert_called_once_with( + context.cov2_plan_id + ) + + +@then("the cov2 lifecycle should have called complete_strategize") +def step_cov2_check_complete_strategize(context: Context) -> None: + """Verify complete_strategize was called.""" + context.cov2_lifecycle.complete_strategize.assert_called_once_with( + context.cov2_plan_id + ) + + +@then("the cov2 lifecycle should have called _commit_plan") +def step_cov2_check_commit_plan(context: Context) -> None: + """Verify _commit_plan was called.""" + assert context.cov2_lifecycle._commit_plan.called + + +@then("the cov2 execution context decision_root_id should be set") +def step_cov2_check_ctx_root_id(context: Context) -> None: + """Verify the execution context decision_root_id was set.""" + assert context.cov2_raised is None, f"Unexpected error: {context.cov2_raised}" + assert context.cov2_exec_ctx.decision_root_id is not None, ( + "Expected decision_root_id to be set" + ) + + +# ---------------------------------------------------------------------- +# PlanExecutor.run_strategize - exception path +# ---------------------------------------------------------------------- + + +@given("a cov2 PlanExecutor with failing strategize actor") +def step_cov2_given_executor_failing_strat(context: Context) -> None: + """Create a PlanExecutor whose strategize actor raises.""" + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, execution_context=None + ) + context.cov2_executor._strategize_actor = MagicMock() + context.cov2_executor._strategize_actor.execute.side_effect = RuntimeError( + "cov2 boom in strategize" + ) + + +@given("a cov2 mock error recovery service") +def step_cov2_given_error_recovery(context: Context) -> None: + """Create a mock error recovery service.""" + context.cov2_error_recovery = MagicMock() + context.cov2_error_recovery.max_retries = 1 + context.cov2_error_recovery.record_error = MagicMock() + context.cov2_error_recovery.should_retry = MagicMock(return_value=False) + + +@given("a cov2 PlanExecutor with error recovery and failing strategize actor") +def step_cov2_given_executor_recovery_failing_strat(context: Context) -> None: + """Create a PlanExecutor with error recovery and a failing strategize actor.""" + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, execution_context=None, + error_recovery_service=context.cov2_error_recovery, + ) + context.cov2_executor._strategize_actor = MagicMock() + context.cov2_executor._strategize_actor.execute.side_effect = RuntimeError( + "cov2 boom in strategize" ) -@then("plan executor has_runtime should be False") -def step_check_no_runtime(context: Context) -> None: - assert context.executor.has_runtime is False - - -@then("plan executor changeset_store should be None") -def step_check_no_changeset_store(context: Context) -> None: - assert context.executor.changeset_store is None - - -@then("plan executor execution_context should be None") -def step_check_no_execution_context(context: Context) -> None: - assert context.executor.execution_context is None - - -# --------------------------------------------------------------------------- -# PlanExecutor.run_strategize happy path -# --------------------------------------------------------------------------- - - -@given("a mock lifecycle with a plan in Strategize phase for executor coverage") -def step_given_lifecycle_strategize(context: Context) -> None: - plan = _make_mock_plan( - phase=PlanPhase.STRATEGIZE, - state=ProcessingState.QUEUED, - definition_of_done="Build the widget", - ) - context.lifecycle = _make_lifecycle(plan) - context.plan_id = PLAN_ID - - -@given("a PlanExecutor using that lifecycle for coverage") -def step_given_executor(context: Context) -> None: - context.executor = PlanExecutor(lifecycle_service=context.lifecycle) - - -@when("I call run_strategize on the PlanExecutor for coverage") -def step_run_strategize(context: Context) -> None: - context.strategize_result = context.executor.run_strategize(context.plan_id) - - -@then("the strategize result should be returned successfully") -def step_check_strategize_ok(context: Context) -> None: - assert isinstance(context.strategize_result, StrategizeResult) - assert len(context.strategize_result.decisions) > 0 - - -@then("the lifecycle should have called start_strategize for coverage") -def step_check_start_strategize(context: Context) -> None: - context.lifecycle.start_strategize.assert_called_once_with(context.plan_id) - - -@then("the lifecycle should have called complete_strategize for coverage") -def step_check_complete_strategize(context: Context) -> None: - context.lifecycle.complete_strategize.assert_called_once_with(context.plan_id) - - -# --------------------------------------------------------------------------- -# PlanExecutor.run_strategize exception path (line 273) -# --------------------------------------------------------------------------- - - -@given("a mock lifecycle with a plan in Strategize phase that will fail during execute") -def step_given_lifecycle_strategize_fail(context: Context) -> None: - plan = _make_mock_plan( - phase=PlanPhase.STRATEGIZE, - state=ProcessingState.QUEUED, - definition_of_done="Build the widget", - ) - context.lifecycle = _make_lifecycle(plan) - context.plan_id = PLAN_ID - - -@given("a PlanExecutor using that failing lifecycle for coverage") -def step_given_executor_failing(context: Context) -> None: - context.executor = PlanExecutor(lifecycle_service=context.lifecycle) - # Patch the internal strategize actor to raise - context.executor._strategize_actor = MagicMock() - context.executor._strategize_actor.execute.side_effect = RuntimeError( - "Boom in strategize" - ) - - -@when("I call run_strategize expecting an exception for coverage") -def step_run_strategize_fail(context: Context) -> None: +@when("I cov2 call run_strategize expecting exception") +def step_cov2_run_strategize_expect_exc(context: Context) -> None: + """Call run_strategize expecting an exception.""" try: - context.executor.run_strategize(context.plan_id) - context.raised_exception = None - except RuntimeError as exc: - context.raised_exception = exc + context.cov2_executor.run_strategize(context.cov2_plan_id) + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc -@then("the lifecycle should have called fail_strategize for coverage") -def step_check_fail_strategize(context: Context) -> None: - assert context.raised_exception is not None, "Expected an exception" - context.lifecycle.fail_strategize.assert_called_once() - call_args = context.lifecycle.fail_strategize.call_args - assert context.plan_id in call_args[0] - assert "RuntimeError" in call_args[0][1] +@then("a cov2 RuntimeError should have been raised") +def step_cov2_check_runtime_error(context: Context) -> None: + """Verify a RuntimeError was raised.""" + assert context.cov2_raised is not None, "Expected an exception" + assert isinstance(context.cov2_raised, RuntimeError), ( + f"Expected RuntimeError, got {type(context.cov2_raised).__name__}" + ) -# --------------------------------------------------------------------------- -# PlanExecutor.run_strategize wrong phase -# --------------------------------------------------------------------------- +@then("the cov2 lifecycle should have called fail_strategize") +def step_cov2_check_fail_strategize(context: Context) -> None: + """Verify fail_strategize was called.""" + context.cov2_lifecycle.fail_strategize.assert_called_once() + args = context.cov2_lifecycle.fail_strategize.call_args[0] + assert context.cov2_plan_id in args -@given("a mock lifecycle with a plan in Execute phase for executor coverage") -def step_given_lifecycle_execute_phase(context: Context) -> None: - plan = _make_mock_plan(phase=PlanPhase.EXECUTE) - context.lifecycle = _make_lifecycle(plan) - context.plan_id = PLAN_ID +@then("the cov2 plan error_details should contain exception_type") +def step_cov2_check_error_details(context: Context) -> None: + """Verify error_details was set with exception_type.""" + plan = context.cov2_lifecycle.get_plan.return_value + assert plan.error_details is not None + if isinstance(plan.error_details, dict): + assert "exception_type" in plan.error_details -@when("I call run_strategize expecting a PlanError for coverage") -def step_run_strategize_wrong_phase(context: Context) -> None: +@then("the cov2 error recovery should have recorded a strategize error") +def step_cov2_check_recovery_strategize(context: Context) -> None: + """Verify error_recovery.record_error was called for strategize.""" + context.cov2_error_recovery.record_error.assert_called_once() + call_kwargs = context.cov2_error_recovery.record_error.call_args + assert call_kwargs[1].get( + "phase", call_kwargs[0][1] if len(call_kwargs[0]) > 1 else None + ) == "strategize" or "strategize" in str(call_kwargs) + + +# ---------------------------------------------------------------------- +# PlanExecutor._guard_execute +# ---------------------------------------------------------------------- + + +@when("I cov2 call guard_execute") +def step_cov2_call_guard_execute(context: Context) -> None: + """Call _guard_execute.""" try: - context.executor.run_strategize(context.plan_id) - context.raised_exception = None - except PlanError as exc: - context.raised_exception = exc + context.cov2_executor._guard_execute(context.cov2_plan_id) + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc -@then("a PlanError should be raised about wrong phase for coverage") -def step_check_wrong_phase_error(context: Context) -> None: - assert context.raised_exception is not None, "Expected a PlanError" - assert isinstance(context.raised_exception, PlanError) - assert "not in Strategize phase" in str(context.raised_exception) +@given("a cov2 plan in Execute phase but Processing state") +def step_cov2_given_plan_exec_processing(context: Context) -> None: + """Set up a plan in Execute phase but Processing state.""" + plan = _cov2_make_plan(phase=PlanPhase.EXECUTE, state=ProcessingState.PROCESSING) + context.cov2_lifecycle.get_plan.return_value = plan + context.cov2_mock_plan = plan -# --------------------------------------------------------------------------- -# PlanExecutor._guard_execute: decision_root_id is None (lines 233-235) -# --------------------------------------------------------------------------- - - -@given("a mock lifecycle with an execute-phase plan missing decision_root_id") -def step_given_lifecycle_no_root(context: Context) -> None: - plan = _make_mock_plan( +@given("a cov2 plan in Execute-Queued state without decision root") +def step_cov2_given_plan_exec_no_root(context: Context) -> None: + """Set up a plan in Execute-Queued state without decision_root_id.""" + plan = _cov2_make_plan( phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED, decision_root_id=None, ) - context.lifecycle = _make_lifecycle(plan) - context.plan_id = PLAN_ID + context.cov2_lifecycle.get_plan.return_value = plan + context.cov2_mock_plan = plan -@when("I call guard_execute for the plan for coverage") -def step_call_guard_execute(context: Context) -> None: +# ---------------------------------------------------------------------- +# PlanExecutor.run_execute - routing and validation +# ---------------------------------------------------------------------- + + +@when("I cov2 call run_execute with empty plan_id") +def step_cov2_run_execute_empty(context: Context) -> None: + """Call run_execute with empty plan_id.""" try: - context.executor._guard_execute(context.plan_id) - context.raised_exception = None - except PlanError as exc: - context.raised_exception = exc + context.cov2_executor.run_execute("") + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc -@then("a PlanError should be raised about missing decision tree") -def step_check_no_decision_tree(context: Context) -> None: - assert context.raised_exception is not None, "Expected a PlanError" - assert isinstance(context.raised_exception, PlanError) - assert "no decision tree" in str(context.raised_exception) - - -@given("a mock lifecycle with a plan in Strategize phase for guard coverage") -def step_given_lifecycle_strategize_for_guard(context: Context) -> None: - plan = _make_mock_plan( - phase=PlanPhase.STRATEGIZE, - state=ProcessingState.QUEUED, - ) - context.lifecycle = _make_lifecycle(plan) - context.plan_id = PLAN_ID - - -@when("I call guard_execute expecting wrong phase for coverage") -def step_call_guard_execute_wrong_phase(context: Context) -> None: - try: - context.executor._guard_execute(context.plan_id) - context.raised_exception = None - except PlanError as exc: - context.raised_exception = exc - - -@then("a PlanError should be raised about not in Execute phase") -def step_check_not_execute_phase(context: Context) -> None: - assert context.raised_exception is not None, "Expected a PlanError" - assert isinstance(context.raised_exception, PlanError) - assert "not in Execute phase" in str(context.raised_exception) - - -@given("a mock lifecycle with an execute-phase plan in processing state for coverage") -def step_given_lifecycle_processing(context: Context) -> None: - plan = _make_mock_plan( - phase=PlanPhase.EXECUTE, - state=ProcessingState.PROCESSING, - ) - context.lifecycle = _make_lifecycle(plan) - context.plan_id = PLAN_ID - - -@when("I call guard_execute expecting wrong state for coverage") -def step_call_guard_execute_wrong_state(context: Context) -> None: - try: - context.executor._guard_execute(context.plan_id) - context.raised_exception = None - except PlanError as exc: - context.raised_exception = exc - - -@then("a PlanError should be raised about not queued") -def step_check_not_queued(context: Context) -> None: - assert context.raised_exception is not None, "Expected a PlanError" - assert isinstance(context.raised_exception, PlanError) - assert "not queued" in str(context.raised_exception) - - -# --------------------------------------------------------------------------- -# PlanExecutor._run_execute_with_stub happy path -# --------------------------------------------------------------------------- - - -@given("a mock lifecycle with a fully configured execute-phase plan for coverage") -def step_given_lifecycle_full_execute(context: Context) -> None: - plan = _make_mock_plan( +@given("a cov2 plan in Execute-Queued state with decision root and definition") +def step_cov2_given_plan_exec_full(context: Context) -> None: + """Set up a fully valid plan for execute phase.""" + plan = _cov2_make_plan( phase=PlanPhase.EXECUTE, state=ProcessingState.QUEUED, definition_of_done="Implement feature\nWrite tests", - decision_root_id="01HROOT000000000000000ROOT", + decision_root_id=COV2_ROOT_ID, ) - context.lifecycle = _make_lifecycle(plan) - context.plan_id = PLAN_ID + context.cov2_lifecycle.get_plan.return_value = plan + context.cov2_mock_plan = plan -@given("a PlanExecutor using that lifecycle without runtime for coverage") -def step_given_executor_no_runtime(context: Context) -> None: - context.executor = PlanExecutor( - lifecycle_service=context.lifecycle, - execution_context=None, - ) - - -@when("I call run_execute on the PlanExecutor for coverage") -def step_run_execute(context: Context) -> None: - context.execute_result = context.executor.run_execute(context.plan_id) - - -@then("the execute result should be returned successfully") -def step_check_execute_ok(context: Context) -> None: - assert isinstance(context.execute_result, ExecuteResult) - assert context.execute_result.changeset_id is not None - - -@then("the lifecycle should have called start_execute for coverage") -def step_check_start_execute(context: Context) -> None: - context.lifecycle.start_execute.assert_called_once_with(context.plan_id) - - -@then("the lifecycle should have called complete_execute for coverage") -def step_check_complete_execute(context: Context) -> None: - context.lifecycle.complete_execute.assert_called_once_with(context.plan_id) - - -# --------------------------------------------------------------------------- -# PlanExecutor._run_execute_with_stub exception path (lines 483, 490) -# --------------------------------------------------------------------------- - - -@given("a mock lifecycle with an execute-phase plan that will fail during stub execute") -def step_given_lifecycle_stub_fail(context: Context) -> None: - plan = _make_mock_plan( - phase=PlanPhase.EXECUTE, - state=ProcessingState.QUEUED, - definition_of_done="Something", - decision_root_id="01HROOT000000000000000ROOT", - ) - context.lifecycle = _make_lifecycle(plan) - context.plan_id = PLAN_ID - - -@given("a PlanExecutor using that failing stub lifecycle for coverage") -def step_given_executor_failing_stub(context: Context) -> None: - context.executor = PlanExecutor( - lifecycle_service=context.lifecycle, - execution_context=None, - ) - # Patch the internal execute actor to raise - context.executor._execute_actor = MagicMock() - context.executor._execute_actor.execute.side_effect = RuntimeError( - "Boom in execute" - ) - - -@when("I call run_execute expecting an exception from stub for coverage") -def step_run_execute_fail(context: Context) -> None: +@when("I cov2 call run_execute") +def step_cov2_run_execute(context: Context) -> None: + """Call run_execute.""" try: - context.executor.run_execute(context.plan_id) - context.raised_exception = None - except RuntimeError as exc: - context.raised_exception = exc - - -@then("the lifecycle should have called fail_execute for coverage") -def step_check_fail_execute(context: Context) -> None: - assert context.raised_exception is not None, "Expected an exception" - context.lifecycle.fail_execute.assert_called_once() - call_args = context.lifecycle.fail_execute.call_args - assert context.plan_id in call_args[0] - assert "RuntimeError" in call_args[0][1] - - -# --------------------------------------------------------------------------- -# PlanExecutor.run_execute validation -# --------------------------------------------------------------------------- - - -@when("I call run_execute with an empty plan_id for coverage") -def step_run_execute_empty_plan_id(context: Context) -> None: - try: - context.executor.run_execute("") - context.raised_exception = None + context.cov2_exec_run_result = context.cov2_executor.run_execute( + context.cov2_plan_id + ) + context.cov2_raised = None except Exception as exc: - context.raised_exception = exc + context.cov2_raised = exc -# --------------------------------------------------------------------------- +@then("the cov2 execute run result should be an ExecuteResult") +def step_cov2_check_exec_result_type(context: Context) -> None: + """Verify the result is an ExecuteResult.""" + assert context.cov2_raised is None, f"Unexpected error: {context.cov2_raised}" + assert isinstance(context.cov2_exec_run_result, ExecuteResult), ( + f"Expected ExecuteResult, got {type(context.cov2_exec_run_result).__name__}" + ) + + +@then("the cov2 lifecycle should have called complete_execute") +def step_cov2_check_complete_execute(context: Context) -> None: + """Verify complete_execute was called.""" + context.cov2_lifecycle.complete_execute.assert_called_once_with( + context.cov2_plan_id + ) + + +# ---------------------------------------------------------------------- +# PlanExecutor._run_execute_with_runtime +# ---------------------------------------------------------------------- + + +@given("a cov2 PlanExecutor with execution context and tool runner") +def step_cov2_given_executor_runtime(context: Context) -> None: + """Create a PlanExecutor with execution context and tool runner.""" + from cleveragents.tool.registry import ToolRegistry + from cleveragents.tool.runner import ToolRunner + + runner = ToolRunner(registry=ToolRegistry()) + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=context.cov2_exec_ctx, + tool_runner=runner, + ) + + +@then("the cov2 execute run result should be a RuntimeExecuteResult") +def step_cov2_check_runtime_result_type(context: Context) -> None: + """Verify the result is a RuntimeExecuteResult.""" + from cleveragents.application.services.plan_execution_context import ( + RuntimeExecuteResult, + ) + + assert context.cov2_raised is None, f"Unexpected error: {context.cov2_raised}" + assert isinstance(context.cov2_exec_run_result, RuntimeExecuteResult), ( + f"Expected RuntimeExecuteResult, got {type(context.cov2_exec_run_result).__name__}" + ) + + +@given("a cov2 PlanExecutor with execution context but no tool runner") +def step_cov2_given_executor_runtime_no_runner(context: Context) -> None: + """Create a PlanExecutor with execution context but no tool_runner.""" + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=context.cov2_exec_ctx, + tool_runner=None, + ) + + +@when("I cov2 call run_execute expecting exception") +def step_cov2_run_execute_expect_exc(context: Context) -> None: + """Call run_execute expecting an exception.""" + try: + context.cov2_exec_run_result = context.cov2_executor.run_execute( + context.cov2_plan_id + ) + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc + + +@given("a cov2 PlanExecutor with execution context and tool runner and failing runtime") +def step_cov2_given_executor_runtime_failing(context: Context) -> None: + """Create a PlanExecutor with runtime that will fail via patched RuntimeExecuteActor.""" + from cleveragents.application.services.plan_execution_context import ( + RuntimeExecuteActor, + ) + from cleveragents.tool.registry import ToolRegistry + from cleveragents.tool.runner import ToolRunner + + runner = ToolRunner(registry=ToolRegistry()) + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=context.cov2_exec_ctx, + tool_runner=runner, + ) + # Patch RuntimeExecuteActor.execute so the real _run_execute_with_runtime + # try/except catches the error and calls fail_execute. + _orig_init = RuntimeExecuteActor.__init__ + + patcher = patch.object( + RuntimeExecuteActor, + "execute", + side_effect=RuntimeError("cov2 boom in runtime execute"), + ) + patcher.start() + # Register cleanup + if not hasattr(context, "_cleanup_handlers"): + context._cleanup_handlers = [] + context._cleanup_handlers.append(patcher.stop) + + +@then("the cov2 lifecycle should have called fail_execute") +def step_cov2_check_fail_execute(context: Context) -> None: + """Verify fail_execute was called.""" + context.cov2_lifecycle.fail_execute.assert_called() + + +# ---------------------------------------------------------------------- +# PlanExecutor._run_execute_with_stub - retry logic +# ---------------------------------------------------------------------- + + +@given("a cov2 PlanExecutor with failing execute actor and no recovery") +def step_cov2_given_executor_failing_stub_no_recovery(context: Context) -> None: + """Create a PlanExecutor with a failing execute actor and no recovery.""" + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=None, + error_recovery_service=None, + ) + context.cov2_executor._execute_actor = MagicMock() + context.cov2_executor._execute_actor.execute.side_effect = RuntimeError( + "cov2 boom in stub execute" + ) + + +@given("a cov2 PlanExecutor with execute actor that fails once then succeeds") +def step_cov2_given_executor_retry_success(context: Context) -> None: + """Create a PlanExecutor with error recovery where first attempt fails, second succeeds.""" + error_recovery = MagicMock() + error_recovery.max_retries = 2 + error_recovery.record_error = MagicMock() + error_recovery.should_retry = MagicMock(return_value=True) + context.cov2_error_recovery = error_recovery + + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=None, + error_recovery_service=error_recovery, + ) + + # First call raises, second call succeeds + real_actor = ExecuteStubActor() + call_count = {"n": 0} + original_execute = real_actor.execute + + def _flaky_execute(**kwargs: Any) -> Any: + call_count["n"] += 1 + if call_count["n"] == 1: + raise RuntimeError("cov2 transient error") + return original_execute(**kwargs) + + context.cov2_executor._execute_actor = MagicMock() + context.cov2_executor._execute_actor.execute.side_effect = _flaky_execute + + +@then("the cov2 error recovery should have recorded an execute error") +def step_cov2_check_recovery_execute(context: Context) -> None: + """Verify error_recovery.record_error was called.""" + context.cov2_error_recovery.record_error.assert_called() + + +@given("a cov2 PlanExecutor with always-failing execute actor and exhausted retries") +def step_cov2_given_executor_exhausted_retries(context: Context) -> None: + """Create a PlanExecutor with error recovery that exhausts retries.""" + error_recovery = MagicMock() + error_recovery.max_retries = 1 + # First should_retry True, second False + error_recovery.should_retry = MagicMock(side_effect=[True, False]) + error_recovery.record_error = MagicMock() + context.cov2_error_recovery = error_recovery + + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=None, + error_recovery_service=error_recovery, + ) + context.cov2_executor._execute_actor = MagicMock() + context.cov2_executor._execute_actor.execute.side_effect = RuntimeError( + "cov2 persistent failure" + ) + + +@given("a cov2 PlanExecutor with failing execute actor and recovery denying retry") +def step_cov2_given_executor_no_retry(context: Context) -> None: + """Create a PlanExecutor with error recovery that denies retry.""" + error_recovery = MagicMock() + error_recovery.max_retries = 2 + error_recovery.should_retry = MagicMock(return_value=False) + error_recovery.record_error = MagicMock() + context.cov2_error_recovery = error_recovery + + context.cov2_executor = PlanExecutor( + lifecycle_service=context.cov2_lifecycle, + execution_context=None, + error_recovery_service=error_recovery, + ) + context.cov2_executor._execute_actor = MagicMock() + context.cov2_executor._execute_actor.execute.side_effect = RuntimeError( + "cov2 no retry error" + ) + + +# ---------------------------------------------------------------------- # PlanExecutor._build_decisions -# --------------------------------------------------------------------------- +# ---------------------------------------------------------------------- -@when("I call build_decisions for the plan for coverage") -def step_call_build_decisions(context: Context) -> None: - plan = context.lifecycle.get_plan(context.plan_id) - context.built_decisions = context.executor._build_decisions(plan) +@when("I cov2 call build_decisions") +def step_cov2_call_build_decisions(context: Context) -> None: + """Call _build_decisions on the mock plan.""" + plan = context.cov2_lifecycle.get_plan(context.cov2_plan_id) + context.cov2_built_decisions = context.cov2_executor._build_decisions(plan) -@then("the decisions should match the plan definition of done steps") -def step_check_built_decisions(context: Context) -> None: - decisions = context.built_decisions - assert len(decisions) == 2 - assert decisions[0].step_text == "Implement feature" - assert decisions[1].step_text == "Write tests" - assert decisions[0].parent_id is None - assert decisions[1].parent_id is not None +@then('the cov2 built decisions should have "{n}" entries') +def step_cov2_check_built_count(context: Context, n: str) -> None: + """Verify the number of built decisions.""" + assert len(context.cov2_built_decisions) == int(n), ( + f"Expected {n} decisions, got {len(context.cov2_built_decisions)}" + ) + + +@then("the cov2 first built decision should use the plan decision_root_id") +def step_cov2_check_first_built(context: Context) -> None: + """Verify the first decision uses the plan's decision_root_id.""" + first = context.cov2_built_decisions[0] + assert first.decision_id == COV2_ROOT_ID, ( + f"Expected {COV2_ROOT_ID}, got {first.decision_id}" + ) + assert first.parent_id is None + + +@then("the cov2 second built decision should have root as parent") +def step_cov2_check_second_built(context: Context) -> None: + """Verify the second decision has root as parent.""" + second = context.cov2_built_decisions[1] + assert second.parent_id == COV2_ROOT_ID, ( + f"Expected parent {COV2_ROOT_ID}, got {second.parent_id}" + ) + + +# ---------------------------------------------------------------------- +# Result model edge cases +# ---------------------------------------------------------------------- + + +@when("I cov2 create a StrategyDecision with negative sequence") +def step_cov2_create_bad_decision(context: Context) -> None: + """Try to create a StrategyDecision with negative sequence.""" + try: + StrategyDecision( + decision_id="01KTEST00000000000000TEST0", + step_text="Test step", + sequence=-1, + ) + context.cov2_raised = None + except Exception as exc: + context.cov2_raised = exc + + +@then("a cov2 validation error should be raised for the model") +def step_cov2_check_model_validation(context: Context) -> None: + """Verify a validation error was raised.""" + assert context.cov2_raised is not None, "Expected a validation error" + + +@when("I cov2 create an ExecuteResult with minimal fields") +def step_cov2_create_minimal_exec_result(context: Context) -> None: + """Create an ExecuteResult with only required fields.""" + from cleveragents.tool.builtins.changeset import ChangeSet + + context.cov2_minimal_result = ExecuteResult( + changeset_id="01KTEST00000000000000CSID0", + changeset=ChangeSet(plan_id=COV2_PLAN_ID), + ) + + +@then('the cov2 ExecuteResult tool_calls_count should default to "{n}"') +def step_cov2_check_default_tool_calls(context: Context, n: str) -> None: + """Verify tool_calls_count defaults correctly.""" + assert context.cov2_minimal_result.tool_calls_count == int(n), ( + f"Expected {n}, got {context.cov2_minimal_result.tool_calls_count}" + ) + + +@then("the cov2 ExecuteResult sandbox_refs should default to empty list") +def step_cov2_check_default_sandbox(context: Context) -> None: + """Verify sandbox_refs defaults to empty list.""" + assert context.cov2_minimal_result.sandbox_refs == [], ( + f"Expected [], got {context.cov2_minimal_result.sandbox_refs}" + ) diff --git a/features/steps/repositories_coverage_steps.py b/features/steps/repositories_coverage_steps.py new file mode 100644 index 00000000..379d2f22 --- /dev/null +++ b/features/steps/repositories_coverage_steps.py @@ -0,0 +1,737 @@ +"""Step definitions for repositories_coverage.feature. + +Targets uncovered lines and branches in ActionRepository and +LifecyclePlanRepository in ``repositories.py``: + Lines: 924, 943-946, 968, 1077-1078, 1124-1127, 1142-1143, 1160, + 1162-1163, 1188, 1267, 1283-1284, 1296-1298, 1303, 1315-1319, + 1338, 1342-1344, 1383, 1393-1394, 1411-1422 + Branches at: 923, 942, 944, 967, 1122, 1159, 1187, 1266, 1282, 1283, + 1295, 1337, 1382, 1414, 1416 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from behave import given, then, when +from behave.runner import Context +from sqlalchemy import create_engine +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session, sessionmaker + +from cleveragents.core.exceptions import DatabaseError +from cleveragents.domain.models.core.action import ( + Action, + ActionArgument, + ActionState, + ArgumentRequirement, + ArgumentType, +) +from cleveragents.domain.models.core.plan import ( + InvariantSource, + NamespacedName, + Plan, + PlanIdentity, + PlanInvariant, + PlanPhase, + PlanTimestamps, + ProcessingState, + ProjectLink, +) +from cleveragents.infrastructure.database.models import ( + Base, + LifecyclePlanModel, +) +from cleveragents.infrastructure.database.repositories import ( + ActionInUseError, + ActionRepository, + DuplicatePlanError, + LifecyclePlanRepository, +) + +# Valid ULIDs for deterministic tests (Crockford base32, 26 chars) +_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +_ULID_COUNTER = 0 + + +def _next_ulid() -> str: + """Return a unique, valid ULID string for each call.""" + global _ULID_COUNTER + _ULID_COUNTER += 1 + n = _ULID_COUNTER + suffix = "" + for _ in range(8): + suffix = _CB32[n % 32] + suffix + n //= 32 + return f"01HGZ6FE0AQDYTR4BX{suffix}" + + +def _make_action( + name: str = "local/test-action", + state: str = "available", +) -> Action: + """Create a minimal valid Action domain object.""" + parts = name.split("/", 1) + namespace = parts[0] if len(parts) == 2 else "local" + short_name = parts[1] if len(parts) == 2 else parts[0] + + return Action( + namespaced_name=NamespacedName(namespace=namespace, name=short_name), + description=f"Test action {short_name}", + long_description=None, + definition_of_done=f"Verify {short_name} completes", + strategy_actor="local/strategist", + execution_actor="local/executor", + estimation_actor=None, + review_actor=None, + arguments=[], + reusable=True, + read_only=False, + state=ActionState(state), + created_at=datetime.now(), + updated_at=datetime.now(), + created_by=None, + tags=[], + ) + + +def _make_plan( + action_name: str, + plan_name: str = "local/test-plan", +) -> Plan: + """Create a minimal valid Plan domain object.""" + parts = plan_name.split("/", 1) + namespace = parts[0] if len(parts) == 2 else "local" + short_name = parts[1] if len(parts) == 2 else parts[0] + now = datetime.now() + + return Plan( + identity=PlanIdentity(plan_id=_next_ulid(), attempt=1), + namespaced_name=NamespacedName(namespace=namespace, name=short_name), + action_name=action_name, + description=f"Test plan {short_name}", + definition_of_done="Verify plan completes", + phase=PlanPhase.STRATEGIZE, + processing_state=ProcessingState.QUEUED, + strategy_actor="local/strategist", + execution_actor="local/executor", + timestamps=PlanTimestamps(created_at=now, updated_at=now), + error_message=None, + error_details=None, + created_by=None, + tags=[], + reusable=True, + read_only=False, + ) + + +# ----------------------------------------------------------------------- +# Background +# ----------------------------------------------------------------------- + + +@given("a repos-cov in-memory database with lifecycle schema") +def step_repos_cov_clean_db(context: Context) -> None: + """Create a fresh in-memory SQLite database with all tables.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + context.rc_engine = engine + session = sessionmaker(bind=engine)() + context.rc_session = session + context.rc_session_factory = lambda: session + context.rc_error = None + context.rc_result = None + context.rc_delete_result = None + context.rc_result_list = None + + +@given("a repos-cov action repository") +def step_repos_cov_action_repo(context: Context) -> None: + """Instantiate an ActionRepository using the session factory.""" + context.rc_action_repo = ActionRepository( + session_factory=context.rc_session_factory, + ) + + +@given("a repos-cov lifecycle plan repository") +def step_repos_cov_plan_repo(context: Context) -> None: + """Instantiate a LifecyclePlanRepository using the session factory.""" + context.rc_plan_repo = LifecyclePlanRepository( + session_factory=context.rc_session_factory, + ) + + +# ----------------------------------------------------------------------- +# Helpers for error-producing sessions +# ----------------------------------------------------------------------- + + +class _QueryRaisingSession: + """Stub session that raises OperationalError on query().""" + + def query(self, *args: Any, **kwargs: Any) -> None: + """Raise OperationalError to simulate a transient DB failure.""" + raise OperationalError("simulated", {}, Exception("db down")) + + def rollback(self) -> None: + """No-op rollback.""" + + def close(self) -> None: + """No-op close.""" + + +@given( + "a repos-cov action repository with a session that raises OperationalError on query" +) +def step_repos_cov_action_repo_error(context: Context) -> None: + """Create an ActionRepository whose session raises on query.""" + context.rc_action_repo = ActionRepository( + session_factory=lambda: _QueryRaisingSession(), # type: ignore[arg-type] + ) + + +@given( + "a repos-cov plan repository with a session that raises OperationalError on query" +) +def step_repos_cov_plan_repo_error(context: Context) -> None: + """Create a LifecyclePlanRepository whose session raises on query.""" + context.rc_plan_repo = LifecyclePlanRepository( + session_factory=lambda: _QueryRaisingSession(), # type: ignore[arg-type] + ) + + +# ----------------------------------------------------------------------- +# ActionRepository.get_by_name +# ----------------------------------------------------------------------- + + +@when('repos-cov get_by_name is called with "{name}"') +def step_repos_cov_get_by_name(context: Context, name: str) -> None: + """Call ActionRepository.get_by_name and capture result or error.""" + try: + context.rc_result = context.rc_action_repo.get_by_name(name) + except Exception as exc: + context.rc_error = exc + + +@then('repos-cov a DatabaseError should be raised containing "{text}"') +def step_repos_cov_verify_db_error(context: Context, text: str) -> None: + """Assert that a DatabaseError was raised with expected text.""" + assert context.rc_error is not None, ( + "Expected DatabaseError but no error was raised" + ) + assert isinstance(context.rc_error, DatabaseError), ( + f"Expected DatabaseError, got {type(context.rc_error).__name__}: {context.rc_error}" + ) + assert text in str(context.rc_error), ( + f"Expected '{text}' in error message: {context.rc_error}" + ) + + +@then('repos-cov the returned action should have name "{name}"') +def step_repos_cov_verify_action_name(context: Context, name: str) -> None: + """Assert the returned action has the expected namespaced name.""" + assert context.rc_result is not None, "Expected an action, got None" + assert str(context.rc_result.namespaced_name) == name, ( + f"Expected '{name}', got '{context.rc_result.namespaced_name}'" + ) + + +# ----------------------------------------------------------------------- +# ActionRepository - persisting actions +# ----------------------------------------------------------------------- + + +@given('repos-cov an available action "{name}" exists') +def step_repos_cov_persist_action(context: Context, name: str) -> None: + """Create and persist an action with default state 'available'.""" + action = _make_action(name=name, state="available") + context.rc_action_repo.create(action) + context.rc_session.commit() + context.rc_current_action = action + + +@given('repos-cov an action "{name}" exists with state "{state}"') +def step_repos_cov_persist_action_state( + context: Context, name: str, state: str +) -> None: + """Create and persist an action with the given state.""" + action = _make_action(name=name, state=state) + context.rc_action_repo.create(action) + context.rc_session.commit() + context.rc_current_action = action + + +# ----------------------------------------------------------------------- +# ActionRepository.get_by_namespace +# ----------------------------------------------------------------------- + + +@when('repos-cov get_by_namespace is called with namespace "{ns}" and state "{state}"') +def step_repos_cov_get_by_namespace_state( + context: Context, ns: str, state: str +) -> None: + """Call get_by_namespace with namespace and state filter.""" + try: + context.rc_result_list = context.rc_action_repo.get_by_namespace( + ns, state=state + ) + except Exception as exc: + context.rc_error = exc + + +@when('repos-cov get_by_namespace is called with namespace "{ns}" expecting error') +def step_repos_cov_get_by_namespace_error(context: Context, ns: str) -> None: + """Call get_by_namespace expecting a DatabaseError.""" + try: + context.rc_action_repo.get_by_namespace(ns) + except Exception as exc: + context.rc_error = exc + + +@then("repos-cov exactly {count:d} action should be in the result list") +def step_repos_cov_verify_count_singular(context: Context, count: int) -> None: + """Assert the result list has exactly count items (singular).""" + assert context.rc_result_list is not None, "Result list is None" + assert len(context.rc_result_list) == count, ( + f"Expected {count} action(s), got {len(context.rc_result_list)}" + ) + + +@then("repos-cov exactly {count:d} actions should be in the result list") +def step_repos_cov_verify_count_plural(context: Context, count: int) -> None: + """Assert the result list has exactly count items (plural).""" + assert context.rc_result_list is not None, "Result list is None" + assert len(context.rc_result_list) == count, ( + f"Expected {count} action(s), got {len(context.rc_result_list)}" + ) + + +@then('repos-cov the result list should contain action named "{name}"') +def step_repos_cov_verify_list_contains(context: Context, name: str) -> None: + """Assert the result list contains an action with the given name.""" + names = [str(a.namespaced_name) for a in context.rc_result_list] + assert name in names, f"Expected '{name}' in {names}" + + +# ----------------------------------------------------------------------- +# ActionRepository.list_available +# ----------------------------------------------------------------------- + + +@when("repos-cov list_available is called without a namespace filter") +def step_repos_cov_list_available_all(context: Context) -> None: + """Call list_available with no namespace.""" + try: + context.rc_result_list = context.rc_action_repo.list_available() + except Exception as exc: + context.rc_error = exc + + +@when('repos-cov list_available is called with namespace "{ns}"') +def step_repos_cov_list_available_ns(context: Context, ns: str) -> None: + """Call list_available with a namespace filter.""" + try: + context.rc_result_list = context.rc_action_repo.list_available(namespace=ns) + except Exception as exc: + context.rc_error = exc + + +@when("repos-cov list_available is called and an error is expected") +def step_repos_cov_list_available_error(context: Context) -> None: + """Call list_available expecting a DatabaseError.""" + try: + context.rc_action_repo.list_available() + except Exception as exc: + context.rc_error = exc + + +# ----------------------------------------------------------------------- +# ActionRepository.update - with min_value/max_value arguments +# ----------------------------------------------------------------------- + + +@given("repos-cov the action has arguments with min_value and max_value set") +def step_repos_cov_add_minmax_args(context: Context) -> None: + """Add arguments with min_value and max_value to the current action.""" + context.rc_current_action.arguments = [ + ActionArgument( + name="count", + arg_type=ArgumentType.INTEGER, + requirement=ArgumentRequirement.REQUIRED, + description="Number of items", + default_value=5, + min_value=1.0, + max_value=100.0, + validation_pattern=None, + ), + ] + + +@when("repos-cov the action is updated via the repository") +def step_repos_cov_update_action(context: Context) -> None: + """Update the current action via the repository.""" + try: + context.rc_result = context.rc_action_repo.update(context.rc_current_action) + context.rc_session.commit() + except Exception as exc: + context.rc_error = exc + + +@then("repos-cov the update should succeed") +def step_repos_cov_update_success(context: Context) -> None: + """Assert no error occurred during update.""" + assert context.rc_error is None, f"Unexpected error: {context.rc_error}" + + +@then("repos-cov the retrieved action arguments should have min_value and max_value") +def step_repos_cov_verify_minmax_args(context: Context) -> None: + """Retrieve the action and verify min_value/max_value on arguments.""" + name = str(context.rc_current_action.namespaced_name) + retrieved = context.rc_action_repo.get_by_id(name) + assert retrieved is not None, f"Action '{name}' not found after update" + assert len(retrieved.arguments) >= 1, "Expected at least 1 argument" + arg = retrieved.arguments[0] + assert arg.min_value == 1.0, f"Expected min_value=1.0, got {arg.min_value}" + assert arg.max_value == 100.0, f"Expected max_value=100.0, got {arg.max_value}" + + +# ----------------------------------------------------------------------- +# ActionRepository.delete +# ----------------------------------------------------------------------- + + +@given('a repos-cov lifecycle plan references action "{action_name}"') +def step_repos_cov_create_referencing_plan(context: Context, action_name: str) -> None: + """Insert a LifecyclePlanModel row referencing the specified action.""" + session: Session = context.rc_session + now_iso = datetime.now().isoformat() + plan_model = LifecyclePlanModel( + plan_id=_next_ulid(), + action_name=action_name, + phase="strategize", + processing_state="queued", + attempt=1, + namespaced_name="local/ref-plan", + namespace="local", + description="Plan referencing action under test", + created_at=now_iso, + updated_at=now_iso, + tags_json="[]", + ) + session.add(plan_model) + session.flush() + session.commit() + + +@when('repos-cov delete is called for action "{name}"') +def step_repos_cov_delete_action(context: Context, name: str) -> None: + """Call ActionRepository.delete and capture result or error.""" + try: + context.rc_delete_result = context.rc_action_repo.delete(name) + context.rc_session.commit() + except Exception as exc: + context.rc_error = exc + + +@then('repos-cov an ActionInUseError should be raised for "{name}"') +def step_repos_cov_verify_action_in_use(context: Context, name: str) -> None: + """Assert that an ActionInUseError was raised.""" + assert context.rc_error is not None, ( + "Expected ActionInUseError but no error was raised" + ) + assert isinstance(context.rc_error, ActionInUseError), ( + f"Expected ActionInUseError, got {type(context.rc_error).__name__}: {context.rc_error}" + ) + assert name in str(context.rc_error), ( + f"Expected '{name}' in error message: {context.rc_error}" + ) + + +@then("repos-cov the delete result should be True") +def step_repos_cov_delete_true(context: Context) -> None: + """Assert delete returned True.""" + assert context.rc_delete_result is True, ( + f"Expected True, got {context.rc_delete_result}" + ) + + +@then("repos-cov the delete result should be False") +def step_repos_cov_delete_false(context: Context) -> None: + """Assert delete returned False.""" + assert context.rc_delete_result is False, ( + f"Expected False, got {context.rc_delete_result}" + ) + + +@then('repos-cov get_by_id for "{name}" should return None') +def step_repos_cov_verify_deleted(context: Context, name: str) -> None: + """Assert the action no longer exists.""" + found = context.rc_action_repo.get_by_id(name) + assert found is None, f"Expected None, got {found}" + + +# ----------------------------------------------------------------------- +# DuplicatePlanError +# ----------------------------------------------------------------------- + + +@when('repos-cov a DuplicatePlanError is created for plan_id "{plan_id}"') +def step_repos_cov_create_dup_plan_error(context: Context, plan_id: str) -> None: + """Instantiate a DuplicatePlanError.""" + context.rc_dup_plan_error = DuplicatePlanError(plan_id) + + +@then('repos-cov the error message should contain "{text}"') +def step_repos_cov_verify_error_contains(context: Context, text: str) -> None: + """Assert the error message contains expected text.""" + assert text in str(context.rc_dup_plan_error), ( + f"Expected '{text}' in '{context.rc_dup_plan_error}'" + ) + + +@then('repos-cov the error plan_id attribute should be "{plan_id}"') +def step_repos_cov_verify_plan_id_attr(context: Context, plan_id: str) -> None: + """Assert the plan_id attribute is correct.""" + assert context.rc_dup_plan_error.plan_id == plan_id, ( + f"Expected plan_id='{plan_id}', got '{context.rc_dup_plan_error.plan_id}'" + ) + + +# ----------------------------------------------------------------------- +# LifecyclePlanRepository.get_by_name +# ----------------------------------------------------------------------- + + +@given( + 'a repos-cov lifecycle plan linked to "{action_name}" with name "{plan_name}" has been persisted' +) +def step_repos_cov_persist_plan( + context: Context, action_name: str, plan_name: str +) -> None: + """Create and persist a lifecycle plan.""" + plan = _make_plan(action_name=action_name, plan_name=plan_name) + context.rc_plan_repo.create(plan) + context.rc_session.commit() + context.rc_current_plan = plan + + +@when('repos-cov plan get_by_name is called with "{name}"') +def step_repos_cov_plan_get_by_name(context: Context, name: str) -> None: + """Call LifecyclePlanRepository.get_by_name and capture result or error.""" + try: + context.rc_result = context.rc_plan_repo.get_by_name(name) + except Exception as exc: + context.rc_error = exc + + +@when('repos-cov plan get_by_name is called with "{name}" expecting error') +def step_repos_cov_plan_get_by_name_error(context: Context, name: str) -> None: + """Call LifecyclePlanRepository.get_by_name expecting error.""" + try: + context.rc_plan_repo.get_by_name(name) + except Exception as exc: + context.rc_error = exc + + +@then("repos-cov the returned plan should not be None") +def step_repos_cov_plan_not_none(context: Context) -> None: + """Assert the returned plan is not None.""" + assert context.rc_result is not None, "Expected a plan, got None" + + +@then("repos-cov the returned plan should be None") +def step_repos_cov_plan_none(context: Context) -> None: + """Assert the returned plan is None.""" + assert context.rc_result is None, f"Expected None, got {context.rc_result}" + + +# ----------------------------------------------------------------------- +# LifecyclePlanRepository.update - with project_links, arguments, invariants +# ----------------------------------------------------------------------- + + +class _StubInvariantWithText: + """Stub invariant with .text and .source attributes (source has .value).""" + + def __init__(self, text: str, source: InvariantSource) -> None: + """Initialise with text and InvariantSource enum.""" + self.text = text + self.source = source + + +class _StubInvariantPlainString: + """Stub invariant that is a plain string (no .text, no .source).""" + + def __init__(self, text: str) -> None: + """Initialise with just a string value.""" + self._text = text + + def __str__(self) -> str: + """Return the text representation.""" + return self._text + + +@given( + "repos-cov the plan has project_links, arguments, and invariants with text attribute" +) +def step_repos_cov_plan_add_children(context: Context) -> None: + """Add project_links, arguments, and invariants (with .text) to the plan.""" + plan = context.rc_current_plan + plan.project_links = [ + ProjectLink(project_name="local/my-project", alias=None, read_only=False), + ] + plan.arguments = {"greeting": "hello", "count": 42} + plan.arguments_order = ["greeting", "count"] + plan.invariants = [ + PlanInvariant(text="Must not break prod", source=InvariantSource.PLAN), + ] + plan.error_message = None + plan.error_details = None + plan.changeset_id = None + + +@when("repos-cov the plan is updated via the repository") +def step_repos_cov_update_plan(context: Context) -> None: + """Update the current plan via the repository.""" + try: + context.rc_result = context.rc_plan_repo.update(context.rc_current_plan) + context.rc_session.commit() + except Exception as exc: + context.rc_error = exc + + +@then("repos-cov the plan update should succeed") +def step_repos_cov_plan_update_success(context: Context) -> None: + """Assert no error occurred during plan update.""" + assert context.rc_error is None, f"Unexpected error: {context.rc_error}" + + +@then("repos-cov the retrieved plan should have project links") +def step_repos_cov_verify_project_links(context: Context) -> None: + """Retrieve the plan and verify project links.""" + plan_id = context.rc_current_plan.identity.plan_id + retrieved = context.rc_plan_repo.get(plan_id) + assert retrieved is not None, f"Plan '{plan_id}' not found after update" + assert len(retrieved.project_links) >= 1, ( + f"Expected at least 1 project link, got {len(retrieved.project_links)}" + ) + assert retrieved.project_links[0].project_name == "local/my-project", ( + f"Expected project_name='local/my-project', got '{retrieved.project_links[0].project_name}'" + ) + + +@then("repos-cov the retrieved plan should have arguments") +def step_repos_cov_verify_plan_args(context: Context) -> None: + """Retrieve the plan and verify arguments.""" + plan_id = context.rc_current_plan.identity.plan_id + retrieved = context.rc_plan_repo.get(plan_id) + assert retrieved is not None, f"Plan '{plan_id}' not found after update" + assert len(retrieved.arguments) >= 1, ( + f"Expected at least 1 argument, got {len(retrieved.arguments)}" + ) + + +@then("repos-cov the retrieved plan should have invariants") +def step_repos_cov_verify_plan_invariants(context: Context) -> None: + """Retrieve the plan and verify invariants.""" + plan_id = context.rc_current_plan.identity.plan_id + retrieved = context.rc_plan_repo.get(plan_id) + assert retrieved is not None, f"Plan '{plan_id}' not found after update" + assert len(retrieved.invariants) >= 1, ( + f"Expected at least 1 invariant, got {len(retrieved.invariants)}" + ) + + +# ----------------------------------------------------------------------- +# LifecyclePlanRepository.update - invariants as plain strings +# ----------------------------------------------------------------------- + + +@given("repos-cov the plan has invariants as plain strings without text attribute") +def step_repos_cov_plan_plain_string_invariants(context: Context) -> None: + """Set invariants on the plan as objects without .text attribute (falls back to str).""" + plan = context.rc_current_plan + plan.project_links = [] + plan.arguments = {} + plan.arguments_order = [] + # Use object.__setattr__ to bypass pydantic validation — the code path does: + # inv_text = inv.text if hasattr(inv, "text") else str(inv) + object.__setattr__( + plan, + "invariants", + [_StubInvariantPlainString("No side effects allowed")], + ) + + +# ----------------------------------------------------------------------- +# LifecyclePlanRepository.update - invariant source with .value enum +# ----------------------------------------------------------------------- + + +@given("repos-cov the plan has invariants with InvariantSource enum values") +def step_repos_cov_plan_enum_invariants(context: Context) -> None: + """Set invariants using stub objects with InvariantSource enum source.""" + plan = context.rc_current_plan + plan.project_links = [] + plan.arguments = {} + plan.arguments_order = [] + # Use object.__setattr__ to bypass pydantic validation + # _StubInvariantWithText has .text and .source (an InvariantSource enum with .value) + object.__setattr__( + plan, + "invariants", + [ + _StubInvariantWithText( + "Keep API backward compatible", InvariantSource.ACTION + ) + ], + ) + + +# ----------------------------------------------------------------------- +# LifecyclePlanRepository.update - processing_state as plain string +# ----------------------------------------------------------------------- + + +@given('repos-cov the plan processing_state is set to a plain string "{state}"') +def step_repos_cov_plan_plain_state(context: Context, state: str) -> None: + """Set the processing_state to a plain string (no .value attribute).""" + plan = context.rc_current_plan + plan.project_links = [] + plan.arguments = {} + plan.arguments_order = [] + plan.invariants = [] + # Use object.__setattr__ to bypass pydantic validation and set a raw string + # that doesn't have a .value attribute — exercises the else branch at line 1317 + object.__setattr__(plan, "processing_state", state) + + +# ----------------------------------------------------------------------- +# LifecyclePlanRepository.update - error_details and changeset_id +# ----------------------------------------------------------------------- + + +@given("repos-cov the plan has error_message, error_details, and changeset_id set") +def step_repos_cov_plan_error_details(context: Context) -> None: + """Set error_message, error_details, and changeset_id on the plan.""" + plan = context.rc_current_plan + plan.project_links = [] + plan.arguments = {} + plan.arguments_order = [] + plan.invariants = [] + plan.error_message = "Something went wrong" + plan.error_details = {"step": "execute", "reason": "timeout"} + plan.changeset_id = "01HGZ6FE0AQDYTR4BX00000001" + + +@then("repos-cov the retrieved plan should have the error_message set") +def step_repos_cov_verify_error_message(context: Context) -> None: + """Retrieve the plan and verify error_message is set.""" + plan_id = context.rc_current_plan.identity.plan_id + retrieved = context.rc_plan_repo.get(plan_id) + assert retrieved is not None, f"Plan '{plan_id}' not found after update" + assert retrieved.error_message == "Something went wrong", ( + f"Expected error_message='Something went wrong', got '{retrieved.error_message}'" + ) diff --git a/features/steps/sandbox_copy_on_write_coverage_steps.py b/features/steps/sandbox_copy_on_write_coverage_steps.py new file mode 100644 index 00000000..ea602b8f --- /dev/null +++ b/features/steps/sandbox_copy_on_write_coverage_steps.py @@ -0,0 +1,428 @@ +"""Step definitions for CopyOnWriteSandbox uncovered lines and branches. + +Targets every gap identified in the coverage report for +``copy_on_write.py`` (line-rate=0.9124, branch-rate=0.8519): + +- Lines 143-145: ``create()`` OSError handler wrapping into SandboxCreationError +- Line 196 branch False→199: ``get_path()`` when already ACTIVE (skip CREATED transition) +- Line 200: ``get_path()`` raises when ``_sandbox_path`` is ``None`` +- Line 230: ``commit()`` raises when ``_sandbox_path`` is ``None`` +- Line 242 branch False→244: ``commit()`` skips ``os.makedirs`` when ``dst_dir`` is empty +- Line 249 branch False→247: ``commit()`` deleted file already missing from original +- Lines 252-254: ``commit()`` OSError handler wrapping into SandboxCommitError +- Line 297: ``rollback()`` raises when ``_sandbox_path`` is ``None`` +- Lines 311-313: ``rollback()`` OSError handler wrapping into SandboxRollbackError +- Line 341 branch False→347: ``cleanup()`` when ``_sandbox_path`` is ``None`` +- Line 344 branch False→347: ``cleanup()`` when parent dir already removed + +All steps use the ``cowcov`` prefix to avoid collisions with existing +``cow`` and ``cowcb`` step prefixes. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from unittest.mock import MagicMock, patch + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox +from cleveragents.infrastructure.sandbox.protocol import ( + SandboxCommitError, + SandboxCreationError, + SandboxRollbackError, + SandboxStateError, + SandboxStatus, +) + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- + + +def _cowcov_make_test_dir() -> str: + """Create a temporary directory with seed files for testing.""" + d = tempfile.mkdtemp(prefix="cowcov-test-dir-") + with open(os.path.join(d, "existing.txt"), "w") as f: + f.write("original content") + with open(os.path.join(d, "to_delete.txt"), "w") as f: + f.write("will be deleted") + os.makedirs(os.path.join(d, "sub"), exist_ok=True) + with open(os.path.join(d, "sub", "nested.txt"), "w") as f: + f.write("nested") + return d + + +def _cowcov_cleanup_dir(path: str) -> None: + """Remove a directory tree if it still exists.""" + if path and os.path.exists(path): + shutil.rmtree(path, ignore_errors=True) + + +# ---------------------------------------------------------------------- +# Given +# ---------------------------------------------------------------------- + + +@given("a cowcov test directory is initialised") +def step_cowcov_init_dir(context: Context) -> None: + """Create a fresh temporary directory with seed files.""" + context.cowcov_test_dir = _cowcov_make_test_dir() + context.cowcov_sandbox = None + context.cowcov_error = None + context.cowcov_commit_result = None + context.cowcov_resolved_paths = [] + context.cowcov_makedirs_mock = None + context._cleanup_handlers.append( + lambda: _cowcov_cleanup_dir(context.cowcov_test_dir) + ) + + +@given("shutil.copytree is patched to raise OSError in cowcov") +def step_cowcov_patch_copytree_oserror(context: Context) -> None: + """Patch shutil.copytree in the copy_on_write module to raise OSError.""" + patcher = patch( + "cleveragents.infrastructure.sandbox.copy_on_write.shutil.copytree", + side_effect=OSError("mocked copytree failure"), + ) + patcher.start() + context._cleanup_handlers.append(patcher.stop) + + +@given('a cowcov sandbox is created for plan "{plan_id}"') +def step_cowcov_create_sandbox(context: Context, plan_id: str) -> None: + """Create a CopyOnWriteSandbox and call create().""" + context.cowcov_sandbox = CopyOnWriteSandbox( + resource_id="res-cowcov", + original_path=context.cowcov_test_dir, + ) + context.cowcov_sandbox.create(plan_id) + sandbox_path = context.cowcov_sandbox._sandbox_path + if sandbox_path: + parent = os.path.dirname(sandbox_path) + context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(parent)) + + +@given('a cowcov sandbox is created and activated for plan "{plan_id}"') +def step_cowcov_create_and_activate(context: Context, plan_id: str) -> None: + """Create a sandbox and transition to ACTIVE via get_path.""" + context.cowcov_sandbox = CopyOnWriteSandbox( + resource_id="res-cowcov", + original_path=context.cowcov_test_dir, + ) + context.cowcov_sandbox.create(plan_id) + context.cowcov_sandbox.get_path("existing.txt") + assert context.cowcov_sandbox.status == SandboxStatus.ACTIVE, ( + f"Expected ACTIVE, got {context.cowcov_sandbox.status}" + ) + sandbox_path = context.cowcov_sandbox._sandbox_path + if sandbox_path: + parent = os.path.dirname(sandbox_path) + context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(parent)) + + +@given("a cowcov sandbox forced to ACTIVE with sandbox_path None") +def step_cowcov_force_active_none(context: Context) -> None: + """Create a sandbox stub with status=ACTIVE and _sandbox_path=None.""" + d = _cowcov_make_test_dir() + context.cowcov_test_dir = d + sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d) + sandbox._status = SandboxStatus.ACTIVE + sandbox._sandbox_path = None + context.cowcov_sandbox = sandbox + context.cowcov_error = None + context.cowcov_commit_result = None + context.cowcov_resolved_paths = [] + context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d)) + + +@given("a cowcov sandbox forced to CREATED with sandbox_path None") +def step_cowcov_force_created_none(context: Context) -> None: + """Create a sandbox stub with status=CREATED and _sandbox_path=None.""" + d = _cowcov_make_test_dir() + context.cowcov_test_dir = d + sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d) + sandbox._status = SandboxStatus.CREATED + sandbox._sandbox_path = None + context.cowcov_sandbox = sandbox + context.cowcov_error = None + context.cowcov_commit_result = None + context.cowcov_resolved_paths = [] + context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d)) + + +@given("a cowcov sandbox in PENDING state with sandbox_path None") +def step_cowcov_pending_none(context: Context) -> None: + """Create a sandbox that is still PENDING (never created).""" + d = _cowcov_make_test_dir() + context.cowcov_test_dir = d + sandbox = CopyOnWriteSandbox(resource_id="res-cowcov", original_path=d) + assert sandbox._sandbox_path is None, "Expected _sandbox_path to be None" + assert sandbox.status == SandboxStatus.PENDING, "Expected PENDING status" + context.cowcov_sandbox = sandbox + context.cowcov_error = None + context.cowcov_commit_result = None + context._cleanup_handlers.append(lambda: _cowcov_cleanup_dir(d)) + + +@given("cowcov _compute_diff is patched to return a changed root file") +def step_cowcov_patch_compute_diff(context: Context) -> None: + """Patch _compute_diff to return one changed file at root level.""" + patcher = patch.object( + CopyOnWriteSandbox, + "_compute_diff", + return_value=(["rootfile.txt"], [], []), + ) + patcher.start() + context._cleanup_handlers.append(patcher.stop) + + +@given("cowcov os.path.dirname is patched to return empty string") +def step_cowcov_patch_dirname(context: Context) -> None: + """Patch os.path.dirname in the copy_on_write module to return ''.""" + patcher = patch( + "cleveragents.infrastructure.sandbox.copy_on_write.os.path.dirname", + return_value="", + ) + patcher.start() + context._cleanup_handlers.append(patcher.stop) + + +@given("cowcov shutil.copy2 is patched to no-op") +def step_cowcov_patch_copy2_noop(context: Context) -> None: + """Patch shutil.copy2 in the copy_on_write module to do nothing.""" + makedirs_mock = MagicMock() + patcher_copy2 = patch( + "cleveragents.infrastructure.sandbox.copy_on_write.shutil.copy2", + ) + patcher_makedirs = patch( + "cleveragents.infrastructure.sandbox.copy_on_write.os.makedirs", + makedirs_mock, + ) + patcher_copy2.start() + patcher_makedirs.start() + context.cowcov_makedirs_mock = makedirs_mock + context._cleanup_handlers.append(patcher_copy2.stop) + context._cleanup_handlers.append(patcher_makedirs.stop) + + +@given('a cowcov file "existing.txt" is modified in the sandbox') +def step_cowcov_modify_existing(context: Context) -> None: + """Modify a file inside the sandbox copy.""" + sandbox_path = context.cowcov_sandbox._sandbox_path + assert sandbox_path is not None, "Sandbox path must be set" + fpath = os.path.join(sandbox_path, "existing.txt") + with open(fpath, "w") as f: + f.write("modified for OSError test") + + +@given("cowcov shutil.copy2 is patched to raise OSError") +def step_cowcov_patch_copy2_oserror(context: Context) -> None: + """Patch shutil.copy2 in copy_on_write module to raise OSError.""" + patcher = patch( + "cleveragents.infrastructure.sandbox.copy_on_write.shutil.copy2", + side_effect=OSError("mocked copy2 failure"), + ) + patcher.start() + context._cleanup_handlers.append(patcher.stop) + + +@given("cowcov shutil.copytree is patched to raise OSError for rollback") +def step_cowcov_patch_copytree_rollback(context: Context) -> None: + """Patch shutil.copytree to raise OSError (for rollback re-copy).""" + patcher = patch( + "cleveragents.infrastructure.sandbox.copy_on_write.shutil.copytree", + side_effect=OSError("mocked copytree failure during rollback"), + ) + patcher.start() + context._cleanup_handlers.append(patcher.stop) + + +@given("the cowcov sandbox parent directory is manually removed") +def step_cowcov_remove_parent(context: Context) -> None: + """Remove the sandbox parent temp dir before cleanup.""" + sandbox_path = context.cowcov_sandbox._sandbox_path + assert sandbox_path is not None, "Sandbox path must be set" + parent = os.path.dirname(sandbox_path) + if os.path.exists(parent): + shutil.rmtree(parent, ignore_errors=True) + + +# ---------------------------------------------------------------------- +# When +# ---------------------------------------------------------------------- + + +@when('a cowcov sandbox create is attempted for plan "{plan_id}"') +def step_cowcov_attempt_create(context: Context, plan_id: str) -> None: + """Attempt to create a sandbox, capturing any error.""" + context.cowcov_sandbox = CopyOnWriteSandbox( + resource_id="res-cowcov", + original_path=context.cowcov_test_dir, + ) + try: + context.cowcov_sandbox.create(plan_id) + except (SandboxCreationError, SandboxStateError) as exc: + context.cowcov_error = exc + + +@when('cowcov get_path is called with "{path}"') +def step_cowcov_get_path(context: Context, path: str) -> None: + """Call get_path and record the resolved path.""" + resolved = context.cowcov_sandbox.get_path(path) + context.cowcov_resolved_paths.append(resolved) + + +@when('cowcov get_path is attempted with "{path}"') +def step_cowcov_attempt_get_path(context: Context, path: str) -> None: + """Attempt get_path, capturing any error.""" + try: + context.cowcov_sandbox.get_path(path) + except (SandboxStateError, ValueError) as exc: + context.cowcov_error = exc + + +@when("cowcov commit is attempted") +def step_cowcov_attempt_commit(context: Context) -> None: + """Attempt commit, capturing any error.""" + try: + context.cowcov_commit_result = context.cowcov_sandbox.commit() + except (SandboxStateError, SandboxCommitError) as exc: + context.cowcov_error = exc + + +@when('the cowcov file "existing.txt" is deleted from both sandbox and original') +def step_cowcov_delete_from_both(context: Context) -> None: + """Delete a file from both sandbox copy and original directory.""" + sandbox_path = context.cowcov_sandbox._sandbox_path + assert sandbox_path is not None, "Sandbox path must be set" + + for base in (sandbox_path, context.cowcov_test_dir): + fpath = os.path.join(base, "existing.txt") + if os.path.exists(fpath): + os.remove(fpath) + + +@when("cowcov rollback is attempted") +def step_cowcov_attempt_rollback(context: Context) -> None: + """Attempt rollback, capturing any error.""" + try: + context.cowcov_sandbox.rollback() + except (SandboxStateError, SandboxRollbackError) as exc: + context.cowcov_error = exc + + +@when("cowcov cleanup is called") +def step_cowcov_cleanup(context: Context) -> None: + """Call cleanup on the sandbox.""" + context.cowcov_sandbox.cleanup() + + +# ---------------------------------------------------------------------- +# Then +# ---------------------------------------------------------------------- + + +@then("a cowcov SandboxCreationError should be raised") +def step_cowcov_check_creation_error(context: Context) -> None: + """Assert a SandboxCreationError was captured.""" + assert context.cowcov_error is not None, ( + "Expected SandboxCreationError but no error occurred" + ) + assert isinstance(context.cowcov_error, SandboxCreationError), ( + f"Expected SandboxCreationError, got {type(context.cowcov_error).__name__}: " + f"{context.cowcov_error}" + ) + + +@then('the cowcov error message should contain "{fragment}"') +def step_cowcov_error_message_contains(context: Context, fragment: str) -> None: + """Assert the error message contains the given fragment.""" + assert context.cowcov_error is not None, "No error was captured" + assert fragment in str(context.cowcov_error), ( + f"Expected '{fragment}' in error message, got: {context.cowcov_error}" + ) + + +@then('the cowcov sandbox status should be "{status}"') +def step_cowcov_check_status(context: Context, status: str) -> None: + """Assert the sandbox status matches the expected value.""" + expected = SandboxStatus(status) + assert context.cowcov_sandbox.status == expected, ( + f"Expected status {expected.value}, got {context.cowcov_sandbox.status.value}" + ) + + +@then("both cowcov resolved paths should be valid") +def step_cowcov_two_paths_valid(context: Context) -> None: + """Assert two paths were resolved and both are inside the sandbox.""" + assert len(context.cowcov_resolved_paths) == 2, ( + f"Expected 2 resolved paths, got {len(context.cowcov_resolved_paths)}" + ) + sandbox_path = context.cowcov_sandbox._sandbox_path + assert sandbox_path is not None, "Sandbox path should be set" + for p in context.cowcov_resolved_paths: + assert p.startswith(sandbox_path), ( + f"Resolved path {p} is not inside sandbox {sandbox_path}" + ) + + +@then('a cowcov SandboxStateError should be raised with message "{msg}"') +def step_cowcov_check_state_error_msg(context: Context, msg: str) -> None: + """Assert a SandboxStateError with the expected message was captured.""" + assert context.cowcov_error is not None, ( + "Expected SandboxStateError but no error occurred" + ) + assert isinstance(context.cowcov_error, SandboxStateError), ( + f"Expected SandboxStateError, got {type(context.cowcov_error).__name__}: " + f"{context.cowcov_error}" + ) + assert msg in str(context.cowcov_error), ( + f"Expected '{msg}' in error message, got: {context.cowcov_error}" + ) + + +@then("the cowcov commit should succeed") +def step_cowcov_commit_success(context: Context) -> None: + """Assert commit completed without error.""" + assert context.cowcov_error is None, ( + f"Expected no error but got: {context.cowcov_error}" + ) + assert context.cowcov_commit_result is not None, "Expected a commit result" + assert context.cowcov_commit_result.success is True, ( + "Expected commit result success=True" + ) + + +@then("cowcov os.makedirs should not have been called") +def step_cowcov_makedirs_not_called(context: Context) -> None: + """Assert os.makedirs was not invoked (dst_dir was empty).""" + assert context.cowcov_makedirs_mock is not None, "makedirs mock not set up" + context.cowcov_makedirs_mock.assert_not_called() + + +@then("a cowcov SandboxCommitError should be raised") +def step_cowcov_check_commit_error(context: Context) -> None: + """Assert a SandboxCommitError was captured.""" + assert context.cowcov_error is not None, ( + "Expected SandboxCommitError but no error occurred" + ) + assert isinstance(context.cowcov_error, SandboxCommitError), ( + f"Expected SandboxCommitError, got {type(context.cowcov_error).__name__}: " + f"{context.cowcov_error}" + ) + + +@then("a cowcov SandboxRollbackError should be raised") +def step_cowcov_check_rollback_error(context: Context) -> None: + """Assert a SandboxRollbackError was captured.""" + assert context.cowcov_error is not None, ( + "Expected SandboxRollbackError but no error occurred" + ) + assert isinstance(context.cowcov_error, SandboxRollbackError), ( + f"Expected SandboxRollbackError, got {type(context.cowcov_error).__name__}: " + f"{context.cowcov_error}" + ) diff --git a/features/steps/skill_cli_coverage_r3_steps.py b/features/steps/skill_cli_coverage_r3_steps.py new file mode 100644 index 00000000..0b7e1ed0 --- /dev/null +++ b/features/steps/skill_cli_coverage_r3_steps.py @@ -0,0 +1,603 @@ +# pyright: reportRedeclaration=false +"""Step definitions for skill_cli_coverage_r3.feature. + +Targets remaining uncovered branches in +``cleveragents.cli.commands.skill``: tools --refresh, list/show +capability-summary fallback, refresh agent_skills discovery, refresh +errors, long description truncation, tools ValueError, and inline tool +capability display. + +All step text uses the ``r3skill-`` prefix to avoid collisions with +existing step definitions. +""" + +from __future__ import annotations + +import json +import tempfile +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import MagicMock, patch + +from behave import given, then, when +from behave.runner import Context +from typer.testing import CliRunner + +from cleveragents.cli.commands.skill import ( + _get_skill_service, + _reset_skill_service, +) +from cleveragents.cli.commands.skill import app as skill_app +from cleveragents.domain.models.core.skill import ( + Skill, + SkillAgentSource, + SkillInclude, + SkillInlineTool, + SkillMcpSource, +) +from cleveragents.domain.models.core.tool import ToolCapability, ToolSource + +# ── helpers ───────────────────────────────────────────────── + + +def _make_skill( + name: str, + description: str = "test skill", + tool_refs: list[str] | None = None, + includes: list[SkillInclude] | None = None, + mcp_servers: list[SkillMcpSource] | None = None, + agent_skills: list[SkillAgentSource] | None = None, + anonymous_tools: list[SkillInlineTool] | None = None, +) -> Skill: + """Create a Skill domain object with optional components.""" + return Skill( + name=name, + description=description, + tool_refs=tool_refs or [], + includes=includes or [], + mcp_servers=mcp_servers or [], + agent_skills=agent_skills or [], + anonymous_tools=anonymous_tools or [], + ) + + +def _register_skill(context: Context, skill: Skill) -> None: + """Register a skill in the service with timestamps.""" + from datetime import datetime + + now = datetime.now() + context.r3_service._skills[skill.name] = skill + context.r3_service._created_at[skill.name] = now + context.r3_service._updated_at[skill.name] = now + + +def _write_yaml(content: str) -> str: + """Write YAML content to a temp file and return the path.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False, prefix="r3skill_" + ) as tmp: + tmp.write(content) + tmp.flush() + return tmp.name + + +# ── Stub classes for mocking ──────────────────────────────── + + +@dataclass(slots=True) +class StubResolvedValue: + """Stub for ConfigService.resolve return value.""" + + value: Any = None + + +@dataclass(slots=True) +class StubDiscoveredAgentSkill: + """Stub for DiscoveredAgentSkill.""" + + name: str = "stub-agent" + description: str = "stub" + path: str = "/tmp/stub" + + +@dataclass(slots=True) +class StubDiscoveryResult: + """Stub for DiscoveryResult.""" + + discovered: list[Any] = field(default_factory=list) + conflicts: list[Any] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +# ── Background ────────────────────────────────────────────── + + +@given("r3skill- a fresh skill CLI service") +def step_r3_background(context: Context) -> None: + """Reset module-level singleton and prepare runner/service.""" + _reset_skill_service() + context.r3_runner = CliRunner() + context.r3_service = _get_skill_service() + context.r3_result = None + context.r3_temp_paths = [] # list[str] + context.r3_patches = [] # list[Any] + context.r3_second_temp = None # str | None + + +# ── Given steps ───────────────────────────────────────────── + + +@given('r3skill- a registered skill "{name}" with tool_refs') +def step_r3_register_skill_with_tool_refs(context: Context, name: str) -> None: + """Directly insert a skill with builtin tool_refs into the service.""" + skill = _make_skill(name=name, tool_refs=["builtin/read-file"]) + _register_skill(context, skill) + + +@given('r3skill- a registered skill "{name}" with a description longer than 50 chars') +def step_r3_register_skill_long_desc(context: Context, name: str) -> None: + """Register a skill with a description exceeding 50 characters.""" + long_desc = "A" * 60 + skill = _make_skill(name=name, description=long_desc, tool_refs=["builtin/echo"]) + _register_skill(context, skill) + + +@given('r3skill- a registered skill "{name}" with inline tools having capabilities') +def step_r3_register_inline_caps(context: Context, name: str) -> None: + """Register a skill with inline tools that have writes and checkpointable.""" + inline = SkillInlineTool( + description="Inline with caps", + source=ToolSource.CUSTOM, + code="return True", + timeout=300, + capability=ToolCapability( + read_only=False, + writes=True, + checkpointable=True, + ), + ) + skill = _make_skill(name=name, anonymous_tools=[inline]) + _register_skill(context, skill) + + +@given('r3skill- timestamps for "{name}" are removed') +def step_r3_remove_timestamps(context: Context, name: str) -> None: + """Remove created_at and updated_at for a skill.""" + context.r3_service._created_at.pop(name, None) + context.r3_service._updated_at.pop(name, None) + + +@given( + "r3skill- agent_skills_paths config resolves to a valid path with discovered skills" +) +def step_r3_agent_paths_valid(context: Context) -> None: + """Set up mocks so refresh/tools --refresh discovers agent skills.""" + stub_resolved = StubResolvedValue(value="/tmp/skills") + stub_discovery = StubDiscoveryResult( + discovered=[StubDiscoveredAgentSkill()], + errors=[], + ) + + p1 = patch( + "cleveragents.application.services.config_service.ConfigService", + ) + mock_config_cls = p1.start() + mock_instance = MagicMock() + mock_instance.resolve.return_value = stub_resolved + mock_config_cls.return_value = mock_instance + context.r3_patches.append(p1) + + p2 = patch( + "cleveragents.skills.discovery.parse_agent_skills_paths", + return_value=["/tmp/skills"], + ) + p2.start() + context.r3_patches.append(p2) + + p3 = patch( + "cleveragents.skills.discovery.discover_agent_skills", + return_value=stub_discovery, + ) + p3.start() + context.r3_patches.append(p3) + + +@given("r3skill- agent_skills_paths config resolves to empty string") +def step_r3_agent_paths_empty(context: Context) -> None: + """Set up mock so agent_skills_paths resolves to empty.""" + stub_resolved = StubResolvedValue(value="") + + p1 = patch( + "cleveragents.application.services.config_service.ConfigService", + ) + mock_config_cls = p1.start() + mock_instance = MagicMock() + mock_instance.resolve.return_value = stub_resolved + mock_config_cls.return_value = mock_instance + context.r3_patches.append(p1) + + +@given("r3skill- agent_skills_paths config resolves with discovery errors") +def step_r3_agent_paths_errors(context: Context) -> None: + """Set up mocks so discovery returns errors.""" + stub_resolved = StubResolvedValue(value="/tmp/skills") + stub_discovery = StubDiscoveryResult( + discovered=[], + errors=["Error scanning /tmp/skills: permission denied"], + ) + + p1 = patch( + "cleveragents.application.services.config_service.ConfigService", + ) + mock_config_cls = p1.start() + mock_instance = MagicMock() + mock_instance.resolve.return_value = stub_resolved + mock_config_cls.return_value = mock_instance + context.r3_patches.append(p1) + + p2 = patch( + "cleveragents.skills.discovery.parse_agent_skills_paths", + return_value=["/tmp/skills"], + ) + p2.start() + context.r3_patches.append(p2) + + p3 = patch( + "cleveragents.skills.discovery.discover_agent_skills", + return_value=stub_discovery, + ) + p3.start() + context.r3_patches.append(p3) + + +@given('r3skill- resolve_tools will raise ValueError for "{name}"') +def step_r3_resolve_tools_valueerror(context: Context, name: str) -> None: + """Patch resolve_tools to raise ValueError for a specific skill.""" + original_resolve = context.r3_service.resolve_tools + + def patched_resolve(skill_name: str) -> Any: + """Raise ValueError for targeted skill only.""" + if skill_name == name: + raise ValueError(f"Cycle detected in {name}") + return original_resolve(skill_name) + + p = patch.object(context.r3_service, "resolve_tools", side_effect=patched_resolve) + p.start() + context.r3_patches.append(p) + + +@given("r3skill- compute_capability_summary will raise ValueError") +def step_r3_cap_summary_valueerror(context: Context) -> None: + """Patch compute_capability_summary to raise ValueError.""" + p = patch.object( + context.r3_service, + "compute_capability_summary", + side_effect=ValueError("cap summary error"), + ) + p.start() + context.r3_patches.append(p) + + +@given("r3skill- compute_capability_summary will raise KeyError") +def step_r3_cap_summary_keyerror(context: Context) -> None: + """Patch compute_capability_summary to raise KeyError.""" + p = patch.object( + context.r3_service, + "compute_capability_summary", + side_effect=KeyError("missing"), + ) + p.start() + context.r3_patches.append(p) + + +@given("r3skill- list_skills will raise RuntimeError") +def step_r3_list_skills_runtime_error(context: Context) -> None: + """Patch list_skills to raise RuntimeError.""" + p = patch.object( + context.r3_service, + "list_skills", + side_effect=RuntimeError("unexpected error"), + ) + p.start() + context.r3_patches.append(p) + + +@given('r3skill- a temp YAML for "{name}" that includes "{included}"') +def step_r3_temp_yaml_with_include(context: Context, name: str, included: str) -> None: + """Create a temp YAML config that includes another skill.""" + yaml_content = f"""\ +name: {name} +description: Skill with include +tools: + - name: builtin/write-file +includes: + - name: {included} +""" + path = _write_yaml(yaml_content) + context.r3_temp_paths.append(path) + + +@given('r3skill- a temp YAML for "{name}" with tool_refs and MCP') +def step_r3_temp_yaml_tools_and_mcp(context: Context, name: str) -> None: + """Create a temp YAML config with tool_refs and MCP servers.""" + yaml_content = f"""\ +name: {name} +description: Original skill with MCP +tools: + - name: builtin/read-file +mcp_servers: + - name: mcp-server-a + transport: stdio + tool_filter: + include: + - tool-x +""" + path = _write_yaml(yaml_content) + context.r3_temp_paths.append(path) + + +@given('r3skill- the skill "{name}" is pre-registered via add') +def step_r3_pre_register_via_add(context: Context, name: str) -> None: + """Register a skill via the CLI add command.""" + config_path = context.r3_temp_paths[-1] + result = context.r3_runner.invoke( + skill_app, ["add", "--config", config_path, "--format", "json"] + ) + assert result.exit_code == 0, f"Pre-registration failed: {result.output}" + + +@given('r3skill- a second temp YAML for "{name}" with different includes and MCP') +def step_r3_second_yaml_diff_includes_mcp(context: Context, name: str) -> None: + """Create a second YAML config with different includes and MCP.""" + yaml_content = f"""\ +name: {name} +description: Updated skill with changed includes and MCP +tools: + - name: builtin/write-file +includes: + - name: local/new-include +mcp_servers: + - name: mcp-server-b + transport: stdio + tool_filter: + include: + - tool-y +""" + path = _write_yaml(yaml_content) + context.r3_second_temp = path + + +# ── When steps ────────────────────────────────────────────── + + +@when('r3skill- I invoke tools "{name}" with --refresh') +def step_r3_invoke_tools_refresh(context: Context, name: str) -> None: + """Invoke skill tools with --refresh flag.""" + context.r3_result = context.r3_runner.invoke( + skill_app, ["tools", name, "--refresh"] + ) + _stop_patches(context) + + +@when('r3skill- I invoke tools "{name}" in rich format') +def step_r3_invoke_tools_rich(context: Context, name: str) -> None: + """Invoke skill tools in default rich format.""" + context.r3_result = context.r3_runner.invoke(skill_app, ["tools", name]) + _stop_patches(context) + + +@when('r3skill- I invoke list in format "{fmt}"') +def step_r3_invoke_list_fmt(context: Context, fmt: str) -> None: + """Invoke skill list with a specified format.""" + context.r3_result = context.r3_runner.invoke(skill_app, ["list", "--format", fmt]) + _stop_patches(context) + + +@when("r3skill- I invoke list in rich format") +def step_r3_invoke_list_rich(context: Context) -> None: + """Invoke skill list in default rich format.""" + context.r3_result = context.r3_runner.invoke(skill_app, ["list"]) + _stop_patches(context) + + +@when('r3skill- I invoke show "{name}" in format "{fmt}"') +def step_r3_invoke_show_fmt(context: Context, name: str, fmt: str) -> None: + """Invoke skill show with a specified format.""" + context.r3_result = context.r3_runner.invoke( + skill_app, ["show", name, "--format", fmt] + ) + _stop_patches(context) + + +@when('r3skill- I invoke show "{name}" in rich format') +def step_r3_invoke_show_rich(context: Context, name: str) -> None: + """Invoke skill show in default rich format.""" + context.r3_result = context.r3_runner.invoke(skill_app, ["show", name]) + _stop_patches(context) + + +@when('r3skill- I invoke refresh "{name}" in rich format') +def step_r3_invoke_refresh_rich(context: Context, name: str) -> None: + """Invoke skill refresh for a single skill in rich format.""" + context.r3_result = context.r3_runner.invoke(skill_app, ["refresh", name]) + _stop_patches(context) + + +@when('r3skill- I invoke refresh "{name}" in format "{fmt}"') +def step_r3_invoke_refresh_fmt(context: Context, name: str, fmt: str) -> None: + """Invoke skill refresh with a specified format.""" + context.r3_result = context.r3_runner.invoke( + skill_app, ["refresh", name, "--format", fmt] + ) + _stop_patches(context) + + +@when("r3skill- I invoke refresh --all in rich format") +def step_r3_invoke_refresh_all_rich(context: Context) -> None: + """Invoke skill refresh --all in rich format.""" + context.r3_result = context.r3_runner.invoke(skill_app, ["refresh", "--all"]) + _stop_patches(context) + + +@when('r3skill- I invoke refresh --all in format "{fmt}"') +def step_r3_invoke_refresh_all_fmt(context: Context, fmt: str) -> None: + """Invoke skill refresh --all with a specified format.""" + context.r3_result = context.r3_runner.invoke( + skill_app, ["refresh", "--all", "--format", fmt] + ) + _stop_patches(context) + + +@when("r3skill- I invoke add with the temp config in rich format") +def step_r3_invoke_add_rich(context: Context) -> None: + """Invoke skill add with the most recent temp config.""" + config_path = context.r3_temp_paths[-1] + context.r3_result = context.r3_runner.invoke( + skill_app, ["add", "--config", config_path] + ) + _stop_patches(context) + + +@when("r3skill- I invoke add with the second config and --update in rich format") +def step_r3_invoke_add_update_rich(context: Context) -> None: + """Invoke skill add --update with the second temp config.""" + assert context.r3_second_temp is not None + context.r3_result = context.r3_runner.invoke( + skill_app, ["add", "--config", context.r3_second_temp, "--update"] + ) + _stop_patches(context) + + +@when('r3skill- I invoke remove "{name}" without --yes and confirm') +def step_r3_invoke_remove_confirm(context: Context, name: str) -> None: + """Invoke skill remove without --yes and type y to confirm.""" + context.r3_result = context.r3_runner.invoke( + skill_app, ["remove", name], input="y\n" + ) + _stop_patches(context) + + +# ── Then steps ────────────────────────────────────────────── + + +@then("r3skill- the CLI exit code should be 0") +def step_r3_exit_code_0(context: Context) -> None: + """Assert CLI exited successfully.""" + assert context.r3_result is not None + assert context.r3_result.exit_code == 0, ( + f"Expected exit_code=0, got {context.r3_result.exit_code}\n" + f"Output: {context.r3_result.output}" + ) + + +@then("r3skill- the CLI exit code should not be 0") +def step_r3_exit_code_not_0(context: Context) -> None: + """Assert CLI exited with an error.""" + assert context.r3_result is not None + assert context.r3_result.exit_code != 0, ( + f"Expected non-zero exit code, got {context.r3_result.exit_code}\n" + f"Output: {context.r3_result.output}" + ) + + +@then('r3skill- the output should contain "{text}"') +def step_r3_output_contains(context: Context, text: str) -> None: + """Assert the CLI output contains the specified text.""" + assert context.r3_result is not None + assert text in context.r3_result.output, ( + f"Expected output to contain '{text}'\n" + f"Actual output: {context.r3_result.output}" + ) + + +@then('r3skill- the output should not contain "{text}"') +def step_r3_output_not_contains(context: Context, text: str) -> None: + """Assert the CLI output does not contain the specified text.""" + assert context.r3_result is not None + assert text not in context.r3_result.output, ( + f"Expected output NOT to contain '{text}'\n" + f"Actual output: {context.r3_result.output}" + ) + + +@then("r3skill- the output should be valid JSON") +def step_r3_output_valid_json(context: Context) -> None: + """Assert the CLI output parses as valid JSON.""" + assert context.r3_result is not None + try: + context.r3_parsed_json = json.loads(context.r3_result.output) + except json.JSONDecodeError as e: + raise AssertionError( + f"Output is not valid JSON: {e}\nOutput: {context.r3_result.output}" + ) from e + + +@then('r3skill- the JSON output should have key "{key}"') +def step_r3_json_has_key(context: Context, key: str) -> None: + """Assert a key is present in the parsed JSON dict.""" + data: Any = context.r3_parsed_json + if isinstance(data, list): + assert len(data) > 0, "JSON list is empty" + assert key in data[0], ( + f"Key '{key}' not found in first element: {list(data[0].keys())}" + ) + else: + assert key in data, f"Key '{key}' not found in: {list(data.keys())}" + + +@then('r3skill- the JSON list first item should have key "{key}"') +def step_r3_json_list_first_has_key(context: Context, key: str) -> None: + """Assert the first item in the JSON list has the given key.""" + data: Any = context.r3_parsed_json + assert isinstance(data, list), f"Expected JSON list, got {type(data).__name__}" + assert len(data) > 0, "JSON list is empty" + assert key in data[0], ( + f"Key '{key}' not found in first element: {list(data[0].keys())}" + ) + + +@then('r3skill- the JSON list first item "{key}" should be null') +def step_r3_json_list_first_key_null(context: Context, key: str) -> None: + """Assert the first item's key is null/None.""" + data: Any = context.r3_parsed_json + assert isinstance(data, list), f"Expected JSON list, got {type(data).__name__}" + assert len(data) > 0, "JSON list is empty" + assert key in data[0], ( + f"Key '{key}' not found in first element: {list(data[0].keys())}" + ) + assert data[0][key] is None, f"Expected '{key}' to be null, got {data[0][key]}" + + +@then('r3skill- the JSON output key "{key}" should be null') +def step_r3_json_key_null(context: Context, key: str) -> None: + """Assert a top-level JSON key is null/None.""" + data: Any = context.r3_parsed_json + assert isinstance(data, dict), f"Expected JSON dict, got {type(data).__name__}" + assert key in data, f"Key '{key}' not found in: {list(data.keys())}" + assert data[key] is None, f"Expected '{key}' to be null, got {data[key]}" + + +@then("r3skill- the long description in the output should be truncated") +def step_r3_long_desc_truncated(context: Context) -> None: + """Assert the output contains a truncated description (with ... or ellipsis).""" + assert context.r3_result is not None + output = context.r3_result.output + # The code adds "..." but Rich may render it as unicode ellipsis + has_dots = "..." in output or "\u2026" in output + assert has_dots, ( + f"Expected truncated description (... or \u2026) in output\n" + f"Actual output: {output}" + ) + + +# ── Cleanup helper ────────────────────────────────────────── + + +def _stop_patches(context: Context) -> None: + """Stop all active patches after a When step.""" + import contextlib + + for p in getattr(context, "r3_patches", []): + with contextlib.suppress(RuntimeError): + p.stop() + context.r3_patches = []