"""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