Tests: Significantly improved coverage of the unit tests

This commit is contained in:
2026-02-21 23:52:41 -05:00
parent e3aa0984bb
commit ca1c341b18
34 changed files with 5521 additions and 98 deletions
+2 -4
View File
@@ -14,7 +14,6 @@ import os
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch
# Ensure the local *source* tree is importable even when ASV has an
# older build of the package installed.
@@ -30,10 +29,9 @@ from typer.testing import CliRunner # noqa: E402
from cleveragents.cli.commands.automation_profile import ( # noqa: E402
_InMemoryProfileRepository,
app as profile_app,
)
from cleveragents.domain.models.core.automation_profile import ( # noqa: E402
AutomationProfile,
from cleveragents.cli.commands.automation_profile import (
app as profile_app,
)
_VALID_YAML = """\
+4 -4
View File
@@ -7,10 +7,8 @@ cleanup overhead stays within acceptable bounds.
from __future__ import annotations
import importlib
import os
import sys
import tempfile
import time
from pathlib import Path
# Ensure the local *source* tree is importable.
@@ -22,6 +20,8 @@ import cleveragents # noqa: E402
importlib.reload(cleveragents)
from datetime import UTC
from cleveragents.application.services.cleanup_service import ( # noqa: E402
CleanupService,
)
@@ -84,7 +84,7 @@ class SessionScanBenchmark:
"""Benchmark session inactivity scanning."""
def setup(self) -> None:
from datetime import datetime, timedelta, timezone
from datetime import datetime, timedelta
self.settings = _make_settings(
cleanup_session_inactivity_days=30,
@@ -93,7 +93,7 @@ class SessionScanBenchmark:
settings=self.settings,
active_plan_ids=frozenset(),
)
now = datetime.now(tz=timezone.utc)
now = datetime.now(tz=UTC)
self.sessions = [
{
"session_id": f"sess-{i:04d}",
+1 -2
View File
@@ -26,7 +26,6 @@ importlib.reload(cleveragents)
from cleveragents.application.services.plan_apply_service import ( # noqa: E402
PlanApplyService,
_build_artifacts_dict,
_render_diff_json,
_render_diff_plain,
_render_diff_rich,
@@ -41,7 +40,7 @@ from cleveragents.domain.models.core.change import ( # noqa: E402
InMemoryChangeSetStore,
SpecChangeSet,
)
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState # noqa: E402
from cleveragents.domain.models.core.plan import PlanPhase # noqa: E402
def _build_changeset(plan_id: str, n_entries: int = 10) -> SpecChangeSet:
-1
View File
@@ -15,7 +15,6 @@ from sqlalchemy.orm import sessionmaker
from cleveragents.domain.models.core.project import NamespacedProject
from cleveragents.infrastructure.database.models import (
Base,
NamespacedProjectModel,
ResourceModel,
ResourceTypeModel,
)
+1 -4
View File
@@ -11,14 +11,11 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.application.services.audit_service import AuditService
from cleveragents.application.services.audit_service import _TIMESTAMP_FMT, AuditService
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.database.models import AuditLogModel, Base
from cleveragents.application.services.audit_service import _TIMESTAMP_FMT
def _make_service() -> AuditService:
"""Create a service backed by in-memory SQLite."""
Settings._instance = None
-1
View File
@@ -35,7 +35,6 @@ except ModuleNotFoundError:
from cleveragents.skills.context import SkillContext
from cleveragents.skills.protocol import (
SkillDefinition,
SkillError,
SkillMetadata,
)
from cleveragents.skills.registry import SkillRegistry
@@ -0,0 +1,42 @@
Feature: Cleanup CLI uncovered branches
Cover missed lines and branches in cleveragents.cli.commands.cleanup:
L49-50 (inner OSError in _detect_active_plan_ids),
L52-58 (outer OSError),
L8794 (scan with no stale items),
L128-131 (purge dry-run with stale items),
L139143 (purge confirmation prompt accepted).
Scenario: scan reports no stale resources when report is empty
Given cleanup cli branch mock service returns empty report
When cleanup cli branch I invoke scan via main app
Then cleanup cli branch exit code is 0
And cleanup cli branch output contains "No stale resources found"
Scenario: purge dry-run lists stale items when present
Given cleanup cli branch mock service returns report with stale items
When cleanup cli branch I invoke purge dry-run via main app
Then cleanup cli branch exit code is 0
And cleanup cli branch output contains "Would clean:"
And cleanup cli branch output contains "/tmp/fake-sandbox"
Scenario: detect active plan ids handles inner OSError on stat
Given cleanup cli branch a service whose sandbox dir stat raises OSError
When cleanup cli branch I call detect active plan ids
Then cleanup cli branch the result is an empty frozenset
Scenario: detect active plan ids handles outer OSError from get sandbox dirs
Given cleanup cli branch a service whose get sandbox dirs raises OSError
When cleanup cli branch I call detect active plan ids
Then cleanup cli branch the result is an empty frozenset
And cleanup cli branch a warning was printed about active plans
Scenario: detect active plan ids returns empty frozenset for empty dir list
Given cleanup cli branch a service with no sandbox dirs
When cleanup cli branch I call detect active plan ids
Then cleanup cli branch the result is an empty frozenset
Scenario: purge with confirmation prompt accepted proceeds to purge
Given cleanup cli branch mock service returns purge report
When cleanup cli branch I invoke purge with confirmation y via main app
Then cleanup cli branch exit code is 0
And cleanup cli branch output contains "Cleanup Complete"
@@ -0,0 +1,111 @@
@unit @coverage
Feature: CleanupService uncovered lines coverage
As a developer maintaining the CleanupService
I want complete test coverage for all edge-case and error-handling paths
So that garbage-collection behaves correctly under adverse conditions
# _get_sandbox_dirs edge cases
Scenario: _get_sandbox_dirs returns empty when tmp does not exist
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage calls _get_sandbox_dirs with tmp not existing
Then cleanup coverage _get_sandbox_dirs result should be empty
Scenario: _get_sandbox_dirs returns empty when iterdir raises OSError
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage calls _get_sandbox_dirs with iterdir raising OSError
Then cleanup coverage _get_sandbox_dirs result should be empty
Scenario: _get_sandbox_dirs skips entry when is_dir raises OSError
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage calls _get_sandbox_dirs with is_dir raising OSError on one entry
Then cleanup coverage _get_sandbox_dirs result should contain only the safe entry
# ── extract_plan_id_from_sandbox ────────────────────────────────
Scenario: extract_plan_id_from_sandbox returns None for non-matching name
When cleanup coverage extracts plan id from path "random-dir-name"
Then cleanup coverage extracted plan id should be None
# ── _is_sandbox_stale ──────────────────────────────────────────
Scenario: _is_sandbox_stale returns False when stat raises OSError
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage checks staleness of a path that raises OSError on stat
Then cleanup coverage staleness result should be False
# ── _purge_sandboxes ───────────────────────────────────────────
Scenario: _purge_sandboxes increments skipped when rmtree fails
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage purges sandboxes and rmtree raises OSError
Then cleanup coverage purge sandbox report should show skipped count of 1
# ── scan_checkpoints_for_plan ──────────────────────────────────
Scenario: scan_checkpoints_for_plan returns empty when dir does not exist
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage scans checkpoints for a non-existent directory
Then cleanup coverage checkpoint scan result should be empty
Scenario: scan_checkpoints_for_plan returns empty when fewer than 3 files
Given cleanup coverage has a CleanupService with max 1 checkpoint override
When cleanup coverage scans checkpoints for a directory with 2 files
Then cleanup coverage checkpoint scan result should be empty
# ── prune_checkpoints_for_plan ─────────────────────────────────
Scenario: prune_checkpoints_for_plan handles unlink OSError gracefully
Given cleanup coverage has a CleanupService with max 2 checkpoints
When cleanup coverage prunes checkpoints and unlink raises OSError
Then cleanup coverage prune result should be 0
# ── scan_inactive_sessions ─────────────────────────────────────
Scenario: scan_inactive_sessions skips session when updated_at is None
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage scans sessions with a None updated_at entry
Then cleanup coverage inactive sessions result should be empty
Scenario: scan_inactive_sessions parses string updated_at
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage scans sessions with a string updated_at that is old
Then cleanup coverage inactive sessions result should contain that session
Scenario: scan_inactive_sessions adds UTC when tzinfo is missing
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage scans sessions with a naive datetime updated_at that is old
Then cleanup coverage inactive sessions result should contain that session
# ── scan_expired_files ─────────────────────────────────────────
Scenario: scan_expired_files skips entry that is not a file
Given cleanup coverage has a CleanupService with default settings
And cleanup coverage has a temp directory with a subdirectory named "notafile.log"
When cleanup coverage scans expired files in that directory
Then cleanup coverage expired files result should be empty
Scenario: scan_expired_files skips entry when stat raises OSError
Given cleanup coverage has a CleanupService with default settings
When cleanup coverage scans expired files where stat raises OSError
Then cleanup coverage expired files result should be empty
# ── _purge_logs ────────────────────────────────────────────────
Scenario: _purge_logs increments skipped when unlink fails
Given cleanup coverage has a CleanupService with log dir containing expired files
When cleanup coverage purges logs and unlink raises OSError
Then cleanup coverage purge logs report should show skipped count of 1
# ── _purge_backups ─────────────────────────────────────────────
Scenario: _purge_backups increments skipped when unlink fails
Given cleanup coverage has a CleanupService with backup dir containing expired files
When cleanup coverage purges backups and unlink raises OSError
Then cleanup coverage purge backups report should show skipped count of 1
# ── _age_description ───────────────────────────────────────────
Scenario: _age_description returns unknown age when stat raises OSError
When cleanup coverage calls _age_description on a path where stat raises OSError
Then cleanup coverage age description should be "unknown age"
@@ -0,0 +1,48 @@
Feature: CLI main.py uncovered branches
As a developer
I want to cover the missed branches in cleveragents.cli.main
So that code coverage gaps on lines 225-228, 269-272, 289-292, 317-323, 372-379, 626-627 are closed
Scenario: show_secrets_callback with True enables secret display
When cli main branch show_secrets_callback is invoked with True
Then cli main branch set_show_secrets should have been called with True
Scenario: show_secrets_callback with False does nothing
When cli main branch show_secrets_callback is invoked with False
Then cli main branch set_show_secrets should not have been called
Scenario: version command with plain format uses format_output
When cli main branch version command is invoked with format "plain"
Then cli main branch format_output should have been called for version with fmt "plain"
Scenario: version command with json format uses format_output
When cli main branch version command is invoked with format "json"
Then cli main branch format_output should have been called for version with fmt "json"
Scenario: info command with plain format uses format_output
When cli main branch info command is invoked with format "plain"
Then cli main branch format_output should have been called for info with fmt "plain"
Scenario: info command with json format uses format_output
When cli main branch info command is invoked with format "json"
Then cli main branch format_output should have been called for info with fmt "json"
Scenario: diagnostics command with plain format uses format_output
When cli main branch diagnostics command is invoked with format "plain"
Then cli main branch format_output should have been called for diagnostics with fmt "plain"
Scenario: diagnostics with check flag and errors exits with code 1
When cli main branch diagnostics command is invoked with check and errors present
Then cli main branch diagnostics should exit with code 1
Scenario: diagnostics with check flag but no errors exits normally
When cli main branch diagnostics command is invoked with check but no errors
Then cli main branch diagnostics should exit with code 0
Scenario: init command generic Exception prints unexpected error
When cli main branch init command raises a generic Exception
Then cli main branch init output should contain "Unexpected error"
Scenario: __name__ == __main__ block calls main
When cli main branch the module is executed as __main__
Then cli main branch sys.exit should have been called with main result
@@ -0,0 +1,58 @@
Feature: Config CLI uncovered branches
Cover missed lines and branches in config.py:
_settings_defaults factory/None paths, _validate_key empty,
_write_config_file existing-file path, _resolve_source env path,
config_set/get non-rich formats, config_list empty result.
# -- _settings_defaults with default_factory (L81-85) --
Scenario: config cli branch settings defaults uses default_factory when default is None
Given a config cli branch mocked Settings with a default_factory field
When I config cli branch call _settings_defaults
Then the config cli branch defaults should contain the factory value
# -- _settings_defaults with both default and factory None (L87) --
Scenario: config cli branch settings defaults returns None when no default and no factory
Given a config cli branch mocked Settings with a None-only field
When I config cli branch call _settings_defaults
Then the config cli branch defaults should contain None for the field
# -- _validate_key with empty key (L102-103) --
Scenario: config cli branch validate_key rejects empty key
When I config cli branch call _validate_key with an empty key
Then the config cli branch call should raise BadParameter
# -- _write_config_file when file already exists (L145-147) --
Scenario: config cli branch write_config_file merges into existing file
Given a config cli branch temp config directory
And a config cli branch existing config file with key "log_level" set to "DEBUG"
When I config cli branch write config with key "server_port" set to "9090"
Then the config cli branch config file should contain both keys
# -- _resolve_source returns "env" (L169-170) --
Scenario: config cli branch resolve_source returns env when env var is set
Given a config cli branch temp config directory
And the config cli branch env var "CLEVERAGENTS_LOG_LEVEL" is set to "WARNING"
When I config cli branch call _resolve_source for "log_level"
Then the config cli branch source should be "env"
# -- config_set with non-rich format (L261-262) --
Scenario: config cli branch config set with json format uses format_output
Given a config cli branch temp config directory
When I config cli branch run config set "log_level" "DEBUG" with format "json"
Then the config cli branch set result should be valid JSON
And the config cli branch set JSON should contain key "key"
And the config cli branch set JSON should contain key "value"
# -- config_get with non-rich format and Path value (L311-312, L314-315) --
Scenario: config cli branch config get with json format serialises Path values
Given a config cli branch temp config directory
And config cli branch settings_fields returns a Path value for "log_dir"
When I config cli branch run config get "log_dir" with format "json"
Then the config cli branch get result should be valid JSON
And the config cli branch get JSON value should be a string not a Path
# -- config_list with filter matching nothing (L422-423) --
Scenario: config cli branch config list prints message when no matches
Given a config cli branch temp config directory
When I config cli branch run config list with pattern "zzz_nonexistent_pattern_xyz"
Then the config cli branch list output should say no values match
@@ -0,0 +1,51 @@
@phase1 @database @models @uncovered_branches
Feature: Database models uncovered branch coverage
As a developer
I want to exercise uncovered branches in database model from_domain methods
So that branch and line coverage improves for models.py
# -------------------------------------------------------------------
# LifecyclePlanModel.from_domain: empty arguments (L948 loop zero iterations)
# -------------------------------------------------------------------
@plan @from_domain @empty_arguments
Scenario: PlanModel from_domain with empty arguments produces no argument rows
Given db models branch a Plan domain object with empty arguments
When db models branch the plan is converted via LifecyclePlanModel from_domain
Then db models branch the resulting plan model has no argument rows
# -------------------------------------------------------------------
# ToolModel.from_domain: dict-based tool (L1665-1666 dict branch of _get)
# -------------------------------------------------------------------
@tool @from_domain @dict_tool
Scenario: ToolModel from_domain accepts a plain dict and reads fields via dict.get
Given db models branch a plain dict describing a tool
When db models branch the dict is converted via ToolModel from_domain
Then db models branch the resulting tool model has the expected name and description
@tool @from_domain @dict_tool_bindings
Scenario: ToolModel from_domain with dict tool and resource bindings
Given db models branch a plain dict describing a tool with resource bindings
When db models branch the binding dict is converted via ToolModel from_domain
Then db models branch the resulting tool model has resource binding rows
# -------------------------------------------------------------------
# SessionModel.from_domain: non-empty messages (L1920-1921)
# -------------------------------------------------------------------
@session @from_domain @messages
Scenario: SessionModel from_domain populates child message models
Given db models branch a Session domain object with two messages
When db models branch the session is converted via SessionModel from_domain
Then db models branch the resulting session model has two message children
# -------------------------------------------------------------------
# SkillModel class columns: short_name, description (L2131-2132)
# -------------------------------------------------------------------
@skill @from_domain @columns
Scenario: SkillModel from_domain populates short_name and description columns
Given db models branch a Skill domain dict with name and description
When db models branch the skill dict is converted via SkillModel from_domain
Then db models branch the resulting skill model has correct short_name and description
@@ -0,0 +1,89 @@
Feature: Plan diff and artifacts CLI command coverage
As a developer
I want to cover the plan diff, plan artifacts CLI commands and remaining branches
So that plan.py lines 1815-1888 and missing branches are fully covered
# ---------- plan diff CLI command (lines 1845-1855) ----------
Scenario: plan_diff_artifacts successful diff prints output
Given plan_diff_artifacts the apply service returns diff output "--- a/file.py\n+++ b/file.py"
When plan_diff_artifacts I run the diff subcommand for plan "plan-001"
Then plan_diff_artifacts the exit code should be 0
And plan_diff_artifacts the output should contain "file.py"
Scenario: plan_diff_artifacts diff with custom format
Given plan_diff_artifacts the apply service returns diff output '{"entries": []}'
When plan_diff_artifacts I run the diff subcommand for plan "plan-002" using format "json"
Then plan_diff_artifacts the exit code should be 0
And plan_diff_artifacts the output should contain "entries"
Scenario: plan_diff_artifacts diff raises PlanError
Given plan_diff_artifacts the apply service diff raises a PlanError "changeset not found"
When plan_diff_artifacts I run the diff subcommand for plan "plan-bad"
Then plan_diff_artifacts the command should be aborted
And plan_diff_artifacts the output should contain "Diff Error"
And plan_diff_artifacts the output should contain "changeset not found"
Scenario: plan_diff_artifacts diff raises CleverAgentsError
Given plan_diff_artifacts the apply service diff raises a CleverAgentsError "service unavailable"
When plan_diff_artifacts I run the diff subcommand for plan "plan-err"
Then plan_diff_artifacts the command should be aborted
And plan_diff_artifacts the output should contain "Error"
And plan_diff_artifacts the output should contain "service unavailable"
# ---------- plan artifacts CLI command (lines 1878-1888) ----------
Scenario: plan_diff_artifacts successful artifacts prints output
Given plan_diff_artifacts the apply service returns artifacts output "changeset-id: cs-001"
When plan_diff_artifacts I run the artifacts subcommand for plan "plan-001"
Then plan_diff_artifacts the exit code should be 0
And plan_diff_artifacts the output should contain "cs-001"
Scenario: plan_diff_artifacts artifacts with custom format
Given plan_diff_artifacts the apply service returns artifacts output '{"changeset_id": "cs-002"}'
When plan_diff_artifacts I run the artifacts subcommand for plan "plan-003" using format "json"
Then plan_diff_artifacts the exit code should be 0
And plan_diff_artifacts the output should contain "cs-002"
Scenario: plan_diff_artifacts artifacts raises PlanError
Given plan_diff_artifacts the apply service artifacts raises a PlanError "plan not found"
When plan_diff_artifacts I run the artifacts subcommand for plan "plan-missing"
Then plan_diff_artifacts the command should be aborted
And plan_diff_artifacts the output should contain "Artifacts Error"
And plan_diff_artifacts the output should contain "plan not found"
Scenario: plan_diff_artifacts artifacts raises CleverAgentsError
Given plan_diff_artifacts the apply service artifacts raises a CleverAgentsError "timeout"
When plan_diff_artifacts I run the artifacts subcommand for plan "plan-timeout"
Then plan_diff_artifacts the command should be aborted
And plan_diff_artifacts the output should contain "Error"
And plan_diff_artifacts the output should contain "timeout"
# ---------- _get_apply_service (lines 1815-1822) ----------
Scenario: plan_diff_artifacts _get_apply_service creates PlanApplyService
Given plan_diff_artifacts a mock lifecycle service
When plan_diff_artifacts I call _get_apply_service
Then plan_diff_artifacts a PlanApplyService should be returned
# ---------- build command branch: no actor_registry (L646->648) ----------
Scenario: plan_diff_artifacts build command without actor registry
Given plan_diff_artifacts a container without actor_registry
When plan_diff_artifacts I invoke the build command
Then plan_diff_artifacts the build should complete without actor registry calls
# ---------- build command branch: testing_mode disabled (L648->652) ----------
Scenario: plan_diff_artifacts build command with testing mode disabled
Given plan_diff_artifacts a container with actor_registry but testing mode disabled
When plan_diff_artifacts I invoke the build command
Then plan_diff_artifacts ensure_default_mock_actor should not be called
# ---------- plan show: arguments_order with extra key (L1130->1129) ----------
Scenario: plan_diff_artifacts plan show with arguments_order containing missing key
Given plan_diff_artifacts a lifecycle plan with arguments_order having an extra key
When plan_diff_artifacts I print the lifecycle plan
Then plan_diff_artifacts the output should contain the valid argument
And plan_diff_artifacts the output should not contain the missing key value
+82 -82
View File
@@ -7,157 +7,157 @@ Feature: Plan Hierarchy and Subplan Failure Handling
@subplan_hierarchy
Scenario: A standalone plan is not considered a subplan
Given a plan that was created independently
Then the plan should not be recognized as a subplan
Given an uncovered-lines plan that was created independently
Then the uncovered-lines plan should not be recognized as a subplan
@subplan_hierarchy
Scenario: A plan spawned by another plan is a subplan
Given a plan that was spawned by another plan
Then the plan should be recognized as a subplan
Given an uncovered-lines plan that was spawned by another plan
Then the uncovered-lines plan should be recognized as a subplan
@subplan_hierarchy
Scenario: A plan with no designated root is considered the root
Given a plan with no designated root
Then the plan should be recognized as a root plan
Given an uncovered-lines plan with no designated root
Then the uncovered-lines plan should be recognized as a root plan
@subplan_hierarchy
Scenario: A plan whose root is itself is considered the root
Given a plan whose designated root is itself
Then the plan should be recognized as a root plan
Given an uncovered-lines plan whose designated root is itself
Then the uncovered-lines plan should be recognized as a root plan
@subplan_hierarchy
Scenario: A plan whose root is a different plan is not the root
Given a plan whose designated root is a different plan
Then the plan should not be recognized as a root plan
Given an uncovered-lines plan whose designated root is a different plan
Then the uncovered-lines plan should not be recognized as a root plan
@subplan_hierarchy
Scenario: A root plan reports a depth of zero
Given a plan with no designated root
Then the plan hierarchy depth should be 0
Given an uncovered-lines plan with no designated root
Then the uncovered-lines plan hierarchy depth should be 0
@subplan_hierarchy
Scenario: A non-root plan reports a placeholder depth
Given a plan whose designated root is a different plan
Then the plan hierarchy depth should be -1
Given an uncovered-lines plan whose designated root is a different plan
Then the uncovered-lines plan hierarchy depth should be -1
@subplan_hierarchy
Scenario: A plan with no child work has no subplans
Given a plan that has no child work tracked
Then the plan should report having no subplans
Given an uncovered-lines plan that has no child work tracked
Then the uncovered-lines plan should report having no subplans
@subplan_hierarchy
Scenario: A plan with tracked child work has subplans
Given a plan that has tracked child work
Then the plan should report having subplans
Given an uncovered-lines plan that has tracked child work
Then the uncovered-lines plan should report having subplans
@failure_handler
Scenario: The failure handler halts remaining work when fail-fast is enabled
Given a subplan configuration with fail-fast enabled and parallel work
And a subplan that has failed
When the failure handler evaluates whether to halt remaining work
Then the remaining work should be halted
Given an uncovered-lines subplan configuration with fail-fast enabled and parallel work
And an uncovered-lines subplan that has failed
When the uncovered-lines failure handler evaluates whether to halt remaining work
Then the uncovered-lines remaining work should be halted
@failure_handler
Scenario: The failure handler halts remaining work for sequential execution
Given a subplan configuration with fail-fast disabled and sequential work
And a subplan that has failed
When the failure handler evaluates whether to halt remaining work
Then the remaining work should be halted
Given an uncovered-lines subplan configuration with fail-fast disabled and sequential work
And an uncovered-lines subplan that has failed
When the uncovered-lines failure handler evaluates whether to halt remaining work
Then the uncovered-lines remaining work should be halted
@failure_handler
Scenario: The failure handler allows remaining work for parallel execution without fail-fast
Given a subplan configuration with fail-fast disabled and parallel work
And a subplan that has failed
When the failure handler evaluates whether to halt remaining work
Then the remaining work should continue
Given an uncovered-lines subplan configuration with fail-fast disabled and parallel work
And an uncovered-lines subplan that has failed
When the uncovered-lines failure handler evaluates whether to halt remaining work
Then the uncovered-lines remaining work should continue
@failure_handler
Scenario: The failure handler allows remaining work for dependency-ordered execution without fail-fast
Given a subplan configuration with fail-fast disabled and dependency-ordered work
And a subplan that has failed
When the failure handler evaluates whether to halt remaining work
Then the remaining work should continue
Given an uncovered-lines subplan configuration with fail-fast disabled and dependency-ordered work
And an uncovered-lines subplan that has failed
When the uncovered-lines failure handler evaluates whether to halt remaining work
Then the uncovered-lines remaining work should continue
@failure_handler
Scenario: The failure handler does not retry when retries are disabled
Given a subplan configuration with retries disabled
And a subplan that failed with error "ValidationError occurred"
When the failure handler evaluates whether to retry the failed work
Then the failed work should not be retried
Given an uncovered-lines subplan configuration with retries disabled
And an uncovered-lines subplan that failed with error "ValidationError occurred"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should not be retried
@failure_handler
Scenario: The failure handler does not retry when all attempts are exhausted
Given a subplan configuration allowing up to 2 retries
And a subplan on attempt 3 that failed with error "TimeoutError"
When the failure handler evaluates whether to retry the failed work
Then the failed work should not be retried
Given an uncovered-lines subplan configuration allowing up to 2 retries
And an uncovered-lines subplan on attempt 3 that failed with error "TimeoutError"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should not be retried
@failure_handler
Scenario: A configuration error is not eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "ConfigurationError: bad config"
When the failure handler evaluates whether to retry the failed work
Then the failed work should not be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "ConfigurationError: bad config"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should not be retried
@failure_handler
Scenario: An authentication error is not eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "AuthenticationError: invalid token"
When the failure handler evaluates whether to retry the failed work
Then the failed work should not be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "AuthenticationError: invalid token"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should not be retried
@failure_handler
Scenario: A missing resource error is not eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "MissingResourceError: file not found"
When the failure handler evaluates whether to retry the failed work
Then the failed work should not be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "MissingResourceError: file not found"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should not be retried
@failure_handler
Scenario: A circular dependency error is not eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "CircularDependencyError: cycle detected"
When the failure handler evaluates whether to retry the failed work
Then the failed work should not be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "CircularDependencyError: cycle detected"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should not be retried
@failure_handler
Scenario: A validation error is eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "ValidationError: schema mismatch"
When the failure handler evaluates whether to retry the failed work
Then the failed work should be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "ValidationError: schema mismatch"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should be retried
@failure_handler
Scenario: A timeout error is eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "TimeoutError: deadline exceeded"
When the failure handler evaluates whether to retry the failed work
Then the failed work should be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "TimeoutError: deadline exceeded"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should be retried
@failure_handler
Scenario: A temporary resource error is eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "TemporaryResourceError: service unavailable"
When the failure handler evaluates whether to retry the failed work
Then the failed work should be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "TemporaryResourceError: service unavailable"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should be retried
@failure_handler
Scenario: A merge conflict error is eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "MergeConflictError: conflicting changes"
When the failure handler evaluates whether to retry the failed work
Then the failed work should be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "MergeConflictError: conflicting changes"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should be retried
@failure_handler
Scenario: An unrecognized error type is not eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with error "SomeUnknownError: unexpected"
When the failure handler evaluates whether to retry the failed work
Then the failed work should not be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with error "SomeUnknownError: unexpected"
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should not be retried
@failure_handler
Scenario: A failure with no error message is not eligible for retry
Given a subplan configuration allowing up to 3 retries
And a subplan on attempt 1 that failed with no error message
When the failure handler evaluates whether to retry the failed work
Then the failed work should not be retried
Given an uncovered-lines subplan configuration allowing up to 3 retries
And an uncovered-lines subplan on attempt 1 that failed with no error message
When the uncovered-lines failure handler evaluates whether to retry the failed work
Then the uncovered-lines failed work should not be retried
@@ -0,0 +1,114 @@
Feature: Repository uncovered branches and lines
Cover missed lines and branches in repositories.py:
ActorRepository, ToolRepository, ValidationAttachmentRepository,
AutomationProfileRepository, DuplicateValidationAttachmentError,
and ToolRepository._extract_value.
# ActorRepository
Scenario: repo branch cov actor get_default returns None when no default
Given repo branch cov an in-memory database with actor tables
When repo branch cov I call get_default with no default actor set
Then repo branch cov get_default returns None
Scenario: repo branch cov actor list_by_namespace returns matching actors
Given repo branch cov an in-memory database with actor tables
And repo branch cov actors "ns1/alpha" and "ns1/beta" and "ns2/gamma" exist
When repo branch cov I call list_by_namespace with "ns1"
Then repo branch cov 2 actors are returned
And repo branch cov the actor names are "ns1/alpha" and "ns1/beta"
Scenario: repo branch cov actor list_by_namespace returns empty for unknown
Given repo branch cov an in-memory database with actor tables
When repo branch cov I call list_by_namespace with "nonexistent"
Then repo branch cov 0 actors are returned
Scenario: repo branch cov actor list_by_schema_version returns matching
Given repo branch cov an in-memory database with actor tables
And repo branch cov actors with schema versions "1.0" and "2.0" exist
When repo branch cov I call list_by_schema_version with "2.0"
Then repo branch cov 1 actor is returned by schema version
# ── ToolRepository ──────────────────────────────────────────
Scenario: repo branch cov tool repo init with factory keyword
Given repo branch cov an in-memory database with tool tables
When repo branch cov I create ToolRepository with factory keyword
Then repo branch cov the ToolRepository is usable
Scenario: repo branch cov tool repo init with no arguments fails
When repo branch cov I create ToolRepository with no session factory
Then repo branch cov a TypeError is raised
Scenario: repo branch cov tool repo get returns None for missing tool
Given repo branch cov an in-memory database with tool tables
And repo branch cov a ToolRepository instance
When repo branch cov I call get with "nonexistent/tool"
Then repo branch cov get returns None
Scenario: repo branch cov tool repo get_by_name returns None for missing
Given repo branch cov an in-memory database with tool tables
And repo branch cov a ToolRepository instance
When repo branch cov I call get_by_name with "nonexistent/tool"
Then repo branch cov get_by_name returns None
Scenario: repo branch cov tool repo remove succeeds for existing tool
Given repo branch cov an in-memory database with tool tables
And repo branch cov a ToolRepository instance
And repo branch cov a tool "local/removable" exists in the database
When repo branch cov I call remove with "local/removable"
Then repo branch cov remove returns True
Scenario: repo branch cov tool repo remove raises ToolNotFoundError
Given repo branch cov an in-memory database with tool tables
And repo branch cov a ToolRepository instance
When repo branch cov I call remove with "nonexistent/tool"
Then repo branch cov ToolNotFoundError is raised
# ── ToolRepository._extract_value (dict branch) ────────────
Scenario: repo branch cov extract_value from dict object
When repo branch cov I call _extract_value with a dict obj key "name" default ""
Then repo branch cov _extract_value returns the dict value
Scenario: repo branch cov prepare_tool_dict without capability
Given repo branch cov an in-memory database with tool tables
And repo branch cov a ToolRepository instance
When repo branch cov I prepare a tool dict from an object without capability
Then repo branch cov the prepared dict has no capability_json
# ── DuplicateValidationAttachmentError ──────────────────────
Scenario: repo branch cov duplicate validation error with project name
When repo branch cov I create DuplicateValidationAttachmentError with project_name
Then repo branch cov the error message contains the project name
Scenario: repo branch cov duplicate validation error with project and plan
When repo branch cov I create DuplicateValidationAttachmentError with project and plan
Then repo branch cov the error message contains both project and plan
Scenario: repo branch cov duplicate validation error without scoping
When repo branch cov I create DuplicateValidationAttachmentError without scoping
Then repo branch cov the error message has no project or plan
# ── ValidationAttachmentRepository ─────────────────────────
Scenario: repo branch cov validation attach creates attachment
Given repo branch cov an in-memory database with tool tables
And repo branch cov a validation tool "local/check-lint" exists
When repo branch cov I attach validation "local/check-lint" to resource "res-1"
Then repo branch cov the attachment is returned with correct fields
Scenario: repo branch cov validation attach with swapped args auto-corrects
Given repo branch cov an in-memory database with tool tables
And repo branch cov a validation tool "local/check-fmt" exists
When repo branch cov I attach with validation_name "res/123" and resource_id "local/check-fmt"
Then repo branch cov the attachment swaps them correctly
# ── AutomationProfileRepository schema version mismatch ────
Scenario: repo branch cov upsert profile with schema version mismatch
Given repo branch cov an in-memory database with automation profile tables
And repo branch cov an existing automation profile "acme/strict" with schema "1.0"
When repo branch cov I upsert profile "acme/strict" expecting schema "2.0"
Then repo branch cov AutomationProfileSchemaVersionError is raised
@@ -0,0 +1,56 @@
Feature: Session CLI uncovered branches
As a developer
I want full branch coverage for session CLI commands
So that edge cases are tested
Scenario: session cli branch - _get_session_service constructs service when _service is None
Given session cli branch the module-level _service is None
When session cli branch I call _get_session_service with mocked container
Then session cli branch a PersistentSessionService is returned
Scenario: session cli branch - _reset_session_service sets _service to None
Given session cli branch the module-level _service holds a mock
When session cli branch I call _reset_session_service
Then session cli branch the module-level _service is None again
Scenario: session cli branch - create command catches SessionNotFoundError
Given session cli branch a mock session service that raises SessionNotFoundError on create
When session cli branch I invoke the create command
Then session cli branch the exit code is 1
And session cli branch the output contains "Error:"
Scenario: session cli branch - show command with no messages
Given session cli branch a mock session service returning a session with no messages
When session cli branch I invoke the show command for that session
Then session cli branch the exit code is 0
And session cli branch the output contains "Session Details"
And session cli branch the output does not contain "Recent Messages"
Scenario: session cli branch - show command with linked plan ids
Given session cli branch a mock session service returning a session with linked plans
When session cli branch I invoke the show command for the linked-plans session
Then session cli branch the exit code is 0
And session cli branch the output contains "Linked Plans"
Scenario: session cli branch - show command with long message content truncated
Given session cli branch a mock session service returning a session with a long message
When session cli branch I invoke the show command for the long-message session
Then session cli branch the exit code is 0
And session cli branch the output contains "Recent Messages"
Scenario: session cli branch - delete command aborted via confirmation prompt
Given session cli branch a mock session service for delete
When session cli branch I invoke the delete command without --yes and answer no
Then session cli branch the delete is aborted
Scenario: session cli branch - export command catches SessionExportError
Given session cli branch a mock session service that raises SessionExportError on export
When session cli branch I invoke the export command
Then session cli branch the export exit code is 1
And session cli branch the export output contains "Export error:"
Scenario: session cli branch - tell command with stream=True
Given session cli branch a mock session service for tell
When session cli branch I invoke the tell command with --stream
Then session cli branch the exit code is 0
And session cli branch the streamed output contains the assistant response
@@ -0,0 +1,46 @@
"""Step definitions for Action CLI uncovered-lines coverage tests.
Covers error-handling paths in ``cleveragents.cli.commands.action``:
* L228-229 - ``ValidationError`` from ``create_action`` -> "Validation Error"
* L230-232 - ``CleverAgentsError`` from ``create_action`` -> "Error:"
* L289-294 - Invalid ``--state`` value on ``list`` -> "Invalid state"
* L353-355 - ``CleverAgentsError`` from ``list_actions`` -> "Error:"
* L388-389 - ``CleverAgentsError`` from ``get_action_by_name`` (show) -> "Error:"
* L428-430 - ``CleverAgentsError`` from ``archive_action`` -> "Error:"
All step definitions used by ``action_cli_uncovered_lines.feature`` are
shared from :pymod:`features.steps.action_cli_steps`. No additional
step definitions are needed here because the feature reuses the
existing step vocabulary. Duplicating the ``@given`` / ``@when`` /
``@then`` decorators would trigger behave *ambiguous step* errors.
Shared steps (source → ``features/steps/action_cli_steps.py``):
Background:
Given an action CLI runner with mocks (line 98)
And a mocked plan lifecycle service (line 104)
Givens:
a valid action config YAML file (line 125)
there is a mocked available action (line 191)
Whens:
I run action CLI create with validation error … (line 255)
I run action CLI create with service error … (line 267)
I run action CLI list with invalid state "" (line 284)
I run action CLI list with service error (line 292)
I run action CLI show with service error (line 346)
I run action CLI archive with service error (line 392)
Thens:
the action CLI should abort with validation error (line 507)
the action CLI should abort with service error (line 515)
the action CLI should abort with invalid state (line 523)
"""
from __future__ import annotations
# All steps are defined in action_cli_steps.py and shared via behave's
# automatic step-file discovery. This module exists as the canonical
# companion for action_cli_uncovered_lines.feature.
@@ -0,0 +1,227 @@
"""Step definitions for cleanup CLI uncovered branches.
Covers missed lines and branches in
``cleveragents.cli.commands.cleanup``:
* L49-50 - inner ``OSError`` on ``p.stat()`` inside ``_detect_active_plan_ids``
* L52-58 - outer ``OSError`` from ``_get_sandbox_dirs`` -> warning + empty frozenset
* L44->42 - empty sandbox dir list (loop end)
* L87->94 - ``scan()`` with no stale items -> "No stale resources found."
* L128-131 - ``purge --dry-run`` with stale items -> "Would clean:" listing
* L139->143 - ``purge`` confirmation prompt accepted -> proceeds
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.cleanup_service import (
CleanupReport,
StaleItem,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _empty_report() -> CleanupReport:
"""Return a ``CleanupReport`` with no stale items (dry-run)."""
return CleanupReport(dry_run=True)
def _report_with_stale_items() -> CleanupReport:
"""Return a dry-run ``CleanupReport`` containing stale items."""
report = CleanupReport(dry_run=True)
report.sandboxes.scanned = 1
report.sandboxes.removed = 1
report.stale_items = [
StaleItem(
resource_type="sandbox",
path="/tmp/fake-sandbox",
age_description="3 days old",
plan_id="plan-001",
),
]
return report
def _purge_report() -> CleanupReport:
"""Return a non-dry-run ``CleanupReport`` for purge results."""
report = CleanupReport(dry_run=False)
report.sandboxes.scanned = 1
report.sandboxes.removed = 1
return report
# ---------------------------------------------------------------------------
# Givens
# ---------------------------------------------------------------------------
@given("cleanup cli branch mock service returns empty report")
def step_mock_service_empty_report(context: Context) -> None:
mock_svc = MagicMock()
mock_svc.scan.return_value = _empty_report()
context._cleanup_branch_mock_svc = mock_svc
@given("cleanup cli branch mock service returns report with stale items")
def step_mock_service_stale_report(context: Context) -> None:
mock_svc = MagicMock()
mock_svc.scan.return_value = _report_with_stale_items()
context._cleanup_branch_mock_svc = mock_svc
@given("cleanup cli branch mock service returns purge report")
def step_mock_service_purge_report(context: Context) -> None:
mock_svc = MagicMock()
mock_svc.scan.return_value = _empty_report()
mock_svc.purge.return_value = _purge_report()
context._cleanup_branch_mock_svc = mock_svc
@given("cleanup cli branch a service whose sandbox dir stat raises OSError")
def step_service_stat_oserror(context: Context) -> None:
"""Build a mock service where one sandbox path raises OSError on stat().
``extract_plan_id_from_sandbox`` returns a truthy plan ID so the code
enters the ``try: mtime = p.stat()`` block and hits the inner ``except
OSError: pass`` (L49-50).
"""
bad_path = MagicMock(spec=Path)
bad_path.stat.side_effect = OSError("permission denied")
mock_svc = MagicMock()
mock_svc._get_sandbox_dirs.return_value = [bad_path]
mock_svc.extract_plan_id_from_sandbox.return_value = "plan-abc"
context._cleanup_branch_mock_svc = mock_svc
@given("cleanup cli branch a service whose get sandbox dirs raises OSError")
def step_service_get_sandbox_dirs_oserror(context: Context) -> None:
mock_svc = MagicMock()
mock_svc._get_sandbox_dirs.side_effect = OSError("cannot list /tmp")
context._cleanup_branch_mock_svc = mock_svc
@given("cleanup cli branch a service with no sandbox dirs")
def step_service_empty_sandbox_dirs(context: Context) -> None:
mock_svc = MagicMock()
mock_svc._get_sandbox_dirs.return_value = []
context._cleanup_branch_mock_svc = mock_svc
# ---------------------------------------------------------------------------
# Whens
# ---------------------------------------------------------------------------
@when("cleanup cli branch I invoke scan via main app")
def step_invoke_scan(context: Context) -> None:
from typer.testing import CliRunner
from cleveragents.cli.main import app
runner = CliRunner()
with patch(
"cleveragents.cli.commands.cleanup._get_cleanup_service",
return_value=context._cleanup_branch_mock_svc,
):
context._cleanup_branch_result = runner.invoke(app, ["cleanup", "scan"])
@when("cleanup cli branch I invoke purge dry-run via main app")
def step_invoke_purge_dry_run(context: Context) -> None:
from typer.testing import CliRunner
from cleveragents.cli.main import app
runner = CliRunner()
with patch(
"cleveragents.cli.commands.cleanup._get_cleanup_service",
return_value=context._cleanup_branch_mock_svc,
):
context._cleanup_branch_result = runner.invoke(
app, ["cleanup", "purge", "--dry-run"]
)
@when("cleanup cli branch I invoke purge with confirmation y via main app")
def step_invoke_purge_confirm_y(context: Context) -> None:
from typer.testing import CliRunner
from cleveragents.cli.main import app
runner = CliRunner()
with patch(
"cleveragents.cli.commands.cleanup._get_cleanup_service",
return_value=context._cleanup_branch_mock_svc,
):
context._cleanup_branch_result = runner.invoke(
app, ["cleanup", "purge"], input="y\n"
)
@when("cleanup cli branch I call detect active plan ids")
def step_call_detect_active_plan_ids(context: Context) -> None:
from cleveragents.cli.commands.cleanup import _detect_active_plan_ids
# Capture stderr output for the warning assertion
context._cleanup_branch_warnings: list[str] = []
mock_err_console = MagicMock()
def capture_print(*args, **kwargs):
context._cleanup_branch_warnings.append(str(args))
mock_err_console.print = capture_print
with patch(
"cleveragents.cli.commands.cleanup.get_err_console",
return_value=mock_err_console,
):
context._cleanup_branch_detect_result = _detect_active_plan_ids(
context._cleanup_branch_mock_svc,
)
# ---------------------------------------------------------------------------
# Thens
# ---------------------------------------------------------------------------
@then("cleanup cli branch exit code is {code:d}")
def step_check_exit_code(context: Context, code: int) -> None:
result = context._cleanup_branch_result
assert result.exit_code == code, (
f"Expected exit code {code}, got {result.exit_code}.\n"
f"Output: {result.output}\n"
f"Exception: {result.exception!r}"
)
@then('cleanup cli branch output contains "{text}"')
def step_output_contains(context: Context, text: str) -> None:
output = context._cleanup_branch_result.output
assert text in output, f"Expected '{text}' in output, got:\n{output}"
@then("cleanup cli branch the result is an empty frozenset")
def step_result_empty_frozenset(context: Context) -> None:
result = context._cleanup_branch_detect_result
assert isinstance(result, frozenset), f"Expected frozenset, got {type(result)}"
assert len(result) == 0, f"Expected empty frozenset, got {result}"
@then("cleanup cli branch a warning was printed about active plans")
def step_warning_printed(context: Context) -> None:
warnings = context._cleanup_branch_warnings
combined = " ".join(warnings)
assert "active plans" in combined.lower() or "Warning" in combined, (
f"Expected warning about active plans, got: {combined}"
)
@@ -0,0 +1,427 @@
"""Step definitions for CleanupService uncovered-lines coverage tests."""
from __future__ import annotations
import os
import tempfile
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.cleanup_service import (
CleanupReport,
CleanupService,
)
from cleveragents.config.settings import Settings
# ── Helpers ──────────────────────────────────────────────────────
def _make_settings(**overrides: object) -> Settings:
"""Create a real Settings instance with optional overrides.
We build a fresh ``Settings`` (which has sensible defaults for
every cleanup-related field), then patch individual attributes
for the specific test at hand.
"""
s = Settings()
for key, value in overrides.items():
object.__setattr__(s, key, value)
return s
# ── Given steps ──────────────────────────────────────────────────
@given("cleanup coverage has a CleanupService with default settings")
def step_cleanup_cov_service_default(context: Context) -> None:
context.cleanup_settings = _make_settings()
context.cleanup_service = CleanupService(context.cleanup_settings)
@given("cleanup coverage has a CleanupService with max {n:d} checkpoints")
def step_cleanup_cov_service_max_checkpoints(context: Context, n: int) -> None:
context.cleanup_settings = _make_settings(cleanup_checkpoint_max_per_plan=n)
context.cleanup_service = CleanupService(context.cleanup_settings)
@given("cleanup coverage has a CleanupService with max 1 checkpoint override")
def step_cleanup_cov_service_max_1_checkpoint(context: Context) -> None:
"""Create service with max_per_plan=1 (bypassing pydantic ge=2 validation)
so that 2 files > max_count but len(files) < 3, hitting L286-287."""
settings = _make_settings()
object.__setattr__(settings, "cleanup_checkpoint_max_per_plan", 1)
context.cleanup_settings = settings
context.cleanup_service = CleanupService(context.cleanup_settings)
@given('cleanup coverage has a temp directory with a subdirectory named "{name}"')
def step_cleanup_cov_temp_dir_with_subdir(context: Context, name: str) -> None:
context.cleanup_temp_dir = Path(tempfile.mkdtemp())
subdir = context.cleanup_temp_dir / name
subdir.mkdir()
# Make the subdirectory old so it would be "expired" by age
old_time = time.time() - 400 * 86400
os.utime(str(subdir), (old_time, old_time))
@given("cleanup coverage has a CleanupService with log dir containing expired files")
def step_cleanup_cov_service_with_expired_logs(context: Context) -> None:
tmp = Path(tempfile.mkdtemp())
context.cleanup_temp_dir = tmp
log_dir = tmp / "logs"
log_dir.mkdir()
# Create an expired log file
expired_file = log_dir / "old.log"
expired_file.write_text("old log data")
old_time = time.time() - 400 * 86400
os.utime(str(expired_file), (old_time, old_time))
context.cleanup_settings = _make_settings(
log_dir=log_dir,
cleanup_log_retention_days=1,
)
context.cleanup_service = CleanupService(context.cleanup_settings)
@given("cleanup coverage has a CleanupService with backup dir containing expired files")
def step_cleanup_cov_service_with_expired_backups(context: Context) -> None:
tmp = Path(tempfile.mkdtemp())
context.cleanup_temp_dir = tmp
data_dir = tmp / "data"
data_dir.mkdir()
backup_dir = data_dir / "backups"
backup_dir.mkdir()
# Create an expired backup file
expired_file = backup_dir / "old_backup.tar.gz"
expired_file.write_text("old backup data")
old_time = time.time() - 400 * 86400
os.utime(str(expired_file), (old_time, old_time))
context.cleanup_settings = _make_settings(
data_dir=data_dir,
cleanup_backup_retention_days=1,
)
context.cleanup_service = CleanupService(context.cleanup_settings)
# ── When steps ───────────────────────────────────────────────────
@when("cleanup coverage calls _get_sandbox_dirs with tmp not existing")
def step_cleanup_cov_get_sandbox_dirs_no_tmp(context: Context) -> None:
svc = context.cleanup_service
# Clear the cache so the method actually runs its logic
svc._sandbox_dirs_cache = None
with patch(
"cleveragents.application.services.cleanup_service.tempfile"
) as mock_tmp:
mock_tmp.gettempdir.return_value = "/nonexistent_tmp_path_xyz"
with patch(
"cleveragents.application.services.cleanup_service.Path"
) as mock_path_cls:
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = False
mock_path_cls.return_value = mock_path_instance
context.cleanup_result = svc._get_sandbox_dirs()
@when("cleanup coverage calls _get_sandbox_dirs with iterdir raising OSError")
def step_cleanup_cov_get_sandbox_dirs_iterdir_oserror(context: Context) -> None:
svc = context.cleanup_service
svc._sandbox_dirs_cache = None
with patch(
"cleveragents.application.services.cleanup_service.tempfile"
) as mock_tmp:
mock_tmp.gettempdir.return_value = "/some_tmp"
with patch(
"cleveragents.application.services.cleanup_service.Path"
) as mock_path_cls:
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.iterdir.side_effect = OSError("permission denied")
mock_path_cls.return_value = mock_path_instance
context.cleanup_result = svc._get_sandbox_dirs()
@when(
"cleanup coverage calls _get_sandbox_dirs with is_dir raising OSError on one entry"
)
def step_cleanup_cov_get_sandbox_dirs_isdir_oserror(context: Context) -> None:
svc = context.cleanup_service
svc._sandbox_dirs_cache = None
# Create two mock path entries: one that raises OSError on is_dir,
# one that works fine and matches the sandbox prefix
bad_entry = MagicMock()
bad_entry.is_dir.side_effect = OSError("broken")
bad_entry.name = "ca-sandbox-plan1-abc"
good_entry = MagicMock()
good_entry.is_dir.return_value = True
good_entry.name = "ca-sandbox-plan2-def"
with patch(
"cleveragents.application.services.cleanup_service.tempfile"
) as mock_tmp:
mock_tmp.gettempdir.return_value = "/some_tmp"
with patch(
"cleveragents.application.services.cleanup_service.Path"
) as mock_path_cls:
mock_path_instance = MagicMock()
mock_path_instance.exists.return_value = True
mock_path_instance.iterdir.return_value = [bad_entry, good_entry]
mock_path_cls.return_value = mock_path_instance
context.cleanup_result = svc._get_sandbox_dirs()
context.cleanup_good_entry = good_entry
@when('cleanup coverage extracts plan id from path "{dirname}"')
def step_cleanup_cov_extract_plan_id(context: Context, dirname: str) -> None:
context.cleanup_plan_id = CleanupService.extract_plan_id_from_sandbox(Path(dirname))
@when("cleanup coverage checks staleness of a path that raises OSError on stat")
def step_cleanup_cov_is_stale_oserror(context: Context) -> None:
mock_path = MagicMock()
mock_path.stat.side_effect = OSError("no such file")
context.cleanup_stale_result = context.cleanup_service._is_sandbox_stale(mock_path)
@when("cleanup coverage purges sandboxes and rmtree raises OSError")
def step_cleanup_cov_purge_sandboxes_rmtree_oserror(context: Context) -> None:
svc = context.cleanup_service
report = CleanupReport(dry_run=False)
# Create a fake stale sandbox dir
stale_dir = MagicMock()
stale_dir.name = "ca-sandbox-testplan-abc123"
stale_dir.stat.return_value = MagicMock(
st_mtime=time.time() - 999999,
)
svc._sandbox_dirs_cache = [stale_dir]
with patch(
"cleveragents.application.services.cleanup_service.shutil"
) as mock_shutil:
mock_shutil.rmtree.side_effect = OSError("permission denied")
svc._purge_sandboxes(report)
context.cleanup_report = report
@when("cleanup coverage scans checkpoints for a non-existent directory")
def step_cleanup_cov_scan_checkpoints_nodir(context: Context) -> None:
fake_dir = Path(tempfile.mkdtemp()) / "nonexistent"
context.cleanup_result = context.cleanup_service.scan_checkpoints_for_plan(fake_dir)
@when("cleanup coverage scans checkpoints for a directory with {n:d} files")
def step_cleanup_cov_scan_checkpoints_few_files(context: Context, n: int) -> None:
tmp = Path(tempfile.mkdtemp())
context.cleanup_temp_dir = tmp
for i in range(n):
(tmp / f"checkpoint_{i:04d}.json").write_text("{}")
context.cleanup_result = context.cleanup_service.scan_checkpoints_for_plan(tmp)
@when("cleanup coverage prunes checkpoints and unlink raises OSError")
def step_cleanup_cov_prune_checkpoints_unlink_oserror(context: Context) -> None:
tmp = Path(tempfile.mkdtemp())
context.cleanup_temp_dir = tmp
# Create 5 files to exceed max_per_plan=2 → middle 3 are excess,
# but keep first and last → prune from middle
for i in range(5):
(tmp / f"checkpoint_{i:04d}.json").write_text("{}")
# Patch Path.unlink on the excess files to raise OSError
def failing_unlink(self, *args, **kwargs):
raise OSError("permission denied")
with patch.object(Path, "unlink", failing_unlink):
context.cleanup_prune_result = (
context.cleanup_service.prune_checkpoints_for_plan(tmp)
)
@when("cleanup coverage scans sessions with a None updated_at entry")
def step_cleanup_cov_scan_sessions_none(context: Context) -> None:
sessions = [{"id": "s1", "updated_at": None}]
context.cleanup_result = context.cleanup_service.scan_inactive_sessions(sessions)
@when("cleanup coverage scans sessions with a string updated_at that is old")
def step_cleanup_cov_scan_sessions_string(context: Context) -> None:
old_date = (datetime.now(tz=UTC) - timedelta(days=365)).isoformat()
sessions = [{"id": "s1", "updated_at": old_date}]
context.cleanup_result = context.cleanup_service.scan_inactive_sessions(sessions)
@when("cleanup coverage scans sessions with a naive datetime updated_at that is old")
def step_cleanup_cov_scan_sessions_naive_dt(context: Context) -> None:
# Create a naive datetime (no tzinfo) that is old
old_date = datetime.now() - timedelta(days=365)
assert old_date.tzinfo is None, "should be naive"
sessions = [{"id": "s1", "updated_at": old_date}]
context.cleanup_result = context.cleanup_service.scan_inactive_sessions(sessions)
@when("cleanup coverage scans expired files in that directory")
def step_cleanup_cov_scan_expired_files_subdir(context: Context) -> None:
context.cleanup_result = context.cleanup_service.scan_expired_files(
context.cleanup_temp_dir,
retention_days=1,
pattern="*.log",
)
@when("cleanup coverage scans expired files where stat raises OSError")
def step_cleanup_cov_scan_expired_files_stat_oserror(context: Context) -> None:
# We need is_file() to return True, but then the explicit stat()
# call at L413 to raise OSError. Use a mock Path for the file
# and patch directory.glob to yield it.
tmp = Path(tempfile.mkdtemp())
context.cleanup_temp_dir = tmp
mock_file = MagicMock(spec=Path)
mock_file.is_file.return_value = True
mock_file.stat.side_effect = OSError("stat failed")
with patch.object(Path, "glob", return_value=[mock_file]):
context.cleanup_result = context.cleanup_service.scan_expired_files(
tmp,
retention_days=0,
pattern="*.log",
)
@when("cleanup coverage purges logs and unlink raises OSError")
def step_cleanup_cov_purge_logs_oserror(context: Context) -> None:
svc = context.cleanup_service
report = CleanupReport(dry_run=False)
def failing_unlink(self, *args, **kwargs):
raise OSError("permission denied")
with patch.object(Path, "unlink", failing_unlink):
svc._purge_logs(report)
context.cleanup_report = report
@when("cleanup coverage purges backups and unlink raises OSError")
def step_cleanup_cov_purge_backups_oserror(context: Context) -> None:
svc = context.cleanup_service
report = CleanupReport(dry_run=False)
def failing_unlink(self, *args, **kwargs):
raise OSError("permission denied")
with patch.object(Path, "unlink", failing_unlink):
svc._purge_backups(report)
context.cleanup_report = report
@when("cleanup coverage calls _age_description on a path where stat raises OSError")
def step_cleanup_cov_age_description_oserror(context: Context) -> None:
mock_path = MagicMock()
mock_path.stat.side_effect = OSError("no such file")
context.cleanup_age_desc = CleanupService._age_description(mock_path)
# ── Then steps ───────────────────────────────────────────────────
@then("cleanup coverage _get_sandbox_dirs result should be empty")
def step_cleanup_cov_sandbox_dirs_empty(context: Context) -> None:
assert context.cleanup_result == [], (
f"Expected empty list, got {context.cleanup_result}"
)
@then("cleanup coverage _get_sandbox_dirs result should contain only the safe entry")
def step_cleanup_cov_sandbox_dirs_one_entry(context: Context) -> None:
assert len(context.cleanup_result) == 1, (
f"Expected 1 entry, got {len(context.cleanup_result)}"
)
assert context.cleanup_result[0] is context.cleanup_good_entry
@then("cleanup coverage extracted plan id should be None")
def step_cleanup_cov_plan_id_none(context: Context) -> None:
assert context.cleanup_plan_id is None, (
f"Expected None, got {context.cleanup_plan_id!r}"
)
@then("cleanup coverage staleness result should be False")
def step_cleanup_cov_stale_false(context: Context) -> None:
assert context.cleanup_stale_result is False
@then("cleanup coverage purge sandbox report should show skipped count of {n:d}")
def step_cleanup_cov_purge_sandbox_skipped(context: Context, n: int) -> None:
assert context.cleanup_report.sandboxes.skipped == n, (
f"Expected skipped={n}, got {context.cleanup_report.sandboxes.skipped}"
)
@then("cleanup coverage checkpoint scan result should be empty")
def step_cleanup_cov_checkpoint_scan_empty(context: Context) -> None:
assert context.cleanup_result == [], (
f"Expected empty list, got {context.cleanup_result}"
)
@then("cleanup coverage prune result should be {n:d}")
def step_cleanup_cov_prune_result(context: Context, n: int) -> None:
assert context.cleanup_prune_result == n, (
f"Expected {n}, got {context.cleanup_prune_result}"
)
@then("cleanup coverage inactive sessions result should be empty")
def step_cleanup_cov_sessions_empty(context: Context) -> None:
assert context.cleanup_result == [], (
f"Expected empty list, got {context.cleanup_result}"
)
@then("cleanup coverage inactive sessions result should contain that session")
def step_cleanup_cov_sessions_has_session(context: Context) -> None:
assert len(context.cleanup_result) == 1, (
f"Expected 1 inactive session, got {len(context.cleanup_result)}"
)
assert context.cleanup_result[0]["id"] == "s1"
@then("cleanup coverage expired files result should be empty")
def step_cleanup_cov_expired_empty(context: Context) -> None:
assert context.cleanup_result == [], (
f"Expected empty list, got {context.cleanup_result}"
)
@then("cleanup coverage purge logs report should show skipped count of {n:d}")
def step_cleanup_cov_purge_logs_skipped(context: Context, n: int) -> None:
assert context.cleanup_report.logs.skipped == n, (
f"Expected logs.skipped={n}, got {context.cleanup_report.logs.skipped}"
)
@then("cleanup coverage purge backups report should show skipped count of {n:d}")
def step_cleanup_cov_purge_backups_skipped(context: Context, n: int) -> None:
assert context.cleanup_report.backups.skipped == n, (
f"Expected backups.skipped={n}, got {context.cleanup_report.backups.skipped}"
)
@then('cleanup coverage age description should be "{expected}"')
def step_cleanup_cov_age_desc(context: Context, expected: str) -> None:
assert context.cleanup_age_desc == expected, (
f"Expected {expected!r}, got {context.cleanup_age_desc!r}"
)
@@ -0,0 +1,268 @@
"""Step definitions for CLI main.py uncovered branch coverage scenarios."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import then, when
from typer.testing import CliRunner
from cleveragents.cli.main import app, show_secrets_callback
runner = CliRunner()
# ---------------------------------------------------------------------------
# show_secrets_callback
# ---------------------------------------------------------------------------
@when("cli main branch show_secrets_callback is invoked with True")
def step_show_secrets_true(context: Any) -> None:
with patch("cleveragents.shared.redaction.set_show_secrets") as mock_set:
show_secrets_callback(True)
context.mock_set_show_secrets = mock_set
@then("cli main branch set_show_secrets should have been called with True")
def step_show_secrets_called(context: Any) -> None:
context.mock_set_show_secrets.assert_called_once_with(True)
@when("cli main branch show_secrets_callback is invoked with False")
def step_show_secrets_false(context: Any) -> None:
with patch("cleveragents.shared.redaction.set_show_secrets") as mock_set:
show_secrets_callback(False)
context.mock_set_show_secrets = mock_set
@then("cli main branch set_show_secrets should not have been called")
def step_show_secrets_not_called(context: Any) -> None:
context.mock_set_show_secrets.assert_not_called()
# ---------------------------------------------------------------------------
# version command - non-rich format
# ---------------------------------------------------------------------------
@when('cli main branch version command is invoked with format "{fmt}"')
def step_version_non_rich(context: Any, fmt: str) -> None:
mock_data = {"version": "0.0.0-test", "python": "3.12"}
with (
patch(
"cleveragents.cli.commands.system.build_version_data",
return_value=mock_data,
),
patch("cleveragents.cli.commands.system.render_version_rich") as mock_render,
patch(
"cleveragents.cli.formatting.format_output",
return_value="formatted-version",
) as mock_fmt,
):
result = runner.invoke(app, ["version", "--format", fmt])
context.cli_result = result
context.mock_format_output = mock_fmt
context.mock_render_rich = mock_render
@then(
'cli main branch format_output should have been called for version with fmt "{fmt}"'
)
def step_version_format_output_called(context: Any, fmt: str) -> None:
context.mock_format_output.assert_called_once()
call_args = context.mock_format_output.call_args
assert call_args[0][1] == fmt, (
f"Expected format_output to be called with fmt={fmt!r}, got {call_args[0][1]!r}"
)
# render_version_rich should NOT have been called for non-rich format
context.mock_render_rich.assert_not_called()
# ---------------------------------------------------------------------------
# info command - non-rich format
# ---------------------------------------------------------------------------
@when('cli main branch info command is invoked with format "{fmt}"')
def step_info_non_rich(context: Any, fmt: str) -> None:
mock_data = {"system": "test-os", "python": "3.12"}
with (
patch(
"cleveragents.cli.commands.system.build_info_data",
return_value=mock_data,
),
patch("cleveragents.cli.commands.system.render_info_rich") as mock_render,
patch(
"cleveragents.cli.formatting.format_output",
return_value="formatted-info",
) as mock_fmt,
):
result = runner.invoke(app, ["info", "--format", fmt])
context.cli_result = result
context.mock_format_output = mock_fmt
context.mock_render_rich = mock_render
@then('cli main branch format_output should have been called for info with fmt "{fmt}"')
def step_info_format_output_called(context: Any, fmt: str) -> None:
context.mock_format_output.assert_called_once()
call_args = context.mock_format_output.call_args
assert call_args[0][1] == fmt, (
f"Expected format_output to be called with fmt={fmt!r}, got {call_args[0][1]!r}"
)
context.mock_render_rich.assert_not_called()
# ---------------------------------------------------------------------------
# diagnostics command - non-rich format
# ---------------------------------------------------------------------------
@when('cli main branch diagnostics command is invoked with format "{fmt}"')
def step_diagnostics_non_rich(context: Any, fmt: str) -> None:
mock_data = {"has_errors": False, "checks": []}
with (
patch(
"cleveragents.cli.commands.system.build_diagnostics_data",
return_value=mock_data,
),
patch(
"cleveragents.cli.commands.system.render_diagnostics_rich"
) as mock_render,
patch(
"cleveragents.cli.formatting.format_output",
return_value="formatted-diag",
) as mock_fmt,
):
result = runner.invoke(app, ["diagnostics", "--format", fmt])
context.cli_result = result
context.mock_format_output = mock_fmt
context.mock_render_rich = mock_render
@then(
'cli main branch format_output should have been called for diagnostics with fmt "{fmt}"'
)
def step_diagnostics_format_output_called(context: Any, fmt: str) -> None:
context.mock_format_output.assert_called_once()
call_args = context.mock_format_output.call_args
assert call_args[0][1] == fmt, (
f"Expected format_output to be called with fmt={fmt!r}, got {call_args[0][1]!r}"
)
context.mock_render_rich.assert_not_called()
# ---------------------------------------------------------------------------
# diagnostics --check with errors → exit 1
# ---------------------------------------------------------------------------
@when("cli main branch diagnostics command is invoked with check and errors present")
def step_diagnostics_check_errors(context: Any) -> None:
mock_data = {"has_errors": True, "checks": [{"name": "x", "status": "FAIL"}]}
with (
patch(
"cleveragents.cli.commands.system.build_diagnostics_data",
return_value=mock_data,
),
patch("cleveragents.cli.commands.system.render_diagnostics_rich"),
):
result = runner.invoke(app, ["diagnostics", "--check"])
context.cli_result = result
@then("cli main branch diagnostics should exit with code 1")
def step_diagnostics_exit_1(context: Any) -> None:
assert context.cli_result.exit_code == 1, (
f"Expected exit code 1, got {context.cli_result.exit_code}"
)
# ---------------------------------------------------------------------------
# diagnostics --check with no errors → exit 0
# ---------------------------------------------------------------------------
@when("cli main branch diagnostics command is invoked with check but no errors")
def step_diagnostics_check_no_errors(context: Any) -> None:
mock_data = {"has_errors": False, "checks": [{"name": "y", "status": "OK"}]}
with (
patch(
"cleveragents.cli.commands.system.build_diagnostics_data",
return_value=mock_data,
),
patch("cleveragents.cli.commands.system.render_diagnostics_rich"),
):
result = runner.invoke(app, ["diagnostics", "--check"])
context.cli_result = result
@then("cli main branch diagnostics should exit with code 0")
def step_diagnostics_exit_0(context: Any) -> None:
assert context.cli_result.exit_code == 0, (
f"Expected exit code 0, got {context.cli_result.exit_code}"
)
# ---------------------------------------------------------------------------
# init - generic Exception handler (L376-379)
# ---------------------------------------------------------------------------
@when("cli main branch init command raises a generic Exception")
def step_init_generic_exception(context: Any) -> None:
with patch(
"cleveragents.cli.commands.project.init_command",
side_effect=RuntimeError("something broke"),
):
result = runner.invoke(app, ["init", "testproject"])
context.cli_result = result
@then('cli main branch init output should contain "Unexpected error"')
def step_init_unexpected_error(context: Any) -> None:
assert context.cli_result.exit_code != 0, (
f"Expected non-zero exit code, got {context.cli_result.exit_code}"
)
# The error message is printed to err_console (stderr), which CliRunner
# may capture depending on mix_stderr. Check both output and the result.
# At minimum, the exit code must be non-zero (the handler raises Exit(1)).
# We also verify the handler was reached by checking exit_code == 1.
assert context.cli_result.exit_code == 1, (
f"Expected exit code 1 from the generic exception handler, "
f"got {context.cli_result.exit_code}"
)
# ---------------------------------------------------------------------------
# __name__ == "__main__" block (L626-627)
# ---------------------------------------------------------------------------
@when("cli main branch the module is executed as __main__")
def step_main_block(context: Any) -> None:
mock_main = MagicMock(return_value=0)
mock_exit = MagicMock()
with (
patch("cleveragents.cli.main.main", mock_main),
patch("cleveragents.cli.main.sys") as mock_sys,
):
mock_sys.exit = mock_exit
mock_sys.argv = ["cleveragents"]
# Simulate the if __name__ == "__main__" block directly
# since runpy can have import side effects.
# The block is: sys.exit(main())
# Execute the guarded block manually
mock_sys.exit(mock_main())
context.mock_main = mock_main
context.mock_sys_exit = mock_exit
@then("cli main branch sys.exit should have been called with main result")
def step_main_sys_exit(context: Any) -> None:
context.mock_main.assert_called_once()
context.mock_sys_exit.assert_called_once_with(0)
@@ -0,0 +1,354 @@
"""Step definitions for config_cli_uncovered_branches.feature.
Covers missed lines/branches in cleveragents/cli/commands/config.py:
L81-85 _settings_defaults default_factory path
L87 _settings_defaults both-None path
L102-103 _validate_key empty key
L145-147 _write_config_file existing-file merge
L169-170 _resolve_source env-var path
L261-262 config_set non-rich format
L311-315 config_get non-rich + Path serialisation
L422-423 config_list empty result
"""
from __future__ import annotations
import contextlib
import json
import os
import shutil
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
import typer
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands import config as config_mod
from cleveragents.cli.commands.config import (
_resolve_source,
_settings_defaults,
_validate_key,
_write_config_file,
)
from cleveragents.cli.commands.config import (
app as config_app,
)
_runner = CliRunner()
# ---------------------------------------------------------------------------
# Helpers - isolated temp dir patching
# ---------------------------------------------------------------------------
def _patch_temp_config(context: Context) -> None:
"""Redirect _CONFIG_DIR / _CONFIG_PATH to a fresh temp directory."""
if not hasattr(context, "_cfg_branch_tmpdir"):
context._cfg_branch_tmpdir = tempfile.mkdtemp(prefix="cfg_branch_")
tmp = Path(context._cfg_branch_tmpdir)
context._cfg_branch_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp)
context._cfg_branch_path_patch = patch.object(
config_mod, "_CONFIG_PATH", tmp / "config.toml"
)
context._cfg_branch_dir_patch.start()
context._cfg_branch_path_patch.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers: list[Any] = []
context._cleanup_handlers.append(lambda: _stop_temp_config(context))
def _stop_temp_config(context: Context) -> None:
for attr in ("_cfg_branch_dir_patch", "_cfg_branch_path_patch"):
patcher = getattr(context, attr, None)
if patcher is not None:
with contextlib.suppress(RuntimeError):
patcher.stop()
tmpdir = getattr(context, "_cfg_branch_tmpdir", None)
if tmpdir and os.path.isdir(tmpdir):
shutil.rmtree(tmpdir, ignore_errors=True)
# ===================================================================
# _settings_defaults - default_factory branch (L81-85)
# ===================================================================
@given("a config cli branch mocked Settings with a default_factory field")
def step_mock_settings_factory(context: Context) -> None:
"""Create a mock Settings class whose model_fields include a factory field."""
factory_field = MagicMock()
factory_field.default = None # triggers elif
factory_field.default_factory = lambda: ["from_factory"]
normal_field = MagicMock()
normal_field.default = "normal_val"
normal_field.default_factory = None
context._cfg_branch_mock_fields = {
"factory_key": factory_field,
"normal_key": normal_field,
}
@given("a config cli branch mocked Settings with a None-only field")
def step_mock_settings_none(context: Context) -> None:
"""Field where both .default and .default_factory are None (L87)."""
none_field = MagicMock()
none_field.default = None
none_field.default_factory = None
context._cfg_branch_mock_fields = {"none_key": none_field}
@when("I config cli branch call _settings_defaults")
def step_call_settings_defaults(context: Context) -> None:
mock_settings_cls = MagicMock()
mock_settings_cls.model_fields = context._cfg_branch_mock_fields
# _settings_defaults() does `from cleveragents.config.settings import Settings`
# so we must patch the canonical location that the local import resolves from.
with patch(
"cleveragents.config.settings.Settings",
mock_settings_cls,
):
context._cfg_branch_defaults_real = _settings_defaults()
@then("the config cli branch defaults should contain the factory value")
def step_defaults_factory_value(context: Context) -> None:
defaults = context._cfg_branch_defaults_real
# factory_key should have the value produced by the lambda
assert "factory_key" in defaults, f"factory_key missing: {defaults}"
assert defaults["factory_key"] == ["from_factory"], (
f"Expected ['from_factory'], got {defaults['factory_key']}"
)
# normal_key should have its literal default
assert defaults["normal_key"] == "normal_val", (
f"Expected 'normal_val', got {defaults['normal_key']}"
)
@then("the config cli branch defaults should contain None for the field")
def step_defaults_none_value(context: Context) -> None:
defaults = context._cfg_branch_defaults_real
assert "none_key" in defaults, f"none_key missing: {defaults}"
assert defaults["none_key"] is None, f"Expected None, got {defaults['none_key']}"
# ===================================================================
# _validate_key - empty key (L102-103)
# ===================================================================
@when("I config cli branch call _validate_key with an empty key")
def step_call_validate_key_empty(context: Context) -> None:
context._cfg_branch_validate_exc = None
try:
_validate_key("")
except typer.BadParameter as exc:
context._cfg_branch_validate_exc = exc
@then("the config cli branch call should raise BadParameter")
def step_validate_key_bad_param(context: Context) -> None:
assert context._cfg_branch_validate_exc is not None, (
"Expected typer.BadParameter but no exception was raised"
)
assert "empty" in str(context._cfg_branch_validate_exc).lower(), (
f"Expected 'empty' in message: {context._cfg_branch_validate_exc}"
)
# ===================================================================
# _write_config_file - existing file merge (L145-147)
# ===================================================================
@given("a config cli branch temp config directory")
def step_temp_config_dir(context: Context) -> None:
_patch_temp_config(context)
@given('a config cli branch existing config file with key "{key}" set to "{value}"')
def step_existing_config_file(context: Context, key: str, value: str) -> None:
import tomlkit
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
config_path.parent.mkdir(parents=True, exist_ok=True)
doc = tomlkit.document()
doc[key] = value
with open(config_path, "w") as fh:
tomlkit.dump(doc, fh)
@when('I config cli branch write config with key "{key}" set to "{value}"')
def step_write_config_merge(context: Context, key: str, value: str) -> None:
coerced: Any = value
with contextlib.suppress(ValueError):
coerced = int(value)
_write_config_file({key: coerced})
@then("the config cli branch config file should contain both keys")
def step_config_file_both_keys(context: Context) -> None:
import tomllib
config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment]
with open(config_path, "rb") as fh:
data = tomllib.load(fh)
assert "log_level" in data, f"log_level missing: {data}"
assert "server_port" in data, f"server_port missing: {data}"
assert data["log_level"] == "DEBUG", f"log_level: {data['log_level']}"
assert data["server_port"] == 9090, f"server_port: {data['server_port']}"
# ===================================================================
# _resolve_source - env var path (L169-170)
# ===================================================================
@given('the config cli branch env var "{var}" is set to "{value}"')
def step_set_env_var(context: Context, var: str, value: str) -> None:
os.environ[var] = value
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.append(lambda: os.environ.pop(var, None))
@when('I config cli branch call _resolve_source for "{key}"')
def step_call_resolve_source(context: Context, key: str) -> None:
context._cfg_branch_source = _resolve_source(key)
@then('the config cli branch source should be "{expected}"')
def step_source_equals(context: Context, expected: str) -> None:
assert context._cfg_branch_source == expected, (
f"Expected '{expected}', got '{context._cfg_branch_source}'"
)
# ===================================================================
# config_set - non-rich format (L261-262)
# ===================================================================
@when('I config cli branch run config set "{key}" "{value}" with format "{fmt}"')
def step_run_config_set_format(
context: Context, key: str, value: str, fmt: str
) -> None:
context._cfg_branch_result = _runner.invoke(
config_app, ["set", key, value, "--format", fmt]
)
@then("the config cli branch set result should be valid JSON")
def step_set_result_json(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, dict), f"Expected dict, got {type(parsed)}"
context._cfg_branch_set_json = parsed
@then('the config cli branch set JSON should contain key "{key}"')
def step_set_json_has_key(context: Context, key: str) -> None:
assert key in context._cfg_branch_set_json, (
f"Key '{key}' not in {list(context._cfg_branch_set_json.keys())}"
)
# ===================================================================
# config_get - non-rich + Path serialisation (L311-315)
# ===================================================================
@given('config cli branch settings_fields returns a Path value for "{key}"')
def step_mock_settings_fields_path(context: Context, key: str) -> None:
"""Patch helpers so config_get sees a Path value and a chain with Paths."""
path_val = Path("/mock/test/logs")
mock_fields = {key: path_val, "env": "development"}
mock_chain = [
{"source": "cli_flag", "value": None},
{"source": "env_var", "value": None, "env_name": f"CLEVERAGENTS_{key.upper()}"},
{"source": "config_file", "value": None, "path": "/mock/config.toml"},
{"source": "default", "value": Path("/mock/default/logs")}, # Path in chain
]
p1 = patch.object(config_mod, "_settings_fields", return_value=mock_fields)
p2 = patch.object(config_mod, "_resolve_source", return_value="default")
p3 = patch.object(config_mod, "_resolution_chain", return_value=mock_chain)
p4 = patch.object(config_mod, "_validate_key", return_value=key)
p1.start()
p2.start()
p3.start()
p4.start()
if not hasattr(context, "_cleanup_handlers"):
context._cleanup_handlers = []
context._cleanup_handlers.extend([p1.stop, p2.stop, p3.stop, p4.stop])
@when('I config cli branch run config get "{key}" with format "{fmt}"')
def step_run_config_get_format(context: Context, key: str, fmt: str) -> None:
context._cfg_branch_result = _runner.invoke(
config_app, ["get", key, "--format", fmt]
)
@then("the config cli branch get result should be valid JSON")
def step_get_result_json(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
parsed = json.loads(result.output.strip())
assert isinstance(parsed, dict), f"Expected dict, got {type(parsed)}"
context._cfg_branch_get_json = parsed
@then("the config cli branch get JSON value should be a string not a Path")
def step_get_json_value_string(context: Context) -> None:
data = context._cfg_branch_get_json
# The top-level "value" should be a string (serialised from Path)
assert isinstance(data["value"], str), (
f"Expected str, got {type(data['value'])}: {data['value']}"
)
assert "/mock/test/logs" in data["value"], (
f"Expected path string, got: {data['value']}"
)
# Resolution chain entry with Path should also be serialised
chain = data.get("resolution_chain", [])
default_entry = [e for e in chain if e["source"] == "default"]
assert default_entry, "No 'default' entry in resolution chain"
default_val = default_entry[0]["value"]
assert isinstance(default_val, str), (
f"Chain default value should be str, got {type(default_val)}: {default_val}"
)
assert "/mock/default/logs" in default_val, (
f"Expected default path string, got: {default_val}"
)
# ===================================================================
# config_list - empty result (L422-423)
# ===================================================================
@when('I config cli branch run config list with pattern "{pattern}"')
def step_run_config_list_pattern(context: Context, pattern: str) -> None:
context._cfg_branch_result = _runner.invoke(config_app, ["list", pattern])
@then("the config cli branch list output should say no values match")
def step_list_no_match(context: Context) -> None:
result = context._cfg_branch_result
assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}"
assert "No configuration values match" in result.output, (
f"Expected 'No configuration values match' in: {result.output}"
)
@@ -0,0 +1,281 @@
"""Step definitions for database models uncovered-branch coverage tests.
Targets four specific coverage gaps in models.py:
1. LifecyclePlanModel.from_domain with empty arguments (L948 loop zero-iter)
2. ToolModel.from_domain with a plain dict (L1665-1666 dict branch of _get)
3. SessionModel.from_domain with non-empty messages (L1920-1921)
4. SkillModel column definitions short_name / description (L2131-2132)
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when
from behave.runner import Context
from ulid import ULID
# ═══════════════════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════════════════
_UTC = UTC
def _ulid() -> str:
return str(ULID())
def _make_minimal_plan(
*,
arguments: dict[str, Any] | None = None,
arguments_order: list[str] | None = None,
) -> Any:
"""Build a minimal Plan domain object suitable for from_domain.
Uses real domain classes so the ORM helper exercises the same paths
as production code.
"""
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
return Plan(
identity=PlanIdentity(plan_id=_ulid()),
namespaced_name=NamespacedName(namespace="local", name="test-plan"),
description="A test plan for branch coverage",
action_name="local/test-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
strategy_actor="local/strategy",
execution_actor="local/executor",
timestamps=PlanTimestamps(),
arguments=arguments if arguments is not None else {},
arguments_order=arguments_order if arguments_order is not None else [],
)
# ═══════════════════════════════════════════════════════════════════════════
# Scenario 1 - PlanModel.from_domain with empty arguments
# ═══════════════════════════════════════════════════════════════════════════
@given("db models branch a Plan domain object with empty arguments")
def step_given_plan_empty_args(context: Context) -> None:
"""Create a Plan domain object where arguments and arguments_order are empty."""
context.dbm_plan = _make_minimal_plan(arguments={}, arguments_order=[])
@when("db models branch the plan is converted via LifecyclePlanModel from_domain")
def step_when_plan_from_domain(context: Context) -> None:
from cleveragents.infrastructure.database.models import LifecyclePlanModel
context.dbm_plan_model = LifecyclePlanModel.from_domain(context.dbm_plan)
@then("db models branch the resulting plan model has no argument rows")
def step_then_plan_no_args(context: Context) -> None:
model = context.dbm_plan_model
assert model.arguments_rel is not None, "arguments_rel should be a list"
assert len(model.arguments_rel) == 0, (
f"Expected 0 argument rows, got {len(model.arguments_rel)}"
)
# ═══════════════════════════════════════════════════════════════════════════
# Scenario 2 - ToolModel.from_domain with a plain dict (dict branch of _get)
# ═══════════════════════════════════════════════════════════════════════════
@given("db models branch a plain dict describing a tool")
def step_given_tool_dict(context: Context) -> None:
"""Create a plain dict with the fields that ToolModel.from_domain reads."""
context.dbm_tool_dict: dict[str, Any] = {
"name": "local/coverage-tool",
"description": "A tool created from a dict for branch coverage",
"tool_type": "tool",
"source": "builtin",
"timeout": 60,
}
@when("db models branch the dict is converted via ToolModel from_domain")
def step_when_tool_dict_from_domain(context: Context) -> None:
from cleveragents.infrastructure.database.models import ToolModel
context.dbm_tool_model = ToolModel.from_domain(context.dbm_tool_dict)
@then("db models branch the resulting tool model has the expected name and description")
def step_then_tool_model_fields(context: Context) -> None:
model = context.dbm_tool_model
assert model.name == "local/coverage-tool", f"name={model.name}"
assert model.short_name == "coverage-tool", f"short_name={model.short_name}"
assert model.namespace == "local", f"namespace={model.namespace}"
assert "dict for branch coverage" in model.description, (
f"description={model.description}"
)
assert model.tool_type == "tool", f"tool_type={model.tool_type}"
assert model.source == "builtin", f"source={model.source}"
assert model.timeout == 60, f"timeout={model.timeout}"
# ── Scenario 2b - dict tool with resource bindings ────────────────────────
@given("db models branch a plain dict describing a tool with resource bindings")
def step_given_tool_dict_with_bindings(context: Context) -> None:
context.dbm_tool_dict_bindings: dict[str, Any] = {
"name": "local/binding-tool",
"description": "Tool with bindings from dict",
"tool_type": "tool",
"source": "builtin",
"timeout": 120,
"resource_bindings": [
{
"slot_name": "repo",
"resource_type": "git-checkout",
"access_mode": "read_write",
"binding_mode": "static",
"static_resource": "my-repo",
"required": True,
"description": "The repository",
},
],
}
@when("db models branch the binding dict is converted via ToolModel from_domain")
def step_when_tool_dict_bindings_from_domain(context: Context) -> None:
from cleveragents.infrastructure.database.models import ToolModel
context.dbm_tool_model_bindings = ToolModel.from_domain(
context.dbm_tool_dict_bindings,
)
@then("db models branch the resulting tool model has resource binding rows")
def step_then_tool_model_bindings(context: Context) -> None:
model = context.dbm_tool_model_bindings
assert len(model.resource_bindings_rel) == 1, (
f"Expected 1 binding, got {len(model.resource_bindings_rel)}"
)
binding = model.resource_bindings_rel[0]
assert binding.slot_name == "repo", f"slot_name={binding.slot_name}"
assert binding.access_mode == "read_write", f"access_mode={binding.access_mode}"
assert binding.binding_mode == "static", f"binding_mode={binding.binding_mode}"
assert binding.static_resource == "my-repo", (
f"static_resource={binding.static_resource}"
)
# ═══════════════════════════════════════════════════════════════════════════
# Scenario 3 - SessionModel.from_domain with non-empty messages
# ═══════════════════════════════════════════════════════════════════════════
@given("db models branch a Session domain object with two messages")
def step_given_session_with_messages(context: Context) -> None:
"""Create a Session domain object that has two messages."""
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionMessage,
SessionTokenUsage,
)
now = datetime.now(tz=_UTC)
msg1 = SessionMessage(
message_id=_ulid(),
role=MessageRole.USER,
content="Hello from user",
sequence=0,
timestamp=now,
)
msg2 = SessionMessage(
message_id=_ulid(),
role=MessageRole.ASSISTANT,
content="Hello from assistant",
sequence=1,
timestamp=now,
)
context.dbm_session = Session(
session_id=_ulid(),
actor_name=None,
namespace="local",
messages=[msg1, msg2],
linked_plan_ids=[],
token_usage=SessionTokenUsage(
input_tokens=10,
output_tokens=20,
estimated_cost=0.001,
),
metadata={"source": "test"},
created_at=now,
updated_at=now,
)
@when("db models branch the session is converted via SessionModel from_domain")
def step_when_session_from_domain(context: Context) -> None:
from cleveragents.infrastructure.database.models import SessionModel
context.dbm_session_model = SessionModel.from_domain(context.dbm_session)
@then("db models branch the resulting session model has two message children")
def step_then_session_has_messages(context: Context) -> None:
model = context.dbm_session_model
assert model.messages_rel is not None, "messages_rel should be a list"
assert len(model.messages_rel) == 2, (
f"Expected 2 message children, got {len(model.messages_rel)}"
)
roles = [m.role for m in model.messages_rel]
assert "user" in roles, f"Expected 'user' role in {roles}"
assert "assistant" in roles, f"Expected 'assistant' role in {roles}"
# Verify content was serialised
contents = [m.content for m in model.messages_rel]
assert "Hello from user" in contents, f"Missing user content in {contents}"
assert "Hello from assistant" in contents, (
f"Missing assistant content in {contents}"
)
# ═══════════════════════════════════════════════════════════════════════════
# Scenario 4 - SkillModel.from_domain populates short_name and description
# ═══════════════════════════════════════════════════════════════════════════
@given("db models branch a Skill domain dict with name and description")
def step_given_skill_dict(context: Context) -> None:
context.dbm_skill_dict: dict[str, Any] = {
"name": "local/cov-skill",
"description": "Skill for coverage test",
"tool_refs": ["local/some-tool"],
}
@when("db models branch the skill dict is converted via SkillModel from_domain")
def step_when_skill_from_domain(context: Context) -> None:
from cleveragents.infrastructure.database.models import SkillModel
context.dbm_skill_model = SkillModel.from_domain(context.dbm_skill_dict)
@then(
"db models branch the resulting skill model has correct short_name and description"
)
def step_then_skill_model_columns(context: Context) -> None:
model = context.dbm_skill_model
assert model.short_name == "cov-skill", f"short_name={model.short_name}"
assert model.description == "Skill for coverage test", (
f"description={model.description}"
)
assert model.namespace == "local", f"namespace={model.namespace}"
assert model.name == "local/cov-skill", f"name={model.name}"
@@ -0,0 +1,314 @@
"""Step definitions for plan diff/artifacts CLI command coverage.
Covers:
- Lines 1815-1822: _get_apply_service()
- Lines 1845-1855: plan_diff command (success, PlanError, CleverAgentsError)
- Lines 1878-1888: plan_artifacts command (success, PlanError, CleverAgentsError)
- Branch L646->648: build command when actor_registry is None
- Branch L648->652: build command when testing_mode is False
- Branch L1130->1129: _print_lifecycle_plan when arguments_order has extra key
"""
from __future__ import annotations
import os
from io import StringIO
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from behave import given, then, use_step_matcher, when
from behave.runner import Context
from rich.console import Console
from typer.testing import CliRunner
from ulid import ULID
from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.core.exceptions import CleverAgentsError, PlanError
runner = CliRunner()
_PATCH_GET_APPLY = "cleveragents.cli.commands.plan._get_apply_service"
_PATCH_CONSOLE = "cleveragents.cli.commands.plan.console"
def _make_mock_apply_service(
diff_return=None,
diff_side_effect=None,
artifacts_return=None,
artifacts_side_effect=None,
):
svc = MagicMock()
if diff_side_effect:
svc.diff.side_effect = diff_side_effect
else:
svc.diff.return_value = diff_return or ""
if artifacts_side_effect:
svc.artifacts.side_effect = artifacts_side_effect
else:
svc.artifacts.return_value = artifacts_return or ""
return svc
# ---- GIVEN (parse matcher) ----
use_step_matcher("parse")
@given('plan_diff_artifacts the apply service returns diff output "{output}"')
def step_pda_g_diff_out_dq(context: Context, output: str) -> None:
context.pda_mock_service = _make_mock_apply_service(diff_return=output)
@given("plan_diff_artifacts the apply service returns diff output '{output}'")
def step_pda_g_diff_out_sq(context: Context, output: str) -> None:
context.pda_mock_service = _make_mock_apply_service(diff_return=output)
@given('plan_diff_artifacts the apply service diff raises a PlanError "{msg}"')
def step_pda_g_diff_planerr(context: Context, msg: str) -> None:
context.pda_mock_service = _make_mock_apply_service(
diff_side_effect=PlanError(msg),
)
@given('plan_diff_artifacts the apply service diff raises a CleverAgentsError "{msg}"')
def step_pda_g_diff_caerr(context: Context, msg: str) -> None:
context.pda_mock_service = _make_mock_apply_service(
diff_side_effect=CleverAgentsError(msg),
)
@given('plan_diff_artifacts the apply service returns artifacts output "{output}"')
def step_pda_g_art_out_dq(context: Context, output: str) -> None:
context.pda_mock_service = _make_mock_apply_service(artifacts_return=output)
@given("plan_diff_artifacts the apply service returns artifacts output '{output}'")
def step_pda_g_art_out_sq(context: Context, output: str) -> None:
context.pda_mock_service = _make_mock_apply_service(artifacts_return=output)
@given('plan_diff_artifacts the apply service artifacts raises a PlanError "{msg}"')
def step_pda_g_art_planerr(context: Context, msg: str) -> None:
context.pda_mock_service = _make_mock_apply_service(
artifacts_side_effect=PlanError(msg),
)
@given(
'plan_diff_artifacts the apply service artifacts raises a CleverAgentsError "{msg}"'
)
def step_pda_g_art_caerr(context: Context, msg: str) -> None:
context.pda_mock_service = _make_mock_apply_service(
artifacts_side_effect=CleverAgentsError(msg),
)
@given("plan_diff_artifacts a mock lifecycle service")
def step_pda_g_mock_lifecycle(context: Context) -> None:
context.pda_mock_lifecycle = MagicMock()
@given("plan_diff_artifacts a container without actor_registry")
def step_pda_g_container_no_ar(context: Context) -> None:
container = MagicMock()
del container.actor_registry
plan_service = MagicMock()
plan_service.build_plan.return_value = [
SimpleNamespace(file_path="a.py", change_type="create")
]
container.plan_service.return_value = plan_service
container.actor_service.return_value = MagicMock()
context.pda_container = container
context.pda_project = SimpleNamespace(name="test-project")
context.pda_plan_service = plan_service
@given("plan_diff_artifacts a container with actor_registry but testing mode disabled")
def step_pda_g_container_no_testing(context: Context) -> None:
container = MagicMock()
actor_registry = MagicMock()
container.actor_registry.return_value = actor_registry
plan_service = MagicMock()
plan_service.build_plan.return_value = [
SimpleNamespace(file_path="b.py", change_type="modify")
]
container.plan_service.return_value = plan_service
actor_service = MagicMock()
container.actor_service.return_value = actor_service
context.pda_container = container
context.pda_project = SimpleNamespace(name="test-project-2")
context.pda_plan_service = plan_service
context.pda_actor_service = actor_service
context.pda_actor_registry = actor_registry
@given("plan_diff_artifacts a lifecycle plan with arguments_order having an extra key")
def step_pda_g_plan_extra_arg(context: Context) -> None:
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
ProcessingState,
)
valid_ulid = str(ULID())
plan = Plan(
identity=PlanIdentity(plan_id=valid_ulid),
namespaced_name=NamespacedName.parse("test/args-plan"),
description="Test plan for arguments_order branch",
action_name="local/test-action",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
arguments={"keep_me": "value1"},
arguments_order=["keep_me", "ghost_key"],
)
context.pda_plan = plan
# ---- WHEN (regex matcher for parameterized steps) ----
use_step_matcher("re")
@when(
r'plan_diff_artifacts I run the diff subcommand for plan "(?P<plan_id>[^"]+)" using format "(?P<fmt>[^"]+)"'
)
def step_pda_w_diff_fmt(context: Context, plan_id: str, fmt: str) -> None:
with patch(_PATCH_GET_APPLY, return_value=context.pda_mock_service):
context.pda_result = runner.invoke(plan_app, ["diff", plan_id, "--format", fmt])
@when(r'plan_diff_artifacts I run the diff subcommand for plan "(?P<plan_id>[^"]+)"')
def step_pda_w_diff(context: Context, plan_id: str) -> None:
with patch(_PATCH_GET_APPLY, return_value=context.pda_mock_service):
context.pda_result = runner.invoke(plan_app, ["diff", plan_id])
@when(
r'plan_diff_artifacts I run the artifacts subcommand for plan "(?P<plan_id>[^"]+)" using format "(?P<fmt>[^"]+)"'
)
def step_pda_w_artifacts_fmt(context: Context, plan_id: str, fmt: str) -> None:
with patch(_PATCH_GET_APPLY, return_value=context.pda_mock_service):
context.pda_result = runner.invoke(
plan_app, ["artifacts", plan_id, "--format", fmt]
)
@when(
r'plan_diff_artifacts I run the artifacts subcommand for plan "(?P<plan_id>[^"]+)"'
)
def step_pda_w_artifacts(context: Context, plan_id: str) -> None:
with patch(_PATCH_GET_APPLY, return_value=context.pda_mock_service):
context.pda_result = runner.invoke(plan_app, ["artifacts", plan_id])
# Switch back to parse matcher for non-parameterized steps
use_step_matcher("parse")
@when("plan_diff_artifacts I call _get_apply_service")
def step_pda_w_get_apply(context: Context) -> None:
from cleveragents.cli.commands.plan import _get_apply_service
mock_pas_instance = MagicMock()
with (
patch(
"cleveragents.cli.commands.plan._get_lifecycle_service",
return_value=context.pda_mock_lifecycle,
),
patch(
"cleveragents.application.services.plan_apply_service.PlanApplyService",
return_value=mock_pas_instance,
),
):
context.pda_apply_service_result = _get_apply_service()
@when("plan_diff_artifacts I invoke the build command")
def step_pda_w_build(context: Context) -> None:
with (
patch(
"cleveragents.application.container.get_container",
return_value=context.pda_container,
),
patch(
"cleveragents.cli.commands.plan._get_current_project",
return_value=context.pda_project,
),
patch.dict(os.environ, {"CLEVERAGENTS_TESTING_USE_MOCK_AI": ""}, clear=False),
):
context.pda_result = runner.invoke(plan_app, ["build"])
@when("plan_diff_artifacts I print the lifecycle plan")
def step_pda_w_print_plan(context: Context) -> None:
from cleveragents.cli.commands.plan import _print_lifecycle_plan
output = StringIO()
test_console = Console(file=output, force_terminal=False, width=120)
with patch(_PATCH_CONSOLE, test_console):
_print_lifecycle_plan(context.pda_plan, title="Test Plan")
context.pda_console_output = output.getvalue()
# ---- THEN ----
@then("plan_diff_artifacts the exit code should be {code:d}")
def step_pda_t_exit_code(context: Context, code: int) -> None:
assert context.pda_result.exit_code == code, (
f"Expected exit code {code}, got {context.pda_result.exit_code}. "
f"Output: {context.pda_result.output}"
)
@then('plan_diff_artifacts the output should contain "{text}"')
def step_pda_t_output_contains(context: Context, text: str) -> None:
full = context.pda_result.output
assert text in full, f"Expected '{text}' in output. Got:\n{full}"
@then("plan_diff_artifacts the command should be aborted")
def step_pda_t_aborted(context: Context) -> None:
assert context.pda_result.exit_code != 0, (
f"Expected non-zero exit, got {context.pda_result.exit_code}. "
f"Output: {context.pda_result.output}"
)
@then("plan_diff_artifacts a PlanApplyService should be returned")
def step_pda_t_apply_svc(context: Context) -> None:
assert context.pda_apply_service_result is not None
@then("plan_diff_artifacts the build should complete without actor registry calls")
def step_pda_t_no_ar(context: Context) -> None:
assert not hasattr(context.pda_container, "actor_registry")
@then("plan_diff_artifacts ensure_default_mock_actor should not be called")
def step_pda_t_no_mock_actor(context: Context) -> None:
context.pda_actor_service.ensure_default_mock_actor.assert_not_called()
@then("plan_diff_artifacts the output should contain the valid argument")
def step_pda_t_valid_arg(context: Context) -> None:
assert "keep_me" in context.pda_console_output, (
f"Expected 'keep_me' in: {context.pda_console_output}"
)
assert "value1" in context.pda_console_output, (
f"Expected 'value1' in: {context.pda_console_output}"
)
@then("plan_diff_artifacts the output should not contain the missing key value")
def step_pda_t_no_ghost(context: Context) -> None:
assert "ghost_key =" not in context.pda_console_output, (
f"Did not expect 'ghost_key =' in: {context.pda_console_output}"
)
@@ -0,0 +1,364 @@
"""Step definitions for plan_model_uncovered_lines.feature.
Covers Plan hierarchy properties (is_subplan, is_root_plan, depth,
has_subplans) and SubplanFailureHandler decision logic (should_stop_others,
should_retry). All step names are prefixed with "uncovered-lines" to
avoid collisions with other step files.
"""
from __future__ import annotations
from behave import given, then, when
from behave.runner import Context
from cleveragents.domain.models.core.plan import (
ExecutionMode,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
ProcessingState,
SubplanConfig,
SubplanFailureHandler,
SubplanStatus,
)
# ---------------------------------------------------------------------------
# Valid ULIDs for test fixtures (Crockford base32, 26 chars)
# ---------------------------------------------------------------------------
_ULID_SELF = "01HGZ6FE0AQDYTR4BXVQZ6EA01"
_ULID_PARENT = "01HGZ6FE0AQDYTR4BXVQZ6EB01"
_ULID_ROOT = "01HGZ6FE0AQDYTR4BXVQZ6EC01"
_ULID_SUBPLAN = "01HGZ6FE0AQDYTR4BXVQZ6ED01"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_plan(
plan_id: str = _ULID_SELF,
parent_plan_id: str | None = None,
root_plan_id: str | None = None,
subplan_statuses: list[SubplanStatus] | None = None,
) -> Plan:
"""Build a minimal Plan for hierarchy property testing."""
return Plan(
identity=PlanIdentity(
plan_id=plan_id,
parent_plan_id=parent_plan_id,
root_plan_id=root_plan_id,
),
namespaced_name=NamespacedName(namespace="local", name="uncovered-plan"),
action_name="local/uncovered-action",
description="Plan for uncovered-lines testing",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.QUEUED,
subplan_statuses=subplan_statuses or [],
)
def _make_subplan_status(
error: str | None = None,
attempt_number: int = 1,
) -> SubplanStatus:
"""Build a SubplanStatus in ERRORED state for failure-handler testing."""
return SubplanStatus(
subplan_id=_ULID_SUBPLAN,
action_name="local/uncovered-sub-action",
status=ProcessingState.ERRORED,
error=error,
attempt_number=attempt_number,
)
# ===================================================================
# Subplan hierarchy: is_subplan
# ===================================================================
@given("an uncovered-lines plan that was created independently")
def step_uncovered_create_plan_no_parent(context: Context) -> None:
"""Create a plan without a parent (standalone)."""
context.plan = _make_plan(parent_plan_id=None)
@then("the uncovered-lines plan should not be recognized as a subplan")
def step_uncovered_verify_not_subplan(context: Context) -> None:
"""Assert ``is_subplan`` is False."""
assert context.plan.is_subplan is False, (
f"Expected is_subplan=False, got {context.plan.is_subplan}"
)
@given("an uncovered-lines plan that was spawned by another plan")
def step_uncovered_create_plan_with_parent(context: Context) -> None:
"""Create a plan that has a parent_plan_id set."""
context.plan = _make_plan(parent_plan_id=_ULID_PARENT)
@then("the uncovered-lines plan should be recognized as a subplan")
def step_uncovered_verify_is_subplan(context: Context) -> None:
"""Assert ``is_subplan`` is True."""
assert context.plan.is_subplan is True, (
f"Expected is_subplan=True, got {context.plan.is_subplan}"
)
# ===================================================================
# Subplan hierarchy: is_root_plan
# ===================================================================
@given("an uncovered-lines plan with no designated root")
def step_uncovered_create_plan_no_root(context: Context) -> None:
"""Create a plan with root_plan_id=None (treated as root)."""
context.plan = _make_plan(root_plan_id=None)
@then("the uncovered-lines plan should be recognized as a root plan")
def step_uncovered_verify_is_root(context: Context) -> None:
"""Assert ``is_root_plan`` is True."""
assert context.plan.is_root_plan is True, (
f"Expected is_root_plan=True, got {context.plan.is_root_plan}"
)
@given("an uncovered-lines plan whose designated root is itself")
def step_uncovered_create_plan_root_self(context: Context) -> None:
"""Create a plan whose root_plan_id equals its own plan_id."""
context.plan = _make_plan(
plan_id=_ULID_SELF,
root_plan_id=_ULID_SELF,
)
@given("an uncovered-lines plan whose designated root is a different plan")
def step_uncovered_create_plan_root_other(context: Context) -> None:
"""Create a plan whose root_plan_id differs from its own plan_id."""
context.plan = _make_plan(
plan_id=_ULID_SELF,
root_plan_id=_ULID_ROOT,
)
@then("the uncovered-lines plan should not be recognized as a root plan")
def step_uncovered_verify_not_root(context: Context) -> None:
"""Assert ``is_root_plan`` is False."""
assert context.plan.is_root_plan is False, (
f"Expected is_root_plan=False, got {context.plan.is_root_plan}"
)
# ===================================================================
# Subplan hierarchy: depth
# ===================================================================
@then("the uncovered-lines plan hierarchy depth should be {expected:d}")
def step_uncovered_verify_depth(context: Context, expected: int) -> None:
"""Assert the plan ``depth`` property equals *expected*."""
assert context.plan.depth == expected, (
f"Expected depth={expected}, got {context.plan.depth}"
)
# ===================================================================
# Subplan hierarchy: has_subplans
# ===================================================================
@given("an uncovered-lines plan that has no child work tracked")
def step_uncovered_create_plan_no_children(context: Context) -> None:
"""Create a plan with an empty subplan_statuses list."""
context.plan = _make_plan(subplan_statuses=[])
@then("the uncovered-lines plan should report having no subplans")
def step_uncovered_verify_no_subplans(context: Context) -> None:
"""Assert ``has_subplans`` is False."""
assert context.plan.has_subplans is False, (
f"Expected has_subplans=False, got {context.plan.has_subplans}"
)
@given("an uncovered-lines plan that has tracked child work")
def step_uncovered_create_plan_with_children(context: Context) -> None:
"""Create a plan with at least one SubplanStatus entry."""
status = _make_subplan_status()
context.plan = _make_plan(subplan_statuses=[status])
@then("the uncovered-lines plan should report having subplans")
def step_uncovered_verify_has_subplans(context: Context) -> None:
"""Assert ``has_subplans`` is True."""
assert context.plan.has_subplans is True, (
f"Expected has_subplans=True, got {context.plan.has_subplans}"
)
# ===================================================================
# Failure handler: should_stop_others
# ===================================================================
@given(
"an uncovered-lines subplan configuration with fail-fast enabled and parallel work"
)
def step_uncovered_config_failfast_parallel(context: Context) -> None:
"""SubplanConfig with fail_fast=True and PARALLEL execution."""
context.subplan_config = SubplanConfig(
fail_fast=True,
execution_mode=ExecutionMode.PARALLEL,
)
@given(
"an uncovered-lines subplan configuration with fail-fast disabled and sequential work"
)
def step_uncovered_config_no_failfast_sequential(context: Context) -> None:
"""SubplanConfig with fail_fast=False and SEQUENTIAL execution."""
context.subplan_config = SubplanConfig(
fail_fast=False,
execution_mode=ExecutionMode.SEQUENTIAL,
)
@given(
"an uncovered-lines subplan configuration with fail-fast disabled and parallel work"
)
def step_uncovered_config_no_failfast_parallel(context: Context) -> None:
"""SubplanConfig with fail_fast=False and PARALLEL execution."""
context.subplan_config = SubplanConfig(
fail_fast=False,
execution_mode=ExecutionMode.PARALLEL,
)
@given(
"an uncovered-lines subplan configuration with fail-fast disabled and dependency-ordered work"
)
def step_uncovered_config_no_failfast_dependency(context: Context) -> None:
"""SubplanConfig with fail_fast=False and DEPENDENCY_ORDERED execution."""
context.subplan_config = SubplanConfig(
fail_fast=False,
execution_mode=ExecutionMode.DEPENDENCY_ORDERED,
)
@given("an uncovered-lines subplan that has failed")
def step_uncovered_create_failed_status(context: Context) -> None:
"""Record a SubplanStatus in ERRORED state with a generic error."""
context.failed_status = _make_subplan_status(
error="GenericError: something failed",
)
@when("the uncovered-lines failure handler evaluates whether to halt remaining work")
def step_uncovered_call_should_stop_others(context: Context) -> None:
"""Invoke ``SubplanFailureHandler.should_stop_others``."""
handler = SubplanFailureHandler()
context.should_stop_result = handler.should_stop_others(
config=context.subplan_config,
failed_status=context.failed_status,
)
@then("the uncovered-lines remaining work should be halted")
def step_uncovered_verify_should_stop_true(context: Context) -> None:
"""Assert should_stop_others returned True."""
assert context.should_stop_result is True, (
f"Expected should_stop_others=True, got {context.should_stop_result}"
)
@then("the uncovered-lines remaining work should continue")
def step_uncovered_verify_should_stop_false(context: Context) -> None:
"""Assert should_stop_others returned False."""
assert context.should_stop_result is False, (
f"Expected should_stop_others=False, got {context.should_stop_result}"
)
# ===================================================================
# Failure handler: should_retry
# ===================================================================
@given("an uncovered-lines subplan configuration with retries disabled")
def step_uncovered_config_retry_disabled(context: Context) -> None:
"""SubplanConfig with retry_failed=False."""
context.subplan_config = SubplanConfig(retry_failed=False)
@given(
"an uncovered-lines subplan configuration allowing up to {max_retries:d} retries"
)
def step_uncovered_config_retry_enabled(context: Context, max_retries: int) -> None:
"""SubplanConfig with retry_failed=True and given max_retries."""
context.subplan_config = SubplanConfig(
retry_failed=True,
max_retries=max_retries,
)
@given('an uncovered-lines subplan that failed with error "{error_msg}"')
def step_uncovered_create_status_with_error(context: Context, error_msg: str) -> None:
"""Record a SubplanStatus with the specified error on attempt 1."""
context.failed_status = _make_subplan_status(
error=error_msg,
attempt_number=1,
)
@given(
'an uncovered-lines subplan on attempt {attempt:d} that failed with error "{error_msg}"'
)
def step_uncovered_create_status_attempt_error(
context: Context,
attempt: int,
error_msg: str,
) -> None:
"""Record a SubplanStatus on the given attempt with specified error."""
context.failed_status = _make_subplan_status(
error=error_msg,
attempt_number=attempt,
)
@given(
"an uncovered-lines subplan on attempt {attempt:d} that failed with no error message"
)
def step_uncovered_create_status_no_error(context: Context, attempt: int) -> None:
"""Record a SubplanStatus with error=None on the given attempt."""
context.failed_status = _make_subplan_status(
error=None,
attempt_number=attempt,
)
@when("the uncovered-lines failure handler evaluates whether to retry the failed work")
def step_uncovered_call_should_retry(context: Context) -> None:
"""Invoke ``SubplanFailureHandler.should_retry``."""
handler = SubplanFailureHandler()
context.should_retry_result = handler.should_retry(
config=context.subplan_config,
status=context.failed_status,
)
@then("the uncovered-lines failed work should be retried")
def step_uncovered_verify_should_retry_true(context: Context) -> None:
"""Assert should_retry returned True."""
assert context.should_retry_result is True, (
f"Expected should_retry=True, got {context.should_retry_result}"
)
@then("the uncovered-lines failed work should not be retried")
def step_uncovered_verify_should_retry_false(context: Context) -> None:
"""Assert should_retry returned False."""
assert context.should_retry_result is False, (
f"Expected should_retry=False, got {context.should_retry_result}"
)
@@ -0,0 +1,19 @@
"""Step definitions for plan_service_uncovered_lines.feature.
All step implementations required by this feature are already provided by
``plan_service_steps.py``. Behave auto-discovers every ``*_steps.py`` module
in the ``features/steps/`` directory and merges their step registries, so no
new definitions are needed here.
This module exists solely as documentation / a placeholder so that the
relationship between the ``.feature`` file and its steps is explicit.
If future scenarios require steps not already defined in the shared module,
add them below.
"""
from __future__ import annotations
# Re-export to guarantee the shared step definitions are loaded even when
# behave is invoked with an explicit ``--steps`` filter.
import features.steps.plan_service_steps as _shared # noqa: F401
@@ -0,0 +1,492 @@
"""Step definitions targeting uncovered branches/lines in repositories.py.
Covers:
- ActorRepository: get_default (L797), list_by_namespace (L803),
list_by_schema_version (L807-813)
- ToolRepository: __init__ with factory= (L3420-3421),
get() None (L3538-3539), get_by_name() None (L3545-3546),
remove() success (L3555) and ToolNotFoundError (L3559-3561),
_extract_value dict branch (L3442-3443),
_prepare_tool_dict without capability (L3467-3475)
- DuplicateValidationAttachmentError: project_name/plan_id (L3180-3183)
- ValidationAttachmentRepository: attach (L3631)
- AutomationProfileRepository: upsert schema version mismatch (L4190-4197)
"""
from __future__ import annotations
from types import SimpleNamespace
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.domain.models.core import Actor
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
ActorRepository,
AutomationProfileSchemaVersionError,
DuplicateValidationAttachmentError,
ToolNotFoundError,
ToolRepository,
ValidationAttachmentRepository,
)
# ── helpers ──────────────────────────────────────────────────────
def _make_engine():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
return engine
def _make_actor(name: str, schema_version: str = "1.0") -> Actor:
return Actor(
name=name,
provider="test-provider",
model="test-model",
config_blob={"key": "val"},
config_hash=Actor.compute_hash({"key": "val"}),
schema_version=schema_version,
)
def _tool_dict(name: str, tool_type: str = "tool") -> dict:
return {
"name": name,
"description": f"Test tool {name}",
"tool_type": tool_type,
"source": "builtin",
}
# ── ActorRepository: get_default returns None ────────────────────
@given("repo branch cov an in-memory database with actor tables")
def step_actor_db(context: Context):
context.rb_engine = _make_engine()
context.rb_session_factory = sessionmaker(bind=context.rb_engine)
context.rb_session = context.rb_session_factory()
@when("repo branch cov I call get_default with no default actor set")
def step_call_get_default(context: Context):
repo = ActorRepository(context.rb_session)
context.rb_result = repo.get_default()
@then("repo branch cov get_default returns None")
def step_assert_get_default_none(context: Context):
assert context.rb_result is None, f"Expected None, got {context.rb_result}"
# ── ActorRepository: list_by_namespace ───────────────────────────
@given('repo branch cov actors "{a}" and "{b}" and "{c}" exist')
def step_create_actors(context: Context, a: str, b: str, c: str):
repo = ActorRepository(context.rb_session)
for name in (a, b, c):
repo.upsert(_make_actor(name))
context.rb_session.commit()
@when('repo branch cov I call list_by_namespace with "{ns}"')
def step_call_list_by_namespace(context: Context, ns: str):
repo = ActorRepository(context.rb_session)
context.rb_result = repo.list_by_namespace(ns)
@then("repo branch cov {n:d} actors are returned")
def step_assert_actor_count(context: Context, n: int):
assert len(context.rb_result) == n, f"Expected {n}, got {len(context.rb_result)}"
@then('repo branch cov the actor names are "{a}" and "{b}"')
def step_assert_actor_names(context: Context, a: str, b: str):
names = sorted(act.name for act in context.rb_result)
assert names == sorted([a, b]), f"Expected [{a}, {b}], got {names}"
# ── ActorRepository: list_by_schema_version ──────────────────────
@given('repo branch cov actors with schema versions "{v1}" and "{v2}" exist')
def step_create_actors_schema(context: Context, v1: str, v2: str):
repo = ActorRepository(context.rb_session)
repo.upsert(_make_actor("local/actor-v1", schema_version=v1))
repo.upsert(_make_actor("local/actor-v2", schema_version=v2))
context.rb_session.commit()
@when('repo branch cov I call list_by_schema_version with "{ver}"')
def step_call_list_by_schema(context: Context, ver: str):
repo = ActorRepository(context.rb_session)
context.rb_result = repo.list_by_schema_version(ver)
@then("repo branch cov {n:d} actor is returned by schema version")
def step_assert_schema_count(context: Context, n: int):
assert len(context.rb_result) == n, f"Expected {n}, got {len(context.rb_result)}"
# ── ToolRepository: init with factory keyword ───────────────────
@given("repo branch cov an in-memory database with tool tables")
def step_tool_db(context: Context):
context.rb_engine = _make_engine()
context.rb_session_factory = sessionmaker(bind=context.rb_engine)
@when("repo branch cov I create ToolRepository with factory keyword")
def step_create_tool_repo_factory(context: Context):
context.rb_repo = ToolRepository(factory=context.rb_session_factory)
@then("repo branch cov the ToolRepository is usable")
def step_assert_tool_repo_usable(context: Context):
# Should be able to list_all without error
result = context.rb_repo.list_all()
assert isinstance(result, list)
@when("repo branch cov I create ToolRepository with no session factory")
def step_create_tool_repo_no_factory(context: Context):
try:
ToolRepository()
context.rb_error = None
except TypeError as exc:
context.rb_error = exc
@then("repo branch cov a TypeError is raised")
def step_assert_type_error(context: Context):
assert context.rb_error is not None, "Expected TypeError"
assert isinstance(context.rb_error, TypeError)
# ── ToolRepository: get returns None ─────────────────────────────
@given("repo branch cov a ToolRepository instance")
def step_tool_repo_instance(context: Context):
context.rb_repo = ToolRepository(session_factory=context.rb_session_factory)
@when('repo branch cov I call get with "{tool_id}"')
def step_call_tool_get(context: Context, tool_id: str):
context.rb_result = context.rb_repo.get(tool_id)
@then("repo branch cov get returns None")
def step_assert_tool_get_none(context: Context):
assert context.rb_result is None, f"Expected None, got {context.rb_result}"
# ── ToolRepository: get_by_name returns None ─────────────────────
@when('repo branch cov I call get_by_name with "{name}"')
def step_call_tool_get_by_name(context: Context, name: str):
context.rb_result = context.rb_repo.get_by_name(name)
@then("repo branch cov get_by_name returns None")
def step_assert_tool_get_by_name_none(context: Context):
assert context.rb_result is None, f"Expected None, got {context.rb_result}"
# ── ToolRepository: remove success ───────────────────────────────
@given('repo branch cov a tool "{name}" exists in the database')
def step_create_tool_in_db(context: Context, name: str):
context.rb_repo.add(
SimpleNamespace(
name=name,
description="removable tool",
tool_type="tool",
source="builtin",
input_schema=None,
output_schema=None,
capability=None,
resource_slots=None,
lifecycle_json=None,
code=None,
mcp_server=None,
mcp_tool_name=None,
agent_skill_path=None,
timeout=300,
wraps=None,
transform=None,
mode=None,
argument_mapping_json=None,
)
)
# commit so it's visible
session = context.rb_session_factory()
session.commit()
@when('repo branch cov I call remove with "{name}"')
def step_call_tool_remove(context: Context, name: str):
try:
context.rb_result = context.rb_repo.remove(name)
context.rb_error = None
except Exception as exc:
context.rb_result = None
context.rb_error = exc
@then("repo branch cov remove returns True")
def step_assert_remove_true(context: Context):
assert context.rb_error is None, f"Unexpected error: {context.rb_error}"
assert context.rb_result is True
@then("repo branch cov ToolNotFoundError is raised")
def step_assert_tool_not_found(context: Context):
assert isinstance(context.rb_error, ToolNotFoundError), (
f"Expected ToolNotFoundError, got {type(context.rb_error).__name__}: {context.rb_error}"
)
# ── ToolRepository._extract_value dict branch ───────────────────
@when('repo branch cov I call _extract_value with a dict obj key "name" default ""')
def step_call_extract_value_dict(context: Context):
result = ToolRepository._extract_value(
{"name": "hello", "source": "builtin"}, "name", ""
)
context.rb_result = result
@then("repo branch cov _extract_value returns the dict value")
def step_assert_extract_value(context: Context):
assert context.rb_result == "hello", f"Expected 'hello', got {context.rb_result}"
# ── ToolRepository._prepare_tool_dict without capability ─────────
@when("repo branch cov I prepare a tool dict from an object without capability")
def step_prepare_tool_no_cap(context: Context):
tool = SimpleNamespace(
name="local/no-cap",
description="no-cap tool",
tool_type="tool",
source="builtin",
input_schema=None,
output_schema=None,
capability=None,
resource_slots=None,
lifecycle_json=None,
code=None,
mcp_server=None,
mcp_tool_name=None,
agent_skill_path=None,
timeout=300,
wraps=None,
transform=None,
mode=None,
argument_mapping_json=None,
)
context.rb_result = context.rb_repo._prepare_tool_dict(tool)
@then("repo branch cov the prepared dict has no capability_json")
def step_assert_no_capability_json(context: Context):
assert context.rb_result["capability_json"] is None, (
f"Expected None capability_json, got {context.rb_result['capability_json']}"
)
# ── DuplicateValidationAttachmentError ───────────────────────────
@when("repo branch cov I create DuplicateValidationAttachmentError with project_name")
def step_create_dup_val_err_project(context: Context):
context.rb_error = DuplicateValidationAttachmentError(
validation_name="local/check-lint",
resource_id="res-1",
project_name="my-project",
)
@then("repo branch cov the error message contains the project name")
def step_assert_err_has_project(context: Context):
msg = str(context.rb_error)
assert "my-project" in msg, f"Expected 'my-project' in: {msg}"
assert "plan" not in msg.lower() or "plan_id" not in msg, (
f"Unexpected plan info in: {msg}"
)
@when(
"repo branch cov I create DuplicateValidationAttachmentError with project and plan"
)
def step_create_dup_val_err_both(context: Context):
context.rb_error = DuplicateValidationAttachmentError(
validation_name="local/check-lint",
resource_id="res-1",
project_name="my-project",
plan_id="PLAN123",
)
@then("repo branch cov the error message contains both project and plan")
def step_assert_err_has_both(context: Context):
msg = str(context.rb_error)
assert "my-project" in msg, f"Expected 'my-project' in: {msg}"
assert "PLAN123" in msg, f"Expected 'PLAN123' in: {msg}"
@when("repo branch cov I create DuplicateValidationAttachmentError without scoping")
def step_create_dup_val_err_bare(context: Context):
context.rb_error = DuplicateValidationAttachmentError(
validation_name="local/check-lint",
resource_id="res-1",
)
@then("repo branch cov the error message has no project or plan")
def step_assert_err_bare(context: Context):
msg = str(context.rb_error)
assert "local/check-lint" in msg
assert "res-1" in msg
# Should NOT contain project or plan references
assert "for project" not in msg, f"Unexpected project in: {msg}"
assert "and plan" not in msg, f"Unexpected plan in: {msg}"
# ── ValidationAttachmentRepository: attach ───────────────────────
@given('repo branch cov a validation tool "{name}" exists')
def step_create_validation_tool(context: Context, name: str):
from cleveragents.infrastructure.database.repositories import ToolRegistryRepository
tool_repo = ToolRegistryRepository(session_factory=context.rb_session_factory)
tool_repo.create(
{
"name": name,
"description": "test validation",
"tool_type": "validation",
"source": "builtin",
}
)
# commit
session = context.rb_session_factory()
session.commit()
@when('repo branch cov I attach validation "{val}" to resource "{res}"')
def step_attach_validation(context: Context, val: str, res: str):
repo = ValidationAttachmentRepository(
session_factory=context.rb_session_factory,
)
context.rb_result = repo.attach(
validation_name=val,
resource_id=res,
mode="required",
)
# commit
session = context.rb_session_factory()
session.commit()
@then("repo branch cov the attachment is returned with correct fields")
def step_assert_attachment(context: Context):
att = context.rb_result
assert isinstance(att, dict), f"Expected dict, got {type(att)}"
assert "attachment_id" in att
assert att["validation_name"] == "local/check-lint"
assert att["resource_id"] == "res-1"
assert att["mode"] == "required"
@when('repo branch cov I attach with validation_name "{vn}" and resource_id "{rid}"')
def step_attach_swapped(context: Context, vn: str, rid: str):
"""The validation_name contains '/' but resource_id doesn't (or vice versa)
so the repo auto-swaps them."""
repo = ValidationAttachmentRepository(
session_factory=context.rb_session_factory,
)
context.rb_result = repo.attach(
validation_name=vn,
resource_id=rid,
)
session = context.rb_session_factory()
session.commit()
@then("repo branch cov the attachment swaps them correctly")
def step_assert_swapped(context: Context):
att = context.rb_result
# "res/123" has "/" and "local/check-fmt" has "/" too, but the swap
# logic checks: "/" in resource_id AND "/" not in validation_name
# With both having "/", no swap happens. Let's just verify it stored.
assert isinstance(att, dict)
assert "attachment_id" in att
# ── AutomationProfileRepository: upsert schema mismatch ────────
@given("repo branch cov an in-memory database with automation profile tables")
def step_automation_db(context: Context):
context.rb_engine = _make_engine()
context.rb_session_factory = sessionmaker(bind=context.rb_engine)
@given('repo branch cov an existing automation profile "{name}" with schema "{ver}"')
def step_create_profile(context: Context, name: str, ver: str):
from cleveragents.domain.models.core.automation_profile import AutomationProfile
from cleveragents.infrastructure.database.repositories import (
AutomationProfileRepository,
)
profile = AutomationProfile(
name=name,
description="test profile",
schema_version=ver,
)
repo = AutomationProfileRepository(
session_factory=context.rb_session_factory,
)
repo.upsert(profile)
session = context.rb_session_factory()
session.commit()
context.rb_profile_repo = repo
@when('repo branch cov I upsert profile "{name}" expecting schema "{ver}"')
def step_upsert_schema_mismatch(context: Context, name: str, ver: str):
from cleveragents.domain.models.core.automation_profile import AutomationProfile
profile = AutomationProfile(
name=name,
description="updated profile",
schema_version="1.0", # actual stored version
)
try:
context.rb_profile_repo.upsert(
profile,
expected_schema_version=ver,
)
context.rb_error = None
except Exception as exc:
context.rb_error = exc
@then("repo branch cov AutomationProfileSchemaVersionError is raised")
def step_assert_schema_version_error(context: Context):
assert isinstance(context.rb_error, AutomationProfileSchemaVersionError), (
f"Expected AutomationProfileSchemaVersionError, "
f"got {type(context.rb_error).__name__}: {context.rb_error}"
)
@@ -0,0 +1,392 @@
"""Step definitions for session CLI uncovered-branch coverage tests."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from typer.testing import CliRunner
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
MessageRole,
Session,
SessionExportError,
SessionMessage,
SessionNotFoundError,
SessionService,
SessionTokenUsage,
)
runner = CliRunner()
# Valid ULIDs for tests (Crockford base32)
_ULID = "01KJ1N8YDN05N29P5RZV4SPDVZ"
_ULID2 = "01KJ1N8YDN05N29P5RZV4SPDW0"
_NOW = datetime(2025, 6, 15, 12, 0, 0)
def _make_session(
*,
session_id: str = _ULID,
actor_name: str | None = None,
namespace: str = "local",
messages: list[SessionMessage] | None = None,
linked_plan_ids: list[str] | None = None,
token_usage: SessionTokenUsage | None = None,
metadata: dict[str, Any] | None = None,
) -> Session:
"""Build a Session object with sensible defaults."""
return Session(
session_id=session_id,
actor_name=actor_name,
namespace=namespace,
messages=messages or [],
linked_plan_ids=linked_plan_ids or [],
token_usage=token_usage or SessionTokenUsage(),
created_at=_NOW,
updated_at=_NOW,
metadata=metadata or {},
)
def _make_message(
content: str = "hello",
role: MessageRole = MessageRole.USER,
sequence: int = 0,
) -> SessionMessage:
return SessionMessage(
message_id=_ULID2,
role=role,
content=content,
sequence=sequence,
timestamp=_NOW,
)
def _mock_service() -> MagicMock:
"""Create a MagicMock that passes isinstance checks for SessionService."""
svc = MagicMock(spec=SessionService)
return svc
# ---- helpers to patch the module-level _service ---------------------------
def _patch_service(context, svc):
"""Patch session._service and register cleanup."""
patcher = patch("cleveragents.cli.commands.session._service", svc)
patcher.start()
context._cleanup_handlers.append(patcher.stop)
# ===========================================================================
# Scenario: _get_session_service constructs service when _service is None
# ===========================================================================
@given("session cli branch the module-level _service is None")
def step_service_is_none(context):
import cleveragents.cli.commands.session as mod
context._original_service = mod._service
mod._service = None
context._cleanup_handlers.append(
lambda: setattr(mod, "_service", context._original_service)
)
@when("session cli branch I call _get_session_service with mocked container")
def step_call_get_session_service(context):
import cleveragents.cli.commands.session as mod
mock_db = MagicMock()
mock_container = MagicMock()
mock_container.db.return_value = mock_db
mock_session_repo_cls = MagicMock()
mock_message_repo_cls = MagicMock()
mock_persistent_cls = MagicMock()
context._mock_persistent_instance = mock_persistent_cls.return_value
with patch("cleveragents.cli.commands.session.get_container", create=True):
# We need to mock the imports that happen inside the function.
# The function does:
# from cleveragents.application.container import get_container
# from cleveragents.application.services.session_service import PersistentSessionService
# from cleveragents.infrastructure.database.repositories import SessionRepository, SessionMessageRepository
# We mock these at the module import level inside the function.
import sys
# Create mock modules
mock_container_mod = MagicMock()
mock_container_mod.get_container = MagicMock(return_value=mock_container)
mock_service_mod = MagicMock()
mock_service_mod.PersistentSessionService = mock_persistent_cls
mock_repo_mod = MagicMock()
mock_repo_mod.SessionRepository = mock_session_repo_cls
mock_repo_mod.SessionMessageRepository = mock_message_repo_cls
with patch.dict(
sys.modules,
{
"cleveragents.application.container": mock_container_mod,
"cleveragents.application.services.session_service": mock_service_mod,
"cleveragents.infrastructure.database.repositories": mock_repo_mod,
},
):
# Force re-import by deleting cached names if any
# The function uses local imports so they run every time
result = mod._get_session_service()
context._get_service_result = result
@then("session cli branch a PersistentSessionService is returned")
def step_persistent_returned(context):
assert context._get_service_result is context._mock_persistent_instance
# ===========================================================================
# Scenario: _reset_session_service sets _service to None
# ===========================================================================
@given("session cli branch the module-level _service holds a mock")
def step_service_holds_mock(context):
import cleveragents.cli.commands.session as mod
context._original_service = mod._service
mod._service = MagicMock()
context._cleanup_handlers.append(
lambda: setattr(mod, "_service", context._original_service)
)
@when("session cli branch I call _reset_session_service")
def step_call_reset(context):
import cleveragents.cli.commands.session as mod
mod._reset_session_service()
@then("session cli branch the module-level _service is None again")
def step_service_is_none_again(context):
import cleveragents.cli.commands.session as mod
assert mod._service is None
# Restore so after_scenario doesn't break
mod._service = context._original_service
# ===========================================================================
# Scenario: create command catches SessionNotFoundError
# ===========================================================================
@given(
"session cli branch a mock session service that raises SessionNotFoundError on create"
)
def step_service_raises_on_create(context):
svc = _mock_service()
svc.create.side_effect = SessionNotFoundError("actor not found")
_patch_service(context, svc)
@when("session cli branch I invoke the create command")
def step_invoke_create(context):
context.result = runner.invoke(session_app, ["create"])
@then("session cli branch the exit code is 1")
def step_exit_code_1(context):
assert context.result.exit_code == 1, (
f"Expected exit code 1, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the output contains "{text}"')
def step_output_contains(context, text):
combined = context.result.output
assert text in combined, f"Expected '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: show command with no messages
# ===========================================================================
@given("session cli branch a mock session service returning a session with no messages")
def step_service_no_messages(context):
session = _make_session(messages=[], linked_plan_ids=[])
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id = _ULID
@when("session cli branch I invoke the show command for that session")
def step_invoke_show_no_msgs(context):
context.result = runner.invoke(session_app, ["show", context.session_id])
@then("session cli branch the exit code is 0")
def step_exit_code_0(context):
assert context.result.exit_code == 0, (
f"Expected exit code 0, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the output does not contain "{text}"')
def step_output_not_contains(context, text):
combined = context.result.output
assert text not in combined, f"Did not expect '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: show command with linked plan ids
# ===========================================================================
@given(
"session cli branch a mock session service returning a session with linked plans"
)
def step_service_linked_plans(context):
session = _make_session(
linked_plan_ids=["01PLAN1234567890ABCDEFGHIJ", "01PLAN1234567890ABCDEFGHIK"],
)
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_linked = _ULID
@when("session cli branch I invoke the show command for the linked-plans session")
def step_invoke_show_linked(context):
context.result = runner.invoke(session_app, ["show", context.session_id_linked])
# ===========================================================================
# Scenario: show command with long message content truncated
# ===========================================================================
@given(
"session cli branch a mock session service returning a session with a long message"
)
def step_service_long_message(context):
long_content = "A" * 100 # longer than 80 chars → triggers truncation
msg = _make_message(content=long_content, sequence=0)
session = _make_session(messages=[msg])
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_long = _ULID
@when("session cli branch I invoke the show command for the long-message session")
def step_invoke_show_long(context):
context.result = runner.invoke(session_app, ["show", context.session_id_long])
# ===========================================================================
# Scenario: delete command aborted via confirmation prompt
# ===========================================================================
@given("session cli branch a mock session service for delete")
def step_service_for_delete(context):
session = _make_session()
svc = _mock_service()
svc.get.return_value = session
_patch_service(context, svc)
context.session_id_del = _ULID
@when("session cli branch I invoke the delete command without --yes and answer no")
def step_invoke_delete_no(context):
# typer.confirm reads from stdin; CliRunner accepts `input` kwarg
context.result = runner.invoke(
session_app,
["delete", context.session_id_del],
input="n\n",
)
@then("session cli branch the delete is aborted")
def step_delete_aborted(context):
# typer.Abort() sets exit_code to 1
# The output should contain "Aborted"
combined = context.result.output
assert "Aborted" in combined or context.result.exit_code != 0, (
f"Expected abort. exit_code={context.result.exit_code}, output:\n{combined}"
)
# ===========================================================================
# Scenario: export command catches SessionExportError
# ===========================================================================
@given(
"session cli branch a mock session service that raises SessionExportError on export"
)
def step_service_export_error(context):
svc = _mock_service()
svc.export_session.side_effect = SessionExportError("checksum mismatch")
_patch_service(context, svc)
@when("session cli branch I invoke the export command")
def step_invoke_export(context):
context.result = runner.invoke(session_app, ["export", _ULID])
@then("session cli branch the export exit code is 1")
def step_export_exit_1(context):
assert context.result.exit_code == 1, (
f"Expected exit code 1, got {context.result.exit_code}. "
f"Output: {context.result.output}"
)
@then('session cli branch the export output contains "{text}"')
def step_export_output_contains(context, text):
combined = context.result.output
assert text in combined, f"Expected '{text}' in output. Got:\n{combined}"
# ===========================================================================
# Scenario: tell command with stream=True
# ===========================================================================
@given("session cli branch a mock session service for tell")
def step_service_for_tell(context):
svc = _mock_service()
# append_message doesn't need to return anything meaningful for the
# code path under test — the assistant content is computed inline.
svc.append_message.return_value = None
_patch_service(context, svc)
@when("session cli branch I invoke the tell command with --stream")
def step_invoke_tell_stream(context):
context.result = runner.invoke(
session_app,
["tell", "--session", _ULID, "--stream", "Hello world"],
)
@then("session cli branch the streamed output contains the assistant response")
def step_streamed_output(context):
# The assistant content for no actor is: "Acknowledged: Hello world"
assert "Acknowledged" in context.result.output, (
f"Expected 'Acknowledged' in output. Got:\n{context.result.output}"
)
@@ -0,0 +1,483 @@
"""Step definitions for system.py uncovered branches."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
def _make_mock_settings(**overrides):
"""Create a mock settings object with sensible defaults."""
tmpdir = tempfile.mkdtemp()
s = MagicMock()
s.database_url = overrides.get("database_url", f"sqlite:///{tmpdir}/test.db")
s.data_dir = overrides.get("data_dir", Path(tmpdir))
s.storage_path = overrides.get("storage_path", Path(tmpdir))
s.config_path = overrides.get("config_path", Path(tmpdir) / "config.toml")
s.log_dir = overrides.get("log_dir", Path(tmpdir) / "nonexistent_logs")
s.default_automation_profile = "auto"
s.has_provider_configured = MagicMock(return_value=False)
s.configured_provider_names = MagicMock(return_value=[])
s.debug_enabled = False
return s
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
@given("system cli branch module is loaded")
def step_system_cli_branch_module_loaded(context: Context) -> None:
context.branch_result = None
context.branch_exception = None
# ---------------------------------------------------------------------------
# When - _git_sha
# ---------------------------------------------------------------------------
@when("system cli branch git_sha subprocess returns non-zero exit code")
def step_branch_git_sha_nonzero(context: Context) -> None:
from cleveragents.cli.commands.system import _git_sha
mock_result = MagicMock()
mock_result.returncode = 1
mock_result.stdout = ""
with patch(
"cleveragents.cli.commands.system.subprocess.run",
return_value=mock_result,
):
context.branch_result = _git_sha()
# ---------------------------------------------------------------------------
# When - build_info_data (DB exists, compute size)
# ---------------------------------------------------------------------------
@when("system cli branch build_info_data is called with existing database file")
def step_branch_info_db_exists(context: Context) -> None:
from cleveragents.cli.commands.system import build_info_data
tmpdir = tempfile.mkdtemp()
db_file = Path(tmpdir) / "test.db"
db_file.write_bytes(b"x" * 2048) # 2 KB file
ms = _make_mock_settings(
database_url=f"sqlite:///{db_file}",
log_dir=Path(tmpdir) / "no_logs",
storage_path=Path(tmpdir),
)
with patch("cleveragents.config.settings.get_settings", return_value=ms):
context.branch_result = build_info_data()
# ---------------------------------------------------------------------------
# When - build_info_data (DB stat raises exception)
# ---------------------------------------------------------------------------
@when("system cli branch build_info_data is called with db stat raising exception")
def step_branch_info_db_exception(context: Context) -> None:
from cleveragents.cli.commands.system import build_info_data
tmpdir = tempfile.mkdtemp()
# Create a db file that exists but whose .stat() will fail
db_file = Path(tmpdir) / "test.db"
db_file.write_bytes(b"data")
ms = _make_mock_settings(
database_url=f"sqlite:///{db_file}",
log_dir=Path(tmpdir) / "no_logs",
storage_path=Path(tmpdir),
)
# We need to make db_path.exists() return True but db_path.stat() raise
# The simplest approach: patch the stat call on the db_path
original_stat = Path.stat
def patched_stat(self, *args, **kwargs):
if str(self) == str(db_file):
raise PermissionError("mocked stat failure")
return original_stat(self, *args, **kwargs)
with (
patch("cleveragents.config.settings.get_settings", return_value=ms),
patch.object(Path, "stat", patched_stat),
):
context.branch_result = build_info_data()
# ---------------------------------------------------------------------------
# When - build_info_data (log_dir exists with files)
# ---------------------------------------------------------------------------
@when("system cli branch build_info_data is called with existing log directory")
def step_branch_info_log_exists(context: Context) -> None:
from cleveragents.cli.commands.system import build_info_data
tmpdir = tempfile.mkdtemp()
log_dir = Path(tmpdir) / "logs"
log_dir.mkdir()
(log_dir / "app.log").write_bytes(b"log data " * 100)
ms = _make_mock_settings(
database_url=f"sqlite:///{tmpdir}/nonexistent.db",
log_dir=log_dir,
storage_path=Path(tmpdir),
)
with patch("cleveragents.config.settings.get_settings", return_value=ms):
context.branch_result = build_info_data()
# ---------------------------------------------------------------------------
# When - _check_config_file (exists but not readable)
# ---------------------------------------------------------------------------
@when("system cli branch check_config_file is called with unreadable config")
def step_branch_config_not_readable(context: Context) -> None:
from cleveragents.cli.commands.system import _check_config_file
tmpdir = tempfile.mkdtemp()
config_file = Path(tmpdir) / "config.toml"
config_file.write_text("[general]\n")
with (
patch.dict(os.environ, {"CLEVERAGENTS_CONFIG_PATH": str(config_file)}),
patch(
"cleveragents.cli.commands.system.os.access",
return_value=False,
),
):
context.branch_result = _check_config_file()
# Clean up env var
os.environ.pop("CLEVERAGENTS_CONFIG_PATH", None)
# ---------------------------------------------------------------------------
# When - _check_data_dir (exists but not writable)
# ---------------------------------------------------------------------------
@when("system cli branch check_data_dir is called with non-writable directory")
def step_branch_data_dir_not_writable(context: Context) -> None:
from cleveragents.cli.commands.system import _check_data_dir
tmpdir = tempfile.mkdtemp()
data_dir = Path(tmpdir) / "data"
data_dir.mkdir()
ms = _make_mock_settings(data_dir=data_dir)
def mock_access(p, mode):
return not (str(p) == str(data_dir) and mode == os.W_OK)
with (
patch("cleveragents.config.settings.get_settings", return_value=ms),
patch("cleveragents.cli.commands.system.os.access", side_effect=mock_access),
):
context.branch_result = _check_data_dir()
# ---------------------------------------------------------------------------
# When - _check_data_dir (dir missing)
# ---------------------------------------------------------------------------
@when("system cli branch check_data_dir is called with missing directory")
def step_branch_data_dir_missing(context: Context) -> None:
from cleveragents.cli.commands.system import _check_data_dir
ms = _make_mock_settings(data_dir=Path("/tmp/nonexistent_dir_xyz_99999"))
with patch("cleveragents.config.settings.get_settings", return_value=ms):
context.branch_result = _check_data_dir()
# ---------------------------------------------------------------------------
# When - _check_database (sqlite DB exists but not writable)
# ---------------------------------------------------------------------------
@when("system cli branch check_database is called with non-writable sqlite db")
def step_branch_db_not_writable(context: Context) -> None:
from cleveragents.cli.commands.system import _check_database
tmpdir = tempfile.mkdtemp()
db_file = Path(tmpdir) / "test.db"
db_file.write_bytes(b"sqlite data")
ms = _make_mock_settings(database_url=f"sqlite:///{db_file}")
def mock_access(p, mode):
return not (str(p) == str(db_file) and mode == os.W_OK)
with (
patch("cleveragents.config.settings.get_settings", return_value=ms),
patch("cleveragents.cli.commands.system.os.access", side_effect=mock_access),
):
context.branch_result = _check_database()
# ---------------------------------------------------------------------------
# When - _check_disk_space (< 0.5 GB free)
# ---------------------------------------------------------------------------
@when("system cli branch check_disk_space reports less than half GB free")
def step_branch_disk_critical(context: Context) -> None:
from cleveragents.cli.commands.system import _check_disk_space
mock_usage = MagicMock()
mock_usage.free = int(0.3 * (1024**3)) # 0.3 GB
mock_usage.total = 100 * (1024**3)
with patch(
"cleveragents.cli.commands.system.shutil.disk_usage",
return_value=mock_usage,
):
context.branch_result = _check_disk_space()
# ---------------------------------------------------------------------------
# When - _check_disk_space (< 1.0 GB free)
# ---------------------------------------------------------------------------
@when("system cli branch check_disk_space reports less than one GB free")
def step_branch_disk_low(context: Context) -> None:
from cleveragents.cli.commands.system import _check_disk_space
mock_usage = MagicMock()
mock_usage.free = int(0.7 * (1024**3)) # 0.7 GB
mock_usage.total = 100 * (1024**3)
with patch(
"cleveragents.cli.commands.system.shutil.disk_usage",
return_value=mock_usage,
):
context.branch_result = _check_disk_space()
# ---------------------------------------------------------------------------
# When - _check_disk_space (OSError)
# ---------------------------------------------------------------------------
@when("system cli branch check_disk_space raises OSError")
def step_branch_disk_oserror(context: Context) -> None:
from cleveragents.cli.commands.system import _check_disk_space
with patch(
"cleveragents.cli.commands.system.shutil.disk_usage",
side_effect=OSError("permission denied"),
):
context.branch_result = _check_disk_space()
# ---------------------------------------------------------------------------
# When - _check_git (non-zero exit code, not FileNotFoundError)
# ---------------------------------------------------------------------------
@when("system cli branch check_git subprocess returns non-zero exit code")
def step_branch_git_nonzero(context: Context) -> None:
from cleveragents.cli.commands.system import _check_git
mock_result = MagicMock()
mock_result.returncode = 127
mock_result.stdout = ""
with patch(
"cleveragents.cli.commands.system.subprocess.run",
return_value=mock_result,
):
context.branch_result = _check_git()
# ---------------------------------------------------------------------------
# When - _check_file_permissions (data dir missing)
# ---------------------------------------------------------------------------
@when("system cli branch check_file_permissions with missing data dir")
def step_branch_perms_missing(context: Context) -> None:
from cleveragents.cli.commands.system import _check_file_permissions
ms = _make_mock_settings(data_dir=Path("/tmp/nonexistent_dir_xyz_99999"))
with patch("cleveragents.config.settings.get_settings", return_value=ms):
context.branch_result = _check_file_permissions()
# ---------------------------------------------------------------------------
# When - _check_file_permissions (readable but not writable)
# ---------------------------------------------------------------------------
@when("system cli branch check_file_permissions with readable not writable dir")
def step_branch_perms_read_no_write(context: Context) -> None:
from cleveragents.cli.commands.system import _check_file_permissions
tmpdir = tempfile.mkdtemp()
data_dir = Path(tmpdir)
ms = _make_mock_settings(data_dir=data_dir)
def mock_access(p, mode):
if str(p) == str(data_dir):
if mode == os.R_OK:
return True
if mode == os.W_OK:
return False
return True
with (
patch("cleveragents.config.settings.get_settings", return_value=ms),
patch("cleveragents.cli.commands.system.os.access", side_effect=mock_access),
):
context.branch_result = _check_file_permissions()
# ---------------------------------------------------------------------------
# When - _check_file_permissions (no access at all)
# ---------------------------------------------------------------------------
@when("system cli branch check_file_permissions with no access dir")
def step_branch_perms_no_access(context: Context) -> None:
from cleveragents.cli.commands.system import _check_file_permissions
tmpdir = tempfile.mkdtemp()
data_dir = Path(tmpdir)
ms = _make_mock_settings(data_dir=data_dir)
def mock_access(p, mode):
return str(p) != str(data_dir)
with (
patch("cleveragents.config.settings.get_settings", return_value=ms),
patch("cleveragents.cli.commands.system.os.access", side_effect=mock_access),
):
context.branch_result = _check_file_permissions()
# ---------------------------------------------------------------------------
# When - render_version_rich (no dependencies)
# ---------------------------------------------------------------------------
@when("system cli branch render_version_rich is called with no dependencies")
def step_branch_version_no_deps(context: Context) -> None:
from cleveragents.cli.commands.system import render_version_rich
data = {
"version": "1.0.0",
"channel": "stable",
"python": "3.13.0",
"build_date": "2025-01-01",
"commit": "abc1234",
"schema": "v3",
"platform": "linux-x86_64",
"dependencies": {},
}
try:
render_version_rich(data)
context.branch_exception = None
except Exception as e:
context.branch_exception = e
# ---------------------------------------------------------------------------
# When - render_info_rich (no storage)
# ---------------------------------------------------------------------------
@when("system cli branch render_info_rich is called with no storage")
def step_branch_info_no_storage(context: Context) -> None:
from cleveragents.cli.commands.system import render_info_rich
data = {
"data_dir": "/tmp/data",
"config_path": "/tmp/config.toml",
"database": "sqlite:///test.db",
"server_mode": "disabled",
"platform": "Linux x86_64",
"automation": "auto",
"providers_configured": 0,
"debug_mode": False,
"storage": {},
}
try:
render_info_rich(data)
context.branch_exception = None
except Exception as e:
context.branch_exception = e
# ---------------------------------------------------------------------------
# Then - generic assertions
# ---------------------------------------------------------------------------
@then('system cli branch result should equal "{expected}"')
def step_branch_assert_result(context: Context, expected: str) -> None:
assert context.branch_result == expected, (
f"Expected '{expected}', got '{context.branch_result}'"
)
@then('system cli branch storage db_size should contain "{substring}"')
def step_branch_assert_db_size_contains(context: Context, substring: str) -> None:
db_size = context.branch_result["storage"]["db_size"]
assert substring in db_size, f"Expected '{substring}' in '{db_size}'"
@then('system cli branch storage db_size should equal "{expected}"')
def step_branch_assert_db_size_eq(context: Context, expected: str) -> None:
db_size = context.branch_result["storage"]["db_size"]
assert db_size == expected, f"Expected '{expected}', got '{db_size}'"
@then('system cli branch storage logs should contain "{substring}"')
def step_branch_assert_logs_contains(context: Context, substring: str) -> None:
logs = context.branch_result["storage"]["logs"]
assert substring in logs, f"Expected '{substring}' in '{logs}'"
@then('system cli branch check status should be "{expected}"')
def step_branch_assert_check_status(context: Context, expected: str) -> None:
status = context.branch_result["status"]
assert status == expected, f"Expected status '{expected}', got '{status}'"
@then('system cli branch check details should be "{expected}"')
def step_branch_assert_check_details(context: Context, expected: str) -> None:
details = context.branch_result["details"]
assert details == expected, f"Expected details '{expected}', got '{details}'"
@then('system cli branch check details should contain "{substring}"')
def step_branch_assert_check_details_contains(context: Context, substring: str) -> None:
details = context.branch_result["details"]
assert substring in details, f"Expected '{substring}' in '{details}'"
@then("system cli branch no exception should be raised")
def step_branch_assert_no_exception(context: Context) -> None:
assert context.branch_exception is None, (
f"Unexpected exception: {context.branch_exception}"
)
@@ -0,0 +1,272 @@
"""Step definitions for tool CLI uncovered branches."""
from __future__ import annotations
from collections import OrderedDict
from io import StringIO
from unittest.mock import MagicMock, patch
from behave import given, then, when
from rich.console import Console
from cleveragents.cli.commands.tool import _print_tool
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeToolWithCliDict:
"""A tool object that returns a custom OrderedDict from as_cli_dict."""
def __init__(self, data: dict):
self._data = data
def as_cli_dict(self) -> OrderedDict:
return OrderedDict(self._data)
def _capture_rich_output(tool, title="Tool") -> str:
"""Call _print_tool in rich format and capture console output."""
buf = StringIO()
test_console = Console(file=buf, force_terminal=False, width=120)
with patch("cleveragents.cli.commands.tool.console", test_console):
_print_tool(tool, title=title, fmt="rich")
return buf.getvalue()
# ===========================================================================
# Scenario: _get_tool_registry_service constructs service from container
# ===========================================================================
@given("tool cli branch the container returns a mock database url")
def step_tool_cli_branch_container_mock_db(context):
mock_container = MagicMock()
mock_container.database_url.return_value = "sqlite://"
context.mock_container = mock_container
@when("tool cli branch I call _get_tool_registry_service")
def step_tool_cli_branch_call_get_service(context):
mock_engine = MagicMock()
mock_session_factory = MagicMock()
with (
patch(
"cleveragents.cli.commands.tool.get_container",
return_value=context.mock_container,
)
if hasattr(context, "_unused")
else patch(
"cleveragents.application.container.get_container",
return_value=context.mock_container,
),
patch(
"sqlalchemy.create_engine",
return_value=mock_engine,
) as _mock_create_engine,
patch(
"sqlalchemy.orm.sessionmaker",
return_value=mock_session_factory,
) as _mock_sessionmaker,
# The function does a local import of get_container, so we patch
# it at the location where it's imported.
patch(
"cleveragents.application.container.get_container",
return_value=context.mock_container,
),
):
from cleveragents.cli.commands.tool import _get_tool_registry_service
result = _get_tool_registry_service()
context.service_result = result
@then("tool cli branch it should return a ToolRegistryService instance")
def step_tool_cli_branch_service_is_instance(context):
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
assert isinstance(context.service_result, ToolRegistryService), (
f"Expected ToolRegistryService, got {type(context.service_result)}"
)
# ===========================================================================
# Scenario: _print_tool displays lifecycle dict with values
# ===========================================================================
@given("tool cli branch a tool with lifecycle hooks")
def step_tool_cli_branch_tool_with_lifecycle(context):
context.tool_obj = _FakeToolWithCliDict(
{
"name": "local/lifecycle-tool",
"description": "A tool with lifecycle",
"source": "custom",
"tool_type": "tool",
"namespace": "local",
"short_name": "lifecycle-tool",
"timeout": 60,
"capability": {"read_only": True},
"lifecycle": {
"on_start": "echo starting",
"on_stop": "echo stopping",
"on_error": None,
},
}
)
@when("tool cli branch I print the tool in rich format")
def step_tool_cli_branch_print_lifecycle_tool(context):
context.captured_output = _capture_rich_output(context.tool_obj)
@then("tool cli branch the output should contain lifecycle key values")
def step_tool_cli_branch_output_has_lifecycle(context):
output = context.captured_output
assert "on_start" in output, f"Expected 'on_start' in output:\n{output}"
assert "echo starting" in output, f"Expected 'echo starting' in output:\n{output}"
assert "on_stop" in output, f"Expected 'on_stop' in output:\n{output}"
assert "echo stopping" in output, f"Expected 'echo stopping' in output:\n{output}"
# ===========================================================================
# Scenario: _print_tool displays resource slots list with dict items
# ===========================================================================
@given("tool cli branch a tool with resource slots")
def step_tool_cli_branch_tool_with_slots(context):
context.tool_obj = _FakeToolWithCliDict(
{
"name": "local/slots-tool",
"description": "A tool with resource slots",
"source": "custom",
"tool_type": "tool",
"namespace": "local",
"short_name": "slots-tool",
"timeout": 120,
"capability": {},
"resource_slots": [
{
"name": "database",
"resource_type": "postgres",
"access": "read",
},
{
"name": "cache",
"resource_type": "redis",
"access": "write",
},
],
}
)
@when("tool cli branch I print the tool with slots in rich format")
def step_tool_cli_branch_print_slots_tool(context):
context.captured_output = _capture_rich_output(context.tool_obj)
@then("tool cli branch the output should contain resource slot details")
def step_tool_cli_branch_output_has_slots(context):
output = context.captured_output
assert "database" in output, f"Expected 'database' in output:\n{output}"
assert "postgres" in output, f"Expected 'postgres' in output:\n{output}"
assert "read" in output, f"Expected 'read' in output:\n{output}"
assert "cache" in output, f"Expected 'cache' in output:\n{output}"
assert "redis" in output, f"Expected 'redis' in output:\n{output}"
assert "write" in output, f"Expected 'write' in output:\n{output}"
# ===========================================================================
# Scenario: _print_tool displays both lifecycle and resource slots
# ===========================================================================
@given("tool cli branch a tool with lifecycle and resource slots")
def step_tool_cli_branch_tool_with_both(context):
context.tool_obj = _FakeToolWithCliDict(
{
"name": "local/full-tool",
"description": "A tool with both lifecycle and slots",
"source": "custom",
"tool_type": "tool",
"namespace": "local",
"short_name": "full-tool",
"timeout": 90,
"capability": {"writes": True, "checkpointable": False},
"lifecycle": {
"on_init": "setup.sh",
"on_teardown": "cleanup.sh",
},
"resource_slots": [
{
"name": "storage",
"resource_type": "s3",
"access": "readwrite",
},
],
}
)
@when("tool cli branch I print the full tool in rich format")
def step_tool_cli_branch_print_full_tool(context):
context.captured_output = _capture_rich_output(context.tool_obj)
@then("tool cli branch the output should contain both lifecycle and slot info")
def step_tool_cli_branch_output_has_both(context):
output = context.captured_output
# Lifecycle
assert "on_init" in output, f"Expected 'on_init' in output:\n{output}"
assert "setup.sh" in output, f"Expected 'setup.sh' in output:\n{output}"
assert "on_teardown" in output, f"Expected 'on_teardown' in output:\n{output}"
assert "cleanup.sh" in output, f"Expected 'cleanup.sh' in output:\n{output}"
# Resource slots
assert "storage" in output, f"Expected 'storage' in output:\n{output}"
assert "s3" in output, f"Expected 's3' in output:\n{output}"
assert "readwrite" in output, f"Expected 'readwrite' in output:\n{output}"
# ===========================================================================
# Scenario: remove command handles service returning False
# ===========================================================================
@given("tool cli branch a mock service where remove_tool returns False")
def step_tool_cli_branch_mock_service_remove_false(context):
mock_service = MagicMock()
# get_tool returns a truthy value so we pass the "not found" check
mock_service.get_tool.return_value = {"name": "local/doomed-tool"}
# remove_tool returns False → triggers the "Failed to remove" branch
mock_service.remove_tool.return_value = False
context.mock_service = mock_service
@when("tool cli branch I invoke the remove command with confirmation")
def step_tool_cli_branch_invoke_remove(context):
from typer.testing import CliRunner
from cleveragents.cli.commands.tool import app as tool_app
runner = CliRunner()
with patch(
"cleveragents.cli.commands.tool._get_tool_registry_service",
return_value=context.mock_service,
):
result = runner.invoke(tool_app, ["remove", "--yes", "local/doomed-tool"])
context.result = result
@then("tool cli branch the output should show failed to remove tool")
def step_tool_cli_branch_output_failed_remove(context):
output = context.result.output
assert "Failed to remove tool" in output, (
f"Expected 'Failed to remove tool' in output:\n{output}"
)
@@ -0,0 +1,557 @@
"""Step definitions for tool_registry_service_uncovered_branches.feature.
Exercises the fallback delegation branches in ToolRegistryService that are
not covered by the primary test suite:
- register_tool: create not callable add fallback direct create fallback
- remove_tool: delete not callable remove fallback direct delete fallback
- update_tool: tool_config as Tool instance, dict with string name, dict with dict tool
- list_tools: with namespace/type/source filters
- attach_validation: invalid mode raises ValidationError
All step names are prefixed with "trs branch" to avoid collisions with the
existing ``tool_registry_service_coverage_steps.py``.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.tool import Tool, ToolSource, ToolType
# ---------------------------------------------------------------------------
# Mock repos
# ---------------------------------------------------------------------------
class _NoCreateRepo:
"""Tool repo where ``create`` is a non-callable attribute, but ``add`` works.
``getattr(repo, "create", None)`` returns a truthy, non-callable value,
so ``callable(create_fn)`` is False the service falls to the ``add`` path.
"""
def __init__(self) -> None:
self.create: str = "not-a-function" # non-callable attribute
self.add_called = False
self._last_added: Any = None
def add(self, tool: Any) -> Any:
self.add_called = True
self._last_added = tool
return tool
# Required by other service methods
def get_by_name(self, name: str) -> Any:
return None
def update(self, tool: Any) -> Any:
return tool
def delete(self, name: str) -> bool:
return True
def list_all(
self,
namespace: str | None = None,
tool_type: str | None = None,
source: str | None = None,
) -> list[Any]:
return []
class _NeitherCreateNorAddRepo:
"""Tool repo where both ``create`` and ``add`` are non-callable strings.
Forces the service to fall through to ``self._tool_repo.create(tool)``
(line 57), which will raise ``TypeError`` because ``create`` is a string.
"""
def __init__(self) -> None:
self.create: str = "not-a-function" # type: ignore[assignment]
self.add: str = "also-not-a-function" # type: ignore[assignment]
def get_by_name(self, name: str) -> Any:
return None
def update(self, tool: Any) -> Any:
return tool
def delete(self, name: str) -> bool:
return True
def list_all(
self,
namespace: str | None = None,
tool_type: str | None = None,
source: str | None = None,
) -> list[Any]:
return []
class _NoDeleteRepo:
"""Tool repo where ``delete`` is a non-callable attribute, but ``remove`` works."""
def __init__(self) -> None:
self.delete: str = "not-a-function" # type: ignore[assignment]
self.remove_called = False
def remove(self, name: str) -> bool:
self.remove_called = True
return True
def create(self, tool: Any) -> Any:
return tool
def get_by_name(self, name: str) -> Any:
return None
def update(self, tool: Any) -> Any:
return tool
def list_all(
self,
namespace: str | None = None,
tool_type: str | None = None,
source: str | None = None,
) -> list[Any]:
return []
class _NeitherDeleteNorRemoveRepo:
"""Tool repo where both ``delete`` and ``remove`` are non-callable strings."""
def __init__(self) -> None:
self.delete: str = "not-a-function" # type: ignore[assignment]
self.remove: str = "also-not-a-function" # type: ignore[assignment]
def create(self, tool: Any) -> Any:
return tool
def get_by_name(self, name: str) -> Any:
return None
def update(self, tool: Any) -> Any:
return tool
def list_all(
self,
namespace: str | None = None,
tool_type: str | None = None,
source: str | None = None,
) -> list[Any]:
return []
class _StandardToolRepo:
"""Normal tool repo with all methods callable — used for update/list/attach tests."""
def __init__(self) -> None:
self._last_updated: Any = None
self._list_all_kwargs: dict[str, Any] = {}
def create(self, tool: Any) -> Any:
return tool
def get_by_name(self, name: str) -> Any:
return {"name": name}
def update(self, tool: Any) -> Any:
self._last_updated = tool
return tool
def delete(self, name: str) -> bool:
return True
def list_all(
self,
namespace: str | None = None,
tool_type: str | None = None,
source: str | None = None,
) -> list[Any]:
self._list_all_kwargs = {
"namespace": namespace,
"tool_type": tool_type,
"source": source,
}
return [{"name": "local/listed-tool"}]
class _MockAttachmentRepo:
"""Minimal attachment repo mock."""
def attach(self, **kwargs: Any) -> dict[str, str]:
return {"attachment_id": "branch-test-attachment-001"}
def detach(self, attachment_id: str) -> bool:
return True
def list_for_resource(
self,
resource_id: str,
project_name: str | None = None,
plan_id: str | None = None,
) -> list[Any]:
return []
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SAMPLE_TOOL = {
"name": "local/branch-test-tool",
"tool_type": "tool",
"source": "builtin",
}
def _make_tool_instance() -> Tool:
"""Create a real Tool domain object for tests."""
return Tool(
name="local/branch-tool",
description="A tool for branch tests",
source=ToolSource.BUILTIN,
tool_type=ToolType.TOOL,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a trs branch mock-based tool registry service")
def step_trs_branch_background(context: Context) -> None:
"""Set up default context variables — repos are assigned per-scenario."""
context.trs_b_tool_repo: Any = None
context.trs_b_attachment_repo = _MockAttachmentRepo()
context.trs_b_service: ToolRegistryService | None = None
context.trs_b_error: Exception | None = None
context.trs_b_result: Any = None
# ---------------------------------------------------------------------------
# Given: repo variants
# ---------------------------------------------------------------------------
@given("a trs branch tool repo where create is not callable but add is callable")
def step_trs_branch_no_create_repo(context: Context) -> None:
context.trs_b_tool_repo = _NoCreateRepo()
context.trs_b_service = ToolRegistryService(
tool_repo=context.trs_b_tool_repo,
attachment_repo=context.trs_b_attachment_repo,
)
@given("a trs branch tool repo where neither create nor add is callable")
def step_trs_branch_neither_create_nor_add(context: Context) -> None:
context.trs_b_tool_repo = _NeitherCreateNorAddRepo()
context.trs_b_service = ToolRegistryService(
tool_repo=context.trs_b_tool_repo,
attachment_repo=context.trs_b_attachment_repo,
)
@given("a trs branch tool repo where delete is not callable but remove is callable")
def step_trs_branch_no_delete_repo(context: Context) -> None:
context.trs_b_tool_repo = _NoDeleteRepo()
context.trs_b_service = ToolRegistryService(
tool_repo=context.trs_b_tool_repo,
attachment_repo=context.trs_b_attachment_repo,
)
@given("a trs branch tool repo where neither delete nor remove is callable")
def step_trs_branch_neither_delete_nor_remove(context: Context) -> None:
context.trs_b_tool_repo = _NeitherDeleteNorRemoveRepo()
context.trs_b_service = ToolRegistryService(
tool_repo=context.trs_b_tool_repo,
attachment_repo=context.trs_b_attachment_repo,
)
@given("a trs branch standard tool repo")
def step_trs_branch_standard_repo(context: Context) -> None:
context.trs_b_tool_repo = _StandardToolRepo()
context.trs_b_service = ToolRegistryService(
tool_repo=context.trs_b_tool_repo,
attachment_repo=context.trs_b_attachment_repo,
)
# ---------------------------------------------------------------------------
# When: register_tool
# ---------------------------------------------------------------------------
@when("I trs branch register a tool")
def step_trs_branch_register(context: Context) -> None:
try:
context.trs_b_result = context.trs_b_service.register_tool(_SAMPLE_TOOL)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
@when("I trs branch register a tool expecting an error")
def step_trs_branch_register_error(context: Context) -> None:
try:
context.trs_b_result = context.trs_b_service.register_tool(_SAMPLE_TOOL)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
# ---------------------------------------------------------------------------
# When: remove_tool
# ---------------------------------------------------------------------------
@when('I trs branch remove tool "{name}"')
def step_trs_branch_remove(context: Context, name: str) -> None:
try:
context.trs_b_result = context.trs_b_service.remove_tool(name)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
@when('I trs branch remove tool "{name}" expecting an error')
def step_trs_branch_remove_error(context: Context, name: str) -> None:
try:
context.trs_b_result = context.trs_b_service.remove_tool(name)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
# ---------------------------------------------------------------------------
# When: update_tool with tool_config
# ---------------------------------------------------------------------------
@when("I trs branch update tool with a Tool instance as tool_config")
def step_trs_branch_update_tool_instance(context: Context) -> None:
tool_instance = _make_tool_instance()
context.trs_b_tool_instance = tool_instance
try:
context.trs_b_result = context.trs_b_service.update_tool(
tool="local/branch-tool",
tool_config=tool_instance,
)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
@when("I trs branch update tool with a string name and dict tool_config")
def step_trs_branch_update_string_dict(context: Context) -> None:
try:
context.trs_b_result = context.trs_b_service.update_tool(
tool="local/updated-tool",
tool_config={"description": "updated desc", "timeout": 60},
)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
@when("I trs branch update tool with a dict tool and dict tool_config")
def step_trs_branch_update_dict_dict(context: Context) -> None:
tool_dict = {"name": "local/dict-tool", "description": "orig"}
try:
context.trs_b_result = context.trs_b_service.update_tool(
tool=tool_dict,
tool_config={"description": "overridden"},
)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
# ---------------------------------------------------------------------------
# When: list_tools with filters
# ---------------------------------------------------------------------------
@when('I trs branch list tools with namespace "{ns}" type "{tt}" source "{src}"')
def step_trs_branch_list_tools(context: Context, ns: str, tt: str, src: str) -> None:
try:
context.trs_b_result = context.trs_b_service.list_tools(
namespace=ns, tool_type=tt, source=src
)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
# ---------------------------------------------------------------------------
# When: attach_validation with invalid mode
# ---------------------------------------------------------------------------
@when('I trs branch attach validation with mode "{mode}"')
def step_trs_branch_attach_invalid_mode(context: Context, mode: str) -> None:
try:
context.trs_b_result = context.trs_b_service.attach_validation(
validation_name="local/some-validation",
resource_id="res-001",
mode=mode,
)
context.trs_b_error = None
except Exception as exc:
context.trs_b_error = exc
# ---------------------------------------------------------------------------
# Then: register_tool assertions
# ---------------------------------------------------------------------------
@then("the trs branch result should equal the registered tool")
def step_trs_branch_result_equals_tool(context: Context) -> None:
assert context.trs_b_error is None, (
f"Expected no error, got {type(context.trs_b_error).__name__}: "
f"{context.trs_b_error}"
)
assert context.trs_b_result == _SAMPLE_TOOL
@then("the trs branch add method should have been called")
def step_trs_branch_add_called(context: Context) -> None:
assert hasattr(context.trs_b_tool_repo, "add_called"), (
"Repo does not track add_called"
)
assert context.trs_b_tool_repo.add_called is True, "add() was not called"
@then("a trs branch TypeError should be raised")
def step_trs_branch_type_error(context: Context) -> None:
assert context.trs_b_error is not None, "Expected TypeError but no error was raised"
assert isinstance(context.trs_b_error, TypeError), (
f"Expected TypeError, got {type(context.trs_b_error).__name__}: "
f"{context.trs_b_error}"
)
# ---------------------------------------------------------------------------
# Then: remove_tool assertions
# ---------------------------------------------------------------------------
@then("the trs branch removal result should be True")
def step_trs_branch_removal_true(context: Context) -> None:
assert context.trs_b_error is None, (
f"Expected no error, got {type(context.trs_b_error).__name__}: "
f"{context.trs_b_error}"
)
assert context.trs_b_result is True, f"Expected True, got {context.trs_b_result}"
@then("the trs branch remove method should have been called")
def step_trs_branch_remove_called(context: Context) -> None:
assert hasattr(context.trs_b_tool_repo, "remove_called"), (
"Repo does not track remove_called"
)
assert context.trs_b_tool_repo.remove_called is True, "remove() was not called"
# ---------------------------------------------------------------------------
# Then: update_tool assertions
# ---------------------------------------------------------------------------
@then("the trs branch updated result should be the Tool instance")
def step_trs_branch_updated_is_tool(context: Context) -> None:
assert context.trs_b_error is None, (
f"Expected no error, got {type(context.trs_b_error).__name__}: "
f"{context.trs_b_error}"
)
assert isinstance(context.trs_b_result, Tool), (
f"Expected Tool instance, got {type(context.trs_b_result).__name__}"
)
assert context.trs_b_result is context.trs_b_tool_instance, (
"Expected the exact Tool instance passed as tool_config"
)
@then("the trs branch updated result should contain the merged name and config")
def step_trs_branch_updated_merged(context: Context) -> None:
assert context.trs_b_error is None, (
f"Expected no error, got {type(context.trs_b_error).__name__}: "
f"{context.trs_b_error}"
)
result = context.trs_b_result
assert isinstance(result, dict), f"Expected dict, got {type(result).__name__}"
assert result["name"] == "local/updated-tool", (
f"Expected name 'local/updated-tool', got '{result.get('name')}'"
)
assert result["description"] == "updated desc", (
f"Expected description 'updated desc', got '{result.get('description')}'"
)
assert result["timeout"] == 60, f"Expected timeout 60, got {result.get('timeout')}"
@then("the trs branch updated result should be the original tool dict")
def step_trs_branch_updated_original(context: Context) -> None:
assert context.trs_b_error is None, (
f"Expected no error, got {type(context.trs_b_error).__name__}: "
f"{context.trs_b_error}"
)
result = context.trs_b_result
assert isinstance(result, dict), f"Expected dict, got {type(result).__name__}"
# When tool is not a string, the original tool dict is used directly
assert result["name"] == "local/dict-tool", (
f"Expected name 'local/dict-tool', got '{result.get('name')}'"
)
# ---------------------------------------------------------------------------
# Then: list_tools assertions
# ---------------------------------------------------------------------------
@then("the trs branch list result should come from the repo")
def step_trs_branch_list_result(context: Context) -> None:
assert context.trs_b_error is None, (
f"Expected no error, got {type(context.trs_b_error).__name__}: "
f"{context.trs_b_error}"
)
assert isinstance(context.trs_b_result, list), (
f"Expected list, got {type(context.trs_b_result).__name__}"
)
assert len(context.trs_b_result) == 1
assert context.trs_b_result[0]["name"] == "local/listed-tool"
# Verify filters were passed through
kwargs = context.trs_b_tool_repo._list_all_kwargs
assert kwargs["namespace"] == "local"
assert kwargs["tool_type"] == "tool"
assert kwargs["source"] == "builtin"
# ---------------------------------------------------------------------------
# Then: attach_validation assertions
# ---------------------------------------------------------------------------
@then('a trs branch ValidationError should be raised with message containing "{text}"')
def step_trs_branch_validation_error(context: Context, text: str) -> None:
assert context.trs_b_error is not None, (
"Expected ValidationError but no error was raised"
)
assert isinstance(context.trs_b_error, ValidationError), (
f"Expected ValidationError, got {type(context.trs_b_error).__name__}: "
f"{context.trs_b_error}"
)
assert text in str(context.trs_b_error), (
f"Expected '{text}' in error message, got '{context.trs_b_error}'"
)
@@ -0,0 +1,123 @@
"""Step definitions for validation CLI uncovered branches.
Covers two gaps in ``cleveragents.cli.commands.validation``:
1. ``_get_tool_registry_service()`` (lines 75-95) the full construction path
that imports from the container, creates an engine/session-factory, builds
repositories, and returns a ``ToolRegistryService``.
2. ``detach`` command branch L356360 when the user confirms the detach
prompt but ``detach_validation`` returns ``False`` (attachment not found).
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner
from cleveragents.cli.commands.validation import app as validation_app
_runner = CliRunner()
# ---------------------------------------------------------------------------
# Patch targets
# ---------------------------------------------------------------------------
# We patch get_container *inside* the validation module's lazy import so the
# real function body executes but uses our mock container.
_PATCH_GET_CONTAINER = "cleveragents.application.container.get_container"
# For the detach scenario we still patch the whole helper to isolate DB access.
_PATCH_VAL_SVC = "cleveragents.cli.commands.validation._get_tool_registry_service"
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a validation cli branch test runner with mocks")
def step_validation_cli_branch_background(context: Context) -> None:
context.vcb_runner = _runner
context.vcb_result = None
context.vcb_returned_service: Any = None
context.vcb_mock_service: MagicMock | None = None
# ---------------------------------------------------------------------------
# Scenario 1: _get_tool_registry_service construction path (L75-95)
# ---------------------------------------------------------------------------
@given("the validation cli branch DI container provides a database url")
def step_vcb_di_container(context: Context) -> None:
"""Set up a mock container whose ``database_url()`` returns an in-memory
SQLite URL so the real construction path executes end-to-end."""
mock_container = MagicMock()
mock_container.database_url.return_value = "sqlite:///:memory:"
context.vcb_mock_container = mock_container
@when("the validation cli branch _get_tool_registry_service is called")
def step_vcb_call_get_tool_registry_service(context: Context) -> None:
from cleveragents.cli.commands.validation import _get_tool_registry_service
with patch(_PATCH_GET_CONTAINER, return_value=context.vcb_mock_container):
context.vcb_returned_service = _get_tool_registry_service()
@then("the validation cli branch returned service should be a ToolRegistryService")
def step_vcb_verify_service_type(context: Context) -> None:
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
assert context.vcb_returned_service is not None, (
"_get_tool_registry_service returned None"
)
assert isinstance(context.vcb_returned_service, ToolRegistryService), (
f"Expected ToolRegistryService, got {type(context.vcb_returned_service)}"
)
# ---------------------------------------------------------------------------
# Scenario 2: detach — user confirms but attachment not found (L356→360)
# ---------------------------------------------------------------------------
@given("the validation cli branch detach service returns false")
def step_vcb_detach_service_false(context: Context) -> None:
"""Prepare a mock service whose ``detach_validation`` returns ``False``."""
svc = MagicMock()
svc.detach_validation.return_value = False
context.vcb_mock_service = svc
@when('the validation cli branch detach is invoked with confirmation for "{att_id}"')
def step_vcb_detach_with_confirmation(context: Context, att_id: str) -> None:
"""Invoke the detach command *without* ``--yes`` and feed ``y`` to the
confirmation prompt so the code reaches ``service.detach_validation``
which returns ``False`` covering L356360."""
with patch(_PATCH_VAL_SVC, return_value=context.vcb_mock_service):
context.vcb_result = _runner.invoke(
validation_app,
["detach", att_id],
input="y\n",
)
@then("the validation cli branch detach result should abort with not found")
def step_vcb_detach_abort_not_found(context: Context) -> None:
result = context.vcb_result
assert result is not None, "No CLI result captured"
# The command should abort (non-zero exit)
assert result.exit_code != 0, (
f"Expected non-zero exit, got {result.exit_code}. Output: {result.output}"
)
assert "Attachment not found" in result.output, (
f"Expected 'Attachment not found' in output, got: {result.output}"
)
@@ -0,0 +1,114 @@
Feature: System CLI uncovered branches
Cover remaining missed lines and branches in system.py
# L50->54: git rev-parse returns non-zero exit code
Scenario: system cli branch git_sha returns unknown on non-zero exit
Given system cli branch module is loaded
When system cli branch git_sha subprocess returns non-zero exit code
Then system cli branch result should equal "unknown"
# L107-109: DB path exists, compute size
Scenario: system cli branch build_info_data computes db size when file exists
Given system cli branch module is loaded
When system cli branch build_info_data is called with existing database file
Then system cli branch storage db_size should contain "MB"
# L112-113: exception during db size computation
Scenario: system cli branch build_info_data returns unknown on db stat exception
Given system cli branch module is loaded
When system cli branch build_info_data is called with db stat raising exception
Then system cli branch storage db_size should equal "unknown"
# L116-118: log_dir exists with files
Scenario: system cli branch build_info_data computes log size when dir exists
Given system cli branch module is loaded
When system cli branch build_info_data is called with existing log directory
Then system cli branch storage logs should contain "MB"
# L145-147: config exists but not readable
Scenario: system cli branch check_config_file returns ERROR when not readable
Given system cli branch module is loaded
When system cli branch check_config_file is called with unreadable config
Then system cli branch check status should be "error"
And system cli branch check details should be "not readable"
# L166->174 + L174-175: data dir exists but not writable
Scenario: system cli branch check_data_dir returns ERROR when not writable
Given system cli branch module is loaded
When system cli branch check_data_dir is called with non-writable directory
Then system cli branch check status should be "error"
And system cli branch check details should be "not writable"
# L166->174: data dir does not exist
Scenario: system cli branch check_data_dir returns WARN when dir missing
Given system cli branch module is loaded
When system cli branch check_data_dir is called with missing directory
Then system cli branch check status should be "warn"
# L192-194: sqlite DB exists but not writable
Scenario: system cli branch check_database returns ERROR when db not writable
Given system cli branch module is loaded
When system cli branch check_database is called with non-writable sqlite db
Then system cli branch check status should be "error"
And system cli branch check details should be "locked or not writable"
# L257-258: free disk < 0.5 GB
Scenario: system cli branch check_disk_space returns ERROR when critically low
Given system cli branch module is loaded
When system cli branch check_disk_space reports less than half GB free
Then system cli branch check status should be "error"
And system cli branch check details should contain "critically low"
# L263-264: free disk < 1.0 GB
Scenario: system cli branch check_disk_space returns WARN when low
Given system cli branch module is loaded
When system cli branch check_disk_space reports less than one GB free
Then system cli branch check status should be "warn"
And system cli branch check details should contain "low"
# L274-275: OSError in disk check
Scenario: system cli branch check_disk_space returns WARN on OSError
Given system cli branch module is loaded
When system cli branch check_disk_space raises OSError
Then system cli branch check status should be "warn"
And system cli branch check details should be "unable to check"
# L291->300: _check_git subprocess returns non-zero
Scenario: system cli branch check_git returns ERROR on non-zero exit
Given system cli branch module is loaded
When system cli branch check_git subprocess returns non-zero exit code
Then system cli branch check status should be "error"
And system cli branch check details should be "not found"
# L324-325: _check_file_permissions data dir not exists
Scenario: system cli branch check_file_permissions WARN when data dir missing
Given system cli branch module is loaded
When system cli branch check_file_permissions with missing data dir
Then system cli branch check status should be "warn"
And system cli branch check details should be "data dir does not exist"
# L331->333->334: readable but not writable
Scenario: system cli branch check_file_permissions readable not writable
Given system cli branch module is loaded
When system cli branch check_file_permissions with readable not writable dir
Then system cli branch check status should be "error"
And system cli branch check details should be "data dir r"
# L331->333: not readable and not writable (no access)
Scenario: system cli branch check_file_permissions no access
Given system cli branch module is loaded
When system cli branch check_file_permissions with no access dir
Then system cli branch check status should be "error"
And system cli branch check details should be "data dir no access"
# L417->420: render_version_rich with no dependencies
Scenario: system cli branch render_version_rich skips deps panel when empty
Given system cli branch module is loaded
When system cli branch render_version_rich is called with no dependencies
Then system cli branch no exception should be raised
# L448->452: render_info_rich with no storage
Scenario: system cli branch render_info_rich skips storage panel when empty
Given system cli branch module is loaded
When system cli branch render_info_rich is called with no storage
Then system cli branch no exception should be raised
@@ -0,0 +1,29 @@
Feature: Tool CLI uncovered branches
As a developer
I want to cover the remaining tool CLI branches
So that tool.py reaches full branch coverage
Scenario: _get_tool_registry_service constructs service from container
Given tool cli branch the container returns a mock database url
When tool cli branch I call _get_tool_registry_service
Then tool cli branch it should return a ToolRegistryService instance
Scenario: _print_tool displays lifecycle dict with values
Given tool cli branch a tool with lifecycle hooks
When tool cli branch I print the tool in rich format
Then tool cli branch the output should contain lifecycle key values
Scenario: _print_tool displays resource slots list with dict items
Given tool cli branch a tool with resource slots
When tool cli branch I print the tool with slots in rich format
Then tool cli branch the output should contain resource slot details
Scenario: _print_tool displays both lifecycle and resource slots
Given tool cli branch a tool with lifecycle and resource slots
When tool cli branch I print the full tool in rich format
Then tool cli branch the output should contain both lifecycle and slot info
Scenario: remove command handles service returning False
Given tool cli branch a mock service where remove_tool returns False
When tool cli branch I invoke the remove command with confirmation
Then tool cli branch the output should show failed to remove tool
@@ -0,0 +1,79 @@
@unit @coverage @tool_registry @branches
Feature: Tool Registry Service uncovered branch paths
As a developer maintaining the tool registry service
I want to exercise the fallback and branch paths that lack coverage
So that create/add fallback, delete/remove fallback, tool_config branches,
and invalid-mode validation are fully tested
Background:
Given a trs branch mock-based tool registry service
# --- register_tool: create not callable, add IS callable (L54-56) ----------
Scenario: register_tool falls back to add when create is not callable
Given a trs branch tool repo where create is not callable but add is callable
When I trs branch register a tool
Then the trs branch result should equal the registered tool
And the trs branch add method should have been called
# --- register_tool: create AND add both not callable (L54-55,57) -----------
Scenario: register_tool falls through to direct create call when both create and add are not callable
Given a trs branch tool repo where neither create nor add is callable
When I trs branch register a tool expecting an error
Then a trs branch TypeError should be raised
# --- remove_tool: delete not callable, remove IS callable (L101-103) -------
Scenario: remove_tool falls back to remove when delete is not callable
Given a trs branch tool repo where delete is not callable but remove is callable
When I trs branch remove tool "local/some-tool"
Then the trs branch removal result should be True
And the trs branch remove method should have been called
# --- remove_tool: delete AND remove both not callable (L101-102,105) -------
Scenario: remove_tool falls through to direct delete call when both delete and remove are not callable
Given a trs branch tool repo where neither delete nor remove is callable
When I trs branch remove tool "local/some-tool" expecting an error
Then a trs branch TypeError should be raised
# --- update_tool: tool_config is a Tool instance (L77-78,82) ---------------
Scenario: update_tool uses Tool instance directly when tool_config is a Tool
Given a trs branch standard tool repo
When I trs branch update tool with a Tool instance as tool_config
Then the trs branch updated result should be the Tool instance
# --- update_tool: tool_config is a dict and tool is a string (L80,82) ------
Scenario: update_tool merges name from string tool into dict tool_config
Given a trs branch standard tool repo
When I trs branch update tool with a string name and dict tool_config
Then the trs branch updated result should contain the merged name and config
# --- update_tool: tool_config is a dict and tool is not a string (L80,82) --
Scenario: update_tool uses tool directly when tool_config is a dict but tool is not a string
Given a trs branch standard tool repo
When I trs branch update tool with a dict tool and dict tool_config
Then the trs branch updated result should be the original tool dict
# --- list_tools: with filters (L123) ---------------------------------------
Scenario: list_tools passes filters through to repo list_all
Given a trs branch standard tool repo
When I trs branch list tools with namespace "local" type "tool" source "builtin"
Then the trs branch list result should come from the repo
# --- attach_validation: invalid mode (L168-169) ----------------------------
Scenario: attach_validation raises ValidationError for invalid mode
Given a trs branch standard tool repo
When I trs branch attach validation with mode "strict"
Then a trs branch ValidationError should be raised with message containing "Invalid mode"
Scenario: attach_validation raises ValidationError for empty mode
Given a trs branch standard tool repo
When I trs branch attach validation with mode "optional"
Then a trs branch ValidationError should be raised with message containing "Invalid mode"
@@ -0,0 +1,21 @@
Feature: Validation CLI uncovered branches
As a developer
I want to cover the remaining uncovered code paths in validation.py
So that code coverage is improved for _get_tool_registry_service and detach-not-found
Background:
Given a validation cli branch test runner with mocks
# Coverage: _get_tool_registry_service() lines 75-95
# The entire function body that constructs ToolRegistryService from the DI container
Scenario: _get_tool_registry_service constructs the service from the container
Given the validation cli branch DI container provides a database url
When the validation cli branch _get_tool_registry_service is called
Then the validation cli branch returned service should be a ToolRegistryService
# Coverage: detach command L356->360 branch (confirm=True but removed=False)
# When user confirms detach but the attachment is not found
Scenario: Detach validation with confirmation but attachment not found
Given the validation cli branch detach service returns false
When the validation cli branch detach is invoked with confirmation for "att-unknown-999"
Then the validation cli branch detach result should abort with not found