"""Behave steps for REPL input mode behavior.""" from __future__ import annotations import os import shutil import subprocess import tempfile from io import StringIO from pathlib import Path from typing import Any from unittest.mock import patch from behave import given, then, when from behave.runner import Context from pydantic import ValidationError from rich.console import Console from cleveragents.cli.commands.repl import ( _best_fuzzy_match, _build_reference_catalog, _expand_references, _find_reference_candidates, _handle_slash_command, _parse_persona_create, _reference_suggestions, _ReplSessionState, _run_shell_command, run_repl, ) from cleveragents.cli.persona import PersonaConfig from features.mocks.fake_repl_input import FakeReplInput def _run_repl_capture(lines: list[str], config_dir: Path) -> tuple[int, str]: from io import StringIO from rich.console import Console output = StringIO() console = Console(file=output, no_color=True, width=180) fake_input = FakeReplInput(lines) with ( patch("cleveragents.cli.commands.repl._console", console), patch("builtins.input", fake_input), patch.dict(os.environ, {"CLEVERAGENTS_CONFIG_DIR": str(config_dir)}), ): code = run_repl(no_history=True) return code, output.getvalue() @given("a temporary REPL config directory") def step_temp_repl_config(context: Context) -> None: config_dir = Path(tempfile.mkdtemp()) context.repl_mode_config_dir = config_dir context.add_cleanup(lambda: shutil.rmtree(str(config_dir), ignore_errors=True)) @given("a deterministic REPL reference catalog fixture") def step_repl_reference_fixture(context: Context) -> None: fixture_dir = Path(tempfile.mkdtemp()) (fixture_dir / "README.md").write_text("# fixture\n", encoding="utf-8") context.repl_reference_fixture_dir = fixture_dir context.add_cleanup(lambda: shutil.rmtree(str(fixture_dir), ignore_errors=True)) @given("a rich REPL reference fixture") def step_rich_repl_reference_fixture(context: Context) -> None: fixture_dir = Path(tempfile.mkdtemp()) (fixture_dir / "README.md").write_text("# fixture\n", encoding="utf-8") (fixture_dir / "examples" / "actors").mkdir(parents=True, exist_ok=True) (fixture_dir / "examples" / "tools").mkdir(parents=True, exist_ok=True) (fixture_dir / "examples" / "skills").mkdir(parents=True, exist_ok=True) (fixture_dir / "examples" / "actors" / "review.yaml").write_text( "name: review\n", encoding="utf-8" ) (fixture_dir / "examples" / "tools" / "search.yml").write_text( "name: search\n", encoding="utf-8" ) (fixture_dir / "examples" / "skills" / "guide").mkdir(parents=True, exist_ok=True) (fixture_dir / "examples" / "skills" / "skill.yml").write_text( "name: skill\n", encoding="utf-8" ) context.repl_reference_fixture_dir = fixture_dir context.add_cleanup(lambda: shutil.rmtree(str(fixture_dir), ignore_errors=True)) @given("a temporary persona export path") def step_temp_persona_export_path(context: Context) -> None: export_path = Path("tmp-persona-export.yaml") context.repl_mode_export_path = export_path context.add_cleanup(lambda: export_path.unlink(missing_ok=True)) @given("an absolute persona export path") def step_absolute_persona_export_path(context: Context) -> None: export_path = Path(tempfile.mkdtemp()) / "persona-export.yaml" context.repl_mode_export_path = export_path context.add_cleanup( lambda: shutil.rmtree(str(export_path.parent), ignore_errors=True) ) @given("an invalid persona import file path") def step_invalid_persona_import_file(context: Context) -> None: invalid_path = Path("tmp-invalid-persona.yaml") invalid_path.write_text("- bad\n- yaml\n", encoding="utf-8") context.repl_mode_invalid_import_path = invalid_path context.add_cleanup(lambda: invalid_path.unlink(missing_ok=True)) @given("an absolute persona import file path") def step_absolute_persona_import_path(context: Context) -> None: temp_dir = Path(tempfile.mkdtemp()) input_path = temp_dir / "persona-import.yaml" input_path.write_text("name: x\nactor: local/mock-default\n", encoding="utf-8") context.repl_mode_absolute_import_path = input_path context.add_cleanup(lambda: shutil.rmtree(str(temp_dir), ignore_errors=True)) @when("I run the REPL with input lines") def step_run_repl_lines(context: Context) -> None: replacements: dict[str, str] = {} export_path = getattr(context, "repl_mode_export_path", None) if export_path is not None: replacements["export_path"] = str(export_path) invalid_import_path = getattr(context, "repl_mode_invalid_import_path", None) if invalid_import_path is not None: replacements["invalid_import_path"] = str(invalid_import_path) absolute_import_path = getattr(context, "repl_mode_absolute_import_path", None) if absolute_import_path is not None: replacements["absolute_import_path"] = str(absolute_import_path) lines = [] for row in context.table: line = row["line"] if replacements: line = line.format(**replacements) lines.append(line) code, output = _run_repl_capture(lines, context.repl_mode_config_dir) context.repl_mode_code = code context.repl_mode_output = output @then('the REPL mode output should contain "{text}"') def step_output_contains(context: Context, text: str) -> None: assert text in context.repl_mode_output, ( f"Expected {text!r} in output, got:\n{context.repl_mode_output}" ) @when('I expand REPL references for "{line}"') def step_expand_references(context: Context, line: str) -> None: fixture_dir = getattr(context, "repl_reference_fixture_dir", None) if fixture_dir is None: context.expanded_repl_line = _expand_references(line) return with patch("cleveragents.cli.commands.repl.Path.cwd", return_value=fixture_dir): context.expanded_repl_line = _expand_references(line) @then('the expanded REPL line should contain "{text}"') def step_expanded_contains(context: Context, text: str) -> None: assert text in context.expanded_repl_line, ( f"Expected {text!r} in expanded line: {context.expanded_repl_line}" ) @when('I validate CLI persona name "{name}"') def step_validate_cli_persona_name(context: Context, name: str) -> None: decoded = name.encode("utf-8").decode("unicode_escape") context.repl_cli_persona_error = None try: PersonaConfig(name=decoded, actor="local/mock-default") except ValidationError as exc: context.repl_cli_persona_error = exc @then('CLI persona validation should fail with "{message}"') def step_cli_persona_validation_fails(context: Context, message: str) -> None: assert context.repl_cli_persona_error is not None assert message in str(context.repl_cli_persona_error) @when('I validate CLI persona payload with color "{color}"') def step_validate_cli_persona_payload_with_color(context: Context, color: str) -> None: context.repl_cli_persona_payload = PersonaConfig.model_validate( {"name": "colorful", "actor": "local/mock-default", "color": color} ) @then('CLI persona color should be "{color}"') def step_cli_persona_color(context: Context, color: str) -> None: assert context.repl_cli_persona_payload.color == color @when('I parse persona create tokens "{command}"') def step_parse_persona_create_tokens(context: Context, command: str) -> None: context.repl_parse_error = None context.repl_parsed_persona = None text = command[1:] if command.startswith("/") else command tokens = text.split() try: context.repl_parsed_persona = _parse_persona_create(tokens) except Exception as exc: context.repl_parse_error = exc @then('parsed persona name should be "{name}"') def step_parsed_persona_name(context: Context, name: str) -> None: assert context.repl_parsed_persona is not None assert context.repl_parsed_persona.name == name @then('parsed persona actor should be "{actor}"') def step_parsed_persona_actor(context: Context, actor: str) -> None: assert context.repl_parsed_persona is not None assert context.repl_parsed_persona.actor == actor @then("parsed persona cycle order should be {cycle:d}") def step_parsed_persona_cycle(context: Context, cycle: int) -> None: assert context.repl_parsed_persona is not None assert context.repl_parsed_persona.cycle_order == cycle @then('persona create parse should fail with "{message}"') def step_parse_persona_create_fails(context: Context, message: str) -> None: assert context.repl_parse_error is not None assert message in str(context.repl_parse_error) def _run_shell_helper_with_capture(command: str) -> tuple[int, str]: output = StringIO() console = Console(file=output, no_color=True, width=180) with patch("cleveragents.cli.commands.repl._console", console): code = _run_shell_command(command) return code, output.getvalue() @when('I run REPL shell helper with command "{command}"') def step_run_shell_helper(context: Context, command: str) -> None: code, output = _run_shell_helper_with_capture(command) context.repl_shell_helper_code = code context.repl_shell_helper_output = output @when("I run REPL shell helper with empty command") def step_run_shell_helper_empty(context: Context) -> None: code, output = _run_shell_helper_with_capture("") context.repl_shell_helper_code = code context.repl_shell_helper_output = output @when('I run REPL shell helper with command "{command}" and shell disabled') def step_run_shell_helper_disabled(context: Context, command: str) -> None: with patch.dict(os.environ, {"CLEVERAGENTS_DISABLE_SHELL_MODE": "true"}): code, output = _run_shell_helper_with_capture(command) context.repl_shell_helper_code = code context.repl_shell_helper_output = output @when('I run REPL shell helper with command "{command}" and subprocess timeout') def step_run_shell_helper_timeout(context: Context, command: str) -> None: output = StringIO() console = Console(file=output, no_color=True, width=180) with ( patch("cleveragents.cli.commands.repl._console", console), patch( "cleveragents.cli.commands.repl.subprocess.run", side_effect=subprocess.TimeoutExpired(command, 30), ), ): context.repl_shell_helper_code = _run_shell_command(command) context.repl_shell_helper_output = output.getvalue() @when( 'I run REPL shell helper with command "{command}" and subprocess stderr "{stderr}" code {code:d}' ) def step_run_shell_helper_stderr( context: Context, command: str, stderr: str, code: int ) -> None: output = StringIO() console = Console(file=output, no_color=True, width=180) proc = subprocess.CompletedProcess( args=command, returncode=code, stdout="", stderr=stderr ) with ( patch("cleveragents.cli.commands.repl._console", console), patch("cleveragents.cli.commands.repl.subprocess.run", return_value=proc), ): context.repl_shell_helper_code = _run_shell_command(command) context.repl_shell_helper_output = output.getvalue() @then("REPL shell helper exit code should be {code:d}") def step_shell_helper_exit_code(context: Context, code: int) -> None: assert context.repl_shell_helper_code == code @then('REPL shell helper output should contain "{text}"') def step_shell_helper_output_contains(context: Context, text: str) -> None: assert text in context.repl_shell_helper_output class _SlashRegistryStub: def __init__(self) -> None: self.personas: dict[str, PersonaConfig] = {} self.active_name = "default" def list_personas(self) -> list[PersonaConfig]: return list(self.personas.values()) def get_persona(self, _name: str) -> PersonaConfig | None: return self.personas.get(_name) def set_active_persona_name(self, name: str) -> None: self.active_name = name def save_persona(self, persona: PersonaConfig) -> None: self.personas[persona.name] = persona def delete_persona(self, name: str) -> bool: return self.personas.pop(name, None) is not None def resolve_import_path(self, path: Path) -> Path: return path @given("slash command test sessions") def step_slash_test_sessions(context: Context) -> None: context.slash_registry = _SlashRegistryStub() context.slash_sessions = { "default": _ReplSessionState(name="default", active_persona="default") } context.slash_current_session = "default" def _handle_slash_with_capture( slash_text: str, *, registry: Any, sessions: Any, current_session: str, input_value: str | None = None, ) -> tuple[int, str, str]: output = StringIO() console = Console(file=output, no_color=True, width=180) with patch("cleveragents.cli.commands.repl._console", console): if input_value is None: code, new_session = _handle_slash_command( slash_text, registry=registry, sessions=sessions, current_session=current_session, ) else: with patch("builtins.input", return_value=input_value): code, new_session = _handle_slash_command( slash_text, registry=registry, sessions=sessions, current_session=current_session, ) return code, new_session, output.getvalue() @when('I handle slash text with parser error "{slash_text}"') def step_handle_slash_parser_error(context: Context, slash_text: str) -> None: code, new_session, output = _handle_slash_with_capture( slash_text, registry=context.slash_registry, sessions=context.slash_sessions, current_session=context.slash_current_session, ) context.slash_handler_code = code context.slash_handler_session = new_session context.slash_handler_output = output @when("I handle slash text that raises parser error") def step_handle_slash_parser_error_forced(context: Context) -> None: output = StringIO() console = Console(file=output, no_color=True, width=180) with ( patch("cleveragents.cli.commands.repl._console", console), patch( "cleveragents.cli.commands.repl.shlex.split", side_effect=ValueError("forced parser failure"), ), ): code, new_session = _handle_slash_command( "persona test", registry=context.slash_registry, sessions=context.slash_sessions, current_session=context.slash_current_session, ) context.slash_handler_code = code context.slash_handler_session = new_session context.slash_handler_output = output.getvalue() @when('I handle slash text "{slash_text}"') def step_handle_slash_text(context: Context, slash_text: str) -> None: code, new_session, output = _handle_slash_with_capture( slash_text, registry=context.slash_registry, sessions=context.slash_sessions, current_session=context.slash_current_session, ) context.slash_handler_code = code context.slash_handler_session = new_session context.slash_handler_output = output @when('I handle picker slash text "{slash_text}" using input "{input_value}"') def step_handle_slash_text_with_input( context: Context, slash_text: str, input_value: str ) -> None: code, new_session, output = _handle_slash_with_capture( slash_text, registry=context.slash_registry, sessions=context.slash_sessions, current_session=context.slash_current_session, input_value=input_value, ) context.slash_handler_code = code context.slash_handler_session = new_session context.slash_handler_output = output @when("I handle empty slash text") def step_handle_empty_slash_text(context: Context) -> None: code, new_session, output = _handle_slash_with_capture( "", registry=context.slash_registry, sessions=context.slash_sessions, current_session=context.slash_current_session, ) context.slash_handler_code = code context.slash_handler_session = new_session context.slash_handler_output = output @then("slash handler exit code should be {code:d}") def step_slash_handler_code(context: Context, code: int) -> None: assert context.slash_handler_code == code @then('slash handler output should contain "{text}"') def step_slash_handler_output_contains(context: Context, text: str) -> None: assert text in context.slash_handler_output @given("slash command test registry with personas") def step_slash_registry_with_personas(context: Context) -> None: context.slash_registry.save_persona( PersonaConfig(name="dev", actor="local/mock-default") ) context.slash_registry.save_persona( PersonaConfig(name="reviewer", actor="local/mock-default") ) @when("I build REPL reference catalog from fixture") def step_build_repl_reference_catalog_fixture(context: Context) -> None: context.repl_reference_catalog = {} outside_root = Path("/tmp/repl-outside-root") def fake_walk(_cwd: Path, followlinks: bool = False): yield ( str(context.repl_reference_fixture_dir), ["examples", ".git"], ["README.md"], ) yield str(outside_root), [], ["outside.txt"] with ( patch( "cleveragents.cli.commands.repl.Path.cwd", return_value=context.repl_reference_fixture_dir, ), patch("cleveragents.cli.commands.repl.os.walk", side_effect=fake_walk), patch.dict( os.environ, {"CLEVERAGENTS_PROJECT": "local/proj", "CLEVERAGENTS_PLAN": "local/plan"}, clear=False, ), ): context.repl_reference_catalog = _build_reference_catalog() @then("REPL catalog should include fixture actor tool skill and env entries") def step_catalog_includes_fixture_entries(context: Context) -> None: catalog = context.repl_reference_catalog assert "local/review" in catalog["actor"] assert "local/search" in catalog["tool"] assert "local/guide" in catalog["skill"] assert "local/skill" in catalog["skill"] assert catalog["project"] == ["local/proj"] assert catalog["plan"] == ["local/plan"] @then("REPL catalog should include non-relative walk file path") def step_catalog_has_non_relative_path(context: Context) -> None: assert any( value.endswith("/outside.txt") for value in context.repl_reference_catalog["file"] ) @when("I evaluate REPL fuzzy and suggestion helpers") def step_eval_repl_fuzzy_helpers(context: Context) -> None: catalog = { "file": ["README.md", "docs/guide.md"], "actor": ["local/reviewer"], "tool": ["local/searcher"], "skill": ["local/guide"], "project": ["local/proj"], "plan": ["local/plan"], } context.repl_helper_checks = [ _best_fuzzy_match("", ["x"]) is None, _best_fuzzy_match("REA", ["README.md"]) == "README.md", _best_fuzzy_match("guide", ["docs/guide.md"]) == "docs/guide.md", _best_fuzzy_match("reviwer", ["local/reviewer"]) == "local/reviewer", _find_reference_candidates("", ["a", "b"], limit=1) == ["a"], _expand_references( "echo \\@literal @file:README @zzz", catalog=catalog ).startswith("echo @literal @file:"), len(_reference_suggestions("inspect @file:READ", catalog=catalog)) > 0, len(_reference_suggestions("inspect @rev", catalog=catalog)) > 0, _reference_suggestions("inspect \\@file:README", catalog=catalog) == [], ] @then("REPL helper evaluation should pass") def step_repl_helper_eval_pass(context: Context) -> None: assert all(context.repl_helper_checks)