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:
+154
-31
@@ -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)
|
||||
fi
|
||||
if [ -n "$h" ]; then
|
||||
s="${s:0:23}-$h"
|
||||
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
|
||||
s="${s:0:23}-${h}"
|
||||
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 <session_name> <agent>
|
||||
# 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 <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
|
||||
# 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 <sess> <text> [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=$?
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
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 1
|
||||
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', {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user