A subagent's tool calls do reach the parent session's hooks: measured, a PostToolUse arrives carrying agent_id and agent_type. Only Stop was guarded against that, and Stop was the case that mattered least. With background subagents the ordering is the harmful one. The main agent ends its turn first, so Stop lands and the session reads "waiting"; the subagents keep working, and their PostToolUse arrives afterwards and puts the session back to "busy". The panel then says a session is working when its input line is free and it is waiting for you -- the precise confusion this indicator exists to prevent, and reported from a live session doing exactly that. Every event carrying agent_id is now ignored. Synchronous subagents lose nothing: the main agent is mid-turn, so its own earlier events already say "busy". Notification is deliberately exempt. It means a human is needed, and that is as true when the agent that got stuck is a subagent -- ignoring it would leave a session silently blocked. Covered both ways in the hook tests, and checked once against a real subagent event captured from a live run rather than a hand-written one.
135 lines
5.1 KiB
Bash
Executable File
135 lines
5.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Exercises the hook's state machine and its behaviour under concurrency.
|
|
#
|
|
# Run: tests/test-hook.sh
|
|
set -u
|
|
|
|
HOOK="$(cd "$(dirname "$0")/.." && pwd)/hooks/claude-status-hook.py"
|
|
export XDG_STATE_HOME="$(mktemp -d)"
|
|
DIR="$XDG_STATE_HOME/claude-code-status"
|
|
SID="test-session"
|
|
FILE="$DIR/$SID.json"
|
|
failures=0
|
|
|
|
check() { # name expected actual
|
|
if [ "$2" = "$3" ]; then
|
|
echo "ok $1"
|
|
else
|
|
echo "FAIL $1 (expected '$2', got '$3')"
|
|
failures=$((failures + 1))
|
|
fi
|
|
}
|
|
|
|
emit() { # json
|
|
echo "$1" | "$HOOK"
|
|
}
|
|
|
|
field() { # key
|
|
python3 -c "import json,sys;print(json.load(open('$FILE')).get('$1',''))" 2>/dev/null
|
|
}
|
|
|
|
ev() { # event [extra json]
|
|
echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}"
|
|
}
|
|
|
|
# --- state machine ---------------------------------------------------------
|
|
emit "$(ev SessionStart '"source":"startup"')"
|
|
check "SessionStart -> waiting" "waiting" "$(field state)"
|
|
|
|
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
|
|
check "UserPromptSubmit -> busy" "busy" "$(field state)"
|
|
|
|
before=$(field event_ts)
|
|
emit "$(ev PostToolUse '"tool_name":"Bash"')"
|
|
check "PostToolUse while busy does not rewrite" "$before" "$(field event_ts)"
|
|
|
|
emit "$(ev Notification '"notification_type":"permission_prompt","message":"run rm -rf"')"
|
|
check "permission_prompt -> blocked" "blocked" "$(field state)"
|
|
check "permission message kept" "run rm -rf" "$(field message)"
|
|
|
|
emit "$(ev PostToolUse '"tool_name":"Bash"')"
|
|
check "PostToolUse clears blocked" "busy" "$(field state)"
|
|
|
|
emit "$(ev Notification '"notification_type":"auth_success","message":"logged in"')"
|
|
check "auth_success ignored" "busy" "$(field state)"
|
|
|
|
emit "$(ev Stop)"
|
|
check "Stop -> waiting" "waiting" "$(field state)"
|
|
|
|
before=$(field event_ts)
|
|
emit "$(ev Notification '"notification_type":"idle_prompt","message":"waiting for input"')"
|
|
check "idle_prompt while waiting does not rewrite" "$before" "$(field event_ts)"
|
|
|
|
# Subagent activity must not touch the state. The background case is the one
|
|
# that bites: the main agent has already stopped, so a subagent's tool call
|
|
# arriving afterwards would claim the session is working when it is waiting.
|
|
emit "$(ev Stop '"agent_id":"sub-1"')"
|
|
check "subagent Stop ignored" "waiting" "$(field state)"
|
|
|
|
emit "$(ev PostToolUse '"tool_name":"Bash","agent_id":"sub-1","agent_type":"general-purpose"')"
|
|
check "background subagent does not un-wait the session" "waiting" "$(field state)"
|
|
|
|
# ...but a subagent that gets stuck still needs a human, so its notification
|
|
# must come through.
|
|
emit "$(ev Notification '"notification_type":"permission_prompt","message":"x","agent_id":"sub-1"')"
|
|
check "subagent permission prompt still blocks" "blocked" "$(field state)"
|
|
emit "$(ev PostToolUse '"tool_name":"Bash"')"
|
|
check "main agent tool call clears it again" "busy" "$(field state)"
|
|
emit "$(ev Stop)"
|
|
check "back to waiting" "waiting" "$(field state)"
|
|
|
|
# Deliberately checked from "busy": compaction raises SessionStart mid-turn,
|
|
# and taking it at face value would drop a working session back to waiting.
|
|
emit "$(ev UserPromptSubmit '"prompt":"go"')"
|
|
since_before=$(field since)
|
|
emit "$(ev SessionStart '"source":"compact"')"
|
|
check "compaction does not reset a live session" "busy" "$(field state)"
|
|
check "compaction does not reset the clock" "$since_before" "$(field since)"
|
|
|
|
# --- concurrency -----------------------------------------------------------
|
|
# The hazard: the last PostToolUse of a turn is async and can still be running
|
|
# when the turn's Stop fires. Two mechanisms defend against it and they are
|
|
# tested separately, because the burst below passes on the timestamp guard
|
|
# alone -- it does not prove the lock is doing anything.
|
|
emit "$(ev UserPromptSubmit '"prompt":"go"')"
|
|
for _ in $(seq 30); do
|
|
emit "$(ev PostToolUse '"tool_name":"Bash"')" &
|
|
done
|
|
sleep 0.3 # let the racers start, so Stop's timestamp is genuinely later
|
|
emit "$(ev Stop)"
|
|
wait
|
|
check "Stop survives a burst of concurrent PostToolUse" "waiting" "$(field state)"
|
|
python3 -c "import json;json.load(open('$FILE'))" 2>/dev/null
|
|
check "state file is still valid JSON" "0" "$?"
|
|
|
|
# The lock itself: what the timestamp guard cannot cover is one hook reading the
|
|
# old state, being descheduled while another writes, then writing its own stale
|
|
# decision on top. Holding the lock from outside makes that window observable --
|
|
# a hook must wait for it rather than read-and-write straight through.
|
|
emit "$(ev UserPromptSubmit '"prompt":"go"')"
|
|
python3 -c "
|
|
import fcntl, time
|
|
with open('$FILE.lock', 'w') as fh:
|
|
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
time.sleep(1.5)
|
|
" &
|
|
holder=$!
|
|
sleep 0.4
|
|
emit "$(ev Stop)" &
|
|
sleep 0.5
|
|
check "hook blocks while the lock is held" "busy" "$(field state)"
|
|
wait $holder
|
|
wait
|
|
check "hook proceeds once the lock is released" "waiting" "$(field state)"
|
|
|
|
# --- teardown --------------------------------------------------------------
|
|
emit "$(ev SessionEnd '"reason":"other"')"
|
|
[ -e "$FILE" ]; check "SessionEnd removes the state file" "1" "$?"
|
|
[ -e "$FILE.lock" ]; check "SessionEnd removes the lock file" "1" "$?"
|
|
|
|
rm -rf "$XDG_STATE_HOME"
|
|
if [ "$failures" -gt 0 ]; then
|
|
echo; echo "$failures failure(s)"; exit 1
|
|
fi
|
|
echo; echo "all passed"
|