Mode was applied per session but stored in one file per machine ($CLAUDE_CONFIG_DIR/.caveman-active). Four bugs followed from that one fact: parallel windows shared a mode; deactivation was spelled "no flag file", so SessionStart re-derived the configured default and undid an explicit "stop caveman" after every auto-compaction; the statusline badge rendered whichever window wrote last; and "off" could not survive a SessionStart at all. payload's `source`, but it read the legacy flag, where off is absence — a deactivated session found nothing stored and fell back to getDefaultMode() regardless. The other three are consequences of the storage shape, not of when the hook re-derives. State now lives in .caveman-sessions/<session_id>.mode, keyed by the session_id Claude Code puts in every hook payload and in the statusline's stdin JSON. The legacy flag stays as a last-write-wins compat mirror and never receives the literal 'off': an older hook reading that would inject "CAVEMAN MODE ACTIVE (off)" and an older statusline would render [CAVEMAN:OFF]. Both spellings of off are read everywhere — a missing file (old) and a literal 'off' (new, durable). caveman-config.js gains the primitives: validateSessionId (a whitelist, because a session id becomes part of a path), resolveActiveMode, readSessionModeRaw, writeSessionMode, per-session prev for one-shot skills (#599), gcSessionStore, and a session_id-tagged recordModeChange (#601). Every one takes a sessionId that may be null or malformed and degrades to the previous machine-wide behavior — the old code path IS the fallback branch, which is why the existing suites, nonid, pass unchanged. SessionStart re-derives the default only on `startup` and `clear`; compact, resume, fork, an unrecognized source and the n` read the stored value. Grouping `clear` with the resets deliberately differs from #691's comment: per-session storage makes the distinction cheap, and nothing else in the conversation survives /clear either. The watchdog must not reset, or a slow payload on a compaction re-arms a session the user turned off. The three entrypoints resolve the new helpers individually instead of demanding them in their requireSibling shape checks (#848): a caveman-config.js from before this change satisfies those checks, so hard-failing would trade "machine-wide mode, no state at all" on exactly the plugin-cache drift that guard exists for. Both statusline ports take session_id from the stdin JSON with bounded, TTY-guarded reads (integer timeout — bash 3.2 rejects fractional read -t) and render nothing for a deactivated session. verify_repo.py greps them against SESSIONS_DIRNAME and SESSION_ID_RE, tws badge. caveman-stats filters the transition l window's switches no longer skew another's es (bin/install.js, uninstall.sh, uninstall.ps1) removes the session store; .caveman-history.jsonl is kept on purpose. opide behaviour — it writes its own flag in its own config dir and never sees a Claude Code session_id. Tests: 25 unit cases in tests/hooks/caveman-config.test.mjs, SessionStartSourceTests, SessionScopedModeTests, 5 statusline cases, and tests/manual/session-mode-smoke.sh (22 checks, end-to-end against a throwaway config dir). docs/testing-session-modes.md carries the full plan including the live-Claude-Code checks. Hook ced. npm test 197/197 · tests/*.js clean · unittesss · smoke 22/22 Signed-off-by: Pavel Filippenko <filippenko.ps@phystech.edu>
9.8 KiB
Test plan — per-session mode state
How to convince yourself the session-scoped mode patch actually works.
Three layers, cheapest first. Layer 1 catches regressions in logic, layer 2 catches regressions in the wiring between the hooks, and layer 3 catches the things only a real Claude Code process can show you — the badge, compaction, and the plugin's own hook registration.
Layer 1 — automated suites
Run everything the CI gate runs:
npm test # installer + hook unit tests
node --test --test-force-exit tests/*.js # standalone Node suites
python3 -m unittest discover -s tests # Python suites
python3 tests/verify_repo.py # repo invariants
.github/workflows/ci.yml runs the same four on ubuntu (Node 18/20/22) and
macOS, plus a windows-powershell job that parses every .ps1 and drives the
standalone install/uninstall round trip through the real Git Bash.
The tests that speak directly to this patch:
| Suite | What it pins down |
|---|---|
tests/hooks/caveman-config.test.mjs |
Session-id validation, path containment, durable off, prev scoping, mode-log tagging, GC. 25 cases. |
tests/test_hooks.py::SessionStartSourceTests |
source branching — compaction and resume must not re-derive the default nor resurrect a deactivated session, but must still re-emit when one is active. |
tests/test_hooks.py::test_hook_never_blocks_on_stdin_that_never_closes |
The hang guard. Fails by timing out, not by asserting. |
tests/test_mode_tracker.py::SessionScopedModeTests |
Two windows, independent modes; the legacy mirror never holding off. |
tests/test_caveman_stats.js |
Statusline stdin parsing, including traversal ids and malformed JSON. |
tests/verify_repo.py::verify_powershell_static |
bash/PowerShell/JS parity, plus the hook checksum manifest. The parity greps are the only guard on the Windows badge — the CI Windows job covers install and parse, not badge output. |
Editing anything under src/hooks/ means regenerating the integrity manifest,
or verify_repo.py fails on a checksum mismatch:
cd src/hooks && awk '{print $2}' checksums.sha256 \
| while read -r f; do printf '%s %s\n' "$(shasum -a 256 "$f" | awk '{print $1}')" "$f"; done \
> /tmp/sums && mv /tmp/sums checksums.sha256
Layer 2 — end-to-end smoke test
bash tests/manual/session-mode-smoke.sh
Drives the actual hook binaries with the JSON payloads Claude Code sends, against
a throwaway CLAUDE_CONFIG_DIR. Your real ~/.claude is never touched. Expect
22 passed, 0 failed; every check prints its own name, so a failure tells you
which link in the chain broke.
What it covers, in the order a real session would hit it: startup writes a
per-session mode → "stop caveman" stores a durable off → compaction does not
undo that → neither does a resume → a second window keeps its own mode → each
badge shows its own window → reinforcement follows the session → compaction still
re-emits when active → traversal ids reach no file → stale files are swept on
startup but not on compact → an old install with only the legacy flag still works,
including across a compaction → a payload-less call still works → stdin without
EOF does not wedge the hook.
To watch a single step by hand, the pattern is the same throughout:
export CLAUDE_CONFIG_DIR=$(mktemp -d)
echo '{"session_id":"sess-A","source":"startup"}' | node src/hooks/caveman-activate.js
echo '{"session_id":"sess-A","prompt":"stop caveman"}' | node src/hooks/caveman-mode-tracker.js
echo '{"session_id":"sess-A"}' | bash src/hooks/caveman-statusline.sh
cat "$CLAUDE_CONFIG_DIR/.caveman-sessions/sess-A.mode"
Always pipe something into caveman-activate.js, even < /dev/null. Run bare in
a terminal it takes the isTTY branch and returns immediately; run with a pipe
that never closes and it waits for the 3000 ms payload watchdog before
activating, which looks like a hang and isn't.
Layer 3 — live Claude Code
The parts no harness can fake. Wire the patched hooks into a scratch config so
your working setup stays intact — by hand, because --config-dir scopes the hook
files and settings.json but not claude plugin install, and --only claude
would install the plugin into your real setup on the way past:
TESTDIR=~/.claude-cavemantest
mkdir -p "$TESTDIR/hooks"
cp src/hooks/package.json src/hooks/caveman-*.js src/hooks/caveman-statusline.sh "$TESTDIR/hooks/"
NODE=$(command -v node)
cat > "$TESTDIR/settings.json" <<JSON
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "\"$NODE\" \"$TESTDIR/hooks/caveman-activate.js\"", "timeout": 5 }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "\"$NODE\" \"$TESTDIR/hooks/caveman-mode-tracker.js\"", "timeout": 5 }] }]
},
"statusLine": { "type": "command", "command": "bash $TESTDIR/hooks/caveman-statusline.sh" }
}
JSON
CLAUDE_CONFIG_DIR="$TESTDIR" claude
The caveman-*.js glob is doing real work there: it picks up caveman-config.js
and caveman-parse.js, and the tracker degrades to a silent no-op without either
— which looks like the patch broke rather than the copy being incomplete.
cavecrew-model-overrides.js is genuinely optional (loaded in a try/catch).
Disable the caveman plugin first if you have it installed — otherwise its hooks run alongside these and you cannot tell which copy produced what.
A. Two windows, two modes. Open two Claude Code windows. In window 1 say
/caveman ultra, in window 2 say /caveman lite. Each statusline shows its own
badge, and each keeps it as you keep typing in the other. Before the patch both
badges tracked whichever window spoke last.
B. Deactivation survives compaction. In window 1: stop caveman — the badge
disappears and replies return to normal prose. Now /compact. The badge stays
gone and the replies stay normal. This is the defect the patch exists for.
Upstream #691 already stopped compaction from clobbering a mid-session level
change, but deactivation was spelled "no flag file", so the hook found nothing
stored and re-derived the default anyway.
C. Caveman survives compaction when it is on. In window 2, with caveman
active, run /compact. Replies stay caveman. Compaction is what prunes the
ruleset out of context, so the hook must re-emit here — "don't fire on compact"
would be the wrong fix and would break exactly this.
D. Resume. Quit window 1 and come back with claude --continue. It is still
off, because off is now a stored value rather than an absent file, and a
resume reads stored state instead of re-deriving the default. Note what this
check is not: a brand-new window is a new session_id with source: startup,
so it legitimately starts at the configured default — durable off is scoped to
the session that chose it, not to the machine. /clear is the other deliberate
reset, since nothing else in the conversation survives it either.
E. Token attribution. Switch modes a few times in one window, run
/caveman-stats, and check the savings figure is not distorted by what the other
window was doing. readModeLog filters the transition log on session_id.
F. Uninstall.
node bin/install.js --uninstall --config-dir ~/.claude-cavemantest
ls -a ~/.claude-cavemantest
.caveman-active, .caveman-active.prev, .caveman-mode-log.jsonl,
.caveman-statusline-suffix, .caveman-nudge-shown and the .caveman-sessions/
directory are all gone. .caveman-history.jsonl remains on purpose — it is the
user's accumulated lifetime savings, not caveman plumbing. Then
rm -rf ~/.claude-cavemantest. (The same list lives in src/hooks/uninstall.sh
and .ps1 — worth running one of those too if you touched it, since they are
still shipped for people who installed via the shell script.)
Mixed-version installs
Worth ten minutes, because it is a real configuration: plugin hooks and standalone
hooks can both be registered at once, and statusLine holds an absolute path
baked in at install time. So a patched hook and an unpatched one can run against
the same state directory.
Upgrade. Put a bare printf 'lite' > $CLAUDE_CONFIG_DIR/.caveman-active in a
fresh config, with no .caveman-sessions/, and start a session. Caveman comes up
in lite, and stays lite through a compaction. Covered by step 10 of the smoke
test.
Downgrade. Check out the previous hook version over a state directory the
patched hooks wrote, and confirm the old code never sees a mode it cannot parse.
The invariant that makes this safe: the legacy mirror never holds the literal
off. Deactivation unlinks it. off is in VALID_MODES, so an older
caveman-mode-tracker.js reading off from that path would pass its
!INDEPENDENT_MODES.has(…) check and inject "CAVEMAN MODE ACTIVE (off)", and an
older caveman-statusline.sh would render [CAVEMAN:OFF]. If you ever change
how deactivation writes state, this is the first thing to re-check.
Stale sibling. The three hook entrypoints resolve the per-session helpers
individually rather than demanding them in their requireSibling shape checks,
so a caveman-config.js from before this patch degrades to machine-wide
behaviour instead of turning the hooks into no-ops. tests/test_hook_missing_sibling.js
covers the absent-file case; the stale-file case is this paragraph plus the
downgrade run above.
What is not covered
- PowerShell badge behaviour. The CI Windows job parses every
.ps1and drives install/uninstall, but nothing renders the badge, socaveman-statusline.ps1is only checked by the parity greps inverify_repo.py. Changing it means testing on Windows by hand. - opencode.
src/plugins/opencode/plugin.jsstill has the machine-wide behaviour and is out of scope: it writes its own flag in the opencode config dir and never sees a Claude Codesession_id.