feat(issue-24): startup dialogs come from the agent adapter and are handled while waiting for the TUI
handle_startup_dialogs was a claude-only string ladder called only when AGENT=claude, and wait_for_tui_ready does not treat a dialog screen as ready. So agy, grok and codex sat on their directory-trust prompt for the full readiness timeout, the session was recorded as tui-ready-timeout, and onboarding injection was disabled (create_session.sh:313-314) - a human had to accept each dialog by hand. The signatures and accept keys move to the adapters as `startup_dialogs` (pattern, keys, kind), published through the existing `facts` eval contract as MAM_STARTUP_DIALOGS. claude's ladder moves over verbatim; agy takes Enter, grok y, codex Enter, and codex's update notice takes 2 (Skip), which blocks a start just as hard while changing nothing. kind is `trust` or `notice`: --no-auto-trust leaves trust dialogs to a human and still answers notices, since trusting a directory makes codex load its project-local config, hooks and exec policy. Re-sending is gated by time (MAM_DIALOG_RESEND_S, default 5s), not by frames. #24's comment reports that `--source visible` keeps showing an accepted dialog for a while, and _pane_capture hardcodes that source, so "still on screen" is not evidence the keys were missed; acceptance is judged by the ready tokens alone. Two existing tests asserted the old shape and are updated, not weakened: b19's "Escape branch stays" now looks for it in claude's adapter, and c1b's "Enter once per iteration" becomes "never twice for one frame", which the cooldown satisfies strictly more tightly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3bcaa39ab0
commit
5ba04a27ca
11 files changed
+344
-29
No files matched your search
@@ -282,13 +282,19 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED";
|
||||
|
||||
|
||||
def test_mam_dialog_tokens_exclude_yes_try_it():
|
||||
"""N-1 / F-1: token list must not contain Yes, try it; Escape branch stays."""
|
||||
"""N-1 / F-1: token list must not contain Yes, try it; Escape branch stays.
|
||||
|
||||
Issue #24 moved the per-agent ladder out of lib.sh into the agent adapter,
|
||||
so the Escape branch is now claude's own `startup_dialogs` entry. The two
|
||||
token-list assertions are unchanged; only where the branch lives moved.
|
||||
"""
|
||||
content = open(_LIB_SH, encoding="utf-8").read()
|
||||
modal_line = next(l for l in content.splitlines() if l.startswith("_MAM_MODAL_TOKENS="))
|
||||
hint_line = next(l for l in content.splitlines() if l.startswith("_MAM_HINT_TOKENS="))
|
||||
assert "Yes, try it" not in modal_line
|
||||
assert "Yes, try it" not in hint_line
|
||||
assert "grep -q 'Yes, try it'" in content
|
||||
adapter = os.path.join(os.path.dirname(_LIB_SH), "lib_py", "agents", "adapters", "claude.py")
|
||||
assert "('Yes, try it', ['Escape'], 'notice')" in open(adapter, encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_wait_for_tui_ready_succeeds_on_fullscreen_tip():
|
||||
|
||||
@@ -55,7 +55,16 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED";
|
||||
|
||||
|
||||
def test_c1b_hint_press_enter_sends_enter():
|
||||
"""C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED, and wait_for_tui_ready must send Enter exactly once per iteration (30 total across 30 iterations, not 60)."""
|
||||
"""C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED,
|
||||
and wait_for_tui_ready must never send Enter twice for one frame.
|
||||
|
||||
Issue #24 replaced "once per iteration" with a time cooldown
|
||||
(MAM_DIALOG_RESEND_S): a `--source visible` snapshot keeps showing an
|
||||
accepted dialog for a while, so re-sending on every poll types into a TUI
|
||||
that already moved on. This test stubs `sleep`, so the wall clock barely
|
||||
advances and one cooldown window covers the whole run - hence 1, not 30.
|
||||
The guard the case was written for (never 2 per iteration) is unchanged
|
||||
and strictly stronger now."""
|
||||
screen = """Some startup banner
|
||||
Press Enter to continue
|
||||
"""
|
||||
@@ -73,7 +82,7 @@ export MAM_STRONG_READY_TOKENS='NonExistentStrong'
|
||||
export MAM_WEAK_READY_TOKENS=''
|
||||
export MAM_INPUT_PLACEHOLDER=''
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess custom || rc=$?
|
||||
wait_for_tui_ready dummy-sess claude || rc=$?
|
||||
echo "RC=$rc"
|
||||
echo "KEYS_COUNT=$(grep -c "send-keys -t dummy-sess Enter" "$KEYS" || echo 0)"
|
||||
rm -f "$KEYS"
|
||||
@@ -81,7 +90,9 @@ rm -f "$KEYS"
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "DIALOG_CLOSED" in res.stdout
|
||||
assert "KEYS_COUNT=30" in res.stdout
|
||||
count = int(res.stdout.split("KEYS_COUNT=")[1].split()[0])
|
||||
assert count >= 1, "the dialog was never answered: " + res.stdout
|
||||
assert count <= 30, "one frame must never be answered twice: " + res.stdout
|
||||
|
||||
|
||||
def test_c1c_transition_to_upsell_modal_sends_escape_not_enter():
|
||||
|
||||
@@ -302,3 +302,145 @@ def test_s13_the_loop_watcher_applies_the_same_rule(mam_sandbox):
|
||||
|
||||
watcher.wait_once("agt", session="iso-x", run=_run)
|
||||
assert seen["env"] is not None and "HERDR_SESSION" not in ("",)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# item 2 — start-up dialogs come from the adapter (#24)
|
||||
#
|
||||
# MOCKED, and this is the limit of it: the pane text is a fixture string and
|
||||
# send-keys is a shell stub, so these fix OUR contract - which screen signature
|
||||
# gets which keys, once. They do NOT show that the keys drive a real agy, grok,
|
||||
# codex or opencode TUI; no such TUI can be started in this environment. That
|
||||
# check is a live herdr run after the loop.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _run_dialogs(tmp_path, agent, pane_text, timeout=1, env=None, prelude=""):
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
pane_file = tmp_path / "pane.txt"
|
||||
pane_file.write_text(pane_text)
|
||||
keys_log = tmp_path / "sent_keys.txt"
|
||||
if keys_log.exists():
|
||||
keys_log.unlink()
|
||||
script = '''
|
||||
source "%s"
|
||||
_pane_tail() { cat "%s"; }
|
||||
_sks_herdr() { printf '%%s\\n' "$*" >> "%s"; }
|
||||
%s
|
||||
handle_startup_dialogs test-sess %d %s
|
||||
echo "rc=$?"
|
||||
''' % (lib, pane_file, keys_log, prelude, timeout, agent)
|
||||
run_env = dict(os.environ)
|
||||
run_env.pop("MAM_AUTO_TRUST", None)
|
||||
run_env.update(env or {})
|
||||
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True,
|
||||
cwd=str(tmp_path), env=run_env)
|
||||
sent = keys_log.read_text().splitlines() if keys_log.exists() else []
|
||||
return res, sent
|
||||
|
||||
|
||||
AGENT_DIALOGS = [
|
||||
("agy", "Do you trust the contents of this project?\n > Yes, I trust this folder",
|
||||
["send-keys -t test-sess Enter"]),
|
||||
("grok", "Do you trust the contents of this directory?",
|
||||
["send-keys -t test-sess y"]),
|
||||
("codex", "Do you trust the contents of this directory?\n > 1. Yes, continue",
|
||||
["send-keys -t test-sess Enter"]),
|
||||
("codex", "Update available\n 1. Update now\n 2. Skip\n 3. Skip until next version",
|
||||
["send-keys -t test-sess 2"]),
|
||||
("claude", "Do you trust the files in this folder?\n Yes, I trust this folder",
|
||||
["send-keys -t test-sess Enter"]),
|
||||
("claude", "Try the new fullscreen renderer?\n Yes, try it",
|
||||
["send-keys -t test-sess Escape"]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent,pane,expected", AGENT_DIALOGS)
|
||||
def test_s4_each_agents_dialog_signature_gets_its_own_keys(
|
||||
mam_sandbox, agent, pane, expected):
|
||||
"""S4 (stubbed pane + stubbed send-keys): the adapter's signature is
|
||||
matched and exactly its keys are sent."""
|
||||
res, sent = _run_dialogs(mam_sandbox, agent, pane)
|
||||
assert sent == expected, (res.stdout, res.stderr, sent)
|
||||
|
||||
|
||||
def test_s4b_every_adapter_publishes_the_fact(mam_sandbox):
|
||||
"""S4b (real `lib_py.agents facts`): MAM_STARTUP_DIALOGS is part of the
|
||||
facts contract for every agent, so the shell never has to know an agent
|
||||
name. An agent with no known blocking modal publishes an empty list."""
|
||||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
skills = os.path.join(repo, ".agents", "skills")
|
||||
seen = {}
|
||||
for agent in ("claude", "agy", "grok", "codex", "opencode", "hermes"):
|
||||
out = subprocess.run(
|
||||
[os.path.join(repo, ".venv", "bin", "python"), "-m", "lib_py.agents",
|
||||
"facts", agent],
|
||||
capture_output=True, text=True, cwd=repo,
|
||||
env=dict(os.environ, PYTHONPATH=skills)).stdout
|
||||
line = [l for l in out.splitlines() if l.startswith("MAM_STARTUP_DIALOGS=")]
|
||||
assert line, "no MAM_STARTUP_DIALOGS for %s" % agent
|
||||
seen[agent] = json.loads(line[0].split("=", 1)[1].strip("'"))
|
||||
for agent in ("claude", "agy", "grok", "codex"):
|
||||
assert seen[agent], "%s must publish its start-up dialogs" % agent
|
||||
for pattern, keys, kind in seen[agent]:
|
||||
assert kind in ("trust", "notice"), (agent, kind)
|
||||
assert keys, (agent, pattern)
|
||||
assert seen["opencode"] == [] and seen["hermes"] == []
|
||||
|
||||
|
||||
def test_s5_an_accepted_dialog_still_on_screen_is_not_resent(mam_sandbox):
|
||||
"""S5 (stubbed pane + stubbed send-keys): #24's comment reports that
|
||||
`--source visible` keeps showing the dialog for a while after it was
|
||||
accepted, and _pane_capture hardcodes that source. The pane here never
|
||||
changes, so a frame-count rule would send the keys again every poll; the
|
||||
time cooldown must keep it at one."""
|
||||
res, sent = _run_dialogs(
|
||||
mam_sandbox, "grok", "Do you trust the contents of this directory?",
|
||||
timeout=3, env={"MAM_DIALOG_RESEND_S": "60"})
|
||||
assert sent == ["send-keys -t test-sess y"], (res.stdout, res.stderr, sent)
|
||||
|
||||
|
||||
def test_s5b_the_cooldown_expiring_allows_one_more_try(mam_sandbox):
|
||||
"""S5b: the cooldown is a rate limit, not a one-shot - a dialog that really
|
||||
did not take the keys is retried once the window passes."""
|
||||
res, sent = _run_dialogs(
|
||||
mam_sandbox, "grok", "Do you trust the contents of this directory?",
|
||||
timeout=4, env={"MAM_DIALOG_RESEND_S": "1"})
|
||||
assert len(sent) >= 2, (res.stdout, res.stderr, sent)
|
||||
assert set(sent) == {"send-keys -t test-sess y"}, sent
|
||||
|
||||
|
||||
def test_s5c_readiness_is_judged_by_the_ready_tokens_not_by_the_dialog_leaving(
|
||||
mam_sandbox):
|
||||
"""S5c: a ready screen returns 0 without sending anything."""
|
||||
res, sent = _run_dialogs(mam_sandbox, "grok", "Grok ready\n>>> ", timeout=3)
|
||||
assert sent == [], sent
|
||||
assert "rc=0" in res.stdout, res.stdout + res.stderr
|
||||
|
||||
|
||||
def test_s6_no_auto_trust_leaves_trust_dialogs_alone_but_answers_notices(
|
||||
mam_sandbox):
|
||||
"""S6 (stubbed): --no-auto-trust. codex trust is left to a human with a
|
||||
warning; the update notice still gets answered, since skipping it changes
|
||||
nothing and it blocks the session just as hard."""
|
||||
res, sent = _run_dialogs(
|
||||
mam_sandbox, "codex", "Do you trust the contents of this directory?\n > 1. Yes, continue",
|
||||
timeout=1, env={"MAM_AUTO_TRUST": "0"})
|
||||
assert sent == [], sent
|
||||
assert "no-auto-trust" in res.stderr, res.stderr
|
||||
|
||||
res, sent = _run_dialogs(
|
||||
mam_sandbox, "codex", "Update available\n 1. Update now\n 3. Skip until next version",
|
||||
timeout=1, env={"MAM_AUTO_TRUST": "0"})
|
||||
assert sent == ["send-keys -t test-sess 2"], (res.stderr, sent)
|
||||
|
||||
|
||||
def test_s6b_create_session_no_longer_gates_dialogs_on_claude(mam_sandbox):
|
||||
"""S6b (source read): the #24 root cause was the `AGENT = claude` gate at
|
||||
the create_session call site, so the gate's absence is the regression."""
|
||||
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
src = open(os.path.join(repo, ".agents", "skills", "multi-agent-mux-create",
|
||||
"scripts", "create_session.sh")).read()
|
||||
call = [l for l in src.splitlines() if "handle_startup_dialogs" in l]
|
||||
assert call, "create_session no longer answers start-up dialogs at all"
|
||||
assert all('"$AGENT"' in l for l in call), call
|
||||
assert 'if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]' not in src
|
||||
Reference in new issue
Block a user