Files
temp/features/steps/repl_cli_coverage_boost_steps.py
freemo 051ee7c290 test(coverage): add Behave BDD tests to improve coverage across 52 source files
Added 52 new .feature files and corresponding _steps.py files targeting
previously uncovered code paths in the following areas:

- TUI layer: app, commands, persona (state/schema/registry), widgets,
  input (shell_exec, reference_parser)
- Application services: plan lifecycle/service/executor, session,
  project, repo indexing, correction, checkpoint, actor, llm_actors,
  strategy coordinator, resource file watcher, service retry wiring
- CLI commands: session, resource, repl, plan, db, automation_profile
- Domain models: retry_policy, resource_type, cost_budget,
  docker_compose_analyzer, detail_level, _sql_string_aware,
  _postgresql_helpers
- Core: circuit_breaker, retry_service_patterns
- Infrastructure: repositories, transaction_sandbox, strategy_registry,
  plugins/loader, container
- Config: settings
- Agents: plan_generation, context_analysis, auto_debug
- A2A: facade

All new tests follow the Behave/Gherkin BDD standard. Resolved step
definition collisions with unique prefixes. Fixed Alembic fileConfig
logger disabling issue (disable_existing_loggers=False).

ISSUES CLOSED: #1068
2026-03-20 21:22:10 +00:00

635 lines
23 KiB
Python

"""Step definitions for repl_cli_coverage_boost.feature.
These steps target specific uncovered lines in
cleveragents/cli/commands/repl.py:
- Line 222: _build_reference_catalog file-count break
- Lines 331-332: _expand_references category match, no fuzzy match
- Line 360: _reference_suggestions bare @-sign token skipped
- Lines 423-424: _print_persona_list empty list
- Lines 467, 469: _parse_persona_create unknown flag / missing --actor
- Lines 506-507: /persona set missing name
- Lines 529-530: /persona pick no personas
- Lines 558-559: /persona delete missing name
- Lines 566-567: /persona delete not found
- Lines 579-580: /persona export missing name
- Lines 584-585: /persona export not found
- Lines 386-389, 404-408: _run_shell_command edge cases
"""
import os
import shlex
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.cli.commands.repl import (
_MAX_REFERENCE_FILES,
_best_fuzzy_match,
_build_reference_catalog,
_compose_prompt,
_expand_references,
_find_reference_candidates,
_get_prompt_context,
_handle_slash_command,
_parse_persona_create,
_print_persona_list,
_reference_cache,
_reference_suggestions,
_ReplSessionState,
_run_shell_command,
)
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("the repl module is imported")
def step_repl_module_imported(context):
"""Ensure the REPL module is importable."""
assert _build_reference_catalog is not None
assert _expand_references is not None
assert _handle_slash_command is not None
# ---------------------------------------------------------------------------
# _build_reference_catalog: file limit break (line 222)
# ---------------------------------------------------------------------------
@given("a temporary directory with more files than the reference limit")
def step_create_many_files(context):
"""Create a temp dir with more than _MAX_REFERENCE_FILES files."""
context.tmpdir = tempfile.mkdtemp()
count = _MAX_REFERENCE_FILES + 50
for i in range(count):
Path(context.tmpdir, f"file_{i:04d}.txt").touch()
context.created_file_count = count
@when("I build the reference catalog from that directory")
def step_build_catalog_many_files(context):
"""Build the reference catalog with cwd set to the temp dir."""
# Clear cache to force rebuild
_reference_cache["cwd"] = None
_reference_cache["created_at"] = 0.0
_reference_cache["catalog"] = None
with patch(
"cleveragents.cli.commands.repl.Path.cwd", return_value=Path(context.tmpdir)
):
context.catalog_result = _build_reference_catalog()
@then("the catalog file list should be capped at the maximum")
def step_verify_catalog_capped(context):
"""Verify that the catalog doesn't exceed the max."""
file_count = len(context.catalog_result.get("file", []))
assert file_count <= _MAX_REFERENCE_FILES, (
f"Expected at most {_MAX_REFERENCE_FILES} files, got {file_count}"
)
# ---------------------------------------------------------------------------
# _expand_references: category match but no fuzzy match (lines 331-332)
# ---------------------------------------------------------------------------
@given("a reference catalog with an empty actor list")
def step_catalog_empty_actor(context):
"""Create a catalog with an empty actor category."""
context.test_catalog = {
"file": ["readme.md"],
"actor": [],
"tool": [],
"skill": [],
"project": [],
"plan": [],
}
@when('I expand the reference "{token}" using that catalog')
def step_expand_reference_token(context, token):
"""Expand references in a line using the test catalog."""
context.expanded_line = _expand_references(token, catalog=context.test_catalog)
@then('the token should remain unchanged as "{expected}"')
def step_verify_token_unchanged(context, expected):
"""Verify the token was not changed."""
assert context.expanded_line == expected, (
f"Expected '{expected}', got '{context.expanded_line}'"
)
# ---------------------------------------------------------------------------
# _reference_suggestions: bare @-sign (line 360)
# ---------------------------------------------------------------------------
@given("a reference catalog with some files")
def step_catalog_with_files(context):
"""Create a catalog with some file entries."""
context.test_catalog = {
"file": ["readme.md", "src/main.py"],
"actor": ["ns/coder"],
"tool": [],
"skill": [],
"project": [],
"plan": [],
}
@when('I request suggestions for a line containing only "@"')
def step_suggestions_bare_at(context):
"""Request suggestions for a line with just '@'."""
context.suggestions = _reference_suggestions("@", catalog=context.test_catalog)
@then("no suggestions should be returned")
def step_verify_no_suggestions(context):
"""Verify empty suggestions."""
assert context.suggestions == [], f"Expected empty list, got {context.suggestions}"
# ---------------------------------------------------------------------------
# _print_persona_list: empty list (lines 423-424)
# ---------------------------------------------------------------------------
@given("a mock persona registry that returns no personas")
def step_mock_registry_no_personas(context):
"""Create a mock PersonaRegistry that returns an empty list."""
context.mock_registry = MagicMock()
context.mock_registry.list_personas.return_value = []
@when("I call print persona list")
def step_call_print_persona_list(context):
"""Call _print_persona_list with the mock registry."""
sessions = {"default": _ReplSessionState(name="default", active_persona="default")}
_print_persona_list(context.mock_registry, sessions, "default")
@then("the output should contain a no-personas warning")
def step_verify_no_personas_warning(context):
"""Verify _print_persona_list was called without errors on empty list."""
# The function prints to console; if it reached here it means
# lines 423-424 were executed (early return on empty list).
context.mock_registry.list_personas.assert_called_once()
# ---------------------------------------------------------------------------
# _parse_persona_create: unknown flags (line 467)
# ---------------------------------------------------------------------------
@when('I parse persona create with tokens "{token_str}"')
def step_parse_persona_create_tokens(context, token_str):
"""Parse persona create from a token string.
_parse_persona_create expects the full token list including 'persona'
at index 0, so we prepend it.
"""
tokens = ["persona", *shlex.split(token_str)]
try:
context.parsed_persona = _parse_persona_create(tokens)
context.parse_error = None
except ValueError as exc:
context.parsed_persona = None
context.parse_error = exc
@then('the resulting persona should have name "{name}" and actor "{actor}"')
def step_verify_persona_name_actor(context, name, actor):
"""Verify the persona was parsed correctly."""
assert context.parse_error is None, f"Unexpected error: {context.parse_error}"
assert context.parsed_persona.name == name
assert context.parsed_persona.actor == actor
# ---------------------------------------------------------------------------
# _parse_persona_create: missing --actor (line 469)
# ---------------------------------------------------------------------------
@then("a ValueError about missing actor should be raised")
def step_verify_missing_actor_error(context):
"""Verify the ValueError about missing --actor."""
assert context.parse_error is not None
assert "actor" in str(context.parse_error).lower()
# ---------------------------------------------------------------------------
# _parse_persona_create: too few tokens (line 434)
# ---------------------------------------------------------------------------
@when("I parse persona create with only two tokens")
def step_parse_persona_create_few_tokens(context):
"""Try to parse persona create with insufficient tokens (< 4 total)."""
try:
context.parsed_persona = _parse_persona_create(["persona", "create", "name"])
context.parse_error = None
except ValueError as exc:
context.parsed_persona = None
context.parse_error = exc
@then("a ValueError about usage should be raised")
def step_verify_usage_error(context):
"""Verify the ValueError about usage."""
assert context.parse_error is not None
assert "usage" in str(context.parse_error).lower()
# ---------------------------------------------------------------------------
# _parse_persona_create: --cycle-order invalid (lines 463-464)
# ---------------------------------------------------------------------------
@when("I parse persona create with a non-integer cycle-order")
def step_parse_persona_create_bad_cycle_order(context):
"""Try to parse with a non-integer --cycle-order."""
tokens = [
"persona",
"create",
"mybot",
"--actor",
"ns/bot",
"--cycle-order",
"abc",
]
try:
context.parsed_persona = _parse_persona_create(tokens)
context.parse_error = None
except ValueError as exc:
context.parsed_persona = None
context.parse_error = exc
@then("a ValueError about cycle-order should be raised")
def step_verify_cycle_order_error(context):
"""Verify the ValueError about cycle-order."""
assert context.parse_error is not None
assert (
"cycle-order" in str(context.parse_error).lower()
or "cycle_order" in str(context.parse_error).lower()
)
# ---------------------------------------------------------------------------
# Helpers for /persona slash commands
# ---------------------------------------------------------------------------
def _make_sessions():
"""Create a default sessions dict."""
return {"default": _ReplSessionState(name="default", active_persona="default")}
@given("a mock persona registry for slash commands")
def step_mock_registry_for_slash(context):
"""Create a standard mock registry for slash command tests."""
context.mock_registry = MagicMock()
context.mock_registry.list_personas.return_value = []
context.mock_registry.get_persona.return_value = None
context.mock_registry.delete_persona.return_value = False
context.sessions = _make_sessions()
context.current_session = "default"
@given("a mock persona registry that returns no personas for slash commands")
def step_mock_registry_no_personas_slash(context):
"""Create a mock registry with no personas for pick."""
context.mock_registry = MagicMock()
context.mock_registry.list_personas.return_value = []
context.sessions = _make_sessions()
context.current_session = "default"
@given("a mock persona registry that cannot delete for slash commands")
def step_mock_registry_cannot_delete(context):
"""Create a mock registry where delete returns False."""
context.mock_registry = MagicMock()
context.mock_registry.delete_persona.return_value = False
context.sessions = _make_sessions()
context.current_session = "default"
@given("a mock persona registry that cannot find persona for slash commands")
def step_mock_registry_cannot_find(context):
"""Create a mock registry where get_persona returns None."""
context.mock_registry = MagicMock()
context.mock_registry.get_persona.return_value = None
context.sessions = _make_sessions()
context.current_session = "default"
@when('I handle slash command "{cmd}"')
def step_handle_slash_command(context, cmd):
"""Handle a slash command."""
exit_code, session = _handle_slash_command(
cmd,
registry=context.mock_registry,
sessions=context.sessions,
current_session=context.current_session,
)
context.slash_exit_code = exit_code
context.current_session = session
@then("the repl slash exit code should be {code:d}")
def step_verify_exit_code(context, code):
"""Verify the exit code from slash command."""
assert context.slash_exit_code == code, (
f"Expected exit code {code}, got {context.slash_exit_code}"
)
# ---------------------------------------------------------------------------
# _run_shell_command: empty text (lines 386-387)
# ---------------------------------------------------------------------------
@when("I run a shell command with empty text")
def step_run_shell_empty(context):
"""Run a shell command with empty text."""
context.shell_exit_code = _run_shell_command("")
@then("the shell exit code should be {code:d}")
def step_verify_shell_exit_code(context, code):
"""Verify the shell command exit code."""
assert context.shell_exit_code == code, (
f"Expected shell exit code {code}, got {context.shell_exit_code}"
)
# ---------------------------------------------------------------------------
# _run_shell_command: disabled (lines 388-389)
# ---------------------------------------------------------------------------
@given("shell mode is disabled via environment")
def step_disable_shell_mode(context):
"""Set CLEVERAGENTS_DISABLE_SHELL_MODE=1."""
context.old_disable_shell = os.environ.get("CLEVERAGENTS_DISABLE_SHELL_MODE")
os.environ["CLEVERAGENTS_DISABLE_SHELL_MODE"] = "1"
def restore():
if context.old_disable_shell is None:
os.environ.pop("CLEVERAGENTS_DISABLE_SHELL_MODE", None)
else:
os.environ["CLEVERAGENTS_DISABLE_SHELL_MODE"] = context.old_disable_shell
context.add_cleanup(restore)
@when('I run a shell command with text "{cmd}"')
def step_run_shell_with_text(context, cmd):
"""Run a shell command with given text."""
context.shell_exit_code = _run_shell_command(cmd)
@then("the shell mode disabled message is shown")
def step_verify_shell_disabled_msg(context):
"""Verify that the disabled message was shown (exit code 2 suffices)."""
assert context.shell_exit_code == 2
# ---------------------------------------------------------------------------
# _run_shell_command: timeout (lines 404-408)
# ---------------------------------------------------------------------------
@when("I run a shell command that will time out")
def step_run_shell_timeout(context):
"""Run a shell command that times out by mocking subprocess.run."""
with patch(
"cleveragents.cli.commands.repl.subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="sleep 999", timeout=30),
):
context.shell_exit_code = _run_shell_command("sleep 999")
# ---------------------------------------------------------------------------
# _compose_prompt
# ---------------------------------------------------------------------------
@when(
'I compose a prompt with base "{base}" and session "{session}" and persona "{persona}"'
)
def step_compose_prompt(context, base, session, persona):
"""Call _compose_prompt."""
state = _ReplSessionState(name=session, active_persona=persona)
context.composed_prompt = _compose_prompt(base + "> ", state)
@then("the prompt should contain session and persona markers")
def step_verify_prompt_markers(context):
"""Verify the composed prompt contains session and persona info."""
assert "[" in context.composed_prompt
assert "]" in context.composed_prompt
assert "s1" in context.composed_prompt
assert "coder" in context.composed_prompt
# ---------------------------------------------------------------------------
# _get_prompt_context
# ---------------------------------------------------------------------------
@given('rclcb CLEVERAGENTS_PROJECT is set to "{value}"')
def step_set_project(context, value):
"""Set the CLEVERAGENTS_PROJECT env var."""
context.old_project = os.environ.get("CLEVERAGENTS_PROJECT")
os.environ["CLEVERAGENTS_PROJECT"] = value
def restore():
if context.old_project is None:
os.environ.pop("CLEVERAGENTS_PROJECT", None)
else:
os.environ["CLEVERAGENTS_PROJECT"] = context.old_project
context.add_cleanup(restore)
@given('CLEVERAGENTS_PLAN is set to "{value}"')
def step_set_plan(context, value):
"""Set the CLEVERAGENTS_PLAN env var."""
context.old_plan = os.environ.get("CLEVERAGENTS_PLAN")
os.environ["CLEVERAGENTS_PLAN"] = value
def restore():
if context.old_plan is None:
os.environ.pop("CLEVERAGENTS_PLAN", None)
else:
os.environ["CLEVERAGENTS_PLAN"] = context.old_plan
context.add_cleanup(restore)
@given("CLEVERAGENTS_PROJECT is not set")
def step_unset_project(context):
"""Ensure CLEVERAGENTS_PROJECT is unset."""
context.old_project = os.environ.pop("CLEVERAGENTS_PROJECT", None)
def restore():
if context.old_project is not None:
os.environ["CLEVERAGENTS_PROJECT"] = context.old_project
context.add_cleanup(restore)
@given("CLEVERAGENTS_PLAN is not set")
def step_unset_plan(context):
"""Ensure CLEVERAGENTS_PLAN is unset."""
context.old_plan = os.environ.pop("CLEVERAGENTS_PLAN", None)
def restore():
if context.old_plan is not None:
os.environ["CLEVERAGENTS_PLAN"] = context.old_plan
context.add_cleanup(restore)
@when("I get the prompt context")
def step_get_prompt_context(context):
"""Call _get_prompt_context."""
context.prompt_context = _get_prompt_context()
@then('the prompt context should contain "{expected}"')
def step_verify_prompt_context_contains(context, expected):
"""Verify prompt context contains expected string."""
assert expected in context.prompt_context, (
f"Expected '{expected}' in '{context.prompt_context}'"
)
@then('the prompt context should be the default "{expected}"')
def step_verify_prompt_context_default(context, expected):
"""Verify prompt context is the default."""
assert context.prompt_context == expected, (
f"Expected '{expected}', got '{context.prompt_context}'"
)
# ---------------------------------------------------------------------------
# _find_reference_candidates: empty query
# ---------------------------------------------------------------------------
@given("a list of reference options")
def step_create_reference_options(context):
"""Create a list of reference options."""
context.ref_options = ["alpha.py", "beta.py", "gamma.py", "delta.py"]
@when("I find candidates with an empty query")
def step_find_candidates_empty(context):
"""Call _find_reference_candidates with empty query."""
context.ref_candidates = _find_reference_candidates(
"", context.ref_options, limit=3
)
@then("the first items from the list should be returned")
def step_verify_first_items(context):
"""Verify the first N items are returned."""
assert context.ref_candidates == ["alpha.py", "beta.py", "gamma.py"]
# ---------------------------------------------------------------------------
# _best_fuzzy_match: substring branch
# ---------------------------------------------------------------------------
@given('a list of options including "{option}"')
def step_create_options_list(context, option):
"""Create an options list containing the given option."""
context.fuzzy_options = ["short", option, "other-thing"]
@when('I fuzzy match with query "{query}"')
def step_fuzzy_match(context, query):
"""Call _best_fuzzy_match."""
context.fuzzy_result = _best_fuzzy_match(query, context.fuzzy_options)
@then('the match should be "{expected}"')
def step_verify_fuzzy_match(context, expected):
"""Verify fuzzy match result."""
assert context.fuzzy_result == expected, (
f"Expected '{expected}', got '{context.fuzzy_result}'"
)
@then("the match should be None")
def step_verify_fuzzy_match_none(context):
"""Verify fuzzy match returned None."""
assert context.fuzzy_result is None, f"Expected None, got '{context.fuzzy_result}'"
# ---------------------------------------------------------------------------
# _reference_suggestions: category-prefixed token
# ---------------------------------------------------------------------------
@given('a reference catalog with actors "{actor1}" and "{actor2}"')
def step_catalog_with_actors(context, actor1, actor2):
"""Create a catalog with specific actors."""
context.test_catalog = {
"file": [],
"actor": [actor1, actor2],
"tool": [],
"skill": [],
"project": [],
"plan": [],
}
@when('I request suggestions for a line containing "{token}"')
def step_suggestions_for_token(context, token):
"""Request suggestions for a line."""
context.suggestions = _reference_suggestions(token, catalog=context.test_catalog)
@then("suggestions should include actor-prefixed candidates")
def step_verify_actor_suggestions(context):
"""Verify suggestions include @actor: prefixed values."""
assert len(context.suggestions) > 0, "Expected at least one suggestion"
assert any(s.startswith("@actor:") for s in context.suggestions), (
f"Expected @actor: prefixed suggestions in {context.suggestions}"
)
# ---------------------------------------------------------------------------
# _expand_references: escaped at-sign
# ---------------------------------------------------------------------------
@when("I expand a line containing a backslash-escaped at-sign token")
def step_expand_escaped_at(context):
"""Expand a line with \\@ to verify the backslash is stripped."""
context.expanded_line = _expand_references(
"\\@literal", catalog=context.test_catalog
)
@then("the escaped at-sign should be stripped to a bare at-sign")
def step_verify_unescaped(context):
"""Verify the expanded line has the backslash removed."""
assert context.expanded_line == "@literal", (
f"Expected '@literal', got '{context.expanded_line!r}'"
)
# ---------------------------------------------------------------------------
# _parse_persona_create: all optional flags
# ---------------------------------------------------------------------------
@when("I parse persona create with all flags")
def step_parse_persona_create_all_flags(context):
"""Parse persona create with all optional flags set."""
tokens = [
"persona",
"create",
"fullbot",
"--actor",
"ns/fullbot",
"--description",
"A full bot",
"--icon",
"robot",
"--greeting",
"Hello!",
"--cycle-order",
"5",
]
try:
context.parsed_persona = _parse_persona_create(tokens)
context.parse_error = None
except ValueError as exc:
context.parsed_persona = None
context.parse_error = exc
@then("the persona should have description icon greeting and cycle-order set")
def step_verify_all_flags_parsed(context):
"""Verify all optional fields were parsed."""
assert context.parse_error is None, f"Unexpected error: {context.parse_error}"
p = context.parsed_persona
assert p.name == "fullbot"
assert p.actor == "ns/fullbot"
assert p.description == "A full bot"
assert p.icon == "robot"
assert p.greeting == "Hello!"
assert p.cycle_order == 5