From 17edf906765d40510b416832ef331aa34850aee5 Mon Sep 17 00:00:00 2001 From: Godopu Date: Fri, 28 Aug 2026 15:21:10 +0900 Subject: [PATCH] fix(tui): establish 2-tier readiness model, modal/hint token separation, and adapter modal contracts - Separate TUI dialog detection into blocking gate layer (_MAM_MODAL_TOKENS) and recovery layer (_MAM_HINT_TOKENS) to eliminate idle dialog starvation - Add modal_tokens contract across agent adapters (claude, cline) with dynamic scope facts resolution in send_keys_safe - Implement fail-closed resolution for ambiguous same-kind panes (R-1) and enforce WORKSPACE_ROOT export (R-2) - Preserve session on TUI readiness timeout when PID is alive for diagnostics - Add comprehensive test suite in tests/test_c1_tui_readiness.py and adapter contract assertions in test_a4 --- .agents/skills/lib.sh | 189 +++++- .agents/skills/lib_py/agents/__main__.py | 3 + .../skills/lib_py/agents/adapters/claude.py | 4 + .../skills/lib_py/agents/adapters/cline.py | 12 + .agents/skills/lib_py/agents/base.py | 12 + .../scripts/create_session.sh | 59 +- tests/test_a4_adapter_contract.py | 16 + tests/test_b19_headless_reconcile_fixes.py | 6 +- tests/test_c1_tui_readiness.py | 609 ++++++++++++++++++ 9 files changed, 868 insertions(+), 42 deletions(-) create mode 100644 tests/test_c1_tui_readiness.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 442e6a1..c054599 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -24,6 +24,7 @@ fi SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}" +export WORKSPACE_ROOT AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" _sanitize_herdr_agent_name() { @@ -43,12 +44,14 @@ _sanitize_herdr_agent_name() { h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8) elif command -v openssl >/dev/null 2>&1; then h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8) - else - h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000") fi - s="${s:0:23}-${h}" + if [ -n "$h" ]; then + s="${s:0:23}-$h" + else + s="${s:0:32}" + fi fi - printf '%s\n' "${s:0:32}" + printf '%s\n' "$s" } # Add common Homebrew and local binary paths to PATH to ensure they are available in non-interactive shells @@ -59,7 +62,9 @@ for dir in /home/linuxbrew/.linuxbrew/bin /home/linuxbrew/.linuxbrew/sbin "$HOME done # Central TUI dialog and readiness validation tokens (OP-6) -_MAM_DIALOG_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|Press Enter to continue|browser to authenticate|Use arrow keys|Esc to cancel|Resuming the full session|Resume from summary' +_MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?' +_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue' +_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}" _MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku' # Workspace-relative defaults with environment overrides (Phase Z) @@ -348,17 +353,18 @@ _resolve_herdr_pane_id() { local ws_flag=() [ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws") pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \ - | TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" python3 -c " + | TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$PWD}" python3 -c " import sys, json, os tn = os.environ.get('TARGET_NAME', '') tsa = os.environ.get('TARGET_SAN', '') tws = os.environ.get('TARGET_WS', '') +ws_root = os.environ.get('WORKSPACE_ROOT', os.getcwd()) try: panes = json.load(sys.stdin).get('result', {}).get('panes', []) # Client-side filter in case an older herdr ignores --workspace. if tws: panes = [p for p in panes if p.get('workspace_id') == tws] - # ISSUE-2: exact match only. + # ISSUE-2: exact match first, then agent-sessions.yaml lookup fallback for key in ('label', 'name', 'agent'): for p in panes: v = p.get(key) @@ -367,6 +373,27 @@ try: if pid: print(pid) sys.exit(0) + agent_kind = None + yaml_path = os.path.join(ws_root, '.mam', 'agent-sessions.yaml') + if os.path.isfile(yaml_path): + try: + import yaml + with open(yaml_path) as f: + ydata = yaml.safe_load(f) or {} + for s in ydata.get('herdr_sessions', []): + if s.get('name') == tn or s.get('name') == tsa: + agent_kind = s.get('pane', {}).get('cmd') or '' + break + except Exception as e: + sys.stderr.write(f'warning: yaml load failed: {e}\n') + if agent_kind: + matching = [p.get('pane_id') for p in panes if p.get('agent') == agent_kind and p.get('pane_id')] + if len(matching) == 1: + print(matching[0]) + sys.exit(0) + elif len(matching) > 1: + # R-1: Ambiguous resolution with multiple same-kind panes — fail closed + sys.exit(1) except Exception: pass sys.exit(1) @@ -731,13 +758,13 @@ except Exception: else res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true) fi - # Fatal CLI errors abort immediately. agent_not_ready is herdr's - # documented "process is up, blocked on a dialog" status — continue - # to wait_for_tui_ready. Do NOT treat "timed out waiting for agent - # startup" as success: herdr returns that for a dead process too. if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then break fi + # Fatal CLI errors abort immediately. agent_not_ready is the + # documented "process is up, blocked on a dialog" status — continue + # to wait_for_tui_ready. Do NOT treat "timed out waiting for agent + # startup" as success: herdr returns that for a dead process too. if echo "$res" | grep -qE "agent_started|agent_not_ready"; then success=1 break @@ -1793,41 +1820,117 @@ start_watchdog() { # wait_for_tui_ready # Waits up to 30 seconds for the agent's TUI to render its welcome screen. +# Returns 0 on readiness, 1 on timeout, 2 on unobservable/headless. wait_for_tui_ready() { local sess="$1" agent="$2" - # Self-contained resolution: works whether or not caller evaluated facts (C1-(3)). - local tokens="${MAM_READY_TOKENS:-}" - if [ -z "$tokens" ]; then + local strong_tokens="${MAM_STRONG_READY_TOKENS:-}" + local weak_tokens="${MAM_WEAK_READY_TOKENS:-}" + local placeholder="${MAM_INPUT_PLACEHOLDER:-}" + local prompt="${MAM_INPUT_PROMPT:-}" + local rule_pattern="${MAM_INPUT_RULE_PATTERN:-}" + local modal_tokens="${MAM_MODAL_TOKENS:-}" + + # Self-contained resolution: fetch facts in 1 call if not already in environment + if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "${MAM_READY_TOKENS:-}" ]; then local _facts="" _facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _facts="" - tokens="$(printf '%s\n' "$_facts" | sed -n 's/^MAM_READY_TOKENS=//p')" - [ -n "$tokens" ] && eval "tokens=$tokens" + if [ -n "$_facts" ]; then + eval "$_facts" + strong_tokens="${MAM_STRONG_READY_TOKENS:-}" + weak_tokens="${MAM_WEAK_READY_TOKENS:-}" + placeholder="${MAM_INPUT_PLACEHOLDER:-}" + prompt="${MAM_INPUT_PROMPT:-}" + rule_pattern="${MAM_INPUT_RULE_PATTERN:-}" + modal_tokens="${MAM_MODAL_TOKENS:-}" + fi fi - if [ -z "$tokens" ]; then + # Fallback if strong_tokens is empty but MAM_READY_TOKENS is set + if [ -z "$strong_tokens" ] && [ -n "${MAM_READY_TOKENS:-}" ]; then + strong_tokens="$MAM_READY_TOKENS" + fi + + if [ -z "$strong_tokens" ] && [ -z "$weak_tokens" ] && [ -z "$placeholder" ]; then echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2 return 1 fi - local i + + local empty_streak=0 + local empty_giveup="${MAM_READY_EMPTY_GIVEUP:-5}" + local modal_pat="$_MAM_MODAL_TOKENS" + [ -n "$modal_tokens" ] && modal_pat="${modal_pat}|${modal_tokens}" + + local i content tail_lines for i in {1..30}; do - if [ "$agent" = "claude" ]; then - handle_startup_dialogs "$sess" 1 || true - fi - if _pane_dialog_open "$sess"; then - if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then - _sks_herdr send-keys -t "$sess" Enter || true - sleep 1 + # 1. Single capture per iteration (T-3) + content=$(_pane_capture "$sess") + + # Consecutive blank capture check (C-6) + if [ -z "$content" ]; then + empty_streak=$((empty_streak + 1)) + if [ "$empty_streak" -ge "$empty_giveup" ]; then + echo "wait_for_tui_ready: unobservable/headless pane for '$sess' (consecutive empty captures: $empty_streak)" >&2 + return 2 fi sleep 1 continue fi - local content - content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "") - if [ -n "$content" ]; then - if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then - echo "✅ $agent TUI detected ready." - return 0 + empty_streak=0 + + # 2. Check modal dialogs FIRST (F-2): if a blocking modal signature is present in the tail lines, + # 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 + sleep 1 + continue + fi + + # 3. Tier 2 Readiness Evaluation (T-2b, T-4: Ready check first) + local s_matched=false + local w_matched=false + local c_matched=false + + # S-layer: Strong ready token match + if [ -n "$strong_tokens" ]; then + if echo "$content" | grep -E -q "$strong_tokens" 2>/dev/null; then + s_matched=true fi fi + + # S-layer: Placeholder match (must be >= 8 characters) + if [ -n "$placeholder" ] && [ "${#placeholder}" -ge 8 ]; then + if echo "$content" | grep -F -q "$placeholder" 2>/dev/null; then + s_matched=true + fi + fi + + # W-layer: Weak ready token match + if [ -n "$weak_tokens" ]; then + if echo "$content" | grep -E -q "$weak_tokens" 2>/dev/null; then + w_matched=true + fi + fi + + # C-layer: Corroboration (Prompt in last 5 non-blank lines OR rule pattern match) + if [ -n "$prompt" ]; then + if printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 5 | grep -F -q "$prompt" 2>/dev/null; then + c_matched=true + fi + fi + if [ -n "$rule_pattern" ]; then + if echo "$content" | grep -E -q "$rule_pattern" 2>/dev/null; then + c_matched=true + fi + fi + + # Decision: S OR (W AND C) + if [ "$s_matched" = true ] || { [ "$w_matched" = true ] && [ "$c_matched" = true ]; }; then + echo "✅ $agent TUI detected ready." + return 0 + fi + + # 4. Startup dialog & hint resolution if not ready + handle_startup_dialogs "$sess" 1 || true sleep 1 done echo "⚠️ TUI readiness check timed out for '$sess'." >&2 @@ -1920,11 +2023,14 @@ _pane_quiescent() { } # _pane_dialog_open — focus-stealing modal signatures (trust / -# permission / OAuth / list-selection), checked in the bottom 20 pane lines +# permission / OAuth / list-selection), checked in the bottom 8 pane lines # only (dialogs render near the input area; conversation text above must not # trigger this). Tokens must NOT appear on normal idle prompt screens. _pane_dialog_open() { - _pane_tail "$1" 20 | grep -Eq "$_MAM_DIALOG_TOKENS" + local extra="${MAM_MODAL_TOKENS:-}" + local pat="$_MAM_MODAL_TOKENS" + [ -n "$extra" ] && pat="${pat}|${extra}" + _pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat" } # send_keys_safe [job_id] @@ -1939,6 +2045,23 @@ _pane_dialog_open() { send_keys_safe() { local sess="$1" text="$2" job_id="${3:-adhoc}" local pre_submit deadline try + local MAM_MODAL_TOKENS="${MAM_MODAL_TOKENS:-}" + local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN + local MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY + + if [ -z "${MAM_MODAL_TOKENS:-}" ]; then + local _sks_agent="" + case "$sess" in + *-claude|*-claude-[0-9]*|claude|claude-[0-9]*) _sks_agent="claude" ;; + *-agy|*-agy-[0-9]*|agy|agy-[0-9]*) _sks_agent="agy" ;; + *-hermes|*-hermes-[0-9]*|hermes|hermes-[0-9]*) _sks_agent="hermes" ;; + *-cline|*-cline-[0-9]*|cline|cline-[0-9]*) _sks_agent="cline" ;; + *-grok|*-grok-[0-9]*|grok|grok-[0-9]*) _sks_agent="grok" ;; + esac + if [ -n "$_sks_agent" ]; then + eval "$("$(_delegate_py_bin)" -m lib_py.agents facts "$_sks_agent" 2>/dev/null || true)" + fi + fi local _q_rc=0 _pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$? diff --git a/.agents/skills/lib_py/agents/__main__.py b/.agents/skills/lib_py/agents/__main__.py index 5349db3..ce07ae4 100644 --- a/.agents/skills/lib_py/agents/__main__.py +++ b/.agents/skills/lib_py/agents/__main__.py @@ -23,6 +23,9 @@ def main(): print(f"MAM_INPUT_PLACEHOLDER={q(adapter.input_placeholder or '')}") print(f"MAM_INPUT_RULE_PATTERN={q(adapter.input_rule_pattern or '')}") print(f"MAM_READY_TOKENS={q(adapter.ready_tokens)}") + 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_EXIT_KEY={q(adapter.exit_key)}") print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}") diff --git a/.agents/skills/lib_py/agents/adapters/claude.py b/.agents/skills/lib_py/agents/adapters/claude.py index e824a14..189334d 100644 --- a/.agents/skills/lib_py/agents/adapters/claude.py +++ b/.agents/skills/lib_py/agents/adapters/claude.py @@ -40,6 +40,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter): def input_rule_pattern(self) -> str: return '─{10,}' + @property + def modal_tokens(self) -> Optional[str]: + return 'Try the new fullscreen renderer\\?' + def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str: return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl" diff --git a/.agents/skills/lib_py/agents/adapters/cline.py b/.agents/skills/lib_py/agents/adapters/cline.py index cb062e7..fe6fe30 100644 --- a/.agents/skills/lib_py/agents/adapters/cline.py +++ b/.agents/skills/lib_py/agents/adapters/cline.py @@ -16,6 +16,18 @@ class ClineAgentAdapter(BaseAgentAdapter): def ready_tokens(self) -> str: return 'Cline|history|Chat|What can I do|slash commands' + @property + def strong_ready_tokens(self) -> str: + return 'Cline|What can I do|slash commands' + + @property + def weak_ready_tokens(self) -> str: + return 'history|Chat' + + @property + def modal_tokens(self) -> Optional[str]: + return 'Select API Provider|Enter API Key|Select an API provider|API Provider|Cline API key' + @property def exit_key(self) -> str: return '/exit' diff --git a/.agents/skills/lib_py/agents/base.py b/.agents/skills/lib_py/agents/base.py index 27e2383..ca2a306 100644 --- a/.agents/skills/lib_py/agents/base.py +++ b/.agents/skills/lib_py/agents/base.py @@ -73,6 +73,18 @@ class BaseAgentAdapter: def input_rule_pattern(self) -> Optional[str]: return None + @property + def modal_tokens(self) -> Optional[str]: + return None + + @property + def strong_ready_tokens(self) -> str: + return self.ready_tokens + + @property + def weak_ready_tokens(self) -> str: + return '' + def derive_session_name(self, slug: str, role: str = "creator") -> str: r = role.lower() return f"{slug}-{r}-{self.name}" diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 351fd48..bed4722 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -225,7 +225,7 @@ spawn cleanup_herdr_on_error() { local exit_code=$? if [ $exit_code -ne 0 ]; then - echo "⚠️ Error occurred during initialization. Rolling back and killing herdr session '$SESSION_NAME'..." >&2 + echo "⚠️ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2 _herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true fi } @@ -237,11 +237,48 @@ if [ -z "$HERDR_SERVER_OPT" ]; then fi # TUI 준비 대기 -if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then - echo "ERROR: agent TUI never became ready — aborting (rollback via trap)" >&2 - exit 1 +TUI_READY_RC=0 +wait_for_tui_ready "$SESSION_NAME" "$AGENT" || TUI_READY_RC=$? +LAST_VISIBLE_STATUS_OVERRIDE="" + +if [ "$TUI_READY_RC" -ne 0 ]; then + # 1. Diagnostic dump + DIAG_DIR="${WORKSPACE:-$PWD}/.mam/diagnostics" + mkdir -p "$DIAG_DIR" 2>/dev/null || true + DIAG_FILE="$DIAG_DIR/${SESSION_NAME}-$(date +%s).txt" + { + echo "=== MAM Diagnostic Dump ===" + echo "Date: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" + echo "Session: $SESSION_NAME" + echo "Agent: $AGENT" + echo "ReturnCode: $TUI_READY_RC" + echo "--- Pane Raw Capture ---" + _pane_capture "$SESSION_NAME" 2>/dev/null || true + } > "$DIAG_FILE" 2>/dev/null || true + if [ ! -f "$DIAG_FILE" ]; then + echo "WARNING: Failed to write diagnostic dump to $DIAG_FILE" >&2 + fi + + # 2. Check pane PID liveness + PANE_PID_CHECK=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "") + ALIVE=false + if [ -n "$PANE_PID_CHECK" ] && [[ "$PANE_PID_CHECK" =~ ^[0-9]+$ ]]; then + if kill -0 "$PANE_PID_CHECK" 2>/dev/null; then + ALIVE=true + fi + fi + + if [ "$ALIVE" = true ] && [ "${MAM_KILL_ON_READY_TIMEOUT:-0}" != "1" ]; then + echo "WARNING: agent TUI never became ready within timeout, but process (PID $PANE_PID_CHECK) is alive. Preserving session for diagnosis." >&2 + LAST_VISIBLE_STATUS_OVERRIDE="tui-ready-timeout" + SUBMIT_JOB_PROMPT="" # disable instruction injection on unready session + else + echo "ERROR: agent TUI never became ready — aborting (rollback via trap)" >&2 + exit 42 + fi fi -if [ "$AGENT" = "claude" ]; then + +if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then handle_startup_dialogs "$SESSION_NAME" 15 fi @@ -257,8 +294,8 @@ NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') # herdr server shim (HERDR_SESSION_NAME picked up by the lib.sh shim). START_CMD="HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\"" -# If --onboard is specified, automatically build the onboarding prompt -if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then +# If --onboard is specified, automatically build the onboarding prompt (if session is ready) +if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks: 1. Read the project documentation at README.md and the multi-agent protocol guidelines at .agents/MULTI_AGENT_RULES.md to understand the design rules. 2. Run 'git status' and 'git diff' to analyze the current modifications and active work in the repository. @@ -268,6 +305,9 @@ fi # agent-sessions.yaml 에 append DELEGATE_JOB_ID="" +if [ -n "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then + SUBMIT_JOB_PROMPT="" +fi if [ -n "$SUBMIT_JOB_PROMPT" ]; then delegate_agent="${MAM_DELEGATE_AGENT_KEY:-}" if [ -z "$delegate_agent" ]; then @@ -306,6 +346,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \ MAM_WS_LABEL="$MAM_WS_LABEL" \ SESSION_UUID="$SESSION_UUID" \ + LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \ DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF' name = os.environ['SESSION_NAME'] agent = os.environ['AGENT'] @@ -389,6 +430,10 @@ elif agent == 'grok': entry['session_id_verified'] = False entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified" +last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '') +if last_vis_override: + entry['last_visible_status'] = last_vis_override + sessions.append(entry) snap = d.setdefault('snapshot', {}) diff --git a/tests/test_a4_adapter_contract.py b/tests/test_a4_adapter_contract.py index 01b0606..3c55339 100644 --- a/tests/test_a4_adapter_contract.py +++ b/tests/test_a4_adapter_contract.py @@ -84,6 +84,22 @@ def test_adapter_required_properties(): assert adapter.delegate_agent_key == delk assert adapter.identity_cache_fields == cache_f + +def test_adapter_modal_tokens_contract(): + """F-8: Verify adapter-level modal_tokens contract (T-2d).""" + expected_modal = { + 'claude': 'Try the new fullscreen renderer\\?', + 'cline': 'Select API Provider|Enter API Key|Select an API provider|API Provider|Cline API key', + 'agy': None, + 'hermes': None, + 'grok': None, + } + for agent, expected in expected_modal.items(): + adapter = get_adapter(agent) + assert adapter is not None + assert adapter.modal_tokens == expected, f"modal_tokens mismatch for {agent}" + + def test_facts_bridge_eval_contract(): import subprocess, sys from pathlib import Path diff --git a/tests/test_b19_headless_reconcile_fixes.py b/tests/test_b19_headless_reconcile_fixes.py index c541fe1..82f0ec4 100644 --- a/tests/test_b19_headless_reconcile_fixes.py +++ b/tests/test_b19_headless_reconcile_fixes.py @@ -284,8 +284,10 @@ 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.""" content = open(_LIB_SH, encoding="utf-8").read() - token_line = next(l for l in content.splitlines() if l.startswith("_MAM_DIALOG_TOKENS=")) - assert "Yes, try it" not in token_line + 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 diff --git a/tests/test_c1_tui_readiness.py b/tests/test_c1_tui_readiness.py new file mode 100644 index 0000000..072f42b --- /dev/null +++ b/tests/test_c1_tui_readiness.py @@ -0,0 +1,609 @@ +# test_c1_tui_readiness.py — Unit and integration tests for TUI readiness, +# 2-tier readiness model, modal/hint token separation, and session diagnostics/preservation. + +import os, subprocess, time, json +from pathlib import Path +import pytest + +_LIB_SH = str(Path(__file__).resolve().parent.parent / ".agents" / "skills" / "lib.sh") +_CREATE_SH = str(Path(__file__).resolve().parent.parent / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh") + + +def _run_lib_helpers(script_body, env_extra=None): + import tempfile, shutil + tmp_dir = tempfile.mkdtemp(prefix="test_tui_ready_") + try: + env = dict(os.environ) + if env_extra: + env.update(env_extra) + env["WORKSPACE_ROOT"] = tmp_dir + wrapper = f""" +set -euo pipefail +cd "{tmp_dir}" +source "{_LIB_SH}" +_init_herdr_isolation() {{ :; }} +{script_body} +""" + return subprocess.run(["bash", "-c", wrapper], capture_output=True, text=True, env=env, cwd=tmp_dir) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def _lib_helper_src(): + content = open(_LIB_SH, encoding="utf-8").read() + start = content.find("_herdr_ws_id_file() {") + end = content.find('cmd="${1:-}"', start) + assert start != -1 and end != -1 + return content[start:end] + + + +def test_c1_hint_tokens_only_dialog_closed(): + """C-1: Hint tokens (Use arrow keys / Esc to cancel / Press Enter) alone must return dialog CLOSED.""" + screen = """Select an option: + 1. Option A + 2. Option B +Use arrow keys to navigate · Esc to cancel +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "DIALOG_CLOSED" in res.stdout + + +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).""" + screen = """Some startup banner +Press Enter to continue +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +KEYS=/tmp/mam-press-enter-keys.$$ +: > "$KEYS" +_sks_herdr() {{ + if [ "${{1:-}}" = "send-keys" ]; then echo "$*" >> "$KEYS"; fi + return 0 +}} +sleep() {{ :; }} +if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi +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=$? +echo "RC=$rc" +echo "KEYS_COUNT=$(grep -c "send-keys -t dummy-sess Enter" "$KEYS" || echo 0)" +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 + + +def test_c1c_transition_to_upsell_modal_sends_escape_not_enter(): + """C-1c: Screen transition from Press Enter to Upsell Modal (Yes, try it) sends Escape, not duplicate Enter.""" + state_file = f"/tmp/mam-screen-state.{os.getpid()}" + script = f""" +echo "1" > "{state_file}" +_pane_capture() {{ + local s + s=$(cat "{state_file}" 2>/dev/null || echo 1) + if [ "$s" = "1" ]; then + printf 'Some banner\\nPress Enter to continue\\n' + else + printf 'Claude Code\\nYes, try it\\n' + fi +}} +KEYS=/tmp/mam-transition-keys.{os.getpid()} +: > "$KEYS" +_sks_herdr() {{ + if [ "${{1:-}}" = "send-keys" ]; then + echo "$*" >> "$KEYS" + # Transition to screen 2 on first Enter + if [ "${{4:-}}" = "Enter" ]; then + echo "2" > "{state_file}" + fi + fi + return 0 +}} +sleep() {{ :; }} +export MAM_STRONG_READY_TOKENS='AnthropicReady' +export MAM_WEAK_READY_TOKENS='' +export MAM_INPUT_PLACEHOLDER='' +export MAM_TUI_TIMEOUT=2 +rc=0 +wait_for_tui_ready dummy-sess claude 2 || rc=$? +echo "RC=$rc" +echo "KEYS_CONTENT=$(tr '\\n' '|' < "$KEYS")" +rm -f "$KEYS" "{state_file}" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "send-keys -t dummy-sess Enter" in res.stdout + assert "send-keys -t dummy-sess Escape" in res.stdout + assert "send-keys -t dummy-sess Enter|send-keys -t dummy-sess Escape|" in res.stdout + + +def test_c2_modal_tokens_dialog_open(): + """C-2: High-confidence modal tokens (e.g. Do you trust the files) must return dialog OPEN.""" + screen = """Security Alert +Do you trust the files in this repository? +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "DIALOG_OPEN" in res.stdout + + +def test_c3_strong_token_and_hint_token_readiness_succeeds(): + """C-3: Strong ready token + hint token together must detect readiness immediately (rc 0).""" + screen = """Cline v3.0.0 +Use arrow keys to select a tool +❯ +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +sleep() {{ :; }} +wait_for_tui_ready dummy-sess cline +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "TUI detected ready" in res.stdout + + +def test_c4_placeholder_only_readiness_succeeds(): + """C-4: Long placeholder (Ask anything..., len>=8) alone satisfies S-tier readiness.""" + screen = """Welcome to the AI Assistant +Ask anything... +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +sleep() {{ :; }} +# Override ready tokens so strong tokens do not match +export MAM_STRONG_READY_TOKENS='NonExistentStrongToken' +export MAM_WEAK_READY_TOKENS='' +export MAM_INPUT_PLACEHOLDER='Ask anything...' +wait_for_tui_ready dummy-sess cline +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "TUI detected ready" in res.stdout + + +def test_c4b_box_modal_does_not_succeed(): + """C-4b: Box modal frame (────── + solo ❯) with no strong token or placeholder must NOT satisfy readiness.""" + screen = """┌───────────────────────────────────┐ +│ Please select authentication mode │ +│ ───────────────────────────────── │ +│ 1. API Key │ +│ 2. OAuth │ +└───────────────────────────────────┘ +❯ +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +sleep() {{ :; }} +export MAM_STRONG_READY_TOKENS='NonExistentStrongToken' +export MAM_WEAK_READY_TOKENS='Chat|history' +export MAM_INPUT_PLACEHOLDER='Ask anything...' +export MAM_INPUT_PROMPT='❯' +export MAM_INPUT_RULE_PATTERN='─{{10,}}' +_MAM_MODAL_TOKENS='NON_MATCHING_MODAL' +rc=0 +wait_for_tui_ready dummy-sess cline || rc=$? +echo "RC=$rc" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "RC=1" in res.stdout + + +def test_c4c_prompt_and_rule_corroboration_alone_cannot_satisfy_readiness(): + """C-4c: Neither prompt alone, rule alone, nor prompt+rule alone can satisfy readiness (C-tier cannot promote alone).""" + screen = """──────────────────────────── +❯ +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +sleep() {{ :; }} +export MAM_STRONG_READY_TOKENS='NonExistentToken' +export MAM_WEAK_READY_TOKENS='NonExistentWeak' +export MAM_INPUT_PLACEHOLDER='NonExistentPlaceholder' +export MAM_INPUT_PROMPT='❯' +export MAM_INPUT_RULE_PATTERN='─{{10,}}' +rc=0 +wait_for_tui_ready dummy-sess cline || rc=$? +echo "RC=$rc" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "RC=1" in res.stdout + + +def test_c4d_weak_token_requires_corroboration(): + """C-4d: Weak token (Chat) alone fails; Weak token + prompt in last 5 lines succeeds.""" + # 1. Weak token alone without prompt in last 5 lines + screen_weak_alone = """Chat history from earlier session: +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 +""" + script1 = f""" +_pane_capture() {{ printf '%s' '{screen_weak_alone}'; }} +sleep() {{ :; }} +export MAM_STRONG_READY_TOKENS='NonExistentStrong' +export MAM_WEAK_READY_TOKENS='Chat|history' +export MAM_INPUT_PLACEHOLDER='' +export MAM_INPUT_PROMPT='❯' +export MAM_INPUT_RULE_PATTERN='' +rc=0 +wait_for_tui_ready dummy-sess cline || rc=$? +echo "RC=$rc" +""" + res1 = _run_lib_helpers(script1) + assert res1.returncode == 0, res1.stderr + res1.stdout + assert "RC=1" in res1.stdout + + # 2. Weak token + prompt in last 5 lines + screen_weak_corroborated = """Chat Session Active +Ready for input +❯ +""" + script2 = f""" +_pane_capture() {{ printf '%s' '{screen_weak_corroborated}'; }} +sleep() {{ :; }} +export MAM_STRONG_READY_TOKENS='NonExistentStrong' +export MAM_WEAK_READY_TOKENS='Chat|history' +export MAM_INPUT_PLACEHOLDER='' +export MAM_INPUT_PROMPT='❯' +export MAM_INPUT_RULE_PATTERN='' +rc=0 +wait_for_tui_ready dummy-sess cline || rc=$? +echo "RC=$rc" +""" + res2 = _run_lib_helpers(script2) + assert res2.returncode == 0, res2.stderr + res2.stdout + assert "RC=0" in res2.stdout + + +def test_c4e_short_placeholder_not_promoted_to_strong(): + """C-4e: Placeholder with length < 8 chars must NOT be promoted to strong S-tier.""" + screen = """Ask +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +sleep() {{ :; }} +export MAM_STRONG_READY_TOKENS='NonExistentStrong' +export MAM_WEAK_READY_TOKENS='' +export MAM_INPUT_PLACEHOLDER='Ask' +rc=0 +wait_for_tui_ready dummy-sess custom || rc=$? +echo "RC=$rc" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "RC=1" in res.stdout + + +def test_c5_hermes_fallback_to_strong_only(): + """C-5: When input_* are all empty (Hermes), strong_ready_tokens alone satisfies readiness.""" + screen = """Hermes Agent v1.0 +System initialized. +""" + script = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +sleep() {{ :; }} +wait_for_tui_ready dummy-sess hermes +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "TUI detected ready" in res.stdout + + +def test_c6_consecutive_empty_captures_unobservable_rc2(): + """C-6: Consecutive empty captures give up as unobservable/headless (rc 2) without burning 30s.""" + script = """ +_pane_capture() { echo ""; } +sleep() { :; } +export MAM_READY_EMPTY_GIVEUP=3 +rc=0 +wait_for_tui_ready dummy-sess claude || rc=$? +echo "RC=$rc" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "RC=2" in res.stdout + assert "unobservable/headless pane" in res.stderr + + +def test_c7_readiness_checks_decoded_text_not_raw_json(): + """C-7: Raw JSON field names (e.g. Chat in JSON envelope) do not cause false positives.""" + raw_json = json.dumps({"result": {"read": {"text": "Just raw text with no ready tokens", "Chat": 1}}}) + script = f""" +_sks_herdr() {{ + if [ "${{1:-}}" = "capture-pane" ]; then + printf '%s' '{raw_json}' + fi + return 0 +}} +sleep() {{ :; }} +export MAM_STRONG_READY_TOKENS='NonExistentStrong' +export MAM_WEAK_READY_TOKENS='Chat' +export MAM_INPUT_PLACEHOLDER='' +export MAM_INPUT_PROMPT='❯' +export MAM_INPUT_RULE_PATTERN='' +rc=0 +wait_for_tui_ready dummy-sess cline || rc=$? +echo "RC=$rc" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "RC=1" in res.stdout + + +def test_c8_mam_dialog_tokens_is_union_of_modal_and_hint(): + """C-8: _MAM_DIALOG_TOKENS must remain the union of _MAM_MODAL_TOKENS and _MAM_HINT_TOKENS.""" + script = """ +echo "MODAL=$_MAM_MODAL_TOKENS" +echo "HINT=$_MAM_HINT_TOKENS" +echo "DIALOG=$_MAM_DIALOG_TOKENS" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + lines = dict(line.split("=", 1) for line in res.stdout.strip().splitlines()) + expected_dialog = f"{lines['MODAL']}|{lines['HINT']}" + assert lines["DIALOG"] == expected_dialog + + +def test_c8b_agent_modal_tokens_merged_into_dialog_open(): + """C-8b (T-2d / F-8): Per-agent modal_tokens from adapter are merged into _pane_dialog_open and block send_keys_safe.""" + # 1. Direct _pane_dialog_open test + screen = """Please enter your Cline API key to continue: +""" + script1 = f""" +_pane_capture() {{ printf '%s' '{screen}'; }} +export MAM_MODAL_TOKENS='Cline API key' +if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED"; fi +""" + res1 = _run_lib_helpers(script1) + assert res1.returncode == 0, res1.stderr + res1.stdout + assert "DIALOG_OPEN" in res1.stdout + + # 2. Dynamic facts resolution via send_keys_safe on cline session + script2 = f""" +_pane_quiescent() {{ return 0; }} +_pane_capture() {{ printf '%s' 'Select an API provider to get started:'; }} +_sks_herdr() {{ return 0; }} +rc=0 +SKS_DIALOG_TIMEOUT=1 send_keys_safe "my-project-worker-cline-01" "some text" || rc=$? +echo "RC=$rc" +""" + res2 = _run_lib_helpers(script2) + assert res2.returncode == 0, res2.stderr + res2.stdout + assert "RC=2" in res2.stdout, f"Expected dialog blocking (RC=2) for cline modal: {res2.stdout}" + + + +def test_c9_create_session_timeout_alive_pid_preserves_session(mam_sandbox, mock_herdr, mock_agents): + """C-9 & C-11: create_session with readiness timeout + alive PID preserves session with timeout status (exit 0) and dumps diagnostic.""" + script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" + diag_dir = mam_sandbox / ".mam" / "diagnostics" + yaml_file = mam_sandbox / ".mam" / "agent-sessions.yaml" + + # Make wait_for_tui_ready immediately return 1 in sandboxed lib.sh + lib_content = lib_path.read_text() + lib_path.write_text(lib_content.replace("wait_for_tui_ready() {", "wait_for_tui_ready() { return 1; #")) + # In sandbox create_session.sh, simulate alive process using $$ + create_content = script_path.read_text() + script_path.write_text(create_content.replace( + 'PANE_PID_CHECK=$(_herdr list-panes', + 'PANE_PID_CHECK=$$ # $(_herdr list-panes' + )) + + cmd = [ + "bash", str(script_path), + "--workspace", str(mam_sandbox), + "--agent", "cline", + "--role", "creator", + "--session", "test-timeout-sess", + "--onboard" + ] + res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 0, res.stderr + res.stdout + assert "WARNING: agent TUI never became ready within timeout, but process" in res.stderr + + # Check diagnostic dump file created (C-11) + dump_files = list(diag_dir.glob("test-timeout-sess-*.txt")) + assert len(dump_files) == 1 + dump_content = dump_files[0].read_text() + assert "ReturnCode: 1" in dump_content + + # Check YAML registration with tui-ready-timeout and NO delegate job (N-C) + assert yaml_file.exists() + yaml_content = yaml_file.read_text() + assert "tui-ready-timeout" in yaml_content + assert "delegate_job_id: null" in yaml_content or "delegate_job_id: None" in yaml_content or "delegate_job_id:" not in yaml_content or "delegate_job_id: ''" in yaml_content + + +def test_c10_create_session_timeout_dead_pid_kills_session(mam_sandbox, mock_herdr, mock_agents): + """C-10: create_session with readiness timeout + dead PID triggers rollback kill and exits with code 42.""" + script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh" + lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh" + diag_dir = mam_sandbox / ".mam" / "diagnostics" + + # Make wait_for_tui_ready return 1 and mock list-panes to return dead PID + lib_content = lib_path.read_text() + create_content = script_path.read_text() + script_path.write_text(create_content.replace( + 'PANE_PID_CHECK=$(_herdr list-panes', + 'PANE_PID_CHECK=99999999 # $(_herdr list-panes' + )) + lib_path.write_text(lib_content.replace("wait_for_tui_ready() {", "wait_for_tui_ready() { return 1; #")) + + cmd = [ + "bash", str(script_path), + "--workspace", str(mam_sandbox), + "--agent", "cline", + "--role", "creator", + "--session", "test-dead-sess" + ] + res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox)) + assert res.returncode == 42, res.stderr + res.stdout + assert "ERROR: agent TUI never became ready" in res.stderr + + # Check diagnostic dump file created + dump_files = list(diag_dir.glob("test-dead-sess-*.txt")) + assert len(dump_files) == 1 + assert "ReturnCode: 1" in dump_files[0].read_text() + + +def test_adapter_strong_weak_partition(): + """N-D: Verify strong_ready_tokens and weak_ready_tokens partition ready_tokens for all adapters.""" + from lib_py.agents.registry import get_adapter + for agent in ('claude', 'agy', 'hermes', 'cline', 'grok'): + adapter = get_adapter(agent) + assert adapter is not None + ready_set = set(t for t in adapter.ready_tokens.split('|') if t) + strong_set = set(t for t in adapter.strong_ready_tokens.split('|') if t) + weak_set = set(t for t in adapter.weak_ready_tokens.split('|') if t) + assert strong_set | weak_set == ready_set, f"Mismatch in {agent}: {strong_set} | {weak_set} != {ready_set}" + assert strong_set.isdisjoint(weak_set), f"Overlap in {agent}: {strong_set & weak_set}" + + +def test_sks_agent_suffix_resolution_not_substring(): + """Verify send_keys_safe resolves agent by strict suffix without substring false positives.""" + script = """ +_pane_quiescent() { return 0; } +_pane_capture() { echo "prompt > "; } +_sks_herdr() { return 0; } +RESOLVED="" +mock_py() { + if [ "$1" = "-m" ] && [ "$2" = "lib_py.agents" ] && [ "$3" = "facts" ]; then + echo "echo RESOLVED_AGENT=$4" + fi +} +_delegate_py_bin() { echo "mock_py"; } + +check_sks() { + local sess="$1" + unset MAM_MODAL_TOKENS + # Call send_keys_safe and capture output + send_keys_safe "$sess" "hello" 2>&1 || true +} + +echo "1: $(check_sks 'claude-reviewer-cline-01')" +echo "2: $(check_sks 'my-claude-creator-cline')" +echo "3: $(check_sks 'reviewer-creator-grok-01')" +echo "4: $(check_sks 'planner-reviewer-claude-01')" +echo "5: $(check_sks 'test-worker-hermes-02')" +echo "6: $(check_sks 'workspace-creator-agy-05')" +echo "7: $(check_sks 'claude')" +echo "8: $(check_sks 'cline-01')" +echo "9: $(check_sks 'grok-02')" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "1: RESOLVED_AGENT=cline" in res.stdout + assert "2: RESOLVED_AGENT=cline" in res.stdout + assert "3: RESOLVED_AGENT=grok" in res.stdout + assert "4: RESOLVED_AGENT=claude" in res.stdout + assert "5: RESOLVED_AGENT=hermes" in res.stdout + assert "6: RESOLVED_AGENT=agy" in res.stdout + assert "7: RESOLVED_AGENT=claude" in res.stdout + assert "8: RESOLVED_AGENT=cline" in res.stdout + assert "9: RESOLVED_AGENT=grok" in res.stdout + + +def test_f2_upsell_modal_defense_with_real_facts_and_banner(): + """F-2/F-6: Real Claude Code banner with upsell modal must trigger modal defense, not return ready.""" + script = """ +SENT_KEYS="" +_sks_herdr() { + if [ "$1" = "send-keys" ]; then + SENT_KEYS="$SENT_KEYS $*" + fi + return 0 +} + +COUNT_FILE=$(mktemp) +echo "0" > "$COUNT_FILE" + +_pane_capture() { + local c + c=$(cat "$COUNT_FILE") + c=$((c + 1)) + echo "$c" > "$COUNT_FILE" + if [ "$c" -le 2 ]; then + # First 2 captures (loop iter 1 capture + tail check): Claude Code banner with Fullscreen Modal + cat << 'EOF' +Claude Code (Sonnet 3.5) +Try the new fullscreen renderer? +Flicker-free output +Selected text auto-copies to your clipboard +Yes, try it +EOF + else + # Subsequent captures (after Escape sent): modal dismissed, normal prompt + cat << 'EOF' +Claude Code (Sonnet 3.5) +❯ +EOF + fi +} + +rc=0 +wait_for_tui_ready "my-claude-sess" "claude" || rc=$? +rm -f "$COUNT_FILE" +echo "RC=$rc" +echo "KEYS=$SENT_KEYS" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "Escape" in res.stdout, f"Expected Escape to be sent for upsell modal: {res.stdout}" + assert "RC=0" in res.stdout + + +def test_r1_resolve_pane_id_fail_closed_on_multiple_same_kind_panes(): + """R-1: If fallback finds multiple panes of same agent kind, fail closed (exit 1).""" + helper = _lib_helper_src() + script = helper + """ + +# Mock real herdr pane list returning 2 claude panes +_real_herdr() { + cat << 'EOF' +{"result": {"panes": [ + {"pane_id": "w1:p1", "label": "unrelated-1", "agent": "claude"}, + {"pane_id": "w1:p2", "label": "unrelated-2", "agent": "claude"} +]}} +EOF +} + +# Create a dummy yaml with unknown session mapped to claude cmd +mkdir -p .mam +cat << 'EOF' > .mam/agent-sessions.yaml +herdr_sessions: + - name: my-unmatched-session + pane: + cmd: claude +EOF + +rc=0 +pid=$(_resolve_herdr_pane_id "my-unmatched-session") || rc=$? +echo "RC=$rc|PID=$pid" +""" + res = _run_lib_helpers(script) + assert res.returncode == 0, res.stderr + res.stdout + assert "RC=1|PID=" in res.stdout, f"Expected fail-closed RC=1 and empty PID: {res.stdout}" + +