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
+106
-22
@@ -2204,7 +2204,7 @@ wait_for_tui_ready() {
|
||||
# handle it and do NOT declare ready on this iteration.
|
||||
tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20)
|
||||
if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
handle_startup_dialogs "$sess" 1 "$agent" || true
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
@@ -2254,7 +2254,7 @@ wait_for_tui_ready() {
|
||||
fi
|
||||
|
||||
# 4. Startup dialog & hint resolution if not ready
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
handle_startup_dialogs "$sess" 1 "$agent" || true
|
||||
sleep 1
|
||||
done
|
||||
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
|
||||
@@ -2702,29 +2702,113 @@ send_keys_safe() {
|
||||
return 4
|
||||
}
|
||||
|
||||
# handle_startup_dialogs <sess> [timeout_sec=20]
|
||||
# Post-start/resume dialog policy for claude: accept the trust / bypass
|
||||
# dialogs ONLY when their signature is positively on screen; return as soon
|
||||
# as the TUI banner is ready. Replaces the blind Enter/Down/Enter sequence.
|
||||
# _mam_load_dialog_facts <agent>
|
||||
# Populate MAM_STARTUP_DIALOGS for <agent>, once per agent per shell. Only that
|
||||
# one fact is taken: `facts` is an eval contract (F8), and evaluating all of it
|
||||
# here would clobber ready/placeholder tokens a caller deliberately set.
|
||||
# An agent the adapters do not know keeps an empty list.
|
||||
_mam_load_dialog_facts() {
|
||||
local agent="$1" _f=""
|
||||
if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then
|
||||
return 0
|
||||
fi
|
||||
_f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null | sed -n 's/^MAM_STARTUP_DIALOGS=//p')" || _f=""
|
||||
if [ -n "$_f" ]; then
|
||||
eval "MAM_STARTUP_DIALOGS=$_f"
|
||||
else
|
||||
MAM_STARTUP_DIALOGS='[]'
|
||||
fi
|
||||
_MAM_DIALOGS_AGENT="$agent"
|
||||
return 0
|
||||
}
|
||||
|
||||
# _mam_dialog_pick — first actionable dialog on this frame.
|
||||
# stdout: "send|<idx>|<key> <key>..." or "skip|<idx>|", nothing when there is
|
||||
# none to act on. rc 1 means "nothing to do" (matched-but-cooling counts).
|
||||
_mam_dialog_pick() {
|
||||
MAM_DLG_PANE="$1" MAM_DLG_JSON="$2" MAM_DLG_TRUST="$3" \
|
||||
MAM_DLG_STATE="$4" MAM_DLG_NOW="$5" MAM_DLG_COOLDOWN="$6" \
|
||||
python3 -c '
|
||||
import os, re, sys, json
|
||||
pane = os.environ.get("MAM_DLG_PANE", "")
|
||||
try:
|
||||
dialogs = json.loads(os.environ.get("MAM_DLG_JSON") or "[]")
|
||||
except Exception:
|
||||
dialogs = []
|
||||
auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0"
|
||||
try:
|
||||
now = int(os.environ.get("MAM_DLG_NOW") or 0)
|
||||
cooldown = int(os.environ.get("MAM_DLG_COOLDOWN") or 5)
|
||||
except ValueError:
|
||||
sys.exit(1)
|
||||
state = {}
|
||||
for tok in (os.environ.get("MAM_DLG_STATE") or "").split():
|
||||
k, _, v = tok.partition(":")
|
||||
try:
|
||||
state[int(k)] = int(v)
|
||||
except ValueError:
|
||||
pass
|
||||
for idx, entry in enumerate(dialogs):
|
||||
try:
|
||||
pattern, keys, kind = entry[0], entry[1], entry[2]
|
||||
if not re.search(pattern, pane):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
last = state.get(idx)
|
||||
if last is not None and 0 <= now - last < cooldown:
|
||||
sys.exit(1)
|
||||
if kind == "trust" and not auto_trust:
|
||||
print("skip|%d|" % idx)
|
||||
sys.exit(0)
|
||||
print("send|%d|%s" % (idx, " ".join(keys)))
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
'
|
||||
}
|
||||
|
||||
# handle_startup_dialogs <sess> [timeout_sec=20] [agent=claude]
|
||||
# Answer the blocking modals an agent shows before its TUI is usable. The
|
||||
# signatures and accept keys come from the agent adapter (MAM_STARTUP_DIALOGS),
|
||||
# so this is agent-agnostic - issue #24: it used to be a claude-only ladder
|
||||
# called only for claude, and agy/grok/codex sat on their trust prompt until
|
||||
# the readiness wait timed out and onboarding was skipped.
|
||||
#
|
||||
# `--source visible` keeps showing an accepted dialog for a while (#24
|
||||
# comment), so a signature still on screen is NOT evidence that the keys were
|
||||
# missed: re-sending for the same signature is gated by MAM_DIALOG_RESEND_S
|
||||
# (default 5s) and acceptance is judged only by the ready tokens.
|
||||
#
|
||||
# MAM_AUTO_TRUST=0 leaves `trust` dialogs unanswered (warned once per
|
||||
# cooldown); `notice` dialogs are always answered.
|
||||
handle_startup_dialogs() {
|
||||
local sess="$1" timeout="${2:-20}" waited=0 pane
|
||||
local sess="$1" timeout="${2:-20}" agent="${3:-claude}"
|
||||
local waited=0 pane pick action rest idx keys key now
|
||||
local cooldown="${MAM_DIALOG_RESEND_S:-5}"
|
||||
local auto_trust="${MAM_AUTO_TRUST:-1}"
|
||||
_mam_load_dialog_facts "$agent"
|
||||
local dialogs="${MAM_STARTUP_DIALOGS:-[]}"
|
||||
local ready_re="${MAM_STRONG_READY_TOKENS:-${MAM_READY_TOKENS:-}}"
|
||||
while [ "$waited" -lt "$timeout" ]; do
|
||||
pane=$(_pane_tail "$sess" 20)
|
||||
if printf '%s\n' "$pane" | grep -Eq 'Do you trust the files|Yes, I trust this folder|Quick safety check'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -q 'Yes, try it'; then
|
||||
# Fullscreen-renderer upsell modal (not the idle /tui tip). Enter would
|
||||
# accept and restart the session without permission flags — reject.
|
||||
_sks_herdr send-keys -t "$sess" Escape
|
||||
elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then
|
||||
_sks_herdr send-keys -t "$sess" Down
|
||||
sleep 0.3
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -q 'Press Enter to continue'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -Eq "${_MAM_READY_TOKENS_CLAUDE:-Anthropic|Assistant|Chat|Welcome}"; then
|
||||
now=$(date +%s)
|
||||
pick=""
|
||||
pick=$(_mam_dialog_pick "$pane" "$dialogs" "$auto_trust" "${_MAM_DIALOG_SENT:-}" "$now" "$cooldown" 2>/dev/null || true)
|
||||
if [ -n "$pick" ]; then
|
||||
action="${pick%%|*}"
|
||||
rest="${pick#*|}"
|
||||
idx="${rest%%|*}"
|
||||
keys="${rest#*|}"
|
||||
if [ "$action" = "send" ]; then
|
||||
for key in $keys; do
|
||||
_sks_herdr send-keys -t "$sess" "$key" || true
|
||||
sleep 0.3
|
||||
done
|
||||
else
|
||||
echo "handle_startup_dialogs: a trust dialog is open on '$sess' and --no-auto-trust was given; accept it manually" >&2
|
||||
fi
|
||||
_MAM_DIALOG_SENT="${_MAM_DIALOG_SENT:-} $idx:$now"
|
||||
elif [ -n "$ready_re" ] && printf '%s\n' "$pane" | grep -Eq "$ready_re" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
@@ -63,6 +63,7 @@ def main():
|
||||
print(f"MAM_STRONG_READY_TOKENS={q(adapter.strong_ready_tokens)}")
|
||||
print(f"MAM_WEAK_READY_TOKENS={q(adapter.weak_ready_tokens)}")
|
||||
print(f"MAM_MODAL_TOKENS={q(adapter.modal_tokens or '')}")
|
||||
print(f"MAM_STARTUP_DIALOGS={q(json.dumps(adapter.startup_dialogs))}")
|
||||
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
|
||||
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'antigravity-cli'
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> list:
|
||||
# #24: "Do you trust the contents of this project?" with
|
||||
# "> Yes, I trust this folder" preselected - Enter accepts.
|
||||
return [
|
||||
('Do you trust the contents of this project|Yes, I trust this folder',
|
||||
['Enter'], 'trust'),
|
||||
]
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('conversation_id', 'conversation_db', 'conversation_brain_dir')
|
||||
|
||||
@@ -44,6 +44,19 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
def modal_tokens(self) -> Optional[str]:
|
||||
return 'Try the new fullscreen renderer\\?'
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> list:
|
||||
# The ladder handle_startup_dialogs used to hardcode, unchanged.
|
||||
return [
|
||||
('Do you trust the files|Yes, I trust this folder|Quick safety check', ['Enter'], 'trust'),
|
||||
# Fullscreen-renderer upsell, not the idle /tui tip: Enter would
|
||||
# accept and restart the session without the permission flags.
|
||||
('Yes, try it', ['Escape'], 'notice'),
|
||||
('Yes, proceed', ['Down', 'Enter'], 'trust'),
|
||||
('Resuming the full session', ['Enter'], 'notice'),
|
||||
('Press Enter to continue', ['Enter'], 'notice'),
|
||||
]
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
|
||||
|
||||
|
||||
@@ -24,6 +24,20 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'codex-cli'
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> list:
|
||||
# #24: the trust prompt lists "1. Yes, continue" preselected, so Enter
|
||||
# accepts. Trust here also loads this project's local config, hooks and
|
||||
# exec policy, which is why --no-auto-trust exists.
|
||||
#
|
||||
# The 0.155.1 update notice (observed 2026-09-22) blocks the session the
|
||||
# same way; "2" is Skip, the choice that changes nothing.
|
||||
return [
|
||||
('Do you trust the contents of this directory|1\\. Yes, continue',
|
||||
['Enter'], 'trust'),
|
||||
('1\\. Update now|Skip until next version', ['2'], 'notice'),
|
||||
]
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('session_id',)
|
||||
|
||||
@@ -26,6 +26,13 @@ class GrokAgentAdapter(BaseAgentAdapter):
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'grok-build'
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> list:
|
||||
# #24: "Do you trust the contents of this directory?" answered with y.
|
||||
return [
|
||||
('Do you trust the contents of this directory', ['y'], 'trust'),
|
||||
]
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('session_id', 'session_jsonl')
|
||||
|
||||
@@ -156,6 +156,21 @@ class BaseAgentAdapter:
|
||||
def modal_tokens(self) -> Optional[str]:
|
||||
return None
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> List[tuple]:
|
||||
"""Blocking modals this CLI can show before its TUI is usable, most
|
||||
specific first: (pattern, keys, kind).
|
||||
|
||||
`pattern` is an extended-regex signature matched against the pane tail,
|
||||
`keys` are herdr send-keys arguments sent in order, and `kind` is
|
||||
'trust' (a directory-trust prompt, gated by --auto-trust) or 'notice'
|
||||
(anything else that blocks the start, always answered).
|
||||
|
||||
Issue #24: without this the start-up trust prompts of agy, grok and
|
||||
codex burn the whole readiness timeout and onboarding is skipped.
|
||||
"""
|
||||
return []
|
||||
|
||||
@property
|
||||
def strong_ready_tokens(self) -> str:
|
||||
return self.ready_tokens
|
||||
|
||||
@@ -43,6 +43,7 @@ Options:
|
||||
use --herdr-session for that.
|
||||
--submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt
|
||||
--onboard automatically submit a project alignment/orientation job to the new agent
|
||||
--no-auto-trust do not answer a start-up directory-trust dialog (default: answer it)
|
||||
--no-onboard disable automatic onboarding job submission
|
||||
--no-isolate legacy flag (no-op; all sessions use global configuration)
|
||||
--isolate legacy flag (no-op; all sessions use global configuration)
|
||||
@@ -60,6 +61,11 @@ HERDR_SERVER_OPT=""
|
||||
HERDR_WORKSPACE_OPT=""
|
||||
SUBMIT_JOB_PROMPT=""
|
||||
ONBOARD=1
|
||||
# Issue #24: answer a start-up directory-trust dialog for the --workspace the
|
||||
# operator named. --no-auto-trust leaves those unanswered (notices are always
|
||||
# answered); codex in particular loads project-local config, hooks and exec
|
||||
# policy once trusted.
|
||||
AUTO_TRUST=1
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -74,6 +80,8 @@ while [ $# -gt 0 ]; do
|
||||
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
|
||||
--onboard) ONBOARD=1; shift ;;
|
||||
--no-onboard) ONBOARD=0; shift ;;
|
||||
--auto-trust) AUTO_TRUST=1; shift ;;
|
||||
--no-auto-trust) AUTO_TRUST=0; shift ;;
|
||||
--isolate) echo "NOTE: --isolate/--no-isolate is a no-op — config-home isolation was removed; sessions always use global config." >&2; shift ;; # legacy compatibility
|
||||
--no-isolate) echo "NOTE: --isolate/--no-isolate is a no-op — config-home isolation was removed; sessions always use global config." >&2; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
@@ -81,6 +89,8 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
export MAM_AUTO_TRUST="$AUTO_TRUST"
|
||||
|
||||
if [ -n "$HERDR_SERVER_OPT" ]; then
|
||||
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
|
||||
fi
|
||||
@@ -331,8 +341,11 @@ if [ "$TUI_READY_RC" -ne 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||
handle_startup_dialogs "$SESSION_NAME" 15
|
||||
# Issue #24: every agent, not only claude. The signatures and accept keys come
|
||||
# from the agent adapter, so an agy/grok/codex trust prompt is answered here
|
||||
# too instead of sitting until the readiness wait gives up.
|
||||
if [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||
handle_startup_dialogs "$SESSION_NAME" 15 "$AGENT"
|
||||
fi
|
||||
|
||||
# pane 메타 캡처
|
||||
|
||||
@@ -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