#!/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. emit "$(ev UserPromptSubmit '"prompt":"launch a batch"')" check "a new turn resets the count" "0" "$(field agents)" emit "$(ev PreToolUse '"tool_name":"Agent"')" emit "$(ev PreToolUse '"tool_name":"Agent"')" check "two launches counted" "2" "$(field agents)" emit "$(ev PreToolUse '"tool_name":"TaskCreate"')" check "a tool that merely starts with Task is not a subagent" "2" "$(field agents)" emit "$(ev Stop)" check "main agent stopping does not free a running batch" "busy" "$(field state)" emit "$(ev SubagentStop '"agent_id":"sub-1","agent_type":"general-purpose"')" check "one down, still working" "busy" "$(field state)" check "count decremented" "1" "$(field agents)" emit "$(ev SubagentStop '"agent_id":"sub-2","agent_type":"general-purpose"')" check "last subagent finishing frees the session" "waiting" "$(field state)" check "count back to zero" "0" "$(field agents)" # A batch that finishes while the main agent is still mid-turn must not free it. emit "$(ev UserPromptSubmit '"prompt":"again"')" emit "$(ev PreToolUse '"tool_name":"Agent"')" emit "$(ev SubagentStop '"agent_id":"sub-3"')" 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. emit "$(ev PreToolUse '"tool_name":"Agent"')" emit "$(ev Stop)" emit "$(ev Notification '"notification_type":"idle_prompt","message":"still there?"')" check "idle nudge cannot free a running batch" "busy" "$(field state)" emit "$(ev SubagentStop '"agent_id":"sub-4"')" 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"