aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-30 17:27:21 +0200
committerIgor Soarez <igor@soarez.org>2026-10-30 17:27:21 +0200
commitb0cfd10b80443d3f67d263ff4db177bb53cc90ed (patch)
tree957951350434f38da83457f5467b6c000e892867 /tests
parent91436536b69c10aa333c2b2978284b57cca3ed64 (diff)
reload pi agents on an old extension: versioned extension, control socket, menu item and reload-agents
Diffstat (limited to 'tests')
-rwxr-xr-xtests/e2e.sh54
-rwxr-xr-xtests/kill-race.sh2
-rwxr-xr-xtests/pi-live.sh68
3 files changed, 116 insertions, 8 deletions
diff --git a/tests/e2e.sh b/tests/e2e.sh
index 4b60316..7f72e3c 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -30,6 +30,8 @@ PASSES=0
# Every dingger invocation below (daemon, focus, poke, menu bar) targets the
# isolated server and the test daemon's hook socket.
export DINGGER_TMUX_SOCKET="$SOCK" DINGGER_AGENT_SOCKET="$HOOK" DINGGER_STATE_DIR="$DIR/state"
+# The daemon updates an installed pi extension when it starts: not the user's.
+export PI_CODING_AGENT_DIR="$DIR/pi-agent"
t() { tmux -u -L "$SOCK" "$@"; }
@@ -38,7 +40,7 @@ cleanup() {
[ -n "${DAEMON2_PID:-}" ] && kill "$DAEMON2_PID" 2>/dev/null
t kill-server 2>/dev/null
tmux -L "$SOCK-restore-$$" kill-server 2>/dev/null
- rm -rf "$DIR"
+ if [ -n "${E2E_KEEP:-}" ]; then echo "kept $DIR"; else rm -rf "$DIR"; fi
}
trap cleanup EXIT
@@ -258,14 +260,14 @@ if [ -x "$MENUBAR" ]; then
expect "A1 done" "opt $A1 @agent_state" done
expect "menu title" "mb --print | head -1" "title: ×1 ✓1"
expect "menu rows: herdr priority; idle most recent first (C1 since its jump in step 4, B1 since the start)" \
- "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;"
+ "mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|pi;○ idle|Review PR 42;"
# Wait for working: B1 was idle before, so idle alone would not show the turn.
hook "$B1" working
expect "B1 working" "opt $B1 @agent_state" working
hook "$B1" idle
expect "B1 finishes a turn while watched: idle" "opt $B1 @agent_state" idle
expect "and is now the most recent idle agent: listed first" \
- "mb --print | tail -n +2 | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;"
+ "mb --print | tail -n +2 | grep -v '^⟳' | cut -f1,2 | tr '\t\n' '|;'" "× blocked|pi;✓ done|Deploy the widget;○ idle|Review PR 42;○ idle|pi;"
expect "subtitle: location, harness, waiting time" "mb --print | grep 'Deploy the widget' | cut -f3" "a · wa1 · pi · done now"
expect "idle subtitle has no waiting time" "mb --print | grep 'Review PR 42' | cut -f3" "b · wb1 · pi"
mb --click "$A1"
@@ -319,9 +321,12 @@ for (const arg of process.argv.slice(2)) {
await new Promise((r) => setTimeout(r, 300));
}
JS
+# The extension as installed: stamped with its version.
+mkdir -p "$DIR/pi-harness"
+PI_CODING_AGENT_DIR="$DIR/pi-harness" "$DINGGER" install pi >/dev/null
ext() { # pane events... (run "inside" a pane: tmux sets TMUX_PANE there)
local pane=$1; shift
- TMUX_PANE="$pane" EXTENSION="$ROOT/crates/dinggerd/assets/pi/pi-agent-state.ts" \
+ TMUX_PANE="$pane" EXTENSION="$DIR/pi-harness/extensions/dingger-agent-state.ts" \
node --experimental-strip-types --no-warnings "$DIR/ext-harness.mjs" "$@"
}
if command -v node >/dev/null; then
@@ -981,13 +986,52 @@ if command -v node >/dev/null; then
: >"$EXTLOG"; iext "$U1" session_start "/unread"
expect "/unread marks it" "opt $U1 @agent_state" done
expect "and says so" "grep -c '^notify marked unread: dingger shows it as done until you come back to it$' $EXTLOG" 1
- look "$AWAY"; look "$U1"
+ # A held mark clears only once a tick sees the user away (a tick here
+ # takes ~0.3 s; two looks within one would never be seen apart).
+ look "$AWAY"; sleep 1; look "$U1"
expect "unr: seen again" "opt $U1 @agent_state" idle
: >"$EXTLOG"; iext "$U1" session_start agent_start "/unread"
expect "/unread while working is refused" "grep -c '^notify only an idle agent can be marked unread$' $EXTLOG" 1
expect "and changes nothing" "opt $U1 @agent_state" working
fi
+# --- 23. agents on an old extension ------------------------------------------
+# pi's extension reports its version and control socket; the daemon counts
+# the agents on another version, which the menu offers to reload. (The
+# reload itself, against a real pi: tests/pi-live.sh.)
+echo "23. agents on an old extension"
+ext_report() { # pane version control
+ python3 - "$HOOK" "$1" "$2" "$3" <<'PY'
+import json, socket, sys
+params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "extension": sys.argv[3], "control": sys.argv[4]}
+s = socket.socket(socket.AF_UNIX)
+s.connect(sys.argv[1])
+s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode())
+s.close()
+PY
+}
+mkdir -p "$DIR/pi-current"
+PI_CODING_AGENT_DIR="$DIR/pi-current" $DINGGER install pi >/dev/null
+CURRENT=$(grep -o 'extensionVersion = "[0-9a-f]*"' "$DIR/pi-current/extensions/dingger-agent-state.ts" | cut -d'"' -f2)
+t new-session -d -s ext -n e1 -c "$DIR" "$DIR/bin/pi"
+E1=$(t display -p -t ext:e1 '#{pane_id}')
+# The agents reported so far sent no version: an extension from before
+# versions, which only a /reload by hand updates.
+expect "ext: the others are by hand" "[ \"\$(gopt @dingger_outdated_by_hand)\" -gt 0 ] && echo yes" yes
+BY_HAND=$(gopt @dingger_outdated_by_hand)
+ext_report "$E1" 0000000000000000 "$DIR/pi-0.sock"
+expect "ext: the version is on the pane" "opt $E1 @dingger_extension" 0000000000000000
+expect "and its control socket" "opt $E1 @dingger_control" "$DIR/pi-0.sock"
+expect "another version: reloadable" "gopt @dingger_outdated" 1
+expect "the menu offers to reload it" "mb --print | grep '^⟳'" \
+ "$(printf '⟳ Reload 1 Agent on an Old Extension\teach when idle and off screen · %s more need /reload' "$BY_HAND")"
+expect "reload-agents refuses without the current extension installed" "$DINGGER reload-agents 2>&1 | grep -c 'run .dingger install pi. first'" 1
+ext_report "$E1" "$CURRENT" "$DIR/pi-1.sock"
+expect "the current version: not counted" "gopt @dingger_outdated" 0
+expect "the menu: only those by hand, not clickable" "mb --print | grep '^⟳'" \
+ "$(printf '⟳ %s Agents Need /reload\ttheir dingger extension is too old to reload them\t(disabled)' "$BY_HAND")"
+t kill-session -t ext
+
# 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
diff --git a/tests/kill-race.sh b/tests/kill-race.sh
index 388c297..d85f654 100755
--- a/tests/kill-race.sh
+++ b/tests/kill-race.sh
@@ -8,7 +8,7 @@ r() { tmux -u -L $R "$@"; }
mkdir -p $D/bin; printf '#!/bin/sh\nexec sleep 100000\n' > $D/bin/pi; chmod +x $D/bin/pi
r -f /dev/null new-session -d -s a -n w1 $D/bin/pi; r new-session -d -s b -n w2 $D/bin/pi
P=$(r display -p -t a:w1 '#{pane_id}')
-start() { DINGGER_TMUX_SOCKET=$R DINGGER_AGENT_SOCKET=$D/hook.sock DINGGER_STATE_DIR=$D/state $BIN watch --interval-secs 30 --no-sounds --no-notifications >/dev/null 2>&1 & DP=$!; for _ in $(seq 50); do [ -S $D/hook.sock ] && break; sleep 0.05; done; }
+start() { PI_CODING_AGENT_DIR=$D/pi-agent DINGGER_TMUX_SOCKET=$R DINGGER_AGENT_SOCKET=$D/hook.sock DINGGER_STATE_DIR=$D/state $BIN watch --interval-secs 30 --no-sounds --no-notifications >/dev/null 2>&1 & DP=$!; for _ in $(seq 50); do [ -S $D/hook.sock ] && break; sleep 0.05; done; }
hook() { python3 -c "
import json,socket; s=socket.socket(socket.AF_UNIX); s.connect('$D/hook.sock')
s.sendall((json.dumps({'method':'pane.report_agent','params':{'pane_id':'$P','agent':'pi','state':'$1'}})+'\n').encode())"; }
diff --git a/tests/pi-live.sh b/tests/pi-live.sh
index 059a92b..4ea4caf 100755
--- a/tests/pi-live.sh
+++ b/tests/pi-live.sh
@@ -9,7 +9,8 @@
# Covers: identification, the extension's state and session reports, a turn
# (working, then done), /reload, an extension's blocked event, /importance, /unread,
# screen detection for a pi without the extension, restore (`pi --session`,
-# importance included), exit.
+# importance included), reloading an agent on an old extension (refused
+# while busy or with a draft), exit.
set -u
PI_BIN=$(cd "$(dirname "$1")" && pwd)/$(basename "$1")
ROOT=$(cd "$(dirname "$0")/.." && pwd)
@@ -35,7 +36,7 @@ cleanup() {
t kill-server 2>/dev/null
[ -n "${DAEMON_PID:-}" ] && kill "$DAEMON_PID" 2>/dev/null
[ -n "${FAKE_PID:-}" ] && kill "$FAKE_PID" 2>/dev/null
- rm -rf "$D"
+ if [ -n "${PI_LIVE_KEEP:-}" ]; then echo "kept $D"; else rm -rf "$D"; fi
}
trap cleanup EXIT
@@ -72,6 +73,12 @@ export PATH="$D/bin:$PATH" PI_CODING_AGENT_DIR="$D/agent" PI_OFFLINE=1 PI_SKIP_V
export DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$D/hook.sock" DINGGER_STATE_DIR="$D/state" DINGGER_TMUX_CONFIG="$D/tmux.conf"
t -f "$D/tmux.conf" new-session -d -s main -n agent -x 140 -y 40 -c "$D/work"
t new-window -d -t main: -n plain -c "$D/work2"
+EXT="$D/agent/extensions/dingger-agent-state.ts"
+stamp() { grep -o 'extensionVersion = "[0-9a-f]*"' "$EXT" | cut -d'"' -f2; }
+CURRENT=$(stamp)
+# An installed extension of another version: the daemon updates it as it
+# starts (a pi started after that loads the current one).
+sed -i '' "s/extensionVersion = \"$CURRENT\"/extensionVersion = \"0000000000000000\"/" "$EXT"
"$DINGGER" watch --interval-secs 1 --no-sounds --no-notifications >"$D/daemon.log" 2>&1 &
DAEMON_PID=$!
t send-keys -t main:agent "command -v pi > $D/which-pi" Enter
@@ -84,6 +91,10 @@ NO_MCP=$("$PI_BIN" --help 2>&1 | grep -q -- --no-mcp && echo --no-mcp)
st() { t show -pv -t "main:$1" @agent_state 2>/dev/null; }
type_line() { t send-keys -t "main:$1" -l "$2"; sleep 0.3; t send-keys -t "main:$1" Enter; }
+echo "0. the daemon updates the installed extension"
+expect "to its own version" "stamp" "$CURRENT" 30
+[ ${#CURRENT} -eq 16 ] && pass "a 16-digit version" || fail "version [$CURRENT]"
+
echo "1. the extension's reports"
t send-keys -t main:agent "clear; PI_CODING_AGENT_DIR=$D/agent pi $NO_MCP" Enter
expect "identified as pi" "t show -pv -t main:agent @agent 2>/dev/null" pi 150
@@ -123,6 +134,59 @@ expect "/unread: done again" "st agent" done 50
expect "by a mark" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'mark unread target=main:agent applied=true'" 1 30
expect "pi says so" "t capture-pane -p -t main:agent | grep -c 'marked unread: dingger shows it as done'" 1 30
+echo "1b. reloading an agent on an old extension"
+ctl() { t show -pv -t main:agent @dingger_control 2>/dev/null; }
+ask_reload() { python3 -c '
+import socket, sys
+s = socket.socket(socket.AF_UNIX); s.settimeout(3); s.connect(sys.argv[1])
+s.sendall(b"{\"method\":\"reload\"}\n"); print(s.makefile().readline().strip())' "$(ctl)"; }
+reloads() { sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c "reload: $1"; }
+expect "the agent reports the current version" "t show -pv -t main:agent @dingger_extension 2>/dev/null" "$CURRENT"
+expect "and a control socket" "[ -S \"\$(ctl)\" ] && echo yes" yes
+expect "nothing to reload" "\"$DINGGER\" reload-agents" "every pi agent runs the current dingger extension" 1
+# Run an old one: change the installed file, /reload by hand, then install
+# the current one again (what a dingger update does).
+sed -i '' "s/extensionVersion = \"$CURRENT\"/extensionVersion = \"0000000000000000\"/" "$EXT"
+type_line agent "/reload"
+expect "after /reload, it reports the old version" "t show -pv -t main:agent @dingger_extension 2>/dev/null" 0000000000000000 80
+expect "its new instance has a socket of its own" "[ -S \"\$(ctl)\" ] && echo yes" yes
+PI_CODING_AGENT_DIR="$D/agent" "$DINGGER" install pi >/dev/null
+expect "the daemon counts it outdated" "t show -gv @dingger_outdated 2>/dev/null" 1 30
+SOCK1=$(ctl)
+# Reloading cannot help while the installed file is not the current one.
+cp "$EXT" "$D/current.ts"; echo "// edited" >>"$EXT"
+expect "reload-agents refuses: install first" "\"$DINGGER\" reload-agents 2>&1 | grep -c 'run .dingger install pi. first'" 1 1
+python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.sendall(b"{\"method\":\"agents.reload_outdated\"}\n"); s.recv(64)' "$D/hook.sock"
+expect "and so does the daemon, asked directly" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'reload agents: the installed pi extension is not'" 1 30
+sleep 1.5
+expect "without reloading it" "reloads reloading" 0 1
+cp "$D/current.ts" "$EXT"
+# The extension's own checks, asked directly.
+type_line agent "slow 4 busy turn"
+expect "working" "st agent" working 50
+expect "busy: it refuses" "ask_reload" '{"ok":false,"reason":"busy"}'
+expect "and the turn ends" "st agent" done 80
+t send-keys -t main:agent -l "half typed"
+sleep 0.5
+expect "a draft: it refuses" "ask_reload" '{"ok":false,"reason":"draft"}'
+# Through dingger: refused for the draft, retried until it can.
+"$DINGGER" reload-agents >"$D/reload.out"
+expect "reload-agents: not yet" "head -1 $D/reload.out" "reloaded 0 of 1 agent on an old dingger extension"
+expect "it says why it waits" "sed -n 2p $D/reload.out" "the other 1 reload once idle and off screen, with an empty input box"
+expect "the daemon was told no: a draft" "sed 's/\x1b\[[0-9;]*m//g' $D/daemon.log | grep -c 'reload: not now target=main:agent reason=draft'" 1 30
+expect "the draft is untouched" "t capture-pane -p -t main:agent | grep -c 'half typed'" 1
+for _ in $(seq 10); do t send-keys -t main:agent BSpace; done
+expect "once the input box is empty: reloaded" "t show -pv -t main:agent @dingger_extension 2>/dev/null" "$CURRENT" 150
+expect "by the daemon" "reloads reloading" 1 30
+expect "a new instance (a new socket)" "[ \"\$(ctl)\" != \"$SOCK1\" ] && [ -S \"\$(ctl)\" ] && echo yes" yes
+expect "the old socket is gone" "[ -e \"$SOCK1\" ] && echo left || echo gone" gone
+expect "nothing left to reload" "t show -gv @dingger_outdated 2>/dev/null" 0 30
+expect "still done (unseen)" "st agent" done
+expect "no message reached the model" "grep -c dingger-reload \"\$(t show -pv -t main:agent @agent_session)\"" 0
+type_line agent "slow 1 after"
+expect "it still works: working" "st agent" working 50
+expect "and done" "st agent" done 60
+
echo "2. screen detection (pi without the extension)"
t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter
expect "identified as pi" "t show -pv -t main:plain @agent 2>/dev/null" pi 150