test(coverage): add Behave scenarios for under-tested modules #419

Merged
freemo merged 1 commits from test/improve-coverage into master 2026-02-24 17:54:37 +00:00
19 changed files with 6020 additions and 1 deletions
+277
View File
@@ -0,0 +1,277 @@
Feature: ConfigService full coverage
As a developer
I want every line of config_service.py tested
So that the module has complete test coverage
# ---------- ConfigLevel enum ----------
Scenario: ConfigLevel enum has five precedence levels
Then ConfigLevel should have values CLI_FLAG ENV_VAR PROJECT GLOBAL DEFAULT
# ---------- ConfigEntry and ResolvedValue dataclasses ----------
Scenario: ConfigEntry dataclass stores all metadata fields
Given a ConfigEntry with key "test.key" and type str
Then the ConfigEntry fields should match the provided values
Scenario: ResolvedValue dataclass stores resolution result
Given a ResolvedValue with key "test.key" and source DEFAULT
Then the ResolvedValue fields should match including an empty chain by default
# ---------- _env_name helper ----------
Scenario: _env_name builds uppercase CLEVERAGENTS env var name
When I call _env_name with section "core" and key "log_level"
Then the env name should be "CLEVERAGENTS_CORE_LOG_LEVEL"
# ---------- _register and _build_catalog ----------
Scenario: _register adds an entry to the global registry
When I register a custom key "custom.test_key" with type int and default 42
Then the registry should contain "custom.test_key" with default 42
Scenario: _register uses explicit env_var when provided
When I register a key "custom.explicit_env" with explicit env_var "MY_CUSTOM_VAR"
Then the entry "custom.explicit_env" should have env_var "MY_CUSTOM_VAR"
Scenario: _build_catalog populates all expected config sections
Then the registry should contain keys from sections core plan provider sandbox context index
Scenario: _build_catalog registers the expected number of keys
Then the registry should contain at least 20 keys
# ---------- ConfigService instantiation ----------
Scenario: ConfigService uses custom config_dir and config_path
Given a temporary directory for config service
When I create a ConfigService with custom paths
Then the service should use the custom config directory and path
Scenario: ConfigService uses defaults when no paths provided
When I create a ConfigService with default paths
Then the service config path should end with ".cleveragents/config.toml"
# ---------- registry(), get_entry(), registered_keys() ----------
Scenario: registry returns a copy of all registered entries
When I call ConfigService.registry
Then it should return a dict with the same keys as the internal registry
Scenario: get_entry returns the entry for a known key
When I call ConfigService.get_entry with "core.log_level"
Then it should return a ConfigEntry with key "core.log_level"
Scenario: get_entry returns None for an unknown key
When I call ConfigService.get_entry with "nonexistent.key"
Then it should return None
Scenario: registered_keys returns a sorted list of all keys
When I call ConfigService.registered_keys
Then it should return a sorted list containing "core.log_level"
# ---------- read_config ----------
Scenario: read_config returns empty dict when file does not exist
Given a temporary directory for config service
When I create a ConfigService pointing to a missing config file
Then read_config should return an empty dict
Scenario: read_config parses an existing TOML file
Given a temporary directory for config service
And a TOML config file with key "core.log_level" set to "DEBUG"
When I create a ConfigService pointing to that TOML file
Then read_config should return a dict with "core.log_level" equal to "DEBUG"
# ---------- write_config ----------
Scenario: write_config creates directories and writes a new TOML file
Given a temporary directory for config service
When I create a ConfigService with a nested non-existent config directory
And I call write_config with key "greeting" value "hello"
Then the TOML file should exist and contain key "greeting" with value "hello"
Scenario: write_config merges data into an existing TOML file
Given a temporary directory for config service
And a TOML config file with key "existing" set to "value1"
When I create a ConfigService pointing to that TOML file
And I call write_config with key "new_key" value "value2"
Then read_config should return a dict containing both "existing" and "new_key"
# ---------- set_value ----------
Scenario: set_value persists a single key-value pair
Given a temporary directory for config service
When I create a ConfigService with custom paths
And I call set_value with key "mykey" and value "myval"
Then read_config should return a dict with "mykey" equal to "myval"
# ---------- validate_key ----------
Scenario: validate_key returns entry for a known key
When I call validate_key with "core.log_level"
Then it should return the ConfigEntry for "core.log_level" without error
Scenario: validate_key raises ValueError for an unknown key
When I call validate_key with "totally.unknown"
Then a ValueError should be raised mentioning "Unknown configuration key"
# ---------- validate_type ----------
Scenario: validate_type returns value unchanged when type already matches
When I call validate_type with key "core.log_level" and value "INFO"
Then it should return "INFO" unchanged
Scenario: validate_type coerces a string to int
When I call validate_type with key "core.server_port" and string value "9090"
Then it should return the integer 9090
Scenario: validate_type coerces a string to float
When I call validate_type with key "provider.temperature" and string value "0.5"
Then it should return the float 0.5
Scenario: validate_type coerces non-string to str
When I call validate_type with key "core.log_level" and integer value 123
Then it should return the string "123"
Scenario: validate_type coerces "true" string to bool True
When I call validate_type with key "core.debug_enabled" and string value "true"
Then it should return boolean True
Scenario: validate_type coerces "1" string to bool True
When I call validate_type with key "core.debug_enabled" and string value "1"
Then it should return boolean True
Scenario: validate_type coerces "yes" string to bool True
When I call validate_type with key "core.debug_enabled" and string value "yes"
Then it should return boolean True
Scenario: validate_type coerces "false" string to bool False
When I call validate_type with key "core.debug_enabled" and string value "false"
Then it should return boolean False
Scenario: validate_type coerces "0" string to bool False
When I call validate_type with key "core.debug_enabled" and string value "0"
Then it should return boolean False
Scenario: validate_type coerces "no" string to bool False
When I call validate_type with key "core.debug_enabled" and string value "no"
Then it should return boolean False
Scenario: validate_type raises TypeError for invalid bool string
When I call validate_type with key "core.debug_enabled" and string value "maybe"
Then a TypeError should be raised mentioning "Cannot convert"
Scenario: validate_type raises TypeError for non-coercible int value
When I call validate_type with key "core.server_port" and string value "not_a_number"
Then a TypeError should be raised mentioning "Type mismatch"
Scenario: validate_type raises TypeError for non-bool non-string to bool key
When I call validate_type with key "core.debug_enabled" and a list value
Then a TypeError should be raised mentioning "Type mismatch"
Scenario: validate_type raises ValueError for completely unknown key
When I call validate_type with unknown key "fake.unknown" and value "x"
Then a ValueError should be raised mentioning "Cannot validate type for unknown key"
# ---------- resolve: five-level chain ----------
Scenario: resolve returns default value when no overrides exist
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I resolve key "core.log_level" with no overrides
Then the resolved value should be "INFO" from source DEFAULT
Scenario: resolve returns CLI flag value when provided
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I resolve key "core.log_level" with cli_value "TRACE"
Then the resolved value should be "TRACE" from source CLI_FLAG
Scenario: resolve returns environment variable when no CLI flag
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
And the env var "CLEVERAGENTS_CORE_LOG_LEVEL" is injected with "WARNING"
When I resolve key "core.log_level" with no overrides
Then the resolved value should be "WARNING" from source ENV_VAR
Scenario: CLI flag takes precedence over environment variable
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
And the env var "CLEVERAGENTS_CORE_LOG_LEVEL" is injected with "WARNING"
When I resolve key "core.log_level" with cli_value "TRACE"
Then the resolved value should be "TRACE" from source CLI_FLAG
Scenario: resolve returns project-scoped value when set
Given a temporary directory for config service
And a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"
When I resolve key "core.log_level" with project_name "myproj"
Then the resolved value should be "PROJECT_DEBUG" from source PROJECT
Scenario: resolve returns global config value when no higher overrides
Given a temporary directory for config service
And a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"
When I resolve key "core.log_level" with no overrides
Then the resolved value should be "GLOBAL_WARN" from source GLOBAL
Scenario: resolve populates chain when verbose is True
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I resolve key "core.log_level" with verbose True and no overrides
Then the resolved chain should have 5 entries covering all levels
Scenario: resolve verbose chain includes env_name for ENV_VAR level
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I resolve key "core.log_level" with verbose True and no overrides
Then the verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_CORE_LOG_LEVEL"
Scenario: resolve verbose chain includes path for GLOBAL level
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I resolve key "core.log_level" with verbose True and no overrides
Then the verbose chain GLOBAL entry should include the config file path
Scenario: resolve verbose chain shows project value when project-scoped wins
Given a temporary directory for config service
And a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"
When I resolve key "core.log_level" with project_name "myproj" and verbose True
Then the resolved value should be "PROJECT_DEBUG" from source PROJECT
And the verbose chain PROJECT entry should have value "PROJECT_DEBUG"
Scenario: resolve verbose chain shows global value when global config wins
Given a temporary directory for config service
And a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"
When I resolve key "core.log_level" with verbose True and no overrides
Then the resolved value should be "GLOBAL_WARN" from source GLOBAL
And the verbose chain GLOBAL entry should have value "GLOBAL_WARN"
Scenario: resolve skips project scope for non-scopable keys
Given a temporary directory for config service
And a ConfigService with project-scoped config for key "core.database_url" value "pg://proj" under project "myproj"
When I resolve key "core.database_url" with project_name "myproj"
Then the resolved value should not be "pg://proj"
And the resolved source should be DEFAULT or GLOBAL
# ---------- env_var_for_key ----------
Scenario: env_var_for_key returns the env var name for a valid key
When I call env_var_for_key with "core.log_level"
Then it should return "CLEVERAGENTS_CORE_LOG_LEVEL"
Scenario: env_var_for_key raises ValueError for an unknown key
When I call env_var_for_key with "nonexistent.key"
Then a ValueError should be raised mentioning "Unknown configuration key"
# ---------- resolve_all ----------
Scenario: resolve_all returns a resolved value for every registered key
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I call resolve_all with no overrides
Then the result should contain an entry for every registered key
Scenario: resolve_all applies CLI overrides to matching keys
Given a temporary directory for config service
And a ConfigService with empty config for resolve tests
When I call resolve_all with cli_override "core.log_level" set to "FATAL"
Then the resolve_all result for "core.log_level" should have value "FATAL" and source CLI_FLAG
@@ -0,0 +1,342 @@
Feature: CorrectionService full coverage
As a developer maintaining CorrectionService
I want every line and branch fully tested
So that correction_service.py achieves 100% coverage
Background:
Given a coverage test correction service
# -------------------------------------------------------------------
# __init__
# -------------------------------------------------------------------
Scenario: Service initializes with empty internal dictionaries
Then the coverage service corrections dict should be empty
And the coverage service impacts dict should be empty
And the coverage service attempts dict should be empty
And the coverage service results dict should be empty
# -------------------------------------------------------------------
# request_correction happy paths
# -------------------------------------------------------------------
Scenario: Create a revert correction with defaults
When I cov-request a correction for plan "P1" targeting "D1" in revert mode
Then the cov-request should be stored in the service
And the cov-request plan_id should be "P1"
And the cov-request target_decision_id should be "D1"
And the cov-request mode should be "revert"
And the cov-request status should be "pending"
And the cov-request dry_run should be false
And the cov-request guidance should be empty
Scenario: Create an append correction with guidance and dry_run
When I cov-request a correction for plan "P2" targeting "D2" in append mode with guidance "fix things" and dry_run
Then the cov-request should be stored in the service
And the cov-request plan_id should be "P2"
And the cov-request target_decision_id should be "D2"
And the cov-request mode should be "append"
And the cov-request dry_run should be true
And the cov-request guidance should be "fix things"
# -------------------------------------------------------------------
# request_correction validation errors
# -------------------------------------------------------------------
Scenario: Empty plan_id raises ValidationError
When I cov-attempt to create a correction with empty plan_id and target "D1"
Then a cov ValidationError should have been raised
Scenario: Whitespace-only plan_id raises ValidationError
When I cov-attempt to create a correction with plan_id " " and target "D1"
Then a cov ValidationError should have been raised
Scenario: Empty target_decision_id raises ValidationError
When I cov-attempt to create a correction with plan_id "P1" and empty target
Then a cov ValidationError should have been raised
Scenario: Whitespace-only target_decision_id raises ValidationError
When I cov-attempt to create a correction with plan_id "P1" and target " "
Then a cov ValidationError should have been raised
# -------------------------------------------------------------------
# analyze_impact BFS traversal
# -------------------------------------------------------------------
Scenario: Analyze impact with no decision tree yields single-node result
Given a cov-stored revert correction for plan "P1" targeting "ROOT"
When I cov-analyze impact with an empty tree
Then the cov-impact affected decisions should be "ROOT"
And the cov-impact risk level should be "low"
And the cov-impact rollback tier should be "full"
And the cov-impact estimated cost should be 1.5
And the cov-impact artifacts to archive should be "ROOT.artifact"
And the cov-impact affected files should be "ROOT.py"
Scenario: Analyze impact with tree having children
Given a cov-stored revert correction for plan "P1" targeting "A"
When I cov-analyze impact with adjacency "A->B,C;B->D"
Then the cov-impact affected decisions should be "A,B,C,D"
And the cov-impact risk level should be "medium"
And the cov-impact estimated cost should be 6.0
And the cov-impact affected decisions count should be 4
Scenario: Analyze impact for append mode sets rollback tier to append_only
Given a cov-stored append correction for plan "P1" targeting "X"
When I cov-analyze impact with an empty tree
Then the cov-impact rollback tier should be "append_only"
Scenario: Analyze impact for nonexistent correction raises ResourceNotFoundError
When I cov-attempt to analyze impact for correction id "BOGUS_ID"
Then a cov ResourceNotFoundError should have been raised
Scenario: Analyze impact sets status to analyzing
Given a cov-stored revert correction for plan "P1" targeting "N1"
When I cov-analyze impact with an empty tree
Then the cov-stored correction status should be "analyzing"
# -------------------------------------------------------------------
# generate_dry_run_report
# -------------------------------------------------------------------
Scenario: Dry-run report for low-risk revert with single decision
Given a cov-stored revert correction for plan "P1" targeting "SOLO"
When I cov-generate dry-run report with an empty tree
Then the cov dry-run mode should be "revert"
And the cov dry-run warnings should be empty
And the cov dry-run decisions to invalidate should be "SOLO"
And the cov dry-run estimated recompute time should be 2.0
Scenario: Dry-run report for medium-risk revert with multiple decisions
Given a cov-stored revert correction for plan "P1" targeting "R"
When I cov-generate dry-run report with adjacency "R->C1,C2,C3,C4"
Then the cov dry-run warnings should contain "Medium risk"
And the cov dry-run warnings should contain "Revert will invalidate 5 decisions"
And the cov dry-run decisions to invalidate count should be 5
And the cov dry-run estimated recompute time should be 10.0
Scenario: Dry-run report for high-risk revert
Given a cov-stored revert correction for plan "P1" targeting "H"
When I cov-generate dry-run report with a chain of 12 nodes rooted at "H"
Then the cov dry-run warnings should contain "High risk"
And the cov dry-run warnings should contain "Revert will invalidate 12 decisions"
And the cov dry-run decisions to invalidate count should be 12
Scenario: Dry-run report for append mode has empty decisions_to_invalidate
Given a cov-stored append correction for plan "P1" targeting "APP"
When I cov-generate dry-run report with an empty tree
Then the cov dry-run mode should be "append"
And the cov dry-run decisions to invalidate should be empty list
And the cov dry-run warnings should be empty
Scenario: Dry-run report for nonexistent correction raises error
When I cov-attempt to generate dry-run report for correction id "NO_SUCH"
Then a cov ResourceNotFoundError should have been raised
# -------------------------------------------------------------------
# execute_revert
# -------------------------------------------------------------------
Scenario: Execute revert on a pending correction succeeds
Given a cov-stored revert correction for plan "P1" targeting "T1"
When I cov-execute revert with adjacency "T1->T2"
Then the cov revert result status should be "applied"
And the cov revert result reverted decisions should include "T1"
And the cov revert result reverted decisions should include "T2"
And the cov revert result archived artifacts should include "T1.artifact"
And the cov revert result archived artifacts should include "T2.artifact"
And the cov-stored correction status should be "applied"
And the cov attempts for the stored correction should have 1 entry
And the cov first attempt should be successful
And the cov first attempt completed_at should be set
Scenario: Execute revert with empty tree marks only the target
Given a cov-stored revert correction for plan "P1" targeting "ONLY"
When I cov-execute revert with empty tree
Then the cov revert result status should be "applied"
And the cov revert result reverted decisions should include "ONLY"
Scenario: Execute revert on already-applied correction raises ValidationError
Given a cov-stored revert correction for plan "P1" targeting "R1"
And the cov-stored correction has been executed via revert with empty tree
When I cov-attempt to execute revert on the stored correction
Then a cov ValidationError should have been raised
# -------------------------------------------------------------------
# execute_append
# -------------------------------------------------------------------
Scenario: Execute append on a pending correction succeeds
Given a cov-stored append correction for plan "P1" targeting "A1"
When I cov-execute append for the stored correction
Then the cov append result status should be "applied"
And the cov append result spawned_child_plan_id should not be none
And the cov append result new_decisions should not be empty
And the cov-stored correction status should be "applied"
And the cov attempts for the stored correction should have 1 entry
And the cov first attempt should be successful
And the cov first attempt details should contain spawned_child_plan_id
Scenario: Execute append on already-applied correction raises ValidationError
Given a cov-stored append correction for plan "P1" targeting "A2"
And the cov-stored correction has been executed via append
When I cov-attempt to execute append on the stored correction
Then a cov ValidationError should have been raised
# -------------------------------------------------------------------
# execute_correction (dispatch)
# -------------------------------------------------------------------
Scenario: Dispatch routes revert mode to execute_revert
Given a cov-stored revert correction for plan "P1" targeting "DR"
When I cov-dispatch execute correction with adjacency "DR->DR2"
Then the cov dispatch result status should be "applied"
And the cov dispatch result reverted decisions should include "DR"
And the cov dispatch result reverted decisions should include "DR2"
Scenario: Dispatch routes append mode to execute_append
Given a cov-stored append correction for plan "P1" targeting "DA"
When I cov-dispatch execute correction with no tree
Then the cov dispatch result status should be "applied"
And the cov dispatch result spawned_child_plan_id should not be none
# -------------------------------------------------------------------
# get_correction
# -------------------------------------------------------------------
Scenario: Get correction by id returns correct request
Given a cov-stored revert correction for plan "PG" targeting "DG"
When I cov-get the stored correction by id
Then the cov-retrieved correction plan_id should be "PG"
And the cov-retrieved correction target_decision_id should be "DG"
Scenario: Get correction for nonexistent id raises ResourceNotFoundError
When I cov-attempt to get correction with id "MISSING"
Then a cov ResourceNotFoundError should have been raised
# -------------------------------------------------------------------
# list_corrections
# -------------------------------------------------------------------
Scenario: List all corrections without filter
Given a cov-stored revert correction for plan "PA" targeting "DA"
And a cov-stored revert correction for plan "PB" targeting "DB"
When I cov-list all corrections without filter
Then the cov corrections list should have at least 2 items
Scenario: List corrections filtered by plan_id
Given a cov-stored revert correction for plan "PX" targeting "DX"
Given a cov-stored append correction for plan "PY" targeting "DY"
When I cov-list corrections for plan "PX"
Then the cov corrections list should have 1 item
Scenario: List corrections filtered by plan_id with no matches
When I cov-list corrections for plan "NONEXISTENT_PLAN"
Then the cov corrections list should have 0 items
# -------------------------------------------------------------------
# list_attempts
# -------------------------------------------------------------------
Scenario: List attempts before any execution returns empty
Given a cov-stored revert correction for plan "P1" targeting "D1"
When I cov-list attempts for the stored correction
Then the cov attempts list should have 0 items
Scenario: List attempts after execution returns the attempt
Given a cov-stored revert correction for plan "P1" targeting "D1"
And the cov-stored correction has been executed via revert with empty tree
When I cov-list attempts for the stored correction
Then the cov attempts list should have 1 item
Scenario: List attempts for nonexistent correction raises error
When I cov-attempt to list attempts for correction id "NOPE"
Then a cov ResourceNotFoundError should have been raised
# -------------------------------------------------------------------
# cancel_correction
# -------------------------------------------------------------------
Scenario: Cancel a pending correction succeeds
Given a cov-stored revert correction for plan "P1" targeting "D1"
When I cov-cancel the stored correction
Then the cov-stored correction status should be "cancelled"
Scenario: Cancel an analyzing correction succeeds
Given a cov-stored revert correction for plan "P1" targeting "D1"
And the cov-stored correction has been analyzed with empty tree
When I cov-cancel the stored correction
Then the cov-stored correction status should be "cancelled"
Scenario: Cancel an already-applied correction raises ValidationError
Given a cov-stored revert correction for plan "P1" targeting "D1"
And the cov-stored correction has been executed via revert with empty tree
When I cov-attempt to cancel the stored correction
Then a cov ValidationError should have been raised
Scenario: Cancel an already-cancelled correction raises ValidationError
Given a cov-stored revert correction for plan "P1" targeting "D1"
When I cov-cancel the stored correction
And I cov-attempt to cancel the stored correction
Then a cov ValidationError should have been raised
# -------------------------------------------------------------------
# _classify_risk (static)
# -------------------------------------------------------------------
Scenario Outline: Risk classification boundary values
Then cov classifying risk for <count> affected returns "<expected>"
Examples:
| count | expected |
| 0 | low |
| 1 | low |
| 3 | low |
| 4 | medium |
| 10 | medium |
| 11 | high |
| 50 | high |
# -------------------------------------------------------------------
# _compute_affected_subtree (static)
# -------------------------------------------------------------------
Scenario: Compute affected subtree with empty tree returns only root
When I cov-compute subtree for "ROOT" with empty tree
Then the cov subtree should be "ROOT"
Scenario: Compute affected subtree with linear chain
When I cov-compute subtree for "A" with adjacency "A->B;B->C;C->D"
Then the cov subtree should be "A,B,C,D"
And the cov subtree count should be 4
Scenario: Compute affected subtree with branching tree
When I cov-compute subtree for "R" with adjacency "R->L,M;L->LL,LR"
Then the cov subtree should be "R,L,M,LL,LR"
Scenario: Compute affected subtree starting at leaf node
When I cov-compute subtree for "LEAF" with adjacency "ROOT->LEAF"
Then the cov subtree should be "LEAF"
And the cov subtree count should be 1
# -------------------------------------------------------------------
# _assert_executable internal helper (via execute_revert / execute_append)
# -------------------------------------------------------------------
Scenario: Execute revert on FAILED correction raises ValidationError
Given a cov-stored revert correction for plan "P1" targeting "F1"
And the cov-stored correction status is forced to "failed"
When I cov-attempt to execute revert on the stored correction
Then a cov ValidationError should have been raised
Scenario: Execute append on EXECUTING correction raises ValidationError
Given a cov-stored append correction for plan "P1" targeting "E1"
And the cov-stored correction status is forced to "executing"
When I cov-attempt to execute append on the stored correction
Then a cov ValidationError should have been raised
Scenario: Execute append on CANCELLED correction raises ValidationError
Given a cov-stored append correction for plan "P1" targeting "C1"
And the cov-stored correction status is forced to "cancelled"
When I cov-attempt to execute append on the stored correction
Then a cov ValidationError should have been raised
@@ -0,0 +1,77 @@
@phase1 @database @models @missing_coverage
Feature: Database models missing coverage for lines 1666, 1921, 2131-2132
As a developer
I want to exercise the remaining uncovered lines in database models
So that code coverage reaches 100%
Background:
Given the missing coverage database is ready
And a missing coverage database session is open
# -------------------------------------------------------------------
# ToolModel.from_domain() with a dict (covers line 1666)
# -------------------------------------------------------------------
@tool @from_domain @dict_input
Scenario: ToolModel.from_domain creates model from a plain dict
Given a tool definition provided as a plain dict
When the tool dict is converted to a ToolModel via from_domain
Then the ToolModel should have the correct name from the dict
And the ToolModel should have the correct description from the dict
And the ToolModel should have the correct tool_type from the dict
And the ToolModel should have the correct source from the dict
@tool @from_domain @dict_input @resource_bindings
Scenario: ToolModel.from_domain populates resource bindings from dict input
Given a tool definition provided as a plain dict with resource bindings
When the tool dict with bindings is converted to a ToolModel via from_domain
Then the ToolModel should have resource binding child records
And each resource binding should have the correct slot_name
And each resource binding should have the correct resource_type
@tool @from_domain @dict_input @namespace
Scenario: ToolModel.from_domain correctly splits namespaced name from dict
Given a tool definition dict with a namespaced name
When the namespaced tool dict is converted to a ToolModel via from_domain
Then the ToolModel namespace should be extracted from the dict name
And the ToolModel short_name should be extracted from the dict name
# -------------------------------------------------------------------
# SessionModel.from_domain() with messages (covers line 1921)
# -------------------------------------------------------------------
@session @from_domain @messages
Scenario: SessionModel.from_domain populates message child models
Given a Session domain object with two messages
When the Session domain object is converted to a SessionModel via from_domain
Then the SessionModel should have two message child records
And the first message record should have role "user"
And the second message record should have role "assistant"
And each message record should have the correct content
@session @from_domain @messages @single
Scenario: SessionModel.from_domain handles a session with a single message
Given a Session domain object with one message
When the single-message Session is converted to a SessionModel via from_domain
Then the SessionModel should have one message child record
And the single message record should have the correct message_id
# -------------------------------------------------------------------
# SkillModel.from_domain() exercises column definitions (covers 2131-2132)
# -------------------------------------------------------------------
@skill @from_domain @columns
Scenario: SkillModel.from_domain creates model with description and version
Given a skill definition dict with description and version
When the skill dict is converted to a SkillModel via from_domain
Then the SkillModel should have the correct description
And the SkillModel should have the correct version
And the SkillModel should have the correct namespace
And the SkillModel should have the correct short_name
@skill @from_domain @no_version
Scenario: SkillModel.from_domain handles skill without version
Given a skill definition dict without a version field
When the versionless skill dict is converted to a SkillModel via from_domain
Then the SkillModel version should be None
And the SkillModel description should still be set
@@ -0,0 +1,91 @@
Feature: Plan CLI cancel, revert, and lifecycle-list rich coverage
As a developer
I want to exercise the uncovered cancel_plan, revert_plan,
and lifecycle_list_plans rich table rendering paths
So that coverage for plan.py lines 1817, 1821-1822, 1845-1855, 1878-1888 is achieved
Background:
Given a CLI runner for cancel-revert coverage
And a mocked lifecycle service for cancel-revert coverage
# ---------- lifecycle-list rich table rendering (lines 1817, 1820-1822) ----------
Scenario: lifecycle-list in rich format renders created_at timestamp
Given the service returns plans with project links and timestamps for rich list
When I invoke lifecycle-list in rich format for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "Lifecycle Plans"
Scenario: lifecycle-list in rich format renders automation profile column
Given the service returns plans with an automation profile for rich list
When I invoke lifecycle-list in rich format for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "Lifecycle Plans"
Scenario: lifecycle-list in rich format renders multiple project links
Given the service returns a plan with three project links for rich list
When I invoke lifecycle-list in rich format for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "more"
Scenario: lifecycle-list in rich format renders invariant count
Given the service returns plans with invariants for rich list
When I invoke lifecycle-list in rich format for cancel-revert coverage
Then the cancel-revert command should succeed
# ---------- cancel_plan (lines 1845-1855) ----------
Scenario: cancel_plan succeeds in rich format without reason
Given the service can cancel a plan for cancel-revert coverage
When I invoke cancel in rich format without reason for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "Plan cancelled"
Scenario: cancel_plan succeeds in rich format with reason
Given the service can cancel a plan for cancel-revert coverage
When I invoke cancel in rich format with reason "no longer needed" for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "Plan cancelled"
And the cancel-revert output should contain "no longer needed"
Scenario: cancel_plan succeeds in JSON format without reason
Given the service can cancel a plan for cancel-revert coverage
When I invoke cancel with format "json" and no reason for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "plan_id"
And the cancel-revert output should not contain "cancel_reason"
Scenario: cancel_plan succeeds in JSON format with reason
Given the service can cancel a plan for cancel-revert coverage
When I invoke cancel with format "json" and reason "obsolete" for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "cancel_reason"
And the cancel-revert output should contain "obsolete"
# ---------- revert_plan (lines 1878-1888) ----------
Scenario: revert_plan succeeds in rich format with default phase
Given the service can revert a plan for cancel-revert coverage
When I invoke revert in rich format with default phase for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "Plan Reverted"
And the cancel-revert output should contain "Reversion count"
Scenario: revert_plan succeeds in rich format with reason
Given the service can revert a plan for cancel-revert coverage
When I invoke revert in rich format with reason "constraints too strict" for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "Plan Reverted"
Scenario: revert_plan succeeds in JSON format
Given the service can revert a plan for cancel-revert coverage
When I invoke revert with format "json" for cancel-revert coverage
Then the cancel-revert command should succeed
And the cancel-revert output should contain "plan_id"
And the cancel-revert output should contain "reversion_reason"
Scenario: revert_plan rejects invalid phase
Given the service can revert a plan for cancel-revert coverage
When I invoke revert with invalid phase "bogus" for cancel-revert coverage
Then the cancel-revert command should abort
And the cancel-revert output should contain "Invalid phase"
+158
View File
@@ -0,0 +1,158 @@
Feature: PlanExecutor coverage for uncovered lines
As a developer
I want to test uncovered paths in plan_executor.py
So that lines 144, 233-235, 273, 483, 490 are covered
# --- StrategizeStubActor._parse_steps empty input (line 144) ---
Scenario: Parse steps returns default when input is empty string
Given a StrategizeStubActor instance for coverage
When I call _parse_steps with an empty string
Then the parsed steps should be the default fallback
Scenario: Parse steps returns default when input is whitespace only
Given a StrategizeStubActor instance for coverage
When I call _parse_steps with whitespace only
Then the parsed steps should be the default fallback
# --- StrategizeStubActor.execute happy path ---
Scenario: Strategize stub actor produces decisions from definition of done
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with a valid plan and definition of done
Then the strategize result should contain decisions matching the steps
Scenario: Strategize stub actor handles None definition of done
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with a None definition of done
Then the strategize result should contain the default decision
Scenario: Strategize stub actor rejects empty plan_id
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with an empty plan_id
Then a plan executor ValidationError should be raised containing "plan_id"
Scenario: Strategize stub actor processes invariants
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with invariants
Then the strategize result should contain invariant records
Scenario: Strategize stub actor invokes stream callback
Given a StrategizeStubActor instance for coverage
When I execute the strategize stub with a stream callback
Then the stream callback should have been called with strategize events
# --- ExecuteStubActor happy path ---
Scenario: Execute stub actor produces a changeset
Given an ExecuteStubActor instance for coverage
When I execute the execute stub with decisions
Then the execute result should contain a changeset id
Scenario: Execute stub actor rejects empty plan_id
Given an ExecuteStubActor instance for coverage
When I execute the execute stub with an empty plan_id
Then a plan executor ValidationError should be raised containing "plan_id"
# --- PlanExecutor construction ---
Scenario: PlanExecutor rejects None lifecycle service
When I construct a PlanExecutor with None lifecycle service
Then a plan executor ValidationError should be raised containing "lifecycle_service"
Scenario: PlanExecutor has_runtime is False without execution context
Given a fresh mock lifecycle for plan executor coverage
When I construct a PlanExecutor without execution context
Then plan executor has_runtime should be False
Scenario: PlanExecutor changeset_store is None without execution context
Given a fresh mock lifecycle for plan executor coverage
When I construct a PlanExecutor without execution context
Then plan executor changeset_store should be None
# --- PlanExecutor.run_strategize happy path ---
Scenario: PlanExecutor run_strategize succeeds for a plan in Strategize phase
Given a mock lifecycle with a plan in Strategize phase for executor coverage
And a PlanExecutor using that lifecycle for coverage
When I call run_strategize on the PlanExecutor for coverage
Then the strategize result should be returned successfully
And the lifecycle should have called start_strategize for coverage
And the lifecycle should have called complete_strategize for coverage
# --- PlanExecutor.run_strategize exception path (line 273) ---
Scenario: PlanExecutor run_strategize handles exception and calls fail_strategize
Given a mock lifecycle with a plan in Strategize phase that will fail during execute
And a PlanExecutor using that failing lifecycle for coverage
When I call run_strategize expecting an exception for coverage
Then the lifecycle should have called fail_strategize for coverage
# --- PlanExecutor.run_strategize wrong phase ---
Scenario: PlanExecutor run_strategize rejects plan not in Strategize phase
Given a mock lifecycle with a plan in Execute phase for executor coverage
And a PlanExecutor using that lifecycle for coverage
When I call run_strategize expecting a PlanError for coverage
Then a PlanError should be raised about wrong phase for coverage
# --- PlanExecutor._guard_execute: decision_root_id is None (lines 233-235) ---
Scenario: Guard execute rejects plan with no decision root id
Given a mock lifecycle with an execute-phase plan missing decision_root_id
And a PlanExecutor using that lifecycle for coverage
When I call guard_execute for the plan for coverage
Then a PlanError should be raised about missing decision tree
Scenario: Guard execute rejects plan not in Execute phase
Given a mock lifecycle with a plan in Strategize phase for guard coverage
And a PlanExecutor using that lifecycle for coverage
When I call guard_execute expecting wrong phase for coverage
Then a PlanError should be raised about not in Execute phase
Scenario: Guard execute rejects plan not in queued state
Given a mock lifecycle with an execute-phase plan in processing state for coverage
And a PlanExecutor using that lifecycle for coverage
When I call guard_execute expecting wrong state for coverage
Then a PlanError should be raised about not queued
# --- PlanExecutor._run_execute_with_stub happy path ---
Scenario: Run execute with stub succeeds for a properly configured plan
Given a mock lifecycle with a fully configured execute-phase plan for coverage
And a PlanExecutor using that lifecycle without runtime for coverage
When I call run_execute on the PlanExecutor for coverage
Then the execute result should be returned successfully
And the lifecycle should have called start_execute for coverage
And the lifecycle should have called complete_execute for coverage
# --- PlanExecutor._run_execute_with_stub exception path (lines 483, 490) ---
Scenario: Run execute with stub handles exception and calls fail_execute
Given a mock lifecycle with an execute-phase plan that will fail during stub execute
And a PlanExecutor using that failing stub lifecycle for coverage
When I call run_execute expecting an exception from stub for coverage
Then the lifecycle should have called fail_execute for coverage
# --- PlanExecutor.run_execute validation ---
Scenario: PlanExecutor run_execute rejects empty plan_id
Given a fresh mock lifecycle for plan executor coverage
And a PlanExecutor using that lifecycle for coverage
When I call run_execute with an empty plan_id for coverage
Then a plan executor ValidationError should be raised containing "plan_id"
# --- PlanExecutor._build_decisions ---
Scenario: Build decisions creates decisions from plan definition of done
Given a mock lifecycle with a fully configured execute-phase plan for coverage
And a PlanExecutor using that lifecycle without runtime for coverage
When I call build_decisions for the plan for coverage
Then the decisions should match the plan definition of done steps
# --- PlanExecutor.execution_context property ---
Scenario: PlanExecutor execution_context returns None without context
Given a fresh mock lifecycle for plan executor coverage
When I construct a PlanExecutor without execution context
Then plan executor execution_context should be None
@@ -0,0 +1,24 @@
Feature: PlanLifecycleService persistence fallback coverage
Cover the uncovered persistence-layer fallback paths in
PlanLifecycleService.get_action() (lines 340-341) and
PlanLifecycleService.get_action_by_name() (lines 376-380).
These branches execute when:
- The service has a UnitOfWork (persisted mode)
- The requested action is NOT in the in-memory _actions cache
- The action IS found in the persistence layer via ctx.actions.get_by_name()
Background:
Given a plan lifecycle service with a mock unit of work
Scenario: get_action falls back to persistence layer when not in memory
Given an action "local/db-only-action" exists only in the persistence layer
When I call get_action with "local/db-only-action"
Then the returned action should have namespaced name "local/db-only-action"
And the action "local/db-only-action" should now be cached in memory
Scenario: get_action_by_name falls back to persistence layer when not in memory
Given an action "local/db-only-named" exists only in the persistence layer for name lookup
When I call get_action_by_name with "local/db-only-named"
Then the returned action from name lookup should have namespaced name "local/db-only-named"
And the action "local/db-only-named" should now be cached in memory after name lookup
+309
View File
@@ -0,0 +1,309 @@
Feature: REPL module full coverage
Comprehensive unit-level tests that exercise every function and branch
inside ``src/cleveragents/cli/commands/repl.py`` to bring its coverage
from 0% to near-100%.
# -----------------------------------------------------------------
# Constants
# -----------------------------------------------------------------
Scenario: Module constants are defined correctly
Given the repl module is imported
Then the constant _MULTILINE_CONTINUATION equals backslash
And the constant _LAST_COMMAND_TOKEN equals "!!"
And the _REPL_COMMANDS list is non-empty
And the _BUILTIN_COMMANDS list contains help exit and quit
# -----------------------------------------------------------------
# _get_prompt_context
# -----------------------------------------------------------------
Scenario: _get_prompt_context returns bare prompt when no env vars set
Given neither CLEVERAGENTS_PROJECT nor CLEVERAGENTS_PLAN is set
When I call _get_prompt_context
Then the prompt result is "agents> "
Scenario: _get_prompt_context returns project-only prompt
Given CLEVERAGENTS_PROJECT is set to "proj1" and CLEVERAGENTS_PLAN is unset
When I call _get_prompt_context
Then the prompt result is "agents (proj1)> "
Scenario: _get_prompt_context returns project+plan prompt
Given CLEVERAGENTS_PROJECT is set to "proj1" and CLEVERAGENTS_PLAN is set to "plan1"
When I call _get_prompt_context
Then the prompt result is "agents (proj1/plan1)> "
# -----------------------------------------------------------------
# _setup_history
# -----------------------------------------------------------------
Scenario: _setup_history loads existing history file
Given a temporary history file that exists
When I call _setup_history with that path
Then readline.read_history_file is called with the path
And readline.set_history_length is called with 10000
Scenario: _setup_history skips read when file does not exist
Given a temporary history path that does not exist
When I call _setup_history with that path
Then readline.read_history_file is not called
And readline.set_history_length is called with 10000
# -----------------------------------------------------------------
# _save_history
# -----------------------------------------------------------------
Scenario: _save_history writes the history file
Given a temporary history path for saving
When I call _save_history with that path
Then readline.write_history_file is called with the path
Scenario: _save_history suppresses OSError
Given readline.write_history_file will raise OSError
When I call _save_history with a dummy path
Then no exception is raised
# -----------------------------------------------------------------
# _setup_completer
# -----------------------------------------------------------------
Scenario: _setup_completer installs readline completer
When I call _setup_completer
Then readline.set_completer is called with a callable
And readline.set_completer_delims is called
And readline.parse_and_bind is called with tab complete
Scenario: The installed completer returns matching tokens
Given the completer is installed via _setup_completer
When I query the completer with text "ver" and state 0
Then the completer returns "version"
Scenario: The installed completer returns None for out-of-range state
Given the completer is installed via _setup_completer
When I query the completer with text "ver" and state 99
Then the completer returns None
# -----------------------------------------------------------------
# _read_multiline
# -----------------------------------------------------------------
Scenario: _read_multiline reads continuation lines ending normally
Given builtins.input will return "continued line"
When I call _read_multiline with initial line ending in backslash
Then the multiline result is "first continued line"
Scenario: _read_multiline handles chained continuations
Given builtins.input will return chained continuations
When I call _read_multiline with initial line ending in backslash
Then the multiline result is "first second third"
Scenario: _read_multiline handles EOFError in continuation
Given builtins.input will raise EOFError on continuation
When I call _read_multiline with initial line ending in backslash
Then the multiline result is "only"
Scenario: _read_multiline handles KeyboardInterrupt in continuation
Given builtins.input will raise KeyboardInterrupt on continuation
When I call _read_multiline with initial line ending in backslash
Then the multiline result is "only"
# -----------------------------------------------------------------
# _print_repl_help
# -----------------------------------------------------------------
Scenario: _print_repl_help runs without error
When I call _print_repl_help
Then no exception is raised from _print_repl_help
# -----------------------------------------------------------------
# dispatch_command
# -----------------------------------------------------------------
Scenario: dispatch_command returns 0 on success
Given the root Typer app is mocked to succeed
When I call dispatch_command with args "version"
Then the dispatch return code is 0
Scenario: dispatch_command returns code from SystemExit
Given the root Typer app raises SystemExit with code 42
When I call dispatch_command with args "bad"
Then the dispatch return code is 42
Scenario: dispatch_command returns 0 for SystemExit with None code
Given the root Typer app raises SystemExit with None code
When I call dispatch_command with args "something"
Then the dispatch return code is 0
Scenario: dispatch_command returns 130 on KeyboardInterrupt
Given the root Typer app raises KeyboardInterrupt
When I call dispatch_command with args "slow"
Then the dispatch return code is 130
Scenario: dispatch_command returns 1 and prints error on generic Exception
Given the root Typer app raises RuntimeError boom
When I call dispatch_command with args "fail"
Then the dispatch return code is 1
And the dispatch error output contains "boom"
# -----------------------------------------------------------------
# run_repl — exit commands
# -----------------------------------------------------------------
Scenario: run_repl exits on :exit command
Given the REPL input sequence is
| line |
| :exit |
When I run the REPL loop
Then the REPL loop exit code is 0
Scenario: run_repl exits on :quit command
Given the REPL input sequence is
| line |
| :quit |
When I run the REPL loop
Then the REPL loop exit code is 0
# -----------------------------------------------------------------
# run_repl — help
# -----------------------------------------------------------------
Scenario: run_repl handles :help and continues
Given the REPL input sequence is
| line |
| :help |
| :exit |
When I run the REPL loop
Then the REPL loop output contains "Built-in commands"
# -----------------------------------------------------------------
# run_repl — !! repeat
# -----------------------------------------------------------------
Scenario: run_repl repeats last command with !!
Given the REPL input sequence is
| line |
| version |
| !! |
| :exit |
And dispatch_command is mocked for run_repl
When I run the REPL loop
Then dispatch_command was called at least 2 times with "version"
Scenario: run_repl warns when !! has no previous command
Given the REPL input sequence is
| line |
| !! |
| :exit |
When I run the REPL loop
Then the REPL loop output contains "No previous command"
# -----------------------------------------------------------------
# run_repl — empty and whitespace input
# -----------------------------------------------------------------
Scenario: run_repl skips empty lines
Given the REPL input sequence is
| line |
| |
| :exit |
When I run the REPL loop
Then the REPL loop exit code is 0
# -----------------------------------------------------------------
# run_repl — multiline
# -----------------------------------------------------------------
Scenario: run_repl handles multiline input ending with backslash
Given the REPL input sequence for multiline is "version \" then "" then ":exit"
And dispatch_command is mocked for run_repl
When I run the REPL loop
Then the REPL loop exit code is 0
# -----------------------------------------------------------------
# run_repl — KeyboardInterrupt / EOFError
# -----------------------------------------------------------------
Scenario: run_repl handles KeyboardInterrupt and continues
Given the REPL input sequence for interrupt then exit
When I run the REPL loop
Then the REPL loop exit code is 0
Scenario: run_repl handles EOFError for clean exit
Given the REPL input sequence is empty
When I run the REPL loop
Then the REPL loop exit code is 0
# -----------------------------------------------------------------
# run_repl — shlex parse error
# -----------------------------------------------------------------
Scenario: run_repl handles shlex parse error gracefully
Given the REPL input sequence has an unclosed quote then exit
When I run the REPL loop
Then the REPL loop output contains "Parse error"
# -----------------------------------------------------------------
# run_repl — history on/off
# -----------------------------------------------------------------
Scenario: run_repl with history enabled calls setup and save
Given the REPL input sequence is
| line |
| :exit |
When I run the REPL loop with history enabled
Then _setup_history was called
And _save_history was called
Scenario: run_repl with no_history skips setup and save
Given the REPL input sequence is
| line |
| :exit |
When I run the REPL loop with no_history
Then _setup_history was not called
And _save_history was not called
# -----------------------------------------------------------------
# run_repl — dispatch a real-ish command
# -----------------------------------------------------------------
Scenario: run_repl dispatches tokenised command
Given the REPL input sequence is
| line |
| info |
| :exit |
And dispatch_command is mocked for run_repl
When I run the REPL loop
Then dispatch_command was called with args "info"
# -----------------------------------------------------------------
# repl_callback
# -----------------------------------------------------------------
Scenario: repl_callback starts REPL when stdout is a tty
Given sys.stdout.isatty returns True
And run_repl is mocked to return 0
When I call repl_callback
Then run_repl was invoked
And repl_callback raises SystemExit with code 0
Scenario: repl_callback starts REPL when CLEVERAGENTS_FORCE_REPL is set
Given sys.stdout.isatty returns False
And CLEVERAGENTS_FORCE_REPL is set in the environment
And run_repl is mocked to return 0
When I call repl_callback
Then run_repl was invoked
Scenario: repl_callback does nothing when not a tty and no force env
Given sys.stdout.isatty returns False
And CLEVERAGENTS_FORCE_REPL is not set
When I call repl_callback
Then run_repl was not invoked
# -----------------------------------------------------------------
# Completer edge case: empty text matches all tokens
# -----------------------------------------------------------------
Scenario: The installed completer returns first token for empty text
Given the completer is installed via _setup_completer
When I query the completer with empty text and state 0
Then the completer returns a non-None value
@@ -0,0 +1,151 @@
Feature: ResourceHandlerService full coverage
As a plan execution engine
I want the ResourceHandlerService to resolve bindings into BoundResources
So that tools receive sandbox-backed resource paths during execution
Background:
Given a mock sandbox manager for rhs
And a mock resource lookup for rhs
And a mock type lookup for rhs
And a ResourceHandlerService under test
# --- resolve_binding: deferred / missing resource_id ---
Scenario: Resolving a deferred binding raises ValueError
Given an rhs binding that is deferred with slot name "repo"
When I resolve the rhs single binding
Then an rhs ValueError is raised with message containing "Cannot resolve deferred binding for slot 'repo'"
Scenario: Resolving a binding with no resource_id raises ValueError
Given an rhs binding with no resource_id and slot name "data"
When I resolve the rhs single binding
Then an rhs ValueError is raised with message containing "Cannot resolve deferred binding for slot 'data'"
# --- resolve_binding: successful resolution ---
Scenario: Resolving a valid binding returns a BoundResource
Given an rhs non-deferred binding with slot "repo" and resource_id "RES001"
And rhs resource "RES001" typed "git-checkout" located at "/tmp/repo" with strategy "none"
And an rhs type spec "git-checkout" with no handler and sandbox strategy "git_worktree"
And the rhs sandbox manager returns a sandbox with path "/sandbox/abc"
When I resolve the rhs single binding with plan_id "plan-1" and access "read_write"
Then the rhs result is a BoundResource with slot "repo" and sandbox_path "/sandbox/abc"
And the rhs BoundResource has resource_id "RES001" and access "read_write"
# --- _resolve_handler_for_type: type_spec has a handler ---
Scenario: Handler is resolved from type_spec handler reference
Given an rhs non-deferred binding with slot "code" and resource_id "RES002"
And rhs resource "RES002" typed "custom/py" located at "/src" with strategy "none"
And an rhs type spec "custom/py" with handler "some.module:Handler" and sandbox strategy "copy_on_write"
And the rhs resolve_handler function returns a mock handler
And the rhs mock handler resolves to a BoundResource with sandbox_path "/sandbox/custom"
When I resolve the rhs single binding with plan_id "plan-2" and access "read_only"
Then the rhs result is a BoundResource with slot "code" and sandbox_path "/sandbox/custom"
# --- _resolve_handler_for_type: handler resolution fails, falls back ---
Scenario: Handler resolution failure falls back to DefaultHandler
Given an rhs non-deferred binding with slot "files" and resource_id "RES003"
And rhs resource "RES003" typed "custom/broken" located at "/data" with strategy "copy_on_write"
And an rhs type spec "custom/broken" with handler "bad.module:Missing" and sandbox strategy "copy_on_write"
And the rhs resolve_handler function raises HandlerResolutionError
And the rhs sandbox manager returns a sandbox with path "/sandbox/fallback"
When I resolve the rhs single binding with plan_id "plan-3" and access "read_only"
Then the rhs result is a BoundResource with slot "files" and sandbox_path "/sandbox/fallback"
# --- _resolve_handler_for_type: no handler on type_spec ---
Scenario: No handler on type_spec uses DefaultHandler directly
Given an rhs non-deferred binding with slot "dir" and resource_id "RES004"
And rhs resource "RES004" typed "fs-directory" located at "/home/user" with strategy "copy_on_write"
And an rhs type spec "fs-directory" with no handler and sandbox strategy "copy_on_write"
And the rhs sandbox manager returns a sandbox with path "/sandbox/dir"
When I resolve the rhs single binding with plan_id "plan-4" and access "read_only"
Then the rhs result is a BoundResource with slot "dir" and sandbox_path "/sandbox/dir"
# --- resolve_bindings: mixed deferred and non-deferred ---
Scenario: resolve_bindings skips deferred bindings and resolves the rest
Given an rhs binding list with:
| slot_name | resource_id | deferred |
| repo | RES010 | false |
| param | | true |
| config | RES011 | false |
And rhs resource "RES010" typed "git-checkout" located at "/repo" with strategy "none"
And rhs resource "RES011" typed "fs-directory" located at "/config" with strategy "none"
And an rhs type spec "git-checkout" with no handler and sandbox strategy "git_worktree"
And an rhs type spec "fs-directory" with no handler and sandbox strategy "copy_on_write"
And the rhs sandbox manager returns a sandbox with path "/sandbox/multi"
When I resolve all rhs bindings with plan_id "plan-5" and access "read_only"
Then the rhs result dict has keys "repo" and "config"
And the rhs result dict does not contain key "param"
# --- resolve_bindings: failure propagation ---
Scenario: resolve_bindings raises when a non-deferred binding fails
Given an rhs binding list with:
| slot_name | resource_id | deferred |
| broken | RES_MISSING | false |
And the rhs resource lookup raises NotFoundError for "RES_MISSING"
When I resolve all rhs bindings expecting an error
Then an rhs NotFoundError is raised
# --- resolve_bindings: all deferred returns empty dict ---
Scenario: resolve_bindings with all deferred bindings returns empty dict
Given an rhs binding list with:
| slot_name | resource_id | deferred |
| a | | true |
| b | | true |
When I resolve all rhs bindings with plan_id "plan-6" and access "read_only"
Then the rhs result dict is empty
# --- resolve_resource: direct resolution ---
Scenario: resolve_resource resolves a resource directly without a binding
Given rhs resource "RES020" typed "git-checkout" located at "/direct/repo" with strategy "none"
And an rhs type spec "git-checkout" with no handler and sandbox strategy "git_worktree"
And the rhs sandbox manager returns a sandbox with path "/sandbox/direct"
When I resolve the rhs resource directly with plan_id "plan-7" slot "src" and access "read_write"
Then the rhs result is a BoundResource with slot "src" and sandbox_path "/sandbox/direct"
And the rhs BoundResource has resource_id "RES020" and access "read_write"
# --- _DefaultHandler: resource sandbox_strategy overrides type ---
Scenario: DefaultHandler uses resource sandbox_strategy when present
Given an rhs non-deferred binding with slot "db" and resource_id "RES030"
And rhs resource "RES030" typed "custom/db" located at "/data/db" with strategy "snapshot"
And an rhs type spec "custom/db" with no handler and sandbox strategy "copy_on_write"
And the rhs sandbox manager returns a sandbox with path "/sandbox/snap"
When I resolve the rhs single binding with plan_id "plan-8" and access "read_only"
Then the rhs sandbox manager was called with strategy "snapshot"
# --- _DefaultHandler: falls back to type_spec sandbox_strategy ---
Scenario: DefaultHandler falls back to type_spec sandbox_strategy when resource has none
Given an rhs non-deferred binding with slot "fs" and resource_id "RES031"
And rhs resource "RES031" typed "fs-directory" located at "/mnt/share" with strategy "none"
And an rhs type spec "fs-directory" with no handler and sandbox strategy "copy_on_write"
And the rhs sandbox manager returns a sandbox with path "/sandbox/cow"
When I resolve the rhs single binding with plan_id "plan-9" and access "read_only"
Then the rhs sandbox manager was called with strategy "copy_on_write"
# --- _DefaultHandler: no location error ---
Scenario: DefaultHandler raises ValueError when resource has no location
Given an rhs non-deferred binding with slot "empty" and resource_id "RES040"
And rhs resource "RES040" typed "fs-directory" with no location
And an rhs type spec "fs-directory" with no handler and sandbox strategy "copy_on_write"
When I resolve the rhs single binding
Then an rhs ValueError is raised with message containing "has no location"
# --- _DefaultHandler: sandbox context is None ---
Scenario: DefaultHandler raises RuntimeError when sandbox has no context
Given an rhs non-deferred binding with slot "ctx" and resource_id "RES050"
And rhs resource "RES050" typed "fs-directory" located at "/tmp/ctx" with strategy "none"
And an rhs type spec "fs-directory" with no handler and sandbox strategy "copy_on_write"
And the rhs sandbox manager returns a sandbox with no context
When I resolve the rhs single binding
Then an rhs RuntimeError is raised with message containing "has no context"
+33
View File
@@ -0,0 +1,33 @@
Feature: Skill CLI uncovered lines
Cover uncovered lines in src/cleveragents/cli/commands/skill.py:
273, 343-344, 353, 393, 589-590, 593-594.
Scenario: Show skill with unregistered include
Given a clean skill service
And a skill with an include pointing to a non-registered skill
When I run skill show for the skill with unregistered include
Then the show output contains the not registered marker
Scenario: Show skill when capability summary raises ValueError
Given a clean skill service
And a registered skill for capability error testing
When I run skill show with capability summary raising ValueError
Then the show output contains OK and no capability panel
Scenario: Show skill with dependent actors
Given a clean skill service
And a registered skill for dependent actors testing
When I run skill show with dependents returning actors
Then the show output contains the actors reference line
Scenario: Tools command with agent_skill entries
Given a clean skill service
And a registered skill with agent_skill sources
When I run skill tools for the agent_skill skill
Then the tools output contains agent_skill source type
Scenario: Remove skill with dependent actors
Given a clean skill service
And a registered skill for removal with actor dependents
When I run skill remove with dependents returning actors
Then the remove output contains the actor dependency warning
@@ -0,0 +1,803 @@
"""Behave steps for complete coverage of config_service.py."""
from __future__ import annotations
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import patch
import tomlkit
from behave import given, then, when
from cleveragents.application.services.config_service import (
_REGISTRY,
ConfigEntry,
ConfigLevel,
ConfigService,
ResolvedValue,
_env_name,
_register,
)
# ---------------------------------------------------------------------------
# ConfigLevel enum
# ---------------------------------------------------------------------------
@then("ConfigLevel should have values CLI_FLAG ENV_VAR PROJECT GLOBAL DEFAULT")
def step_config_level_enum(context: Any) -> None:
assert ConfigLevel.CLI_FLAG.value == "cli_flag"
assert ConfigLevel.ENV_VAR.value == "env_var"
assert ConfigLevel.PROJECT.value == "project"
assert ConfigLevel.GLOBAL.value == "global"
assert ConfigLevel.DEFAULT.value == "default"
assert len(ConfigLevel) == 5
# ---------------------------------------------------------------------------
# ConfigEntry dataclass
# ---------------------------------------------------------------------------
@given('a ConfigEntry with key "test.key" and type str')
def step_create_config_entry(context: Any) -> None:
context.entry = ConfigEntry(
key="test.key",
python_type=str,
default="default_val",
env_var="CLEVERAGENTS_TEST_KEY",
project_scopable=True,
description="A test key.",
section="test",
)
@then("the ConfigEntry fields should match the provided values")
def step_verify_config_entry(context: Any) -> None:
e = context.entry
assert e.key == "test.key"
assert e.python_type is str
assert e.default == "default_val"
assert e.env_var == "CLEVERAGENTS_TEST_KEY"
assert e.project_scopable is True
assert e.description == "A test key."
assert e.section == "test"
# ---------------------------------------------------------------------------
# ResolvedValue dataclass
# ---------------------------------------------------------------------------
@given('a ResolvedValue with key "test.key" and source DEFAULT')
def step_create_resolved_value(context: Any) -> None:
context.resolved = ResolvedValue(
key="test.key",
value="default_val",
source=ConfigLevel.DEFAULT,
)
@then("the ResolvedValue fields should match including an empty chain by default")
def step_verify_resolved_value(context: Any) -> None:
rv = context.resolved
assert rv.key == "test.key"
assert rv.value == "default_val"
assert rv.source == ConfigLevel.DEFAULT
assert rv.chain == []
# ---------------------------------------------------------------------------
# _env_name helper
# ---------------------------------------------------------------------------
@when('I call _env_name with section "core" and key "log_level"')
def step_call_env_name(context: Any) -> None:
context.env_name_result = _env_name("core", "log_level")
@then('the env name should be "CLEVERAGENTS_CORE_LOG_LEVEL"')
def step_verify_env_name(context: Any) -> None:
assert context.env_name_result == "CLEVERAGENTS_CORE_LOG_LEVEL"
# ---------------------------------------------------------------------------
# _register
# ---------------------------------------------------------------------------
@when('I register a custom key "custom.test_key" with type int and default 42')
def step_register_custom_key(context: Any) -> None:
_register("custom", "test_key", int, 42, description="Custom test key.")
@then('the registry should contain "custom.test_key" with default 42')
def step_verify_custom_key(context: Any) -> None:
entry = _REGISTRY.get("custom.test_key")
assert entry is not None
assert entry.default == 42
assert entry.python_type is int
assert entry.env_var == "CLEVERAGENTS_CUSTOM_TEST_KEY"
@when('I register a key "custom.explicit_env" with explicit env_var "MY_CUSTOM_VAR"')
def step_register_explicit_env(context: Any) -> None:
_register(
"custom",
"explicit_env",
str,
"",
env_var="MY_CUSTOM_VAR",
description="Explicit env var.",
)
@then('the entry "custom.explicit_env" should have env_var "MY_CUSTOM_VAR"')
def step_verify_explicit_env(context: Any) -> None:
entry = _REGISTRY.get("custom.explicit_env")
assert entry is not None
assert entry.env_var == "MY_CUSTOM_VAR"
# ---------------------------------------------------------------------------
# _build_catalog
# ---------------------------------------------------------------------------
@then(
"the registry should contain keys from sections core plan provider sandbox context index"
)
def step_verify_catalog_sections(context: Any) -> None:
sections_found: set[str] = set()
for entry in _REGISTRY.values():
sections_found.add(entry.section)
for expected in ("core", "plan", "provider", "sandbox", "context", "index"):
assert expected in sections_found, f"Section '{expected}' not found in registry"
@then("the registry should contain at least 20 keys")
def step_verify_catalog_count(context: Any) -> None:
# _build_catalog registers ~24 keys from the source
assert len(_REGISTRY) >= 20, f"Expected >=20 keys, got {len(_REGISTRY)}"
# ---------------------------------------------------------------------------
# ConfigService instantiation
# ---------------------------------------------------------------------------
@given("a temporary directory for config service")
def step_temp_dir(context: Any) -> None:
context.temp_dir = Path(tempfile.mkdtemp())
context.config_dir = context.temp_dir / "config"
context.config_path = context.config_dir / "config.toml"
context.svc = None
context.result = None
context.error = None
@when("I create a ConfigService with custom paths")
def step_create_service_custom(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
context.svc = ConfigService(
config_dir=context.config_dir,
config_path=context.config_path,
)
@then("the service should use the custom config directory and path")
def step_verify_custom_paths(context: Any) -> None:
assert context.svc._config_dir == context.config_dir
assert context.svc._config_path == context.config_path
@when("I create a ConfigService with default paths")
def step_create_service_default(context: Any) -> None:
context.svc = ConfigService()
@then('the service config path should end with ".cleveragents/config.toml"')
def step_verify_default_path(context: Any) -> None:
path_str = str(context.svc._config_path)
assert path_str.endswith(".cleveragents/config.toml"), f"Got: {path_str}"
# ---------------------------------------------------------------------------
# registry(), get_entry(), registered_keys()
# ---------------------------------------------------------------------------
@when("I call ConfigService.registry")
def step_call_registry(context: Any) -> None:
context.result = ConfigService.registry()
@then("it should return a dict with the same keys as the internal registry")
def step_verify_registry_copy(context: Any) -> None:
assert isinstance(context.result, dict)
assert set(context.result.keys()) == set(_REGISTRY.keys())
# Verify it's a copy, not the same object
assert context.result is not _REGISTRY
@when('I call ConfigService.get_entry with "core.log_level"')
def step_call_get_entry_known(context: Any) -> None:
context.result = ConfigService.get_entry("core.log_level")
@then('it should return a ConfigEntry with key "core.log_level"')
def step_verify_get_entry_known(context: Any) -> None:
assert isinstance(context.result, ConfigEntry)
assert context.result.key == "core.log_level"
@when('I call ConfigService.get_entry with "nonexistent.key"')
def step_call_get_entry_unknown(context: Any) -> None:
context.result = ConfigService.get_entry("nonexistent.key")
@then("it should return None")
def step_verify_get_entry_none(context: Any) -> None:
assert context.result is None
@when("I call ConfigService.registered_keys")
def step_call_registered_keys(context: Any) -> None:
context.result = ConfigService.registered_keys()
@then('it should return a sorted list containing "core.log_level"')
def step_verify_registered_keys(context: Any) -> None:
assert isinstance(context.result, list)
assert context.result == sorted(context.result)
assert "core.log_level" in context.result
# ---------------------------------------------------------------------------
# read_config
# ---------------------------------------------------------------------------
@when("I create a ConfigService pointing to a missing config file")
def step_service_missing_file(context: Any) -> None:
context.svc = ConfigService(
config_dir=context.config_dir,
config_path=context.config_dir / "nonexistent.toml",
)
@then("read_config should return an empty dict")
def step_verify_read_empty(context: Any) -> None:
data = context.svc.read_config()
assert data == {}
@given('a TOML config file with key "core.log_level" set to "DEBUG"')
def step_write_toml_log_level(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
doc["core.log_level"] = "DEBUG"
with open(context.config_path, "w") as fh:
tomlkit.dump(doc, fh)
@when("I create a ConfigService pointing to that TOML file")
def step_service_existing_file(context: Any) -> None:
context.svc = ConfigService(
config_dir=context.config_dir,
config_path=context.config_path,
)
@then('read_config should return a dict with "core.log_level" equal to "DEBUG"')
def step_verify_read_debug(context: Any) -> None:
data = context.svc.read_config()
assert data.get("core.log_level") == "DEBUG"
# ---------------------------------------------------------------------------
# write_config
# ---------------------------------------------------------------------------
@when("I create a ConfigService with a nested non-existent config directory")
def step_service_nested_dir(context: Any) -> None:
nested = context.temp_dir / "a" / "b" / "c"
context.config_dir = nested
context.config_path = nested / "config.toml"
context.svc = ConfigService(
config_dir=nested,
config_path=context.config_path,
)
@when('I call write_config with key "greeting" value "hello"')
def step_call_write_config(context: Any) -> None:
context.svc.write_config({"greeting": "hello"})
@then('the TOML file should exist and contain key "greeting" with value "hello"')
def step_verify_written_toml(context: Any) -> None:
assert context.config_path.exists()
data = context.svc.read_config()
assert data["greeting"] == "hello"
@given('a TOML config file with key "existing" set to "value1"')
def step_write_toml_existing(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
doc["existing"] = "value1"
with open(context.config_path, "w") as fh:
tomlkit.dump(doc, fh)
@when('I call write_config with key "new_key" value "value2"')
def step_call_write_config_merge(context: Any) -> None:
context.svc.write_config({"new_key": "value2"})
@then('read_config should return a dict containing both "existing" and "new_key"')
def step_verify_merged_toml(context: Any) -> None:
data = context.svc.read_config()
assert data["existing"] == "value1"
assert data["new_key"] == "value2"
# ---------------------------------------------------------------------------
# set_value
# ---------------------------------------------------------------------------
@when('I call set_value with key "mykey" and value "myval"')
def step_call_set_value(context: Any) -> None:
context.svc.set_value("mykey", "myval")
@then('read_config should return a dict with "mykey" equal to "myval"')
def step_verify_set_value(context: Any) -> None:
data = context.svc.read_config()
assert data["mykey"] == "myval"
# ---------------------------------------------------------------------------
# validate_key
# ---------------------------------------------------------------------------
@when('I call validate_key with "core.log_level"')
def step_call_validate_key_known(context: Any) -> None:
try:
context.result = ConfigService.validate_key("core.log_level")
context.error = None
except Exception as exc:
context.error = exc
@then('it should return the ConfigEntry for "core.log_level" without error')
def step_verify_validate_key_ok(context: Any) -> None:
assert context.error is None
assert isinstance(context.result, ConfigEntry)
assert context.result.key == "core.log_level"
@when('I call validate_key with "totally.unknown"')
def step_call_validate_key_unknown(context: Any) -> None:
try:
context.result = ConfigService.validate_key("totally.unknown")
context.error = None
except ValueError as exc:
context.error = exc
@then('a ValueError should be raised mentioning "Unknown configuration key"')
def step_verify_validate_key_error(context: Any) -> None:
assert context.error is not None
assert isinstance(context.error, ValueError)
assert "Unknown configuration key" in str(context.error)
# ---------------------------------------------------------------------------
# validate_type
# ---------------------------------------------------------------------------
@when('I call validate_type with key "core.log_level" and value "INFO"')
def step_validate_type_same(context: Any) -> None:
context.result = ConfigService.validate_type("core.log_level", "INFO")
@then('it should return "INFO" unchanged')
def step_verify_type_same(context: Any) -> None:
assert context.result == "INFO"
assert isinstance(context.result, str)
@when('I call validate_type with key "core.server_port" and string value "9090"')
def step_validate_type_str_to_int(context: Any) -> None:
context.result = ConfigService.validate_type("core.server_port", "9090")
@then("it should return the integer 9090")
def step_verify_int_coercion(context: Any) -> None:
assert context.result == 9090
assert isinstance(context.result, int)
@when('I call validate_type with key "provider.temperature" and string value "0.5"')
def step_validate_type_str_to_float(context: Any) -> None:
context.result = ConfigService.validate_type("provider.temperature", "0.5")
@then("it should return the float 0.5")
def step_verify_float_coercion(context: Any) -> None:
assert context.result == 0.5
assert isinstance(context.result, float)
@when('I call validate_type with key "core.log_level" and integer value 123')
def step_validate_type_int_to_str(context: Any) -> None:
context.result = ConfigService.validate_type("core.log_level", 123)
@then('it should return the string "123"')
def step_verify_str_coercion(context: Any) -> None:
assert context.result == "123"
assert isinstance(context.result, str)
@when('I call validate_type with key "core.debug_enabled" and string value "true"')
def step_validate_type_bool_true(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "true")
@then("it should return boolean True")
def step_verify_bool_true(context: Any) -> None:
assert context.result is True
@when('I call validate_type with key "core.debug_enabled" and string value "1"')
def step_validate_type_bool_one(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "1")
@when('I call validate_type with key "core.debug_enabled" and string value "yes"')
def step_validate_type_bool_yes(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "yes")
@when('I call validate_type with key "core.debug_enabled" and string value "false"')
def step_validate_type_bool_false(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "false")
@then("it should return boolean False")
def step_verify_bool_false(context: Any) -> None:
assert context.result is False
@when('I call validate_type with key "core.debug_enabled" and string value "0"')
def step_validate_type_bool_zero(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "0")
@when('I call validate_type with key "core.debug_enabled" and string value "no"')
def step_validate_type_bool_no(context: Any) -> None:
context.result = ConfigService.validate_type("core.debug_enabled", "no")
@when('I call validate_type with key "core.debug_enabled" and string value "maybe"')
def step_validate_type_bool_invalid(context: Any) -> None:
try:
context.result = ConfigService.validate_type("core.debug_enabled", "maybe")
context.error = None
except TypeError as exc:
context.error = exc
@then('a TypeError should be raised mentioning "Cannot convert"')
def step_verify_bool_invalid_error(context: Any) -> None:
assert context.error is not None
assert isinstance(context.error, TypeError)
assert "Cannot convert" in str(context.error)
@when(
'I call validate_type with key "core.server_port" and string value "not_a_number"'
)
def step_validate_type_int_invalid(context: Any) -> None:
try:
context.result = ConfigService.validate_type("core.server_port", "not_a_number")
context.error = None
except TypeError as exc:
context.error = exc
@then('a TypeError should be raised mentioning "Type mismatch"')
def step_verify_type_mismatch_error(context: Any) -> None:
assert context.error is not None
assert isinstance(context.error, TypeError)
assert "Type mismatch" in str(context.error)
@when('I call validate_type with key "core.debug_enabled" and a list value')
def step_validate_type_bool_list(context: Any) -> None:
try:
context.result = ConfigService.validate_type("core.debug_enabled", [1, 2, 3])
context.error = None
except TypeError as exc:
context.error = exc
@when('I call validate_type with unknown key "fake.unknown" and value "x"')
def step_validate_type_unknown_key(context: Any) -> None:
try:
context.result = ConfigService.validate_type("fake.unknown", "x")
context.error = None
except ValueError as exc:
context.error = exc
@then('a ValueError should be raised mentioning "Cannot validate type for unknown key"')
def step_verify_validate_type_unknown_key_error(context: Any) -> None:
assert context.error is not None
assert isinstance(context.error, ValueError)
assert "Cannot validate type for unknown key" in str(context.error)
# ---------------------------------------------------------------------------
# resolve - five-level chain
# ---------------------------------------------------------------------------
@given("a ConfigService with empty config for resolve tests")
def step_service_empty_for_resolve(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
context.svc = ConfigService(
config_dir=context.config_dir,
config_path=context.config_path,
)
@when('I resolve key "core.log_level" with no overrides')
def step_resolve_no_overrides(context: Any) -> None:
context.result = context.svc.resolve("core.log_level")
@then('the resolved value should be "INFO" from source DEFAULT')
def step_verify_resolve_default(context: Any) -> None:
assert context.result.value == "INFO"
assert context.result.source == ConfigLevel.DEFAULT
@when('I resolve key "core.log_level" with cli_value "TRACE"')
def step_resolve_cli(context: Any) -> None:
context.result = context.svc.resolve("core.log_level", cli_value="TRACE")
@then('the resolved value should be "TRACE" from source CLI_FLAG')
def step_verify_resolve_cli(context: Any) -> None:
assert context.result.value == "TRACE"
assert context.result.source == ConfigLevel.CLI_FLAG
# Clean up env patch if present (from CLI-over-env scenario)
if hasattr(context, "_env_patch"):
context._env_patch.stop()
@given('the env var "{name}" is injected with "{value}"')
def step_inject_env_var(context: Any, name: str, value: str) -> None:
context._env_patch = patch.dict("os.environ", {name: value})
context._env_patch.start()
@then('the resolved value should be "WARNING" from source ENV_VAR')
def step_verify_resolve_env(context: Any) -> None:
assert context.result.value == "WARNING"
assert context.result.source == ConfigLevel.ENV_VAR
# Clean up the patch
if hasattr(context, "_env_patch"):
context._env_patch.stop()
@given(
'a ConfigService with project-scoped config for key "core.log_level" value "PROJECT_DEBUG" under project "myproj"'
)
def step_service_project_scoped(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
project_table = tomlkit.table()
myproj_table = tomlkit.table()
myproj_table["core.log_level"] = "PROJECT_DEBUG"
project_table["myproj"] = myproj_table
doc["project"] = project_table
with open(context.config_path, "w") as fh:
tomlkit.dump(doc, fh)
context.svc = ConfigService(
config_dir=context.config_dir,
config_path=context.config_path,
)
@when('I resolve key "core.log_level" with project_name "myproj"')
def step_resolve_project(context: Any) -> None:
context.result = context.svc.resolve("core.log_level", project_name="myproj")
@then('the resolved value should be "PROJECT_DEBUG" from source PROJECT')
def step_verify_resolve_project(context: Any) -> None:
assert context.result.value == "PROJECT_DEBUG"
assert context.result.source == ConfigLevel.PROJECT
@given('a ConfigService with global config key "core.log_level" set to "GLOBAL_WARN"')
def step_service_global_config(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
doc["core.log_level"] = "GLOBAL_WARN"
with open(context.config_path, "w") as fh:
tomlkit.dump(doc, fh)
context.svc = ConfigService(
config_dir=context.config_dir,
config_path=context.config_path,
)
@then('the resolved value should be "GLOBAL_WARN" from source GLOBAL')
def step_verify_resolve_global(context: Any) -> None:
assert context.result.value == "GLOBAL_WARN"
assert context.result.source == ConfigLevel.GLOBAL
@when('I resolve key "core.log_level" with verbose True and no overrides')
def step_resolve_verbose(context: Any) -> None:
context.result = context.svc.resolve("core.log_level", verbose=True)
@then("the resolved chain should have 5 entries covering all levels")
def step_verify_verbose_chain_length(context: Any) -> None:
chain = context.result.chain
assert len(chain) == 5, f"Expected 5 chain entries, got {len(chain)}"
sources = [entry["source"] for entry in chain]
assert sources == [
ConfigLevel.CLI_FLAG.value,
ConfigLevel.ENV_VAR.value,
ConfigLevel.PROJECT.value,
ConfigLevel.GLOBAL.value,
ConfigLevel.DEFAULT.value,
]
@then(
'the verbose chain ENV_VAR entry should include env_name "CLEVERAGENTS_CORE_LOG_LEVEL"'
)
def step_verify_verbose_env_name(context: Any) -> None:
chain = context.result.chain
env_entry = next(e for e in chain if e["source"] == ConfigLevel.ENV_VAR.value)
assert env_entry["env_name"] == "CLEVERAGENTS_CORE_LOG_LEVEL"
@then("the verbose chain GLOBAL entry should include the config file path")
def step_verify_verbose_global_path(context: Any) -> None:
chain = context.result.chain
global_entry = next(e for e in chain if e["source"] == ConfigLevel.GLOBAL.value)
assert "path" in global_entry
assert str(context.config_path) in global_entry["path"]
# -- project scope skip for non-scopable keys --
@given(
'a ConfigService with project-scoped config for key "core.database_url" value "pg://proj" under project "myproj"'
)
def step_service_project_scoped_nonscopable(context: Any) -> None:
context.config_dir.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
project_table = tomlkit.table()
myproj_table = tomlkit.table()
myproj_table["core.database_url"] = "pg://proj"
project_table["myproj"] = myproj_table
doc["project"] = project_table
with open(context.config_path, "w") as fh:
tomlkit.dump(doc, fh)
context.svc = ConfigService(
config_dir=context.config_dir,
config_path=context.config_path,
)
@when('I resolve key "core.log_level" with project_name "myproj" and verbose True')
def step_resolve_project_verbose(context: Any) -> None:
context.result = context.svc.resolve(
"core.log_level", project_name="myproj", verbose=True
)
@then('the verbose chain PROJECT entry should have value "PROJECT_DEBUG"')
def step_verify_verbose_project_value(context: Any) -> None:
chain = context.result.chain
proj_entry = next(e for e in chain if e["source"] == ConfigLevel.PROJECT.value)
assert proj_entry["value"] == "PROJECT_DEBUG"
@then('the verbose chain GLOBAL entry should have value "GLOBAL_WARN"')
def step_verify_verbose_global_value(context: Any) -> None:
chain = context.result.chain
global_entry = next(e for e in chain if e["source"] == ConfigLevel.GLOBAL.value)
assert global_entry["value"] == "GLOBAL_WARN"
@when('I resolve key "core.database_url" with project_name "myproj"')
def step_resolve_nonscopable(context: Any) -> None:
context.result = context.svc.resolve("core.database_url", project_name="myproj")
@then('the resolved value should not be "pg://proj"')
def step_verify_not_project_value(context: Any) -> None:
assert context.result.value != "pg://proj"
@then("the resolved source should be DEFAULT or GLOBAL")
def step_verify_source_not_project(context: Any) -> None:
assert context.result.source in (ConfigLevel.DEFAULT, ConfigLevel.GLOBAL)
# ---------------------------------------------------------------------------
# env_var_for_key
# ---------------------------------------------------------------------------
@when('I call env_var_for_key with "core.log_level"')
def step_call_env_var_for_key_valid(context: Any) -> None:
context.result = ConfigService.env_var_for_key("core.log_level")
@then('it should return "CLEVERAGENTS_CORE_LOG_LEVEL"')
def step_verify_env_var_for_key(context: Any) -> None:
assert context.result == "CLEVERAGENTS_CORE_LOG_LEVEL"
@when('I call env_var_for_key with "nonexistent.key"')
def step_call_env_var_for_key_unknown(context: Any) -> None:
try:
context.result = ConfigService.env_var_for_key("nonexistent.key")
context.error = None
except ValueError as exc:
context.error = exc
# The "a ValueError should be raised mentioning 'Unknown configuration key'"
# step is already defined above for validate_key and is reused here.
# ---------------------------------------------------------------------------
# resolve_all
# ---------------------------------------------------------------------------
@when("I call resolve_all with no overrides")
def step_call_resolve_all(context: Any) -> None:
context.result = context.svc.resolve_all()
@then("the result should contain an entry for every registered key")
def step_verify_resolve_all_keys(context: Any) -> None:
for key in _REGISTRY:
assert key in context.result, f"Missing resolved key: {key}"
assert isinstance(context.result[key], ResolvedValue)
@when('I call resolve_all with cli_override "core.log_level" set to "FATAL"')
def step_call_resolve_all_with_overrides(context: Any) -> None:
context.result = context.svc.resolve_all(cli_overrides={"core.log_level": "FATAL"})
@then(
'the resolve_all result for "core.log_level" should have value "FATAL" and source CLI_FLAG'
)
def step_verify_resolve_all_override(context: Any) -> None:
rv = context.result["core.log_level"]
assert rv.value == "FATAL"
assert rv.source == ConfigLevel.CLI_FLAG
@@ -0,0 +1,769 @@
"""Step definitions for correction_service_coverage.feature.
Provides full line-and-branch coverage for
``cleveragents.application.services.correction_service.CorrectionService``.
All step-text uses a ``cov-`` prefix to avoid collisions with existing
correction_flows_steps.py and correction_service_new_coverage_steps.py.
"""
from __future__ import annotations
from behave import given, then, when
from cleveragents.application.services.correction_service import CorrectionService
from cleveragents.core.exceptions import ResourceNotFoundError, ValidationError
from cleveragents.domain.models.core.correction import (
CorrectionMode,
CorrectionStatus,
)
# -------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------
def _parse_adj(spec: str) -> dict[str, list[str]]:
"""Parse ``"A->B,C;B->D"`` into an adjacency dict."""
tree: dict[str, list[str]] = {}
for seg in spec.split(";"):
seg = seg.strip()
if not seg:
continue
parent, children_str = seg.split("->")
tree[parent.strip()] = [c.strip() for c in children_str.split(",")]
return tree
def _build_chain_cov(root: str, count: int) -> dict[str, list[str]]:
"""Build a linear chain of *count* total nodes starting at *root*."""
tree: dict[str, list[str]] = {}
current = root
for i in range(1, count):
child = f"{root}_ch{i}"
tree[current] = [child]
current = child
return tree
# -------------------------------------------------------------------
# Background / Given
# -------------------------------------------------------------------
@given("a coverage test correction service")
def step_bg_service(context):
context.cov_svc = CorrectionService()
context.cov_cid = None
context.cov_req = None
context.cov_impact = None
context.cov_revert_result = None
context.cov_append_result = None
context.cov_dispatch_result = None
context.cov_dry_run = None
context.cov_retrieved = None
context.cov_list = None
context.cov_attempts = None
context.cov_subtree = None
context.cov_error = None
@given('a cov-stored revert correction for plan "{plan}" targeting "{target}"')
def step_stored_revert_cov(context, plan, target):
req = context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.cov_cid = req.correction_id
context.cov_req = req
@given('a cov-stored append correction for plan "{plan}" targeting "{target}"')
def step_stored_append_cov(context, plan, target):
req = context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.APPEND,
)
context.cov_cid = req.correction_id
context.cov_req = req
@given("the cov-stored correction has been executed via revert with empty tree")
def step_pre_execute_revert(context):
context.cov_svc.execute_revert(context.cov_cid, {})
@given("the cov-stored correction has been executed via append")
def step_pre_execute_append(context):
context.cov_svc.execute_append(context.cov_cid)
@given("the cov-stored correction has been analyzed with empty tree")
def step_pre_analyze(context):
context.cov_svc.analyze_impact(context.cov_cid, {})
@given('the cov-stored correction status is forced to "{status}"')
def step_force_status(context, status):
req = context.cov_svc.get_correction(context.cov_cid)
req.status = CorrectionStatus(status)
# -------------------------------------------------------------------
# When steps - request_correction
# -------------------------------------------------------------------
@when(
'I cov-request a correction for plan "{plan}" targeting "{target}" in revert mode'
)
def step_request_revert(context, plan, target):
context.cov_req = context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.cov_cid = context.cov_req.correction_id
@when(
'I cov-request a correction for plan "{plan}" targeting "{target}"'
' in append mode with guidance "{guidance}" and dry_run'
)
def step_request_append_guidance_dryrun(context, plan, target, guidance):
context.cov_req = context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.APPEND,
guidance=guidance,
dry_run=True,
)
context.cov_cid = context.cov_req.correction_id
@when(
'I cov-attempt to create a correction with plan_id "{plan}" and target "{target}"'
)
def step_attempt_create(context, plan, target):
try:
context.cov_svc.request_correction(
plan_id=plan,
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.cov_error = None
except (ValidationError, Exception) as exc:
context.cov_error = exc
@when('I cov-attempt to create a correction with empty plan_id and target "{target}"')
def step_attempt_create_empty_plan(context, target):
try:
context.cov_svc.request_correction(
plan_id="",
target_decision_id=target,
mode=CorrectionMode.REVERT,
)
context.cov_error = None
except (ValidationError, Exception) as exc:
context.cov_error = exc
@when('I cov-attempt to create a correction with plan_id "{plan}" and empty target')
def step_attempt_create_empty_target(context, plan):
try:
context.cov_svc.request_correction(
plan_id=plan,
target_decision_id="",
mode=CorrectionMode.REVERT,
)
context.cov_error = None
except (ValidationError, Exception) as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - analyze_impact
# -------------------------------------------------------------------
@when("I cov-analyze impact with an empty tree")
def step_analyze_empty(context):
context.cov_impact = context.cov_svc.analyze_impact(context.cov_cid, {})
@when('I cov-analyze impact with adjacency "{spec}"')
def step_analyze_adj(context, spec):
tree = _parse_adj(spec)
context.cov_impact = context.cov_svc.analyze_impact(context.cov_cid, tree)
@when('I cov-attempt to analyze impact for correction id "{cid}"')
def step_attempt_analyze(context, cid):
try:
context.cov_svc.analyze_impact(cid, {})
context.cov_error = None
except ResourceNotFoundError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - generate_dry_run_report
# -------------------------------------------------------------------
@when("I cov-generate dry-run report with an empty tree")
def step_dryrun_empty(context):
context.cov_dry_run = context.cov_svc.generate_dry_run_report(context.cov_cid, {})
@when('I cov-generate dry-run report with adjacency "{spec}"')
def step_dryrun_adj(context, spec):
tree = _parse_adj(spec)
context.cov_dry_run = context.cov_svc.generate_dry_run_report(context.cov_cid, tree)
@when('I cov-generate dry-run report with a chain of {n:d} nodes rooted at "{root}"')
def step_dryrun_chain(context, n, root):
tree = _build_chain_cov(root, n)
context.cov_dry_run = context.cov_svc.generate_dry_run_report(context.cov_cid, tree)
@when('I cov-attempt to generate dry-run report for correction id "{cid}"')
def step_attempt_dryrun(context, cid):
try:
context.cov_svc.generate_dry_run_report(cid, {})
context.cov_error = None
except ResourceNotFoundError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - execute_revert
# -------------------------------------------------------------------
@when('I cov-execute revert with adjacency "{spec}"')
def step_exec_revert_adj(context, spec):
tree = _parse_adj(spec)
context.cov_revert_result = context.cov_svc.execute_revert(context.cov_cid, tree)
@when("I cov-execute revert with empty tree")
def step_exec_revert_empty(context):
context.cov_revert_result = context.cov_svc.execute_revert(context.cov_cid, {})
@when("I cov-attempt to execute revert on the stored correction")
def step_attempt_exec_revert(context):
try:
context.cov_svc.execute_revert(context.cov_cid, {})
context.cov_error = None
except (ValidationError, ResourceNotFoundError) as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - execute_append
# -------------------------------------------------------------------
@when("I cov-execute append for the stored correction")
def step_exec_append(context):
context.cov_append_result = context.cov_svc.execute_append(context.cov_cid)
@when("I cov-attempt to execute append on the stored correction")
def step_attempt_exec_append(context):
try:
context.cov_svc.execute_append(context.cov_cid)
context.cov_error = None
except (ValidationError, ResourceNotFoundError) as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - execute_correction (dispatch)
# -------------------------------------------------------------------
@when('I cov-dispatch execute correction with adjacency "{spec}"')
def step_dispatch_adj(context, spec):
tree = _parse_adj(spec)
context.cov_dispatch_result = context.cov_svc.execute_correction(
context.cov_cid, tree
)
@when("I cov-dispatch execute correction with no tree")
def step_dispatch_no_tree(context):
context.cov_dispatch_result = context.cov_svc.execute_correction(context.cov_cid)
# -------------------------------------------------------------------
# When steps - get_correction
# -------------------------------------------------------------------
@when("I cov-get the stored correction by id")
def step_get_by_id(context):
context.cov_retrieved = context.cov_svc.get_correction(context.cov_cid)
@when('I cov-attempt to get correction with id "{cid}"')
def step_attempt_get(context, cid):
try:
context.cov_svc.get_correction(cid)
context.cov_error = None
except ResourceNotFoundError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - list_corrections
# -------------------------------------------------------------------
@when("I cov-list all corrections without filter")
def step_list_all(context):
context.cov_list = context.cov_svc.list_corrections()
@when('I cov-list corrections for plan "{plan}"')
def step_list_by_plan(context, plan):
context.cov_list = context.cov_svc.list_corrections(plan_id=plan)
# -------------------------------------------------------------------
# When steps - list_attempts
# -------------------------------------------------------------------
@when("I cov-list attempts for the stored correction")
def step_list_attempts(context):
context.cov_attempts = context.cov_svc.list_attempts(context.cov_cid)
@when('I cov-attempt to list attempts for correction id "{cid}"')
def step_attempt_list_attempts(context, cid):
try:
context.cov_svc.list_attempts(cid)
context.cov_error = None
except ResourceNotFoundError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - cancel_correction
# -------------------------------------------------------------------
@when("I cov-cancel the stored correction")
def step_cancel(context):
context.cov_svc.cancel_correction(context.cov_cid)
@when("I cov-attempt to cancel the stored correction")
def step_attempt_cancel(context):
try:
context.cov_svc.cancel_correction(context.cov_cid)
context.cov_error = None
except ValidationError as exc:
context.cov_error = exc
# -------------------------------------------------------------------
# When steps - _compute_affected_subtree
# -------------------------------------------------------------------
@when('I cov-compute subtree for "{root}" with empty tree')
def step_subtree_empty(context, root):
context.cov_subtree = CorrectionService._compute_affected_subtree(root, {})
@when('I cov-compute subtree for "{root}" with adjacency "{spec}"')
def step_subtree_adj(context, root, spec):
tree = _parse_adj(spec)
context.cov_subtree = CorrectionService._compute_affected_subtree(root, tree)
# ===================================================================
# Then steps
# ===================================================================
# -------------------------------------------------------------------
# __init__ assertions
# -------------------------------------------------------------------
@then("the coverage service corrections dict should be empty")
def step_init_corrections(context):
assert len(context.cov_svc._corrections) == 0
@then("the coverage service impacts dict should be empty")
def step_init_impacts(context):
assert len(context.cov_svc._impacts) == 0
@then("the coverage service attempts dict should be empty")
def step_init_attempts(context):
assert len(context.cov_svc._attempts) == 0
@then("the coverage service results dict should be empty")
def step_init_results(context):
assert len(context.cov_svc._results) == 0
# -------------------------------------------------------------------
# request_correction assertions
# -------------------------------------------------------------------
@then("the cov-request should be stored in the service")
def step_req_stored(context):
assert context.cov_req is not None
fetched = context.cov_svc.get_correction(context.cov_req.correction_id)
assert fetched.correction_id == context.cov_req.correction_id
@then('the cov-request plan_id should be "{val}"')
def step_req_plan(context, val):
assert context.cov_req.plan_id == val
@then('the cov-request target_decision_id should be "{val}"')
def step_req_target(context, val):
assert context.cov_req.target_decision_id == val
@then('the cov-request mode should be "{val}"')
def step_req_mode(context, val):
assert context.cov_req.mode.value == val
@then('the cov-request status should be "{val}"')
def step_req_status(context, val):
assert context.cov_req.status.value == val
@then("the cov-request dry_run should be false")
def step_req_dryrun_false(context):
assert context.cov_req.dry_run is False
@then("the cov-request dry_run should be true")
def step_req_dryrun_true(context):
assert context.cov_req.dry_run is True
@then('the cov-request guidance should be "{val}"')
def step_req_guidance(context, val):
assert context.cov_req.guidance == val
@then("the cov-request guidance should be empty")
def step_req_guidance_empty(context):
assert context.cov_req.guidance == ""
# -------------------------------------------------------------------
# Error assertions
# -------------------------------------------------------------------
@then("a cov ValidationError should have been raised")
def step_cov_val_error(context):
assert context.cov_error is not None, "Expected ValidationError but none was raised"
assert isinstance(context.cov_error, ValidationError), (
f"Expected ValidationError, got {type(context.cov_error).__name__}"
)
@then("a cov ResourceNotFoundError should have been raised")
def step_cov_rnf_error(context):
assert context.cov_error is not None, (
"Expected ResourceNotFoundError but none was raised"
)
assert isinstance(context.cov_error, ResourceNotFoundError), (
f"Expected ResourceNotFoundError, got {type(context.cov_error).__name__}"
)
# -------------------------------------------------------------------
# Impact assertions
# -------------------------------------------------------------------
@then('the cov-impact affected decisions should be "{expected}"')
def step_impact_decisions(context, expected):
expected_list = [d.strip() for d in expected.split(",")]
assert context.cov_impact.affected_decisions == expected_list, (
f"Expected {expected_list}, got {context.cov_impact.affected_decisions}"
)
@then('the cov-impact risk level should be "{val}"')
def step_impact_risk(context, val):
assert context.cov_impact.risk_level == val
@then('the cov-impact rollback tier should be "{val}"')
def step_impact_rollback(context, val):
assert context.cov_impact.rollback_tier == val
@then("the cov-impact estimated cost should be {cost:g}")
def step_impact_cost(context, cost):
assert context.cov_impact.estimated_cost == cost
@then('the cov-impact artifacts to archive should be "{expected}"')
def step_impact_artifacts(context, expected):
expected_list = [a.strip() for a in expected.split(",")]
assert context.cov_impact.artifacts_to_archive == expected_list
@then('the cov-impact affected files should be "{expected}"')
def step_impact_files(context, expected):
expected_list = [f.strip() for f in expected.split(",")]
assert context.cov_impact.affected_files == expected_list
@then("the cov-impact affected decisions count should be {n:d}")
def step_impact_count(context, n):
actual = len(context.cov_impact.affected_decisions)
assert actual == n, f"Expected {n}, got {actual}"
@then('the cov-stored correction status should be "{val}"')
def step_stored_status(context, val):
req = context.cov_svc.get_correction(context.cov_cid)
assert req.status.value == val, f"Expected status '{val}', got '{req.status.value}'"
# -------------------------------------------------------------------
# Dry-run report assertions
# -------------------------------------------------------------------
@then('the cov dry-run mode should be "{val}"')
def step_dr_mode(context, val):
assert context.cov_dry_run.mode.value == val
@then("the cov dry-run warnings should be empty")
def step_dr_warnings_empty(context):
assert len(context.cov_dry_run.warnings) == 0, (
f"Expected no warnings, got {context.cov_dry_run.warnings}"
)
@then('the cov dry-run warnings should contain "{fragment}"')
def step_dr_warning_contains(context, fragment):
joined = " | ".join(context.cov_dry_run.warnings)
assert fragment in joined, (
f"'{fragment}' not found in warnings: {context.cov_dry_run.warnings}"
)
@then('the cov dry-run decisions to invalidate should be "{expected}"')
def step_dr_invalidate(context, expected):
expected_list = [d.strip() for d in expected.split(",")]
assert context.cov_dry_run.decisions_to_invalidate == expected_list
@then("the cov dry-run decisions to invalidate should be empty list")
def step_dr_invalidate_empty(context):
assert context.cov_dry_run.decisions_to_invalidate == []
@then("the cov dry-run decisions to invalidate count should be {n:d}")
def step_dr_invalidate_count(context, n):
actual = len(context.cov_dry_run.decisions_to_invalidate)
assert actual == n, f"Expected {n}, got {actual}"
@then("the cov dry-run estimated recompute time should be {t:g}")
def step_dr_recompute(context, t):
assert context.cov_dry_run.estimated_recompute_time_seconds == t
# -------------------------------------------------------------------
# Revert result assertions
# -------------------------------------------------------------------
@then('the cov revert result status should be "{val}"')
def step_revert_status(context, val):
assert context.cov_revert_result.status.value == val
@then('the cov revert result reverted decisions should include "{d}"')
def step_revert_incl(context, d):
assert d in context.cov_revert_result.reverted_decisions
@then('the cov revert result archived artifacts should include "{a}"')
def step_revert_artifacts(context, a):
assert a in context.cov_revert_result.archived_artifacts
# -------------------------------------------------------------------
# Append result assertions
# -------------------------------------------------------------------
@then('the cov append result status should be "{val}"')
def step_append_status(context, val):
assert context.cov_append_result.status.value == val
@then("the cov append result spawned_child_plan_id should not be none")
def step_append_child(context):
assert context.cov_append_result.spawned_child_plan_id is not None
@then("the cov append result new_decisions should not be empty")
def step_append_new_decisions(context):
assert len(context.cov_append_result.new_decisions) > 0
@then("the cov first attempt details should contain spawned_child_plan_id")
def step_append_attempt_details(context):
attempts = context.cov_svc.list_attempts(context.cov_cid)
assert "spawned_child_plan_id" in attempts[0].details
# -------------------------------------------------------------------
# Dispatch result assertions
# -------------------------------------------------------------------
@then('the cov dispatch result status should be "{val}"')
def step_dispatch_status(context, val):
assert context.cov_dispatch_result.status.value == val
@then('the cov dispatch result reverted decisions should include "{d}"')
def step_dispatch_reverted(context, d):
assert d in context.cov_dispatch_result.reverted_decisions
@then("the cov dispatch result spawned_child_plan_id should not be none")
def step_dispatch_child(context):
assert context.cov_dispatch_result.spawned_child_plan_id is not None
# -------------------------------------------------------------------
# get_correction assertions
# -------------------------------------------------------------------
@then('the cov-retrieved correction plan_id should be "{val}"')
def step_get_plan(context, val):
assert context.cov_retrieved.plan_id == val
@then('the cov-retrieved correction target_decision_id should be "{val}"')
def step_get_target(context, val):
assert context.cov_retrieved.target_decision_id == val
# -------------------------------------------------------------------
# list_corrections assertions
# -------------------------------------------------------------------
@then("the cov corrections list should have at least {n:d} items")
def step_list_min(context, n):
actual = len(context.cov_list)
assert actual >= n, f"Expected at least {n}, got {actual}"
@then("the cov corrections list should have {n:d} item")
def step_list_exact_one(context, n):
actual = len(context.cov_list)
assert actual == n, f"Expected {n}, got {actual}"
@then("the cov corrections list should have {n:d} items")
def step_list_exact(context, n):
actual = len(context.cov_list)
assert actual == n, f"Expected {n}, got {actual}"
# -------------------------------------------------------------------
# list_attempts assertions
# -------------------------------------------------------------------
@then("the cov attempts list should have {n:d} items")
def step_attempts_count_plural(context, n):
actual = len(context.cov_attempts)
assert actual == n, f"Expected {n}, got {actual}"
@then("the cov attempts list should have {n:d} item")
def step_attempts_count_single(context, n):
actual = len(context.cov_attempts)
assert actual == n, f"Expected {n}, got {actual}"
# -------------------------------------------------------------------
# Attempt detail assertions
# -------------------------------------------------------------------
@then("the cov attempts for the stored correction should have {n:d} entry")
def step_attempts_for_stored(context, n):
attempts = context.cov_svc.list_attempts(context.cov_cid)
assert len(attempts) == n, f"Expected {n}, got {len(attempts)}"
@then("the cov first attempt should be successful")
def step_first_attempt_ok(context):
attempts = context.cov_svc.list_attempts(context.cov_cid)
assert attempts[0].success is True
@then("the cov first attempt completed_at should be set")
def step_first_attempt_completed(context):
attempts = context.cov_svc.list_attempts(context.cov_cid)
assert attempts[0].completed_at is not None
# -------------------------------------------------------------------
# _classify_risk assertions
# -------------------------------------------------------------------
@then('cov classifying risk for {n:d} affected returns "{expected}"')
def step_classify_risk(context, n, expected):
result = CorrectionService._classify_risk(n)
assert result == expected, (
f"_classify_risk({n}) = '{result}', expected '{expected}'"
)
# -------------------------------------------------------------------
# _compute_affected_subtree assertions
# -------------------------------------------------------------------
@then('the cov subtree should be "{expected}"')
def step_subtree_result(context, expected):
expected_list = [n.strip() for n in expected.split(",")]
assert context.cov_subtree == expected_list, (
f"Expected {expected_list}, got {context.cov_subtree}"
)
@then("the cov subtree count should be {n:d}")
def step_subtree_count(context, n):
actual = len(context.cov_subtree)
assert actual == n, f"Expected {n}, got {actual}"
@@ -0,0 +1,408 @@
"""Step definitions for database models missing coverage tests.
Targets uncovered lines:
- Line 1666: ToolModel.from_domain() dict branch of _get helper
- Line 1921: SessionModel.from_domain() iterating over session.messages
- Lines 2131-2132: SkillModel column definitions (description, version)
"""
import json
from datetime import UTC, datetime
from behave import given, then, when
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from ulid import ULID
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionTokenUsage,
)
from cleveragents.infrastructure.database.models import (
Base,
SessionModel,
SkillModel,
ToolModel,
)
# ---------------------------------------------------------------------------
# Reusable ULIDs
# ---------------------------------------------------------------------------
ULID_SESSION = str(ULID())
ULID_MSG_1 = str(ULID())
ULID_MSG_2 = str(ULID())
NOW = datetime.now(tz=UTC)
NOW_ISO = NOW.isoformat()
# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------
@given("the missing coverage database is ready")
def step_missing_coverage_db_ready(context):
"""Set up an in-memory database with all tables."""
context.mc_engine = create_engine("sqlite:///:memory:")
context.McSessionLocal = sessionmaker(
bind=context.mc_engine, autoflush=False, autocommit=False
)
Base.metadata.create_all(context.mc_engine)
@given("a missing coverage database session is open")
def step_missing_coverage_session_open(context):
"""Open a database session for missing coverage tests."""
context.mc_session = context.McSessionLocal()
# ===================================================================
# ToolModel.from_domain() with dict input — line 1666
# ===================================================================
@given("a tool definition provided as a plain dict")
def step_tool_as_plain_dict(context):
"""Create a plain dict with tool fields."""
context.tool_dict = {
"name": "myns/my-tool",
"description": "A test tool from a dict",
"tool_type": "function",
"source": "custom",
"input_schema_json": json.dumps({"type": "object"}),
"output_schema_json": json.dumps({"type": "string"}),
"capability_json": None,
"lifecycle_json": None,
"code": "print('hello')",
"mcp_server": None,
"mcp_tool_name": None,
"agent_skill_path": None,
"timeout": 60,
"wraps": None,
"transform": None,
"mode": None,
"argument_mapping_json": None,
"resource_bindings": [],
}
@when("the tool dict is converted to a ToolModel via from_domain")
def step_convert_tool_dict(context):
"""Call ToolModel.from_domain with a plain dict."""
context.tool_model = ToolModel.from_domain(context.tool_dict)
@then("the ToolModel should have the correct name from the dict")
def step_tool_model_name(context):
assert context.tool_model.name == "myns/my-tool"
@then("the ToolModel should have the correct description from the dict")
def step_tool_model_description(context):
assert context.tool_model.description == "A test tool from a dict"
@then("the ToolModel should have the correct tool_type from the dict")
def step_tool_model_tool_type(context):
assert context.tool_model.tool_type == "function"
@then("the ToolModel should have the correct source from the dict")
def step_tool_model_source(context):
assert context.tool_model.source == "custom"
# -------------------------------------------------------------------
# ToolModel.from_domain() with resource bindings as dicts
# -------------------------------------------------------------------
@given("a tool definition provided as a plain dict with resource bindings")
def step_tool_dict_with_bindings(context):
"""Create a dict with resource_bindings also as dicts."""
context.tool_dict_bindings = {
"name": "myns/binding-tool",
"description": "Tool with bindings",
"tool_type": "function",
"source": "custom",
"resource_bindings": [
{
"slot_name": "db_conn",
"resource_type": "database/postgres",
"access_mode": "read_write",
"binding_mode": "static",
"static_resource": "main-db",
"required": True,
"description": "Primary database connection",
},
{
"slot_name": "cache",
"resource_type": "cache/redis",
"access_mode": "read_only",
"binding_mode": "contextual",
"static_resource": None,
"required": False,
"description": "Optional cache layer",
},
],
}
@when("the tool dict with bindings is converted to a ToolModel via from_domain")
def step_convert_tool_dict_bindings(context):
"""Call ToolModel.from_domain with dict resource bindings."""
context.tool_model_bindings = ToolModel.from_domain(context.tool_dict_bindings)
@then("the ToolModel should have resource binding child records")
def step_tool_model_has_bindings(context):
assert len(context.tool_model_bindings.resource_bindings_rel) == 2
@then("each resource binding should have the correct slot_name")
def step_binding_slot_names(context):
bindings = context.tool_model_bindings.resource_bindings_rel
assert bindings[0].slot_name == "db_conn"
assert bindings[1].slot_name == "cache"
@then("each resource binding should have the correct resource_type")
def step_binding_resource_types(context):
bindings = context.tool_model_bindings.resource_bindings_rel
assert bindings[0].resource_type == "database/postgres"
assert bindings[1].resource_type == "cache/redis"
# -------------------------------------------------------------------
# ToolModel.from_domain() with namespaced name from dict
# -------------------------------------------------------------------
@given("a tool definition dict with a namespaced name")
def step_tool_dict_namespaced(context):
"""Create a dict whose name includes a namespace."""
context.tool_dict_ns = {
"name": "acme/super-tool",
"description": "Namespaced tool",
"tool_type": "tool",
"source": "builtin",
}
@when("the namespaced tool dict is converted to a ToolModel via from_domain")
def step_convert_ns_tool_dict(context):
"""Call ToolModel.from_domain with a namespaced dict."""
context.tool_model_ns = ToolModel.from_domain(context.tool_dict_ns)
@then("the ToolModel namespace should be extracted from the dict name")
def step_tool_model_ns_namespace(context):
assert context.tool_model_ns.namespace == "acme"
@then("the ToolModel short_name should be extracted from the dict name")
def step_tool_model_ns_short_name(context):
assert context.tool_model_ns.short_name == "super-tool"
# ===================================================================
# SessionModel.from_domain() with messages — line 1921
# ===================================================================
def _make_session_message(message_id, role, content, sequence, tool_call_id=None):
"""Create a SessionMessage domain object."""
return SessionMessage(
message_id=message_id,
role=role,
content=content,
sequence=sequence,
timestamp=NOW,
metadata={"source": "test"},
tool_call_id=tool_call_id,
)
@given("a Session domain object with two messages")
def step_session_with_two_messages(context):
"""Create a Session domain object containing two messages."""
msg1 = _make_session_message(ULID_MSG_1, MessageRole.USER, "Hello agent", 0)
msg2 = _make_session_message(ULID_MSG_2, MessageRole.ASSISTANT, "Hello human", 1)
context.session_domain = Session(
session_id=ULID_SESSION,
actor_name="local/test-actor",
namespace="local",
linked_plan_ids=[],
token_usage=SessionTokenUsage(
input_tokens=100,
output_tokens=50,
estimated_cost=0.002,
),
metadata={"env": "test"},
messages=[msg1, msg2],
created_at=NOW,
updated_at=NOW,
)
@when("the Session domain object is converted to a SessionModel via from_domain")
def step_convert_session_domain(context):
"""Call SessionModel.from_domain with a Session that has messages."""
context.session_model = SessionModel.from_domain(context.session_domain)
@then("the SessionModel should have two message child records")
def step_session_model_two_messages(context):
assert len(context.session_model.messages_rel) == 2
@then('the first message record should have role "{role}"')
def step_first_msg_role(context, role):
assert context.session_model.messages_rel[0].role == role
@then('the second message record should have role "{role}"')
def step_second_msg_role(context, role):
assert context.session_model.messages_rel[1].role == role
@then("each message record should have the correct content")
def step_msg_contents(context):
assert context.session_model.messages_rel[0].content == "Hello agent"
assert context.session_model.messages_rel[1].content == "Hello human"
# -------------------------------------------------------------------
# SessionModel.from_domain() with a single message
# -------------------------------------------------------------------
@given("a Session domain object with one message")
def step_session_with_one_message(context):
"""Create a Session domain object containing one message."""
single_msg_id = str(ULID())
msg = _make_session_message(single_msg_id, MessageRole.USER, "Single message", 0)
context.single_msg_id = single_msg_id
context.session_domain_single = Session(
session_id=str(ULID()),
actor_name="local/solo-actor",
namespace="local",
linked_plan_ids=[],
token_usage=SessionTokenUsage(
input_tokens=10,
output_tokens=5,
estimated_cost=0.0001,
),
metadata={},
messages=[msg],
created_at=NOW,
updated_at=NOW,
)
@when("the single-message Session is converted to a SessionModel via from_domain")
def step_convert_single_msg_session(context):
"""Call SessionModel.from_domain with a single-message Session."""
context.session_model_single = SessionModel.from_domain(
context.session_domain_single
)
@then("the SessionModel should have one message child record")
def step_session_model_one_message(context):
assert len(context.session_model_single.messages_rel) == 1
@then("the single message record should have the correct message_id")
def step_single_msg_id(context):
assert (
context.session_model_single.messages_rel[0].message_id == context.single_msg_id
)
# ===================================================================
# SkillModel.from_domain() — lines 2131-2132 (description, version)
# ===================================================================
@given("a skill definition dict with description and version")
def step_skill_dict_with_version(context):
"""Create a dict representing a skill with description and version."""
context.skill_dict = {
"name": "local/code-tools",
"description": "A collection of code analysis tools",
"version": "1.2.3",
"tool_refs": ["local/lint", "local/format"],
"includes": [],
"anonymous_tools": [],
"mcp_servers": [],
"agent_skills": [],
"overrides": {},
}
@when("the skill dict is converted to a SkillModel via from_domain")
def step_convert_skill_dict(context):
"""Call SkillModel.from_domain with a dict."""
context.skill_model = SkillModel.from_domain(context.skill_dict)
@then("the SkillModel should have the correct description")
def step_skill_model_description(context):
assert context.skill_model.description == "A collection of code analysis tools"
@then("the SkillModel should have the correct version")
def step_skill_model_version(context):
assert context.skill_model.version == "1.2.3"
@then("the SkillModel should have the correct namespace")
def step_skill_model_namespace(context):
assert context.skill_model.namespace == "local"
@then("the SkillModel should have the correct short_name")
def step_skill_model_short_name(context):
assert context.skill_model.short_name == "code-tools"
# -------------------------------------------------------------------
# SkillModel.from_domain() without version
# -------------------------------------------------------------------
@given("a skill definition dict without a version field")
def step_skill_dict_no_version(context):
"""Create a skill dict without the version key."""
context.skill_dict_noversion = {
"name": "local/basic-skill",
"description": "A basic skill without version",
"tool_refs": [],
"includes": [],
"anonymous_tools": [],
"mcp_servers": [],
"agent_skills": [],
"overrides": {},
}
@when("the versionless skill dict is converted to a SkillModel via from_domain")
def step_convert_versionless_skill_dict(context):
"""Call SkillModel.from_domain with a dict missing version."""
context.skill_model_noversion = SkillModel.from_domain(context.skill_dict_noversion)
@then("the SkillModel version should be None")
def step_skill_model_version_none(context):
assert context.skill_model_noversion.version is None
@then("the SkillModel description should still be set")
def step_skill_model_description_still_set(context):
assert context.skill_model_noversion.description == "A basic skill without version"
@@ -0,0 +1,359 @@
"""Step definitions for plan_cli_cancel_revert_coverage.feature.
Covers uncovered lines in cleveragents/cli/commands/plan.py:
- Line 1817: timestamps.created_at.strftime in lifecycle_list_plans rich table
- Lines 1821-1822: console.print(table) and CleverAgentsError catch
- Lines 1845-1855: cancel_plan command body
- Lines 1878-1888: revert_plan command body
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
InvariantSource,
NamespacedName,
Plan,
PlanIdentity,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
ProjectLink,
)
# Valid ULIDs for test plans
_ULID_CANCEL = "01ARZ3NDEKTSV4RRFFQ69G5FB1"
_ULID_REVERT = "01ARZ3NDEKTSV4RRFFQ69G5FB2"
_ULID_LIST_1 = "01ARZ3NDEKTSV4RRFFQ69G5FB3"
_ULID_LIST_2 = "01ARZ3NDEKTSV4RRFFQ69G5FB4"
def _make_plan(
*,
plan_id: str = _ULID_CANCEL,
name: str = "local/test-plan",
description: str = "Test plan for cancel-revert 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,
timestamps: PlanTimestamps | None = None,
reversion_count: int = 0,
action_name: str = "local/test-action",
) -> Plan:
"""Build a real Plan object for testing."""
if timestamps is None:
timestamps = PlanTimestamps(
created_at=datetime(2025, 6, 15, 10, 30, 0),
updated_at=datetime(2025, 6, 15, 11, 0, 0),
)
return Plan(
identity=PlanIdentity(plan_id=plan_id),
namespaced_name=NamespacedName.parse(name),
action_name=action_name,
description=description,
phase=phase,
processing_state=processing_state,
project_links=project_links or [],
automation_profile=automation_profile,
invariants=invariants or [],
timestamps=timestamps,
reversion_count=reversion_count,
reusable=True,
read_only=False,
)
# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------
@given("a CLI runner for cancel-revert coverage")
def step_cli_runner(context):
context.runner = CliRunner()
@given("a mocked lifecycle service for cancel-revert coverage")
def step_mocked_lifecycle_service(context):
context.mock_service = MagicMock()
patcher = patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.mock_service,
)
patcher.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(patcher.stop)
# ---------------------------------------------------------------------------
# Given steps — lifecycle-list rich format
# ---------------------------------------------------------------------------
@given("the service returns plans with project links and timestamps for rich list")
def step_service_plans_with_links_and_timestamps(context):
plans = [
_make_plan(
plan_id=_ULID_LIST_1,
name="local/plan-with-links",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[
ProjectLink(project_name="proj-alpha"),
ProjectLink(project_name="proj-beta"),
],
timestamps=PlanTimestamps(
created_at=datetime(2025, 3, 10, 14, 25, 0),
updated_at=datetime(2025, 3, 10, 15, 0, 0),
),
),
]
context.mock_service.list_plans.return_value = plans
@given("the service returns plans with an automation profile for rich list")
def step_service_plans_with_profile(context):
plans = [
_make_plan(
plan_id=_ULID_LIST_1,
name="local/profiled-plan",
phase=PlanPhase.EXECUTE,
processing_state=ProcessingState.PROCESSING,
project_links=[ProjectLink(project_name="proj-one")],
automation_profile=AutomationProfileRef(
profile_name="trusted",
provenance=AutomationProfileProvenance.ACTION,
),
timestamps=PlanTimestamps(
created_at=datetime(2025, 4, 1, 9, 0, 0),
updated_at=datetime(2025, 4, 1, 10, 0, 0),
),
),
]
context.mock_service.list_plans.return_value = plans
@given("the service returns a plan with three project links for rich list")
def step_service_plan_three_links(context):
plans = [
_make_plan(
plan_id=_ULID_LIST_1,
name="local/multi-project",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[
ProjectLink(project_name="proj-a"),
ProjectLink(project_name="proj-b"),
ProjectLink(project_name="proj-c"),
],
timestamps=PlanTimestamps(
created_at=datetime(2025, 5, 20, 8, 15, 0),
updated_at=datetime(2025, 5, 20, 9, 0, 0),
),
),
]
context.mock_service.list_plans.return_value = plans
@given("the service returns plans with invariants for rich list")
def step_service_plans_with_invariants(context):
plans = [
_make_plan(
plan_id=_ULID_LIST_1,
name="local/invariant-plan",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="proj-one")],
invariants=[
PlanInvariant(
text="No breaking changes", source=InvariantSource.ACTION
),
PlanInvariant(
text="Keep backward compat", source=InvariantSource.PROJECT
),
],
timestamps=PlanTimestamps(
created_at=datetime(2025, 6, 1, 12, 0, 0),
updated_at=datetime(2025, 6, 1, 13, 0, 0),
),
),
]
context.mock_service.list_plans.return_value = plans
# ---------------------------------------------------------------------------
# Given steps — cancel_plan
# ---------------------------------------------------------------------------
@given("the service can cancel a plan for cancel-revert coverage")
def step_service_can_cancel(context):
plan = _make_plan(
plan_id=_ULID_CANCEL,
name="local/cancel-target",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.CANCELLED,
project_links=[ProjectLink(project_name="proj-cancel")],
)
context.mock_service.cancel_plan.return_value = plan
context._cancel_plan_id = _ULID_CANCEL
# ---------------------------------------------------------------------------
# Given steps — revert_plan
# ---------------------------------------------------------------------------
@given("the service can revert a plan for cancel-revert coverage")
def step_service_can_revert(context):
plan = _make_plan(
plan_id=_ULID_REVERT,
name="local/revert-target",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
project_links=[ProjectLink(project_name="proj-revert")],
reversion_count=1,
)
context.mock_service.revert_plan.return_value = plan
context._revert_plan_id = _ULID_REVERT
# ---------------------------------------------------------------------------
# When steps — lifecycle-list
# ---------------------------------------------------------------------------
@when("I invoke lifecycle-list in rich format for cancel-revert coverage")
def step_invoke_lifecycle_list_rich(context):
context.result = context.runner.invoke(
plan_app,
["lifecycle-list", "--format", "rich"],
)
# ---------------------------------------------------------------------------
# When steps — cancel_plan
# ---------------------------------------------------------------------------
@when("I invoke cancel in rich format without reason for cancel-revert coverage")
def step_invoke_cancel_rich_no_reason(context):
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id],
)
@when(
'I invoke cancel in rich format with reason "{reason}" for cancel-revert coverage'
)
def step_invoke_cancel_rich_with_reason(context, reason):
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--reason", reason],
)
@when('I invoke cancel with format "{fmt}" and no reason for cancel-revert coverage')
def step_invoke_cancel_fmt_no_reason(context, fmt):
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--format", fmt],
)
@when(
'I invoke cancel with format "{fmt}" and reason "{reason}" for cancel-revert coverage'
)
def step_invoke_cancel_fmt_with_reason(context, fmt, reason):
context.result = context.runner.invoke(
plan_app,
["cancel", context._cancel_plan_id, "--format", fmt, "--reason", reason],
)
# ---------------------------------------------------------------------------
# When steps — revert_plan
# ---------------------------------------------------------------------------
@when("I invoke revert in rich format with default phase for cancel-revert coverage")
def step_invoke_revert_rich_default(context):
context.result = context.runner.invoke(
plan_app,
["revert", context._revert_plan_id],
)
@when(
'I invoke revert in rich format with reason "{reason}" for cancel-revert coverage'
)
def step_invoke_revert_rich_with_reason(context, reason):
context.result = context.runner.invoke(
plan_app,
["revert", context._revert_plan_id, "--reason", reason],
)
@when('I invoke revert with format "{fmt}" for cancel-revert coverage')
def step_invoke_revert_fmt(context, fmt):
context.result = context.runner.invoke(
plan_app,
["revert", context._revert_plan_id, "--format", fmt],
)
@when('I invoke revert with invalid phase "{phase}" for cancel-revert coverage')
def step_invoke_revert_invalid_phase(context, phase):
context.result = context.runner.invoke(
plan_app,
["revert", context._revert_plan_id, "--to-phase", phase],
)
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
def _output(context) -> str:
return getattr(context.result, "output", "") if hasattr(context, "result") else ""
@then("the cancel-revert command should succeed")
def step_command_succeeds(context):
assert context.result.exit_code == 0, (
f"Expected exit code 0 but got {context.result.exit_code}.\n"
f"Output: {_output(context)}"
)
@then("the cancel-revert command should abort")
def step_command_aborts(context):
assert context.result.exit_code != 0, (
f"Expected non-zero exit code but got 0.\nOutput: {_output(context)}"
)
@then('the cancel-revert output should contain "{text}"')
def step_output_contains(context, text):
output = _output(context)
assert text in output, f"Expected '{text}' in output:\n{output}"
@then('the cancel-revert output should not contain "{text}"')
def step_output_not_contains(context, text):
output = _output(context)
assert text not in output, f"Did not expect '{text}' in output:\n{output}"
@@ -0,0 +1,612 @@
"""Step definitions for plan_executor.py coverage tests.
Targets uncovered lines: 144, 233-235, 273, 483, 490.
"""
from __future__ import annotations
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_executor import (
ExecuteResult,
ExecuteStubActor,
PlanExecutor,
StrategizeResult,
StrategizeStubActor,
StrategyDecision,
)
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.plan import (
InvariantSource,
PlanInvariant,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
PLAN_ID = "01HABCDE12345678901234PLAN"
def _make_mock_plan(
*,
phase: PlanPhase = PlanPhase.STRATEGIZE,
state: ProcessingState = ProcessingState.QUEUED,
definition_of_done: str | None = "Step one\nStep two",
decision_root_id: str | None = "01HROOT000000000000000ROOT",
invariants: list[PlanInvariant] | None = None,
) -> MagicMock:
"""Build a mock plan object with sensible defaults."""
plan = MagicMock()
plan.phase = phase
plan.state = state
plan.processing_state = state
plan.definition_of_done = definition_of_done
plan.decision_root_id = decision_root_id
plan.invariants = invariants or []
plan.timestamps = PlanTimestamps()
plan.changeset_id = None
plan.sandbox_refs = []
plan.error_details = None
return plan
def _make_lifecycle(plan: Any | None = None) -> MagicMock:
"""Build a mock lifecycle service returning *plan* on get_plan."""
lcs = MagicMock()
if plan is not None:
lcs.get_plan.return_value = plan
lcs.start_strategize = MagicMock()
lcs.complete_strategize = MagicMock()
lcs.fail_strategize = MagicMock()
lcs.start_execute = MagicMock()
lcs.complete_execute = MagicMock()
lcs.fail_execute = MagicMock()
lcs._commit_plan = MagicMock()
return lcs
# ---------------------------------------------------------------------------
# StrategizeStubActor._parse_steps (line 144)
# ---------------------------------------------------------------------------
@given("a StrategizeStubActor instance for coverage")
def step_given_strategize_stub(context: Context) -> None:
context.strategize_actor = StrategizeStubActor()
@when("I call _parse_steps with an empty string")
def step_parse_steps_empty(context: Context) -> None:
context.parsed_steps = context.strategize_actor._parse_steps("")
@when("I call _parse_steps with whitespace only")
def step_parse_steps_whitespace(context: Context) -> None:
context.parsed_steps = context.strategize_actor._parse_steps(" \n\t ")
@then("the parsed steps should be the default fallback")
def step_check_default_steps(context: Context) -> None:
assert context.parsed_steps == ["Complete the plan objectives"], (
f"Expected default fallback, got {context.parsed_steps}"
)
# ---------------------------------------------------------------------------
# StrategizeStubActor.execute happy paths
# ---------------------------------------------------------------------------
@when("I execute the strategize stub with a valid plan and definition of done")
def step_exec_strategize_valid(context: Context) -> None:
context.strategize_result = context.strategize_actor.execute(
plan_id=PLAN_ID,
definition_of_done="First step\nSecond step",
)
@then("the strategize result should contain decisions matching the steps")
def step_check_strategize_decisions(context: Context) -> None:
result: StrategizeResult = context.strategize_result
assert len(result.decisions) == 2
assert result.decisions[0].step_text == "First step"
assert result.decisions[1].step_text == "Second step"
assert result.decision_root_id == result.decisions[0].decision_id
@when("I execute the strategize stub with a None definition of done")
def step_exec_strategize_none_dod(context: Context) -> None:
context.strategize_result = context.strategize_actor.execute(
plan_id=PLAN_ID,
definition_of_done=None,
)
@then("the strategize result should contain the default decision")
def step_check_default_decision(context: Context) -> None:
result: StrategizeResult = context.strategize_result
assert len(result.decisions) == 1
assert result.decisions[0].step_text == "Complete the plan objectives"
@when("I execute the strategize stub with an empty plan_id")
def step_exec_strategize_empty_plan_id(context: Context) -> None:
try:
context.strategize_actor.execute(plan_id="", definition_of_done="x")
context.raised_exception = None
except Exception as exc:
context.raised_exception = exc
@then('a plan executor ValidationError should be raised containing "{text}"')
def step_check_validation_error(context: Context, text: str) -> None:
assert context.raised_exception is not None, (
"Expected an exception but none was raised"
)
assert isinstance(context.raised_exception, ValidationError), (
f"Expected ValidationError, got {type(context.raised_exception).__name__}"
)
assert text in str(context.raised_exception), (
f"Expected '{text}' in '{context.raised_exception}'"
)
@when("I execute the strategize stub with invariants")
def step_exec_strategize_with_invariants(context: Context) -> None:
invariants = [
PlanInvariant(text="Must be safe", source=InvariantSource.PLAN),
PlanInvariant(text="Must be fast", source=InvariantSource.PROJECT),
]
context.strategize_result = context.strategize_actor.execute(
plan_id=PLAN_ID,
definition_of_done="Do it",
invariants=invariants,
)
@then("the strategize result should contain invariant records")
def step_check_invariant_records(context: Context) -> None:
result: StrategizeResult = context.strategize_result
assert len(result.invariant_records) == 2
assert result.invariant_records[0]["text"] == "Must be safe"
assert result.invariant_records[1]["source"] == "project"
@when("I execute the strategize stub with a stream callback")
def step_exec_strategize_with_callback(context: Context) -> None:
context.stream_events = []
def _cb(event: str, data: dict) -> None:
context.stream_events.append((event, data))
context.strategize_result = context.strategize_actor.execute(
plan_id=PLAN_ID,
definition_of_done="A step",
stream_callback=_cb,
)
@then("the stream callback should have been called with strategize events")
def step_check_stream_events(context: Context) -> None:
event_names = [e[0] for e in context.stream_events]
assert "strategize_started" in event_names
assert "strategize_decisions" in event_names
assert "strategize_complete" in event_names
# ---------------------------------------------------------------------------
# ExecuteStubActor
# ---------------------------------------------------------------------------
@given("an ExecuteStubActor instance for coverage")
def step_given_execute_stub(context: Context) -> None:
context.execute_actor = ExecuteStubActor()
@when("I execute the execute stub with decisions")
def step_exec_execute_stub(context: Context) -> None:
decisions = [
StrategyDecision(
decision_id="01HDEC0000000000000000DEC0",
step_text="Do the thing",
sequence=0,
),
]
context.execute_result = context.execute_actor.execute(
plan_id=PLAN_ID,
decisions=decisions,
)
@then("the execute result should contain a changeset id")
def step_check_execute_changeset(context: Context) -> None:
result: ExecuteResult = context.execute_result
assert result.changeset_id is not None
assert len(result.changeset_id) > 0
@when("I execute the execute stub with an empty plan_id")
def step_exec_execute_stub_empty_plan_id(context: Context) -> None:
try:
context.execute_actor.execute(plan_id="", decisions=[])
context.raised_exception = None
except Exception as exc:
context.raised_exception = exc
# ---------------------------------------------------------------------------
# PlanExecutor construction
# ---------------------------------------------------------------------------
@when("I construct a PlanExecutor with None lifecycle service")
def step_construct_none_lifecycle(context: Context) -> None:
try:
PlanExecutor(lifecycle_service=None)
context.raised_exception = None
except Exception as exc:
context.raised_exception = exc
@given("a fresh mock lifecycle for plan executor coverage")
def step_given_fresh_mock_lifecycle(context: Context) -> None:
context.lifecycle = _make_lifecycle()
@when("I construct a PlanExecutor without execution context")
def step_construct_no_ctx(context: Context) -> None:
context.executor = PlanExecutor(
lifecycle_service=context.lifecycle,
execution_context=None,
)
@then("plan executor has_runtime should be False")
def step_check_no_runtime(context: Context) -> None:
assert context.executor.has_runtime is False
@then("plan executor changeset_store should be None")
def step_check_no_changeset_store(context: Context) -> None:
assert context.executor.changeset_store is None
@then("plan executor execution_context should be None")
def step_check_no_execution_context(context: Context) -> None:
assert context.executor.execution_context is None
# ---------------------------------------------------------------------------
# PlanExecutor.run_strategize happy path
# ---------------------------------------------------------------------------
@given("a mock lifecycle with a plan in Strategize phase for executor coverage")
def step_given_lifecycle_strategize(context: Context) -> None:
plan = _make_mock_plan(
phase=PlanPhase.STRATEGIZE,
state=ProcessingState.QUEUED,
definition_of_done="Build the widget",
)
context.lifecycle = _make_lifecycle(plan)
context.plan_id = PLAN_ID
@given("a PlanExecutor using that lifecycle for coverage")
def step_given_executor(context: Context) -> None:
context.executor = PlanExecutor(lifecycle_service=context.lifecycle)
@when("I call run_strategize on the PlanExecutor for coverage")
def step_run_strategize(context: Context) -> None:
context.strategize_result = context.executor.run_strategize(context.plan_id)
@then("the strategize result should be returned successfully")
def step_check_strategize_ok(context: Context) -> None:
assert isinstance(context.strategize_result, StrategizeResult)
assert len(context.strategize_result.decisions) > 0
@then("the lifecycle should have called start_strategize for coverage")
def step_check_start_strategize(context: Context) -> None:
context.lifecycle.start_strategize.assert_called_once_with(context.plan_id)
@then("the lifecycle should have called complete_strategize for coverage")
def step_check_complete_strategize(context: Context) -> None:
context.lifecycle.complete_strategize.assert_called_once_with(context.plan_id)
# ---------------------------------------------------------------------------
# PlanExecutor.run_strategize exception path (line 273)
# ---------------------------------------------------------------------------
@given("a mock lifecycle with a plan in Strategize phase that will fail during execute")
def step_given_lifecycle_strategize_fail(context: Context) -> None:
plan = _make_mock_plan(
phase=PlanPhase.STRATEGIZE,
state=ProcessingState.QUEUED,
definition_of_done="Build the widget",
)
context.lifecycle = _make_lifecycle(plan)
context.plan_id = PLAN_ID
@given("a PlanExecutor using that failing lifecycle for coverage")
def step_given_executor_failing(context: Context) -> None:
context.executor = PlanExecutor(lifecycle_service=context.lifecycle)
# Patch the internal strategize actor to raise
context.executor._strategize_actor = MagicMock()
context.executor._strategize_actor.execute.side_effect = RuntimeError(
"Boom in strategize"
)
@when("I call run_strategize expecting an exception for coverage")
def step_run_strategize_fail(context: Context) -> None:
try:
context.executor.run_strategize(context.plan_id)
context.raised_exception = None
except RuntimeError as exc:
context.raised_exception = exc
@then("the lifecycle should have called fail_strategize for coverage")
def step_check_fail_strategize(context: Context) -> None:
assert context.raised_exception is not None, "Expected an exception"
context.lifecycle.fail_strategize.assert_called_once()
call_args = context.lifecycle.fail_strategize.call_args
assert context.plan_id in call_args[0]
assert "RuntimeError" in call_args[0][1]
# ---------------------------------------------------------------------------
# PlanExecutor.run_strategize wrong phase
# ---------------------------------------------------------------------------
@given("a mock lifecycle with a plan in Execute phase for executor coverage")
def step_given_lifecycle_execute_phase(context: Context) -> None:
plan = _make_mock_plan(phase=PlanPhase.EXECUTE)
context.lifecycle = _make_lifecycle(plan)
context.plan_id = PLAN_ID
@when("I call run_strategize expecting a PlanError for coverage")
def step_run_strategize_wrong_phase(context: Context) -> None:
try:
context.executor.run_strategize(context.plan_id)
context.raised_exception = None
except PlanError as exc:
context.raised_exception = exc
@then("a PlanError should be raised about wrong phase for coverage")
def step_check_wrong_phase_error(context: Context) -> None:
assert context.raised_exception is not None, "Expected a PlanError"
assert isinstance(context.raised_exception, PlanError)
assert "not in Strategize phase" in str(context.raised_exception)
# ---------------------------------------------------------------------------
# PlanExecutor._guard_execute: decision_root_id is None (lines 233-235)
# ---------------------------------------------------------------------------
@given("a mock lifecycle with an execute-phase plan missing decision_root_id")
def step_given_lifecycle_no_root(context: Context) -> None:
plan = _make_mock_plan(
phase=PlanPhase.EXECUTE,
state=ProcessingState.QUEUED,
decision_root_id=None,
)
context.lifecycle = _make_lifecycle(plan)
context.plan_id = PLAN_ID
@when("I call guard_execute for the plan for coverage")
def step_call_guard_execute(context: Context) -> None:
try:
context.executor._guard_execute(context.plan_id)
context.raised_exception = None
except PlanError as exc:
context.raised_exception = exc
@then("a PlanError should be raised about missing decision tree")
def step_check_no_decision_tree(context: Context) -> None:
assert context.raised_exception is not None, "Expected a PlanError"
assert isinstance(context.raised_exception, PlanError)
assert "no decision tree" in str(context.raised_exception)
@given("a mock lifecycle with a plan in Strategize phase for guard coverage")
def step_given_lifecycle_strategize_for_guard(context: Context) -> None:
plan = _make_mock_plan(
phase=PlanPhase.STRATEGIZE,
state=ProcessingState.QUEUED,
)
context.lifecycle = _make_lifecycle(plan)
context.plan_id = PLAN_ID
@when("I call guard_execute expecting wrong phase for coverage")
def step_call_guard_execute_wrong_phase(context: Context) -> None:
try:
context.executor._guard_execute(context.plan_id)
context.raised_exception = None
except PlanError as exc:
context.raised_exception = exc
@then("a PlanError should be raised about not in Execute phase")
def step_check_not_execute_phase(context: Context) -> None:
assert context.raised_exception is not None, "Expected a PlanError"
assert isinstance(context.raised_exception, PlanError)
assert "not in Execute phase" in str(context.raised_exception)
@given("a mock lifecycle with an execute-phase plan in processing state for coverage")
def step_given_lifecycle_processing(context: Context) -> None:
plan = _make_mock_plan(
phase=PlanPhase.EXECUTE,
state=ProcessingState.PROCESSING,
)
context.lifecycle = _make_lifecycle(plan)
context.plan_id = PLAN_ID
@when("I call guard_execute expecting wrong state for coverage")
def step_call_guard_execute_wrong_state(context: Context) -> None:
try:
context.executor._guard_execute(context.plan_id)
context.raised_exception = None
except PlanError as exc:
context.raised_exception = exc
@then("a PlanError should be raised about not queued")
def step_check_not_queued(context: Context) -> None:
assert context.raised_exception is not None, "Expected a PlanError"
assert isinstance(context.raised_exception, PlanError)
assert "not queued" in str(context.raised_exception)
# ---------------------------------------------------------------------------
# PlanExecutor._run_execute_with_stub happy path
# ---------------------------------------------------------------------------
@given("a mock lifecycle with a fully configured execute-phase plan for coverage")
def step_given_lifecycle_full_execute(context: Context) -> None:
plan = _make_mock_plan(
phase=PlanPhase.EXECUTE,
state=ProcessingState.QUEUED,
definition_of_done="Implement feature\nWrite tests",
decision_root_id="01HROOT000000000000000ROOT",
)
context.lifecycle = _make_lifecycle(plan)
context.plan_id = PLAN_ID
@given("a PlanExecutor using that lifecycle without runtime for coverage")
def step_given_executor_no_runtime(context: Context) -> None:
context.executor = PlanExecutor(
lifecycle_service=context.lifecycle,
execution_context=None,
)
@when("I call run_execute on the PlanExecutor for coverage")
def step_run_execute(context: Context) -> None:
context.execute_result = context.executor.run_execute(context.plan_id)
@then("the execute result should be returned successfully")
def step_check_execute_ok(context: Context) -> None:
assert isinstance(context.execute_result, ExecuteResult)
assert context.execute_result.changeset_id is not None
@then("the lifecycle should have called start_execute for coverage")
def step_check_start_execute(context: Context) -> None:
context.lifecycle.start_execute.assert_called_once_with(context.plan_id)
@then("the lifecycle should have called complete_execute for coverage")
def step_check_complete_execute(context: Context) -> None:
context.lifecycle.complete_execute.assert_called_once_with(context.plan_id)
# ---------------------------------------------------------------------------
# PlanExecutor._run_execute_with_stub exception path (lines 483, 490)
# ---------------------------------------------------------------------------
@given("a mock lifecycle with an execute-phase plan that will fail during stub execute")
def step_given_lifecycle_stub_fail(context: Context) -> None:
plan = _make_mock_plan(
phase=PlanPhase.EXECUTE,
state=ProcessingState.QUEUED,
definition_of_done="Something",
decision_root_id="01HROOT000000000000000ROOT",
)
context.lifecycle = _make_lifecycle(plan)
context.plan_id = PLAN_ID
@given("a PlanExecutor using that failing stub lifecycle for coverage")
def step_given_executor_failing_stub(context: Context) -> None:
context.executor = PlanExecutor(
lifecycle_service=context.lifecycle,
execution_context=None,
)
# Patch the internal execute actor to raise
context.executor._execute_actor = MagicMock()
context.executor._execute_actor.execute.side_effect = RuntimeError(
"Boom in execute"
)
@when("I call run_execute expecting an exception from stub for coverage")
def step_run_execute_fail(context: Context) -> None:
try:
context.executor.run_execute(context.plan_id)
context.raised_exception = None
except RuntimeError as exc:
context.raised_exception = exc
@then("the lifecycle should have called fail_execute for coverage")
def step_check_fail_execute(context: Context) -> None:
assert context.raised_exception is not None, "Expected an exception"
context.lifecycle.fail_execute.assert_called_once()
call_args = context.lifecycle.fail_execute.call_args
assert context.plan_id in call_args[0]
assert "RuntimeError" in call_args[0][1]
# ---------------------------------------------------------------------------
# PlanExecutor.run_execute validation
# ---------------------------------------------------------------------------
@when("I call run_execute with an empty plan_id for coverage")
def step_run_execute_empty_plan_id(context: Context) -> None:
try:
context.executor.run_execute("")
context.raised_exception = None
except Exception as exc:
context.raised_exception = exc
# ---------------------------------------------------------------------------
# PlanExecutor._build_decisions
# ---------------------------------------------------------------------------
@when("I call build_decisions for the plan for coverage")
def step_call_build_decisions(context: Context) -> None:
plan = context.lifecycle.get_plan(context.plan_id)
context.built_decisions = context.executor._build_decisions(plan)
@then("the decisions should match the plan definition of done steps")
def step_check_built_decisions(context: Context) -> None:
decisions = context.built_decisions
assert len(decisions) == 2
assert decisions[0].step_text == "Implement feature"
assert decisions[1].step_text == "Write tests"
assert decisions[0].parent_id is None
assert decisions[1].parent_id is not None
@@ -0,0 +1,155 @@
"""Step definitions for plan_lifecycle_service_persistence_coverage.feature.
Targets uncovered persistence-fallback lines in PlanLifecycleService:
- Lines 340-341: get_action() persistence fallback when action not in memory
- Lines 376-380: get_action_by_name() persistence fallback when action not
in memory and linear scan also misses
"""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.plan_lifecycle_service import (
PlanLifecycleService,
)
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import NamespacedName
def _make_action(name: str) -> Action:
"""Create a real Action domain object with the given namespaced name."""
return Action(
namespaced_name=NamespacedName.parse(name),
description=f"Persisted action {name}",
definition_of_done="All tests pass",
strategy_actor="openai/gpt-4",
execution_actor="openai/gpt-4",
state=ActionState.AVAILABLE,
)
def _build_mock_uow(action_by_name_map: dict[str, Action | None]) -> MagicMock:
"""Build a mock UnitOfWork whose transaction context returns actions.
``action_by_name_map`` maps action name strings to Action objects
(or None for not-found). The mock ctx.actions.get_by_name(name) will
look up the name in this map and return the corresponding value.
"""
mock_uow = MagicMock()
mock_ctx = MagicMock()
mock_ctx.actions.get_by_name.side_effect = lambda n: action_by_name_map.get(n)
@contextmanager
def _transaction():
yield mock_ctx
mock_uow.transaction = _transaction
return mock_uow
# -----------------------------------------------------------------
# Background
# -----------------------------------------------------------------
@given("a plan lifecycle service with a mock unit of work")
def step_create_service_with_mock_uow(context: Context) -> None:
"""Create a PlanLifecycleService backed by a mock UnitOfWork."""
settings = MagicMock()
# Start with an empty action map; scenarios will populate it.
context.action_by_name_map: dict[str, Action | None] = {}
mock_uow = _build_mock_uow(context.action_by_name_map)
context.service = PlanLifecycleService(settings=settings, unit_of_work=mock_uow)
context.result_action = None
# -----------------------------------------------------------------
# get_action persistence fallback (lines 340-341)
# -----------------------------------------------------------------
@given('an action "{name}" exists only in the persistence layer')
def step_action_in_persistence_only(context: Context, name: str) -> None:
"""Add an action to the mock persistence layer but NOT to in-memory cache."""
action = _make_action(name)
# Add to the mock UoW lookup map so ctx.actions.get_by_name finds it
context.action_by_name_map[name] = action
# Ensure it is NOT in the in-memory cache
context.service._actions.pop(name, None)
@when('I call get_action with "{name}"')
def step_call_get_action(context: Context, name: str) -> None:
"""Call service.get_action() which should fall back to persistence."""
context.result_action = context.service.get_action(name)
@then('the returned action should have namespaced name "{name}"')
def step_check_returned_action_name(context: Context, name: str) -> None:
"""Verify the returned action has the expected namespaced name."""
assert context.result_action is not None, "Expected an action, got None"
assert str(context.result_action.namespaced_name) == name, (
f"Expected namespaced name '{name}', "
f"got '{context.result_action.namespaced_name}'"
)
@then('the action "{name}" should now be cached in memory')
def step_check_action_cached(context: Context, name: str) -> None:
"""Verify the action was added to the in-memory _actions cache."""
assert name in context.service._actions, (
f"Expected '{name}' in _actions cache, "
f"but found keys: {list(context.service._actions.keys())}"
)
assert str(context.service._actions[name].namespaced_name) == name
# -----------------------------------------------------------------
# get_action_by_name persistence fallback (lines 376-380)
# -----------------------------------------------------------------
@given('an action "{name}" exists only in the persistence layer for name lookup')
def step_action_in_persistence_for_name_lookup(context: Context, name: str) -> None:
"""Add an action to the mock persistence layer for get_action_by_name.
The action must not be in the in-memory _actions dict at all, so that
both the direct dict lookup AND the linear scan miss, forcing the
persistence fallback on lines 376-380.
"""
action = _make_action(name)
# Add to the mock UoW lookup map
context.action_by_name_map[name] = action
# Ensure it is NOT in the in-memory cache
context.service._actions.pop(name, None)
@when('I call get_action_by_name with "{name}"')
def step_call_get_action_by_name(context: Context, name: str) -> None:
"""Call service.get_action_by_name() which should fall back to persistence."""
context.result_action = context.service.get_action_by_name(name)
@then('the returned action from name lookup should have namespaced name "{name}"')
def step_check_returned_action_by_name(context: Context, name: str) -> None:
"""Verify the returned action has the expected namespaced name."""
assert context.result_action is not None, "Expected an action, got None"
assert str(context.result_action.namespaced_name) == name, (
f"Expected namespaced name '{name}', "
f"got '{context.result_action.namespaced_name}'"
)
@then('the action "{name}" should now be cached in memory after name lookup')
def step_check_action_cached_after_name_lookup(context: Context, name: str) -> None:
"""Verify the action was added to the in-memory _actions cache."""
assert name in context.service._actions, (
f"Expected '{name}' in _actions cache, "
f"but found keys: {list(context.service._actions.keys())}"
)
assert str(context.service._actions[name].namespaced_name) == name
+736
View File
@@ -0,0 +1,736 @@
"""Behave step implementations for repl_coverage.feature.
Provides full unit-level coverage of every function and branch in
``src/cleveragents/cli/commands/repl.py``.
"""
from __future__ import annotations
import os
import tempfile
from io import StringIO
from pathlib import Path
from unittest.mock import MagicMock, call, patch
from behave import given, then, when
from behave.runner import Context
# ===================================================================
# Helpers
# ===================================================================
class _FakeInputCov:
"""Feed a list of strings then raise EOFError."""
def __init__(self, lines: list[str]) -> None:
self._lines = list(lines)
self._idx = 0
def __call__(self, prompt: str = "") -> str:
if self._idx >= len(self._lines):
raise EOFError
line = self._lines[self._idx]
self._idx += 1
if line == "__KEYBOARD_INTERRUPT__":
raise KeyboardInterrupt
return line
def _make_console_and_buf():
"""Return a (Console, StringIO) pair for capturing Rich output."""
from rich.console import Console
buf = StringIO()
console = Console(file=buf, no_color=True, width=200)
return console, buf
# ===================================================================
# Constants
# ===================================================================
@given("the repl module is imported")
def step_import_repl_module(context: Context) -> None:
import cleveragents.cli.commands.repl as mod
context.repl_mod = mod
@then("the constant _MULTILINE_CONTINUATION equals backslash")
def step_check_multiline_continuation(context: Context) -> None:
assert context.repl_mod._MULTILINE_CONTINUATION == "\\"
@then('the constant _LAST_COMMAND_TOKEN equals "!!"')
def step_check_last_command_token(context: Context) -> None:
assert context.repl_mod._LAST_COMMAND_TOKEN == "!!"
@then("the _REPL_COMMANDS list is non-empty")
def step_repl_commands_non_empty(context: Context) -> None:
assert len(context.repl_mod._REPL_COMMANDS) > 0
@then("the _BUILTIN_COMMANDS list contains help exit and quit")
def step_builtin_commands(context: Context) -> None:
builtins_list = context.repl_mod._BUILTIN_COMMANDS
for token in (":help", ":exit", ":quit"):
assert token in builtins_list, f"{token} not in _BUILTIN_COMMANDS"
# ===================================================================
# _get_prompt_context
# ===================================================================
@given("neither CLEVERAGENTS_PROJECT nor CLEVERAGENTS_PLAN is set")
def step_clear_prompt_env(context: Context) -> None:
context.saved_project = os.environ.pop("CLEVERAGENTS_PROJECT", None)
context.saved_plan = os.environ.pop("CLEVERAGENTS_PLAN", None)
def _restore():
if context.saved_project is not None:
os.environ["CLEVERAGENTS_PROJECT"] = context.saved_project
if context.saved_plan is not None:
os.environ["CLEVERAGENTS_PLAN"] = context.saved_plan
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_restore)
@given('CLEVERAGENTS_PROJECT is set to "proj1" and CLEVERAGENTS_PLAN is unset')
def step_project_only_env(context: Context) -> None:
context.saved_project = os.environ.get("CLEVERAGENTS_PROJECT")
context.saved_plan = os.environ.get("CLEVERAGENTS_PLAN")
os.environ["CLEVERAGENTS_PROJECT"] = "proj1"
os.environ.pop("CLEVERAGENTS_PLAN", None)
def _restore():
if context.saved_project is not None:
os.environ["CLEVERAGENTS_PROJECT"] = context.saved_project
else:
os.environ.pop("CLEVERAGENTS_PROJECT", None)
if context.saved_plan is not None:
os.environ["CLEVERAGENTS_PLAN"] = context.saved_plan
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_restore)
@given('CLEVERAGENTS_PROJECT is set to "proj1" and CLEVERAGENTS_PLAN is set to "plan1"')
def step_project_and_plan_env(context: Context) -> None:
context.saved_project = os.environ.get("CLEVERAGENTS_PROJECT")
context.saved_plan = os.environ.get("CLEVERAGENTS_PLAN")
os.environ["CLEVERAGENTS_PROJECT"] = "proj1"
os.environ["CLEVERAGENTS_PLAN"] = "plan1"
def _restore():
if context.saved_project is not None:
os.environ["CLEVERAGENTS_PROJECT"] = context.saved_project
else:
os.environ.pop("CLEVERAGENTS_PROJECT", None)
if context.saved_plan is not None:
os.environ["CLEVERAGENTS_PLAN"] = context.saved_plan
else:
os.environ.pop("CLEVERAGENTS_PLAN", None)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_restore)
@when("I call _get_prompt_context")
def step_call_get_prompt_context(context: Context) -> None:
from cleveragents.cli.commands.repl import _get_prompt_context
context.prompt_result = _get_prompt_context()
@then('the prompt result is "{expected}"')
def step_check_prompt_result(context: Context, expected: str) -> None:
assert context.prompt_result == expected, (
f"Expected {expected!r}, got {context.prompt_result!r}"
)
# ===================================================================
# _setup_history
# ===================================================================
@given("a temporary history file that exists")
def step_temp_history_exists(context: Context) -> None:
fd, path = tempfile.mkstemp(suffix="_hist")
os.close(fd)
context.history_path = Path(path)
def _cleanup():
context.history_path.unlink(missing_ok=True)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_cleanup)
@given("a temporary history path that does not exist")
def step_temp_history_not_exists(context: Context) -> None:
tmpdir = tempfile.mkdtemp()
context.history_path = Path(tmpdir) / "subdir" / "nonexistent_history"
def _cleanup():
import shutil
shutil.rmtree(tmpdir, ignore_errors=True)
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(_cleanup)
@when("I call _setup_history with that path")
def step_call_setup_history(context: Context) -> None:
from cleveragents.cli.commands.repl import _setup_history
with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
_setup_history(context.history_path)
context.mock_readline = mock_rl
@then("readline.read_history_file is called with the path")
def step_rl_read_called(context: Context) -> None:
context.mock_readline.read_history_file.assert_called_once_with(
str(context.history_path)
)
@then("readline.read_history_file is not called")
def step_rl_read_not_called(context: Context) -> None:
context.mock_readline.read_history_file.assert_not_called()
@then("readline.set_history_length is called with 10000")
def step_rl_set_length(context: Context) -> None:
context.mock_readline.set_history_length.assert_called_once_with(10_000)
# ===================================================================
# _save_history
# ===================================================================
@given("a temporary history path for saving")
def step_temp_save_path(context: Context) -> None:
context.save_path = Path(tempfile.mkdtemp()) / "save_hist"
@when("I call _save_history with that path")
def step_call_save_history(context: Context) -> None:
from cleveragents.cli.commands.repl import _save_history
with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
_save_history(context.save_path)
context.mock_readline = mock_rl
@then("readline.write_history_file is called with the path")
def step_rl_write_called(context: Context) -> None:
context.mock_readline.write_history_file.assert_called_once_with(
str(context.save_path)
)
@given("readline.write_history_file will raise OSError")
def step_rl_write_raises(context: Context) -> None:
context.oserror_flag = True
@when("I call _save_history with a dummy path")
def step_call_save_history_oserror(context: Context) -> None:
from cleveragents.cli.commands.repl import _save_history
with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
mock_rl.write_history_file.side_effect = OSError("disk full")
context.exc = None
try:
_save_history(Path("/tmp/dummy_hist"))
except Exception as exc:
context.exc = exc
@then("no exception is raised")
def step_no_exception(context: Context) -> None:
assert context.exc is None, f"Unexpected exception: {context.exc}"
# ===================================================================
# _setup_completer
# ===================================================================
@when("I call _setup_completer")
def step_call_setup_completer(context: Context) -> None:
from cleveragents.cli.commands.repl import _setup_completer
with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
_setup_completer()
context.mock_readline = mock_rl
# Grab the completer function that was passed to set_completer
context.completer_fn = mock_rl.set_completer.call_args[0][0]
@then("readline.set_completer is called with a callable")
def step_rl_set_completer(context: Context) -> None:
assert context.mock_readline.set_completer.called
assert callable(context.completer_fn)
@then("readline.set_completer_delims is called")
def step_rl_set_delims(context: Context) -> None:
context.mock_readline.set_completer_delims.assert_called_once()
@then("readline.parse_and_bind is called with tab complete")
def step_rl_parse_bind(context: Context) -> None:
context.mock_readline.parse_and_bind.assert_called_once_with("tab: complete")
@given("the completer is installed via _setup_completer")
def step_install_completer(context: Context) -> None:
from cleveragents.cli.commands.repl import _setup_completer
with patch("cleveragents.cli.commands.repl.readline") as mock_rl:
_setup_completer()
context.completer_fn = mock_rl.set_completer.call_args[0][0]
@when('I query the completer with text "{text}" and state {state:d}')
def step_query_completer(context: Context, text: str, state: int) -> None:
context.completer_result = context.completer_fn(text, state)
@when("I query the completer with empty text and state {state:d}")
def step_query_completer_empty(context: Context, state: int) -> None:
context.completer_result = context.completer_fn("", state)
@then('the completer returns "{expected}"')
def step_completer_returns(context: Context, expected: str) -> None:
assert context.completer_result == expected, (
f"Expected {expected!r}, got {context.completer_result!r}"
)
@then("the completer returns None")
def step_completer_returns_none(context: Context) -> None:
assert context.completer_result is None
@then("the completer returns a non-None value")
def step_completer_returns_non_none(context: Context) -> None:
assert context.completer_result is not None, "Expected non-None from completer"
# ===================================================================
# _read_multiline
# ===================================================================
@given('builtins.input will return "continued line"')
def step_input_returns_single(context: Context) -> None:
context.fake_continuation_lines = ["continued line"]
context.multiline_initial = "first\\"
@given("builtins.input will return chained continuations")
def step_input_returns_chained(context: Context) -> None:
context.fake_continuation_lines = ["second\\", "third"]
context.multiline_initial = "first\\"
@given("builtins.input will raise EOFError on continuation")
def step_input_raises_eof(context: Context) -> None:
context.fake_continuation_lines = "__EOF__"
context.multiline_initial = "only\\"
@given("builtins.input will raise KeyboardInterrupt on continuation")
def step_input_raises_kbi(context: Context) -> None:
context.fake_continuation_lines = "__KBI__"
context.multiline_initial = "only\\"
@when("I call _read_multiline with initial line ending in backslash")
def step_call_read_multiline(context: Context) -> None:
from cleveragents.cli.commands.repl import _read_multiline
if context.fake_continuation_lines == "__EOF__":
fake = MagicMock(side_effect=EOFError)
elif context.fake_continuation_lines == "__KBI__":
fake = MagicMock(side_effect=KeyboardInterrupt)
else:
fake = _FakeInputCov(context.fake_continuation_lines)
with patch("builtins.input", fake):
context.multiline_result = _read_multiline(context.multiline_initial)
@then('the multiline result is "{expected}"')
def step_check_multiline_result(context: Context, expected: str) -> None:
assert context.multiline_result == expected, (
f"Expected {expected!r}, got {context.multiline_result!r}"
)
# ===================================================================
# _print_repl_help
# ===================================================================
@when("I call _print_repl_help")
def step_call_print_repl_help(context: Context) -> None:
from cleveragents.cli.commands.repl import _print_repl_help
console, buf = _make_console_and_buf()
context.help_exc = None
try:
with patch("cleveragents.cli.commands.repl._console", console):
_print_repl_help()
except Exception as exc:
context.help_exc = exc
context.help_output = buf.getvalue()
@then("no exception is raised from _print_repl_help")
def step_no_help_exception(context: Context) -> None:
assert context.help_exc is None, f"Unexpected exception: {context.help_exc}"
# ===================================================================
# dispatch_command
# ===================================================================
@given("the root Typer app is mocked to succeed")
def step_mock_typer_success(context: Context) -> None:
context.dispatch_typer_side_effect = None
@given("the root Typer app raises SystemExit with code 42")
def step_mock_typer_sysexit_42(context: Context) -> None:
context.dispatch_typer_side_effect = SystemExit(42)
@given("the root Typer app raises SystemExit with None code")
def step_mock_typer_sysexit_none(context: Context) -> None:
context.dispatch_typer_side_effect = SystemExit(None)
@given("the root Typer app raises KeyboardInterrupt")
def step_mock_typer_kbi(context: Context) -> None:
context.dispatch_typer_side_effect = KeyboardInterrupt()
@given("the root Typer app raises RuntimeError boom")
def step_mock_typer_runtime_error(context: Context) -> None:
context.dispatch_typer_side_effect = RuntimeError("boom")
@when('I call dispatch_command with args "{arg}"')
def step_call_dispatch_command(context: Context, arg: str) -> None:
from cleveragents.cli.commands.repl import dispatch_command
console, buf = _make_console_and_buf()
mock_app = MagicMock()
if context.dispatch_typer_side_effect is not None:
mock_app.side_effect = context.dispatch_typer_side_effect
with (
patch("cleveragents.cli.commands.repl._console", console),
patch(
"cleveragents.cli.main._register_subcommands",
MagicMock(),
),
patch("cleveragents.cli.main.app", mock_app),
):
context.dispatch_rc = dispatch_command([arg])
context.dispatch_output = buf.getvalue()
@then("the dispatch return code is {code:d}")
def step_check_dispatch_rc(context: Context, code: int) -> None:
assert context.dispatch_rc == code, (
f"Expected dispatch rc={code}, got {context.dispatch_rc}"
)
@then('the dispatch error output contains "boom"')
def step_dispatch_output_boom(context: Context) -> None:
assert "boom" in context.dispatch_output, (
f"Expected 'boom' in output: {context.dispatch_output}"
)
# ===================================================================
# run_repl — shared helpers
# ===================================================================
def _run_repl_cov(
lines: list[str],
*,
no_history: bool = True,
mock_dispatch: bool = False,
mock_setup_hist: bool = False,
mock_save_hist: bool = False,
):
"""Run the REPL with mocked input/console and optionally mocked dispatch."""
from cleveragents.cli.commands.repl import run_repl
console, buf = _make_console_and_buf()
fake = _FakeInputCov(lines)
patches = [
patch("cleveragents.cli.commands.repl._console", console),
patch("builtins.input", fake),
]
mock_dispatch_obj = None
mock_setup_obj = None
mock_save_obj = None
if mock_dispatch:
m = MagicMock(return_value=0)
patches.append(patch("cleveragents.cli.commands.repl.dispatch_command", m))
mock_dispatch_obj = m
if mock_setup_hist:
m = MagicMock()
patches.append(patch("cleveragents.cli.commands.repl._setup_history", m))
mock_setup_obj = m
if mock_save_hist:
m = MagicMock()
patches.append(patch("cleveragents.cli.commands.repl._save_history", m))
mock_save_obj = m
for p in patches:
p.start()
try:
code = run_repl(no_history=no_history)
finally:
for p in patches:
p.stop()
return code, buf.getvalue(), mock_dispatch_obj, mock_setup_obj, mock_save_obj
# --- Given steps for REPL input sequences (using tables) ---
@given("the REPL input sequence is")
def step_repl_input_from_table(context: Context) -> None:
context.cov_repl_lines = [row["line"] for row in context.table]
@given("the REPL input sequence is empty")
def step_repl_input_empty(context: Context) -> None:
context.cov_repl_lines = []
@given('the REPL input sequence for multiline is "version \\" then "" then ":exit"')
def step_repl_input_multiline(context: Context) -> None:
context.cov_repl_lines = ["version \\", "", ":exit"]
@given("the REPL input sequence for interrupt then exit")
def step_repl_input_kbi(context: Context) -> None:
context.cov_repl_lines = ["__KEYBOARD_INTERRUPT__", ":exit"]
@given("the REPL input sequence has an unclosed quote then exit")
def step_repl_input_shlex_error(context: Context) -> None:
context.cov_repl_lines = ['"unclosed', ":exit"]
@given("dispatch_command is mocked for run_repl")
def step_mock_dispatch_for_repl(context: Context) -> None:
context.mock_dispatch_flag = True
# --- When steps ---
@when("I run the REPL loop")
def step_run_repl_loop(context: Context) -> None:
mock_dispatch = getattr(context, "mock_dispatch_flag", False)
code, output, mock_d, _, _ = _run_repl_cov(
context.cov_repl_lines,
no_history=True,
mock_dispatch=mock_dispatch,
)
context.cov_repl_exit_code = code
context.cov_repl_output = output
context.cov_mock_dispatch = mock_d
@when("I run the REPL loop with history enabled")
def step_run_repl_loop_history(context: Context) -> None:
code, output, _, mock_setup, mock_save = _run_repl_cov(
context.cov_repl_lines,
no_history=False,
mock_setup_hist=True,
mock_save_hist=True,
)
context.cov_repl_exit_code = code
context.cov_repl_output = output
context.cov_mock_setup_hist = mock_setup
context.cov_mock_save_hist = mock_save
@when("I run the REPL loop with no_history")
def step_run_repl_loop_no_history(context: Context) -> None:
code, output, _, mock_setup, mock_save = _run_repl_cov(
context.cov_repl_lines,
no_history=True,
mock_setup_hist=True,
mock_save_hist=True,
)
context.cov_repl_exit_code = code
context.cov_repl_output = output
context.cov_mock_setup_hist = mock_setup
context.cov_mock_save_hist = mock_save
# ===================================================================
# run_repl — Then steps
# ===================================================================
@then("the REPL loop exit code is {code:d}")
def step_check_repl_loop_ec(context: Context, code: int) -> None:
assert context.cov_repl_exit_code == code, (
f"Expected {code}, got {context.cov_repl_exit_code}"
)
@then('the REPL loop output contains "{text}"')
def step_repl_loop_output_contains(context: Context, text: str) -> None:
assert text in context.cov_repl_output, (
f"Expected {text!r} in output:\n{context.cov_repl_output}"
)
@then('dispatch_command was called at least 2 times with "{arg}"')
def step_dispatch_called_twice(context: Context, arg: str) -> None:
assert context.cov_mock_dispatch is not None
calls_matching = [
c for c in context.cov_mock_dispatch.call_args_list if c == call([arg])
]
assert len(calls_matching) >= 2, (
f"Expected dispatch(['{arg}']) >= 2 times, got {len(calls_matching)}: "
f"{context.cov_mock_dispatch.call_args_list}"
)
@then('dispatch_command was called with args "{arg}"')
def step_dispatch_called_with(context: Context, arg: str) -> None:
assert context.cov_mock_dispatch is not None
context.cov_mock_dispatch.assert_any_call([arg])
@then("_setup_history was called")
def step_setup_hist_called(context: Context) -> None:
assert context.cov_mock_setup_hist.called
@then("_setup_history was not called")
def step_setup_hist_not_called(context: Context) -> None:
assert not context.cov_mock_setup_hist.called
@then("_save_history was called")
def step_save_hist_called(context: Context) -> None:
assert context.cov_mock_save_hist.called
@then("_save_history was not called")
def step_save_hist_not_called(context: Context) -> None:
assert not context.cov_mock_save_hist.called
# ===================================================================
# repl_callback
# ===================================================================
@given("sys.stdout.isatty returns True")
def step_isatty_true(context: Context) -> None:
context.isatty_value = True
@given("sys.stdout.isatty returns False")
def step_isatty_false(context: Context) -> None:
context.isatty_value = False
@given("run_repl is mocked to return 0")
def step_mock_run_repl(context: Context) -> None:
context.mock_run_repl_rc = 0
@given("CLEVERAGENTS_FORCE_REPL is set in the environment")
def step_force_repl_set(context: Context) -> None:
os.environ["CLEVERAGENTS_FORCE_REPL"] = "1"
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(
lambda: os.environ.pop("CLEVERAGENTS_FORCE_REPL", None)
)
@given("CLEVERAGENTS_FORCE_REPL is not set")
def step_force_repl_unset(context: Context) -> None:
os.environ.pop("CLEVERAGENTS_FORCE_REPL", None)
@when("I call repl_callback")
def step_call_repl_callback(context: Context) -> None:
from cleveragents.cli.commands.repl import repl_callback
mock_isatty = MagicMock(return_value=context.isatty_value)
mock_run = MagicMock(return_value=getattr(context, "mock_run_repl_rc", 0))
context.cov_repl_callback_exc = None
context.cov_mock_run_repl = mock_run
with (
patch("cleveragents.cli.commands.repl.sys") as mock_sys,
patch("cleveragents.cli.commands.repl.run_repl", mock_run),
):
mock_sys.stdout.isatty = mock_isatty
try:
repl_callback(no_history=False, history_path=Path("/tmp/fake_hist"))
except SystemExit as exc:
context.cov_repl_callback_exc = exc
@then("run_repl was invoked")
def step_run_repl_invoked(context: Context) -> None:
assert context.cov_mock_run_repl.called, "run_repl was not called"
@then("run_repl was not invoked")
def step_run_repl_not_invoked(context: Context) -> None:
assert not context.cov_mock_run_repl.called, "run_repl should not have been called"
@then("repl_callback raises SystemExit with code 0")
def step_callback_sysexit_0(context: Context) -> None:
assert context.cov_repl_callback_exc is not None, "Expected SystemExit"
assert context.cov_repl_callback_exc.code == 0, (
f"Expected SystemExit(0), got SystemExit({context.cov_repl_callback_exc.code})"
)
@@ -0,0 +1,514 @@
"""Step definitions for ResourceHandlerService full coverage tests.
All step patterns are prefixed with 'rhs' to avoid collisions with other
step definition files in the same features/steps directory.
"""
from __future__ import annotations
import contextlib
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.application.services.resource_handler_service import (
ResourceHandlerService,
)
from cleveragents.core.exceptions import NotFoundError
from cleveragents.domain.models.core.resource_slot import BindingResult
from cleveragents.resource.handlers.resolver import HandlerResolutionError
from cleveragents.tool.context import BoundResource
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_resource_mock(
resource_id,
resource_type_name,
location=None,
sandbox_strategy=None,
):
"""Create a mock Resource with the given attributes."""
resource = MagicMock()
resource.resource_id = resource_id
resource.resource_type_name = resource_type_name
resource.location = location
# sandbox_strategy=None means no override; "none" is a valid strategy string
resource.sandbox_strategy = sandbox_strategy
return resource
def _make_type_spec_mock(
name,
handler=None,
sandbox_strategy="copy_on_write",
):
"""Create a mock ResourceTypeSpec with the given attributes."""
spec = MagicMock()
spec.name = name
spec.handler = handler
spec.sandbox_strategy = sandbox_strategy
return spec
def _make_binding_obj(
slot_name,
resource_id=None,
deferred=False,
):
"""Create a BindingResult with the given attributes."""
return BindingResult(
slot_name=slot_name,
resource_id=resource_id,
binding_mode="parameter" if deferred else "contextual",
deferred=deferred,
)
def _make_sandbox_mock(sandbox_path):
"""Create a mock Sandbox with a context carrying sandbox_path."""
sandbox = MagicMock()
if sandbox_path is not None:
sandbox.context = MagicMock()
sandbox.context.sandbox_path = sandbox_path
else:
sandbox.context = None
return sandbox
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a mock sandbox manager for rhs")
def step_rhs_mock_sandbox_manager(context):
context.rhs_sandbox_manager = MagicMock()
context.rhs_sandbox_call_args = None
@given("a mock resource lookup for rhs")
def step_rhs_mock_resource_lookup(context):
context.rhs_resources = {}
context.rhs_resource_lookup_error = {}
def _lookup(name_or_id):
if name_or_id in context.rhs_resource_lookup_error:
raise context.rhs_resource_lookup_error[name_or_id]
return context.rhs_resources[name_or_id]
context.rhs_resource_lookup = _lookup
@given("a mock type lookup for rhs")
def step_rhs_mock_type_lookup(context):
context.rhs_type_specs = {}
def _lookup(name):
return context.rhs_type_specs[name]
context.rhs_type_lookup = _lookup
@given("a ResourceHandlerService under test")
def step_rhs_service_instance(context):
context.rhs_service = ResourceHandlerService(
sandbox_manager=context.rhs_sandbox_manager,
resource_lookup=context.rhs_resource_lookup,
type_lookup=context.rhs_type_lookup,
)
context.rhs_result = None
context.rhs_error = None
context.rhs_resolve_handler_patcher = None
# ---------------------------------------------------------------------------
# Binding setup
# ---------------------------------------------------------------------------
@given('an rhs binding that is deferred with slot name "{slot}"')
def step_rhs_deferred_binding(context, slot):
context.rhs_binding = _make_binding_obj(slot_name=slot, deferred=True)
@given('an rhs binding with no resource_id and slot name "{slot}"')
def step_rhs_binding_no_resource_id(context, slot):
context.rhs_binding = _make_binding_obj(
slot_name=slot, resource_id=None, deferred=False
)
@given('an rhs non-deferred binding with slot "{slot}" and resource_id "{rid}"')
def step_rhs_non_deferred_binding(context, slot, rid):
context.rhs_binding = _make_binding_obj(
slot_name=slot, resource_id=rid, deferred=False
)
# ---------------------------------------------------------------------------
# Resource setup — single step for location + strategy
# ---------------------------------------------------------------------------
@given(
'rhs resource "{rid}" typed "{rtype}" located at "{loc}" with strategy "{strat}"'
)
def step_rhs_resource_with_loc_and_strat(context, rid, rtype, loc, strat):
# Convert the string "none" to Python None for sandbox_strategy
strategy = None if strat == "none" else strat
context.rhs_resources[rid] = _make_resource_mock(
resource_id=rid,
resource_type_name=rtype,
location=loc,
sandbox_strategy=strategy,
)
@given('rhs resource "{rid}" typed "{rtype}" with no location')
def step_rhs_resource_no_location(context, rid, rtype):
context.rhs_resources[rid] = _make_resource_mock(
resource_id=rid,
resource_type_name=rtype,
location=None,
sandbox_strategy=None,
)
# ---------------------------------------------------------------------------
# Type spec setup
# ---------------------------------------------------------------------------
@given('an rhs type spec "{name}" with no handler and sandbox strategy "{strat}"')
def step_rhs_type_spec_no_handler(context, name, strat):
context.rhs_type_specs[name] = _make_type_spec_mock(
name=name, handler=None, sandbox_strategy=strat
)
@given(
'an rhs type spec "{name}" with handler "{handler}" and sandbox strategy "{strat}"'
)
def step_rhs_type_spec_with_handler(context, name, handler, strat):
context.rhs_type_specs[name] = _make_type_spec_mock(
name=name, handler=handler, sandbox_strategy=strat
)
# ---------------------------------------------------------------------------
# Sandbox manager setup
# ---------------------------------------------------------------------------
@given('the rhs sandbox manager returns a sandbox with path "{path}"')
def step_rhs_sandbox_returns_path(context, path):
sandbox = _make_sandbox_mock(sandbox_path=path)
def _capture_call(**kwargs):
context.rhs_sandbox_call_args = kwargs
return sandbox
context.rhs_sandbox_manager.get_or_create_sandbox = MagicMock(
side_effect=_capture_call
)
@given("the rhs sandbox manager returns a sandbox with no context")
def step_rhs_sandbox_returns_no_context(context):
sandbox = _make_sandbox_mock(sandbox_path=None)
context.rhs_sandbox_manager.get_or_create_sandbox = MagicMock(return_value=sandbox)
# ---------------------------------------------------------------------------
# Handler mock setup
# ---------------------------------------------------------------------------
@given("the rhs resolve_handler function returns a mock handler")
def step_rhs_resolve_handler_returns_mock(context):
context.rhs_mock_handler = MagicMock()
context.rhs_resolve_handler_patcher = patch(
"cleveragents.application.services.resource_handler_service.resolve_handler",
return_value=context.rhs_mock_handler,
)
context.rhs_resolve_handler_patcher.start()
@given('the rhs mock handler resolves to a BoundResource with sandbox_path "{path}"')
def step_rhs_mock_handler_returns_bound(context, path):
def _resolve(**kwargs):
return BoundResource(
slot_name=kwargs["slot_name"],
resource_id=kwargs["resource"].resource_id,
resource_type=kwargs["resource"].resource_type_name,
sandbox_path=path,
access=kwargs.get("access", "read_only"),
)
context.rhs_mock_handler.resolve = MagicMock(side_effect=_resolve)
@given("the rhs resolve_handler function raises HandlerResolutionError")
def step_rhs_resolve_handler_raises(context):
context.rhs_resolve_handler_patcher = patch(
"cleveragents.application.services.resource_handler_service.resolve_handler",
side_effect=HandlerResolutionError("handler not found"),
)
context.rhs_resolve_handler_patcher.start()
# ---------------------------------------------------------------------------
# Error setup for resource lookup
# ---------------------------------------------------------------------------
@given('the rhs resource lookup raises NotFoundError for "{rid}"')
def step_rhs_resource_lookup_raises(context, rid):
context.rhs_resource_lookup_error[rid] = NotFoundError(
message=f"Resource '{rid}' not found"
)
# ---------------------------------------------------------------------------
# Binding list setup
# ---------------------------------------------------------------------------
@given("an rhs binding list with:")
def step_rhs_binding_list(context):
context.rhs_bindings = []
for row in context.table:
slot_name = row["slot_name"]
resource_id = row["resource_id"] if row["resource_id"] else None
deferred = row["deferred"].lower() == "true"
context.rhs_bindings.append(
_make_binding_obj(
slot_name=slot_name, resource_id=resource_id, deferred=deferred
)
)
# ---------------------------------------------------------------------------
# Cleanup helper
# ---------------------------------------------------------------------------
def _rhs_cleanup_patcher(context):
"""Stop the resolve_handler patcher if active."""
patcher = getattr(context, "rhs_resolve_handler_patcher", None)
if patcher is not None:
with contextlib.suppress(RuntimeError):
patcher.stop()
context.rhs_resolve_handler_patcher = None
# ---------------------------------------------------------------------------
# When: resolve_binding
# ---------------------------------------------------------------------------
@when("I resolve the rhs single binding")
def step_rhs_resolve_single_binding_default(context):
try:
context.rhs_result = context.rhs_service.resolve_binding(
binding=context.rhs_binding,
plan_id="default-plan",
access="read_only",
)
except Exception as exc:
context.rhs_error = exc
finally:
_rhs_cleanup_patcher(context)
@when('I resolve the rhs single binding with plan_id "{pid}" and access "{access}"')
def step_rhs_resolve_single_binding(context, pid, access):
try:
context.rhs_result = context.rhs_service.resolve_binding(
binding=context.rhs_binding,
plan_id=pid,
access=access,
)
except Exception as exc:
context.rhs_error = exc
finally:
_rhs_cleanup_patcher(context)
# ---------------------------------------------------------------------------
# When: resolve_bindings
# ---------------------------------------------------------------------------
@when('I resolve all rhs bindings with plan_id "{pid}" and access "{access}"')
def step_rhs_resolve_all_bindings(context, pid, access):
try:
context.rhs_result = context.rhs_service.resolve_bindings(
bindings=context.rhs_bindings,
plan_id=pid,
access=access,
)
except Exception as exc:
context.rhs_error = exc
finally:
_rhs_cleanup_patcher(context)
@when("I resolve all rhs bindings expecting an error")
def step_rhs_resolve_all_bindings_error(context):
try:
context.rhs_result = context.rhs_service.resolve_bindings(
bindings=context.rhs_bindings,
plan_id="error-plan",
access="read_only",
)
except Exception as exc:
context.rhs_error = exc
finally:
_rhs_cleanup_patcher(context)
# ---------------------------------------------------------------------------
# When: resolve_resource
# ---------------------------------------------------------------------------
@when(
'I resolve the rhs resource directly with plan_id "{pid}" slot "{slot}" and access "{access}"'
)
def step_rhs_resolve_resource_directly(context, pid, slot, access):
# Use the most recently added resource
rid = list(context.rhs_resources.keys())[-1]
resource = context.rhs_resources[rid]
try:
context.rhs_result = context.rhs_service.resolve_resource(
resource=resource,
plan_id=pid,
slot_name=slot,
access=access,
)
except Exception as exc:
context.rhs_error = exc
finally:
_rhs_cleanup_patcher(context)
# ---------------------------------------------------------------------------
# Then: BoundResource assertions
# ---------------------------------------------------------------------------
@then('the rhs result is a BoundResource with slot "{slot}" and sandbox_path "{path}"')
def step_rhs_assert_bound_resource(context, slot, path):
assert context.rhs_error is None, f"Unexpected error: {context.rhs_error}"
assert isinstance(context.rhs_result, BoundResource), (
f"Expected BoundResource, got {type(context.rhs_result)}"
)
assert context.rhs_result.slot_name == slot, (
f"Expected slot_name '{slot}', got '{context.rhs_result.slot_name}'"
)
assert context.rhs_result.sandbox_path == path, (
f"Expected sandbox_path '{path}', got '{context.rhs_result.sandbox_path}'"
)
@then('the rhs BoundResource has resource_id "{rid}" and access "{access}"')
def step_rhs_assert_bound_resource_details(context, rid, access):
assert context.rhs_result.resource_id == rid, (
f"Expected resource_id '{rid}', got '{context.rhs_result.resource_id}'"
)
assert context.rhs_result.access == access, (
f"Expected access '{access}', got '{context.rhs_result.access}'"
)
# ---------------------------------------------------------------------------
# Then: error assertions
# ---------------------------------------------------------------------------
@then('an rhs ValueError is raised with message containing "{fragment}"')
def step_rhs_assert_value_error(context, fragment):
assert context.rhs_error is not None, (
"Expected a ValueError but no error was raised"
)
assert isinstance(context.rhs_error, ValueError), (
f"Expected ValueError, got {type(context.rhs_error).__name__}: {context.rhs_error}"
)
assert fragment in str(context.rhs_error), (
f"Expected '{fragment}' in error message, got: {context.rhs_error}"
)
@then("an rhs NotFoundError is raised")
def step_rhs_assert_not_found_error(context):
assert context.rhs_error is not None, (
"Expected a NotFoundError but no error was raised"
)
assert isinstance(context.rhs_error, NotFoundError), (
f"Expected NotFoundError, got {type(context.rhs_error).__name__}: {context.rhs_error}"
)
@then('an rhs RuntimeError is raised with message containing "{fragment}"')
def step_rhs_assert_runtime_error(context, fragment):
assert context.rhs_error is not None, (
"Expected a RuntimeError but no error was raised"
)
assert isinstance(context.rhs_error, RuntimeError), (
f"Expected RuntimeError, got {type(context.rhs_error).__name__}: {context.rhs_error}"
)
assert fragment in str(context.rhs_error), (
f"Expected '{fragment}' in error message, got: {context.rhs_error}"
)
# ---------------------------------------------------------------------------
# Then: dict result assertions
# ---------------------------------------------------------------------------
@then('the rhs result dict has keys "{key1}" and "{key2}"')
def step_rhs_assert_dict_keys(context, key1, key2):
assert context.rhs_error is None, f"Unexpected error: {context.rhs_error}"
assert isinstance(context.rhs_result, dict), (
f"Expected dict, got {type(context.rhs_result)}"
)
assert key1 in context.rhs_result, f"Key '{key1}' not found in result dict"
assert key2 in context.rhs_result, f"Key '{key2}' not found in result dict"
@then('the rhs result dict does not contain key "{key}"')
def step_rhs_assert_dict_missing_key(context, key):
assert key not in context.rhs_result, (
f"Key '{key}' should not be in result dict but was found"
)
@then("the rhs result dict is empty")
def step_rhs_assert_dict_empty(context):
assert context.rhs_error is None, f"Unexpected error: {context.rhs_error}"
assert isinstance(context.rhs_result, dict), (
f"Expected dict, got {type(context.rhs_result)}"
)
assert len(context.rhs_result) == 0, (
f"Expected empty dict, got {len(context.rhs_result)} entries"
)
# ---------------------------------------------------------------------------
# Then: sandbox manager call assertions
# ---------------------------------------------------------------------------
@then('the rhs sandbox manager was called with strategy "{strategy}"')
def step_rhs_assert_sandbox_strategy(context, strategy):
assert context.rhs_error is None, f"Unexpected error: {context.rhs_error}"
assert context.rhs_sandbox_call_args is not None, (
"sandbox_manager.get_or_create_sandbox was not called"
)
actual = context.rhs_sandbox_call_args.get("sandbox_strategy")
assert actual == strategy, f"Expected sandbox_strategy '{strategy}', got '{actual}'"
+201
View File
@@ -0,0 +1,201 @@
# pyright: reportRedeclaration=false
"""Step definitions for skill CLI uncovered-lines coverage."""
from __future__ import annotations
from unittest.mock import patch
from behave import given, then, when
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,
SkillMcpSource,
)
# ── 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,
) -> Skill:
return Skill(
name=name,
description=description,
tool_refs=tool_refs or [],
includes=includes or [],
mcp_servers=mcp_servers or [],
agent_skills=agent_skills or [],
)
# ── Given ───────────────────────────────────────────────────
@given("a clean skill service")
def step_clean_skill_service(context):
_reset_skill_service()
context.runner = CliRunner()
context.service = _get_skill_service()
@given("a skill with an include pointing to a non-registered skill")
def step_skill_with_unregistered_include(context):
skill = _make_skill(
name="local/inc-parent",
includes=[SkillInclude(name="local/missing-child")],
)
context.service._skills[skill.name] = skill
context.skill_name = skill.name
@given("a registered skill for capability error testing")
def step_skill_for_capability_error(context):
skill = _make_skill(
name="local/cap-error",
tool_refs=["builtin/read-file"],
)
context.service._skills[skill.name] = skill
context.skill_name = skill.name
@given("a registered skill for dependent actors testing")
def step_skill_for_dependent_actors(context):
skill = _make_skill(
name="local/dep-actors",
tool_refs=["builtin/write-file"],
)
context.service._skills[skill.name] = skill
context.skill_name = skill.name
@given("a registered skill with agent_skill sources")
def step_skill_with_agent_skills(context):
skill = _make_skill(
name="local/with-agents",
agent_skills=[SkillAgentSource(path="./skills/my-agent")],
)
context.service._skills[skill.name] = skill
context.skill_name = skill.name
@given("a registered skill for removal with actor dependents")
def step_skill_for_removal_actors(context):
skill = _make_skill(
name="local/removable-actors",
tool_refs=["builtin/exec"],
)
context.service._skills[skill.name] = skill
context.skill_name = skill.name
# ── When ────────────────────────────────────────────────────
@when("I run skill show for the skill with unregistered include")
def step_run_show_unregistered_include(context):
context.result = context.runner.invoke(skill_app, ["show", context.skill_name])
@when("I run skill show with capability summary raising ValueError")
def step_run_show_capability_error(context):
with patch.object(
context.service,
"compute_capability_summary",
side_effect=ValueError("boom"),
):
context.result = context.runner.invoke(skill_app, ["show", context.skill_name])
@when("I run skill show with dependents returning actors")
def step_run_show_dependent_actors(context):
with patch.object(
context.service,
"get_dependents",
return_value={"skills": [], "actors": ["actor/alpha", "actor/beta"]},
):
context.result = context.runner.invoke(skill_app, ["show", context.skill_name])
@when("I run skill tools for the agent_skill skill")
def step_run_tools_agent_skill(context):
context.result = context.runner.invoke(skill_app, ["tools", context.skill_name])
@when("I run skill remove with dependents returning actors")
def step_run_remove_with_actor_dependents(context):
with patch.object(
context.service,
"get_dependents",
return_value={
"skills": [],
"actors": ["actor/gamma", "actor/delta"],
},
):
context.result = context.runner.invoke(
skill_app,
["remove", context.skill_name, "--yes"],
)
# ── Then ────────────────────────────────────────────────────
@then("the show output contains the not registered marker")
def step_show_not_registered(context):
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}\n{context.result.output}"
)
assert "not registered" in context.result.output
@then("the show output contains OK and no capability panel")
def step_show_ok_no_capability(context):
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}\n{context.result.output}"
)
assert "OK" in context.result.output
# The capability panel should be absent because the exception was caught
assert "Capability Summary" not in context.result.output
@then("the show output contains the actors reference line")
def step_show_actors_reference(context):
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}\n{context.result.output}"
)
assert "actor/alpha" in context.result.output
assert "actor/beta" in context.result.output
assert "Actors" in context.result.output
@then("the tools output contains agent_skill source type")
def step_tools_agent_skill_source(context):
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}\n{context.result.output}"
)
assert "agent_skill" in context.result.output
@then("the remove output contains the actor dependency warning")
def step_remove_actor_warning(context):
assert context.result.exit_code == 0, (
f"exit_code={context.result.exit_code}\n{context.result.output}"
)
assert "actor(s) reference this skill" in context.result.output
assert "actor/gamma" in context.result.output
assert "actor/delta" in context.result.output
+1 -1
View File
@@ -179,7 +179,7 @@ def step_register_skills_table(context: Context) -> None:
description=row["description"],
)
svc.add_skill(skill)
_commit_pending(context)
_commit_pending(context)
@given('a valid skill named "{name}" with overrides for "{tool_name}"')