test(persistence): add decision persistence suites #438

Closed
brent.edwards wants to merge 2 commits from feature/m4-decision-tests into master
25 changed files with 5679 additions and 5 deletions
+3
View File
@@ -42,6 +42,9 @@
structured error types for configuration, provider, and file I/O failures, error code
mapping, bare-except prohibition, and secret redaction in error details. (#320)
- Added 32 BDD scenarios to boost unit test coverage from 97.0% to 97.2%.
- Added Behave BDD scenarios for six under-tested modules (container, correction
service, plan lifecycle service, plan CLI, skill CLI, database models) to exercise
uncovered lines, exception-handling paths, and partial branches. (#446)
- Fixed failing Robot Framework integration tests related to security secrets handling.
- Fixed style check violations across the codebase.
- Fixed failing unit tests.
+2 -3
View File
@@ -9,6 +9,7 @@ Benchmarks the extended test scenarios added in #326:
from __future__ import annotations
import contextlib
import importlib
import sys
from datetime import datetime
@@ -254,10 +255,8 @@ class ActorValidationErrorSuite:
def time_invalid_actor_rejection(self) -> None:
"""Benchmark rejecting an invalid actor name."""
try:
with contextlib.suppress(ValidationError):
validate_namespaced_actor("bad-format", "--strategy-actor")
except ValidationError:
pass
def time_plan_use_invalid_actor_cli(self) -> None:
"""Benchmark CLI rejection of invalid actor flag."""
+2 -2
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
import importlib
import sys
import tempfile
from pathlib import Path
from typing import Any
@@ -32,6 +31,8 @@ import cleveragents # noqa: E402
importlib.reload(cleveragents)
from mcp_stub_server import McpStubServer # noqa: E402
from cleveragents.actor.loader import ActorLoader # noqa: E402
from cleveragents.actor.schema import ActorConfigSchema # noqa: E402
from cleveragents.domain.models.core.skill import Skill, SkillInlineTool # noqa: E402
@@ -49,7 +50,6 @@ from cleveragents.tool.lifecycle import ( # noqa: E402
ToolResult,
ToolRuntime,
)
from mcp_stub_server import McpStubServer # noqa: E402
_FIXTURES_DIR = Path(__file__).resolve().parents[1] / "features" / "fixtures" / "m2"
+91
View File
@@ -0,0 +1,91 @@
Feature: Container module coverage round 2
As a developer maintaining container.py
I want every uncovered line and branch exercised
So that container.py achieves full coverage
# -------------------------------------------------------------------
# get_ai_provider non-mock paths (lines 51 False, 66-67, 69)
# -------------------------------------------------------------------
Scenario: AI provider returns None when mock disabled and no providers configured
Given r2cont- the mock AI environment flag is disabled
And r2cont- a provider registry with no configured providers
When r2cont- I call get_ai_provider with the mocked registry
Then r2cont- the ai provider result should be None
Scenario: AI provider returns real provider when mock disabled and providers exist
Given r2cont- the mock AI environment flag is disabled
And r2cont- a provider registry with configured providers
When r2cont- I call get_ai_provider with the mocked registry
Then r2cont- the ai provider result should not be None
# -------------------------------------------------------------------
# get_ai_provider features path already on sys.path (line 57 partial)
# -------------------------------------------------------------------
Scenario: AI provider with features path already on sys.path does not duplicate it
Given r2cont- the mock AI environment flag is enabled
And r2cont- the features path is already on sys.path
When r2cont- I call get_ai_provider without registry args
Then r2cont- the features path should appear exactly once on sys.path
# -------------------------------------------------------------------
# _build_project_resource_link_repo (lines 125-130)
# -------------------------------------------------------------------
Scenario: Build project resource link repo creates valid repository
When r2cont- I build a project resource link repo with an in-memory database
Then r2cont- the result should be a ProjectResourceLinkRepository instance
# -------------------------------------------------------------------
# get_database_url env var branches (lines 82, 87)
# -------------------------------------------------------------------
Scenario: get_database_url returns fallback when no env vars are set
Given r2cont- no database URL environment variables are set
When r2cont- I call get_database_url
Then r2cont- the database URL should start with "sqlite:///"
Scenario: get_database_url returns CLEVERAGENTS_DATABASE_URL when set
Given r2cont- the env var "CLEVERAGENTS_DATABASE_URL" is set to "sqlite:///explicit.db"
When r2cont- I call get_database_url
Then r2cont- the database URL should be "sqlite:///explicit.db"
Scenario: get_database_url returns CLEVERAGENTS_TEST_DATABASE_URL when primary is unset
Given r2cont- the env var "CLEVERAGENTS_DATABASE_URL" is cleared
And r2cont- the env var "CLEVERAGENTS_TEST_DATABASE_URL" is set to "sqlite:///test.db"
When r2cont- I call get_database_url
Then r2cont- the database URL should be "sqlite:///test.db"
Scenario: get_database_url skips empty env var values
Given r2cont- the env var "CLEVERAGENTS_DATABASE_URL" is set to empty
And r2cont- the env var "CLEVERAGENTS_TEST_DATABASE_URL" is set to empty
When r2cont- I call get_database_url
Then r2cont- the database URL should start with "sqlite:///"
# -------------------------------------------------------------------
# get_container cached instance (line 256 False branch)
# -------------------------------------------------------------------
Scenario: get_container returns the cached instance on subsequent calls
Given r2cont- the global container has been reset
When r2cont- I call get_container twice
Then r2cont- both calls should return the same container instance
# -------------------------------------------------------------------
# override_providers non-existent attribute (line 285 False)
# -------------------------------------------------------------------
Scenario: override_providers ignores non-existent provider names
Given r2cont- the global container has been reset
When r2cont- I override a provider with a non-existent name
Then r2cont- no error should have been raised
# -------------------------------------------------------------------
# override_providers non-database_url provider (line 288 else)
# -------------------------------------------------------------------
Scenario: override_providers handles non-database_url providers via Object
Given r2cont- the global container has been reset
When r2cont- I override the settings provider with a mock value
Then r2cont- the settings provider should return the mock value
@@ -0,0 +1,89 @@
Feature: CorrectionService exception-handling coverage (round 2)
As a developer maintaining CorrectionService
I want the exception-handling paths in execute_revert and execute_append fully tested
So that lines 254-262 and 320-328 of correction_service.py achieve coverage
Background:
Given r2corr-a fresh correction service
# -------------------------------------------------------------------
# execute_revert exception path (lines 254-262)
# -------------------------------------------------------------------
Scenario: execute_revert catches internal error and returns FAILED result
Given r2corr-a stored revert correction for plan "P1" targeting "D1"
And r2corr-analyze_impact is patched to raise RuntimeError "boom in analyze"
When r2corr-I execute revert on the stored correction
Then r2corr-the revert result status should be "failed"
And r2corr-the revert result error_message should contain "boom in analyze"
And r2corr-the stored correction status should be "failed"
And r2corr-the first attempt should have success false
And r2corr-the first attempt details should contain error key
And r2corr-the first attempt details error should contain "boom in analyze"
And r2corr-the first attempt completed_at should be set
And r2corr-the result should be stored in the service results dict
Scenario: execute_revert exception path preserves attempt count
Given r2corr-a stored revert correction for plan "P1" targeting "D2"
And r2corr-analyze_impact is patched to raise ValueError "bad value"
When r2corr-I execute revert on the stored correction
Then r2corr-the attempts for the stored correction should have 1 entry
And r2corr-the first attempt should have success false
Scenario: execute_revert exception with TypeError message
Given r2corr-a stored revert correction for plan "P1" targeting "D3"
And r2corr-analyze_impact is patched to raise TypeError "wrong type"
When r2corr-I execute revert on the stored correction
Then r2corr-the revert result status should be "failed"
And r2corr-the revert result error_message should contain "wrong type"
And r2corr-the stored correction status should be "failed"
# -------------------------------------------------------------------
# execute_append exception path (lines 320-328)
# -------------------------------------------------------------------
Scenario: execute_append catches internal error and returns FAILED result
Given r2corr-a stored append correction for plan "P1" targeting "A1"
And r2corr-ULID is patched to raise RuntimeError "ulid broken"
When r2corr-I execute append on the stored correction
Then r2corr-the append result status should be "failed"
And r2corr-the append result error_message should contain "ulid broken"
And r2corr-the stored correction status should be "failed"
And r2corr-the first attempt should have success false
And r2corr-the first attempt details should contain error key
And r2corr-the first attempt details error should contain "ulid broken"
And r2corr-the first attempt completed_at should be set
And r2corr-the append result should be stored in the service results dict
Scenario: execute_append exception path preserves attempt count
Given r2corr-a stored append correction for plan "P1" targeting "A2"
And r2corr-ULID is patched to raise ValueError "ulid value error"
When r2corr-I execute append on the stored correction
Then r2corr-the attempts for the stored correction should have 1 entry
And r2corr-the first attempt should have success false
Scenario: execute_append exception with OSError message
Given r2corr-a stored append correction for plan "P1" targeting "A3"
And r2corr-ULID is patched to raise OSError "system failure"
When r2corr-I execute append on the stored correction
Then r2corr-the append result status should be "failed"
And r2corr-the append result error_message should contain "system failure"
And r2corr-the stored correction status should be "failed"
# -------------------------------------------------------------------
# execute_correction dispatch into exception paths
# -------------------------------------------------------------------
Scenario: dispatch to revert hits exception path when analyze_impact fails
Given r2corr-a stored revert correction for plan "P1" targeting "DR1"
And r2corr-analyze_impact is patched to raise RuntimeError "dispatch revert boom"
When r2corr-I dispatch execute correction on the stored correction
Then r2corr-the dispatch result status should be "failed"
And r2corr-the dispatch result error_message should contain "dispatch revert boom"
Scenario: dispatch to append hits exception path when ULID fails
Given r2corr-a stored append correction for plan "P1" targeting "DA1"
And r2corr-ULID is patched to raise RuntimeError "dispatch append boom"
When r2corr-I dispatch execute correction on the stored correction
Then r2corr-the dispatch result status should be "failed"
And r2corr-the dispatch result error_message should contain "dispatch append boom"
@@ -0,0 +1,87 @@
Feature: LifecycleAction and LifecyclePlan model branch coverage (round 2)
As a developer maintaining the ORM layer
I want every branch in the domain-conversion helpers tested
So that models.py achieves near-100 % branch coverage
# ===================================================================
# LifecycleActionModel.to_domain() lines 307-388
# Targets: None default_value_json, None min_value, None max_value,
# None tags_json, None inputs_schema_json, empty rels
# ===================================================================
Scenario: r2mod- ActionModel.to_domain args with all None optional fields
Given a r2mod-ActionModel with an argument having None defaults
When I r2mod-convert the ActionModel to domain
Then the r2mod-action first argument default_value should be None
And the r2mod-action first argument min_value should be None
And the r2mod-action first argument max_value should be None
Scenario: r2mod- ActionModel.to_domain with None tags_json and no inputs_schema
Given a r2mod-ActionModel with None tags_json and None inputs_schema_json
When I r2mod-convert the ActionModel to domain
Then the r2mod-action tags should be empty
And the r2mod-action inputs_schema should be None
Scenario: r2mod- ActionModel.to_domain with None arguments_rel and None invariants_rel
Given a r2mod-ActionModel with None rels
When I r2mod-convert the ActionModel to domain
Then the r2mod-action arguments should be empty
And the r2mod-action invariants should be empty
# ===================================================================
# LifecyclePlanModel.to_domain() — lines 700-840
# Targets: None automation_profile, None validation_summary_json,
# None sandbox_refs_json, None tags_json, empty action_name,
# None error_details_json, None argument value_json,
# None project_links_rel/invariants_rel/arguments_rel
# ===================================================================
Scenario: r2mod- PlanModel.to_domain with all optional fields None
Given a r2mod-PlanModel with all optional fields set to None
When I r2mod-convert the PlanModel to domain
Then the r2mod-plan automation_profile should be None
And the r2mod-plan validation_summary should be None
And the r2mod-plan sandbox_refs should be empty
And the r2mod-plan tags should be empty
And the r2mod-plan error_details should be None
Scenario: r2mod- PlanModel.to_domain with empty action_name falls back to empty string
Given a r2mod-PlanModel with None action_name
When I r2mod-attempt to convert the PlanModel to domain
Then a r2mod-ValidationError should have been raised with "action_name"
Scenario: r2mod- PlanModel.to_domain with argument having None value_json
Given a r2mod-PlanModel with an argument having None value_json
When I r2mod-convert the PlanModel to domain
Then the r2mod-plan argument "test_arg" should be None
# ===================================================================
# LifecycleActionModel.from_domain() — lines 391-468
# Targets: None inputs_schema, state with value attr, arg with None
# default_value
# ===================================================================
Scenario: r2mod- ActionModel.from_domain with None inputs_schema
When I r2mod-create ActionModel from domain with None inputs_schema
Then the r2mod-created ActionModel inputs_schema_json should be None
Scenario: r2mod- ActionModel.from_domain with argument having None default
When I r2mod-create ActionModel from domain with argument having None default
Then the r2mod-created ActionModel first argument default_value_json should be None
# ===================================================================
# LifecyclePlanModel helpers — _parse_iso / _to_iso
# Targets: None branches
# ===================================================================
Scenario: r2mod- PlanModel._parse_iso with None returns None
Then r2mod-_parse_iso with None should return None
Scenario: r2mod- PlanModel._to_iso with None returns None
Then r2mod-_to_iso with None should return None
Scenario: r2mod- PlanModel._parse_iso with valid ISO returns datetime
Then r2mod-_parse_iso with "2024-01-15T10:30:00" should return a datetime
Scenario: r2mod- PlanModel._to_iso with valid datetime returns string
Then r2mod-_to_iso with a datetime should return an ISO string
@@ -0,0 +1,91 @@
Feature: Session, Tool, and Project model branch coverage (round 2)
As a developer maintaining the ORM layer
I want every branch in the domain-conversion helpers tested
So that models.py achieves near-100 % branch coverage
# ===================================================================
# SessionModel.to_domain() lines 1858-1896
# Targets: None branches on linked_plan_ids_json, token_usage_json,
# metadata_json, messages_rel
# ===================================================================
Scenario: r2mod- SessionModel.to_domain with all None JSON fields
Given a r2mod-SessionModel with all JSON fields as None
When I r2mod-convert the SessionModel to domain
Then the r2mod-session linked_plan_ids should be empty
And the r2mod-session token_usage input_tokens should be 0
And the r2mod-session metadata should be empty
And the r2mod-session messages should be empty
# ===================================================================
# SessionMessageModel.to_domain() — line 1983
# Targets: None metadata_json branch
# ===================================================================
Scenario: r2mod- SessionMessageModel.to_domain with None metadata_json
Given a r2mod-SessionMessageModel with None metadata_json
When I r2mod-convert the SessionMessageModel to domain
Then the r2mod-message metadata should be empty
# ===================================================================
# ToolModel.to_domain() / from_domain() — lines 1612-1718
# Targets: None resource_bindings_rel branch, dict-vs-object input,
# no-slash name branch
# ===================================================================
Scenario: r2mod- ToolModel.to_domain with None resource_bindings_rel
Given a r2mod-ToolModel with no resource bindings
When I r2mod-convert the ToolModel to domain
Then the r2mod-tool resource_bindings should be empty
Scenario: r2mod- ToolModel.from_domain with dict input and no slash in name
When I r2mod-create ToolModel from dict with name "nonamespace"
Then the r2mod-created ToolModel namespace should be empty string
And the r2mod-created ToolModel short_name should be "nonamespace"
Scenario: r2mod- ToolModel.from_domain with dict input and slash in name
When I r2mod-create ToolModel from dict with name "ns/tool-name"
Then the r2mod-created ToolModel namespace should be "ns"
And the r2mod-created ToolModel short_name should be "tool-name"
Scenario: r2mod- ToolModel.from_domain with object input
When I r2mod-create ToolModel from object with name "ns/objtool"
Then the r2mod-created ToolModel namespace should be "ns"
Scenario: r2mod- ToolModel.from_domain with resource_bindings as None
When I r2mod-create ToolModel from dict with no resource_bindings
Then the r2mod-created ToolModel resource_bindings_rel should be empty
# ===================================================================
# NamespacedProjectModel.to_domain() — lines 1109-1153
# Targets: no context_policy_json, no resource_links, no-slash name
# ===================================================================
Scenario: r2mod- ProjectModel.to_domain with no context policy and no-slash name
Given a r2mod-ProjectModel with no context_policy_json and name "simplename"
When I r2mod-convert the ProjectModel to domain
Then the r2mod-project name should be "simplename"
And the r2mod-project linked_resources should be empty
Scenario: r2mod- ProjectModel.to_domain with None resource_links
Given a r2mod-ProjectModel with None resource_links
When I r2mod-convert the ProjectModel to domain
Then the r2mod-project linked_resources should be empty
# ===================================================================
# NamespacedProjectModel.from_domain() — lines 1156-1187
# Targets: None context_config branch
# ===================================================================
Scenario: r2mod- ProjectModel.from_domain with None context_config
When I r2mod-create ProjectModel from domain with None context_config
Then the r2mod-created ProjectModel context_policy_json should be None
# ===================================================================
# SessionMessageModel.from_domain() — lines 1996-2014
# Targets: role with value attr vs plain string
# ===================================================================
Scenario: r2mod- SessionMessageModel.from_domain with string role
When I r2mod-create SessionMessageModel from domain with string role
Then the r2mod-created SessionMessageModel role should be "user"
+108
View File
@@ -0,0 +1,108 @@
Feature: SkillModel to_domain/from_domain branch coverage (round 2)
As a developer maintaining the ORM layer
I want every branch in the domain-conversion helpers tested
So that models.py achieves near-100 % branch coverage
# ===================================================================
# SkillModel.to_domain() lines 2185-2224
# Targets: uncovered lines 2188-2189, branch partials on item_type
# checks, item_config_str checks, metadata_str check
# ===================================================================
Scenario: r2mod- SkillModel.to_domain with None items_rel returns empty lists
Given a r2mod-SkillModel with no items and no metadata
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill tool_refs should be empty
And the r2mod-skill includes should be empty
And the r2mod-skill anonymous_tools should be empty
And the r2mod-skill mcp_servers should be empty
And the r2mod-skill agent_skills should be empty
And the r2mod-skill overrides should be empty
Scenario: r2mod- SkillModel.to_domain with tool_ref items
Given a r2mod-SkillModel with a tool_ref item named "local/my-tool"
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill tool_refs should contain "local/my-tool"
Scenario: r2mod- SkillModel.to_domain with include item with config overrides
Given a r2mod-SkillModel with an include item "local/inc" with config overrides
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill includes should have 1 entry
And the r2mod-skill first include name should be "local/inc"
And the r2mod-skill first include overrides should not be None
Scenario: r2mod- SkillModel.to_domain with include item without config
Given a r2mod-SkillModel with an include item "local/inc" without config
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill includes should have 1 entry
And the r2mod-skill first include overrides should be None
Scenario: r2mod- SkillModel.to_domain with inline_tool item without config (skipped)
Given a r2mod-SkillModel with an inline_tool item without config
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill anonymous_tools should be empty
Scenario: r2mod- SkillModel.to_domain with mcp_source item without config (skipped)
Given a r2mod-SkillModel with an mcp_source item without config
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill mcp_servers should be empty
Scenario: r2mod- SkillModel.to_domain with agent_source item
Given a r2mod-SkillModel with an agent_source item "path/to/agent"
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill agent_skills should have 1 entry
And the r2mod-skill first agent_skill path should be "path/to/agent"
Scenario: r2mod- SkillModel.to_domain with metadata_json containing overrides
Given a r2mod-SkillModel with metadata_json containing overrides
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill overrides should not be empty
Scenario: r2mod- SkillModel.to_domain with inline_tool and mcp_source with config
Given a r2mod-SkillModel with inline_tool and mcp_source items with config
When I r2mod-convert the SkillModel to domain
Then the r2mod-skill anonymous_tools should have 1 entry
And the r2mod-skill mcp_servers should have 1 entry
# ===================================================================
# SkillModel.from_domain() — lines 2227-2366
# Targets: ValueError branch, overrides branch, include-as-string,
# anonymous_tools-as-dict, mcp_servers-as-dict,
# agent_skills-as-string
# ===================================================================
Scenario: r2mod- SkillModel.from_domain raises ValueError for invalid name
When I r2mod-attempt from_domain on SkillModel with name "badname"
Then a r2mod-ValueError should have been raised
Scenario: r2mod- SkillModel.from_domain with no overrides
When I r2mod-create SkillModel from domain with name "ns/sk" and no overrides
Then the r2mod-created SkillModel metadata_json should be None
Scenario: r2mod- SkillModel.from_domain with overrides
When I r2mod-create SkillModel from domain with name "ns/sk" and overrides
Then the r2mod-created SkillModel metadata_json should contain "overrides"
Scenario: r2mod- SkillModel.from_domain with include as string
When I r2mod-create SkillModel from domain with includes as strings
Then the r2mod-created SkillModel should have include items
Scenario: r2mod- SkillModel.from_domain with anonymous_tools as dict
When I r2mod-create SkillModel from domain with anonymous_tools as dicts
Then the r2mod-created SkillModel should have inline_tool items
Scenario: r2mod- SkillModel.from_domain with mcp_servers as dict
When I r2mod-create SkillModel from domain with mcp_servers as dicts
Then the r2mod-created SkillModel should have mcp_source items
Scenario: r2mod- SkillModel.from_domain with agent_skills as string
When I r2mod-create SkillModel from domain with agent_skills as strings
Then the r2mod-created SkillModel should have agent_source items
Scenario: r2mod- SkillModel.from_domain with anonymous_tools fallback branch
When I r2mod-create SkillModel from domain with anonymous_tools as plain objects
Then the r2mod-created SkillModel should have inline_tool items
Scenario: r2mod- SkillModel.from_domain with mcp_servers fallback branch
When I r2mod-create SkillModel from domain with mcp_servers as plain objects
Then the r2mod-created SkillModel should have mcp_source items
+228
View File
@@ -0,0 +1,228 @@
Feature: Plan CLI commands branch coverage (round 2)
As a developer
I want to exercise uncovered branches in CLI command functions
So that branch coverage for use_action, execute, apply, list, revert, status, correct, and diff rises
# ===================================================================
# use_action argument parsing int / float / bool / string
# ===================================================================
Scenario: r2plan use_action parses integer argument
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and arg "count=42"
Then r2plan-the parsed arguments should have "count" as int 42
Scenario: r2plan use_action parses float argument
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and arg "ratio=3.14"
Then r2plan-the parsed arguments should have "ratio" as float 3.14
Scenario: r2plan use_action parses bool true argument
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and arg "verbose=true"
Then r2plan-the parsed arguments should have "verbose" as bool true
Scenario: r2plan use_action parses bool false argument
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and arg "verbose=false"
Then r2plan-the parsed arguments should have "verbose" as bool false
Scenario: r2plan use_action parses string argument
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and arg "name=hello"
Then r2plan-the parsed arguments should have "name" as string "hello"
Scenario: r2plan use_action rejects argument missing equals sign
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and arg "badarg"
Then r2plan-the command should abort
And r2plan-the output should contain "Invalid argument format"
# ===================================================================
# use_action invalid automation profile
# ===================================================================
Scenario: r2plan use_action rejects invalid automation profile
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and automation profile "bogus"
Then r2plan-the command should abort
And r2plan-the output should contain "Invalid automation profile"
# ===================================================================
# use_action actor overrides with invalid names
# ===================================================================
Scenario: r2plan use_action rejects invalid strategy actor
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and strategy actor "INVALID"
Then r2plan-the command should abort
And r2plan-the output should contain "Validation Error"
Scenario: r2plan use_action rejects invalid execution actor
Given r2plan-a mocked lifecycle service
When r2plan-I invoke use with action "local/test" and execution actor "bad-name"
Then r2plan-the command should abort
And r2plan-the output should contain "Validation Error"
# ===================================================================
# execute_plan auto-resolve: no plans / multiple plans
# ===================================================================
Scenario: r2plan execute_plan aborts when no plans ready
Given r2plan-a mocked lifecycle service
And r2plan-the service lists no complete strategize plans
When r2plan-I invoke execute without plan_id
Then r2plan-the command should abort
And r2plan-the output should contain "No plans ready for execution"
Scenario: r2plan execute_plan aborts when multiple plans ready
Given r2plan-a mocked lifecycle service
And r2plan-the service lists multiple complete strategize plans
When r2plan-I invoke execute without plan_id
Then r2plan-the command should abort
And r2plan-the output should contain "Multiple plans ready"
# ===================================================================
# lifecycle_apply_plan auto-resolve: no plans / multiple plans
# ===================================================================
Scenario: r2plan lifecycle_apply aborts when no plans ready
Given r2plan-a mocked lifecycle service
And r2plan-the service lists no complete execute plans
When r2plan-I invoke lifecycle-apply without plan_id
Then r2plan-the command should abort
And r2plan-the output should contain "No plans ready for apply"
Scenario: r2plan lifecycle_apply aborts when multiple plans ready
Given r2plan-a mocked lifecycle service
And r2plan-the service lists multiple complete execute plans
When r2plan-I invoke lifecycle-apply without plan_id
Then r2plan-the command should abort
And r2plan-the output should contain "Multiple plans ready"
# ===================================================================
# lifecycle_list_plans invalid phase / invalid state
# ===================================================================
Scenario: r2plan lifecycle_list rejects invalid phase
Given r2plan-a mocked lifecycle service
When r2plan-I invoke lifecycle-list with phase "nonexistent"
Then r2plan-the command should abort
And r2plan-the output should contain "Invalid phase"
Scenario: r2plan lifecycle_list rejects invalid state
Given r2plan-a mocked lifecycle service
When r2plan-I invoke lifecycle-list with state "nonexistent"
Then r2plan-the command should abort
And r2plan-the output should contain "Invalid state"
# ===================================================================
# lifecycle_list_plans empty result
# ===================================================================
Scenario: r2plan lifecycle_list shows no-plans message when empty
Given r2plan-a mocked lifecycle service
And r2plan-the service lists no plans
When r2plan-I invoke lifecycle-list
Then r2plan-the command should succeed
And r2plan-the output should contain "No plans found"
# ===================================================================
# revert_plan invalid --to-phase
# ===================================================================
Scenario: r2plan revert_plan rejects invalid to-phase
Given r2plan-a mocked lifecycle service
When r2plan-I invoke revert with plan "PLAN123" and invalid phase "badphase"
Then r2plan-the command should abort
And r2plan-the output should contain "Invalid phase"
Scenario: r2plan revert_plan non-rich format
Given r2plan-a mocked lifecycle service
And r2plan-the service can revert a plan
When r2plan-I invoke revert with plan "PLAN123" and format "json"
Then r2plan-the command should succeed
And r2plan-the output should contain "plan_id"
# ===================================================================
# plan_status without plan_id: no plans, plans in non-rich, rich
# ===================================================================
Scenario: r2plan status shows no-plans message
Given r2plan-a mocked lifecycle service
And r2plan-the service lists no plans
When r2plan-I invoke status without plan_id
Then r2plan-the command should succeed
And r2plan-the output should contain "No v3 lifecycle plans found"
Scenario: r2plan status without plan_id lists plans in non-rich
Given r2plan-a mocked lifecycle service
And r2plan-the service lists some plans
When r2plan-I invoke status without plan_id and format "json"
Then r2plan-the command should succeed
And r2plan-the output should contain "plan_id"
Scenario: r2plan status with plan_id in non-rich format
Given r2plan-a mocked lifecycle service
And r2plan-the service can get a plan by id
When r2plan-I invoke status with plan_id and format "json"
Then r2plan-the command should succeed
And r2plan-the output should contain "plan_id"
# ===================================================================
# correct_decision invalid mode / empty guidance
# ===================================================================
Scenario: r2plan correct rejects invalid mode
Given r2plan-a mocked lifecycle service
When r2plan-I invoke correct with mode "badmode"
Then r2plan-the command should abort
And r2plan-the output should contain "Invalid mode"
Scenario: r2plan correct rejects empty guidance
Given r2plan-a mocked lifecycle service
When r2plan-I invoke correct with empty guidance
Then r2plan-the command should abort
And r2plan-the output should contain "guidance"
# ===================================================================
# plan_diff correction flag present
# ===================================================================
Scenario: r2plan diff shows correction stub when correction flag set
Given r2plan-a mocked lifecycle service
When r2plan-I invoke diff with plan "P1" and correction "CORR1"
Then r2plan-the command should succeed
And r2plan-the output should contain "Correction Attempt"
And r2plan-the output should contain "CORR1"
# ===================================================================
# execute_plan auto-resolve with exactly one plan
# ===================================================================
Scenario: r2plan execute_plan auto-resolves single plan
Given r2plan-a mocked lifecycle service
And r2plan-the service lists exactly one complete strategize plan
When r2plan-I invoke execute without plan_id
Then r2plan-the command should succeed
# ===================================================================
# lifecycle_apply auto-resolve with exactly one plan
# ===================================================================
Scenario: r2plan lifecycle_apply auto-resolves single plan
Given r2plan-a mocked lifecycle service
And r2plan-the service lists exactly one complete execute plan
When r2plan-I invoke lifecycle-apply without plan_id
Then r2plan-the command should succeed
# ===================================================================
# lifecycle_list_plans projects > 2 truncation
# ===================================================================
Scenario: r2plan lifecycle_list truncates projects over 2
Given r2plan-a mocked lifecycle service
And r2plan-the service lists a plan with 4 project links
When r2plan-I invoke lifecycle-list
Then r2plan-the command should succeed
And r2plan-the output should contain "+2 more"
+80
View File
@@ -0,0 +1,80 @@
Feature: Plan CLI legacy wrappers and resolve branch coverage (round 2)
As a developer
I want to exercise uncovered branches in legacy wrappers and _resolve_active_plan_id
So that branch coverage for legacy and resolve logic rises above the current level
# ===================================================================
# Legacy programmatic wrappers no project branch
# ===================================================================
Scenario: r2plan tell_command raises when no project
When r2plan-I call tell_command with no project
Then r2plan-a CleverAgentsError should be raised with message "No project found"
Scenario: r2plan build_command raises when no project
When r2plan-I call build_command with no project
Then r2plan-a CleverAgentsError should be raised with message "No project found"
Scenario: r2plan apply_command raises when no project
When r2plan-I call apply_command with no project
Then r2plan-a CleverAgentsError should be raised with message "No project found"
Scenario: r2plan new_command raises when no project
When r2plan-I call new_command with no project
Then r2plan-a CleverAgentsError should be raised with message "No project found"
Scenario: r2plan current_command raises when no project
When r2plan-I call current_command with no project
Then r2plan-a CleverAgentsError should be raised with message "No project found"
Scenario: r2plan list_command raises when no project
When r2plan-I call list_command with no project
Then r2plan-a CleverAgentsError should be raised with message "No project found"
Scenario: r2plan cd_command raises when no project
When r2plan-I call cd_command with no project
Then r2plan-a CleverAgentsError should be raised with message "No project found"
Scenario: r2plan continue_command raises when no project
When r2plan-I call continue_command with no project
Then r2plan-a CleverAgentsError should be raised with message "No project found"
# ===================================================================
# continue_command with prompt vs no-prompt + no current plan
# ===================================================================
Scenario: r2plan continue_command with prompt calls continue_plan
When r2plan-I call continue_command with prompt "add tests"
Then r2plan-the continue_plan service method should be called
Scenario: r2plan continue_command no prompt and no current plan raises
When r2plan-I call continue_command with no prompt and no current plan
Then r2plan-a CleverAgentsError should be raised with message "No current plan"
# ===================================================================
# build_command returns empty list when changes is None
# ===================================================================
Scenario: r2plan build_command returns empty list when None
When r2plan-I call build_command with build returning None
Then r2plan-the build result should be an empty list
# ===================================================================
# list_command returns empty list when plans is None
# ===================================================================
Scenario: r2plan list_command returns empty list when None
When r2plan-I call list_command with list returning None
Then r2plan-the list result should be an empty list
# ===================================================================
# _resolve_active_plan_id no active plans / service error
# ===================================================================
Scenario: r2plan resolve_active_plan_id aborts when no active plans
When r2plan-I call _resolve_active_plan_id with no active plans
Then r2plan-a typer Abort should be raised
Scenario: r2plan resolve_active_plan_id aborts on service error
When r2plan-I call _resolve_active_plan_id with service error
Then r2plan-a typer Abort should be raised
+190
View File
@@ -0,0 +1,190 @@
Feature: Plan CLI spec dict and print branch coverage (round 2)
As a developer
I want to exercise uncovered branches in _plan_spec_dict and _print_lifecycle_plan
So that branch coverage for spec-dict and print logic rises above the current level
# ===================================================================
# _plan_spec_dict project link alias / read_only branches
# ===================================================================
Scenario: r2plan spec dict includes alias in project link
Given r2plan-a v3 Plan with a project link that has alias "backend"
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict project_links should include alias "backend"
Scenario: r2plan spec dict includes read_only in project link
Given r2plan-a v3 Plan with a project link that is read_only
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict project_links should include read_only true
Scenario: r2plan spec dict omits alias and read_only when unset
Given r2plan-a v3 Plan with a plain project link
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict project_links should not include alias
And r2plan-the spec dict project_links should not include read_only
# ===================================================================
# _plan_spec_dict automation_profile truthy / falsy
# ===================================================================
Scenario: r2plan spec dict includes automation_profile when set
Given r2plan-a v3 Plan with automation_profile "review"
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict automation_profile should be "review"
Scenario: r2plan spec dict has null automation_profile when unset
Given r2plan-a v3 Plan without automation_profile
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict automation_profile should be null
# ===================================================================
# _plan_spec_dict invariants, validation_summary/dod
# ===================================================================
Scenario: r2plan spec dict includes invariants when present
Given r2plan-a v3 Plan with invariants
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict should contain key "invariants"
And r2plan-the spec dict invariants count should be 2
Scenario: r2plan spec dict omits invariants when empty
Given r2plan-a v3 Plan without invariants
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict should not contain key "invariants"
Scenario: r2plan spec dict includes dod_evaluation when validated
Given r2plan-a v3 Plan with dod validation summary
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict should contain key "dod_evaluation"
And r2plan-the spec dict dod_evaluation all_passed should be true
Scenario: r2plan spec dict omits dod_evaluation when no validation
Given r2plan-a v3 Plan without validation_summary
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict should not contain key "dod_evaluation"
# ===================================================================
# _plan_spec_dict last_completed_step / last_checkpoint_id
# ===================================================================
Scenario: r2plan spec dict includes last_completed_step when >= 0
Given r2plan-a v3 Plan with last_completed_step 3
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict should contain key "last_completed_step"
And r2plan-the spec dict last_completed_step should be 3
Scenario: r2plan spec dict omits last_completed_step when -1
Given r2plan-a v3 Plan with last_completed_step default
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict should not contain key "last_completed_step"
Scenario: r2plan spec dict includes last_checkpoint_id when set
Given r2plan-a v3 Plan with last_checkpoint_id "01CHKPT001"
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict should contain key "last_checkpoint_id"
Scenario: r2plan spec dict omits last_checkpoint_id when None
Given r2plan-a v3 Plan without last_checkpoint_id
When r2plan-I call _plan_spec_dict
Then r2plan-the spec dict should not contain key "last_checkpoint_id"
# ===================================================================
# _print_lifecycle_plan definition_of_done branches
# ===================================================================
Scenario: r2plan print shows definition_of_done when short
Given r2plan-a v3 Plan with definition_of_done "All tests pass"
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "Definition of Done"
And r2plan-the printed output should contain "All tests pass"
Scenario: r2plan print truncates long definition_of_done
Given r2plan-a v3 Plan with definition_of_done longer than 200 chars
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "Definition of Done"
And r2plan-the printed output should contain "..."
Scenario: r2plan print omits definition_of_done when None
Given r2plan-a v3 Plan without definition_of_done
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should not contain "Definition of Done"
# ===================================================================
# _print_lifecycle_plan validation_summary dod pass / fail
# ===================================================================
Scenario: r2plan print shows DoD PASSED evaluation
Given r2plan-a v3 Plan with dod evaluated as passed
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "DoD Evaluation"
And r2plan-the printed output should contain "PASSED"
Scenario: r2plan print shows DoD FAILED evaluation with failures
Given r2plan-a v3 Plan with dod evaluated as failed
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "DoD Evaluation"
And r2plan-the printed output should contain "FAILED"
And r2plan-the printed output should contain "failed"
# ===================================================================
# _print_lifecycle_plan arguments with/without order
# ===================================================================
Scenario: r2plan print shows arguments using arguments_order
Given r2plan-a v3 Plan with arguments and arguments_order
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "Arguments"
And r2plan-the printed output should contain "target_coverage = 80"
Scenario: r2plan print shows arguments sorted when no order
Given r2plan-a v3 Plan with arguments but no arguments_order
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "Arguments"
# ===================================================================
# _print_lifecycle_plan long description > 200 chars
# ===================================================================
Scenario: r2plan print truncates long description
Given r2plan-a v3 Plan with description longer than 200 chars
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "..."
# ===================================================================
# _print_lifecycle_plan automation_profile
# ===================================================================
Scenario: r2plan print shows automation profile
Given r2plan-a v3 Plan with automation_profile "review"
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "Automation Profile"
And r2plan-the printed output should contain "review"
# ===================================================================
# _print_lifecycle_plan invariants display
# ===================================================================
Scenario: r2plan print shows invariants
Given r2plan-a v3 Plan with invariants
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "Invariants"
# ===================================================================
# _print_lifecycle_plan resume metadata
# ===================================================================
Scenario: r2plan print shows resume metadata
Given r2plan-a v3 Plan with resume metadata
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "Last Completed Step"
And r2plan-the printed output should contain "Last Checkpoint"
# ===================================================================
# _print_lifecycle_plan project link alias and read_only
# ===================================================================
Scenario: r2plan print shows project link with alias and read_only
Given r2plan-a v3 Plan with project link alias and read_only
When r2plan-I call _print_lifecycle_plan
Then r2plan-the printed output should contain "alias:"
And r2plan-the printed output should contain "local/ref-data"
+118
View File
@@ -0,0 +1,118 @@
Feature: PlanLifecycleService error and persistence branch coverage (round 2)
As a developer maintaining PlanLifecycleService
I want every partial branch fully exercised
So that plan_lifecycle_service.py branch coverage reaches 100%
# Targets the following partial branches:
# Line 100 InvalidPhaseTransitionError.__init__ `if not message:` False branch
# Line 216 _commit_plan `if self._persisted …` True branch (via mock UoW)
# Line 327 create_action `if self._persisted …` True branch
# Line 364 get_action `if self._persisted …` True branch (not-found fallback)
# Line 461 archive_action `if self._persisted …` True branch
# Line 570 use_action `if self._persisted …` True branch
# Line 607 get_plan `if self._persisted …` True branch (not-found fallback)
# -------------------------------------------------------------------
# In-memory mode (no UoW) validates False side of _persisted checks
# -------------------------------------------------------------------
Background:
Given r2plc-a fresh plan lifecycle service with mock UoW
# -------------------------------------------------------------------
# InvalidPhaseTransitionError with custom message (line 100 False)
# -------------------------------------------------------------------
Scenario: InvalidPhaseTransitionError raised with explicit custom message
When r2plc-I construct InvalidPhaseTransitionError with a custom message
Then r2plc-the error message should be the custom message
And r2plc-the from_phase should be STRATEGIZE
And r2plc-the to_phase should be APPLY
Scenario: InvalidPhaseTransitionError raised with default message
When r2plc-I construct InvalidPhaseTransitionError without a message
Then r2plc-the error message should contain "Invalid phase transition"
And r2plc-the from_phase should be STRATEGIZE
And r2plc-the to_phase should be EXECUTE
Scenario: revert_plan raises InvalidPhaseTransitionError with custom message
Given r2plc-a plan in STRATEGIZE phase
When r2plc-I attempt to revert the plan to APPLY phase
Then r2plc-an InvalidPhaseTransitionError should have been raised
And r2plc-the caught error message should contain "Cannot revert"
# -------------------------------------------------------------------
# _commit_plan in persisted mode (line 216 True)
# -------------------------------------------------------------------
Scenario: _commit_plan persists via UoW when persisted mode is active
Given r2plc-a plan in STRATEGIZE phase
When r2plc-I start strategize on the plan
Then r2plc-the mock UoW should have received a plan update call
Scenario: fail_strategize commits plan via persisted _commit_plan
Given r2plc-a plan in STRATEGIZE PROCESSING state
When r2plc-I fail the strategize with error "something broke"
Then r2plc-the mock UoW should have received a plan update call
And r2plc-the plan processing state should be "errored"
# -------------------------------------------------------------------
# create_action persisted mode (line 327 True)
# -------------------------------------------------------------------
Scenario: create_action persists via UoW when persisted
When r2plc-I create an action "local/r2-persist-test" in persisted mode
Then r2plc-the mock UoW should have received an action create call
And r2plc-the action should also be in the in-memory cache
# -------------------------------------------------------------------
# use_action persisted mode (line 570 True)
# -------------------------------------------------------------------
Scenario: use_action persists the new plan via UoW
Given r2plc-an action "local/r2-use-persist" exists
When r2plc-I use the action to create a plan in persisted mode
Then r2plc-the mock UoW should have received a plan create call
And r2plc-the plan should also be in the in-memory plan cache
# -------------------------------------------------------------------
# archive_action persisted mode (line 461 True)
# -------------------------------------------------------------------
Scenario: archive_action persists via UoW when persisted
Given r2plc-an action "local/r2-archive-persist" exists
When r2plc-I archive the action "local/r2-archive-persist" in persisted mode
Then r2plc-the mock UoW should have received an action update call
And r2plc-the action state should be archived
# -------------------------------------------------------------------
# get_action persistence fallback returns None (line 364-370)
# -------------------------------------------------------------------
Scenario: get_action raises NotFoundError when persistence also returns None
When r2plc-I attempt to get action "local/nonexistent-r2" in persisted mode
Then r2plc-a NotFoundError should have been raised for action
# -------------------------------------------------------------------
# get_plan persistence fallback returns None (line 607-613)
# -------------------------------------------------------------------
Scenario: get_plan raises NotFoundError when persistence also returns None
When r2plc-I attempt to get plan "01ZZZZZZZZZZZZZZZZZZZZZZZZ" in persisted mode
Then r2plc-a NotFoundError should have been raised for plan
# -------------------------------------------------------------------
# update_error_details in persisted mode (hits _commit_plan True)
# -------------------------------------------------------------------
Scenario: update_error_details merges details and persists
Given r2plc-a plan in STRATEGIZE phase
When r2plc-I update error details with key "reason" value "timeout"
Then r2plc-the plan error_details should contain key "reason"
And r2plc-the mock UoW should have received a plan update call
Scenario: update_error_details merges into existing error_details
Given r2plc-a plan in STRATEGIZE phase with existing error_details
When r2plc-I update error details with key "extra" value "info"
Then r2plc-the plan error_details should contain key "extra"
And r2plc-the plan error_details should contain key "original"
@@ -0,0 +1,67 @@
Feature: PlanLifecycleService lifecycle transition branch coverage (round 2)
As a developer maintaining PlanLifecycleService
I want every partial branch fully exercised
So that plan_lifecycle_service.py branch coverage reaches 100%
# Targets the following partial branches:
# Line 576 use_action with non-reusable action archives it
# Line 216 execute_plan / apply_plan / cancel_plan / pause_plan / resume_plan
# persisted mode (line 216 True via _commit_plan)
Background:
Given r2plc-a fresh plan lifecycle service with mock UoW
# -------------------------------------------------------------------
# use_action with non-reusable action archives it (line 576-577)
# -------------------------------------------------------------------
Scenario: use_action on non-reusable action archives it after plan creation
Given r2plc-a non-reusable action "local/r2-oneshot" exists
When r2plc-I use the non-reusable action to create a plan
Then r2plc-the action "local/r2-oneshot" should be archived
And r2plc-a plan should have been created from the action
# -------------------------------------------------------------------
# execute_plan persisted mode (line 216 True via _commit_plan)
# -------------------------------------------------------------------
Scenario: execute_plan transitions and persists via _commit_plan
Given r2plc-a plan in STRATEGIZE COMPLETE state
When r2plc-I call execute_plan in persisted mode
Then r2plc-the plan should be in EXECUTE phase
And r2plc-the mock UoW should have received a plan update call
# -------------------------------------------------------------------
# apply_plan persisted mode (line 216 True via _commit_plan)
# -------------------------------------------------------------------
Scenario: apply_plan transitions and persists via _commit_plan
Given r2plc-a plan in EXECUTE COMPLETE state
When r2plc-I call apply_plan in persisted mode
Then r2plc-the plan should be in APPLY phase
And r2plc-the mock UoW should have received a plan update call
# -------------------------------------------------------------------
# cancel_plan persisted mode (line 216 True via _commit_plan)
# -------------------------------------------------------------------
Scenario: cancel_plan persists cancellation via _commit_plan
Given r2plc-a plan in STRATEGIZE phase
When r2plc-I cancel the plan with reason "user requested"
Then r2plc-the plan processing state should be "cancelled"
And r2plc-the mock UoW should have received a plan update call
# -------------------------------------------------------------------
# pause_plan and resume_plan persisted mode
# -------------------------------------------------------------------
Scenario: pause_plan persists via _commit_plan
Given r2plc-a plan in STRATEGIZE phase
When r2plc-I pause the plan
Then r2plc-the plan automation profile should be manual
And r2plc-the mock UoW should have received a plan update call
Scenario: resume_plan persists and auto-progresses via _commit_plan
Given r2plc-a plan in STRATEGIZE phase
When r2plc-I resume the plan with profile "auto"
Then r2plc-the plan automation profile should be "auto"
+272
View File
@@ -0,0 +1,272 @@
Feature: Skill CLI branch coverage round 2
Cover partial branches in src/cleveragents/cli/commands/skill.py
Lines 74, 109, 112, 121, 155, 164, 169, and related conditionals.
All lines are hit but many conditional branches are only tested in one direction.
Background:
Given r2skill- a reset skill CLI service
# -------------------------------------------------------------------
# _get_skill_service singleton (line 74 false branch)
# -------------------------------------------------------------------
Scenario: Service singleton returns existing instance on second call
When r2skill- I call _get_skill_service twice without resetting
Then r2skill- both calls return the same SkillService object
# -------------------------------------------------------------------
# _skill_spec_dict — missing timestamps (lines 109, 112 false)
# -------------------------------------------------------------------
Scenario: Show skill with JSON format when timestamps are absent
Given r2skill- a registered skill "local/no-timestamps" with tool_refs
And r2skill- the timestamps for "local/no-timestamps" are removed
When r2skill- I invoke show "local/no-timestamps" with format "json"
Then r2skill- the output should be valid JSON
And r2skill- the JSON output should not contain key "created"
And r2skill- the JSON output should not contain key "updated"
# -------------------------------------------------------------------
# _tool_count — MCP with no tools (line 121-122 false branch)
# -------------------------------------------------------------------
Scenario: Tool count for skill with MCP server having no tools
Given r2skill- a registered skill "local/mcp-no-tools" with MCP server but no tool list
When r2skill- I invoke show "local/mcp-no-tools" with format "rich"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "OK"
# -------------------------------------------------------------------
# _print_skill_registered — no includes (line 155 false)
# -------------------------------------------------------------------
Scenario: Add skill with no includes shows no Includes panel
Given r2skill- a temp YAML config for "local/no-includes" with only tool_refs
When r2skill- I invoke add with the temp config in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "Skill Registered"
And r2skill- the output should not contain "Includes"
# -------------------------------------------------------------------
# _print_skill_registered — empty sources (line 164 false)
# -------------------------------------------------------------------
Scenario: Add skill with no tool sources shows no Tool Sources panel
Given r2skill- a temp YAML config for "local/empty-skill" with no tools
When r2skill- I invoke add with the temp config in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "Skill Registered"
And r2skill- the output should not contain "Tool Sources"
# -------------------------------------------------------------------
# _print_skill_registered — source type branches (lines 169, 173, 177, 181)
# -------------------------------------------------------------------
Scenario: Add skill with only MCP sources excludes builtin from sources
Given r2skill- a temp YAML config for "local/mcp-only" with MCP and tools
When r2skill- I invoke add with the temp config in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "mcp"
Scenario: Add skill with only custom inline tools
Given r2skill- a temp YAML config for "local/custom-only" with inline tools
When r2skill- I invoke add with the temp config in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "custom"
# -------------------------------------------------------------------
# add command — non-rich format (line 483 true branch)
# -------------------------------------------------------------------
Scenario: Add skill with JSON format returns JSON output
Given r2skill- a temp YAML config for "local/json-add" with only tool_refs
When r2skill- I invoke add with the temp config in format "json"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should be valid JSON
And r2skill- the JSON output should contain key "name"
# -------------------------------------------------------------------
# add command — update existing skill (lines 474, 491)
# -------------------------------------------------------------------
Scenario: Update existing skill via add --update shows Changes panel
Given r2skill- a temp YAML config for "local/updatable" with only tool_refs
And r2skill- the skill "local/updatable" is already registered via add
And r2skill- a second temp YAML config for "local/updatable" with different tools
When r2skill- I invoke add with the second config and --update in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "Skill Updated"
And r2skill- the output should contain "Changes"
# -------------------------------------------------------------------
# add command — duplicate without --update (line 500-510)
# -------------------------------------------------------------------
Scenario: Add duplicate skill without update raises already-registered error
Given r2skill- a temp YAML config for "local/duplicate" with only tool_refs
And r2skill- the skill "local/duplicate" is already registered via add
And r2skill- a duplicate temp YAML config for "local/duplicate"
When r2skill- I invoke add with the duplicate config without update
Then r2skill- the CLI exit code should not be 0
And r2skill- the output should contain "already registered"
And r2skill- the output should contain "--update"
# -------------------------------------------------------------------
# list — empty registry (line 646 true)
# -------------------------------------------------------------------
Scenario: List skills when registry is empty shows message
When r2skill- I invoke list in rich format
Then r2skill- the output should contain "No skills found"
# -------------------------------------------------------------------
# list — non-rich format (line 651 true)
# -------------------------------------------------------------------
Scenario: List skills with JSON format
Given r2skill- a registered skill "local/for-json-list" with tool_refs
When r2skill- I invoke list in format "json"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should be valid JSON
# -------------------------------------------------------------------
# list — non-local namespace (line 671 else branch)
# -------------------------------------------------------------------
Scenario: List skills with non-local namespace counts server skills
Given r2skill- a registered skill "server/remote-skill" with tool_refs
When r2skill- I invoke list in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "server/remote-skill"
# -------------------------------------------------------------------
# show — non-rich format (line 724 true)
# -------------------------------------------------------------------
Scenario: Show skill with JSON format returns structured data
Given r2skill- a registered skill "local/for-json-show" with tool_refs
When r2skill- I invoke show "local/for-json-show" with format "json"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should be valid JSON
And r2skill- the JSON output should contain key "name"
# -------------------------------------------------------------------
# show — KeyError path (line 731-733)
# -------------------------------------------------------------------
Scenario: Show nonexistent skill raises not-found error
When r2skill- I invoke show "local/nonexistent" with format "rich"
Then r2skill- the CLI exit code should not be 0
And r2skill- the output should contain "Skill not found"
# -------------------------------------------------------------------
# remove — rich format output (line 564 false, full rich path)
# -------------------------------------------------------------------
Scenario: Remove skill with rich format shows removal panel
Given r2skill- a registered skill "local/to-remove" with tool_refs
When r2skill- I invoke remove "local/to-remove" with --yes in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "Skill Removed"
And r2skill- the output should contain "OK"
# -------------------------------------------------------------------
# remove — non-rich format (line 564 true)
# -------------------------------------------------------------------
Scenario: Remove skill with JSON format returns structured output
Given r2skill- a registered skill "local/to-remove-json" with tool_refs
When r2skill- I invoke remove "local/to-remove-json" with --yes in format "json"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should be valid JSON
# -------------------------------------------------------------------
# remove — dependent skills (lines 577-587)
# -------------------------------------------------------------------
Scenario: Remove skill that is included by another skill shows dependency warning
Given r2skill- a registered skill "local/base-dep" with tool_refs
And r2skill- a registered skill "local/parent-dep" that includes "local/base-dep"
When r2skill- I invoke remove "local/base-dep" with --yes in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "skill(s) include this skill"
# -------------------------------------------------------------------
# remove — KeyError path (line 601-603)
# -------------------------------------------------------------------
Scenario: Remove nonexistent skill raises not-found error
When r2skill- I invoke remove "local/nonexistent" with --yes in rich format
Then r2skill- the CLI exit code should not be 0
And r2skill- the output should contain "Skill not found"
# -------------------------------------------------------------------
# tools — non-rich format (line 801 true)
# -------------------------------------------------------------------
Scenario: Tools command with JSON format returns structured tool data
Given r2skill- a registered skill "local/for-json-tools" with tool_refs
When r2skill- I invoke tools "local/for-json-tools" with format "json"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should be valid JSON
# -------------------------------------------------------------------
# tools — inline entries in non-rich output (lines 806-811)
# -------------------------------------------------------------------
Scenario: Tools command with inline tools in JSON format shows custom source
Given r2skill- a registered skill "local/inline-tools" with anonymous inline tools
When r2skill- I invoke tools "local/inline-tools" with format "json"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should be valid JSON
And r2skill- the JSON tool list should contain source "custom"
# -------------------------------------------------------------------
# tools — KeyError path (line 827-829)
# -------------------------------------------------------------------
Scenario: Tools for nonexistent skill raises not-found error
When r2skill- I invoke tools "local/nonexistent" with format "rich"
Then r2skill- the CLI exit code should not be 0
And r2skill- the output should contain "Skill not found"
# -------------------------------------------------------------------
# _print_skill_registered — MCP servers panel (line 189)
# -------------------------------------------------------------------
Scenario: Add skill with MCP servers shows MCP Servers panel
Given r2skill- a temp YAML config for "local/with-mcp-panel" with MCP servers
When r2skill- I invoke add with the temp config in rich format
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "MCP Servers"
# -------------------------------------------------------------------
# _print_skill_detail — no direct tools (line 302 false)
# -------------------------------------------------------------------
Scenario: Show skill with no direct tools omits direct tools table
Given r2skill- a registered skill "local/no-direct-tools" with no tools at all
When r2skill- I invoke show "local/no-direct-tools" with format "rich"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should not contain "Direct Tools"
# -------------------------------------------------------------------
# _print_skill_detail — MCP tools in show (line 287 true/false)
# -------------------------------------------------------------------
Scenario: Show skill with MCP servers displays MCP panel
Given r2skill- a registered skill "local/show-mcp" with MCP server and explicit tools
When r2skill- I invoke show "local/show-mcp" with format "rich"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should contain "MCP Servers"
# -------------------------------------------------------------------
# tools — MCP entry in non-rich format (line 806 true)
# -------------------------------------------------------------------
Scenario: Tools command with MCP entries in JSON format
Given r2skill- a registered skill "local/mcp-tools-json" with MCP server and explicit tools
When r2skill- I invoke tools "local/mcp-tools-json" with format "json"
Then r2skill- the CLI exit code should be 0
And r2skill- the output should be valid JSON
And r2skill- the JSON tool list should contain source "mcp"
@@ -0,0 +1,388 @@
"""Step definitions for container_coverage_r2.feature.
Covers remaining uncovered lines and partial branches in
``cleveragents.application.container``:
- Lines 66-67, 69: get_ai_provider non-mock paths
- Lines 125-130: _build_project_resource_link_repo
- Partial branches at lines 51, 57, 82, 87, 256, 284-285, 288
All step text uses the ``r2cont-`` prefix to avoid collisions with
existing step definitions in container_and_repository_coverage_steps.py.
"""
from __future__ import annotations
import os
import sys
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _save_env(context: Context, key: str) -> None:
"""Save an environment variable for later restoration."""
if not hasattr(context, "_r2cont_saved_env"):
context._r2cont_saved_env = {}
if key not in context._r2cont_saved_env:
context._r2cont_saved_env[key] = os.environ.get(key)
def _restore_env(context: Context) -> None:
"""Restore all saved environment variables."""
saved: dict[str, str | None] = getattr(context, "_r2cont_saved_env", {})
for key, original in saved.items():
if original is None:
os.environ.pop(key, None)
else:
os.environ[key] = original
def _register_cleanup(context: Context, fn: Any) -> None:
"""Register a cleanup callable on the behave context."""
if not hasattr(context, "_r2cont_cleanups"):
context._r2cont_cleanups = []
context._r2cont_cleanups.append(fn)
def _run_cleanups(context: Context) -> None:
"""Run all registered cleanups (called in after_scenario hook or manually)."""
for fn in getattr(context, "_r2cont_cleanups", []):
fn()
context._r2cont_cleanups = []
# -------------------------------------------------------------------
# Given: get_ai_provider setup
# -------------------------------------------------------------------
@given("r2cont- the mock AI environment flag is disabled")
def step_disable_mock_ai(context: Context) -> None:
"""Ensure CLEVERAGENTS_TESTING_USE_MOCK_AI is not set."""
_save_env(context, "CLEVERAGENTS_TESTING_USE_MOCK_AI")
os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
_register_cleanup(context, lambda: _restore_env(context))
@given("r2cont- a provider registry with no configured providers")
def step_registry_no_providers(context: Context) -> None:
"""Create a mock ProviderRegistry that returns no configured providers."""
mock_registry = MagicMock()
mock_registry.get_configured_providers.return_value = []
context.r2cont_registry = mock_registry
@given("r2cont- a provider registry with configured providers")
def step_registry_with_providers(context: Context) -> None:
"""Create a mock ProviderRegistry that reports configured providers."""
mock_registry = MagicMock()
mock_registry.get_configured_providers.return_value = [MagicMock()]
mock_provider = MagicMock()
mock_registry.create_ai_provider.return_value = mock_provider
context.r2cont_registry = mock_registry
context.r2cont_expected_provider = mock_provider
@given("r2cont- the mock AI environment flag is enabled")
def step_enable_mock_ai(context: Context) -> None:
"""Set the env flag to trigger mock AI loading."""
_save_env(context, "CLEVERAGENTS_TESTING_USE_MOCK_AI")
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = "true"
_register_cleanup(context, lambda: _restore_env(context))
@given("r2cont- the features path is already on sys.path")
def step_features_path_on_syspath(context: Context) -> None:
"""Ensure the features directory IS on sys.path before calling get_ai_provider."""
from pathlib import Path as _Path
# Compute the features path the same way container.py does:
# Path(__file__).parent.parent.parent.parent / "features"
# where __file__ is src/cleveragents/application/container.py
from cleveragents.application.container import __file__ as container_file
feat_path = str(_Path(container_file).parent.parent.parent.parent / "features")
context.r2cont_features_path = feat_path
if feat_path not in sys.path:
sys.path.insert(0, feat_path)
# Record the count so we can verify no duplicates were added
context.r2cont_features_path_count_before = sys.path.count(feat_path)
def cleanup() -> None:
# Remove any extra entries we may have added, restoring original count
while sys.path.count(feat_path) > context.r2cont_features_path_count_before:
sys.path.remove(feat_path)
_register_cleanup(context, cleanup)
# -------------------------------------------------------------------
# Given: get_database_url env var setup
# -------------------------------------------------------------------
@given("r2cont- no database URL environment variables are set")
def step_clear_all_db_env_vars(context: Context) -> None:
"""Remove both database URL env vars."""
for key in ("CLEVERAGENTS_DATABASE_URL", "CLEVERAGENTS_TEST_DATABASE_URL"):
_save_env(context, key)
os.environ.pop(key, None)
_register_cleanup(context, lambda: _restore_env(context))
@given('r2cont- the env var "{key}" is set to empty')
def step_set_env_var_empty(context: Context, key: str) -> None:
"""Set a specific environment variable to the empty string."""
_save_env(context, key)
os.environ[key] = ""
_register_cleanup(context, lambda: _restore_env(context))
@given('r2cont- the env var "{key}" is set to "{value}"')
def step_set_env_var(context: Context, key: str, value: str) -> None:
"""Set a specific environment variable."""
_save_env(context, key)
os.environ[key] = value
_register_cleanup(context, lambda: _restore_env(context))
@given('r2cont- the env var "{key}" is cleared')
def step_clear_env_var(context: Context, key: str) -> None:
"""Clear a specific environment variable."""
_save_env(context, key)
os.environ.pop(key, None)
_register_cleanup(context, lambda: _restore_env(context))
# -------------------------------------------------------------------
# Given: Container singleton setup
# -------------------------------------------------------------------
@given("r2cont- the global container has been reset")
def step_reset_global_container(context: Context) -> None:
"""Reset the global container singleton to None."""
from cleveragents.application.container import reset_container
reset_container()
_register_cleanup(context, reset_container)
# -------------------------------------------------------------------
# When: get_ai_provider
# -------------------------------------------------------------------
@when("r2cont- I call get_ai_provider with the mocked registry")
def step_call_get_ai_provider_mocked(context: Context) -> None:
"""Call get_ai_provider passing the mock settings and registry."""
from cleveragents.application.container import get_ai_provider
mock_settings = MagicMock()
context.r2cont_ai_result = get_ai_provider(
settings=mock_settings,
provider_registry=context.r2cont_registry,
)
@when("r2cont- I call get_ai_provider without registry args")
def step_call_get_ai_provider_default(context: Context) -> None:
"""Call get_ai_provider using defaults (will trigger mock AI path)."""
from cleveragents.application.container import get_ai_provider
context.r2cont_ai_result = get_ai_provider()
# -------------------------------------------------------------------
# When: _build_project_resource_link_repo
# -------------------------------------------------------------------
@when("r2cont- I build a project resource link repo with an in-memory database")
def step_build_project_resource_link_repo(context: Context) -> None:
"""Call _build_project_resource_link_repo with sqlite memory URL."""
from cleveragents.application.container import _build_project_resource_link_repo
context.r2cont_link_repo = _build_project_resource_link_repo("sqlite:///:memory:")
# -------------------------------------------------------------------
# When: get_database_url
# -------------------------------------------------------------------
@when("r2cont- I call get_database_url")
def step_call_get_database_url(context: Context) -> None:
"""Call get_database_url and store the result."""
from cleveragents.application.container import get_database_url
context.r2cont_db_url = get_database_url()
# -------------------------------------------------------------------
# When: get_container
# -------------------------------------------------------------------
@when("r2cont- I call get_container twice")
def step_call_get_container_twice(context: Context) -> None:
"""Call get_container twice and store both references."""
from cleveragents.application.container import get_container
context.r2cont_container_first = get_container()
context.r2cont_container_second = get_container()
# -------------------------------------------------------------------
# When: override_providers - non-existent name
# -------------------------------------------------------------------
@when("r2cont- I override a provider with a non-existent name")
def step_override_nonexistent(context: Context) -> None:
"""Call override_providers with a name that doesn't exist on Container."""
from cleveragents.application.container import override_providers
context.r2cont_override_error = None
try:
override_providers(this_provider_does_not_exist="some_value")
except Exception as exc:
context.r2cont_override_error = exc
# -------------------------------------------------------------------
# When: override_providers - non-database_url provider
# -------------------------------------------------------------------
@when("r2cont- I override the settings provider with a mock value")
def step_override_settings(context: Context) -> None:
"""Override the 'settings' provider (not database_url) to hit the else branch."""
from cleveragents.application.container import override_providers
mock_settings = MagicMock()
mock_settings._r2cont_marker = "mocked_settings"
context.r2cont_mock_settings = mock_settings
override_providers(settings=mock_settings)
# -------------------------------------------------------------------
# Then: get_ai_provider assertions
# -------------------------------------------------------------------
@then("r2cont- the ai provider result should be None")
def step_ai_provider_none(context: Context) -> None:
"""Assert get_ai_provider returned None."""
assert context.r2cont_ai_result is None, (
f"Expected None, got {context.r2cont_ai_result}"
)
@then("r2cont- the ai provider result should not be None")
def step_ai_provider_not_none(context: Context) -> None:
"""Assert get_ai_provider returned a provider instance."""
assert context.r2cont_ai_result is not None, "Expected a provider, got None"
assert context.r2cont_ai_result is context.r2cont_expected_provider
@then("r2cont- the features path should appear exactly once on sys.path")
def step_features_path_no_duplicate(context: Context) -> None:
"""Verify the features path was not duplicated on sys.path."""
count = sys.path.count(context.r2cont_features_path)
assert count == context.r2cont_features_path_count_before, (
f"Expected features path count {context.r2cont_features_path_count_before}, "
f"got {count}"
)
_run_cleanups(context)
# -------------------------------------------------------------------
# Then: _build_project_resource_link_repo assertions
# -------------------------------------------------------------------
@then("r2cont- the result should be a ProjectResourceLinkRepository instance")
def step_verify_link_repo_type(context: Context) -> None:
"""Assert the returned object is a ProjectResourceLinkRepository."""
from cleveragents.infrastructure.database.repositories import (
ProjectResourceLinkRepository,
)
assert isinstance(context.r2cont_link_repo, ProjectResourceLinkRepository), (
f"Expected ProjectResourceLinkRepository, "
f"got {type(context.r2cont_link_repo).__name__}"
)
# -------------------------------------------------------------------
# Then: get_database_url assertions
# -------------------------------------------------------------------
@then('r2cont- the database URL should start with "{prefix}"')
def step_db_url_starts_with(context: Context, prefix: str) -> None:
"""Assert the database URL starts with the given prefix."""
assert context.r2cont_db_url.startswith(prefix), (
f"Expected URL starting with '{prefix}', got '{context.r2cont_db_url}'"
)
_run_cleanups(context)
@then('r2cont- the database URL should be "{expected}"')
def step_db_url_exact(context: Context, expected: str) -> None:
"""Assert the database URL matches exactly."""
assert context.r2cont_db_url == expected, (
f"Expected '{expected}', got '{context.r2cont_db_url}'"
)
_run_cleanups(context)
# -------------------------------------------------------------------
# Then: get_container assertions
# -------------------------------------------------------------------
@then("r2cont- both calls should return the same container instance")
def step_same_container(context: Context) -> None:
"""Assert both get_container calls returned the same object."""
assert context.r2cont_container_first is context.r2cont_container_second, (
"Expected the same container instance on both calls"
)
_run_cleanups(context)
# -------------------------------------------------------------------
# Then: override_providers assertions
# -------------------------------------------------------------------
@then("r2cont- no error should have been raised")
def step_no_override_error(context: Context) -> None:
"""Assert no exception was raised during override_providers."""
assert context.r2cont_override_error is None, (
f"Unexpected error: {context.r2cont_override_error}"
)
_run_cleanups(context)
@then("r2cont- the settings provider should return the mock value")
def step_settings_overridden(context: Context) -> None:
"""Assert the settings provider now returns our mock."""
from cleveragents.application.container import get_container
container = get_container()
result = container.settings()
assert result is context.r2cont_mock_settings, (
f"Expected mocked settings, got {result}"
)
_run_cleanups(context)
@@ -0,0 +1,318 @@
"""Step definitions for correction_service_coverage_r2.feature.
Covers the exception-handling paths in ``execute_revert`` (lines 254-262)
and ``execute_append`` (lines 320-328) of
``cleveragents.application.services.correction_service.CorrectionService``.
These paths are unreachable under normal operation because the try-block
code is straightforward. We use ``unittest.mock.patch`` to force
exceptions inside the try blocks so that the ``except Exception`` handlers
execute.
All step text uses a ``r2corr-`` prefix to avoid collisions with existing
step definitions in correction_service_coverage_steps.py and
correction_service_new_coverage_steps.py.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
from behave import given, then, when
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.domain.models.core.correction import (
CorrectionMode,
CorrectionStatus,
)
# -------------------------------------------------------------------
# Background
# -------------------------------------------------------------------
@given("r2corr-a fresh correction service")
def step_r2_background(context: Any) -> None:
"""Initialise a clean service and all context slots."""
context.r2_svc = CorrectionService()
context.r2_cid = None
context.r2_revert_result = None
context.r2_append_result = None
context.r2_dispatch_result = None
context.r2_patch = None
# -------------------------------------------------------------------
# Given - create corrections
# -------------------------------------------------------------------
@given('r2corr-a stored revert correction for plan "{plan}" targeting "{target}"')
def step_r2_stored_revert(context: Any, plan: str, target: str) -> None:
req = context.r2_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.r2_cid = req.correction_id
@given('r2corr-a stored append correction for plan "{plan}" targeting "{target}"')
def step_r2_stored_append(context: Any, plan: str, target: str) -> None:
req = context.r2_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.APPEND,
)
context.r2_cid = req.correction_id
# -------------------------------------------------------------------
# Given - monkeypatches to force exceptions
# -------------------------------------------------------------------
@given('r2corr-analyze_impact is patched to raise RuntimeError "{msg}"')
def step_r2_patch_analyze_runtime(context: Any, msg: str) -> None:
"""Replace ``analyze_impact`` with a function that raises RuntimeError."""
original = context.r2_svc.analyze_impact
def _boom(*args: Any, **kwargs: Any) -> None:
raise RuntimeError(msg)
context.r2_svc.analyze_impact = _boom # type: ignore[assignment]
context.r2_original_analyze = original
@given('r2corr-analyze_impact is patched to raise ValueError "{msg}"')
def step_r2_patch_analyze_value(context: Any, msg: str) -> None:
"""Replace ``analyze_impact`` with a function that raises ValueError."""
def _boom(*args: Any, **kwargs: Any) -> None:
raise ValueError(msg)
context.r2_svc.analyze_impact = _boom # type: ignore[assignment]
@given('r2corr-analyze_impact is patched to raise TypeError "{msg}"')
def step_r2_patch_analyze_type(context: Any, msg: str) -> None:
"""Replace ``analyze_impact`` with a function that raises TypeError."""
def _boom(*args: Any, **kwargs: Any) -> None:
raise TypeError(msg)
context.r2_svc.analyze_impact = _boom # type: ignore[assignment]
@given('r2corr-ULID is patched to raise RuntimeError "{msg}"')
def step_r2_patch_ulid_runtime(context: Any, msg: str) -> None:
"""Store patch info so the When step can apply it in the right scope."""
context.r2_ulid_exc = RuntimeError(msg)
@given('r2corr-ULID is patched to raise ValueError "{msg}"')
def step_r2_patch_ulid_value(context: Any, msg: str) -> None:
context.r2_ulid_exc = ValueError(msg)
@given('r2corr-ULID is patched to raise OSError "{msg}"')
def step_r2_patch_ulid_os(context: Any, msg: str) -> None:
context.r2_ulid_exc = OSError(msg)
# -------------------------------------------------------------------
# When - execute_revert
# -------------------------------------------------------------------
@when("r2corr-I execute revert on the stored correction")
def step_r2_exec_revert(context: Any) -> None:
"""Execute revert; analyze_impact is already patched on the instance."""
context.r2_revert_result = context.r2_svc.execute_revert(context.r2_cid, {})
# -------------------------------------------------------------------
# When - execute_append
# -------------------------------------------------------------------
@when("r2corr-I execute append on the stored correction")
def step_r2_exec_append(context: Any) -> None:
"""Execute append with ULID patched to raise."""
exc = getattr(context, "r2_ulid_exc", None)
if exc is not None:
with patch(
"cleveragents.application.services.correction_service.ULID",
side_effect=exc,
):
context.r2_append_result = context.r2_svc.execute_append(
context.r2_cid,
)
else:
context.r2_append_result = context.r2_svc.execute_append(
context.r2_cid,
)
# -------------------------------------------------------------------
# When - execute_correction (dispatch)
# -------------------------------------------------------------------
@when("r2corr-I dispatch execute correction on the stored correction")
def step_r2_dispatch(context: Any) -> None:
"""Dispatch execute_correction; patches are already active."""
exc = getattr(context, "r2_ulid_exc", None)
if exc is not None:
with patch(
"cleveragents.application.services.correction_service.ULID",
side_effect=exc,
):
context.r2_dispatch_result = context.r2_svc.execute_correction(
context.r2_cid,
)
else:
context.r2_dispatch_result = context.r2_svc.execute_correction(
context.r2_cid,
{},
)
# -------------------------------------------------------------------
# Then - revert result assertions
# -------------------------------------------------------------------
@then('r2corr-the revert result status should be "{val}"')
def step_r2_revert_status(context: Any, val: str) -> None:
assert context.r2_revert_result is not None, "No revert result captured"
assert context.r2_revert_result.status.value == val, (
f"Expected status '{val}', got '{context.r2_revert_result.status.value}'"
)
@then('r2corr-the revert result error_message should contain "{fragment}"')
def step_r2_revert_error_msg(context: Any, fragment: str) -> None:
msg = context.r2_revert_result.error_message
assert msg is not None, "error_message is None"
assert fragment in msg, f"'{fragment}' not found in error_message: '{msg}'"
# -------------------------------------------------------------------
# Then - append result assertions
# -------------------------------------------------------------------
@then('r2corr-the append result status should be "{val}"')
def step_r2_append_status(context: Any, val: str) -> None:
assert context.r2_append_result is not None, "No append result captured"
assert context.r2_append_result.status.value == val, (
f"Expected status '{val}', got '{context.r2_append_result.status.value}'"
)
@then('r2corr-the append result error_message should contain "{fragment}"')
def step_r2_append_error_msg(context: Any, fragment: str) -> None:
msg = context.r2_append_result.error_message
assert msg is not None, "error_message is None"
assert fragment in msg, f"'{fragment}' not found in error_message: '{msg}'"
# -------------------------------------------------------------------
# Then - dispatch result assertions
# -------------------------------------------------------------------
@then('r2corr-the dispatch result status should be "{val}"')
def step_r2_dispatch_status(context: Any, val: str) -> None:
assert context.r2_dispatch_result is not None, "No dispatch result captured"
assert context.r2_dispatch_result.status.value == val, (
f"Expected status '{val}', got '{context.r2_dispatch_result.status.value}'"
)
@then('r2corr-the dispatch result error_message should contain "{fragment}"')
def step_r2_dispatch_error_msg(context: Any, fragment: str) -> None:
msg = context.r2_dispatch_result.error_message
assert msg is not None, "error_message is None"
assert fragment in msg, f"'{fragment}' not found in error_message: '{msg}'"
# -------------------------------------------------------------------
# Then - stored correction status
# -------------------------------------------------------------------
@then('r2corr-the stored correction status should be "{val}"')
def step_r2_stored_status(context: Any, val: str) -> None:
req = context.r2_svc.get_correction(context.r2_cid)
assert req.status.value == val, (
f"Expected stored status '{val}', got '{req.status.value}'"
)
# -------------------------------------------------------------------
# Then - attempt assertions
# -------------------------------------------------------------------
@then("r2corr-the first attempt should have success false")
def step_r2_attempt_fail(context: Any) -> None:
attempts = context.r2_svc.list_attempts(context.r2_cid)
assert len(attempts) > 0, "No attempts recorded"
assert attempts[0].success is False, (
f"Expected success=False, got {attempts[0].success}"
)
@then("r2corr-the first attempt details should contain error key")
def step_r2_attempt_error_key(context: Any) -> None:
attempts = context.r2_svc.list_attempts(context.r2_cid)
assert "error" in attempts[0].details, (
f"Expected 'error' key in details, got {attempts[0].details}"
)
@then('r2corr-the first attempt details error should contain "{fragment}"')
def step_r2_attempt_error_val(context: Any, fragment: str) -> None:
attempts = context.r2_svc.list_attempts(context.r2_cid)
error_val = attempts[0].details.get("error", "")
assert fragment in error_val, (
f"'{fragment}' not found in attempt error: '{error_val}'"
)
@then("r2corr-the first attempt completed_at should be set")
def step_r2_attempt_completed(context: Any) -> None:
attempts = context.r2_svc.list_attempts(context.r2_cid)
assert attempts[0].completed_at is not None, "completed_at is None"
@then("r2corr-the attempts for the stored correction should have {n:d} entry")
def step_r2_attempt_count(context: Any, n: int) -> None:
attempts = context.r2_svc.list_attempts(context.r2_cid)
assert len(attempts) == n, f"Expected {n} attempts, got {len(attempts)}"
# -------------------------------------------------------------------
# Then - result stored in service
# -------------------------------------------------------------------
@then("r2corr-the result should be stored in the service results dict")
def step_r2_result_stored(context: Any) -> None:
assert context.r2_cid in context.r2_svc._results, (
f"correction_id {context.r2_cid} not in _results"
)
stored = context.r2_svc._results[context.r2_cid]
assert stored.status == CorrectionStatus.FAILED
@then("r2corr-the append result should be stored in the service results dict")
def step_r2_append_result_stored(context: Any) -> None:
assert context.r2_cid in context.r2_svc._results, (
f"correction_id {context.r2_cid} not in _results"
)
stored = context.r2_svc._results[context.r2_cid]
assert stored.status == CorrectionStatus.FAILED
@@ -0,0 +1,409 @@
"""Step definitions for models_lifecycle_coverage_r2.feature.
Provides branch coverage for ``LifecycleActionModel`` and
``LifecyclePlanModel`` conversion helpers and plan helper functions in
``cleveragents.infrastructure.database.models``.
All step-text uses an ``r2mod-`` prefix to avoid collisions with
existing step definition files.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when
from sqlalchemy.orm import attributes as sa_attr
from cleveragents.infrastructure.database.models import (
ActionArgumentModel,
ActionInvariantModel,
LifecycleActionModel,
LifecyclePlanModel,
PlanArgumentModel,
)
# ===================================================================
# Helpers
# ===================================================================
_NOW_ISO: str = datetime.now(tz=UTC).isoformat()
# Valid ULID constant for use in test models
_ULID_PLAN = "01ARZ3NDEKTSV4RRFFQ69G5FA2"
def _set_rel_none(model: Any, attr: str) -> None:
"""Bypass SQLAlchemy's instrumented setter to force a relationship to None.
This lets us test the ``or []`` fallback branches in ``to_domain()``.
"""
state = sa_attr.instance_state(model)
state.dict[attr] = None
def _make_action_model(
*,
arguments: list[ActionArgumentModel] | None = None,
invariants: list[ActionInvariantModel] | None = None,
tags_json: str | None = "[]",
inputs_schema_json: str | None = None,
) -> LifecycleActionModel:
"""Create a minimal ``LifecycleActionModel``."""
model = LifecycleActionModel(
namespaced_name="local/test-action",
namespace="local",
name="test-action",
description="Test action",
long_description=None,
definition_of_done="tests pass",
strategy_actor="local/strat",
execution_actor="local/exec",
review_actor=None,
apply_actor=None,
estimation_actor=None,
invariant_actor=None,
automation_profile=None,
reusable=True,
read_only=False,
inputs_schema_json=inputs_schema_json,
state="available",
created_by=None,
tags_json=tags_json,
created_at=_NOW_ISO,
updated_at=_NOW_ISO,
)
if arguments is not None:
model.arguments_rel = arguments
else:
model.arguments_rel = []
if invariants is not None:
model.invariants_rel = invariants
else:
model.invariants_rel = []
return model
def _make_plan_model(
*,
automation_profile: str | None = None,
sandbox_refs_json: str | None = None,
validation_summary_json: str | None = None,
tags_json: str | None = None,
action_name: str | None = None,
error_details_json: str | None = None,
error_message: str | None = None,
arguments: list[PlanArgumentModel] | None = None,
) -> LifecyclePlanModel:
"""Create a minimal ``LifecyclePlanModel``."""
model = LifecyclePlanModel(
plan_id=_ULID_PLAN,
parent_plan_id=None,
root_plan_id=None,
action_name=action_name or "",
namespaced_name="local/test-plan",
namespace="local",
phase="action",
processing_state="queued",
attempt=1,
description="Test plan",
definition_of_done=None,
strategy_actor=None,
execution_actor=None,
review_actor=None,
apply_actor=None,
estimation_actor=None,
invariant_actor=None,
automation_profile=automation_profile,
reusable=True,
read_only=False,
inputs_schema_json=None,
changeset_id=None,
sandbox_refs_json=sandbox_refs_json,
validation_summary_json=validation_summary_json,
decision_root_id=None,
error_message=error_message,
error_details_json=error_details_json,
created_by=None,
tags_json=tags_json or "[]",
created_at=_NOW_ISO,
updated_at=_NOW_ISO,
completed_at=None,
strategize_started_at=None,
strategize_completed_at=None,
execute_started_at=None,
execute_completed_at=None,
apply_started_at=None,
applied_at=None,
)
model.project_links_rel = []
model.invariants_rel = []
if arguments is not None:
model.arguments_rel = arguments
else:
model.arguments_rel = []
return model
# ===================================================================
# LifecycleActionModel.to_domain()
# ===================================================================
@given("a r2mod-ActionModel with an argument having None defaults")
def step_action_arg_none_defaults(context: Any) -> None:
arg = ActionArgumentModel(
name="arg1",
arg_type="string",
requirement="required",
description="desc",
default_value_json=None,
min_value=None,
max_value=None,
validation_pattern=None,
position=0,
)
context.r2_action_model = _make_action_model(arguments=[arg])
@given("a r2mod-ActionModel with None tags_json and None inputs_schema_json")
def step_action_none_tags_inputs(context: Any) -> None:
context.r2_action_model = _make_action_model(
tags_json=None, inputs_schema_json=None
)
@given("a r2mod-ActionModel with None rels")
def step_action_none_rels(context: Any) -> None:
model = _make_action_model()
_set_rel_none(model, "arguments_rel")
_set_rel_none(model, "invariants_rel")
context.r2_action_model = model
@when("I r2mod-convert the ActionModel to domain")
def step_action_to_domain(context: Any) -> None:
context.r2_action_domain = context.r2_action_model.to_domain()
@then("the r2mod-action first argument default_value should be None")
def step_action_arg_default_none(context: Any) -> None:
assert context.r2_action_domain.arguments[0].default_value is None
@then("the r2mod-action first argument min_value should be None")
def step_action_arg_min_none(context: Any) -> None:
assert context.r2_action_domain.arguments[0].min_value is None
@then("the r2mod-action first argument max_value should be None")
def step_action_arg_max_none(context: Any) -> None:
assert context.r2_action_domain.arguments[0].max_value is None
@then("the r2mod-action tags should be empty")
def step_action_tags_empty(context: Any) -> None:
assert context.r2_action_domain.tags == []
@then("the r2mod-action inputs_schema should be None")
def step_action_inputs_none(context: Any) -> None:
assert context.r2_action_domain.inputs_schema is None
@then("the r2mod-action arguments should be empty")
def step_action_args_empty(context: Any) -> None:
assert context.r2_action_domain.arguments == []
@then("the r2mod-action invariants should be empty")
def step_action_invs_empty(context: Any) -> None:
assert context.r2_action_domain.invariants == []
# ===================================================================
# LifecyclePlanModel.to_domain()
# ===================================================================
@given("a r2mod-PlanModel with all optional fields set to None")
def step_plan_all_none(context: Any) -> None:
model = _make_plan_model(
automation_profile=None,
sandbox_refs_json=None,
validation_summary_json=None,
tags_json=None,
action_name="local/test-action", # required non-empty by Plan domain
error_details_json=None,
)
_set_rel_none(model, "project_links_rel")
_set_rel_none(model, "invariants_rel")
_set_rel_none(model, "arguments_rel")
context.r2_plan_model = model
@given("a r2mod-PlanModel with None action_name")
def step_plan_none_action(context: Any) -> None:
context.r2_plan_model = _make_plan_model(action_name=None)
@when("I r2mod-attempt to convert the PlanModel to domain")
def step_plan_to_domain_attempt(context: Any) -> None:
try:
context.r2_plan_domain = context.r2_plan_model.to_domain()
context.r2_error = None
except Exception as exc:
context.r2_error = exc
@then('a r2mod-ValidationError should have been raised with "{fragment}"')
def step_r2_validation_error_fragment(context: Any, fragment: str) -> None:
assert context.r2_error is not None, "Expected an error but none was raised"
assert fragment in str(context.r2_error), (
f"Expected '{fragment}' in error: {context.r2_error}"
)
@given("a r2mod-PlanModel with an argument having None value_json")
def step_plan_arg_none_value(context: Any) -> None:
arg = PlanArgumentModel(
name="test_arg",
value_json=None,
value_type="string",
position=0,
)
context.r2_plan_model = _make_plan_model(
arguments=[arg], action_name="local/test-action"
)
@when("I r2mod-convert the PlanModel to domain")
def step_plan_to_domain(context: Any) -> None:
context.r2_plan_domain = context.r2_plan_model.to_domain()
@then("the r2mod-plan automation_profile should be None")
def step_plan_profile_none(context: Any) -> None:
assert context.r2_plan_domain.automation_profile is None
@then("the r2mod-plan validation_summary should be None")
def step_plan_validation_none(context: Any) -> None:
assert context.r2_plan_domain.validation_summary is None
@then("the r2mod-plan sandbox_refs should be empty")
def step_plan_sandbox_empty(context: Any) -> None:
assert context.r2_plan_domain.sandbox_refs == []
@then("the r2mod-plan tags should be empty")
def step_plan_tags_empty(context: Any) -> None:
assert context.r2_plan_domain.tags == []
@then("the r2mod-plan action_name should be empty string")
def step_plan_action_empty(context: Any) -> None:
assert context.r2_plan_domain.action_name == ""
@then("the r2mod-plan error_details should be None")
def step_plan_err_none(context: Any) -> None:
assert context.r2_plan_domain.error_details is None
@then('the r2mod-plan argument "{name}" should be None')
def step_plan_arg_value_none(context: Any, name: str) -> None:
assert context.r2_plan_domain.arguments[name] is None
# ===================================================================
# LifecycleActionModel.from_domain()
# ===================================================================
@when("I r2mod-create ActionModel from domain with None inputs_schema")
def step_action_from_domain_none_schema(context: Any) -> None:
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import NamespacedName
action = Action(
namespaced_name=NamespacedName(namespace="local", name="test-act"),
description="test",
definition_of_done="tests pass",
strategy_actor="local/strat",
execution_actor="local/exec",
inputs_schema=None,
state=ActionState.AVAILABLE,
)
context.r2_created_action = LifecycleActionModel.from_domain(action)
@then("the r2mod-created ActionModel inputs_schema_json should be None")
def step_action_created_schema_none(context: Any) -> None:
assert context.r2_created_action.inputs_schema_json is None
@when("I r2mod-create ActionModel from domain with argument having None default")
def step_action_from_domain_arg_none_default(context: Any) -> None:
from cleveragents.domain.models.core.action import (
Action,
ActionArgument,
ActionState,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import NamespacedName
arg = ActionArgument(
name="myarg",
arg_type=ArgumentType.STRING,
requirement=ArgumentRequirement.OPTIONAL,
description="no default",
default_value=None,
)
action = Action(
namespaced_name=NamespacedName(namespace="local", name="test-act"),
description="test",
definition_of_done="tests pass",
strategy_actor="local/strat",
execution_actor="local/exec",
arguments=[arg],
state=ActionState.AVAILABLE,
)
context.r2_created_action = LifecycleActionModel.from_domain(action)
@then("the r2mod-created ActionModel first argument default_value_json should be None")
def step_action_created_arg_default_none(context: Any) -> None:
assert context.r2_created_action.arguments_rel[0].default_value_json is None
# ===================================================================
# LifecyclePlanModel helpers
# ===================================================================
@then("r2mod-_parse_iso with None should return None")
def step_parse_iso_none(context: Any) -> None:
assert LifecyclePlanModel._parse_iso(None) is None
@then("r2mod-_to_iso with None should return None")
def step_to_iso_none(context: Any) -> None:
assert LifecyclePlanModel._to_iso(None) is None
@then('r2mod-_parse_iso with "{iso}" should return a datetime')
def step_parse_iso_value(context: Any, iso: str) -> None:
result = LifecyclePlanModel._parse_iso(iso)
assert isinstance(result, datetime)
@then("r2mod-_to_iso with a datetime should return an ISO string")
def step_to_iso_value(context: Any) -> None:
now = datetime.now(tz=UTC)
result = LifecyclePlanModel._to_iso(now)
assert isinstance(result, str)
assert "T" in result
@@ -0,0 +1,314 @@
"""Step definitions for models_record_coverage_r2.feature.
Provides branch coverage for ``SessionModel``, ``SessionMessageModel``,
``ToolModel``, and ``NamespacedProjectModel`` conversion helpers in
``cleveragents.infrastructure.database.models``.
All step-text uses an ``r2mod-`` prefix to avoid collisions with
existing step definition files.
"""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from behave import given, then, when
from sqlalchemy.orm import attributes as sa_attr
from cleveragents.infrastructure.database.models import (
NamespacedProjectModel,
SessionMessageModel,
SessionModel,
ToolModel,
)
# ===================================================================
# Helpers
# ===================================================================
_NOW_ISO: str = datetime.now(tz=UTC).isoformat()
# Valid ULID constants for use in test models
_ULID_SESS: str = "01HTEST0000000000000SESS01" # invalid; replaced below
_ULID_MSG1: str = "01HTEST0000000000000MSG001"
# Actually valid ULIDs matching ^[0-9A-HJKMNP-TV-Z]{26}$
_ULID_SESS = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
_ULID_MSG1 = "01ARZ3NDEKTSV4RRFFQ69G5FA1"
def _set_rel_none(model: Any, attr: str) -> None:
"""Bypass SQLAlchemy's instrumented setter to force a relationship to None.
This lets us test the ``or []`` fallback branches in ``to_domain()``.
"""
state = sa_attr.instance_state(model)
state.dict[attr] = None
# ===================================================================
# SessionModel.to_domain()
# ===================================================================
@given("a r2mod-SessionModel with all JSON fields as None")
def step_session_none_json(context: Any) -> None:
model = SessionModel(
session_id=_ULID_SESS,
actor_name=None,
namespace="local",
linked_plan_ids_json=None,
token_usage_json=None,
metadata_json=None,
created_at=_NOW_ISO,
updated_at=_NOW_ISO,
)
_set_rel_none(model, "messages_rel")
context.r2_session_model = model
@when("I r2mod-convert the SessionModel to domain")
def step_session_to_domain(context: Any) -> None:
context.r2_session_domain = context.r2_session_model.to_domain()
@then("the r2mod-session linked_plan_ids should be empty")
def step_session_plan_ids_empty(context: Any) -> None:
assert context.r2_session_domain.linked_plan_ids == []
@then("the r2mod-session token_usage input_tokens should be 0")
def step_session_tokens_zero(context: Any) -> None:
assert context.r2_session_domain.token_usage.input_tokens == 0
@then("the r2mod-session metadata should be empty")
def step_session_meta_empty(context: Any) -> None:
assert context.r2_session_domain.metadata == {}
@then("the r2mod-session messages should be empty")
def step_session_messages_empty(context: Any) -> None:
assert context.r2_session_domain.messages == []
# ===================================================================
# SessionMessageModel.to_domain()
# ===================================================================
@given("a r2mod-SessionMessageModel with None metadata_json")
def step_msg_none_meta(context: Any) -> None:
context.r2_msg_model = SessionMessageModel(
message_id=_ULID_MSG1,
session_id=_ULID_SESS,
role="user",
content="hello",
sequence=0,
timestamp=_NOW_ISO,
metadata_json=None,
tool_call_id=None,
)
@when("I r2mod-convert the SessionMessageModel to domain")
def step_msg_to_domain(context: Any) -> None:
context.r2_msg_domain = context.r2_msg_model.to_domain()
@then("the r2mod-message metadata should be empty")
def step_msg_meta_empty(context: Any) -> None:
assert context.r2_msg_domain.metadata == {}
# ===================================================================
# ToolModel.to_domain() / from_domain()
# ===================================================================
@given("a r2mod-ToolModel with no resource bindings")
def step_tool_no_bindings(context: Any) -> None:
model = ToolModel(
name="local/test-tool",
namespace="local",
short_name="test-tool",
description="A test tool",
tool_type="tool",
source="builtin",
input_schema_json=None,
output_schema_json=None,
capability_json=None,
lifecycle_json=None,
code=None,
mcp_server=None,
mcp_tool_name=None,
agent_skill_path=None,
timeout=300,
wraps=None,
transform=None,
mode=None,
argument_mapping_json=None,
created_at=_NOW_ISO,
updated_at=_NOW_ISO,
)
_set_rel_none(model, "resource_bindings_rel")
model.validation_attachments_rel = []
context.r2_tool_model = model
@when("I r2mod-convert the ToolModel to domain")
def step_tool_to_domain(context: Any) -> None:
context.r2_tool_domain = context.r2_tool_model.to_domain()
@then("the r2mod-tool resource_bindings should be empty")
def step_tool_bindings_empty(context: Any) -> None:
assert context.r2_tool_domain["resource_bindings"] == []
@when('I r2mod-create ToolModel from dict with name "{name}"')
def step_tool_from_dict(context: Any, name: str) -> None:
context.r2_created_tool = ToolModel.from_domain({"name": name, "description": "d"})
@when('I r2mod-create ToolModel from object with name "{name}"')
def step_tool_from_obj(context: Any, name: str) -> None:
obj = SimpleNamespace(name=name, description="d")
context.r2_created_tool = ToolModel.from_domain(obj)
@when("I r2mod-create ToolModel from dict with no resource_bindings")
def step_tool_from_dict_no_bindings(context: Any) -> None:
context.r2_created_tool = ToolModel.from_domain(
{"name": "ns/t", "description": "d", "resource_bindings": None}
)
@then("the r2mod-created ToolModel namespace should be empty string")
def step_tool_ns_empty(context: Any) -> None:
assert context.r2_created_tool.namespace == ""
@then('the r2mod-created ToolModel namespace should be "{ns}"')
def step_tool_ns_value(context: Any, ns: str) -> None:
assert context.r2_created_tool.namespace == ns
@then('the r2mod-created ToolModel short_name should be "{sn}"')
def step_tool_short_name(context: Any, sn: str) -> None:
assert context.r2_created_tool.short_name == sn
@then("the r2mod-created ToolModel resource_bindings_rel should be empty")
def step_tool_created_bindings_empty(context: Any) -> None:
assert len(context.r2_created_tool.resource_bindings_rel) == 0
# ===================================================================
# NamespacedProjectModel.to_domain()
# ===================================================================
@given('a r2mod-ProjectModel with no context_policy_json and name "{name}"')
def step_project_no_policy(context: Any, name: str) -> None:
model = NamespacedProjectModel(
namespaced_name=name,
namespace="local",
description=None,
invariants_json=None,
automation_profile=None,
invariant_actor=None,
context_policy_json=None,
tags_json="[]",
created_by=None,
created_at=_NOW_ISO,
updated_at=_NOW_ISO,
)
model.resource_links = []
context.r2_project_model = model
@given("a r2mod-ProjectModel with None resource_links")
def step_project_none_links(context: Any) -> None:
model = NamespacedProjectModel(
namespaced_name="local/proj",
namespace="local",
description=None,
invariants_json=None,
automation_profile=None,
invariant_actor=None,
context_policy_json=None,
tags_json="[]",
created_by=None,
created_at=_NOW_ISO,
updated_at=_NOW_ISO,
)
_set_rel_none(model, "resource_links")
context.r2_project_model = model
@when("I r2mod-convert the ProjectModel to domain")
def step_project_to_domain(context: Any) -> None:
context.r2_project_domain = context.r2_project_model.to_domain()
@then('the r2mod-project name should be "{name}"')
def step_project_name(context: Any, name: str) -> None:
assert context.r2_project_domain.name == name
@then("the r2mod-project linked_resources should be empty")
def step_project_links_empty(context: Any) -> None:
assert context.r2_project_domain.linked_resources == []
# ===================================================================
# NamespacedProjectModel.from_domain()
# ===================================================================
@when("I r2mod-create ProjectModel from domain with None context_config")
def step_project_from_domain_none_config(context: Any) -> None:
obj = SimpleNamespace(
namespaced_name="local/proj",
namespace="local",
description="test",
context_config=None,
tags=[],
created_at=datetime.now(tz=UTC),
updated_at=datetime.now(tz=UTC),
)
context.r2_created_project = NamespacedProjectModel.from_domain(obj)
@then("the r2mod-created ProjectModel context_policy_json should be None")
def step_project_created_policy_none(context: Any) -> None:
assert context.r2_created_project.context_policy_json is None
# ===================================================================
# SessionMessageModel.from_domain()
# ===================================================================
@when("I r2mod-create SessionMessageModel from domain with string role")
def step_msg_from_domain_string_role(context: Any) -> None:
"""Test the branch where role has no .value attr (plain string)."""
obj = SimpleNamespace(
message_id=_ULID_MSG1,
session_id=_ULID_SESS,
role="user", # plain string, no .value
content="hello",
sequence=0,
timestamp=datetime.now(tz=UTC),
metadata={},
tool_call_id=None,
)
context.r2_created_msg = SessionMessageModel.from_domain(obj)
@then('the r2mod-created SessionMessageModel role should be "{role}"')
def step_msg_created_role(context: Any, role: str) -> None:
assert context.r2_created_msg.role == role
@@ -0,0 +1,401 @@
"""Step definitions for models_skill_coverage_r2.feature.
Provides branch coverage for the ``SkillModel.to_domain()`` /
``SkillModel.from_domain()`` conversion helpers in
``cleveragents.infrastructure.database.models``.
All step-text uses an ``r2mod-`` prefix to avoid collisions with
existing step definition files.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from behave import given, then, when
from sqlalchemy.orm import attributes as sa_attr
from cleveragents.infrastructure.database.models import (
SkillItemModel,
SkillModel,
)
# ===================================================================
# Helpers
# ===================================================================
_NOW_ISO: str = datetime.now(tz=UTC).isoformat()
def _set_rel_none(model: Any, attr: str) -> None:
"""Bypass SQLAlchemy's instrumented setter to force a relationship to None.
This lets us test the ``or []`` fallback branches in ``to_domain()``.
"""
state = sa_attr.instance_state(model)
state.dict[attr] = None
def _make_skill_model(
*,
items: list[SkillItemModel] | None = None,
metadata_json: str | None = None,
) -> SkillModel:
"""Create a minimal ``SkillModel`` with controllable items/metadata."""
model = SkillModel(
name="test/skill",
namespace="test",
short_name="skill",
description="A test skill",
version=None,
metadata_json=metadata_json,
created_at=_NOW_ISO,
updated_at=_NOW_ISO,
)
if items is not None:
model.items_rel = items
else:
# Force None to test the ``or []`` fallback
_set_rel_none(model, "items_rel")
return model
def _make_skill_item(
*,
item_type: str,
item_name: str = "dummy",
item_config: str | None = None,
item_order: int = 0,
) -> SkillItemModel:
"""Create a standalone ``SkillItemModel``."""
return SkillItemModel(
item_type=item_type,
item_name=item_name,
item_config=item_config,
item_order=item_order,
created_at=_NOW_ISO,
)
# ===================================================================
# SkillModel.to_domain() - Given
# ===================================================================
@given("a r2mod-SkillModel with no items and no metadata")
def step_skill_no_items(context: Any) -> None:
context.r2_skill_model = _make_skill_model(items=None, metadata_json=None)
@given('a r2mod-SkillModel with a tool_ref item named "{name}"')
def step_skill_tool_ref(context: Any, name: str) -> None:
item = _make_skill_item(item_type="tool_ref", item_name=name)
context.r2_skill_model = _make_skill_model(items=[item])
@given('a r2mod-SkillModel with an include item "{name}" with config overrides')
def step_skill_include_with_config(context: Any, name: str) -> None:
config = json.dumps({"overrides": {"timeout": 60}})
item = _make_skill_item(item_type="include", item_name=name, item_config=config)
context.r2_skill_model = _make_skill_model(items=[item])
@given('a r2mod-SkillModel with an include item "{name}" without config')
def step_skill_include_no_config(context: Any, name: str) -> None:
item = _make_skill_item(item_type="include", item_name=name, item_config=None)
context.r2_skill_model = _make_skill_model(items=[item])
@given("a r2mod-SkillModel with an inline_tool item without config")
def step_skill_inline_no_config(context: Any) -> None:
item = _make_skill_item(item_type="inline_tool", item_name="anon", item_config=None)
context.r2_skill_model = _make_skill_model(items=[item])
@given("a r2mod-SkillModel with an mcp_source item without config")
def step_skill_mcp_no_config(context: Any) -> None:
item = _make_skill_item(
item_type="mcp_source", item_name="server1", item_config=None
)
context.r2_skill_model = _make_skill_model(items=[item])
@given('a r2mod-SkillModel with an agent_source item "{path}"')
def step_skill_agent_source(context: Any, path: str) -> None:
item = _make_skill_item(item_type="agent_source", item_name=path)
context.r2_skill_model = _make_skill_model(items=[item])
@given("a r2mod-SkillModel with metadata_json containing overrides")
def step_skill_with_metadata(context: Any) -> None:
meta = json.dumps({"overrides": {"local/my-tool": {"timeout": 30}}})
context.r2_skill_model = _make_skill_model(items=[], metadata_json=meta)
@given("a r2mod-SkillModel with inline_tool and mcp_source items with config")
def step_skill_inline_and_mcp_with_config(context: Any) -> None:
inline_config = json.dumps({"description": "inline desc", "source": "custom"})
mcp_config = json.dumps({"server": "mcp-srv", "tools": None, "env": None})
items = [
_make_skill_item(
item_type="inline_tool",
item_name="inline desc",
item_config=inline_config,
item_order=0,
),
_make_skill_item(
item_type="mcp_source",
item_name="mcp-srv",
item_config=mcp_config,
item_order=1,
),
]
context.r2_skill_model = _make_skill_model(items=items)
# ===================================================================
# SkillModel.to_domain() - When / Then
# ===================================================================
@when("I r2mod-convert the SkillModel to domain")
def step_skill_to_domain(context: Any) -> None:
context.r2_skill_domain = context.r2_skill_model.to_domain()
@then("the r2mod-skill tool_refs should be empty")
def step_skill_tool_refs_empty(context: Any) -> None:
assert context.r2_skill_domain.tool_refs == []
@then('the r2mod-skill tool_refs should contain "{name}"')
def step_skill_tool_refs_contains(context: Any, name: str) -> None:
assert name in context.r2_skill_domain.tool_refs
@then("the r2mod-skill includes should be empty")
def step_skill_includes_empty(context: Any) -> None:
assert context.r2_skill_domain.includes == []
@then("the r2mod-skill includes should have {n:d} entry")
def step_skill_includes_count(context: Any, n: int) -> None:
assert len(context.r2_skill_domain.includes) == n
@then('the r2mod-skill first include name should be "{name}"')
def step_skill_first_include_name(context: Any, name: str) -> None:
assert context.r2_skill_domain.includes[0].name == name
@then("the r2mod-skill first include overrides should not be None")
def step_skill_first_include_overrides_not_none(context: Any) -> None:
assert context.r2_skill_domain.includes[0].overrides is not None
@then("the r2mod-skill first include overrides should be None")
def step_skill_first_include_overrides_none(context: Any) -> None:
assert context.r2_skill_domain.includes[0].overrides is None
@then("the r2mod-skill anonymous_tools should be empty")
def step_skill_anon_empty(context: Any) -> None:
assert context.r2_skill_domain.anonymous_tools == []
@then("the r2mod-skill anonymous_tools should have {n:d} entry")
def step_skill_anon_count(context: Any, n: int) -> None:
assert len(context.r2_skill_domain.anonymous_tools) == n
@then("the r2mod-skill mcp_servers should be empty")
def step_skill_mcp_empty(context: Any) -> None:
assert context.r2_skill_domain.mcp_servers == []
@then("the r2mod-skill mcp_servers should have {n:d} entry")
def step_skill_mcp_count(context: Any, n: int) -> None:
assert len(context.r2_skill_domain.mcp_servers) == n
@then("the r2mod-skill agent_skills should be empty")
def step_skill_agents_empty(context: Any) -> None:
assert context.r2_skill_domain.agent_skills == []
@then("the r2mod-skill agent_skills should have {n:d} entry")
def step_skill_agents_count(context: Any, n: int) -> None:
assert len(context.r2_skill_domain.agent_skills) == n
@then('the r2mod-skill first agent_skill path should be "{path}"')
def step_skill_first_agent_path(context: Any, path: str) -> None:
assert context.r2_skill_domain.agent_skills[0].path == path
@then("the r2mod-skill overrides should be empty")
def step_skill_overrides_empty(context: Any) -> None:
assert context.r2_skill_domain.overrides == {}
@then("the r2mod-skill overrides should not be empty")
def step_skill_overrides_not_empty(context: Any) -> None:
assert context.r2_skill_domain.overrides != {}
# ===================================================================
# SkillModel.from_domain() - When / Then
# ===================================================================
@when('I r2mod-attempt from_domain on SkillModel with name "{name}"')
def step_skill_from_domain_invalid(context: Any, name: str) -> None:
try:
SkillModel.from_domain({"name": name, "description": "d"})
context.r2_error = None
except ValueError as exc:
context.r2_error = exc
@then("a r2mod-ValueError should have been raised")
def step_r2_valueerror(context: Any) -> None:
assert context.r2_error is not None
assert isinstance(context.r2_error, ValueError)
@when('I r2mod-create SkillModel from domain with name "{name}" and no overrides')
def step_skill_from_domain_no_overrides(context: Any, name: str) -> None:
context.r2_created_skill = SkillModel.from_domain(
{"name": name, "description": "d", "overrides": {}}
)
@when('I r2mod-create SkillModel from domain with name "{name}" and overrides')
def step_skill_from_domain_with_overrides(context: Any, name: str) -> None:
context.r2_created_skill = SkillModel.from_domain(
{
"name": name,
"description": "d",
"overrides": {"local/tool": {"timeout": 10}},
}
)
@then("the r2mod-created SkillModel metadata_json should be None")
def step_skill_created_meta_none(context: Any) -> None:
assert context.r2_created_skill.metadata_json is None
@then('the r2mod-created SkillModel metadata_json should contain "overrides"')
def step_skill_created_meta_overrides(context: Any) -> None:
assert context.r2_created_skill.metadata_json is not None
assert "overrides" in context.r2_created_skill.metadata_json
@when("I r2mod-create SkillModel from domain with includes as strings")
def step_skill_from_domain_string_includes(context: Any) -> None:
context.r2_created_skill = SkillModel.from_domain(
{
"name": "ns/sk",
"description": "d",
"includes": ["included-skill"],
}
)
@then("the r2mod-created SkillModel should have include items")
def step_skill_has_include_items(context: Any) -> None:
include_items = [
i for i in context.r2_created_skill.items_rel if i.item_type == "include"
]
assert len(include_items) > 0
@when("I r2mod-create SkillModel from domain with anonymous_tools as dicts")
def step_skill_from_domain_anon_dicts(context: Any) -> None:
context.r2_created_skill = SkillModel.from_domain(
{
"name": "ns/sk",
"description": "d",
"anonymous_tools": [
{"description": "dict tool", "source": "custom"},
],
}
)
@then("the r2mod-created SkillModel should have inline_tool items")
def step_skill_has_inline_items(context: Any) -> None:
inline_items = [
i for i in context.r2_created_skill.items_rel if i.item_type == "inline_tool"
]
assert len(inline_items) > 0
@when("I r2mod-create SkillModel from domain with mcp_servers as dicts")
def step_skill_from_domain_mcp_dicts(context: Any) -> None:
context.r2_created_skill = SkillModel.from_domain(
{
"name": "ns/sk",
"description": "d",
"mcp_servers": [
{"server": "my-mcp", "tools": None, "env": None},
],
}
)
@then("the r2mod-created SkillModel should have mcp_source items")
def step_skill_has_mcp_items(context: Any) -> None:
mcp_items = [
i for i in context.r2_created_skill.items_rel if i.item_type == "mcp_source"
]
assert len(mcp_items) > 0
@when("I r2mod-create SkillModel from domain with agent_skills as strings")
def step_skill_from_domain_agent_strings(context: Any) -> None:
context.r2_created_skill = SkillModel.from_domain(
{
"name": "ns/sk",
"description": "d",
"agent_skills": ["path/to/agent"],
}
)
@then("the r2mod-created SkillModel should have agent_source items")
def step_skill_has_agent_items(context: Any) -> None:
agent_items = [
i for i in context.r2_created_skill.items_rel if i.item_type == "agent_source"
]
assert len(agent_items) > 0
@when("I r2mod-create SkillModel from domain with anonymous_tools as plain objects")
def step_skill_from_domain_anon_plain_obj(context: Any) -> None:
"""Test the fallback else branch (not model_dump, not dict)."""
obj = SimpleNamespace(description="plain obj tool", source="custom")
context.r2_created_skill = SkillModel.from_domain(
{
"name": "ns/sk",
"description": "d",
"anonymous_tools": [obj],
}
)
@when("I r2mod-create SkillModel from domain with mcp_servers as plain objects")
def step_skill_from_domain_mcp_plain_obj(context: Any) -> None:
"""Test the fallback else branch (not model_dump, not dict)."""
obj = SimpleNamespace(server="my-mcp")
context.r2_created_skill = SkillModel.from_domain(
{
"name": "ns/sk",
"description": "d",
"mcp_servers": [obj],
}
)
@@ -0,0 +1,475 @@
"""Step definitions for plan_cli_commands_r2.feature.
Targets remaining partial branches in
``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 2 of 3.
Covers:
- ``use_action`` argument parsing: int/float/bool/string, missing '=',
invalid automation profile, invalid actor overrides
- ``execute_plan`` auto-resolve: 0 plans / >1 plans / exactly 1
- ``lifecycle_apply_plan`` auto-resolve: same subcases
- ``lifecycle_list_plans``: invalid phase/state, empty result, project truncation
- ``revert_plan``: invalid to-phase, non-rich format
- ``plan_status``: no plans, non-rich list, non-rich single plan
- ``correct_decision``: invalid mode, empty guidance
- ``plan_diff``: correction flag
All step text uses the ``r2plan-`` prefix to avoid collisions.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands import plan as plan_module
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.domain.models.core.plan import (
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F"
_runner = CliRunner()
def _ulid(suffix: str = "A1") -> str:
"""Return a valid 26-char ULID for tests.
ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U).
"""
# Map potentially invalid chars to valid Crockford Base32
cleaned = (
suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V")
)
base = _ULID_BASE + cleaned
return base[:26]
def _make_plan(
*,
plan_id: str | None = None,
name: str = "local/r2-plan",
description: str = "Test plan for r2 coverage",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
validation_summary: dict[str, Any] | None = None,
error_message: str | None = None,
last_completed_step: int = -1,
last_checkpoint_id: str | None = None,
definition_of_done: str | None = None,
arguments: dict[str, Any] | None = None,
arguments_order: list[str] | None = None,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
timestamps: PlanTimestamps | None = None,
action_name: str = "local/test-action",
) -> Plan:
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime.now(),
updated_at=datetime.now(),
)
return Plan(
identity=PlanIdentity(plan_id=plan_id or _ulid("A1")),
namespaced_name=NamespacedName.parse(name),
action_name=action_name,
description=description,
definition_of_done=definition_of_done,
phase=phase,
processing_state=processing_state,
strategy_actor=None,
execution_actor=None,
project_links=project_links or [],
automation_profile=automation_profile,
invariants=invariants or [],
validation_summary=validation_summary,
error_message=error_message,
last_completed_step=last_completed_step,
last_checkpoint_id=last_checkpoint_id,
arguments=arguments or {},
arguments_order=arguments_order or [],
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
timestamps=timestamps,
created_by=None,
reusable=True,
read_only=False,
)
# ---------------------------------------------------------------------------
# Given steps - mocked lifecycle service (shared for CLI command scenarios)
# ---------------------------------------------------------------------------
@given("r2plan-a mocked lifecycle service")
def step_mocked_lifecycle(context: Any) -> None:
context.r2_mock_svc = MagicMock()
context.r2_cleanups = [] # list[Any]
# Patch the lifecycle service getter
p = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.r2_mock_svc,
)
p.start()
context.r2_cleanups.append(p.stop)
# Replace the module-level console with a wider one for table tests
from rich.console import Console as RichConsole
wide_console = RichConsole(width=200)
original_console = plan_module.console
plan_module.console = wide_console
def _restore_console() -> None:
plan_module.console = original_console
context.r2_cleanups.append(_restore_console)
# Default: get_action_by_name returns a mock action
action = MagicMock()
action.namespaced_name = "local/test"
context.r2_mock_svc.get_action_by_name.return_value = action
# Default: use_action returns a plan (needed for use_action scenarios)
context.r2_use_plan = _make_plan()
context.r2_mock_svc.use_action.return_value = context.r2_use_plan
# Register cleanup
if not hasattr(context, "_r2_after_scenario"):
def _cleanup(ctx: Any) -> None:
for c in getattr(ctx, "r2_cleanups", []):
c()
context.add_cleanup(_cleanup, context)
@given("r2plan-the service lists no complete strategize plans")
def step_no_strategize_plans(context: Any) -> None:
context.r2_mock_svc.list_plans.return_value = []
@given("r2plan-the service lists multiple complete strategize plans")
def step_multi_strategize_plans(context: Any) -> None:
p1 = _make_plan(
plan_id=_ulid("B1"),
processing_state=ProcessingState.COMPLETE,
)
p2 = _make_plan(
plan_id=_ulid("B2"),
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p1, p2]
@given("r2plan-the service lists exactly one complete strategize plan")
def step_one_strategize_plan(context: Any) -> None:
p = _make_plan(
plan_id=_ulid("C1"),
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p]
context.r2_mock_svc.execute_plan.return_value = p
@given("r2plan-the service lists no complete execute plans")
def step_no_execute_plans(context: Any) -> None:
context.r2_mock_svc.list_plans.return_value = []
@given("r2plan-the service lists multiple complete execute plans")
def step_multi_execute_plans(context: Any) -> None:
p1 = _make_plan(
plan_id=_ulid("D1"),
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
p2 = _make_plan(
plan_id=_ulid("D2"),
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p1, p2]
@given("r2plan-the service lists exactly one complete execute plan")
def step_one_execute_plan(context: Any) -> None:
p = _make_plan(
plan_id=_ulid("E1"),
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.COMPLETE,
)
context.r2_mock_svc.list_plans.return_value = [p]
context.r2_mock_svc.apply_plan.return_value = p
@given("r2plan-the service lists no plans")
def step_no_plans(context: Any) -> None:
context.r2_mock_svc.list_plans.return_value = []
@given("r2plan-the service lists some plans")
def step_some_plans(context: Any) -> None:
plans = [_make_plan(plan_id=_ulid("F1"))]
context.r2_mock_svc.list_plans.return_value = plans
@given("r2plan-the service can get a plan by id")
def step_get_plan_by_id(context: Any) -> None:
plan = _make_plan(plan_id=_ulid("G1"))
context.r2_mock_svc.get_plan.return_value = plan
context.r2_plan_id = _ulid("G1")
@given("r2plan-the service can revert a plan")
def step_service_can_revert(context: Any) -> None:
plan = _make_plan(plan_id=_ulid("H1"))
context.r2_mock_svc.revert_plan.return_value = plan
@given("r2plan-the service lists a plan with 4 project links")
def step_plan_with_4_links(context: Any) -> None:
links = [ProjectLink(project_name=f"local/proj-{i}") for i in range(4)]
p = _make_plan(plan_id=_ulid("I1"), project_links=links)
context.r2_mock_svc.list_plans.return_value = [p]
# ---------------------------------------------------------------------------
# When steps - CLI use_action
# ---------------------------------------------------------------------------
@when('r2plan-I invoke use with action "{action}" and arg "{arg_str}"')
def step_invoke_use_with_arg(context: Any, action: str, arg_str: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["use", action, "--arg", arg_str],
)
# Capture the arguments passed to use_action for inspection
if context.r2_mock_svc.use_action.called:
call_kwargs = context.r2_mock_svc.use_action.call_args
context.r2_parsed_args = (
call_kwargs.kwargs.get("arguments", call_kwargs[1].get("arguments", {}))
if call_kwargs
else {}
)
else:
context.r2_parsed_args = {}
@when('r2plan-I invoke use with action "{action}" and automation profile "{profile}"')
def step_invoke_use_with_profile(context: Any, action: str, profile: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["use", action, "--automation-profile", profile],
)
@when('r2plan-I invoke use with action "{action}" and strategy actor "{actor}"')
def step_invoke_use_strategy_actor(context: Any, action: str, actor: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["use", action, "--strategy-actor", actor],
)
@when('r2plan-I invoke use with action "{action}" and execution actor "{actor}"')
def step_invoke_use_execution_actor(context: Any, action: str, actor: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["use", action, "--execution-actor", actor],
)
# ---------------------------------------------------------------------------
# When steps - CLI execute / lifecycle-apply
# ---------------------------------------------------------------------------
@when("r2plan-I invoke execute without plan_id")
def step_invoke_execute_no_id(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["execute"])
@when("r2plan-I invoke lifecycle-apply without plan_id")
def step_invoke_apply_no_id(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-apply"])
# ---------------------------------------------------------------------------
# When steps - CLI lifecycle-list
# ---------------------------------------------------------------------------
@when('r2plan-I invoke lifecycle-list with phase "{phase}"')
def step_invoke_list_phase(context: Any, phase: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-list", "--phase", phase])
@when('r2plan-I invoke lifecycle-list with state "{state}"')
def step_invoke_list_state(context: Any, state: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-list", "--state", state])
@when("r2plan-I invoke lifecycle-list")
def step_invoke_lifecycle_list(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["lifecycle-list"])
# ---------------------------------------------------------------------------
# When steps - CLI revert
# ---------------------------------------------------------------------------
@when('r2plan-I invoke revert with plan "{pid}" and invalid phase "{phase}"')
def step_invoke_revert_invalid(context: Any, pid: str, phase: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["revert", pid, "--to-phase", phase])
@when('r2plan-I invoke revert with plan "{pid}" and format "{fmt}"')
def step_invoke_revert_json(context: Any, pid: str, fmt: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["revert", pid, "--format", fmt])
# ---------------------------------------------------------------------------
# When steps - CLI status
# ---------------------------------------------------------------------------
@when("r2plan-I invoke status without plan_id")
def step_invoke_status_no_id(context: Any) -> None:
context.r2_result = _runner.invoke(plan_app, ["status"])
@when('r2plan-I invoke status without plan_id and format "{fmt}"')
def step_invoke_status_no_id_fmt(context: Any, fmt: str) -> None:
context.r2_result = _runner.invoke(plan_app, ["status", "--format", fmt])
@when('r2plan-I invoke status with plan_id and format "{fmt}"')
def step_invoke_status_with_id_fmt(context: Any, fmt: str) -> None:
pid = getattr(context, "r2_plan_id", _ulid("G1"))
context.r2_result = _runner.invoke(plan_app, ["status", pid, "--format", fmt])
# ---------------------------------------------------------------------------
# When steps - CLI correct
# ---------------------------------------------------------------------------
@when('r2plan-I invoke correct with mode "{mode}"')
def step_invoke_correct_bad_mode(context: Any, mode: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["correct", "DEC-001", "--mode", mode, "--guidance", "fix it", "--yes"],
)
@when("r2plan-I invoke correct with empty guidance")
def step_invoke_correct_empty_guidance(context: Any) -> None:
context.r2_result = _runner.invoke(
plan_app,
["correct", "DEC-001", "--mode", "revert", "--guidance", "", "--yes"],
)
# ---------------------------------------------------------------------------
# When steps - CLI diff
# ---------------------------------------------------------------------------
@when('r2plan-I invoke diff with plan "{pid}" and correction "{corr}"')
def step_invoke_diff_correction(context: Any, pid: str, corr: str) -> None:
context.r2_result = _runner.invoke(
plan_app,
["diff", pid, "--correction", corr],
)
# ---------------------------------------------------------------------------
# Then steps - CLI command assertions
# ---------------------------------------------------------------------------
@then("r2plan-the command should abort")
def step_cmd_abort(context: Any) -> None:
assert context.r2_result.exit_code != 0, (
f"Expected abort but got exit code {context.r2_result.exit_code}.\n"
f"Output: {context.r2_result.output}"
)
@then("r2plan-the command should succeed")
def step_cmd_succeed(context: Any) -> None:
assert context.r2_result.exit_code == 0, (
f"Expected success but got exit code {context.r2_result.exit_code}.\n"
f"Output: {context.r2_result.output}"
)
@then('r2plan-the output should contain "{text}"')
def step_cli_output_contains(context: Any, text: str) -> None:
output = context.r2_result.output
assert text in output, f"Expected '{text}' in output:\n{output}"
# -- use_action argument parsing assertions --
@then('r2plan-the parsed arguments should have "{key}" as int {val:d}')
def step_arg_int(context: Any, key: str, val: int) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) == val, f"Expected {key}={val}, got {args.get(key)}"
assert isinstance(args[key], int)
@then('r2plan-the parsed arguments should have "{key}" as float {val:g}')
def step_arg_float(context: Any, key: str, val: float) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) == val, f"Expected {key}={val}, got {args.get(key)}"
assert isinstance(args[key], float)
@then('r2plan-the parsed arguments should have "{key}" as bool true')
def step_arg_bool_true(context: Any, key: str) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) is True, f"Expected {key}=True, got {args.get(key)}"
@then('r2plan-the parsed arguments should have "{key}" as bool false')
def step_arg_bool_false(context: Any, key: str) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) is False, f"Expected {key}=False, got {args.get(key)}"
@then('r2plan-the parsed arguments should have "{key}" as string "{val}"')
def step_arg_string(context: Any, key: str, val: str) -> None:
call_args = context.r2_mock_svc.use_action.call_args
args = call_args.kwargs.get("arguments") or call_args[1].get("arguments") or {}
assert args.get(key) == val, f"Expected {key}='{val}', got {args.get(key)}"
assert isinstance(args[key], str)
+442
View File
@@ -0,0 +1,442 @@
"""Step definitions for plan_cli_legacy_r2.feature.
Targets remaining partial branches in
``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 3 of 3.
Covers:
- Legacy wrappers: no-project branch, continue_command prompt/no-prompt
- ``_resolve_active_plan_id``: no active plans, service error
- ``build_command``: None changes -> empty list
- ``list_command``: None plans -> empty list
All step text uses the ``r2plan-`` prefix to avoid collisions.
"""
from __future__ import annotations
import warnings
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock, patch
import typer
from behave import then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.plan import (
_resolve_active_plan_id,
)
from cleveragents.core.exceptions import CleverAgentsError
from cleveragents.domain.models.core.plan import (
AutomationProfileRef,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F"
_runner = CliRunner()
def _ulid(suffix: str = "A1") -> str:
"""Return a valid 26-char ULID for tests.
ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U).
"""
# Map potentially invalid chars to valid Crockford Base32
cleaned = (
suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V")
)
base = _ULID_BASE + cleaned
return base[:26]
def _make_plan(
*,
plan_id: str | None = None,
name: str = "local/r2-plan",
description: str = "Test plan for r2 coverage",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
validation_summary: dict[str, Any] | None = None,
error_message: str | None = None,
last_completed_step: int = -1,
last_checkpoint_id: str | None = None,
definition_of_done: str | None = None,
arguments: dict[str, Any] | None = None,
arguments_order: list[str] | None = None,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
timestamps: PlanTimestamps | None = None,
action_name: str = "local/test-action",
) -> Plan:
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime.now(),
updated_at=datetime.now(),
)
return Plan(
identity=PlanIdentity(plan_id=plan_id or _ulid("A1")),
namespaced_name=NamespacedName.parse(name),
action_name=action_name,
description=description,
definition_of_done=definition_of_done,
phase=phase,
processing_state=processing_state,
strategy_actor=None,
execution_actor=None,
project_links=project_links or [],
automation_profile=automation_profile,
invariants=invariants or [],
validation_summary=validation_summary,
error_message=error_message,
last_completed_step=last_completed_step,
last_checkpoint_id=last_checkpoint_id,
arguments=arguments or {},
arguments_order=arguments_order or [],
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
timestamps=timestamps,
created_by=None,
reusable=True,
read_only=False,
)
def _mock_container(project_exists: bool = True) -> MagicMock:
"""Build a mock container for legacy wrapper tests."""
container = MagicMock()
project_service = MagicMock()
plan_service = MagicMock()
if project_exists:
project_service.get_current_project.return_value = SimpleNamespace(name="proj")
else:
project_service.get_current_project.return_value = None
container.project_service.return_value = project_service
container.plan_service.return_value = plan_service
return container
# ---------------------------------------------------------------------------
# When steps - legacy programmatic wrappers (no project)
# ---------------------------------------------------------------------------
@when("r2plan-I call tell_command with no project")
def step_tell_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import tell_command
tell_command("do stuff")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call build_command with no project")
def step_build_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import build_command
build_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call apply_command with no project")
def step_apply_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import apply_command
apply_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call new_command with no project")
def step_new_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import new_command
new_command("test")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call current_command with no project")
def step_current_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import current_command
current_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call list_command with no project")
def step_list_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import list_command
list_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call cd_command with no project")
def step_cd_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import cd_command
cd_command("some-plan")
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
@when("r2plan-I call continue_command with no project")
def step_continue_no_project(context: Any) -> None:
container = _mock_container(project_exists=False)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import continue_command
continue_command()
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
# ---------------------------------------------------------------------------
# When steps - continue_command with prompt / no-prompt
# ---------------------------------------------------------------------------
@when('r2plan-I call continue_command with prompt "{prompt}"')
def step_continue_with_prompt(context: Any, prompt: str) -> None:
container = _mock_container(project_exists=True)
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import continue_command
continue_command(prompt=prompt)
context.r2_plan_service = container.plan_service()
@when("r2plan-I call continue_command with no prompt and no current plan")
def step_continue_no_prompt_no_plan(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.get_current_plan.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
try:
from cleveragents.cli.commands.plan import continue_command
continue_command(prompt=None)
context.r2_error = None
except CleverAgentsError as exc:
context.r2_error = exc
# ---------------------------------------------------------------------------
# When steps - build_command returns None / list_command returns None
# ---------------------------------------------------------------------------
@when("r2plan-I call build_command with build returning None")
def step_build_returns_none(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.build_plan.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import build_command
context.r2_build_result = build_command()
@when("r2plan-I call list_command with list returning None")
def step_list_returns_none(context: Any) -> None:
container = _mock_container(project_exists=True)
plan_service = container.plan_service()
plan_service.list_plans.return_value = None
with (
patch(
"cleveragents.application.container.get_container", return_value=container
),
warnings.catch_warnings(),
):
warnings.simplefilter("ignore", DeprecationWarning)
from cleveragents.cli.commands.plan import list_command
context.r2_list_result = list_command()
# ---------------------------------------------------------------------------
# When steps - _resolve_active_plan_id
# ---------------------------------------------------------------------------
@when("r2plan-I call _resolve_active_plan_id with no active plans")
def step_resolve_no_active(context: Any) -> None:
mock_svc = MagicMock()
# All plans are terminal
p = _make_plan(processing_state=ProcessingState.APPLIED)
mock_svc.list_plans.return_value = [p]
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=mock_svc,
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
@when("r2plan-I call _resolve_active_plan_id with service error")
def step_resolve_service_error(context: Any) -> None:
with patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
side_effect=CleverAgentsError("service unavailable"),
):
try:
_resolve_active_plan_id()
context.r2_error = None
except typer.Abort:
context.r2_error = typer.Abort()
# ---------------------------------------------------------------------------
# Then steps - legacy wrapper assertions
# ---------------------------------------------------------------------------
@then('r2plan-a CleverAgentsError should be raised with message "{fragment}"')
def step_agents_error(context: Any, fragment: str) -> None:
assert context.r2_error is not None, "Expected CleverAgentsError but none raised"
assert isinstance(context.r2_error, CleverAgentsError), (
f"Expected CleverAgentsError, got {type(context.r2_error).__name__}"
)
assert fragment in context.r2_error.message, (
f"Expected '{fragment}' in '{context.r2_error.message}'"
)
@then("r2plan-the continue_plan service method should be called")
def step_continue_called(context: Any) -> None:
context.r2_plan_service.continue_plan.assert_called_once()
@then("r2plan-the build result should be an empty list")
def step_build_empty(context: Any) -> None:
assert context.r2_build_result == [], f"Expected [], got {context.r2_build_result}"
@then("r2plan-the list result should be an empty list")
def step_list_empty(context: Any) -> None:
assert context.r2_list_result == [], f"Expected [], got {context.r2_list_result}"
@then("r2plan-a typer Abort should be raised")
def step_typer_abort(context: Any) -> None:
assert context.r2_error is not None, "Expected typer.Abort but none raised"
assert isinstance(context.r2_error, typer.Abort), (
f"Expected typer.Abort, got {type(context.r2_error).__name__}"
)
@@ -0,0 +1,396 @@
"""Step definitions for plan_cli_spec_print_r2.feature.
Targets remaining partial branches in
``cleveragents.cli.commands.plan`` (plan.py) - round 2, split 1 of 3.
Covers:
- ``_plan_spec_dict``: project link alias/read_only, automation_profile,
invariants, validation_summary/dod, last_completed_step, last_checkpoint_id
- ``_print_lifecycle_plan``: definition_of_done, dod evaluation pass/fail,
arguments with/without order, automation profile, invariants, resume
metadata, project link alias/read_only, long description
All step text uses the ``r2plan-`` prefix to avoid collisions.
"""
from __future__ import annotations
from datetime import datetime
from io import StringIO
from typing import Any
from unittest.mock import patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands import plan as plan_module
from cleveragents.cli.commands.plan import (
_plan_spec_dict,
_print_lifecycle_plan,
)
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_ULID_BASE = "01ARZ3NDEKTSV4RRFFQ69G5F"
_runner = CliRunner()
def _ulid(suffix: str = "A1") -> str:
"""Return a valid 26-char ULID for tests.
ULIDs use Crockford's Base32 (0-9, A-H, J-K, M-N, P-T, V-Z; no I/L/O/U).
"""
# Map potentially invalid chars to valid Crockford Base32
cleaned = (
suffix.replace("I", "J").replace("L", "K").replace("O", "P").replace("U", "V")
)
base = _ULID_BASE + cleaned
return base[:26]
def _make_plan(
*,
plan_id: str | None = None,
name: str = "local/r2-plan",
description: str = "Test plan for r2 coverage",
phase: PlanPhase = PlanPhase.STRATEGIZE,
processing_state: ProcessingState = ProcessingState.QUEUED,
project_links: list[ProjectLink] | None = None,
automation_profile: AutomationProfileRef | None = None,
invariants: list[PlanInvariant] | None = None,
validation_summary: dict[str, Any] | None = None,
error_message: str | None = None,
last_completed_step: int = -1,
last_checkpoint_id: str | None = None,
definition_of_done: str | None = None,
arguments: dict[str, Any] | None = None,
arguments_order: list[str] | None = None,
estimation_actor: str | None = None,
invariant_actor: str | None = None,
timestamps: PlanTimestamps | None = None,
action_name: str = "local/test-action",
) -> Plan:
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime.now(),
updated_at=datetime.now(),
)
return Plan(
identity=PlanIdentity(plan_id=plan_id or _ulid("A1")),
namespaced_name=NamespacedName.parse(name),
action_name=action_name,
description=description,
definition_of_done=definition_of_done,
phase=phase,
processing_state=processing_state,
strategy_actor=None,
execution_actor=None,
project_links=project_links or [],
automation_profile=automation_profile,
invariants=invariants or [],
validation_summary=validation_summary,
error_message=error_message,
last_completed_step=last_completed_step,
last_checkpoint_id=last_checkpoint_id,
arguments=arguments or {},
arguments_order=arguments_order or [],
estimation_actor=estimation_actor,
invariant_actor=invariant_actor,
timestamps=timestamps,
created_by=None,
reusable=True,
read_only=False,
)
def _capture_print(plan: Any) -> str:
"""Call _print_lifecycle_plan and capture the console output."""
buf = StringIO()
from rich.console import Console as RichConsole
test_console = RichConsole(file=buf, width=200, no_color=True)
with patch.object(plan_module, "console", test_console):
_print_lifecycle_plan(plan, title="R2 Test")
return buf.getvalue()
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given('r2plan-a v3 Plan with a project link that has alias "{alias}"')
def step_plan_link_alias(context: Any, alias: str) -> None:
link = ProjectLink(project_name="local/api", alias=alias)
context.r2_plan = _make_plan(project_links=[link])
@given("r2plan-a v3 Plan with a project link that is read_only")
def step_plan_link_readonly(context: Any) -> None:
link = ProjectLink(project_name="local/docs", read_only=True)
context.r2_plan = _make_plan(project_links=[link])
@given("r2plan-a v3 Plan with a plain project link")
def step_plan_link_plain(context: Any) -> None:
link = ProjectLink(project_name="local/plain")
context.r2_plan = _make_plan(project_links=[link])
@given('r2plan-a v3 Plan with automation_profile "{profile}"')
def step_plan_with_automation_profile(context: Any, profile: str) -> None:
ap = AutomationProfileRef(
profile_name=profile,
provenance=AutomationProfileProvenance.PLAN,
)
context.r2_plan = _make_plan(automation_profile=ap)
@given("r2plan-a v3 Plan without automation_profile")
def step_plan_without_automation_profile(context: Any) -> None:
context.r2_plan = _make_plan(automation_profile=None)
@given("r2plan-a v3 Plan with invariants")
def step_plan_with_invariants(context: Any) -> None:
invs = [
PlanInvariant(text="No new warnings", source=InvariantSource.PLAN),
PlanInvariant(text="Coverage >= 80%", source=InvariantSource.ACTION),
]
context.r2_plan = _make_plan(invariants=invs)
@given("r2plan-a v3 Plan without invariants")
def step_plan_without_invariants(context: Any) -> None:
context.r2_plan = _make_plan(invariants=[])
@given("r2plan-a v3 Plan with dod validation summary")
def step_plan_with_dod_validation(context: Any) -> None:
vs = {
"dod_evaluated": True,
"dod_all_passed": True,
"required_passed": 3,
"required_failed": 0,
}
context.r2_plan = _make_plan(validation_summary=vs)
@given("r2plan-a v3 Plan without validation_summary")
def step_plan_without_validation_summary(context: Any) -> None:
context.r2_plan = _make_plan(validation_summary=None)
@given("r2plan-a v3 Plan with last_completed_step {n:d}")
def step_plan_with_step(context: Any, n: int) -> None:
context.r2_plan = _make_plan(last_completed_step=n)
@given("r2plan-a v3 Plan with last_completed_step default")
def step_plan_with_step_default(context: Any) -> None:
context.r2_plan = _make_plan(last_completed_step=-1)
@given('r2plan-a v3 Plan with last_checkpoint_id "{chk}"')
def step_plan_with_checkpoint(context: Any, chk: str) -> None:
context.r2_plan = _make_plan(last_checkpoint_id=chk)
@given("r2plan-a v3 Plan without last_checkpoint_id")
def step_plan_without_checkpoint(context: Any) -> None:
context.r2_plan = _make_plan(last_checkpoint_id=None)
@given('r2plan-a v3 Plan with definition_of_done "{dod}"')
def step_plan_with_dod(context: Any, dod: str) -> None:
context.r2_plan = _make_plan(definition_of_done=dod)
@given("r2plan-a v3 Plan with definition_of_done longer than 200 chars")
def step_plan_with_long_dod(context: Any) -> None:
dod = "x" * 250
context.r2_plan = _make_plan(definition_of_done=dod)
@given("r2plan-a v3 Plan without definition_of_done")
def step_plan_without_dod(context: Any) -> None:
context.r2_plan = _make_plan(definition_of_done=None)
@given("r2plan-a v3 Plan with dod evaluated as passed")
def step_plan_dod_passed(context: Any) -> None:
vs = {
"dod_evaluated": True,
"dod_all_passed": True,
"required_passed": 5,
"required_failed": 0,
}
context.r2_plan = _make_plan(validation_summary=vs)
@given("r2plan-a v3 Plan with dod evaluated as failed")
def step_plan_dod_failed(context: Any) -> None:
vs = {
"dod_evaluated": True,
"dod_all_passed": False,
"required_passed": 2,
"required_failed": 3,
}
context.r2_plan = _make_plan(validation_summary=vs)
@given("r2plan-a v3 Plan with arguments and arguments_order")
def step_plan_with_args_order(context: Any) -> None:
context.r2_plan = _make_plan(
arguments={"target_coverage": 80, "format": "json"},
arguments_order=["target_coverage", "format"],
)
@given("r2plan-a v3 Plan with arguments but no arguments_order")
def step_plan_with_args_no_order(context: Any) -> None:
context.r2_plan = _make_plan(
arguments={"beta_key": "val", "alpha_key": "val2"},
arguments_order=[],
)
@given("r2plan-a v3 Plan with description longer than 200 chars")
def step_plan_with_long_description(context: Any) -> None:
context.r2_plan = _make_plan(description="D" * 250)
@given("r2plan-a v3 Plan with resume metadata")
def step_plan_with_resume_metadata(context: Any) -> None:
context.r2_plan = _make_plan(
last_completed_step=5,
last_checkpoint_id="01CHKPTRESUME000000000000",
)
@given("r2plan-a v3 Plan with project link alias and read_only")
def step_plan_with_link_alias_readonly(context: Any) -> None:
link = ProjectLink(project_name="local/ref-data", alias="data", read_only=True)
context.r2_plan = _make_plan(project_links=[link])
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when("r2plan-I call _plan_spec_dict")
def step_call_spec_dict(context: Any) -> None:
context.r2_spec = _plan_spec_dict(context.r2_plan)
@when("r2plan-I call _print_lifecycle_plan")
def step_call_print_plan(context: Any) -> None:
context.r2_output = _capture_print(context.r2_plan)
# ---------------------------------------------------------------------------
# Then steps - spec dict assertions
# ---------------------------------------------------------------------------
@then('r2plan-the spec dict project_links should include alias "{alias}"')
def step_spec_alias(context: Any, alias: str) -> None:
links = context.r2_spec["project_links"]
assert any(link.get("alias") == alias for link in links), (
f"No link with alias '{alias}' in {links}"
)
@then("r2plan-the spec dict project_links should include read_only true")
def step_spec_readonly(context: Any) -> None:
links = context.r2_spec["project_links"]
assert any(link.get("read_only") is True for link in links), (
f"No link with read_only=True in {links}"
)
@then("r2plan-the spec dict project_links should not include alias")
def step_spec_no_alias(context: Any) -> None:
links = context.r2_spec["project_links"]
assert all("alias" not in link for link in links), f"Unexpected alias in {links}"
@then("r2plan-the spec dict project_links should not include read_only")
def step_spec_no_readonly(context: Any) -> None:
links = context.r2_spec["project_links"]
assert all("read_only" not in link for link in links), (
f"Unexpected read_only in {links}"
)
@then('r2plan-the spec dict automation_profile should be "{profile}"')
def step_spec_profile(context: Any, profile: str) -> None:
assert context.r2_spec["automation_profile"] == profile, (
f"Expected '{profile}', got '{context.r2_spec['automation_profile']}'"
)
@then("r2plan-the spec dict automation_profile should be null")
def step_spec_profile_null(context: Any) -> None:
assert context.r2_spec["automation_profile"] is None
@then('r2plan-the spec dict should contain key "{key}"')
def step_spec_has_key(context: Any, key: str) -> None:
assert key in context.r2_spec, (
f"Key '{key}' not in spec dict: {list(context.r2_spec.keys())}"
)
@then('r2plan-the spec dict should not contain key "{key}"')
def step_spec_no_key(context: Any, key: str) -> None:
assert key not in context.r2_spec, f"Key '{key}' should not be in spec dict"
@then("r2plan-the spec dict invariants count should be {n:d}")
def step_spec_inv_count(context: Any, n: int) -> None:
actual = len(context.r2_spec["invariants"])
assert actual == n, f"Expected {n} invariants, got {actual}"
@then("r2plan-the spec dict dod_evaluation all_passed should be true")
def step_spec_dod_passed(context: Any) -> None:
assert context.r2_spec["dod_evaluation"]["all_passed"] is True
@then("r2plan-the spec dict last_completed_step should be {n:d}")
def step_spec_step(context: Any, n: int) -> None:
assert context.r2_spec["last_completed_step"] == n
# -- print output assertions --
@then('r2plan-the printed output should contain "{text}"')
def step_output_contains(context: Any, text: str) -> None:
assert text in context.r2_output, (
f"Expected '{text}' in output:\n{context.r2_output}"
)
@then('r2plan-the printed output should not contain "{text}"')
def step_output_not_contains(context: Any, text: str) -> None:
assert text not in context.r2_output, (
f"Did not expect '{text}' in output:\n{context.r2_output}"
)
@@ -0,0 +1,448 @@
"""Step definitions for plan_lifecycle_error_r2.feature.
Targets partial branches in ``plan_lifecycle_service.py`` that are only
exercised in one direction (True-only or False-only):
* **Line 100** ``InvalidPhaseTransitionError.__init__``:
``if not message:`` False branch (custom message provided).
* **Line 216** ``_commit_plan``:
``if self._persisted and self.unit_of_work is not None:`` True branch.
* **Line 327** ``create_action``:
``if self._persisted `` True branch.
* **Line 364** ``get_action`` persistence fallback: True branch
(action not in memory, persistence also returns ``None``).
* **Line 461** ``archive_action``:
``if self._persisted `` True branch.
* **Line 570** ``use_action``:
``if self._persisted `` True branch.
* **Line 607** ``get_plan`` persistence fallback: True branch
(plan not in memory, persistence also returns ``None``).
All step text uses the ``r2plc-`` prefix to avoid collisions with
existing step definitions.
This file also contains shared steps used by both error and transition
feature files (Behave discovers all steps globally from the steps/ dir).
"""
from __future__ import annotations
from collections.abc import Generator
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
PlanLifecycleService,
)
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.action import ActionState
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProjectLink,
)
# -------------------------------------------------------------------
# Mock UoW builder
# -------------------------------------------------------------------
def _build_mock_uow() -> tuple[MagicMock, MagicMock]:
"""Build a mock UnitOfWork and return (uow, shared_ctx).
The shared ``ctx`` mock is reused across all ``transaction()`` calls
so that assertions can inspect cumulative interactions.
"""
mock_uow = MagicMock()
mock_ctx = MagicMock()
# Make persistence lookups return None by default (not found)
mock_ctx.actions.get_by_name.return_value = None
mock_ctx.lifecycle_plans.get.return_value = None
@contextmanager
def _transaction() -> Generator[MagicMock]:
yield mock_ctx
mock_uow.transaction = _transaction
return mock_uow, mock_ctx
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _create_action(context: Context, name: str, **kwargs: Any) -> Any:
"""Create an action through the service with sensible defaults."""
defaults: dict[str, Any] = {
"name": name,
"description": f"R2 test action {name}",
"definition_of_done": "Tests pass",
"strategy_actor": "openai/gpt-4",
"execution_actor": "openai/gpt-4",
}
defaults.update(kwargs)
return context.r2_service.create_action(**defaults)
# -------------------------------------------------------------------
# Background
# -------------------------------------------------------------------
@given("r2plc-a fresh plan lifecycle service with mock UoW")
def step_r2_bg(context: Context) -> None:
"""Create a PlanLifecycleService backed by a mock UoW."""
settings = MagicMock()
mock_uow, mock_ctx = _build_mock_uow()
context.r2_uow = mock_uow
context.r2_ctx = mock_ctx
context.r2_service = PlanLifecycleService(settings=settings, unit_of_work=mock_uow)
context.r2_plan = None
context.r2_error = None
context.r2_action = None
# ===================================================================
# InvalidPhaseTransitionError with custom message (line 100)
# ===================================================================
@when("r2plc-I construct InvalidPhaseTransitionError with a custom message")
def step_r2_construct_with_message(context: Context) -> None:
"""Directly construct the exception with a custom message."""
context.r2_error = InvalidPhaseTransitionError(
from_phase=PlanPhase.STRATEGIZE,
to_phase=PlanPhase.APPLY,
message="Custom: cannot go there",
)
@when("r2plc-I construct InvalidPhaseTransitionError without a message")
def step_r2_construct_without_message(context: Context) -> None:
"""Construct the exception without a message (default path)."""
context.r2_error = InvalidPhaseTransitionError(
from_phase=PlanPhase.STRATEGIZE,
to_phase=PlanPhase.EXECUTE,
)
@then("r2plc-the error message should be the custom message")
def step_r2_check_custom_message(context: Context) -> None:
assert str(context.r2_error) == "Custom: cannot go there", (
f"Expected custom message, got: {context.r2_error}"
)
@then('r2plc-the error message should contain "Invalid phase transition"')
def step_r2_check_default_message(context: Context) -> None:
assert "Invalid phase transition" in str(context.r2_error), (
f"Expected default message, got: {context.r2_error}"
)
@then("r2plc-the from_phase should be STRATEGIZE")
def step_r2_check_from_strategize(context: Context) -> None:
err = context.r2_error
assert isinstance(err, InvalidPhaseTransitionError)
assert err.from_phase == PlanPhase.STRATEGIZE
@then("r2plc-the to_phase should be APPLY")
def step_r2_check_to_apply(context: Context) -> None:
err = context.r2_error
assert isinstance(err, InvalidPhaseTransitionError)
assert err.to_phase == PlanPhase.APPLY
@then("r2plc-the to_phase should be EXECUTE")
def step_r2_check_to_execute(context: Context) -> None:
err = context.r2_error
assert isinstance(err, InvalidPhaseTransitionError)
assert err.to_phase == PlanPhase.EXECUTE
# ===================================================================
# revert_plan with InvalidPhaseTransitionError (line 100 + 1245-1250)
# ===================================================================
@given("r2plc-a plan in STRATEGIZE phase")
def step_r2_plan_in_strategize(context: Context) -> None:
"""Create an action and use it to produce a plan in STRATEGIZE/QUEUED."""
# Reset the mock call tracking so we can check calls per-scenario
context.r2_ctx.reset_mock()
action = _create_action(context, f"local/r2-strat-{id(context)}")
plan = context.r2_service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-r2")],
)
context.r2_plan = plan
# Reset again after setup so assertions only see scenario-specific calls
context.r2_ctx.reset_mock()
@when("r2plc-I attempt to revert the plan to APPLY phase")
def step_r2_revert_to_apply(context: Context) -> None:
"""Attempt to revert from STRATEGIZE to APPLY (invalid transition)."""
try:
context.r2_service.revert_plan(
context.r2_plan.identity.plan_id,
to_phase=PlanPhase.APPLY,
reason="testing invalid revert",
)
context.r2_error = None
except InvalidPhaseTransitionError as exc:
context.r2_error = exc
@then("r2plc-an InvalidPhaseTransitionError should have been raised")
def step_r2_check_invalid_transition(context: Context) -> None:
assert context.r2_error is not None, (
"Expected InvalidPhaseTransitionError but none raised"
)
assert isinstance(context.r2_error, InvalidPhaseTransitionError), (
f"Expected InvalidPhaseTransitionError, got {type(context.r2_error).__name__}"
)
@then('r2plc-the caught error message should contain "Cannot revert"')
def step_r2_check_revert_message(context: Context) -> None:
assert "Cannot revert" in str(context.r2_error), (
f"Expected 'Cannot revert' in message, got: {context.r2_error}"
)
# ===================================================================
# _commit_plan in persisted mode (line 216 True)
# ===================================================================
@when("r2plc-I start strategize on the plan")
def step_r2_start_strategize(context: Context) -> None:
"""Start strategize — calls _commit_plan internally."""
context.r2_plan = context.r2_service.start_strategize(
context.r2_plan.identity.plan_id
)
@then("r2plc-the mock UoW should have received a plan update call")
def step_r2_check_plan_update(context: Context) -> None:
"""Verify the mock ctx received at least one lifecycle_plans.update call."""
assert context.r2_ctx.lifecycle_plans.update.called, (
"Expected lifecycle_plans.update() to be called on mock ctx, "
f"but it was not. Calls: {context.r2_ctx.mock_calls}"
)
# ===================================================================
# fail_strategize in persisted mode (line 216 True)
# ===================================================================
@given("r2plc-a plan in STRATEGIZE PROCESSING state")
def step_r2_plan_strategize_processing(context: Context) -> None:
"""Create a plan and advance it to STRATEGIZE/PROCESSING."""
context.r2_ctx.reset_mock()
action = _create_action(context, f"local/r2-sp-{id(context)}")
plan = context.r2_service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-r2")],
)
context.r2_service.start_strategize(plan.identity.plan_id)
context.r2_plan = context.r2_service.get_plan(plan.identity.plan_id)
context.r2_ctx.reset_mock()
@when('r2plc-I fail the strategize with error "{msg}"')
def step_r2_fail_strategize(context: Context, msg: str) -> None:
context.r2_plan = context.r2_service.fail_strategize(
context.r2_plan.identity.plan_id, msg
)
@then('r2plc-the plan processing state should be "{state}"')
def step_r2_check_processing_state(context: Context, state: str) -> None:
actual = context.r2_plan.processing_state.value
assert actual == state, f"Expected '{state}', got '{actual}'"
# ===================================================================
# create_action persisted mode (line 327 True)
# ===================================================================
@when('r2plc-I create an action "{name}" in persisted mode')
def step_r2_create_action_persisted(context: Context, name: str) -> None:
context.r2_ctx.reset_mock()
context.r2_action = _create_action(context, name)
@then("r2plc-the mock UoW should have received an action create call")
def step_r2_check_action_create(context: Context) -> None:
assert context.r2_ctx.actions.create.called, (
"Expected actions.create() to be called on mock ctx, "
f"but it was not. Calls: {context.r2_ctx.mock_calls}"
)
@then("r2plc-the action should also be in the in-memory cache")
def step_r2_check_action_in_cache(context: Context) -> None:
name = str(context.r2_action.namespaced_name)
assert name in context.r2_service._actions, (
f"Expected '{name}' in _actions cache, "
f"got keys: {list(context.r2_service._actions.keys())}"
)
# ===================================================================
# use_action persisted mode (line 570 True)
# ===================================================================
@given('r2plc-an action "{name}" exists')
def step_r2_action_exists(context: Context, name: str) -> None:
context.r2_ctx.reset_mock()
_create_action(context, name)
context.r2_ctx.reset_mock()
@when("r2plc-I use the action to create a plan in persisted mode")
def step_r2_use_action_persisted(context: Context) -> None:
context.r2_ctx.reset_mock()
action_name = "local/r2-use-persist"
context.r2_plan = context.r2_service.use_action(
action_name=action_name,
project_links=[ProjectLink(project_name="proj-r2-use")],
)
@then("r2plc-the mock UoW should have received a plan create call")
def step_r2_check_plan_create(context: Context) -> None:
assert context.r2_ctx.lifecycle_plans.create.called, (
"Expected lifecycle_plans.create() to be called on mock ctx, "
f"but it was not. Calls: {context.r2_ctx.mock_calls}"
)
@then("r2plc-the plan should also be in the in-memory plan cache")
def step_r2_check_plan_in_cache(context: Context) -> None:
plan_id = context.r2_plan.identity.plan_id
assert plan_id in context.r2_service._plans, (
f"Expected plan '{plan_id}' in _plans cache"
)
# ===================================================================
# archive_action persisted mode (line 461 True)
# ===================================================================
@when('r2plc-I archive the action "{name}" in persisted mode')
def step_r2_archive_action_persisted(context: Context, name: str) -> None:
context.r2_ctx.reset_mock()
context.r2_action = context.r2_service.archive_action(name)
@then("r2plc-the mock UoW should have received an action update call")
def step_r2_check_action_update(context: Context) -> None:
assert context.r2_ctx.actions.update.called, (
"Expected actions.update() to be called on mock ctx, "
f"but it was not. Calls: {context.r2_ctx.mock_calls}"
)
@then("r2plc-the action state should be archived")
def step_r2_check_archived_state(context: Context) -> None:
assert context.r2_action.state == ActionState.ARCHIVED, (
f"Expected ARCHIVED, got {context.r2_action.state}"
)
# ===================================================================
# get_action persistence fallback → NotFoundError (line 364-370)
# ===================================================================
@when('r2plc-I attempt to get action "{name}" in persisted mode')
def step_r2_get_action_not_found(context: Context, name: str) -> None:
"""Call get_action for an action not in memory or persistence."""
try:
context.r2_service.get_action(name)
context.r2_error = None
except NotFoundError as exc:
context.r2_error = exc
@then("r2plc-a NotFoundError should have been raised for action")
def step_r2_check_action_not_found(context: Context) -> None:
assert context.r2_error is not None, "Expected NotFoundError but none raised"
assert isinstance(context.r2_error, NotFoundError), (
f"Expected NotFoundError, got {type(context.r2_error).__name__}"
)
# ===================================================================
# get_plan persistence fallback → NotFoundError (line 607-613)
# ===================================================================
@when('r2plc-I attempt to get plan "{plan_id}" in persisted mode')
def step_r2_get_plan_not_found(context: Context, plan_id: str) -> None:
"""Call get_plan for a plan not in memory or persistence."""
try:
context.r2_service.get_plan(plan_id)
context.r2_error = None
except NotFoundError as exc:
context.r2_error = exc
@then("r2plc-a NotFoundError should have been raised for plan")
def step_r2_check_plan_not_found(context: Context) -> None:
assert context.r2_error is not None, "Expected NotFoundError but none raised"
assert isinstance(context.r2_error, NotFoundError), (
f"Expected NotFoundError, got {type(context.r2_error).__name__}"
)
# ===================================================================
# update_error_details in persisted mode (line 216 True)
# ===================================================================
@when('r2plc-I update error details with key "{key}" value "{value}"')
def step_r2_update_error_details(context: Context, key: str, value: str) -> None:
context.r2_ctx.reset_mock()
context.r2_service.update_error_details(
context.r2_plan.identity.plan_id,
{key: value},
)
# Refresh the plan reference
context.r2_plan = context.r2_service.get_plan(context.r2_plan.identity.plan_id)
@then('r2plc-the plan error_details should contain key "{key}"')
def step_r2_check_error_details_key(context: Context, key: str) -> None:
details = context.r2_plan.error_details
assert details is not None, "error_details is None"
assert key in details, f"Expected key '{key}' in error_details, got: {details}"
@given("r2plc-a plan in STRATEGIZE phase with existing error_details")
def step_r2_plan_with_error_details(context: Context) -> None:
"""Create a plan and manually set error_details."""
context.r2_ctx.reset_mock()
action = _create_action(context, f"local/r2-errdet-{id(context)}")
plan = context.r2_service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-r2-err")],
)
# Manually set existing error_details
plan.error_details = {"original": "existing_value"}
context.r2_plan = plan
context.r2_ctx.reset_mock()
@@ -0,0 +1,208 @@
"""Step definitions for plan_lifecycle_transitions_r2.feature.
Targets lifecycle transition branches in ``plan_lifecycle_service.py``:
* Non-reusable ``use_action`` archiving (line 576-577).
* ``execute_plan`` persisted mode (line 216 True via ``_commit_plan``).
* ``apply_plan`` persisted mode (line 216 True via ``_commit_plan``).
* ``cancel_plan`` persisted mode (line 216 True via ``_commit_plan``).
* ``pause_plan`` / ``resume_plan`` persisted mode.
All step text uses the ``r2plc-`` prefix to avoid collisions with
existing step definitions.
Shared steps (Background, common Given/Then) live in
``plan_lifecycle_error_r2_steps.py`` and are discovered globally by
Behave from the ``steps/`` directory.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.action import ActionState
from cleveragents.domain.models.core.plan import (
PlanPhase,
ProjectLink,
)
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _create_action(context: Context, name: str, **kwargs: Any) -> Any:
"""Create an action through the service with sensible defaults."""
defaults: dict[str, Any] = {
"name": name,
"description": f"R2 test action {name}",
"definition_of_done": "Tests pass",
"strategy_actor": "openai/gpt-4",
"execution_actor": "openai/gpt-4",
}
defaults.update(kwargs)
return context.r2_service.create_action(**defaults)
# ===================================================================
# use_action with non-reusable action (line 576-577)
# ===================================================================
@given('r2plc-a non-reusable action "{name}" exists')
def step_r2_non_reusable_action(context: Context, name: str) -> None:
context.r2_ctx.reset_mock()
_create_action(context, name, reusable=False)
context.r2_ctx.reset_mock()
@when("r2plc-I use the non-reusable action to create a plan")
def step_r2_use_non_reusable(context: Context) -> None:
context.r2_plan = context.r2_service.use_action(
action_name="local/r2-oneshot",
project_links=[ProjectLink(project_name="proj-r2-oneshot")],
)
@then('r2plc-the action "{name}" should be archived')
def step_r2_check_non_reusable_archived(context: Context, name: str) -> None:
action = context.r2_service.get_action(name)
assert action.state == ActionState.ARCHIVED, (
f"Expected action '{name}' to be ARCHIVED, got {action.state}"
)
@then("r2plc-a plan should have been created from the action")
def step_r2_check_plan_created(context: Context) -> None:
assert context.r2_plan is not None, "Expected a plan to be created"
assert context.r2_plan.phase == PlanPhase.STRATEGIZE
# ===================================================================
# execute_plan persisted mode (line 216 True via _commit_plan)
# ===================================================================
@given("r2plc-a plan in STRATEGIZE COMPLETE state")
def step_r2_plan_strategize_complete(context: Context) -> None:
"""Create a plan and advance to STRATEGIZE/COMPLETE."""
context.r2_ctx.reset_mock()
action = _create_action(context, f"local/r2-exec-{id(context)}")
plan = context.r2_service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-r2-exec")],
)
context.r2_service.start_strategize(plan.identity.plan_id)
context.r2_service.complete_strategize(plan.identity.plan_id)
# complete_strategize calls auto_progress which may call execute_plan
# Re-fetch the plan to see its actual state
context.r2_plan = context.r2_service.get_plan(plan.identity.plan_id)
# If auto_progress already executed, the plan may be in EXECUTE.
# For testing execute_plan explicitly, we need the plan in STRATEGIZE/COMPLETE.
# The manual profile (default) has auto_execute=1.0 so auto_progress won't fire.
context.r2_ctx.reset_mock()
@when("r2plc-I call execute_plan in persisted mode")
def step_r2_execute_plan_persisted(context: Context) -> None:
context.r2_plan = context.r2_service.execute_plan(context.r2_plan.identity.plan_id)
@then("r2plc-the plan should be in EXECUTE phase")
def step_r2_check_execute_phase(context: Context) -> None:
assert context.r2_plan.phase == PlanPhase.EXECUTE, (
f"Expected EXECUTE, got {context.r2_plan.phase}"
)
# ===================================================================
# apply_plan persisted mode (line 216 True via _commit_plan)
# ===================================================================
@given("r2plc-a plan in EXECUTE COMPLETE state")
def step_r2_plan_execute_complete(context: Context) -> None:
"""Create a plan and advance to EXECUTE/COMPLETE."""
context.r2_ctx.reset_mock()
action = _create_action(context, f"local/r2-apply-{id(context)}")
plan = context.r2_service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-r2-apply")],
)
pid = plan.identity.plan_id
context.r2_service.start_strategize(pid)
context.r2_service.complete_strategize(pid)
context.r2_service.execute_plan(pid)
context.r2_service.start_execute(pid)
context.r2_service.complete_execute(pid)
context.r2_plan = context.r2_service.get_plan(pid)
context.r2_ctx.reset_mock()
@when("r2plc-I call apply_plan in persisted mode")
def step_r2_apply_plan_persisted(context: Context) -> None:
context.r2_plan = context.r2_service.apply_plan(context.r2_plan.identity.plan_id)
@then("r2plc-the plan should be in APPLY phase")
def step_r2_check_apply_phase(context: Context) -> None:
assert context.r2_plan.phase == PlanPhase.APPLY, (
f"Expected APPLY, got {context.r2_plan.phase}"
)
# ===================================================================
# cancel_plan persisted mode (line 216 True via _commit_plan)
# ===================================================================
@when('r2plc-I cancel the plan with reason "{reason}"')
def step_r2_cancel_plan(context: Context, reason: str) -> None:
context.r2_ctx.reset_mock()
context.r2_plan = context.r2_service.cancel_plan(
context.r2_plan.identity.plan_id, reason=reason
)
# ===================================================================
# pause_plan persisted mode
# ===================================================================
@when("r2plc-I pause the plan")
def step_r2_pause_plan(context: Context) -> None:
context.r2_ctx.reset_mock()
context.r2_plan = context.r2_service.pause_plan(context.r2_plan.identity.plan_id)
@then("r2plc-the plan automation profile should be manual")
def step_r2_check_manual_profile(context: Context) -> None:
assert context.r2_plan.automation_profile is not None
assert context.r2_plan.automation_profile.profile_name == "manual", (
f"Expected 'manual', got '{context.r2_plan.automation_profile.profile_name}'"
)
# ===================================================================
# resume_plan persisted mode
# ===================================================================
@when('r2plc-I resume the plan with profile "{profile}"')
def step_r2_resume_plan(context: Context, profile: str) -> None:
context.r2_ctx.reset_mock()
context.r2_plan = context.r2_service.resume_plan(
context.r2_plan.identity.plan_id,
automation_profile=profile,
)
@then('r2plc-the plan automation profile should be "{profile}"')
def step_r2_check_profile(context: Context, profile: str) -> None:
assert context.r2_plan.automation_profile is not None
assert context.r2_plan.automation_profile.profile_name == profile, (
f"Expected '{profile}', got '{context.r2_plan.automation_profile.profile_name}'"
)
@@ -0,0 +1,452 @@
"""Step definitions for skill_cli_coverage_r2.feature.
Targets partial branch coverage in
``cleveragents.cli.commands.skill`` (lines 74, 109, 112, 121, 155,
164, 169, and related conditional branches).
All step text uses the ``r2skill-`` prefix to avoid collisions with
existing step definitions in skill_cli_steps.py and
skill_cli_coverage_steps.py.
"""
from __future__ import annotations
import json
import tempfile
from typing import Any
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 _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="r2skill_"
) as tmp:
tmp.write(content)
tmp.flush()
return tmp.name
# ── Background ──────────────────────────────────────────────
@given("r2skill- a reset skill CLI service")
def step_r2_background(context: Context) -> None:
"""Reset module-level singleton and prepare runner/service."""
_reset_skill_service()
context.r2_runner = CliRunner()
context.r2_service = _get_skill_service()
context.r2_result = None
context.r2_temp_paths = [] # list[str]
context.r2_second_temp = None # str | None
context.r2_service_a = None
context.r2_service_b = None
# ── Given steps ─────────────────────────────────────────────
@given('r2skill- a registered skill "{name}" with tool_refs')
def step_r2_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"])
context.r2_service._skills[skill.name] = skill
@given('r2skill- the timestamps for "{name}" are removed')
def step_r2_remove_timestamps(context: Context, name: str) -> None:
"""Remove created_at and updated_at for a skill to force None returns."""
context.r2_service._created_at.pop(name, None)
context.r2_service._updated_at.pop(name, None)
@given('r2skill- a registered skill "{name}" with MCP server but no tool list')
def step_r2_register_mcp_no_tools(context: Context, name: str) -> None:
"""Skill with an MCP server whose tools list is None."""
skill = _make_skill(
name=name,
mcp_servers=[SkillMcpSource(server="test-mcp-server", tools=None)],
)
context.r2_service._skills[skill.name] = skill
@given('r2skill- a temp YAML config for "{name}" with only tool_refs')
def step_r2_temp_yaml_tool_refs(context: Context, name: str) -> None:
"""Create a temp YAML config with only tool references."""
yaml_content = f"""\
name: {name}
description: A test skill with tool refs
tools:
- name: builtin/read-file
"""
path = _write_yaml(yaml_content)
context.r2_temp_paths.append(path)
@given('r2skill- a temp YAML config for "{name}" with no tools')
def step_r2_temp_yaml_no_tools(context: Context, name: str) -> None:
"""Create a temp YAML config with no tools/mcp/agent/inline at all."""
yaml_content = f"""\
name: {name}
description: A skill with absolutely no tool sources
"""
path = _write_yaml(yaml_content)
context.r2_temp_paths.append(path)
@given('r2skill- a temp YAML config for "{name}" with MCP and tools')
def step_r2_temp_yaml_mcp_with_tools(context: Context, name: str) -> None:
"""Create a YAML config for a skill with only MCP servers (no builtin tools)."""
yaml_content = f"""\
name: {name}
description: Skill with only MCP sources
mcp_servers:
- name: my-mcp
transport: stdio
tool_filter:
include:
- read
- write
"""
path = _write_yaml(yaml_content)
context.r2_temp_paths.append(path)
@given('r2skill- a temp YAML config for "{name}" with inline tools')
def step_r2_temp_yaml_inline(context: Context, name: str) -> None:
"""Create a YAML config with inline (custom) tools only."""
yaml_content = f"""\
name: {name}
description: Skill with inline tools
inline_tools:
- name: my-custom-tool
description: A custom tool
source: custom
code: "print('hello')"
"""
path = _write_yaml(yaml_content)
context.r2_temp_paths.append(path)
@given('r2skill- the skill "{name}" is already registered via add')
def step_r2_pre_register_via_add(context: Context, name: str) -> None:
"""Register a skill via the CLI add command (first invocation)."""
# Use the last temp path that was created
config_path = context.r2_temp_paths[-1]
result = context.r2_runner.invoke(
skill_app, ["add", "--config", config_path, "--format", "json"]
)
assert result.exit_code == 0, f"Pre-registration failed: {result.output}"
@given('r2skill- a second temp YAML config for "{name}" with different tools')
def step_r2_second_yaml_different_tools(context: Context, name: str) -> None:
"""Create a second YAML config for the same skill with different tools."""
yaml_content = f"""\
name: {name}
description: Updated skill with different tools
tools:
- name: builtin/write-file
- name: builtin/exec-command
"""
path = _write_yaml(yaml_content)
context.r2_second_temp = path
@given('r2skill- a duplicate temp YAML config for "{name}"')
def step_r2_duplicate_yaml(context: Context, name: str) -> None:
"""Create a duplicate YAML config for the same skill name."""
yaml_content = f"""\
name: {name}
description: Duplicate skill
tools:
- name: builtin/read-file
"""
path = _write_yaml(yaml_content)
context.r2_second_temp = path
@given('r2skill- a registered skill "{name}" that includes "{included}"')
def step_r2_register_with_include(context: Context, name: str, included: str) -> None:
"""Register a skill that includes another skill."""
skill = _make_skill(
name=name,
tool_refs=["builtin/exec-command"],
includes=[SkillInclude(name=included)],
)
context.r2_service._skills[skill.name] = skill
@given('r2skill- a registered skill "{name}" with anonymous inline tools')
def step_r2_register_inline_tools(context: Context, name: str) -> None:
"""Register a skill with anonymous inline tools."""
inline = SkillInlineTool(
description="An inline tool",
source=ToolSource.CUSTOM,
code="print('hello')",
capability=ToolCapability(read_only=True),
)
skill = _make_skill(
name=name,
anonymous_tools=[inline],
)
context.r2_service._skills[skill.name] = skill
@given('r2skill- a registered skill "{name}" with no tools at all')
def step_r2_register_no_tools(context: Context, name: str) -> None:
"""Register a skill with no tool sources whatsoever."""
skill = _make_skill(name=name)
context.r2_service._skills[skill.name] = skill
@given('r2skill- a registered skill "{name}" with MCP server and explicit tools')
def step_r2_register_mcp_explicit_tools(context: Context, name: str) -> None:
"""Register a skill with an MCP server that has explicit tool names."""
skill = _make_skill(
name=name,
mcp_servers=[SkillMcpSource(server="my-mcp-srv", tools=["tool-a", "tool-b"])],
)
context.r2_service._skills[skill.name] = skill
@given('r2skill- a temp YAML config for "{name}" with MCP servers')
def step_r2_temp_yaml_mcp_servers(context: Context, name: str) -> None:
"""Create a YAML config that defines MCP servers."""
yaml_content = f"""\
name: {name}
description: Skill with MCP servers
mcp_servers:
- name: mcp-panel-server
transport: stdio
tool_filter:
include:
- read-data
"""
path = _write_yaml(yaml_content)
context.r2_temp_paths.append(path)
# ── When steps ──────────────────────────────────────────────
@when("r2skill- I call _get_skill_service twice without resetting")
def step_r2_get_service_twice(context: Context) -> None:
"""Call _get_skill_service twice to exercise the cached path."""
# First call already done in Background via _get_skill_service()
# Do NOT reset — call again to hit the `if _service is None:` false branch.
context.r2_service_a = _get_skill_service()
context.r2_service_b = _get_skill_service()
@when('r2skill- I invoke show "{name}" with format "{fmt}"')
def step_r2_invoke_show(context: Context, name: str, fmt: str) -> None:
"""Invoke the skill show command."""
context.r2_result = context.r2_runner.invoke(
skill_app, ["show", name, "--format", fmt]
)
@when("r2skill- I invoke add with the temp config in rich format")
def step_r2_invoke_add_rich(context: Context) -> None:
"""Invoke skill add with the most recent temp config, rich format."""
config_path = context.r2_temp_paths[-1]
context.r2_result = context.r2_runner.invoke(
skill_app, ["add", "--config", config_path]
)
@when('r2skill- I invoke add with the temp config in format "{fmt}"')
def step_r2_invoke_add_fmt(context: Context, fmt: str) -> None:
"""Invoke skill add with the most recent temp config and a specified format."""
config_path = context.r2_temp_paths[-1]
context.r2_result = context.r2_runner.invoke(
skill_app, ["add", "--config", config_path, "--format", fmt]
)
@when("r2skill- I invoke add with the second config and --update in rich format")
def step_r2_invoke_add_update_rich(context: Context) -> None:
"""Invoke skill add --update with the second temp config."""
assert context.r2_second_temp is not None
context.r2_result = context.r2_runner.invoke(
skill_app, ["add", "--config", context.r2_second_temp, "--update"]
)
@when("r2skill- I invoke add with the duplicate config without update")
def step_r2_invoke_add_duplicate(context: Context) -> None:
"""Invoke skill add with a duplicate name and no --update flag."""
assert context.r2_second_temp is not None
context.r2_result = context.r2_runner.invoke(
skill_app, ["add", "--config", context.r2_second_temp]
)
@when("r2skill- I invoke list in rich format")
def step_r2_invoke_list_rich(context: Context) -> None:
"""Invoke skill list with rich format (the default)."""
context.r2_result = context.r2_runner.invoke(skill_app, ["list"])
@when('r2skill- I invoke list in format "{fmt}"')
def step_r2_invoke_list_fmt(context: Context, fmt: str) -> None:
"""Invoke skill list with a specified format."""
context.r2_result = context.r2_runner.invoke(skill_app, ["list", "--format", fmt])
@when('r2skill- I invoke remove "{name}" with --yes in rich format')
def step_r2_invoke_remove_yes_rich(context: Context, name: str) -> None:
"""Invoke skill remove with --yes in rich format."""
context.r2_result = context.r2_runner.invoke(skill_app, ["remove", name, "--yes"])
@when('r2skill- I invoke remove "{name}" with --yes in format "{fmt}"')
def step_r2_invoke_remove_yes_fmt(context: Context, name: str, fmt: str) -> None:
"""Invoke skill remove --yes with a specified format."""
context.r2_result = context.r2_runner.invoke(
skill_app, ["remove", name, "--yes", "--format", fmt]
)
@when('r2skill- I invoke tools "{name}" with format "{fmt}"')
def step_r2_invoke_tools_fmt(context: Context, name: str, fmt: str) -> None:
"""Invoke skill tools with a specified format."""
context.r2_result = context.r2_runner.invoke(
skill_app, ["tools", name, "--format", fmt]
)
# ── Then steps ──────────────────────────────────────────────
@then("r2skill- both calls return the same SkillService object")
def step_r2_assert_same_service(context: Context) -> None:
"""Assert the singleton returned the same object."""
assert context.r2_service_a is context.r2_service_b, (
"Expected the same SkillService instance but got different objects"
)
@then("r2skill- the CLI exit code should be 0")
def step_r2_exit_code_0(context: Context) -> None:
"""Assert CLI exited successfully."""
assert context.r2_result is not None
assert context.r2_result.exit_code == 0, (
f"Expected exit_code=0, got {context.r2_result.exit_code}\n"
f"Output: {context.r2_result.output}"
)
@then("r2skill- the CLI exit code should not be 0")
def step_r2_exit_code_not_0(context: Context) -> None:
"""Assert CLI exited with an error."""
assert context.r2_result is not None
assert context.r2_result.exit_code != 0, (
f"Expected non-zero exit code, got {context.r2_result.exit_code}\n"
f"Output: {context.r2_result.output}"
)
@then("r2skill- the output should be valid JSON")
def step_r2_output_valid_json(context: Context) -> None:
"""Assert the CLI output parses as valid JSON."""
assert context.r2_result is not None
try:
context.r2_parsed_json = json.loads(context.r2_result.output)
except json.JSONDecodeError as e:
raise AssertionError(
f"Output is not valid JSON: {e}\nOutput: {context.r2_result.output}"
) from e
@then('r2skill- the JSON output should not contain key "{key}"')
def step_r2_json_no_key(context: Context, key: str) -> None:
"""Assert a key is absent from the parsed JSON dict."""
data: dict[str, Any] = context.r2_parsed_json
assert key not in data, (
f"Expected key '{key}' to be absent, but it was found in: {list(data.keys())}"
)
@then('r2skill- the JSON output should contain key "{key}"')
def step_r2_json_has_key(context: Context, key: str) -> None:
"""Assert a key is present in the parsed JSON dict."""
data: Any = context.r2_parsed_json
if isinstance(data, list):
# Check first element
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('r2skill- the output should contain "{text}"')
def step_r2_output_contains(context: Context, text: str) -> None:
"""Assert the CLI output contains the specified text."""
assert context.r2_result is not None
assert text in context.r2_result.output, (
f"Expected output to contain '{text}'\n"
f"Actual output: {context.r2_result.output}"
)
@then('r2skill- the output should not contain "{text}"')
def step_r2_output_not_contains(context: Context, text: str) -> None:
"""Assert the CLI output does not contain the specified text."""
assert context.r2_result is not None
assert text not in context.r2_result.output, (
f"Expected output NOT to contain '{text}'\n"
f"Actual output: {context.r2_result.output}"
)
@then('r2skill- the JSON tool list should contain source "{source}"')
def step_r2_json_tool_list_source(context: Context, source: str) -> None:
"""Assert the JSON tool list contains an entry with the given source."""
data: Any = context.r2_parsed_json
assert isinstance(data, list), f"Expected list, got {type(data).__name__}"
sources = [entry.get("source", "") for entry in data]
assert source in sources, f"Expected source '{source}' in tool list, got: {sources}"