aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-23 13:20:11 +0300
committerIgor Soarez <igor@soarez.org>2026-10-23 13:20:11 +0300
commitc082f5044eedd2294d72f920d1018e40d73aab33 (patch)
tree54420e9c0fcb366e8db776c9463c4530737704d7 /tests
parent0f06b0b15d6b064703fa1166fb8e7ceda209aaa8 (diff)
restore, step 2: resume agents, retype command lines
- the pi extension reports its session file; the daemon keeps it in the pane option @agent_session, and snapshots carry it. restore runs `pi --session <file>` in the rebuilt pane, one agent every 0.5s, then reports which agents run and which were interrupted mid-turn. - other panes: the command line running in them is typed at the new prompt but not run. exactly as typed with the zsh hook (dingger install zsh: preexec/precmd keep @dingger_cmd), else the foreground program's exact argv from the kernel (KERN_PROCARGS2), shell-quoted. - "at a prompt" is the pane's own shell owning the terminal's foreground process group, not a name check: `sh -c ...` is a running program. - an agent or command that exits keeps its place in snapshots for 60s, so a shutdown that stops programs before tmux still restores them. - typing waits for each new prompt (text typed earlier echoes twice). e2e step 16 covers resume with a session path containing a space, the interrupted report, an agent that exits just before the server dies, a quoted command retyped not run, and the hook's text; dropping the grace period, judging prompts by name, running retyped commands, or not storing the session reference each fail it. the test's fake pi is guarded so a real pi never starts.
Diffstat (limited to 'tests')
-rwxr-xr-xtests/e2e.sh74
1 files changed, 69 insertions, 5 deletions
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 425fdf3..29d900b 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -353,12 +353,21 @@ fi
echo "16. restore"
R="$SOCK-restore"
STATE2="$DIR/state2"
-r() { tmux -u -L "$R" "$@"; }
-renv() { env DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$DIR/hook2.sock" DINGGER_STATE_DIR="$STATE2" \
- DINGGER_TMUX_CONFIG=/dev/null "$@"; }
+# 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" "$@"; }
+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 "$@"; }
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.
+mkdir -p "$DIR/rbin/real"
+cp /bin/sleep "$DIR/rbin/real/pi" && codesign --force --sign - "$DIR/rbin/real/pi" 2>/dev/null
+printf '#!/bin/sh\necho "$*" >> %s\nexec %s 100000\n' "$DIR/pi-args" "$DIR/rbin/real/pi" >"$DIR/rbin/pi"
+chmod +x "$DIR/rbin/pi"
start_viewer() {
- r -f /dev/null new-session -d -s viewer -x 120 -y 40 \; set -g default-shell /bin/sh \; \
+ env PATH="$DIR/rbin:$PATH" tmux -u -L "$R" -f /dev/null new-session -d -s viewer -x 120 -y 40 \; set -g default-shell /bin/sh \; \
+ set -g default-command /bin/sh \; \
send-keys -t viewer "exec env -u TMUX tmux -u -L $R new-session -As 0 -c $W/one" Enter
for _ in $(seq 50); do r has-session -t =0 2>/dev/null && [ -n "$(r list-clients)" ] && return; sleep 0.1; done
echo "viewer client did not attach"
@@ -380,6 +389,22 @@ r new-window -d -t '=work:7' -n logs -c "$W/one"
r select-window -t '=work:7'
r new-session -d -s keep -n saved -c "$W/one"
r new-window -d -t '=keep:' -n saved2
+# Agents, a running command, and a command the zsh hook recorded.
+r new-window -d -t '=work:3' -n agent -c "$W/two"
+r new-window -d -t '=work:4' -n gone -c "$W/one"
+r new-window -d -t '=work:5' -n cmd -c "$W/one"
+r new-window -d -t '=work:6' -n hooked -c "$W/one"
+# Never start the real pi: the panes must find the fake one first.
+r send-keys -t '=work:agent' "command -v pi > $DIR/which-pi" Enter
+for _ in $(seq 30); do [ -s "$DIR/which-pi" ] && break; sleep 0.1; done
+if [ "$(cat "$DIR/which-pi" 2>/dev/null)" != "$DIR/rbin/pi" ]; then
+ fail "the restore server's panes would run $(cat "$DIR/which-pi" 2>/dev/null), not the fake pi; stopping"
+ echo "$PASSES passed, $FAILS failed"; exit 1
+fi
+r send-keys -t '=work:agent' 'clear; pi' Enter
+r send-keys -t '=work:gone' 'pi' Enter
+r send-keys -t '=work:cmd' "sh -c 'sleep 100000; :' 'two words'" Enter
+r send-keys -t '=work:hooked' 'sleep 200000' Enter
fingerprint() {
r list-panes -a -F '#{session_name}|#{window_index}|#{?automatic-rename,(auto),#{window_name}}|#{window_active}|#{window_zoomed_flag}|#{automatic-rename}|#{pane_index}|#{pane_active}|#{pane_left},#{pane_top},#{pane_width}x#{pane_height}|#{pane_current_path}' \
| grep -E '^(0|work)\|'
@@ -389,6 +414,33 @@ renv "$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >>"$DIR/d
DAEMON2_PID=$!
latest_snapshot() { ls "$STATE2"/snapshots/*.json 2>/dev/null | tail -1; }
expect "a snapshot of both sessions" "grep -c '\"name\": \"work\"' \"\$(latest_snapshot)\" 2>/dev/null" 1 80
+hook2() { # pane state session-file
+ python3 - "$DIR/hook2.sock" "$1" "$2" "$3" <<'PY'
+import json, socket, sys
+s = socket.socket(socket.AF_UNIX)
+s.connect(sys.argv[1])
+s.sendall((json.dumps({"method": "pane.report_agent", "params": {"pane_id": sys.argv[2],
+ "agent": "pi", "state": sys.argv[3], "agent_session_path": sys.argv[4]}}) + "\n").encode())
+s.close()
+PY
+}
+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
+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'
+expect "an agent's session file is kept on its pane" "r show -pv -t $AGENT @agent_session 2>/dev/null" "$W/sessions/a b.jsonl"
+in_snapshot() { python3 -c 'import sys; print(sys.argv[2] in open(sys.argv[1]).read())' "$(latest_snapshot)" "$1"; }
+expect "snapshot: the agent's session" "in_snapshot '\"session\": \"$W/sessions/a b.jsonl\"'" True 80
+expect "snapshot: a running command's exact arguments" "in_snapshot \"'sleep 100000; :' 'two words'\"" True 80
+expect "snapshot: the zsh hook's text" "in_snapshot '\"command\": \"slp 200000 | cat\"'" True 80
+# An agent that exits just before tmux dies (a shutdown stops programs first).
+kill "$(ps -o tpgid= -p "$(r display -p -t "$GONE" '#{pane_pid}')" | tr -d ' ')"
+expect "the exited agent is noticed" "r show -pv -t $GONE @agent 2>/dev/null" ""
+sleep 1.5
+expect "snapshot: the exited agent is kept for a while" "in_snapshot gone.jsonl" True
r kill-server
expect "the dead server's layout becomes the restore candidate" \
"python3 -c 'import json,sys; c=json.load(open(sys.argv[1])); print(c[\"status\"], len(c[\"snapshot\"][\"sessions\"]))' $STATE2/restore.json 2>/dev/null" "offered 4" 80
@@ -399,9 +451,21 @@ expect "the new server has a bare 0" "r list-windows -t =0 -F '#{window_panes}'
expect "the candidate survives the new server's snapshots" \
"python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"snapshot\"][\"server\"] != json.load(open(sys.argv[2]))[\"server\"])' $STATE2/restore.json \"\$(latest_snapshot)\"" True 80
expect "dry run plans: replace 0, keep keep and viewer, create work" \
- "renv \"$DINGGER\" restore --dry-run | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;"
+ "renv \"$DINGGER\" restore --dry-run | grep -v '^ ' | cut -c1-14 | sort | tr '\n' ';'" "create work:;keep keep:;keep viewe;replace 0: 2 ;"
renv "$DINGGER" restore >"$DIR/restore.out" 2>&1
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 "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
+expect "the interrupted turn is reported" "grep -c '^interrupted work:agent' $DIR/restore.out" 1
+typed_line() { r capture-pane -p -t "$1" | grep -v '^ *$' | tail -1; }
+expect "a command is retyped with its quoting" "typed_line '=work:cmd' | grep -o \"sh -c 'sleep 100000; :' 'two words'\$\"" "sh -c 'sleep 100000; :' 'two words'"
+expect "the zsh hook's text is retyped" "typed_line '=work:hooked' | grep -o 'slp 200000 | cat\$'" "slp 200000 | cat"
+expect "neither runs: the shell waits for Enter" \
+ "r display -p -t '=work:cmd' '#{pane_current_command}'; r display -p -t '=work:hooked' '#{pane_current_command}'" "bash
+bash"
expect "windows, panes, layouts, directories, focus, zoom are back" "fingerprint" "$BEFORE"
expect "the terminal's client moved to the restored 0, still attached" "r list-clients -F '#{session_name}'" 0
expect "a used session is left alone" "r list-windows -t =keep -F '#{window_name}'" mine