A session whose main agent had stopped while a background subagent worked showed as waiting. The count was kept by hand -- +1 on PreToolUse matched to ^(Agent|Task)$, -1 on SubagentStop -- and the two halves do not see the same thing. A launch reaches the hook only at the top level: a subagent spawning its own subagents does it through events carrying agent_id, which are dropped. SubagentStop arrives for every subagent at every depth. Each nested one subtracted from a batch it had never joined. Measured on the live session that showed it: one background agent, then fourteen nested stops, the first of which took the count to zero and turned the chip white with the batch still running. Replaying those recorded events through the old hook reproduces it exactly, and through the new one holds busy throughout, rising to two while two background agents ran. The events carry the answer themselves. Stop and SubagentStop -- the two that can end a turn, and the only two where it matters -- come with background_tasks: every running task with its type and status. The count is now read from there and nothing accumulates, so it cannot drift, and a subagent that dies without sending SubagentStop no longer leaks a count that pins the chip at busy. Events without the field leave the stored value alone, which is what keeps an idle_prompt nudge from freeing a working session. A background shell is deliberately not counted. A dev server left running says nothing about whether the session needs you, and treating it as work would hold the chip at "working" for as long as it lives. PreToolUse is no longer registered: counting was the only thing it was for. It stays listed as a legacy event so that both install and uninstall sweep it out of settings.json rather than leaving it there to spawn the hook on every agent launch for nothing.
254 lines
11 KiB
Bash
Executable File
254 lines
11 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}}"
|
|
}
|
|
|
|
# --- identifying the claude process ----------------------------------------
|
|
# The hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its
|
|
# parent's command line contains "claude" in a path without being claude.
|
|
# Matching on the raw string latches onto that shell, which exits at once.
|
|
python3 - "$HOOK" <<'PY'
|
|
import importlib.util, sys
|
|
spec = importlib.util.spec_from_file_location("h", sys.argv[1])
|
|
h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
|
|
cases = [
|
|
(["/bin/sh", "-c", "/home/u/claude-code-gnome-extension/hooks/claude-status-hook.py"], False),
|
|
(["/home/u/.local/bin/claude", "--resume"], True),
|
|
(["bash", "/home/u/bin/claude"], True),
|
|
(["node", "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"], True),
|
|
(["/home/u/bin/zellij", "--server", "/run/user/1000/zellij/x"], False),
|
|
(["nvim", "/home/u/.claude/settings.json"], False),
|
|
]
|
|
bad = 0
|
|
for argv, want in cases:
|
|
got = h.looks_like_claude(argv)
|
|
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (" ".join(argv)[:46], got))
|
|
bad += got != want
|
|
|
|
# A one-shot run has no input line and no human in front of it. The prompt is
|
|
# an ordinary argument, so a prompt that merely mentions -p must not count.
|
|
headless = [
|
|
(["claude"], False),
|
|
(["claude", "--resume"], False),
|
|
(["claude", "-p", "summarise this"], True),
|
|
(["claude", "--print", "--output-format", "stream-json"], True),
|
|
(["claude", "explain what -p does"], False),
|
|
(["claude", "--permission-mode", "plan"], False),
|
|
]
|
|
for argv, want in headless:
|
|
got = h.is_headless(argv)
|
|
print(("ok " if got == want else "FAIL ") + "headless %r -> %s" % (" ".join(argv)[:46], got))
|
|
bad += got != want
|
|
sys.exit(1 if bad else 0)
|
|
PY
|
|
check "command lines classified correctly" "0" "$?"
|
|
|
|
# End to end, through /proc rather than through the classifier: a fake "claude"
|
|
# runs the hook as a child, exactly as the real one does.
|
|
FAKE="$XDG_STATE_HOME/claude"
|
|
printf '#!/bin/sh\n"$1"\n' > "$FAKE"
|
|
chmod +x "$FAKE"
|
|
printf '{"session_id":"headless","hook_event_name":"SessionStart","cwd":"/tmp/p"}' \
|
|
| "$FAKE" "$HOOK" -p
|
|
[ -e "$DIR/headless.json" ]; check "claude -p leaves no state file" "1" "$?"
|
|
|
|
printf '{"session_id":"headless","hook_event_name":"SessionStart","cwd":"/tmp/p"}' \
|
|
| "$FAKE" "$HOOK"
|
|
[ -e "$DIR/headless.json" ]; check "the same session without -p is recorded" "0" "$?"
|
|
rm -f "$DIR/headless.json" "$DIR/headless.json.lock"
|
|
|
|
# --- state machine ---------------------------------------------------------
|
|
emit "$(ev SessionStart '"source":"startup"')"
|
|
check "SessionStart -> waiting" "waiting" "$(field state)"
|
|
|
|
# Pins the recorded pid to one process, so a state file that outlives a reboot
|
|
# cannot be revived by whatever inherits that pid number next.
|
|
start=$(field pid_start)
|
|
check "process start time recorded" "yes" "$([ -n "$start" ] && [ "$start" != "0" ] && echo yes || echo no)"
|
|
|
|
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)"
|
|
|
|
# --- subagents -------------------------------------------------------------
|
|
# The scenario this exists for: you ask for a batch, the main agent launches it
|
|
# and ends its turn, and the session waits for the results to consolidate them.
|
|
# It is working, not waiting for you, and must not call you over.
|
|
#
|
|
# What is running is not counted from starts and stops but taken from the list
|
|
# the events carry, exactly as measured on a live session: Stop and
|
|
# SubagentStop bring "background_tasks", the rest of the events bring nothing.
|
|
bg() { # running-subagent-count -> the snapshot as those events carry it
|
|
tasks=""
|
|
for i in $(seq 0 $(($1 - 1))); do
|
|
tasks="$tasks${tasks:+,}{\"id\":\"sub-$i\",\"type\":\"subagent\",\"status\":\"running\"}"
|
|
done
|
|
echo "\"background_tasks\":[$tasks]"
|
|
}
|
|
|
|
emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')"
|
|
emit "$(ev Stop "$(bg 2)")"
|
|
check "main agent stopping does not free a running batch" "busy" "$(field state)"
|
|
check "the snapshot is what gets stored" "2" "$(field agents)"
|
|
|
|
emit "$(ev SubagentStop "\"agent_id\":\"sub-0\",\"agent_type\":\"general-purpose\",$(bg 1)")"
|
|
check "one down, still working" "busy" "$(field state)"
|
|
check "the count follows the snapshot" "1" "$(field agents)"
|
|
|
|
# The regression this replaced counting for: a subagent's own subagent stops,
|
|
# and its SubagentStop reaches this session just like a top-level one -- while
|
|
# its *launch* never did, because that event carried agent_id and was dropped.
|
|
# Subtracting it freed a session whose batch was still running.
|
|
emit "$(ev SubagentStop "\"agent_id\":\"nested-1\",\"agent_type\":\"\",$(bg 1)")"
|
|
check "a nested subagent stopping does not free the batch" "busy" "$(field state)"
|
|
check "and does not touch the count" "1" "$(field agents)"
|
|
|
|
emit "$(ev SubagentStop "\"agent_id\":\"sub-1\",$(bg 0)")"
|
|
check "last subagent finishing frees the session" "waiting" "$(field state)"
|
|
check "count back to zero" "0" "$(field agents)"
|
|
|
|
# A background shell is a background task too, and must not be mistaken for
|
|
# work: a dev server left running would pin the chip at "working" for good.
|
|
emit "$(ev UserPromptSubmit '"prompt":"serve"')"
|
|
emit "$(ev Stop '"background_tasks":[{"id":"b1","type":"bash","status":"running"}]')"
|
|
check "a background shell is not a subagent" "waiting" "$(field state)"
|
|
|
|
# A batch that finishes while the main agent is still mid-turn must not free it.
|
|
emit "$(ev UserPromptSubmit '"prompt":"again"')"
|
|
emit "$(ev SubagentStop "\"agent_id\":\"sub-3\",$(bg 0)")"
|
|
check "batch done mid-turn leaves the session working" "busy" "$(field state)"
|
|
|
|
# An idle nudge while a batch runs must not claim the session is free either.
|
|
# It carries no snapshot of its own, so this also checks the stored one stands.
|
|
emit "$(ev Stop "$(bg 1)")"
|
|
emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')"
|
|
check "idle nudge cannot free a running batch" "busy" "$(field state)"
|
|
check "an event without a snapshot leaves the count alone" "1" "$(field agents)"
|
|
emit "$(ev SubagentStop "\"agent_id\":\"sub-4\",$(bg 0)")"
|
|
check "and it frees properly once the batch ends" "waiting" "$(field state)"
|
|
|
|
# --- 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" "$?"
|
|
# The lock deliberately stays. Unlinking it while holding it would drop mutual
|
|
# exclusion for any hook already blocked on the old inode; the reader sweeps it
|
|
# once it is orphaned and old.
|
|
[ -e "$FILE.lock" ]; check "SessionEnd keeps the lock inode" "0" "$?"
|
|
|
|
rm -rf "$XDG_STATE_HOME"
|
|
if [ "$failures" -gt 0 ]; then
|
|
echo; echo "$failures failure(s)"; exit 1
|
|
fi
|
|
echo; echo "all passed"
|