aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-23 15:08:53 +0300
committerIgor Soarez <igor@soarez.org>2026-10-23 15:08:53 +0300
commit7520141a55e8876824dcb4e2cf5e8e3027efa59d (patch)
tree02022949d5b9bd41c5421ae7134ca8816f4b045b /tests
parenta7d34521c7f874beb44e0314017ab28979550495 (diff)
restore Claude Code agents in their own sessions
`dingger install claude` adds a SessionStart hook to Claude's settings (every other key and hook kept, in order; the original backed up; written through a symlink; installing again changes nothing). The hook is `dingger hook claude`: it reports the session's transcript for its tmux pane, prints nothing (Claude adds SessionStart output to the conversation) and always exits 0. Claude's state still comes from its screen. restore types `claude --resume <id>` in the directory the session ran in (elsewhere, Claude moves the conversation there). it resumes an agent only when its session file exists: a Claude session that never got a message has no transcript, and `--resume` fails ("No conversation found"). the snapshot now keeps an agent's command line too, so such a pane gets it retyped instead of nothing. - the hook receiver takes a report without a state: only the session. - a report that arrives after the watcher listed the panes is not dropped as "agent gone": Claude reports once, as it starts. - serde_json preserve_order (indexmap is already a dependency, via toml). e2e step 18: install against a settings file like a real one, a fake claude running the exact installed hook command, resume by id, retype without a transcript, the resumed agent reporting again. a server that restore starts now gets the test's plain shells: it got the user's login shell, whose profile put the real claude first, which started (stuck at its trust dialog) during development. every test restore now checks that no real pi or claude runs in a test pane.
Diffstat (limited to 'tests')
-rwxr-xr-xtests/e2e.sh108
1 files changed, 106 insertions, 2 deletions
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 26914f1..c4bcabb 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -361,8 +361,18 @@ STATE2="$DIR/state2"
# tmux gives new windows the environment of the client that creates them:
# both the test and restore run with the fake pi first on PATH.
r() { env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" "$@"; }
+# A server restore starts gets the same plain shells as the test's own: the
+# user's login shell would read their profile, which puts the real agents
+# first on PATH.
+printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$DIR/tmux-test.conf"
renv() { env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
- DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG=/dev/null "$@"; }
+ DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" "$@"; }
+# Every pi or claude in a test pane must be a fake (they run from $DIR).
+real_agents() {
+ for tty in $(r list-panes -a -F '#{pane_tty}' 2>/dev/null | sed 's|/dev/||'); do
+ ps -o comm=,args= -t "$tty" | awk -v dir="$DIR/" '($1 ~ /(^|\/)(pi|claude)$/ || $2 ~ /^(pi|claude)$/) && index($2, dir) != 1'
+ done
+}
W="$DIR/w"; mkdir -p "$W/one" "$W/two" "$W/three"
# A fake pi named pi (the daemon finds agents by process name) that records
# its arguments.
@@ -417,7 +427,7 @@ fingerprint() {
BEFORE=$(fingerprint)
# Started directly, not through a function: $! must be the daemon itself.
env PATH="$DIR/rbin:$PATH" DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" \
- DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG=/dev/null \
+ DINGGER_STATE_DIR="$STATE2" DINGGER_TMUX_CONFIG="$DIR/tmux-test.conf" \
"$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/daemon2.log" 2>&1 &
DAEMON2_PID=$!
latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; }
@@ -436,6 +446,8 @@ AGENT=$(r display -p -t '=work:agent' '#{pane_id}')
GONE=$(r display -p -t '=work:gone' '#{pane_id}')
expect "the fake pi agents are detected" "r show -pv -t $AGENT @agent 2>/dev/null; r show -pv -t $GONE @agent 2>/dev/null" "pi
pi" 80
+# Session files exist: restore resumes only sessions with one.
+mkdir -p "$W/sessions" && touch "$W/sessions/a b.jsonl" "$W/sessions/gone.jsonl"
hook2 "$AGENT" working "$W/sessions/a b.jsonl"
hook2 "$GONE" idle "$W/sessions/gone.jsonl"
r set-option -p -t '=work:hooked' @dingger_cmd 'slp 200000 | cat'
@@ -473,6 +485,7 @@ cp "$STATE2/restore.log" "$DIR/restore.out" 2>/dev/null
expect "restore succeeds" "grep -c FAILED $DIR/restore.out" 0
expect "agents resumed with their session files" "grep -- --session $DIR/pi-args | sort" "--session $W/sessions/a b.jsonl
--session $W/sessions/gone.jsonl"
+expect "no real agent runs in a test pane" "real_agents" ""
expect "both agents run" "r display -p -t '=work:agent' '#{pane_current_command}'; r display -p -t '=work:gone' '#{pane_current_command}'" "pi
pi"
expect "restore reports them" "grep -c '^resumed 2 of 2 agents' $DIR/restore.out" 1
@@ -508,6 +521,8 @@ expect "a new offer: all four sessions, tmux is not running" \
"python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" 4 80
mbr --restore work
expect "restoring with no server starts it and rebuilds the session" "names work" "$WORK_BEFORE"
+expect "with the test's shell, not the user's login shell" "r display -p -t '=work:agent' '#{pane_current_command}'" bash
+expect "no real agent runs in a test pane (no server)" "real_agents" ""
expect "the offer shrinks to what is still missing" \
"python3 -c 'import json,sys; print(sorted(s[\"name\"] for s in json.load(open(sys.argv[1]))[\"sessions\"]))' $STATE2/offer.json 2>/dev/null" \
"['0', 'keep', 'viewer']" 80
@@ -524,6 +539,95 @@ echo "17. kill during a write"
expect "20 kills right after done: hook and display state agree" \
"\"$ROOT/tests/kill-race.sh\" \"$DINGGER\" 20 | grep -o '^[^:]*: [0-9]* of' | sed 's/.*: //'" "0 of" 1
+# --- 18. claude: install its hook; restore resumes its session ---------------
+echo "18. claude"
+CC="$DIR/cc-config"; mkdir -p "$CC/real"
+cat >"$CC/real/settings.json" <<'JSON'
+{
+ "env": {
+ "TRACE_TO_LANGSMITH": "true"
+ },
+ "model": "sonnet",
+ "hooks": {
+ "SessionStart": [
+ {
+ "matcher": "^(startup|resume|clear|compact|fork)$",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash '/x/herdr-agent-state.sh' session",
+ "timeout": 10
+ }
+ ]
+ }
+ ]
+ },
+ "theme": "auto"
+}
+JSON
+cp "$CC/real/settings.json" "$CC/original.json"
+ln -s "$CC/real/settings.json" "$CC/settings.json"
+ccinstall() { CLAUDE_CONFIG_DIR="$CC" "$DINGGER" install claude; }
+ccinstall >"$DIR/ccinstall.out" 2>&1
+expect "install claude succeeds" "grep -c '^installed in' $DIR/ccinstall.out" 1
+expect "settings: the same keys in the same order, herdr's hook first, dingger's added" \
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(list(d), [h[\"command\"].rsplit(\"/\", 1)[-1] for g in d[\"hooks\"][\"SessionStart\"] for h in g[\"hooks\"]])' $CC/settings.json" \
+ "['env', 'model', 'hooks', 'theme'] [\"herdr-agent-state.sh' session\", 'dingger hook claude']"
+expect "everything else unchanged" \
+ "python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); d[\"hooks\"][\"SessionStart\"].pop(); print(d == json.load(open(sys.argv[2])))' $CC/settings.json $CC/original.json" True
+expect "written through the symlink" "[ -L $CC/settings.json ] && cmp -s $CC/settings.json $CC/real/settings.json && echo yes" yes
+expect "the original is backed up" "cmp -s $CC/real/settings.json.bak-dingger $CC/original.json && echo same" same
+ccinstall >"$DIR/ccinstall2.out" 2>&1
+expect "installing again changes nothing" "cat $DIR/ccinstall2.out" "already installed in $(cd "$CC/real" && pwd -P)/settings.json"
+echo '{"model": ' >"$CC/broken.json"; cp "$CC/broken.json" "$CC/broken-copy.json"
+expect "invalid JSON is not touched" \
+ "CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; mkdir -p $CC/b; cp $CC/broken.json $CC/b/settings.json; CLAUDE_CONFIG_DIR=$CC/b $DINGGER install claude >/dev/null 2>&1; echo \$?; cmp -s $CC/b/settings.json $CC/broken-copy.json && echo unchanged" "1
+unchanged"
+# The exact command Claude would run, from the installed settings.
+HOOK_CMD=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["hooks"]["SessionStart"][-1]["hooks"][0]["command"])' "$CC/settings.json")
+expect "the hook prints nothing and exits 0, even with no daemon or bad input" \
+ "printf '{\"hook_event_name\":\"SessionStart\",\"transcript_path\":\"/x.jsonl\"}' | TMUX_PANE=%99 DINGGER_AGENT_SOCKET=$DIR/none.sock sh -c \"$HOOK_CMD\"; echo garbage | sh -c \"$HOOK_CMD\"; echo \"exit \$?\"" "exit 0"
+# A fake claude named claude: records its arguments, runs the SessionStart
+# hook with Claude's input (`claude <id>` starts session <id>, `claude
+# --resume <id>` resumes it), then waits.
+cp /bin/sleep "$DIR/rbin/real/claude" && codesign --force --sign - "$DIR/rbin/real/claude" 2>/dev/null
+cat >"$DIR/rbin/claude" <<EOF
+#!/bin/sh
+echo "\$*" >> $DIR/claude-args
+id=\${2:-\$1}
+printf '{"session_id":"%s","transcript_path":"%s","cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' \
+ "\$id" "$W/claude/\$id.jsonl" "\$PWD" | DINGGER_AGENT_SOCKET=$DIR/hook2.sock sh -c "$HOOK_CMD"
+exec $DIR/rbin/real/claude 100000
+EOF
+chmod +x "$DIR/rbin/claude"
+# "talked" has a transcript; "fresh" never got a message, so it has none.
+mkdir -p "$W/claude" && touch "$W/claude/talked.jsonl"
+r new-session -d -s cc -n talked -c "$W/two"
+r new-window -d -t '=cc:' -n fresh -c "$W/three"
+r send-keys -t '=cc:talked' 'claude talked' Enter
+r send-keys -t '=cc:fresh' 'claude fresh' Enter
+r set-option -p -t '=cc:fresh' @dingger_cmd 'claude fresh'
+TALKED=$(r display -p -t '=cc:talked' '#{pane_id}')
+expect "claude is detected" "r show -pv -t '=cc:talked' @agent 2>/dev/null; r show -pv -t '=cc:fresh' @agent 2>/dev/null" "claude
+claude" 80
+expect "its hook's session is kept on its pane" "r show -pv -t $TALKED @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80
+expect "snapshot: claude's session" "in_snapshot '\"session\": \"$W/claude/talked.jsonl\"'" True 80
+r kill-server
+expect "a new restore candidate" \
+ "python3 -c 'import json,sys; print(\"cc\" in [s[\"name\"] for s in json.load(open(sys.argv[1]))[\"snapshot\"][\"sessions\"]])' $STATE2/restore.json 2>/dev/null" True 80
+: >"$DIR/claude-args"
+renv "$DINGGER" restore --session cc >"$DIR/restore-cc.out" 2>&1
+expect "no real agent runs in a test pane (claude)" "real_agents" ""
+expect "claude resumed by id" "cat $DIR/claude-args" "--resume talked"
+expect "in its directory" "r display -p -t '=cc:talked' '#{pane_current_path}'" "$(cd "$W/two" && pwd -P)"
+expect "restore reports it" "grep -c '^resumed 1 of 1 agents' $DIR/restore-cc.out" 1
+expect "no transcript: its command line is typed, not run" \
+ "typed_line '=cc:fresh' | grep -o 'claude fresh\$'; r display -p -t '=cc:fresh' '#{pane_current_command}'" "claude fresh
+bash"
+expect "the resumed claude reports its session again" \
+ "r show -pv -t '=cc:talked' @agent_session 2>/dev/null" "$W/claude/talked.jsonl" 80
+[ "$FAILS" -gt 0 ] && { echo "--- restore-cc:"; cat "$DIR/restore-cc.out"; }
+
# Every daemon this run started must stop with it.
kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null
expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0