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
This commit is contained in:
2026-08-28 15:21:10 +09:00
parent 4a5a093986
commit 17edf90676
9 changed files with 868 additions and 42 deletions
+154 -31
View File
@@ -24,6 +24,7 @@ fi
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" export PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}" WORKSPACE_ROOT="${WORKSPACE_ROOT:-$(cd "$SKILL_DIR/../.." && pwd)}"
export WORKSPACE_ROOT
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}" AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
_sanitize_herdr_agent_name() { _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) h=$(printf '%s' "$s" | sha1sum 2>/dev/null | awk '{print $1}' | cut -c 1-8)
elif command -v openssl >/dev/null 2>&1; then 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) h=$(printf '%s' "$s" | openssl sha1 2>/dev/null | awk '{print $NF}' | cut -c 1-8)
fi
if [ -n "$h" ]; then
s="${s:0:23}-$h"
else else
h=$(python3 -c "import hashlib,sys; print(hashlib.sha1(sys.argv[1].encode()).hexdigest()[:8])" "$s" 2>/dev/null || echo "00000000") s="${s:0:32}"
fi fi
s="${s:0:23}-${h}"
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 # 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 done
# Central TUI dialog and readiness validation tokens (OP-6) # 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' _MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
# Workspace-relative defaults with environment overrides (Phase Z) # Workspace-relative defaults with environment overrides (Phase Z)
@@ -348,17 +353,18 @@ _resolve_herdr_pane_id() {
local ws_flag=() local ws_flag=()
[ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws") [ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \ 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 import sys, json, os
tn = os.environ.get('TARGET_NAME', '') tn = os.environ.get('TARGET_NAME', '')
tsa = os.environ.get('TARGET_SAN', '') tsa = os.environ.get('TARGET_SAN', '')
tws = os.environ.get('TARGET_WS', '') tws = os.environ.get('TARGET_WS', '')
ws_root = os.environ.get('WORKSPACE_ROOT', os.getcwd())
try: try:
panes = json.load(sys.stdin).get('result', {}).get('panes', []) panes = json.load(sys.stdin).get('result', {}).get('panes', [])
# Client-side filter in case an older herdr ignores --workspace. # Client-side filter in case an older herdr ignores --workspace.
if tws: if tws:
panes = [p for p in panes if p.get('workspace_id') == 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 key in ('label', 'name', 'agent'):
for p in panes: for p in panes:
v = p.get(key) v = p.get(key)
@@ -367,6 +373,27 @@ try:
if pid: if pid:
print(pid) print(pid)
sys.exit(0) 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: except Exception:
pass pass
sys.exit(1) sys.exit(1)
@@ -731,13 +758,13 @@ except Exception:
else else
res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true) res=$(eval "_real_herdr agent start \"$agent_name\" --kind \"$kind\" --pane \"$target_pane\"" 2>&1 || true)
fi 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 if echo "$res" | grep -qiE "^usage:|unknown option|unknown flag|missing required|invalid_agent_name|^error:"; then
break break
fi 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 if echo "$res" | grep -qE "agent_started|agent_not_ready"; then
success=1 success=1
break break
@@ -1793,41 +1820,117 @@ start_watchdog() {
# wait_for_tui_ready <session_name> <agent> # wait_for_tui_ready <session_name> <agent>
# Waits up to 30 seconds for the agent's TUI to render its welcome screen. # 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() { wait_for_tui_ready() {
local sess="$1" agent="$2" local sess="$1" agent="$2"
# Self-contained resolution: works whether or not caller evaluated facts (C1-(3)). local strong_tokens="${MAM_STRONG_READY_TOKENS:-}"
local tokens="${MAM_READY_TOKENS:-}" local weak_tokens="${MAM_WEAK_READY_TOKENS:-}"
if [ -z "$tokens" ]; then 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="" local _facts=""
_facts="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _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')" if [ -n "$_facts" ]; then
[ -n "$tokens" ] && eval "tokens=$tokens" 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 fi
# 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 echo "wait_for_tui_ready: no ready tokens for agent '$agent'" >&2
return 1 return 1
fi 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 for i in {1..30}; do
if [ "$agent" = "claude" ]; then # 1. Single capture per iteration (T-3)
handle_startup_dialogs "$sess" 1 || true content=$(_pane_capture "$sess")
fi
if _pane_dialog_open "$sess"; then # Consecutive blank capture check (C-6)
if printf '%s\n' "$(_pane_tail "$sess" 5)" | grep -q 'Press Enter to continue'; then if [ -z "$content" ]; then
_sks_herdr send-keys -t "$sess" Enter || true empty_streak=$((empty_streak + 1))
sleep 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 fi
sleep 1 sleep 1
continue continue
fi fi
local content empty_streak=0
content=$(_sks_herdr capture-pane -p -t "$sess" 2>/dev/null || echo "")
if [ -n "$content" ]; then # 2. Check modal dialogs FIRST (F-2): if a blocking modal signature is present in the tail lines,
if echo "$content" | grep -E -q "$tokens" 2>/dev/null; then # 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." echo "$agent TUI detected ready."
return 0 return 0
fi fi
fi
# 4. Startup dialog & hint resolution if not ready
handle_startup_dialogs "$sess" 1 || true
sleep 1 sleep 1
done done
echo "⚠️ TUI readiness check timed out for '$sess'." >&2 echo "⚠️ TUI readiness check timed out for '$sess'." >&2
@@ -1920,11 +2023,14 @@ _pane_quiescent() {
} }
# _pane_dialog_open <sess> — focus-stealing modal signatures (trust / # _pane_dialog_open <sess> — 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 # only (dialogs render near the input area; conversation text above must not
# trigger this). Tokens must NOT appear on normal idle prompt screens. # trigger this). Tokens must NOT appear on normal idle prompt screens.
_pane_dialog_open() { _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 <sess> <text> [job_id] # send_keys_safe <sess> <text> [job_id]
@@ -1939,6 +2045,23 @@ _pane_dialog_open() {
send_keys_safe() { send_keys_safe() {
local sess="$1" text="$2" job_id="${3:-adhoc}" local sess="$1" text="$2" job_id="${3:-adhoc}"
local pre_submit deadline try 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 local _q_rc=0
_pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$? _pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
+3
View File
@@ -23,6 +23,9 @@ def main():
print(f"MAM_INPUT_PLACEHOLDER={q(adapter.input_placeholder or '')}") 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_INPUT_RULE_PATTERN={q(adapter.input_rule_pattern or '')}")
print(f"MAM_READY_TOKENS={q(adapter.ready_tokens)}") 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_EXIT_KEY={q(adapter.exit_key)}")
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}") print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
@@ -40,6 +40,10 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
def input_rule_pattern(self) -> str: def input_rule_pattern(self) -> str:
return '{10,}' return '{10,}'
@property
def modal_tokens(self) -> Optional[str]:
return 'Try the new fullscreen renderer\\?'
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str: def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl" return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
@@ -16,6 +16,18 @@ class ClineAgentAdapter(BaseAgentAdapter):
def ready_tokens(self) -> str: def ready_tokens(self) -> str:
return 'Cline|history|Chat|What can I do|slash commands' 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 @property
def exit_key(self) -> str: def exit_key(self) -> str:
return '/exit' return '/exit'
+12
View File
@@ -73,6 +73,18 @@ class BaseAgentAdapter:
def input_rule_pattern(self) -> Optional[str]: def input_rule_pattern(self) -> Optional[str]:
return None 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: def derive_session_name(self, slug: str, role: str = "creator") -> str:
r = role.lower() r = role.lower()
return f"{slug}-{r}-{self.name}" return f"{slug}-{r}-{self.name}"
@@ -225,7 +225,7 @@ spawn
cleanup_herdr_on_error() { cleanup_herdr_on_error() {
local exit_code=$? local exit_code=$?
if [ $exit_code -ne 0 ]; then 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 _herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
fi fi
} }
@@ -237,11 +237,48 @@ if [ -z "$HERDR_SERVER_OPT" ]; then
fi fi
# TUI 준비 대기 # TUI 준비 대기
if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then TUI_READY_RC=0
echo "ERROR: agent TUI never became ready — aborting (rollback via trap)" >&2 wait_for_tui_ready "$SESSION_NAME" "$AGENT" || TUI_READY_RC=$?
exit 1 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 fi
if [ "$AGENT" = "claude" ]; then
# 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" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
handle_startup_dialogs "$SESSION_NAME" 15 handle_startup_dialogs "$SESSION_NAME" 15
fi 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). # 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\"" 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 is specified, automatically build the onboarding prompt (if session is ready)
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then 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: 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. 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. 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 # agent-sessions.yaml 에 append
DELEGATE_JOB_ID="" DELEGATE_JOB_ID=""
if [ -n "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
SUBMIT_JOB_PROMPT=""
fi
if [ -n "$SUBMIT_JOB_PROMPT" ]; then if [ -n "$SUBMIT_JOB_PROMPT" ]; then
delegate_agent="${MAM_DELEGATE_AGENT_KEY:-}" delegate_agent="${MAM_DELEGATE_AGENT_KEY:-}"
if [ -z "$delegate_agent" ]; then if [ -z "$delegate_agent" ]; then
@@ -306,6 +346,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \ HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
MAM_WS_LABEL="$MAM_WS_LABEL" \ MAM_WS_LABEL="$MAM_WS_LABEL" \
SESSION_UUID="$SESSION_UUID" \ SESSION_UUID="$SESSION_UUID" \
LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF' DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
name = os.environ['SESSION_NAME'] name = os.environ['SESSION_NAME']
agent = os.environ['AGENT'] agent = os.environ['AGENT']
@@ -389,6 +430,10 @@ elif agent == 'grok':
entry['session_id_verified'] = False entry['session_id_verified'] = False
entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified" 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) sessions.append(entry)
snap = d.setdefault('snapshot', {}) snap = d.setdefault('snapshot', {})
+16
View File
@@ -84,6 +84,22 @@ def test_adapter_required_properties():
assert adapter.delegate_agent_key == delk assert adapter.delegate_agent_key == delk
assert adapter.identity_cache_fields == cache_f 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(): def test_facts_bridge_eval_contract():
import subprocess, sys import subprocess, sys
from pathlib import Path from pathlib import Path
+4 -2
View File
@@ -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(): 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."""
content = open(_LIB_SH, encoding="utf-8").read() content = open(_LIB_SH, encoding="utf-8").read()
token_line = next(l for l in content.splitlines() if l.startswith("_MAM_DIALOG_TOKENS=")) modal_line = next(l for l in content.splitlines() if l.startswith("_MAM_MODAL_TOKENS="))
assert "Yes, try it" not in token_line 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 assert "grep -q 'Yes, try it'" in content
+609
View File
@@ -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}"