1707 lines
66 KiB
Python
1707 lines
66 KiB
Python
"""
|
|
REPL approval-flow e2e test.
|
|
|
|
Spawns ``omnigent chat examples/agents/ask-demo/`` as a subprocess
|
|
under a pseudo-TTY (pexpect), feeds real input, and asserts
|
|
the agent responds after the user approves a policy ASK.
|
|
This exercises the full Phase 10 path — prompt_toolkit's
|
|
real input loop, the SSE stream consuming ``ElicitationRequest``
|
|
events, the REPL's future-based approval wiring, and the
|
|
server PATCHing the verdict back through the durable
|
|
session workflow.
|
|
|
|
Unlike ``test_policies_e2e.py`` (polling API, background=True),
|
|
this test drives the REPL through the actual streaming code
|
|
path — the code path a human types into at the terminal.
|
|
|
|
All 14 tests run against the mock LLM server: ``OPENAI_BASE_URL``
|
|
is injected into the REPL subprocess's environment so the inner
|
|
OpenAI harness routes to the mock server. Each test pre-configures
|
|
the mock's keyed response queue before spawning the subprocess.
|
|
|
|
Prerequisites:
|
|
- ``pexpect`` installed (4.9+).
|
|
- ``ap`` on ``PATH`` resolving to this worktree's entry
|
|
point (set ``PYTHONPATH`` so the editable install from
|
|
a sibling worktree doesn't shadow it).
|
|
|
|
Usage::
|
|
|
|
python -m pytest tests/e2e/test_repl_approval_e2e.py -v --timeout=180
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from tests.e2e.conftest import (
|
|
configure_mock_llm,
|
|
get_mock_requests,
|
|
reset_mock_llm,
|
|
)
|
|
|
|
pexpect = pytest.importorskip("pexpect")
|
|
|
|
_ASK_DEMO_DIR = Path(__file__).resolve().parents[1] / "resources" / "agents" / "ask-demo"
|
|
_FIXTURES_DIR = Path(__file__).resolve().parents[1] / "_fixtures" / "agents"
|
|
_TOOL_GATE_DIR = _FIXTURES_DIR / "e2e-tool-gate"
|
|
_SUBAGENT_GATE_DIR = _FIXTURES_DIR / "e2e-subagent-gate"
|
|
_LABEL_ASK_GATE_DIR = _FIXTURES_DIR / "e2e-label-ask-gate"
|
|
_OUTPUT_GATE_DIR = _FIXTURES_DIR / "e2e-output-gate"
|
|
_TOOL_RESULT_GATE_DIR = _FIXTURES_DIR / "e2e-tool-result-gate"
|
|
_SUBAGENT_TOOL_GATE_DIR = _FIXTURES_DIR / "e2e-subagent-tool-gate"
|
|
|
|
# Seconds to wait for ``omnigent run`` to reach an input-ready REPL —
|
|
# the LAUNCH phase only (daemon spawn, local-server boot, agent upload,
|
|
# runner bring-up, session attach, wrapper-redirect probe).
|
|
#
|
|
# This MUST exceed the CLI's own internal cold-start budget, which is
|
|
# sequential on the critical path of every launch
|
|
# (``_prepare_chat_session_via_daemon`` in omnigent/chat.py):
|
|
#
|
|
# wait_for_host_online up to 30s (_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S)
|
|
# launch_or_reuse_daemon_runner ~16.5s (transient-409 host-reconnect retry)
|
|
# wait_for_runner_online up to 60s (_DAEMON_CHAT_RUNNER_ONLINE_TIMEOUT_S)
|
|
# ≈ 106s worst case
|
|
#
|
|
# The old 60s sat *below* that budget, so on the rare slow path (loaded
|
|
# CI runner, host-tunnel reconnect) the test aborted — still animating
|
|
# the "Launching your agent…" spinner, before the approval path was
|
|
# reached — before the CLI itself would have. This value tracks the
|
|
# internal budget + margin, NOT an arbitrary inflation: the median
|
|
# launch is a few seconds, so this ceiling only bites on the tail. To
|
|
# lower it, first lower the internal timeouts above (they guard real
|
|
# users on cold/slow hosts). Deliberately separate from the post-launch
|
|
# assertion timeouts (approval, echo, turn-complete), which stay tight
|
|
# so a real hang *after* launch still fails fast. Kept under the
|
|
# ``--timeout=180`` per-test cap.
|
|
_LAUNCH_TIMEOUT = 120
|
|
|
|
# Regex to strip ANSI escape codes from pexpect output before
|
|
# asserting. prompt_toolkit emits heavy styling — searching for
|
|
# substrings ("approval required", "Hi") against the raw bytes
|
|
# finds them most of the time but is flaky on split sequences.
|
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
|
|
|
|
|
def _strip_ansi(text: str) -> str:
|
|
"""
|
|
Remove ANSI escape codes from a pexpect buffer slice.
|
|
|
|
:param text: Captured output with escape sequences.
|
|
:returns: Plain text suitable for substring assertions.
|
|
"""
|
|
return _ANSI_RE.sub("", text)
|
|
|
|
|
|
def _wait_for_function_call_outputs(
|
|
mock_llm_server_url: str,
|
|
*,
|
|
timeout: float = 120.0,
|
|
poll_interval: float = 0.5,
|
|
) -> str:
|
|
"""
|
|
Poll the mock server until the tool round-trip's
|
|
``function_call_output`` is recorded, then return the joined outputs.
|
|
|
|
Waits on the *exact* post-condition the callers assert on (a
|
|
recorded ``function_call_output``) rather than sampling
|
|
:func:`get_mock_requests` once after a proxy signal (the follow-up
|
|
text rendering). The REPL can render the follow-up reply a beat
|
|
before the mock server finishes persisting the request that carried
|
|
the output, so a single sample races and returns ``''`` (~3% flake
|
|
observed on CI shard 2). Polling the real signal removes the race;
|
|
``timeout`` is only a safety cap, not the thing we time against.
|
|
|
|
:param mock_llm_server_url: Mock server URL.
|
|
:param timeout: Max seconds to wait for the output to be recorded.
|
|
:param poll_interval: Seconds between polls.
|
|
:returns: Space-joined ``function_call_output`` values (``''`` if
|
|
none were recorded within ``timeout``).
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
reqs = get_mock_requests(mock_llm_server_url)
|
|
outputs = [
|
|
item.get("output", "")
|
|
for req in reqs
|
|
for item in (req.get("input") or [])
|
|
if isinstance(item, dict) and item.get("type") == "function_call_output"
|
|
]
|
|
joined = " ".join(str(o) for o in outputs)
|
|
if outputs or time.monotonic() >= deadline:
|
|
return joined
|
|
time.sleep(poll_interval)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def repl_env(
|
|
llm_api_key: str,
|
|
mock_llm_server_url: str,
|
|
tmp_path_factory: pytest.TempPathFactory,
|
|
) -> dict[str, str]:
|
|
"""
|
|
Build the env dict for ``omnigent chat`` — OPENAI_API_KEY plus
|
|
whatever PYTHONPATH the outer shell already provides (so
|
|
``omnigent`` + ``omnigent_client`` resolve to this
|
|
worktree, not the sibling editable install).
|
|
|
|
Redirects ``HOME`` to a temp dir seeded with
|
|
``.omnigent/config.yaml`` so the spawned interactive REPL starts
|
|
cleanly under pexpect:
|
|
|
|
- ``tui.theme`` is persisted, so the REPL's first-launch theme
|
|
picker (``_repl._load_startup_theme`` → ``startup_theme_picker``,
|
|
which reads ``$HOME/.omnigent/config.yaml`` — NOT
|
|
``OMNIGENT_CONFIG_HOME``) is skipped. Under pexpect's pty stdin
|
|
is a tty, so without a persisted theme the arrow-key picker blocks
|
|
and the welcome banner never appears (the CI failure, where
|
|
``$HOME`` is fresh).
|
|
- ``auto_open_conversation: false`` stops the interactive REPL from
|
|
opening a browser tab per run (``--no-open`` is not a valid
|
|
``run`` flag; config is the supported path). ``OMNIGENT_CONFIG_HOME``
|
|
points at the same dir so the CLI reads it too.
|
|
|
|
Because ``HOME`` is redirected, ``DATABRICKS_CONFIG_FILE`` is pinned
|
|
to the real ``~/.databrickscfg`` so ``--profile`` lookups still
|
|
resolve, and ``OMNIGENT_SKIP_ONBOARD`` guards against any other
|
|
first-run prompt (these tests exercise REPL approval, not onboarding).
|
|
|
|
``OPENAI_BASE_URL`` is pointed at the session-scoped mock LLM
|
|
server so the REPL subprocess's inner OpenAI harness routes all
|
|
completions through the mock instead of hitting ``api.openai.com``.
|
|
|
|
:param llm_api_key: The API key for the LLM (``"mock-key"`` in
|
|
mock mode).
|
|
:param mock_llm_server_url: Base URL of the mock LLM server,
|
|
e.g. ``"http://127.0.0.1:12345"``.
|
|
:param tmp_path_factory: Pytest temp-path factory for the fake HOME.
|
|
:returns: Env mapping for ``pexpect.spawn``.
|
|
"""
|
|
real_databrickscfg = Path.home() / ".databrickscfg"
|
|
fake_home = tmp_path_factory.mktemp("repl_home")
|
|
config_home = fake_home / ".omnigent"
|
|
config_home.mkdir(parents=True, exist_ok=True)
|
|
(config_home / "config.yaml").write_text(
|
|
"auto_open_conversation: false\ntui:\n theme: dark\n"
|
|
)
|
|
env: dict[str, str] = {
|
|
**os.environ,
|
|
"OPENAI_API_KEY": llm_api_key,
|
|
# Point the inner OpenAI harness at the mock LLM server.
|
|
# The SDK appends /responses to the base URL, so include /v1.
|
|
"OPENAI_BASE_URL": f"{mock_llm_server_url}/v1",
|
|
"HOME": str(fake_home),
|
|
"OMNIGENT_CONFIG_HOME": str(config_home),
|
|
"DATABRICKS_CONFIG_FILE": str(real_databrickscfg),
|
|
"OMNIGENT_SKIP_ONBOARD": "1",
|
|
# Force ANSI on — pexpect captures everything, stripping
|
|
# happens per-assertion via _strip_ansi.
|
|
"TERM": "xterm-256color",
|
|
# Disable prompt_toolkit's alt-screen / mouse reporting
|
|
# so the buffer doesn't fill with cursor-position-query
|
|
# sequences that throw off expect matches.
|
|
"PROMPT_TOOLKIT_NO_CPR": "1",
|
|
}
|
|
return env
|
|
|
|
|
|
def _configure_mock_text(
|
|
mock_llm_server_url: str, texts: list[str], *, match: str | None = None
|
|
) -> None:
|
|
"""
|
|
Pre-load the mock LLM server with simple text responses.
|
|
|
|
Resets all queues first, then configures a ``"default"`` queue
|
|
with one ``QueuedResponse`` per string in *texts*. All agent
|
|
fixtures use ``model: gpt-4o``, which falls through to the
|
|
``"default"`` queue on the mock server.
|
|
|
|
:param mock_llm_server_url: Mock server base URL.
|
|
:param texts: Ordered list of response texts the mock should
|
|
return, one per LLM call.
|
|
:param match: Optional content-routing token (the unique user
|
|
message this test sends). When set, these responses are served
|
|
only to requests whose user input contains the token, isolating
|
|
this test's queue from a stray/late request fired by another
|
|
test on the shared mock (#523 cross-test contamination).
|
|
"""
|
|
reset_mock_llm(mock_llm_server_url)
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[{"text": t} for t in texts],
|
|
match=match,
|
|
)
|
|
|
|
|
|
def _configure_mock_tool_then_text(
|
|
mock_llm_server_url: str,
|
|
tool_calls: list[dict[str, str]],
|
|
follow_up_text: str,
|
|
*,
|
|
match: str | None = None,
|
|
) -> None:
|
|
"""
|
|
Configure a tool-call response followed by a text response.
|
|
|
|
The first LLM call returns a function_call; after the tool
|
|
executes and the result is sent back, the second LLM call
|
|
returns a plain text reply.
|
|
|
|
:param mock_llm_server_url: Mock server base URL.
|
|
:param tool_calls: Tool call dicts (``call_id``, ``name``,
|
|
``arguments``).
|
|
:param follow_up_text: Text for the second LLM call.
|
|
:param match: Optional content-routing token (the unique user
|
|
message this test sends). When set, these responses are served
|
|
only to requests whose user input contains the token, isolating
|
|
this test's queue from a stray/late request fired by another
|
|
test on the shared mock (#523 cross-test contamination).
|
|
"""
|
|
reset_mock_llm(mock_llm_server_url)
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
{"tool_calls": tool_calls},
|
|
{"text": follow_up_text},
|
|
],
|
|
match=match,
|
|
)
|
|
|
|
|
|
def _require_omnigent_cli() -> str:
|
|
"""
|
|
Resolve the CLI path. Prefers the framework's own
|
|
``omnigent`` binary (via the running pytest interpreter's
|
|
venv) over a sibling ``ap`` binary on PATH — the legacy
|
|
``omnigent`` ``ap`` CLI doesn't understand
|
|
Omnigent-format fixtures.
|
|
|
|
:returns: Absolute path to an executable.
|
|
"""
|
|
venv_omnigent = Path(sys.executable).parent / "omnigent"
|
|
if venv_omnigent.exists():
|
|
return str(venv_omnigent)
|
|
path = shutil.which("omnigent") or shutil.which("ap")
|
|
if path is None:
|
|
pytest.skip("Neither omnigent nor omnigent CLI on PATH")
|
|
return path
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def ap_cli() -> str:
|
|
"""Session-scoped resolved ``ap`` binary."""
|
|
return _require_omnigent_cli()
|
|
|
|
|
|
def _wait_for_prompt_ready(
|
|
child: Any,
|
|
timeout: float = 30.0,
|
|
welcome_pattern: str = "ask.demo",
|
|
) -> None:
|
|
"""
|
|
Wait until the REPL is ready for input.
|
|
|
|
``omnigent chat <path>`` starts a local server, waits for
|
|
health, then launches the REPL. The welcome block
|
|
(TimedFormatter renders the agent name with dashes →
|
|
spaces) renders BEFORE prompt_toolkit's input loop is
|
|
live — matching only the banner and sending immediately
|
|
races the submit ahead of the input loop, so the
|
|
keystroke is dropped and the turn never starts.
|
|
|
|
The reliable input-readiness signal is the bottom status
|
|
toolbar, which renders ``· ready`` (with ``state:
|
|
sleeping``) once prompt_toolkit's application is running
|
|
and idle. Waiting for that after the banner makes the
|
|
subsequent ``child.send(...)`` land in the live input
|
|
loop. Using a generous timeout — agent upload + server boot
|
|
add latency on cold starts.
|
|
|
|
:param child: Active pexpect child.
|
|
:param timeout: Max seconds to wait.
|
|
:param welcome_pattern: Regex pattern to match in the
|
|
welcome block. Defaults to ``"ask.demo"`` (for the
|
|
``ask-demo`` fixture); pass a different pattern for
|
|
other fixtures.
|
|
"""
|
|
child.expect(welcome_pattern, timeout=timeout)
|
|
# The banner is not input-readiness. Wait for the status
|
|
# toolbar's ``· ready`` (idle ``state: sleeping``) marker
|
|
# so the next send() lands in prompt_toolkit's live loop
|
|
# instead of racing ahead of it.
|
|
child.expect(r"·\s*ready", timeout=timeout)
|
|
|
|
|
|
def _wait_for_turn_complete(child: Any, timeout: float = 45.0) -> None:
|
|
"""
|
|
Block until the current turn has finished streaming.
|
|
|
|
The REPL's bottom toolbar is the turn-state oracle: it reads
|
|
``state: running`` (animated spinner) while a handler task is
|
|
in flight and flips back to ``state: sleeping`` (``· ready``)
|
|
once the turn fully lands. The older tests waited on a
|
|
``\\d+\\.\\d+s`` decimal "elapsed" footer, but the current REPL
|
|
never renders one — the only elapsed readout is the integer
|
|
``streaming… Ns`` segment that disappears on completion. Waiting
|
|
for that stale pattern times out even though the turn finished.
|
|
|
|
A single ``· ready`` expect is enough: every send/approve site
|
|
leaves the toolbar in ``state: running`` (the turn is dispatched,
|
|
or an approval is pending), so the next ``· ready`` is the
|
|
post-turn idle settle — never a stale pre-turn toolbar. Keeping it
|
|
to one expect also preserves ``child.before`` as the full
|
|
span of the turn's rendered output, which several callers scan.
|
|
|
|
:param child: Active pexpect child.
|
|
:param timeout: Max seconds to wait for the turn to settle.
|
|
"""
|
|
child.expect(r"·\s*ready", timeout=timeout)
|
|
|
|
|
|
def _read_pending(child: Any, seconds: float = 0.2) -> str:
|
|
"""
|
|
Non-blocking read of everything buffered so far.
|
|
|
|
:param child: pexpect child.
|
|
:param seconds: Small timeout so the call returns promptly
|
|
after the buffer is drained.
|
|
:returns: Whatever pexpect had queued, stripped of ANSI.
|
|
"""
|
|
with contextlib.suppress(pexpect.EOF):
|
|
child.expect(pexpect.TIMEOUT, timeout=seconds)
|
|
captured = child.before or ""
|
|
if isinstance(captured, bytes):
|
|
captured = captured.decode("utf-8", errors="replace")
|
|
return _strip_ansi(captured)
|
|
|
|
|
|
def test_repl_single_approval_allows_llm_response(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
Drive the full approval → LLM → response loop through the
|
|
REPL.
|
|
|
|
Scenario: the ``ask-demo`` agent declares
|
|
``always_ask_on_input`` (a policy at INPUT that
|
|
always ASKs). We send "Hello", expect the approval
|
|
prompt, type "y", and expect the LLM's real reply.
|
|
|
|
Why this is the right test layer: unit tests can stub the
|
|
approval hook, but only a real pexpect run proves the
|
|
end-to-end stack — prompt_toolkit's raw keystroke
|
|
handling, the SDK's ``ElicitationRequest`` event routing,
|
|
the server's ``response.elicitation_request`` emission, the
|
|
session ``approval`` event reply path, and the
|
|
server's durable-workflow wake semantics — all cohere in production.
|
|
|
|
Load-bearing assertion: EXACTLY ONE approval prompt. The
|
|
"three approvals for one message" bug (prior bug:
|
|
``_enforce_input_policies`` walked history from index 0
|
|
each invocation) would fail this test by rendering
|
|
multiple ``⚠ approval required`` banners. Counting on
|
|
the ANSI-stripped buffer is the regression guard.
|
|
"""
|
|
_configure_mock_text(
|
|
mock_llm_server_url, ["Hi there! How can I help you today?"], match="approve-llm-resp"
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_ASK_DEMO_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120), # rows, cols
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(child, timeout=_LAUNCH_TIMEOUT)
|
|
|
|
# Send the user message and wait for the approval
|
|
# banner. 'approval required' is the human-readable
|
|
# header emitted by the REPL's _make_approval_prompt.
|
|
child.send("Hello approve-llm-resp" + "\r")
|
|
child.expect("approval required", timeout=30)
|
|
# The preview line should echo what we just typed —
|
|
# confirms the server-side INPUT-phase eval and the
|
|
# client-side SSE parsing both agreed on the payload.
|
|
child.expect("Hello", timeout=5)
|
|
|
|
# Approve. Any input while an approval is pending is
|
|
# routed to the verdict future — no special slash
|
|
# command, just "y".
|
|
child.send("y" + "\r")
|
|
# Echo line confirms the REPL resolved the verdict
|
|
# (sanity on the main-loop routing).
|
|
child.expect("approved", timeout=5)
|
|
|
|
# Now expect the LLM's actual reply. gpt-4o against
|
|
# the ask-demo AGENTS.md should produce a short
|
|
# greeting ("Hi", "Hello", etc.). We assert on a
|
|
# minimal substring that any reasonable reply
|
|
# contains — the test isn't asserting what the model
|
|
# says, only that SOMETHING of non-trivial length
|
|
# arrived after approval.
|
|
child.expect(pexpect.TIMEOUT, timeout=8)
|
|
buffered = _read_pending(child, seconds=2.0)
|
|
# Drain a little more in case the response is still
|
|
# streaming in chunks.
|
|
buffered += _read_pending(child, seconds=3.0)
|
|
|
|
# Exactly one approval banner — regression guard for
|
|
# the "three approvals for one message" bug.
|
|
approval_count = buffered.count("approval required")
|
|
# The `.expect("approval required")` above already
|
|
# consumed the first banner from pexpect's buffer,
|
|
# so anything here would be an extra. Zero is the
|
|
# correct assertion.
|
|
assert approval_count == 0, (
|
|
"Saw "
|
|
f"{approval_count} extra approval banners after the first — "
|
|
"`_enforce_input_policies` re-firing on same message?\n"
|
|
f"Buffer snippet:\n{buffered[:800]}"
|
|
)
|
|
# The agent replied with some text. We don't know the
|
|
# exact wording, but the AGENTS.md asks for a brief
|
|
# greeting, so any reasonable reply contains some
|
|
# letters after the approval.
|
|
assert re.search(r"[A-Za-z]{3,}", buffered), (
|
|
f"No LLM response text appeared after approval.\nBuffer snippet:\n{buffered[:800]}"
|
|
)
|
|
finally:
|
|
# Best-effort clean shutdown — /quit is the REPL's
|
|
# documented exit command, but if it's stuck we fall
|
|
# back to SIGTERM.
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
def test_repl_refusal_shows_deny_sentinel(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
Same flow, user refuses → server substitutes the DENY
|
|
sentinel → that text lands as the assistant reply.
|
|
|
|
This proves the fail-closed path end-to-end: hook returns
|
|
False → SDK POSTs a session ``approval`` event → server's
|
|
``_await_elicitation`` parses verdict, hits the DENY
|
|
branch, ``_enforce_input_policies`` returns the
|
|
``[Denied by policy: ...]`` sentinel, and
|
|
``_persist_input_deny_sentinel`` surfaces it as the
|
|
assistant message the REPL renders.
|
|
"""
|
|
# No LLM call expected on refuse — configure a dummy response
|
|
# so the mock doesn't 500 if the server unexpectedly calls it.
|
|
_configure_mock_text(mock_llm_server_url, ["should not appear"], match="deny-sentinel")
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_ASK_DEMO_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(child, timeout=_LAUNCH_TIMEOUT)
|
|
|
|
child.send("Hello deny-sentinel" + "\r")
|
|
child.expect("approval required", timeout=30)
|
|
child.expect("Hello", timeout=5)
|
|
|
|
# Refuse. Typing anything non-affirmative refuses —
|
|
# "n" is the natural keyboard muscle memory.
|
|
child.send("n" + "\r")
|
|
child.expect("refused", timeout=5)
|
|
|
|
# The server emits the DENY sentinel as the assistant
|
|
# reply. Exact reason string is shaped by the
|
|
# Policy spec in ask-demo/config.yaml
|
|
# ("Confirm this message before I process it.").
|
|
child.expect(r"Denied by policy", timeout=10)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
def test_repl_two_turns_fires_one_approval_per_turn(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
Regression guard for the multi-turn duplicate-ASK bug.
|
|
|
|
Scenario: two consecutive turns in the same conversation.
|
|
Each turn must fire EXACTLY ONE approval. The bug this
|
|
pins: `_enforce_input_policies` previously walked history
|
|
from index 0 on every new workflow, re-ASKing historical
|
|
user messages from prior turns.
|
|
|
|
The fix: skip past the last assistant message on fresh
|
|
invocation. The fact that we only see one approval on
|
|
turn 2 proves the prior user message from turn 1 is NOT
|
|
being re-enforced.
|
|
"""
|
|
# Two turns, each approved — two LLM responses needed.
|
|
_configure_mock_text(
|
|
mock_llm_server_url,
|
|
[
|
|
"Hello! Nice to meet you.",
|
|
"Sure thing, got it!",
|
|
],
|
|
match="two-turns-guard",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_ASK_DEMO_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(child, timeout=_LAUNCH_TIMEOUT)
|
|
|
|
# Turn 1: approve, wait for reply.
|
|
child.send("Hello two-turns-guard" + "\r")
|
|
child.expect("approval required", timeout=30)
|
|
child.send("y" + "\r")
|
|
child.expect("approved", timeout=5)
|
|
# Wait for the turn to fully land by syncing on the
|
|
# scripted reply text (deterministic content) rather than
|
|
# the cosmetic `· ready` idle-settle, which can race /
|
|
# not-render under CI load (the observed flake).
|
|
child.expect("Nice to meet you", timeout=30)
|
|
|
|
# Drain anything queued so the next expect starts
|
|
# from a clean slate. Generous wait because the REPL
|
|
# emits a flurry of cursor-position codes after the
|
|
# response completes — we want them all absorbed
|
|
# before sending the next input.
|
|
_read_pending(child, seconds=1.5)
|
|
|
|
# Turn 2: a brand-new message in the same
|
|
# conversation. If the old bug were present, the
|
|
# REPL would render TWO approval banners here (one
|
|
# for the historical "Hello", one for "kk"). The
|
|
# fix means exactly one banner appears — for "kk".
|
|
child.send("kk" + "\r")
|
|
# Capture the buffer from the send through the
|
|
# approval banner so we can inspect the preview line
|
|
# — pexpect's .expect on "preview:\\s*kk" has been
|
|
# flaky against heavily-styled output. Match on the
|
|
# banner, then scan the drained buffer afterwards.
|
|
child.expect("approval required", timeout=30)
|
|
# Pull the remaining banner text (policy / reason /
|
|
# preview / prompt line) into a buffer we can assert
|
|
# against with substring checks after ANSI stripping.
|
|
banner_tail = _read_pending(child, seconds=1.5)
|
|
assert "kk" in banner_tail, (
|
|
"Turn 2 banner's preview did not contain 'kk' — the fix for "
|
|
"`_enforce_input_policies` re-firing on historical messages "
|
|
"may have regressed.\n"
|
|
f"Tail captured (ANSI-stripped):\n{banner_tail[:800]}"
|
|
)
|
|
# And the banner MUST NOT show 'Hello' as its preview —
|
|
# that would be the historical-message regression.
|
|
assert "preview: Hello" not in banner_tail, (
|
|
"Turn 2's approval is previewing the prior turn's 'Hello' — "
|
|
"`_enforce_input_policies` re-firing on historical messages.\n"
|
|
f"Tail:\n{banner_tail[:800]}"
|
|
)
|
|
|
|
# Approve and confirm one-and-done.
|
|
child.send("y" + "\r")
|
|
child.expect("approved", timeout=5)
|
|
# Sync on the scripted turn-2 reply (deterministic content)
|
|
# instead of the racy `· ready` idle-settle marker.
|
|
child.expect("Sure thing", timeout=30)
|
|
|
|
# Final sweep: no extra approval banners after the
|
|
# two we expected.
|
|
buffered = _read_pending(child, seconds=1.0)
|
|
extras = buffered.count("approval required")
|
|
assert extras == 0, f"Unexpected extra approval banner after turn 2:\n{buffered[:800]}"
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
def test_repl_approve_always_caches_for_later_turns(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
End-to-end coverage for the "approve always" cache.
|
|
|
|
Turn 1: user types "a" at the approval prompt. The
|
|
``_ApprovalState`` caches ``(always_ask_on_input, input)``
|
|
for this REPL session.
|
|
|
|
Turn 2: the same policy fires at the same phase. The hook
|
|
short-circuits on the cache — prints a muted
|
|
``auto-approved`` audit line and returns True WITHOUT
|
|
rendering the ``⚠ approval required`` banner. The LLM
|
|
proceeds as if the user pre-approved.
|
|
|
|
Load-bearing assertions:
|
|
|
|
1. Turn 2 must show ``auto-approved`` in the transcript —
|
|
silent auto-approve would be security-hostile (users
|
|
forget they flipped "always" on).
|
|
2. Turn 2 must NOT show ``⚠ approval required`` — that's
|
|
the whole point of the cache; a user who typed "a"
|
|
expects no more prompting for this policy in this
|
|
session.
|
|
"""
|
|
# Turn 1 approved-always, turn 2 auto-approved — two LLM calls.
|
|
_configure_mock_text(
|
|
mock_llm_server_url,
|
|
[
|
|
"Hello there!",
|
|
"Following up as requested.",
|
|
],
|
|
match="approve-cache",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_ASK_DEMO_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(child, timeout=_LAUNCH_TIMEOUT)
|
|
|
|
# Turn 1: approve always.
|
|
child.send("Hello approve-cache" + "\r")
|
|
child.expect("approval required", timeout=30)
|
|
child.send("a" + "\r")
|
|
# Echo line confirms the REPL parsed "a" as
|
|
# APPROVE_ALWAYS, not as a generic non-"y" refusal.
|
|
child.expect("approved always", timeout=5)
|
|
_wait_for_turn_complete(child, timeout=30)
|
|
|
|
# Drain between turns so the next buffer is clean.
|
|
_read_pending(child, seconds=1.5)
|
|
|
|
# Turn 2: the auto-approved audit line must appear
|
|
# AND the banner must NOT. After .expect() lands on
|
|
# the elapsed-time marker, ``child.before`` holds the
|
|
# full span from the last expect up to (but not
|
|
# including) the match. That's the whole turn 2
|
|
# output — banner (if any) + auto-approved line (if
|
|
# any) + LLM response + elapsed-time prefix.
|
|
child.send("follow up please" + "\r")
|
|
_wait_for_turn_complete(child, timeout=45)
|
|
turn_two_raw = child.before or ""
|
|
if isinstance(turn_two_raw, bytes):
|
|
turn_two_raw = turn_two_raw.decode("utf-8", errors="replace")
|
|
turn_two = _strip_ansi(turn_two_raw)
|
|
|
|
assert "auto-approved" in turn_two, (
|
|
"Turn 2 did not render the auto-approve audit line.\n"
|
|
f"Captured (ANSI-stripped, {len(turn_two)} chars):\n{turn_two[:1500]}"
|
|
)
|
|
assert "approval required" not in turn_two, (
|
|
"Turn 2 rendered the approval banner even though the user "
|
|
"said 'always' on turn 1 — cache lookup is broken.\n"
|
|
f"Captured:\n{turn_two[:1500]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
# ── TOOL_CALL-phase approval coverage ─────────────────────
|
|
#
|
|
# Phase 6 wired the TOOL_CALL enforcement site in
|
|
# ``_execute_tools``. These tests prove the full round-trip:
|
|
# user message → LLM emits tool_call → policy ASKs → server
|
|
# parks → SSE surfaces ``response.elicitation_request`` →
|
|
# REPL renders → user answers → SDK POSTs a session approval
|
|
# event → server wakes the parked workflow → tool dispatches (on approve)
|
|
# or sentinel replaces output (on refuse).
|
|
|
|
|
|
def test_repl_tool_call_approval_allows_tool_to_run(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
TOOL_CALL ASK → approve → tool runs → LLM responds.
|
|
|
|
The mock LLM is scripted (like the TOOL_RESULT tests) to emit
|
|
an ``echo`` ``function_call`` on the first call, then a plain
|
|
text follow-up on the second. The ``ask_before_echo`` policy
|
|
ASKs on every ``tool_call:echo``. After the user approves, the
|
|
tool runs and its output (prefixed ``echo:``) flows back to the
|
|
LLM in the follow-up call.
|
|
|
|
The banner's ``phase`` field must be ``tool_call`` — not
|
|
``input`` — which is the critical distinction from the
|
|
INPUT-phase tests above. Proves the TOOL_CALL site is
|
|
wired and end-to-end correct.
|
|
"""
|
|
follow_up = "tool-call-approve-followup-marker"
|
|
_configure_mock_tool_then_text(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"call_id": "tc1",
|
|
"name": "echo",
|
|
"arguments": json.dumps({"message": "testing123"}),
|
|
}
|
|
],
|
|
follow_up,
|
|
match="testing123",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_TOOL_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(child, timeout=_LAUNCH_TIMEOUT, welcome_pattern="e2e.tool.gate")
|
|
child.send("testing123" + "\r")
|
|
child.expect("approval required", timeout=45)
|
|
banner_tail = _read_pending(child, seconds=1.0)
|
|
# Must be the TOOL_CALL phase (not INPUT) — this is
|
|
# the whole point of the test.
|
|
assert "tool_call" in banner_tail, (
|
|
"Banner phase field was not 'tool_call' — the ASK may have "
|
|
"fired at a different phase than expected.\n"
|
|
f"Banner tail:\n{banner_tail[:800]}"
|
|
)
|
|
# Policy name and echo tool should be on the banner.
|
|
assert "ask_before_echo" in banner_tail, (
|
|
f"Policy name missing from banner.\nBanner:\n{banner_tail[:800]}"
|
|
)
|
|
child.send("y" + "\r")
|
|
child.expect("approved", timeout=5)
|
|
# Sync on the post-tool follow-up reply — it only renders after the
|
|
# LLM's second call, proving the tool round-trip completed.
|
|
child.expect(follow_up, timeout=120)
|
|
# The echo tool runs; its output prefix 'echo:' should reach the
|
|
# LLM's function_call_output on the follow-up call.
|
|
joined = _wait_for_function_call_outputs(mock_llm_server_url)
|
|
assert "echo: testing123" in joined, (
|
|
"Tool output did not reach the LLM's function_call_output after "
|
|
f"approval.\nfunction_call_outputs: {joined[:800]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
def test_repl_tool_call_refusal_blocks_tool(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
TOOL_CALL ASK → explicit decline → turn aborts → tool NEVER runs.
|
|
|
|
An explicit refusal (typing "n") now aborts the agent turn rather
|
|
than feeding a denial marker to the LLM and letting it continue.
|
|
The tool must never execute: its raw output must not appear in the
|
|
terminal or reach the mock LLM as a function_call_output.
|
|
|
|
The mock LLM is scripted to emit the ``echo`` ``function_call`` so
|
|
the TOOL_CALL ASK fires. (The follow-up text is never reached: the
|
|
turn aborts on decline before any second LLM call.)
|
|
"""
|
|
_configure_mock_tool_then_text(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"call_id": "tc2",
|
|
"name": "echo",
|
|
"arguments": json.dumps({"message": "testing456"}),
|
|
}
|
|
],
|
|
"tool-call-refuse-followup-marker",
|
|
match="testing456",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_TOOL_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(child, timeout=_LAUNCH_TIMEOUT, welcome_pattern="e2e.tool.gate")
|
|
child.send("testing456" + "\r")
|
|
child.expect("approval required", timeout=45)
|
|
child.send("n" + "\r")
|
|
child.expect("refused", timeout=5)
|
|
# Turn is now aborted — wait for the REPL to return to idle.
|
|
_wait_for_turn_complete(child, timeout=30)
|
|
# The tool must never have run: raw echo output must not appear
|
|
# anywhere in the terminal buffer captured so far.
|
|
assert "echo: testing456" not in child.before, (
|
|
"Raw tool output appeared in REPL despite refusal — the tool "
|
|
"ran when it must not have."
|
|
)
|
|
# The mock LLM must NOT have received a second request carrying a
|
|
# function_call_output, because the turn was aborted before the
|
|
# denial reached the LLM.
|
|
joined = _wait_for_function_call_outputs(mock_llm_server_url, timeout=5.0)
|
|
assert "echo: testing456" not in joined, (
|
|
"Raw tool output leaked to the LLM despite refusal — "
|
|
f"function_call_outputs: {joined[:800]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
# ── Sub-agent approval tunneling ──────────────────────────
|
|
#
|
|
# When a sub-agent hits an ASK, the parked workflow is the
|
|
# SUB-AGENT's, but the ``response.elicitation_request`` must
|
|
# surface on the ROOT task's SSE stream so the REPL (which
|
|
# is attached to the root) sees it. This is the same
|
|
# tunneling path client-side tool calls use from within
|
|
# sub-agents — POLICIES.md §7 / workflow.py's
|
|
# ``_handle_policy_ask`` ``publish_target`` computation.
|
|
|
|
|
|
def test_repl_subagent_ask_does_not_tunnel_banner_to_root(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
Sub-agent INPUT-phase ASK does NOT surface a banner on the root
|
|
REPL — it is non-interactive today.
|
|
|
|
This asserts the CURRENT behavior, not the aspirational tunneled
|
|
interactive approval (which is tracked by #765). The original
|
|
test expected the worker's INPUT ASK to tunnel a
|
|
``⚠ approval required`` banner onto the root SSE stream and wait
|
|
for the user to approve. That interactive tunnel is not wired in
|
|
the REPL path.
|
|
|
|
Observed reality (verified live against the mock LLM): the parent
|
|
spawns the ``worker`` sub-agent via ``sys_session_send``; the
|
|
worker's ``worker_input_gate`` (an ``on: [request]`` ASK) does
|
|
NOT park for a root-surfaced prompt — the headless sub-agent runs
|
|
to completion, its reply lands in the parent's inbox, and the
|
|
parent composes its final summary. The turn finishes without any
|
|
banner or human interaction.
|
|
|
|
Load-bearing assertions:
|
|
|
|
- NO ``approval required`` banner surfaces on the root REPL.
|
|
- The parent's final summary text appears, proving the sub-agent
|
|
ran end-to-end despite the unprompted ASK.
|
|
"""
|
|
# The parent (root user message) and the worker (delegated task)
|
|
# draw from separate content-routed queues on DISTINCT,
|
|
# mutually-non-substring tokens so each agent's scripted calls land
|
|
# deterministically. With a single shared queue the parent's
|
|
# post-spawn continuation call races the worker's call: the parent
|
|
# consumes the worker's queued reply and the worker parks forever
|
|
# (the flake). Splitting them closes that intra-test race and the
|
|
# model-fallback contamination vector entirely (#523 isolation):
|
|
# - "saask-parent" appears ONLY in the root user message, so the
|
|
# parent's calls route here. The delegated-task token lives in a
|
|
# function_call, not user content, so it never leaks into the
|
|
# parent's user text.
|
|
# - "saask-worker" appears ONLY in the task delegated to the
|
|
# worker, so the sub-agent's call routes to its own queue.
|
|
worker_reply = "worker-reply-render-marker"
|
|
parent_summary = "parent-summary-render-marker"
|
|
reset_mock_llm(mock_llm_server_url)
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"tool_calls": [
|
|
{
|
|
"call_id": "sa1",
|
|
"name": "sys_session_send",
|
|
"arguments": json.dumps(
|
|
{"agent": "worker", "title": "t", "args": "say hello saask-worker"}
|
|
),
|
|
}
|
|
]
|
|
},
|
|
{"text": parent_summary},
|
|
{"text": "(spare)"},
|
|
{"text": "(spare)"},
|
|
],
|
|
match="saask-parent",
|
|
)
|
|
# Worker queue — content-routed on the delegated-task token.
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
{"text": worker_reply},
|
|
{"text": "(spare)"},
|
|
{"text": "(spare)"},
|
|
],
|
|
match="saask-worker",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_SUBAGENT_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=90,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(
|
|
child,
|
|
timeout=90,
|
|
welcome_pattern="e2e.subagent.gate",
|
|
)
|
|
child.send("say hello saask-parent" + "\r")
|
|
# Deterministic content-marker sync: the parent's summary renders
|
|
# only after the worker ran to completion and its result landed in
|
|
# the inbox. Had the worker parked on the unprompted ASK, the
|
|
# result would never arrive and this would never render. Keying on
|
|
# the marker (not the racy ``· ready`` toolbar) makes it stable.
|
|
child.expect(parent_summary, timeout=120)
|
|
full_turn = child.before or ""
|
|
if isinstance(full_turn, bytes):
|
|
full_turn = full_turn.decode("utf-8", errors="replace")
|
|
full_turn = _strip_ansi(full_turn)
|
|
# Drain any trailing render so a late line is captured.
|
|
full_turn += _read_pending(child, seconds=2.0)
|
|
# No interactive approval banner tunneled to the root REPL.
|
|
assert "approval required" not in full_turn, (
|
|
"A sub-agent ASK banner surfaced on the root REPL — interactive "
|
|
"tunneled mid-flight ASK is not implemented (see #765); the "
|
|
f"sub-agent ASK is non-interactive today.\nCaptured:\n{full_turn[:1500]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
# ── Label-driven ASK composition ──────────────────────────
|
|
#
|
|
# Tests the two-turn chain:
|
|
# - Turn 1 with a trigger token: FunctionPolicy ALLOWs and
|
|
# writes a taint label.
|
|
# - Turn 2: Policy with ``condition: {tainted: "1"}``
|
|
# fires ASK because the label persisted across the
|
|
# workflow boundary.
|
|
#
|
|
# Complements ``test_label_gate_*`` in test_policies_e2e.py
|
|
# which cover the DENY variant via the polling API.
|
|
|
|
|
|
def test_repl_label_driven_ask_approves(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
Two-turn label-ASK composition, approve path.
|
|
|
|
Turn 1: user message contains ``BANANA_TRIGGER``. The
|
|
FunctionPolicy writes ``tainted: "1"``; the gated policy's
|
|
condition checks the pre-evaluation snapshot so does NOT
|
|
fire yet. LLM responds normally.
|
|
|
|
Turn 2: any message. The persisted label makes the
|
|
Policy condition match → ASK. User approves → LLM
|
|
runs normally for the second turn.
|
|
|
|
Load-bearing: proves (a) FunctionPolicy label writes
|
|
persist to the store and survive the sub-agent /
|
|
workflow restart, (b) condition gates read
|
|
the live cache on turn 2, (c) ASK composition with a
|
|
write in the chain doesn't leak the write on refuse
|
|
(that's a separate refuse test below).
|
|
"""
|
|
# Turn 1: LLM responds normally (no ASK). Turn 2: ASK fires,
|
|
# approved, then LLM responds.
|
|
_configure_mock_text(
|
|
mock_llm_server_url,
|
|
[
|
|
"Got it, banana trigger noted.",
|
|
"Continuing as requested.",
|
|
],
|
|
match="label-approve",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_LABEL_ASK_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(
|
|
child,
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
welcome_pattern="e2e.label.ask.gate",
|
|
)
|
|
# Turn 1: trigger taint — no ASK fires this turn
|
|
# (condition checks the pre-evaluation snapshot).
|
|
child.send("hello BANANA_TRIGGER label-approve" + "\r")
|
|
# The LLM still replies normally. Wait for turn end.
|
|
_wait_for_turn_complete(child, timeout=45)
|
|
turn_one = child.before or ""
|
|
if isinstance(turn_one, bytes):
|
|
turn_one = turn_one.decode("utf-8", errors="replace")
|
|
turn_one = _strip_ansi(turn_one)
|
|
# Turn 1 MUST NOT show an approval banner — the
|
|
# taint label didn't exist when the condition was
|
|
# checked.
|
|
assert "approval required" not in turn_one, (
|
|
"Turn 1 fired an ASK before the taint label was set — "
|
|
"condition gate is reading the post-write snapshot.\n"
|
|
f"Turn 1:\n{turn_one[:1500]}"
|
|
)
|
|
|
|
_read_pending(child, seconds=1.0)
|
|
|
|
# Turn 2: label persists from the store → condition
|
|
# matches → ASK fires.
|
|
child.send("please continue" + "\r")
|
|
child.expect("approval required", timeout=45)
|
|
banner_tail = _read_pending(child, seconds=1.0)
|
|
assert "ask_when_tainted" in banner_tail, (
|
|
"Turn 2's banner didn't come from the label-gated policy.\n"
|
|
f"Banner:\n{banner_tail[:800]}"
|
|
)
|
|
child.send("y" + "\r")
|
|
child.expect("approved", timeout=5)
|
|
# Turn 2 completes — LLM replies normally.
|
|
_wait_for_turn_complete(child, timeout=45)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
def test_repl_label_driven_ask_refuse_shows_sentinel(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
Same composition, refuse path.
|
|
|
|
Turn 2's ASK refused → server substitutes the DENY
|
|
sentinel → REPL shows ``Denied by policy``. Proves the
|
|
label-gated ASK's refuse branch goes through the same
|
|
pre-persist sentinel path as INPUT DENY.
|
|
"""
|
|
# Turn 1: LLM responds normally. Turn 2: refused — DENY sentinel,
|
|
# no second LLM call. Extra dummy response as fail-safe.
|
|
_configure_mock_text(
|
|
mock_llm_server_url,
|
|
[
|
|
"Banana trigger received.",
|
|
"should not appear",
|
|
],
|
|
match="label-refuse",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_LABEL_ASK_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(
|
|
child,
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
welcome_pattern="e2e.label.ask.gate",
|
|
)
|
|
# Turn 1: taint.
|
|
child.send("hi BANANA_TRIGGER label-refuse" + "\r")
|
|
_wait_for_turn_complete(child, timeout=45)
|
|
_read_pending(child, seconds=1.0)
|
|
|
|
# Turn 2: ASK fires, user refuses.
|
|
child.send("anything" + "\r")
|
|
child.expect("approval required", timeout=45)
|
|
child.send("n" + "\r")
|
|
child.expect("refused", timeout=5)
|
|
_wait_for_turn_complete(child, timeout=45)
|
|
full_turn = child.before or ""
|
|
if isinstance(full_turn, bytes):
|
|
full_turn = full_turn.decode("utf-8", errors="replace")
|
|
full_turn = _strip_ansi(full_turn)
|
|
assert "Denied by policy" in full_turn, (
|
|
"Refused label-gated ASK did not produce a DENY sentinel.\n"
|
|
f"Captured:\n{full_turn[:1500]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
# ── OUTPUT-phase approval coverage ────────────────────────
|
|
#
|
|
# POLICIES.md §11.4: the raw assistant text must never reach
|
|
# ``conversation_items`` when OUTPUT policy DENYs —
|
|
# compaction could resurface it otherwise. These tests prove
|
|
# the pre-persistence ordering holds end-to-end when the user
|
|
# actually refuses the assistant reply.
|
|
|
|
|
|
def test_repl_output_ask_does_not_prompt_in_repl(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
RESPONSE(OUTPUT)-phase ASK is NON-INTERACTIVE in the REPL today.
|
|
|
|
Asserts the CURRENT behavior, not an aspirational one. Like the
|
|
TOOL_RESULT phase (#775), a RESPONSE-phase ASK does NOT surface a
|
|
``⚠ approval required`` banner: the ``ask_on_output`` policy fires
|
|
but cannot prompt mid-flight, so the assistant reply passes straight
|
|
through to the user and the turn completes normally. Interactive
|
|
mid-flight ASK is tracked by #765.
|
|
|
|
Verified live against the mock LLM: ``say hi`` → the scripted reply
|
|
renders (``◆ <reply>``) and the turn settles to ``ready`` with NO
|
|
banner and NO deny sentinel.
|
|
|
|
(History: this test previously asserted an interactive *approve* path
|
|
and was ``pytest.skip``-ped as "requires real LLM" — both were
|
|
misdiagnoses. ``repl_env`` already targets the mock server, and
|
|
RESPONSE-phase ASK is a pass-through, not an interactive gate.
|
|
Contrast #789, which proved TOOL_CALL *does* surface a banner; the
|
|
"same fix applies to OUTPUT" follow-up there does not hold.)
|
|
"""
|
|
reply = "output-passthrough-reply-marker"
|
|
_configure_mock_text(mock_llm_server_url, [reply], match="output-noprompt")
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_OUTPUT_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(
|
|
child,
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
welcome_pattern="e2e.output.gate",
|
|
)
|
|
child.send("say hi output-noprompt" + "\r")
|
|
# The reply renders without ever parking on a banner. Sync on the
|
|
# reply marker (deterministic content) rather than the cosmetic
|
|
# `· ready` idle-settle, which can race/not-render under CI load.
|
|
child.expect(reply, timeout=120)
|
|
full_turn = _strip_ansi((child.before or "") + reply)
|
|
# NO interactive approval banner for the RESPONSE-phase ASK.
|
|
assert "approval required" not in full_turn, (
|
|
"A RESPONSE-phase approval banner surfaced — interactive output "
|
|
"ASK is not implemented (it is a pass-through today; see #765); "
|
|
f"the REPL must not render one.\nCaptured:\n{full_turn[:1500]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
def test_repl_output_ask_passes_reply_through_no_sentinel(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
RESPONSE(OUTPUT)-phase ASK passes the reply through UNCHANGED.
|
|
|
|
Security-relevant companion to
|
|
:func:`test_repl_output_ask_does_not_prompt_in_repl`: not only is
|
|
there no banner, the raw reply is also NOT replaced by a
|
|
``[Denied by policy: ...]`` sentinel — the ASK verdict is a silent
|
|
no-op, so the model's output reaches the user verbatim. This is a
|
|
**fail-OPEN** gate, the same shape as TOOL_RESULT ASK (#775) and the
|
|
opposite of a DENY. (Contrast the INPUT and TOOL_CALL phases, which
|
|
DO surface an interactive banner — #789.) The missing interactive
|
|
enforcement is tracked by #765.
|
|
|
|
(History: this test previously asserted a *refuse → sentinel* path
|
|
and was ``pytest.skip``-ped as "requires real LLM". Both were
|
|
misdiagnoses: ``repl_env`` targets the mock server, and there is no
|
|
interactive refuse — the ASK passes through, so no sentinel is
|
|
substituted. Verified live against the mock.)
|
|
"""
|
|
reply = "output-verbatim-reply-marker"
|
|
_configure_mock_text(mock_llm_server_url, [reply], match="output-passthru")
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_OUTPUT_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(
|
|
child,
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
welcome_pattern="e2e.output.gate",
|
|
)
|
|
child.send("say hi output-passthru" + "\r")
|
|
# The raw reply reaches the user verbatim (no DENY substitution).
|
|
# Sync on the reply marker itself — its appearance IS the proof the
|
|
# output passed through ungated.
|
|
child.expect(reply, timeout=120)
|
|
full_turn = _strip_ansi((child.before or "") + reply)
|
|
# Fail-open: NO deny sentinel replaced the reply (RESPONSE-phase ASK
|
|
# is a no-op today, not a block).
|
|
assert "Denied by policy" not in full_turn, (
|
|
"A deny sentinel replaced the reply — RESPONSE-phase ASK does not "
|
|
f"collapse to DENY on this path today (see #765).\nCaptured:\n{full_turn[:1500]}"
|
|
)
|
|
# And no interactive banner either.
|
|
assert "approval required" not in full_turn, (
|
|
"A RESPONSE-phase approval banner surfaced — interactive output "
|
|
f"ASK is not implemented (see #765).\nCaptured:\n{full_turn[:1500]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
# ── TOOL_RESULT-phase approval coverage ───────────────────
|
|
#
|
|
# Distinct from TOOL_CALL: the policy fires AFTER the tool
|
|
# dispatches and returns, BEFORE the result reaches
|
|
# function_call_output. Tool output exfiltration is the
|
|
# canonical motivating case — "run the tool but I want to
|
|
# review what it returned before the LLM sees it".
|
|
|
|
|
|
def test_repl_tool_result_ask_does_not_prompt_in_repl(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
TOOL_RESULT-phase ASK is NON-INTERACTIVE in the REPL today.
|
|
|
|
This asserts the CURRENT behavior, not an aspirational one.
|
|
Interactive mid-flight ASK is tracked by #765; until then a
|
|
TOOL_RESULT ASK never surfaces a ``⚠ approval required``
|
|
banner in the REPL.
|
|
|
|
Observed reality (verified live against the mock LLM): the
|
|
``ask_on_echo_result`` policy fires at the TOOL_RESULT phase but
|
|
cannot prompt mid-flight, so the tool's output is NOT held for
|
|
review — it passes straight through to the LLM and the turn
|
|
completes normally. (The runner-side collapse-to-DENY helper in
|
|
``policy.py`` is currently unused on this server-mediated
|
|
callable-tool path; the server's TOOL_RESULT enforcement at
|
|
``sessions.py`` acts only on DENY/transform verdicts, so an ASK
|
|
verdict is a pass-through.) The net REPL outcome:
|
|
|
|
- NO approval banner appears.
|
|
- The tool runs and its ``echo: <input>`` output reaches the LLM.
|
|
- The mock follow-up text lands as the assistant reply.
|
|
|
|
Load-bearing assertions: (1) no banner, (2) the raw tool output
|
|
reached the LLM's function_call_output (no deny sentinel
|
|
substituted), proving the ASK was a no-op rather than a block.
|
|
"""
|
|
follow_up = "tool-result-followup-marker"
|
|
_configure_mock_tool_then_text(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"call_id": "tr1",
|
|
"name": "echo",
|
|
"arguments": json.dumps({"message": "pineapple"}),
|
|
}
|
|
],
|
|
follow_up,
|
|
match="pineapple",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_TOOL_RESULT_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(
|
|
child,
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
welcome_pattern="e2e.tool.result.gate",
|
|
)
|
|
child.send("pineapple" + "\r")
|
|
# Sync on the follow-up reply, NOT the cosmetic `· ready` idle-settle.
|
|
# The post-tool reply only renders after the LLM's second call (the
|
|
# tool round-trip completed) and proves the turn finished — while the
|
|
# `· ready` idle-settle marker intermittently fails to render under CI
|
|
# load (the #523 pexpect family), hanging the wait past 120s even
|
|
# though the turn itself completed. The sibling pass-through test syncs
|
|
# the same way and is stable. If a banner had parked the turn, the
|
|
# follow-up would never arrive and this expect would time out.
|
|
child.expect(follow_up, timeout=120)
|
|
full_turn = _strip_ansi((child.before or "") + follow_up)
|
|
# No interactive approval banner for the TOOL_RESULT ASK.
|
|
assert "approval required" not in full_turn, (
|
|
"A TOOL_RESULT-phase approval banner surfaced — interactive "
|
|
"mid-flight ASK is not implemented (see #765); the REPL must "
|
|
f"not render one today.\nCaptured:\n{full_turn[:1500]}"
|
|
)
|
|
# The raw tool output reached the LLM untouched (no deny
|
|
# sentinel) — the ASK passed through rather than blocking.
|
|
joined = _wait_for_function_call_outputs(mock_llm_server_url)
|
|
assert "echo: pineapple" in joined, (
|
|
"Expected the raw echo output to reach the LLM's "
|
|
"function_call_output (TOOL_RESULT ASK is a no-op today), but "
|
|
f"it was not present.\nfunction_call_outputs: {joined[:800]}"
|
|
)
|
|
assert "Denied by policy" not in joined, (
|
|
"A deny sentinel replaced the tool output — the current "
|
|
"TOOL_RESULT ASK path does not collapse to DENY on this "
|
|
f"callable-tool path.\nfunction_call_outputs: {joined[:800]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
def test_repl_tool_result_ask_passes_output_through(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
TOOL_RESULT-phase ASK cannot be refused interactively today.
|
|
|
|
Companion to ``test_repl_tool_result_ask_does_not_prompt_in_repl``
|
|
from the "refuse" angle. The original test expected the user to
|
|
refuse the tool result and see a ``[Denied by policy: ...]``
|
|
sentinel. That interactive refuse is not implemented (tracked by
|
|
#765): with no banner, there is nothing for the user to refuse,
|
|
so the tool output is never suppressed.
|
|
|
|
This asserts the CURRENT behavior: a TOOL_RESULT ASK leaves the
|
|
tool output intact (no sentinel) and the turn completes. We pin
|
|
the second turn explicitly — a stale-state regression that DID
|
|
start suppressing output would surface a sentinel here.
|
|
"""
|
|
follow_up = "second-turn-followup-marker"
|
|
_configure_mock_tool_then_text(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"call_id": "tr2",
|
|
"name": "echo",
|
|
"arguments": json.dumps({"message": "mangosteen"}),
|
|
}
|
|
],
|
|
follow_up,
|
|
match="mangosteen",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_TOOL_RESULT_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(
|
|
child,
|
|
timeout=_LAUNCH_TIMEOUT,
|
|
welcome_pattern="e2e.tool.result.gate",
|
|
)
|
|
child.send("mangosteen" + "\r")
|
|
# Sync on the post-tool follow-up reply: it only renders after the
|
|
# LLM's second call, which requires the function_call_output round-trip
|
|
# to have completed. 120s headroom for REPL turn latency under CI
|
|
# worker contention (#523 family); within the --timeout=180 pytest cap.
|
|
# (The follow-up render is a proxy; the function_call_output assertion
|
|
# below waits on the real signal via _wait_for_function_call_outputs,
|
|
# which polls until the mock has recorded the output.)
|
|
child.expect(follow_up, timeout=120)
|
|
full_turn = _strip_ansi((child.before or "") + follow_up)
|
|
assert "approval required" not in full_turn, (
|
|
"A TOOL_RESULT-phase approval banner surfaced — interactive "
|
|
f"mid-flight ASK is not implemented (see #765).\nCaptured:\n{full_turn[:1500]}"
|
|
)
|
|
# No deny sentinel: today's path does not suppress the output.
|
|
joined = _wait_for_function_call_outputs(mock_llm_server_url)
|
|
assert "echo: mangosteen" in joined, (
|
|
"The raw echo output did not reach the LLM — TOOL_RESULT ASK "
|
|
f"is a no-op today and must not suppress it.\noutputs: {joined[:800]}"
|
|
)
|
|
assert "Denied by policy" not in joined, (
|
|
"A deny sentinel appeared — the TOOL_RESULT ASK refuse path is "
|
|
f"not wired in the REPL today (see #765).\noutputs: {joined[:800]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|
|
|
|
|
|
# ── Sub-agent TOOL_CALL approval (non-interactive) ─────────
|
|
#
|
|
# The sub-agent fires an ASK from the TOOL_CALL phase. Like the
|
|
# INPUT-phase sub-agent ASK, it does NOT tunnel an interactive
|
|
# banner to the root REPL today — the headless sub-agent runs
|
|
# the tool to completion and the ASK is a pass-through (#765).
|
|
|
|
|
|
def test_repl_subagent_tool_call_ask_does_not_tunnel_banner_to_root(
|
|
ap_cli: str,
|
|
repl_env: dict[str, str],
|
|
mock_llm_server_url: str,
|
|
) -> None:
|
|
"""
|
|
Sub-agent TOOL_CALL ASK does NOT tunnel a banner to the root REPL.
|
|
|
|
This asserts the CURRENT behavior, not the aspirational tunneled
|
|
interactive approval (tracked by #765). The fixture's
|
|
``toolworker`` sub-agent has a LOCAL function tool ``echo`` and a
|
|
``worker_tool_gate`` policy that ASKs on ``tool_call:echo``.
|
|
|
|
Observed reality (captured live against the mock LLM): the parent
|
|
spawns ``toolworker`` via ``sys_session_send``; the headless
|
|
sub-agent's ``echo`` TOOL_CALL ASK does NOT park for a
|
|
root-surfaced prompt — the sub-agent runs ``echo`` to completion,
|
|
its reply lands in the parent's inbox, and the parent composes its
|
|
final summary. The turn finishes with no banner or human
|
|
interaction.
|
|
|
|
Load-bearing assertions:
|
|
|
|
- The sub-agent's nested local ``echo`` tool registers with the
|
|
spawned child's executor — the call does NOT error
|
|
``Tool echo not found`` (the regression in #763).
|
|
- NO ``approval required`` banner surfaces on the root REPL.
|
|
- The parent's final summary renders, proving the sub-agent ran
|
|
end-to-end (echo executed) despite the unprompted TOOL_CALL ASK.
|
|
|
|
The parent (``model: gpt-4o``) and the sub-agent
|
|
(``model: gpt-4o-mini``) draw from separate keyed mock queues so
|
|
each agent's scripted calls land deterministically — a single
|
|
shared queue races the parent's continuation call against the
|
|
sub-agent's ``echo`` call (the parent would consume the
|
|
sub-agent's tool call and error ``Tool echo not found``).
|
|
"""
|
|
worker_reply = "worker-reply-render-marker"
|
|
parent_summary = "parent-summary-render-marker"
|
|
reset_mock_llm(mock_llm_server_url)
|
|
# Both queues are content-routed on DISTINCT, mutually-non-substring
|
|
# tokens so parent and sub-agent calls split correctly AND neither
|
|
# queue is reachable by model fallback (closes the gpt-4o / gpt-4o-mini
|
|
# contamination vectors entirely — #523 isolation):
|
|
# - "statool-parent" appears ONLY in the root user message, so the
|
|
# parent's calls (and its post-spawn continuation) route here. The
|
|
# delegated-task token lives in a function_call, not user content,
|
|
# so it never leaks into the parent's user text.
|
|
# - "statool-worker" appears ONLY in the task delegated to the
|
|
# toolworker, so the sub-agent's calls route to its own queue.
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"tool_calls": [
|
|
{
|
|
"call_id": "sa1",
|
|
"name": "sys_session_send",
|
|
"arguments": json.dumps(
|
|
{
|
|
"agent": "toolworker",
|
|
"title": "t",
|
|
"args": "return the word durian statool-worker",
|
|
}
|
|
),
|
|
}
|
|
]
|
|
},
|
|
{"text": parent_summary},
|
|
{"text": "(spare)"},
|
|
{"text": "(spare)"},
|
|
],
|
|
match="statool-parent",
|
|
)
|
|
# Toolworker queue — content-routed on the delegated-task token.
|
|
configure_mock_llm(
|
|
mock_llm_server_url,
|
|
[
|
|
{
|
|
"tool_calls": [
|
|
{
|
|
"call_id": "we1",
|
|
"name": "echo",
|
|
"arguments": json.dumps({"message": "durian"}),
|
|
}
|
|
]
|
|
},
|
|
{"text": worker_reply},
|
|
{"text": "(spare)"},
|
|
{"text": "(spare)"},
|
|
],
|
|
match="statool-worker",
|
|
)
|
|
child = pexpect.spawn(
|
|
ap_cli,
|
|
["run", str(_SUBAGENT_TOOL_GATE_DIR)],
|
|
env=repl_env,
|
|
encoding="utf-8",
|
|
codec_errors="replace",
|
|
dimensions=(40, 120),
|
|
timeout=90,
|
|
)
|
|
try:
|
|
_wait_for_prompt_ready(
|
|
child,
|
|
timeout=90,
|
|
welcome_pattern="e2e.subagent.tool.gate",
|
|
)
|
|
child.send("return the word durian statool-parent" + "\r")
|
|
# Deterministic content-marker sync: the parent's summary text
|
|
# renders only after the sub-agent ran echo end-to-end and its
|
|
# result landed in the inbox. Keying on the marker (not the
|
|
# racy ``· ready`` toolbar) makes the completion signal stable.
|
|
child.expect(parent_summary, timeout=120)
|
|
full_turn = child.before or ""
|
|
if isinstance(full_turn, bytes):
|
|
full_turn = full_turn.decode("utf-8", errors="replace")
|
|
full_turn = _strip_ansi(full_turn)
|
|
# Drain any trailing render so a late line is captured.
|
|
full_turn += _read_pending(child, seconds=2.0)
|
|
# The nested local echo tool registered with the spawned
|
|
# child's executor — the SDK did NOT reject the call. This is
|
|
# the #763 regression guard.
|
|
assert "Tool echo not found" not in full_turn, (
|
|
"The sub-agent's nested local 'echo' tool failed to register "
|
|
"with the spawned child's executor (regression #763).\n"
|
|
f"Captured:\n{full_turn[:1500]}"
|
|
)
|
|
# No interactive approval banner tunneled to the root REPL —
|
|
# the sub-agent TOOL_CALL ASK is a non-interactive pass-through
|
|
# today, exactly like the sub-agent INPUT-phase ASK.
|
|
assert "approval required" not in full_turn, (
|
|
"A sub-agent TOOL_CALL ASK banner surfaced on the root REPL — "
|
|
"interactive tunneled mid-flight ASK is not implemented; "
|
|
"the sub-agent ASK is non-interactive today.\n"
|
|
f"Captured:\n{full_turn[:1500]}"
|
|
)
|
|
finally:
|
|
try:
|
|
child.send("/quit" + "\r")
|
|
child.expect(pexpect.EOF, timeout=5)
|
|
except Exception:
|
|
pass
|
|
if child.isalive():
|
|
child.terminate(force=True)
|