Files
2026-07-13 13:12:00 +08:00

249 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""End-to-end coverage for the REPL "!" shell passthrough.
The unit suite (``tests/repl/test_bang_command.py``) covers the extracted
helpers — the clip/context builders and the ``_run_bang_command`` runner. What
it cannot reach is the wiring inside ``run_repl.on_input``: the ``!`` / ``!!`` /
bare-``!`` dispatch and the buffer that folds a command's output into the *next*
agent turn. Those only exist as a closure over the live REPL, so they are
exercised here by driving the real ``omnigent run`` REPL under a PTY and
inspecting what the model actually received via the mock LLM server.
**What breaks if these fail:**
- ``!<cmd>`` stops running in the shell / stops rendering its output + footer.
- A bare ``!<cmd>`` wrongly costs a model turn (or silently drops its output
instead of folding it into the next turn's ``llm_text``).
- The bare ``!`` usage hint or the ``!!`` literal-escape routing regresses.
"""
from __future__ import annotations
import json
from pathlib import Path
import httpx
from tests.e2e.omnigent._pexpect_harness import (
await_turn_complete,
clean_exit,
spawn_omnigent_run,
strip_ansi,
submit_prompt,
)
from tests.e2e.omnigent._repl_test_helpers import drain_for
from tests.e2e.omnigent.conftest import configure_mock_llm, reset_mock_llm
_MODEL = "mock-model"
_HARNESS = "openai-agents"
# Visible turn-synchronization markers — same ones the green smoke / model /
# effort e2e tests use. ``working`` is the streaming activity line; `` `` is
# the input prompt the REPL re-renders when a turn settles.
_RUNNING_MARKER = r"working"
_COMPLETION_MARKER = r" "
_SPAWN_TIMEOUT = 60.0
_BOOT_TIMEOUT = 30.0
_RUNNING_TIMEOUT = 20.0
_COMPLETION_TIMEOUT = 60.0
_BANG_TIMEOUT = 20.0
_EXIT_TIMEOUT = 15.0
def _captured_requests(mock_llm_server_url: str) -> list[dict]:
resp = httpx.get(f"{mock_llm_server_url}/mock/requests", timeout=5.0)
resp.raise_for_status()
return resp.json()["requests"]
def test_repl_bang_runs_and_folds_into_next_turn(
omnigent_python: Path,
omnigent_repo_root: Path,
mock_credentials_env: dict[str, str],
mock_llm_server_url: str,
) -> None:
"""
``!echo`` runs locally, renders its output, costs no model turn, and its
output is folded into the following prompt's request to the model.
"""
reset_mock_llm(mock_llm_server_url)
configure_mock_llm(mock_llm_server_url, [{"text": "Understood."}])
yaml_path = omnigent_repo_root / "tests" / "resources" / "examples" / "hello_world.yaml"
child = spawn_omnigent_run(
omnigent_python=omnigent_python,
yaml_path=yaml_path,
model=_MODEL,
harness=_HARNESS,
env=mock_credentials_env,
cwd=omnigent_repo_root,
timeout=_SPAWN_TIMEOUT,
)
try:
child.expect(_COMPLETION_MARKER, timeout=_BOOT_TIMEOUT)
# A "!" command runs in the shell and prints its own exit footer; it
# does NOT enter a model turn, so synchronize on the footer, not on
# "working".
submit_prompt(child, "!echo BANG_SENTINEL_XYZ")
child.expect(r"exit 0", timeout=_BANG_TIMEOUT)
bang_render = strip_ansi(child.before or "") + strip_ansi(drain_for(child, 2.0))
assert "BANG_SENTINEL_XYZ" in bang_render, (
"bang command stdout not rendered on screen:\n" + bang_render[-1500:]
)
# The bang alone must not have hit the model.
assert _captured_requests(mock_llm_server_url) == [], (
"a bare '!' command wrongly triggered a model request"
)
# Now a normal prompt: the buffered bang output must be folded into it.
submit_prompt(child, "what did that print?")
await_turn_complete(
child,
running_timeout=_RUNNING_TIMEOUT,
completion_timeout=_COMPLETION_TIMEOUT,
running_marker=_RUNNING_MARKER,
completion_pattern=_COMPLETION_MARKER,
)
clean_exit(child, timeout=_EXIT_TIMEOUT)
finally:
if not child.closed:
child.close(force=True)
requests = _captured_requests(mock_llm_server_url)
assert requests, "the follow-up prompt never reached the model"
blob = json.dumps(requests)
assert "BANG_SENTINEL_XYZ" in blob, (
"bang stdout was not folded into the next turn's model request:\n" + blob[-2000:]
)
assert "I ran a shell command" in blob, (
"the model-facing bang context header is missing from the request"
)
assert "what did that print?" in blob, (
"the user's follow-up prompt is missing from the model request"
)
def test_repl_bang_bare_shows_hint_and_double_bang_escapes(
omnigent_python: Path,
omnigent_repo_root: Path,
mock_credentials_env: dict[str, str],
mock_llm_server_url: str,
) -> None:
"""
A bare ``!`` prints a usage hint and costs no model turn; ``!!text`` drops
one ``!`` and is sent to the model as the literal prompt ``!text`` (it is
NOT executed as a shell command).
"""
reset_mock_llm(mock_llm_server_url)
configure_mock_llm(mock_llm_server_url, [{"text": "Ok."}])
yaml_path = omnigent_repo_root / "tests" / "resources" / "examples" / "hello_world.yaml"
child = spawn_omnigent_run(
omnigent_python=omnigent_python,
yaml_path=yaml_path,
model=_MODEL,
harness=_HARNESS,
env=mock_credentials_env,
cwd=omnigent_repo_root,
timeout=_SPAWN_TIMEOUT,
)
try:
child.expect(_COMPLETION_MARKER, timeout=_BOOT_TIMEOUT)
submit_prompt(child, "!")
child.expect(r"runs a shell command", timeout=_BANG_TIMEOUT)
assert _captured_requests(mock_llm_server_url) == [], (
"a bare '!' usage hint wrongly triggered a model request"
)
# "!!" escapes: one "!" is stripped and the rest is an ordinary prompt.
submit_prompt(child, "!!literal-bang-prompt")
turn = await_turn_complete(
child,
running_timeout=_RUNNING_TIMEOUT,
completion_timeout=_COMPLETION_TIMEOUT,
running_marker=_RUNNING_MARKER,
completion_pattern=_COMPLETION_MARKER,
)
clean_exit(child, timeout=_EXIT_TIMEOUT)
finally:
if not child.closed:
child.close(force=True)
requests = _captured_requests(mock_llm_server_url)
assert requests, "the '!!'-escaped prompt never reached the model"
blob = json.dumps(requests)
# Exactly one leading "!" survives the escape.
assert "!literal-bang-prompt" in blob, (
"'!!' did not send a literal single-'!' prompt to the model:\n" + blob[-2000:]
)
# The escaped prompt must NOT have been run as a shell command.
assert "I ran a shell command" not in blob, (
"'!!' prompt was wrongly executed as a shell command"
)
assert "exit 0" not in turn.stripped, (
"'!!' prompt rendered a shell exit footer — it was executed, not escaped"
)
def test_repl_bang_buffer_dropped_on_new_conversation(
omnigent_python: Path,
omnigent_repo_root: Path,
mock_credentials_env: dict[str, str],
mock_llm_server_url: str,
) -> None:
"""
Buffered ``!`` output is discarded when a new conversation starts (``/clear``)
— it belonged to the prior conversation and must not leak into the fresh
conversation's first turn.
"""
reset_mock_llm(mock_llm_server_url)
configure_mock_llm(mock_llm_server_url, [{"text": "Ok."}])
yaml_path = omnigent_repo_root / "tests" / "resources" / "examples" / "hello_world.yaml"
child = spawn_omnigent_run(
omnigent_python=omnigent_python,
yaml_path=yaml_path,
model=_MODEL,
harness=_HARNESS,
env=mock_credentials_env,
cwd=omnigent_repo_root,
timeout=_SPAWN_TIMEOUT,
)
try:
child.expect(_COMPLETION_MARKER, timeout=_BOOT_TIMEOUT)
submit_prompt(child, "!echo LEAK_SENTINEL_QQQ")
child.expect(r"exit 0", timeout=_BANG_TIMEOUT)
# /clear starts a new conversation — it must drop the buffered output.
# Re-sync on the freshly re-rendered prompt before sending the next
# line, otherwise it races the /clear redraw and never submits.
submit_prompt(child, "/clear")
child.expect(_COMPLETION_MARKER, timeout=_BOOT_TIMEOUT)
drain_for(child, 1.5)
submit_prompt(child, "hello fresh conversation")
await_turn_complete(
child,
running_timeout=_RUNNING_TIMEOUT,
completion_timeout=_COMPLETION_TIMEOUT,
running_marker=_RUNNING_MARKER,
completion_pattern=_COMPLETION_MARKER,
)
clean_exit(child, timeout=_EXIT_TIMEOUT)
finally:
if not child.closed:
child.close(force=True)
requests = _captured_requests(mock_llm_server_url)
assert requests, "the post-/clear prompt never reached the model"
blob = json.dumps(requests)
assert "LEAK_SENTINEL_QQQ" not in blob, (
"buffered bang output leaked into the new conversation after /clear:\n" + blob[-2000:]
)
assert "hello fresh conversation" in blob, (
"the post-/clear prompt is missing from the model request"
)