Files
cleveragents-core/features/steps/repl_input_modes_steps.py
aditya b8f9da4cca
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 16s
CI / build (pull_request) Successful in 28s
CI / quality (pull_request) Successful in 35s
CI / security (pull_request) Successful in 43s
CI / typecheck (pull_request) Successful in 45s
CI / unit_tests (pull_request) Successful in 3m4s
CI / integration_tests (pull_request) Successful in 3m43s
CI / e2e_tests (pull_request) Successful in 3m48s
CI / docker (pull_request) Successful in 55s
CI / coverage (pull_request) Successful in 6m54s
CI / benchmark-regression (pull_request) Successful in 38m2s
fix(tui/persona): lock delete operation in registry
Wrap `PersonaRegistry.delete()` with the personas file lock and use race-safe `unlink` handling for concurrent save/delete consistency.
2026-03-18 10:09:06 +00:00

540 lines
20 KiB
Python

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