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:
GodopuandClaude Opus 5 committed 2026-09-22 04:22:09 +00:00
1 parent 3bcaa39ab0
commit 5ba04a27ca
11 files changed
+344 -29

No files matched your search

+8 -2
View File
@@ -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():
+14 -3
View File
@@ -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():
+142
View File
@@ -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