all repos — dingger @ master

Watches the AI coding agents in your tmux panes on macOS and tells you which need you, from the menu bar, a floating panel, the status line and notifications; jumps to them, and restores tmux sessions and agents after a crash

tests/pi-live.sh (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
#!/bin/bash
# Live check of dingger against a real pi binary, on a tmux server and
# dingger daemon of its own, with a fake local model (tests/fake-model.py):
# no network, no credentials, nothing of the user's pi touched (pi gets its
# own agent directory).
#
#   tests/pi-live.sh <pi binary> [dingger binary]
#
# 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), 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)
DINGGER="${2:-$ROOT/target/release/dingger}"
case "$DINGGER" in /*) ;; *) DINGGER="$PWD/$DINGGER" ;; esac
MENU="$ROOT/target/dingger-menu"
D=$(mktemp -d /tmp/dingger-pi-live.XXXXXX)
R="dingger-pi-live-$$"
PASSES=0; FAILS=0
pass() { PASSES=$((PASSES + 1)); echo "  ok   $1"; }
fail() { FAILS=$((FAILS + 1)); echo "  FAIL $1"; }
expect() { # description command expected [tries of 0.1s, default 50]
  local desc=$1 cmd=$2 want=$3 tries=${4:-50} got
  for _ in $(seq "$tries"); do
    got=$(eval "$cmd")
    [ "$got" = "$want" ] && { pass "$desc"; return; }
    sleep 0.1
  done
  fail "$desc: want [$want] got [$got]"
}
note() { echo "  note $1"; }
t() { tmux -u -L "$R" "$@"; }
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
  if [ -n "${PI_LIVE_KEEP:-}" ]; then echo "kept $D"; else rm -rf "$D"; fi
}
trap cleanup EXIT

echo "pi $("$PI_BIN" --version 2>&1 | head -1), dingger $DINGGER"
python3 "$ROOT/tests/fake-model.py" >"$D/port" 2>"$D/fake.err" &
FAKE_PID=$!
for _ in $(seq 50); do [ -s "$D/port" ] && break; sleep 0.1; done
PORT=$(cat "$D/port")
# Two agent directories: one with dingger's extension (and a test extension
# that blocks the way extensions do), one without (screen detection).
for agent in agent agent-plain; do
  mkdir -p "$D/$agent/extensions"
  cat >"$D/$agent/models.json" <<EOF
{"providers": {"fake": {"baseUrl": "http://127.0.0.1:$PORT/v1", "api": "openai-completions", "apiKey": "fake", "models": [{"id": "fake-model", "contextWindow": 400, "cost": {"input": 100000, "output": 1000000, "cacheRead": 0, "cacheWrite": 0}}]}}}
EOF
  # A small window, so a few replies fill it measurably; nothing compacts
  # on its own, and /compact keeps almost nothing.
  echo '{"defaultProvider": "fake", "defaultModel": "fake-model", "compaction": {"enabled": false, "keepRecentTokens": 1}}' >"$D/$agent/settings.json"
done
PI_CODING_AGENT_DIR="$D/agent" "$DINGGER" install pi >/dev/null
cat >"$D/agent/extensions/test-blocked.ts" <<'EOF'
export default function (pi) {
  pi.registerCommand("block", { description: "test: emit blocked", handler: async () => { pi.events.emit("blocked", { active: true, label: "test block" }); } });
  pi.registerCommand("unblock", { description: "test: clear blocked", handler: async () => { pi.events.emit("blocked", { active: false }); } });
  pi.registerCommand("ask", { description: "test: a confirm dialog", handler: async (_args, ctx) => { await ctx.ui.confirm("Test dialog", "Proceed?"); } });
}
EOF
mkdir -p "$D/bin" "$D/work" "$D/work2"
ln -s "$PI_BIN" "$D/bin/pi"
# Plain shells: a login shell would read the user's profile, which puts the
# user's own pi first on PATH.
printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$D/tmux.conf"
# Every pi here uses the test's agent directory, including one a restore
# starts (`pi --session`, without the command line's own variables).
export PATH="$D/bin:$PATH" PI_CODING_AGENT_DIR="$D/agent" PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0
# The fake model's prices make each reply cost $4.00 (10 input tokens at
# $100,000 per million, 3 output at $1,000,000); the daemon reads costs
# from session files every 0.3 s.
export DINGGER_COST_SCAN_SECS=0.3
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
for _ in $(seq 30); do [ -s "$D/which-pi" ] && break; sleep 0.1; done
if [ "$(cat "$D/which-pi")" != "$D/bin/pi" ]; then
  echo "the test panes would run $(cat "$D/which-pi"), not $PI_BIN; stopping"; exit 1
fi
# --no-mcp (pi 1.0.4+): no MCP servers, whatever the user configured.
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
expect "idle at start" "st agent" idle 100
expect "the extension reported (state from the hook)" "t show -pv -t main:agent @dingger_hook 2>/dev/null" idle 100
expect "its session file is on the pane" "t show -pv -t main:agent @agent_session 2>/dev/null | grep -c '^$D/agent/sessions/.*\.jsonl$'" 1 100
type_line agent "slow 3 hello"
expect "a turn: working" "st agent" working 50
expect "then done (nobody watching)" "st agent" done 80
expect "the session file is written" "[ -s \"\$(t show -pv -t main:agent @agent_session)\" ] && echo yes" yes
type_line agent "/reload"
sleep 2
type_line agent "slow 2 again"
expect "after /reload: working" "st agent" working 50
expect "and done" "st agent" done 60
type_line agent "/block"
expect "an extension's blocked event: blocked" "st agent" blocked 50
type_line agent "/unblock"
expect "cleared: back to done" "st agent" done 50
type_line agent "/ask"
sleep 1.5
note "during a pi confirm dialog dingger shows: $(st agent) (pi gives extensions no dialog event)"
t send-keys -t main:agent Escape
sleep 0.5
type_line agent "/importance high"
expect "/importance high: the pane option" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50
expect "pi says so" "t capture-pane -p -t main:agent | grep -c 'importance: high'" 1 30
expect "and adds no footer line" "t capture-pane -p -t main:agent | grep -v '^ *$' | tail -1 | grep -c 'importance'" 0
expect "saved in the session file" "grep -c '\"customType\":\"dingger-importance\"' \"\$(t show -pv -t main:agent @agent_session)\"" 1 30
# Seen (what a jump does; no client is attached here), then marked unread.
t set -p -t main:agent @dingger_ack 1; "$DINGGER" poke
expect "seen: idle" "st agent" idle 50
type_line agent "/unread"
expect "/unread: done again" "st agent" done 50
# Not a turn (an unknown command would be sent to the model, and also end
# done): the daemon applied a mark, and pi said so.
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"}'
draft() { t show -pv -t main:agent @dingger_draft 2>/dev/null; }
draft_file() { echo "$D/drafts/$(basename "$(t show -pv -t main:agent @agent_session)").draft"; }
expect "the draft reaches dingger" "draft" "half typed" 30
expect "and is kept for the session" "cat \"\$(draft_file)\"" "half typed" 30
# 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 "deleted: no draft" "draft" "" 30
expect "nor its file" "[ -e \"\$(draft_file)\" ] && echo left || echo gone" gone 30
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
t send-keys -t main:agent -l "slow 1 after"
expect "typed, then a pause: a draft" "draft" "slow 1 after" 30
t send-keys -t main:agent Enter
expect "it still works: working" "st agent" working 50
expect "and done" "st agent" done 60
expect "a message sent is no draft" "draft" "" 30
expect "nor kept" "[ -e \"\$(draft_file)\" ] && echo left || echo gone" gone 30
# What it cost: pi prices each reply; dingger counts them.
replies() { grep -c '"role":"assistant"' "$1"; }
SESSION_FILE=$(t show -pv -t main:agent @agent_session)
N=$(replies "$SESSION_FILE")
[ "$N" -ge 3 ] && pass "$N replies so far" || fail "only $N replies"
WANT=$(python3 -c "print(f'{4*$N:.2f}')" )
expect "the agent's spend today" "t show -pv -t main:agent @dingger_cost 2>/dev/null" "$WANT" 30
expect "in total" "t show -gv @dingger_cost_today 2>/dev/null" "$WANT" 30
expect "and this week and month" "t show -gv @dingger_cost_week; t show -gv @dingger_cost_month" "$WANT
$WANT"
# A fork copies the session's replies into a new file, then adds its own.
(cd "$D/work" && pi --fork "$SESSION_FILE" -p "slow 1 forked" $NO_MCP >"$D/fork.out" 2>&1)
FORK=$(ls -t "$(dirname "$SESSION_FILE")"/*.jsonl | head -1)
[ "$FORK" != "$SESSION_FILE" ] && [ "$(replies "$FORK")" -eq $((N + 1)) ] \
  && pass "pi --fork: a new file with the $N replies copied and its own" || fail "fork: $FORK ($(replies "$FORK") replies)"
expect "the fork adds only its own reply" "t show -gv @dingger_cost_today 2>/dev/null" "$(python3 -c "print(f'{4*($N+1):.2f}')")" 30
expect "the agent's own spend is unchanged" "t show -pv -t main:agent @dingger_cost 2>/dev/null" "$WANT"
# How full its context is: the number pi's footer shows ("3.3%/400"), in
# an agent of its own (compacting would hide this one's conversation from
# the restore below).
mkdir -p "$D/work3"
t new-window -d -t main: -n ctx -c "$D/work3"
t send-keys -t main:ctx "pi $NO_MCP" Enter
expect "context: an agent of its own" "st ctx" idle 150
footer() { t capture-pane -p -t main:ctx | grep -oE '(\?|[0-9.]+%)/400' | tail -1 | sed 's|/400||'; }
as_footer() { python3 -c "import sys; f=sys.argv[1]; print('' if f in ('?', '') else round(float(f.rstrip('%'))))" "$(footer)"; }
ctx() { t show -pv -t main:ctx @dingger_context 2>/dev/null; }
same_as_footer() { [ "$(ctx)" = "$(as_footer)" ] && echo same || echo "dingger [$(ctx)] footer [$(footer)]"; }
expect "context: none yet" "footer; ctx" "0.0%
0" 30
for msg in "slow 1 one" "slow 1 two"; do
  t send-keys -t main:ctx -l "$msg" && t send-keys -t main:ctx Enter
  expect "and done" "st ctx" done 60
done
expect "context: after replies, pi's footer" "footer" "3.3%" 30
expect "context: dingger has pi's number" same_as_footer same 30
t send-keys -t main:ctx -l "/compact" && t send-keys -t main:ctx Enter
expect "context: after compacting, pi does not know" "footer" "?" 60
expect "context: nor does dingger" same_as_footer same 30
expect "the menu shows no percentage" "\"$MENU\" --print | grep -F 'main · ctx' | grep -c '%'" 0
t send-keys -t main:ctx -l "slow 1 after compacting" && t send-keys -t main:ctx Enter
expect "and done" "st ctx" done 60
expect "context: known again after a reply" "ctx | grep -cE '^[0-9]+$'" 1 30
expect "context: pi's number again" same_as_footer same 30
expect "the menu: the percentage last" "\"$MENU\" --print | grep -F 'main · ctx' | cut -f3 | grep -oE ' [0-9]+%$'" " $(ctx)%"
expect "the panel: cost, then context" "\"$MENU\" --print-panel --full | grep -F 'main · ctx' | cut -f4 | grep -oE '^\\\$[0-9.]+ [0-9]+%$' | grep -c ' $(ctx)%'" 1
# Its cost, compaction included, as pi's footer counts it ("$16.000").
footer_cost() { t capture-pane -p -t main:ctx | grep -E '/400' | grep -oE '\$[0-9.]+' | tail -1 | tr -d '$'; }
same_cost() { python3 -c "import sys; a,b=sys.argv[1:]; print('same' if a and b and abs(float(a)-float(b))<0.005 else f'dingger [{a}] footer [{b}]')" "$(t show -pv -t main:ctx @dingger_cost 2>/dev/null)" "$(footer_cost)"; }
expect "cost: compaction counted, as pi's footer counts" same_cost same 30
t kill-window -t main:ctx

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
expect "idle at start" "st plain" idle 100
expect "no hook state" "t show -pv -t main:plain @dingger_hook 2>/dev/null" ""
type_line plain "slow 4 hello"
expect "a turn: working, from the screen" "st plain" working 50
expect "then done" "st plain" done 80

echo "3. restore"
SESSION=$(t show -pv -t main:agent @agent_session)
latest() { ls "$D"/state/snapshots/*.json 2>/dev/null | tail -1; }
expect "the snapshot has the agent's session" "grep -c \"\\\"session\\\": \\\"$SESSION\\\"\" \"\$(latest)\" 2>/dev/null" 1 80
# A draft left behind when tmux goes away.
# (`\;`: send-keys would end its command at a trailing ;)
t send-keys -t main:agent -l 'an unsent idea\;'
expect "a draft before the crash" "t show -pv -t main:agent @dingger_draft 2>/dev/null" "an unsent idea;" 30
t kill-server
expect "the layout becomes the restore candidate" "python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"status\"])' $D/state/restore.json 2>/dev/null" offered 80
"$DINGGER" restore >"$D/restore.out" 2>&1
expect "restore resumes it by its session file" "grep -c -- \"resume .*pi --session\" $D/restore.out" 1
expect "resumed 1 of 1 agents" "grep -c '^resumed 1 of 1 agents' $D/restore.out" 1
expect "it runs again, reporting the same session" "t show -pv -t main:agent @agent_session 2>/dev/null" "$SESSION" 150
expect "with its conversation" "t capture-pane -p -t main:agent -S - | grep -c 'slow 3 hello'" 1 50
expect "and its importance" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50
expect "and its draft, back in the input box" "t capture-pane -p -t main:agent | grep -c '^an unsent idea;'" 1 50
expect "reported again" "t show -pv -t main:agent @dingger_draft 2>/dev/null" "an unsent idea;" 30

echo "4. exit"
LAST_DRAFT=$(draft_file)
expect "its draft is kept" "cat $LAST_DRAFT" "an unsent idea;"
# Deleted, then quit at once: before the input box is read again.
for _ in $(seq 20); do t send-keys -t main:agent BSpace; done
t send-keys -t main:agent C-d
expect "the agent is gone from the pane" "t show -pv -t main:agent @agent 2>/dev/null" "" 100
expect "a draft deleted before quitting is not kept" "[ -e $LAST_DRAFT ] && echo left || echo gone" gone 30

echo
echo "$PASSES passed, $FAILS failed"
[ "$FAILS" -eq 0 ] || { echo "--- restore output:"; cat "$D/restore.out" 2>/dev/null; echo "--- daemon log tail:"; tail -15 "$D/daemon.log" | sed 's/\x1b\[[0-9;]*m//g' | cut -c29-160; exit 1; }