Files
cleveragents-core/features/steps/repl_steps.py
T
brent.edwards f48eb5a05f
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / quality (pull_request) Successful in 19s
CI / build (pull_request) Successful in 24s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Successful in 43s
CI / integration_tests (pull_request) Successful in 2m37s
CI / unit_tests (pull_request) Successful in 6m12s
CI / docker (pull_request) Successful in 1m1s
CI / benchmark-regression (pull_request) Successful in 15m33s
CI / coverage (pull_request) Successful in 21m54s
feat(cli): add interactive repl
Implement `agents repl` command providing an interactive read-eval-print
loop that dispatches to existing CLI commands without the `agents` prefix.

Features:
- Command dispatch via existing Typer app
- readline history with --no-history opt-out (default: ~/.cleveragents/history)
- Tab-completion for all CLI commands and built-in commands
- !! last-command repetition
- Prompt context from CLEVERAGENTS_PROJECT / CLEVERAGENTS_PLAN env vars
- Ctrl+C (continue) and Ctrl+D (exit) signal handling
- Multi-line input with \ continuation
- :help and :exit/:quit built-in commands

Test coverage:
- 15 Behave scenarios (features/repl.feature)
- 10 Robot Framework smoke tests (robot/repl_smoke.robot)
- ASV benchmark suite (benchmarks/repl_bench.py)
- All nox sessions pass: lint, typecheck, unit_tests, integration_tests
- Coverage: 97.2% (threshold: 97%)
2026-02-23 03:11:12 +00:00

256 lines
7.8 KiB
Python

"""Behave step implementations for the REPL feature."""
from __future__ import annotations
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from behave import given, then, when
from behave.runner import Context
from cleveragents.cli.commands.repl import (
_get_prompt_context,
run_repl,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeInput:
"""Simulate user input for the REPL by feeding lines then raising EOFError."""
def __init__(self, lines: list[str]) -> None:
self._lines = list(lines)
self._index = 0
def __call__(self, prompt: str = "") -> str:
if self._index >= len(self._lines):
raise EOFError
line = self._lines[self._index]
self._index += 1
if line == "__KEYBOARD_INTERRUPT__":
raise KeyboardInterrupt
return line
def _run_repl_with_input(
lines: list[str],
*,
no_history: bool = False,
history_path: Path | None = None,
) -> tuple[int, str]:
"""Run the REPL with simulated input and capture console output."""
from io import StringIO
from rich.console import Console
buf = StringIO()
console = Console(file=buf, no_color=True, width=200)
kw: dict[str, object] = {"no_history": no_history}
if history_path is not None:
kw["history_path"] = history_path
fake = _FakeInput(lines)
with (
patch("cleveragents.cli.commands.repl._console", console),
patch("builtins.input", fake),
):
code = run_repl(**kw) # type: ignore[arg-type]
return code, buf.getvalue()
# ---------------------------------------------------------------------------
# Given
# ---------------------------------------------------------------------------
@given("the REPL is started")
def step_repl_started(context: Context) -> None:
context.repl_lines = []
context.repl_output = ""
context.repl_exit_code = 0
context.repl_no_history = True # default to no history in tests
context.repl_history_path = None
@given("the REPL is started with --no-history")
def step_repl_started_no_history(context: Context) -> None:
context.repl_lines = []
context.repl_output = ""
context.repl_exit_code = 0
context.repl_no_history = True
context.repl_history_path = None
@given("the REPL is started with a custom history path")
def step_repl_started_custom_history(context: Context) -> None:
context.repl_lines = []
context.repl_output = ""
context.repl_exit_code = 0
context.repl_no_history = False
tmpdir = Path(tempfile.mkdtemp())
context.repl_history_path = tmpdir / "test_history"
context.add_cleanup(lambda: shutil.rmtree(str(tmpdir), ignore_errors=True))
# ---------------------------------------------------------------------------
# When
# ---------------------------------------------------------------------------
@when('I send the input "{text}"')
def step_send_input(context: Context, text: str) -> None:
context.repl_lines.append(text)
# Execute REPL with accumulated lines
code, output = _run_repl_with_input(
context.repl_lines,
no_history=context.repl_no_history,
history_path=context.repl_history_path,
)
context.repl_exit_code = code
context.repl_output = output
@when("I send a multi-line version command")
def step_send_multiline_version(context: Context) -> None:
# Simulate typing ``version \`` followed by an empty continuation line
context.repl_lines.append("version \\")
context.repl_lines.append("")
code, output = _run_repl_with_input(
context.repl_lines,
no_history=context.repl_no_history,
history_path=context.repl_history_path,
)
context.repl_exit_code = code
context.repl_output = output
@when("I send an empty input line")
def step_send_empty_input(context: Context) -> None:
context.repl_lines.append("")
code, output = _run_repl_with_input(
context.repl_lines,
no_history=context.repl_no_history,
history_path=context.repl_history_path,
)
context.repl_exit_code = code
context.repl_output = output
@when("the REPL exits")
def step_repl_exits(context: Context) -> None:
# Re-run with the accumulated lines (EOFError ends the REPL)
code, output = _run_repl_with_input(
context.repl_lines,
no_history=context.repl_no_history,
history_path=context.repl_history_path,
)
context.repl_exit_code = code
context.repl_output = output
@when("I query the REPL prompt context")
def step_query_prompt(context: Context) -> None:
context.repl_prompt = _get_prompt_context()
@when("I simulate a KeyboardInterrupt during input")
def step_simulate_ctrl_c(context: Context) -> None:
# Send a keyboard interrupt marker, then :exit
context.repl_lines = ["__KEYBOARD_INTERRUPT__", ":exit"]
code, output = _run_repl_with_input(
context.repl_lines,
no_history=True,
)
context.repl_exit_code = code
context.repl_output = output
@when("I simulate an EOFError during input")
def step_simulate_ctrl_d(context: Context) -> None:
# Empty lines list → immediate EOFError → clean exit
context.repl_lines = []
code, output = _run_repl_with_input(
context.repl_lines,
no_history=True,
)
context.repl_exit_code = code
context.repl_output = output
# ---------------------------------------------------------------------------
# Then
# ---------------------------------------------------------------------------
@then('the REPL output should contain "{text}"')
def step_output_contains(context: Context, text: str) -> None:
assert text in context.repl_output, (
f"Expected '{text}' in output but got:\n{context.repl_output}"
)
@then("the REPL exit code should be {code:d}")
def step_exit_code(context: Context, code: int) -> None:
assert context.repl_exit_code == code, (
f"Expected exit code {code} but got {context.repl_exit_code}"
)
@then("the REPL should exit cleanly")
def step_exit_cleanly(context: Context) -> None:
# A clean exit means exit code 0
if not hasattr(context, "repl_exit_code") or context.repl_exit_code is None:
# Run with empty input to trigger EOFError → clean exit
code, output = _run_repl_with_input(
context.repl_lines,
no_history=True,
)
context.repl_exit_code = code
context.repl_output = output
assert context.repl_exit_code == 0, (
f"Expected clean exit (code 0) but got {context.repl_exit_code}"
)
@then("the REPL should continue running")
def step_repl_continues(context: Context) -> None:
# After Ctrl+C the REPL should still exit cleanly (via :exit or EOFError)
assert context.repl_exit_code == 0, (
f"REPL did not continue after interrupt (exit code {context.repl_exit_code})"
)
@then("the REPL should continue without error")
def step_repl_continues_no_error(context: Context) -> None:
assert context.repl_exit_code == 0
@then("no history file should be written")
def step_no_history_file(context: Context) -> None:
if context.repl_history_path is not None:
assert not context.repl_history_path.exists(), (
f"History file should not exist but found: {context.repl_history_path}"
)
@then("the history file should exist")
def step_history_exists(context: Context) -> None:
assert context.repl_history_path is not None
assert context.repl_history_path.exists(), (
f"History file should exist at {context.repl_history_path}"
)
@then('the prompt should contain "{text}"')
def step_prompt_contains(context: Context, text: str) -> None:
assert text in context.repl_prompt, (
f"Expected '{text}' in prompt '{context.repl_prompt}'"
)