switchroom 0.18.31 → 0.18.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot-reconcile perf regression: at boot the subagent-watcher must NOT do the
|
|
3
|
+
* full initial `readSubTail` (a whole-transcript parse + per-record sqlite
|
|
4
|
+
* liveness/backfill round-trips) for provably-dead prior-session workers.
|
|
5
|
+
*
|
|
6
|
+
* Production incident (2026-07-19): on a busy 24/7 agent ~1177 dead `running`
|
|
7
|
+
* JSONLs (last written days-to-weeks ago) each got the full register → whole-
|
|
8
|
+
* file read → liveness/backfill → staleness → FSWatcher-decision treatment on
|
|
9
|
+
* every boot. On a slow disk that O(filesize) work delayed the gateway
|
|
10
|
+
* answering Telegram by ~7 minutes after each restart, so the agent looked
|
|
11
|
+
* dead. The staleness verdict (`last write > inflightPromoteMaxAgeMs`) was
|
|
12
|
+
* already reached, but only AFTER the expensive read.
|
|
13
|
+
*
|
|
14
|
+
* The fix replaces the full read for a stale-by-mtime historical entry with a
|
|
15
|
+
* BOUNDED TAIL PROBE. Outcomes asserted here (not code paths):
|
|
16
|
+
* 1. running-at-boot-stale (no turn_end): registered historical, NO watcher,
|
|
17
|
+
* NOT swept (nothing terminal to sweep), and the disk read is bounded to
|
|
18
|
+
* the tail window even for a huge file (never a byte-0 whole-transcript
|
|
19
|
+
* read).
|
|
20
|
+
* 2. done-at-boot-stale (turn_end present): still reaches terminal cleanup —
|
|
21
|
+
* `onTerminalCleanup` fires after the grace window so the worker-feed row
|
|
22
|
+
* is swept (the regression the coordinator caught: a done-at-boot orphan
|
|
23
|
+
* must NOT be silently left as a ghost feed row).
|
|
24
|
+
* 3. fresh in-flight-at-boot: takes the full path — its JSONL IS read from
|
|
25
|
+
* byte 0 and it DOES get a per-file FSWatcher (no over-pruning).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
29
|
+
import type * as fs from 'fs'
|
|
30
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
31
|
+
|
|
32
|
+
interface FakeWatcher { path: string; close: ReturnType<typeof vi.fn> }
|
|
33
|
+
interface ReadCall { position: number; length: number }
|
|
34
|
+
|
|
35
|
+
const AGENT_DIR = '/home/user/.switchroom/agents/myagent'
|
|
36
|
+
const PROJECTS = `${AGENT_DIR}/.claude/projects`
|
|
37
|
+
const PROJECT = `${PROJECTS}/proj`
|
|
38
|
+
const SESSION = `${PROJECT}/sess`
|
|
39
|
+
const SUBAGENTS = `${SESSION}/subagents`
|
|
40
|
+
const NOW = 100_000_000
|
|
41
|
+
|
|
42
|
+
const turnEndLine = () => JSON.stringify({ type: 'system', subtype: 'turn_duration', duration_ms: 100 })
|
|
43
|
+
const toolUseLine = () =>
|
|
44
|
+
JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: {} }] } })
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Harness with per-path read tracking (position + length of every readSync, the
|
|
48
|
+
* proxy for "how much of the transcript did boot read") and FSWatcher tracking.
|
|
49
|
+
* `reportedSize` can far exceed the synthetic tail content, so a test can assert
|
|
50
|
+
* that only the tail window — not the whole (huge) file — was read.
|
|
51
|
+
*/
|
|
52
|
+
function makeHarness(opts: {
|
|
53
|
+
fileName: string
|
|
54
|
+
ageMs: number
|
|
55
|
+
reportedSize: number
|
|
56
|
+
/** Synthetic bytes returned when the file is read (represents its tail). */
|
|
57
|
+
tail: Buffer
|
|
58
|
+
}) {
|
|
59
|
+
const jsonl = `${SUBAGENTS}/${opts.fileName}`
|
|
60
|
+
const reads: ReadCall[] = []
|
|
61
|
+
const watchers: FakeWatcher[] = []
|
|
62
|
+
const timeouts: Array<{ fn: () => void; fireAt: number; ref: number }> = []
|
|
63
|
+
const terminalCleanups: string[] = []
|
|
64
|
+
let currentTime = NOW
|
|
65
|
+
let nextRef = 1
|
|
66
|
+
|
|
67
|
+
const mockFs = {
|
|
68
|
+
existsSync: ((p: fs.PathLike) => {
|
|
69
|
+
const ps = String(p)
|
|
70
|
+
return ps === PROJECTS || ps === PROJECT || ps === SESSION || ps === SUBAGENTS || ps === jsonl
|
|
71
|
+
}) as typeof fs.existsSync,
|
|
72
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
73
|
+
const ps = String(p)
|
|
74
|
+
if (ps === PROJECTS) return ['proj']
|
|
75
|
+
if (ps === PROJECT) return ['sess']
|
|
76
|
+
if (ps === SESSION) return ['subagents']
|
|
77
|
+
if (ps === SUBAGENTS) return [opts.fileName]
|
|
78
|
+
return []
|
|
79
|
+
}) as unknown as typeof fs.readdirSync,
|
|
80
|
+
statSync: ((p: fs.PathLike) => {
|
|
81
|
+
if (String(p) !== jsonl) return { size: 0, mtimeMs: currentTime } as fs.Stats
|
|
82
|
+
return { size: opts.reportedSize, mtimeMs: currentTime - opts.ageMs } as fs.Stats
|
|
83
|
+
}) as typeof fs.statSync,
|
|
84
|
+
openSync: (() => 7) as unknown as typeof fs.openSync,
|
|
85
|
+
closeSync: (() => undefined) as typeof fs.closeSync,
|
|
86
|
+
readSync: ((
|
|
87
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
88
|
+
): number => {
|
|
89
|
+
reads.push({ position: position ?? 0, length })
|
|
90
|
+
const src = opts.tail.subarray(0, Math.min(length, opts.tail.length))
|
|
91
|
+
src.copy(buf as Buffer, offset)
|
|
92
|
+
return src.length
|
|
93
|
+
}) as unknown as typeof fs.readSync,
|
|
94
|
+
watch: ((p: fs.PathLike) => {
|
|
95
|
+
const w: FakeWatcher = { path: String(p), close: vi.fn() }
|
|
96
|
+
watchers.push(w)
|
|
97
|
+
return w as unknown as fs.FSWatcher
|
|
98
|
+
}) as unknown as typeof fs.watch,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const watcher = startSubagentWatcher({
|
|
102
|
+
agentDir: AGENT_DIR,
|
|
103
|
+
fs: mockFs,
|
|
104
|
+
now: () => currentTime,
|
|
105
|
+
setInterval: () => ({ ref: 0 }),
|
|
106
|
+
clearInterval: () => {},
|
|
107
|
+
setTimeout: (fn: () => void, ms: number) => {
|
|
108
|
+
const ref = nextRef++
|
|
109
|
+
timeouts.push({ fn, fireAt: currentTime + ms, ref })
|
|
110
|
+
return { ref }
|
|
111
|
+
},
|
|
112
|
+
clearTimeout: (handle) => {
|
|
113
|
+
const { ref } = handle as { ref: number }
|
|
114
|
+
const idx = timeouts.findIndex((t) => t.ref === ref)
|
|
115
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
116
|
+
},
|
|
117
|
+
onTerminalCleanup: (agentId: string) => { terminalCleanups.push(agentId) },
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const advance = (ms: number): void => {
|
|
121
|
+
currentTime += ms
|
|
122
|
+
for (;;) {
|
|
123
|
+
timeouts.sort((a, b) => a.fireAt - b.fireAt)
|
|
124
|
+
const next = timeouts[0]
|
|
125
|
+
if (!next || next.fireAt > currentTime) break
|
|
126
|
+
timeouts.shift()
|
|
127
|
+
next.fn()
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
watcher,
|
|
133
|
+
reads,
|
|
134
|
+
advance,
|
|
135
|
+
terminalCleanups,
|
|
136
|
+
jsonl,
|
|
137
|
+
fileWatchers: () => watchers.filter((w) => w.path === jsonl),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
describe('subagent-watcher boot reconcile: skip full read for dead prior-session workers', () => {
|
|
142
|
+
it('running-at-boot-stale: historical, no watcher, bounded tail read (not whole transcript), not swept', () => {
|
|
143
|
+
const agentId = 'deadrunning'
|
|
144
|
+
// A huge (50 MB) JSONL last written 3 days ago whose tail has NO turn_end.
|
|
145
|
+
const tail = Buffer.from(`${toolUseLine()}\n${toolUseLine()}\n${toolUseLine()}\n`, 'utf-8')
|
|
146
|
+
const h = makeHarness({
|
|
147
|
+
fileName: `agent-${agentId}.jsonl`,
|
|
148
|
+
ageMs: 3 * 24 * 3600_000,
|
|
149
|
+
reportedSize: 50_000_000,
|
|
150
|
+
tail,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const entry = h.watcher.getRegistry().get(agentId)
|
|
154
|
+
expect(entry?.historical).toBe(true)
|
|
155
|
+
expect(entry?.state).toBe('running') // never promoted, never done
|
|
156
|
+
expect(h.fileWatchers()).toHaveLength(0) // no FD leak for a dead worker
|
|
157
|
+
expect(h.terminalCleanups).not.toContain(agentId) // nothing terminal to sweep
|
|
158
|
+
|
|
159
|
+
// The disk read was BOUNDED to the tail window — the 50 MB file was NEVER
|
|
160
|
+
// read from byte 0 (that whole-transcript read is the ~7-min boot cost).
|
|
161
|
+
expect(h.reads.length).toBeGreaterThan(0)
|
|
162
|
+
for (const r of h.reads) {
|
|
163
|
+
expect(r.position).toBeGreaterThan(0) // tail offset, not byte 0
|
|
164
|
+
expect(r.length).toBeLessThanOrEqual(512 * 1024)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
h.watcher.stop()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('done-at-boot-stale: still reaches terminal cleanup (onTerminalCleanup fires → feed row swept)', () => {
|
|
171
|
+
const agentId = 'deaddone'
|
|
172
|
+
// A stale JSONL whose tail ends in a turn_duration line → done at boot.
|
|
173
|
+
const tail = Buffer.from(`${toolUseLine()}\n${turnEndLine()}\n`, 'utf-8')
|
|
174
|
+
const h = makeHarness({
|
|
175
|
+
fileName: `agent-${agentId}.jsonl`,
|
|
176
|
+
ageMs: 3 * 24 * 3600_000,
|
|
177
|
+
reportedSize: 50_000_000,
|
|
178
|
+
tail,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const entry = h.watcher.getRegistry().get(agentId)
|
|
182
|
+
expect(entry?.historical).toBe(true)
|
|
183
|
+
expect(entry?.state).toBe('done') // detected terminal via the probe
|
|
184
|
+
expect(h.fileWatchers()).toHaveLength(0) // no watcher for a done-at-boot worker
|
|
185
|
+
expect(h.terminalCleanups).toHaveLength(0) // not yet — waits out the grace window
|
|
186
|
+
|
|
187
|
+
// Fire the scheduled terminal cleanup (30s grace) → onTerminalCleanup so the
|
|
188
|
+
// gateway sweeps the ghost feed row. THIS is the regression guard.
|
|
189
|
+
h.advance(30_000)
|
|
190
|
+
expect(h.terminalCleanups).toContain(agentId)
|
|
191
|
+
|
|
192
|
+
// Still bounded — the probe read only the tail, never the whole 50 MB file.
|
|
193
|
+
for (const r of h.reads) {
|
|
194
|
+
expect(r.position).toBeGreaterThan(0)
|
|
195
|
+
expect(r.length).toBeLessThanOrEqual(512 * 1024)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
h.watcher.stop()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('fresh in-flight-at-boot: full path — read from byte 0 and given an FSWatcher (no over-pruning)', () => {
|
|
202
|
+
const agentId = 'liveone'
|
|
203
|
+
const tail = Buffer.from(`${toolUseLine()}\n`, 'utf-8')
|
|
204
|
+
const h = makeHarness({
|
|
205
|
+
fileName: `agent-${agentId}.jsonl`,
|
|
206
|
+
ageMs: 30_000, // 30s old — inside the freshness window
|
|
207
|
+
reportedSize: tail.length,
|
|
208
|
+
tail,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Full path: the JSONL is read from byte 0 (the whole transcript) and a
|
|
212
|
+
// per-file FSWatcher is opened so live transitions are observed.
|
|
213
|
+
expect(h.reads.some((r) => r.position === 0)).toBe(true)
|
|
214
|
+
expect(h.fileWatchers()).toHaveLength(1)
|
|
215
|
+
|
|
216
|
+
h.watcher.stop()
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for SubagentWatcher re-registration after a SendMessage resume
|
|
3
|
+
* (issue #3315).
|
|
4
|
+
*
|
|
5
|
+
* A background worker that completes normally (a REAL `sub_agent_turn_end`)
|
|
6
|
+
* is swept out of the registry by `cleanupTerminalAgent`, which records the
|
|
7
|
+
* agent id in `terminatedAgentIds` so a rescan of the still-present JSONL
|
|
8
|
+
* doesn't re-register and re-fire a duplicate handback (issue #1116 Bug B).
|
|
9
|
+
*
|
|
10
|
+
* But a SendMessage-resume reuses the SAME `agent-<id>.jsonl`, appending a
|
|
11
|
+
* NEW turn to it. Pre-fix the `terminatedAgentIds` guard suppressed that file
|
|
12
|
+
* forever regardless of growth, so the resumed worker was never tracked again:
|
|
13
|
+
* no FSWatcher, no `onProgress`, no progress card — the exact UX failure the
|
|
14
|
+
* card exists to prevent (operator: "I don't see any progress cards, anything
|
|
15
|
+
* happening??").
|
|
16
|
+
*
|
|
17
|
+
* This suite pins BOTH directions of the deterministic size-based mechanism:
|
|
18
|
+
*
|
|
19
|
+
* (a) terminal cleanup → resume (JSONL grows past the terminal size) ⇒ the
|
|
20
|
+
* watcher re-registers the agent as a LIVE worker and emits progress
|
|
21
|
+
* cues again. The re-registration reads only the NEW turn — it does NOT
|
|
22
|
+
* replay the already-handed-back completed turn (no duplicate onFinish
|
|
23
|
+
* until the resumed turn genuinely ends).
|
|
24
|
+
* (b) terminal cleanup with NO resume (JSONL static) ⇒ the agent stays
|
|
25
|
+
* cleaned up: no re-registration, no duplicate onFinish, no leaked card.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
29
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
30
|
+
import * as fs from 'fs'
|
|
31
|
+
|
|
32
|
+
function buildJSONL(...lines: object[]): string {
|
|
33
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
34
|
+
}
|
|
35
|
+
function subAgentUserMsg(promptText: string) {
|
|
36
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
37
|
+
}
|
|
38
|
+
function subAgentToolUse(name: string, id: string, input: Record<string, unknown> = {}) {
|
|
39
|
+
return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input }] } }
|
|
40
|
+
}
|
|
41
|
+
function subAgentTurnEnd() {
|
|
42
|
+
return { type: 'system', subtype: 'turn_duration', duration_ms: 1234 }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const RESCAN_MS = 500
|
|
46
|
+
const CLEANUP_GRACE_MS = 30_000 // TERMINAL_CLEANUP_GRACE_MS in subagent-watcher.ts
|
|
47
|
+
|
|
48
|
+
interface ProgressCall {
|
|
49
|
+
agentId: string
|
|
50
|
+
skeleton: boolean
|
|
51
|
+
progressLine?: string
|
|
52
|
+
toolCount: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function makeHarness(opts: { agentId?: string } = {}) {
|
|
56
|
+
const { agentId = 'resume-agent' } = opts
|
|
57
|
+
|
|
58
|
+
let currentTime = 1000
|
|
59
|
+
const progressCalls: ProgressCall[] = []
|
|
60
|
+
const finishCalls: Array<{ agentId: string; outcome: string }> = []
|
|
61
|
+
const terminalCleanupCalls: string[] = []
|
|
62
|
+
const resumeCalls: Array<{ agentId: string; description: string }> = []
|
|
63
|
+
const logs: string[] = []
|
|
64
|
+
|
|
65
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
66
|
+
const sessionId = 'mock-session'
|
|
67
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
68
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
69
|
+
const sessionDir = `${projectDir}/${sessionId}`
|
|
70
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
71
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
72
|
+
|
|
73
|
+
// The agent JSONL is NOT present at construction, so the boot scan registers
|
|
74
|
+
// nothing and the file — when it later appears — registers post-boot as a
|
|
75
|
+
// LIVE (non-historical) worker, exactly like a freshly-dispatched one.
|
|
76
|
+
const fileContents = new Map<string, Buffer>()
|
|
77
|
+
|
|
78
|
+
let lastOpenedPath: string | null = null
|
|
79
|
+
const mockFs = {
|
|
80
|
+
existsSync: ((p: fs.PathLike) => {
|
|
81
|
+
const ps = String(p)
|
|
82
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
83
|
+
return fileContents.has(ps)
|
|
84
|
+
}) as typeof fs.existsSync,
|
|
85
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
86
|
+
const ps = String(p)
|
|
87
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
88
|
+
if (ps === projectDir) return [sessionId]
|
|
89
|
+
if (ps === sessionDir) return ['subagents']
|
|
90
|
+
if (ps === subagentsDir) {
|
|
91
|
+
// Dynamic: list the agent-*.jsonl files currently present on "disk".
|
|
92
|
+
const names: string[] = []
|
|
93
|
+
for (const key of fileContents.keys()) {
|
|
94
|
+
if (key.startsWith(subagentsDir + '/agent-') && key.endsWith('.jsonl')) {
|
|
95
|
+
names.push(key.slice(subagentsDir.length + 1))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return names
|
|
99
|
+
}
|
|
100
|
+
return []
|
|
101
|
+
}) as unknown as typeof fs.readdirSync,
|
|
102
|
+
statSync: ((p: fs.PathLike) =>
|
|
103
|
+
({ size: fileContents.get(String(p))?.length ?? 0, mtimeMs: currentTime }) as fs.Stats) as typeof fs.statSync,
|
|
104
|
+
openSync: ((p: fs.PathLike) => {
|
|
105
|
+
lastOpenedPath = String(p)
|
|
106
|
+
return 42
|
|
107
|
+
}) as unknown as typeof fs.openSync,
|
|
108
|
+
closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
|
|
109
|
+
readSync: ((
|
|
110
|
+
_fd: number,
|
|
111
|
+
buf: NodeJS.ArrayBufferView,
|
|
112
|
+
offset: number,
|
|
113
|
+
length: number,
|
|
114
|
+
position: number | null,
|
|
115
|
+
): number => {
|
|
116
|
+
const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
|
|
117
|
+
if (!content) return 0
|
|
118
|
+
const pos = position ?? 0
|
|
119
|
+
const src = content.slice(pos, pos + length)
|
|
120
|
+
;(src as Buffer).copy(buf as Buffer, offset)
|
|
121
|
+
return src.length
|
|
122
|
+
}) as unknown as typeof fs.readSync,
|
|
123
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
|
|
127
|
+
const timeouts: Array<{ fn: () => void; ref: number; fireAt: number }> = []
|
|
128
|
+
let nextRef = 1
|
|
129
|
+
|
|
130
|
+
const watcher = startSubagentWatcher({
|
|
131
|
+
agentDir,
|
|
132
|
+
rescanMs: RESCAN_MS,
|
|
133
|
+
onProgress: ({ agentId: id, skeleton, progressLine, toolCount }) =>
|
|
134
|
+
progressCalls.push({ agentId: id, skeleton: skeleton === true, progressLine, toolCount }),
|
|
135
|
+
onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
|
|
136
|
+
onTerminalCleanup: (id) => terminalCleanupCalls.push(id),
|
|
137
|
+
onResume: (id, description) => resumeCalls.push({ agentId: id, description }),
|
|
138
|
+
now: () => currentTime,
|
|
139
|
+
setInterval: (fn, ms) => {
|
|
140
|
+
const ref = nextRef++
|
|
141
|
+
intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
|
|
142
|
+
return { ref }
|
|
143
|
+
},
|
|
144
|
+
clearInterval: (handle) => {
|
|
145
|
+
const { ref } = handle as { ref: number }
|
|
146
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
147
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
148
|
+
},
|
|
149
|
+
setTimeout: (fn, ms) => {
|
|
150
|
+
const ref = nextRef++
|
|
151
|
+
timeouts.push({ fn, ref, fireAt: currentTime + ms })
|
|
152
|
+
return { ref }
|
|
153
|
+
},
|
|
154
|
+
clearTimeout: (handle) => {
|
|
155
|
+
const { ref } = handle as { ref: number }
|
|
156
|
+
const idx = timeouts.findIndex((t) => t.ref === ref)
|
|
157
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
158
|
+
},
|
|
159
|
+
fs: mockFs,
|
|
160
|
+
log: (msg) => logs.push(msg),
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const advance = (ms: number): void => {
|
|
164
|
+
currentTime += ms
|
|
165
|
+
for (;;) {
|
|
166
|
+
intervals.sort((a, b) => a.fireAt - b.fireAt)
|
|
167
|
+
timeouts.sort((a, b) => a.fireAt - b.fireAt)
|
|
168
|
+
const nextI = intervals[0]
|
|
169
|
+
const nextT = timeouts[0]
|
|
170
|
+
const iReady = nextI && nextI.fireAt <= currentTime
|
|
171
|
+
const tReady = nextT && nextT.fireAt <= currentTime
|
|
172
|
+
if (!iReady && !tReady) break
|
|
173
|
+
if (tReady && (!iReady || nextT!.fireAt <= nextI!.fireAt)) {
|
|
174
|
+
timeouts.shift()
|
|
175
|
+
nextT!.fn()
|
|
176
|
+
} else {
|
|
177
|
+
nextI!.fireAt += nextI!.ms
|
|
178
|
+
nextI!.fn()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const setFile = (content: string): void => {
|
|
184
|
+
fileContents.set(jsonlPath, Buffer.from(content, 'utf-8'))
|
|
185
|
+
}
|
|
186
|
+
const append = (content: string): void => {
|
|
187
|
+
const cur = fileContents.get(jsonlPath) ?? Buffer.alloc(0)
|
|
188
|
+
fileContents.set(jsonlPath, Buffer.concat([cur, Buffer.from(content, 'utf-8')]))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
agentId,
|
|
193
|
+
progressCalls,
|
|
194
|
+
finishCalls,
|
|
195
|
+
terminalCleanupCalls,
|
|
196
|
+
resumeCalls,
|
|
197
|
+
logs,
|
|
198
|
+
advance,
|
|
199
|
+
watcher,
|
|
200
|
+
setFile,
|
|
201
|
+
append,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe('subagent-watcher resume re-registration (issue #3315)', () => {
|
|
206
|
+
it('(a) re-registers a terminal-then-resumed worker so its progress card resumes', () => {
|
|
207
|
+
const agentId = 'resume-live'
|
|
208
|
+
const h = makeHarness({ agentId })
|
|
209
|
+
|
|
210
|
+
// A worker is dispatched post-boot: its JSONL appears with a running turn.
|
|
211
|
+
h.setFile(buildJSONL(subAgentUserMsg('bg task'), subAgentToolUse('Bash', 't1')))
|
|
212
|
+
h.advance(RESCAN_MS) // rescan discovers the file → LIVE registration
|
|
213
|
+
|
|
214
|
+
const registered = h.watcher.getRegistry().get(agentId)
|
|
215
|
+
expect(registered).toBeDefined()
|
|
216
|
+
expect(registered!.state).toBe('running')
|
|
217
|
+
expect(registered!.historical).toBe(false)
|
|
218
|
+
|
|
219
|
+
// It completes normally with a REAL turn_end.
|
|
220
|
+
h.append(buildJSONL(subAgentTurnEnd()))
|
|
221
|
+
h.advance(RESCAN_MS) // poll reads turn_end → done → onFinish(completed)
|
|
222
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
223
|
+
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
224
|
+
|
|
225
|
+
// Grace elapses → the entry is swept out of the registry.
|
|
226
|
+
h.advance(CLEANUP_GRACE_MS)
|
|
227
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(false)
|
|
228
|
+
expect(h.terminalCleanupCalls).toContain(agentId)
|
|
229
|
+
|
|
230
|
+
const progressBeforeResume = h.progressCalls.length
|
|
231
|
+
|
|
232
|
+
// SendMessage resume: Claude Code appends a NEW turn to the SAME JSONL.
|
|
233
|
+
h.append(buildJSONL(
|
|
234
|
+
subAgentUserMsg('follow-up question'),
|
|
235
|
+
subAgentToolUse('Read', 't2', { file_path: '/tmp/x' }),
|
|
236
|
+
))
|
|
237
|
+
h.advance(RESCAN_MS) // rescan sees growth past terminal size → re-register
|
|
238
|
+
|
|
239
|
+
// The worker is tracked again — live, running, non-historical.
|
|
240
|
+
const revived = h.watcher.getRegistry().get(agentId)
|
|
241
|
+
expect(revived).toBeDefined()
|
|
242
|
+
expect(revived!.state).toBe('running')
|
|
243
|
+
expect(revived!.historical).toBe(false)
|
|
244
|
+
expect(h.logs.some((l) => l.includes('resumed after terminal cleanup') && l.includes(agentId))).toBe(true)
|
|
245
|
+
|
|
246
|
+
// Issue #3373: onResume fired exactly once for the resumed worker, BEFORE
|
|
247
|
+
// registration re-fires onProgress — this is what clears the feed's terminal
|
|
248
|
+
// `finalized` latch so the resumed cues re-surface a card instead of being
|
|
249
|
+
// swallowed. (The feed-side re-surface behaviour is covered in
|
|
250
|
+
// worker-activity-feed.test.ts.)
|
|
251
|
+
expect(h.resumeCalls.filter((c) => c.agentId === agentId)).toHaveLength(1)
|
|
252
|
+
|
|
253
|
+
// Progress cues resume (the card updates again) — the user-visible fix.
|
|
254
|
+
expect(h.progressCalls.length).toBeGreaterThan(progressBeforeResume)
|
|
255
|
+
expect(h.progressCalls.slice(progressBeforeResume).some((c) => c.agentId === agentId)).toBe(true)
|
|
256
|
+
|
|
257
|
+
// The completed turn was NOT replayed — no duplicate onFinish fired on
|
|
258
|
+
// re-registration (the old turn_end sits before the resume cursor).
|
|
259
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
260
|
+
|
|
261
|
+
// When the resumed turn genuinely ends, onFinish fires again for the new
|
|
262
|
+
// turn — the resumed work gets its own handback.
|
|
263
|
+
h.append(buildJSONL(subAgentTurnEnd()))
|
|
264
|
+
h.advance(RESCAN_MS)
|
|
265
|
+
expect(h.finishCalls).toHaveLength(2)
|
|
266
|
+
expect(h.finishCalls[1].outcome).toBe('completed')
|
|
267
|
+
|
|
268
|
+
h.watcher.stop()
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('(b) a terminal worker that is NOT resumed stays cleaned up (no re-register, no duplicate handback)', () => {
|
|
272
|
+
const agentId = 'stay-clean'
|
|
273
|
+
const h = makeHarness({ agentId })
|
|
274
|
+
|
|
275
|
+
h.setFile(buildJSONL(subAgentUserMsg('bg task'), subAgentToolUse('Bash', 't1')))
|
|
276
|
+
h.advance(RESCAN_MS)
|
|
277
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(true)
|
|
278
|
+
|
|
279
|
+
h.append(buildJSONL(subAgentTurnEnd()))
|
|
280
|
+
h.advance(RESCAN_MS)
|
|
281
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
282
|
+
|
|
283
|
+
h.advance(CLEANUP_GRACE_MS) // sweep
|
|
284
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(false)
|
|
285
|
+
const finishAfterCleanup = h.finishCalls.length
|
|
286
|
+
const progressAfterCleanup = h.progressCalls.length
|
|
287
|
+
|
|
288
|
+
// The JSONL stays on disk, static (Claude Code leaves it), and the worker
|
|
289
|
+
// is never resumed. Many rescans must NOT re-register it or re-fire the
|
|
290
|
+
// handback (issue #1116 Bug B guard preserved) — the reverse-bug direction.
|
|
291
|
+
for (let i = 0; i < 20; i++) h.advance(RESCAN_MS)
|
|
292
|
+
|
|
293
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(false)
|
|
294
|
+
expect(h.finishCalls).toHaveLength(finishAfterCleanup) // no duplicate
|
|
295
|
+
expect(h.progressCalls).toHaveLength(progressAfterCleanup) // no card churn
|
|
296
|
+
// Cleanup fired exactly once — no leaked/duplicated terminal sweep.
|
|
297
|
+
expect(h.terminalCleanupCalls.filter((id) => id === agentId)).toHaveLength(1)
|
|
298
|
+
// Issue #3373 anti-zombie: a genuinely-finished worker that never grew past
|
|
299
|
+
// its terminal boundary must NEVER fire onResume — otherwise the feed's
|
|
300
|
+
// finalized latch would be cleared and a dead worker could zombie-resurface.
|
|
301
|
+
expect(h.resumeCalls.filter((c) => c.agentId === agentId)).toHaveLength(0)
|
|
302
|
+
|
|
303
|
+
h.watcher.stop()
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -395,4 +395,36 @@ describe('subagent-watcher card resurrection (issue #3023)', () => {
|
|
|
395
395
|
h.advance(500)
|
|
396
396
|
expect(h.resurrectCalls).toHaveLength(1)
|
|
397
397
|
})
|
|
398
|
+
|
|
399
|
+
it('(f) a false finish resumed after cleanup grace is owned by resurrection, not the #3315 resume path', () => {
|
|
400
|
+
// Non-interference guard (issue #3315 × #3023): once the terminal-cleanup
|
|
401
|
+
// grace has elapsed, a falsely-finalised worker's id sits in BOTH
|
|
402
|
+
// `terminatedAgentIds` (the #3315 resume-detection map) AND
|
|
403
|
+
// `falseFinishTracker`. Its JSONL resuming must be handled by the
|
|
404
|
+
// resurrection path (bounded-chain + onResurrect semantics), NOT
|
|
405
|
+
// double-handled by the scanSubagentsDir resume branch. The branch defers
|
|
406
|
+
// when a false-finish record exists.
|
|
407
|
+
const agentId = 'false-finish-post-grace'
|
|
408
|
+
const h = makeHarness({ agentId })
|
|
409
|
+
|
|
410
|
+
h.advance(500)
|
|
411
|
+
unmarkHistorical(h, agentId)
|
|
412
|
+
|
|
413
|
+
driveToFalseFinish(h, agentId)
|
|
414
|
+
// Let the 30s terminal-cleanup grace elapse so the entry is swept — its id
|
|
415
|
+
// is now in BOTH terminatedAgentIds and falseFinishTracker.
|
|
416
|
+
h.advance(30_000)
|
|
417
|
+
expect(h.watcher.getRegistry().has(agentId)).toBe(false)
|
|
418
|
+
|
|
419
|
+
// JSONL resumes growing.
|
|
420
|
+
h.appendActivity()
|
|
421
|
+
h.advance(500)
|
|
422
|
+
|
|
423
|
+
// Resurrected exactly once via the #3023 path...
|
|
424
|
+
expect(h.resurrectCalls).toHaveLength(1)
|
|
425
|
+
expect(h.watcher.getRegistry().get(agentId)?.state).toBe('running')
|
|
426
|
+
// ...and NOT re-registered by the #3315 resume branch (its log marker is
|
|
427
|
+
// absent — the branch deferred because a false-finish record existed).
|
|
428
|
+
expect(h.logs.some((l) => l.includes('resumed after terminal cleanup'))).toBe(false)
|
|
429
|
+
})
|
|
398
430
|
})
|
|
@@ -647,6 +647,36 @@ describe('startSubagentWatcher', () => {
|
|
|
647
647
|
h.poll()
|
|
648
648
|
// The draft is staged, not yet resolved — no cue yet.
|
|
649
649
|
// The very next event is a reply tool with matching text → SUPPRESS.
|
|
650
|
+
// #3231: feed the PREFIXED prod wire shape (mcp__…__stream_reply), the
|
|
651
|
+
// form real jsonl actually carries. Before the isReplyTool fix a bare
|
|
652
|
+
// REPLY_TOOLS.has() missed this and the draft was WRONGLY surfaced.
|
|
653
|
+
appendFileSync(jsonlPath, buildJSONL({
|
|
654
|
+
type: 'assistant',
|
|
655
|
+
message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
|
|
656
|
+
}))
|
|
657
|
+
h.poll()
|
|
658
|
+
expect(narrativeCues.length).toBe(0)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('narrative gate: a draft-then-reply sub_agent_text is SUPPRESSED with a BARE reply tool name too (#3231)', () => {
|
|
662
|
+
// Symmetric with the prefixed case above: bare 'stream_reply' can still
|
|
663
|
+
// arrive from non-MCP sources, so isReplyTool must match it as well.
|
|
664
|
+
const narrativeCues: string[] = []
|
|
665
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
666
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
667
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
668
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
669
|
+
const h = startWatcherSync({
|
|
670
|
+
agentDir,
|
|
671
|
+
onProgress: ({ progressLine, latestSummary, skeleton }) => {
|
|
672
|
+
if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo path')))
|
|
676
|
+
h.poll()
|
|
677
|
+
const answer = 'The repo is at /home/user/code/switchroom.'
|
|
678
|
+
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText(answer)))
|
|
679
|
+
h.poll()
|
|
650
680
|
appendFileSync(jsonlPath, buildJSONL({
|
|
651
681
|
type: 'assistant',
|
|
652
682
|
message: { content: [{ type: 'tool_use', name: 'stream_reply', id: 'r1', input: { text: answer } }] },
|
|
@@ -786,10 +816,11 @@ describe('startSubagentWatcher', () => {
|
|
|
786
816
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
|
|
787
817
|
h.poll()
|
|
788
818
|
const answer = 'The fix touches three files and adds a unit test for the double-Done case.'
|
|
789
|
-
// Final tool of the turn is stream_reply carrying the answer.
|
|
819
|
+
// Final tool of the turn is stream_reply carrying the answer. #3231: use
|
|
820
|
+
// the PREFIXED prod wire shape so lastReplyText capture (isReplyTool) fires.
|
|
790
821
|
appendFileSync(jsonlPath, buildJSONL({
|
|
791
822
|
type: 'assistant',
|
|
792
|
-
message: { content: [{ type: 'tool_use', name: '
|
|
823
|
+
message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
|
|
793
824
|
}))
|
|
794
825
|
h.poll()
|
|
795
826
|
// Trailing text block (separate message) that drafts the delivered answer.
|
|
@@ -819,9 +850,10 @@ describe('startSubagentWatcher', () => {
|
|
|
819
850
|
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
|
|
820
851
|
h.poll()
|
|
821
852
|
const answer = 'The fix touches three files and adds a unit test for the double-Done case.'
|
|
853
|
+
// #3231: prefixed prod wire shape.
|
|
822
854
|
appendFileSync(jsonlPath, buildJSONL({
|
|
823
855
|
type: 'assistant',
|
|
824
|
-
message: { content: [{ type: 'tool_use', name: '
|
|
856
|
+
message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
|
|
825
857
|
}))
|
|
826
858
|
h.poll()
|
|
827
859
|
appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('Done — cleaning up the worktree now.')))
|
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Outcome contract: if a pre-purge op in the turn_end handler body THROWS
|
|
5
5
|
* before the canonical purge (endCurrentTurnAtomic → purgeReactionTracking)
|
|
6
6
|
* runs, the guarded finally must still clear this turn's gate state
|
|
7
|
-
* (activeTurnStartedAt
|
|
8
|
-
* gate re-opens for the next inbound. On the happy path —
|
|
9
|
-
* clears the key itself — the backstop must be a no-op (no
|
|
10
|
-
* the original error must always propagate.
|
|
7
|
+
* (activeTurnStartedAt; the purge also emits the machine turnEnd) so the
|
|
8
|
+
* #1556 inbound gate re-opens for the next inbound. On the happy path —
|
|
9
|
+
* where the body clears the key itself — the backstop must be a no-op (no
|
|
10
|
+
* double purge), and the original error must always propagate.
|
|
11
11
|
*/
|
|
12
12
|
import { describe, it, expect } from 'vitest'
|
|
13
13
|
import { withTurnEndGateBackstop } from '../gateway/turn-end-gate-backstop.js'
|
|
@@ -22,19 +22,16 @@ function statusKey(chatId: string, threadId?: number): string {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
/** Mirror of the production gate state + a purge that clears it, as
|
|
25
|
-
* purgeReactionTracking does (activeTurnStartedAt.delete
|
|
26
|
-
* drain for the key). */
|
|
25
|
+
* purgeReactionTracking does (activeTurnStartedAt.delete for the key). */
|
|
27
26
|
function freshGate(turn: Turn) {
|
|
28
27
|
const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
|
|
29
28
|
const activeTurnStartedAt = new Map<string, number>([[key, Date.now()]])
|
|
30
|
-
const claudeBusyKeys = new Set<string>([key])
|
|
31
29
|
const purgeCalls: Array<{ key: string; endingTurn: Turn | undefined }> = []
|
|
32
30
|
const purge = (k: string, endingTurn: Turn | undefined) => {
|
|
33
31
|
purgeCalls.push({ key: k, endingTurn })
|
|
34
32
|
activeTurnStartedAt.delete(k)
|
|
35
|
-
claudeBusyKeys.delete(k)
|
|
36
33
|
}
|
|
37
|
-
return { key, activeTurnStartedAt,
|
|
34
|
+
return { key, activeTurnStartedAt, purge, purgeCalls }
|
|
38
35
|
}
|
|
39
36
|
|
|
40
37
|
function deps(gate: ReturnType<typeof freshGate>) {
|
|
@@ -58,10 +55,9 @@ describe('withTurnEndGateBackstop (#2094 finding 1)', () => {
|
|
|
58
55
|
}, deps(gate)),
|
|
59
56
|
).toThrow('redactOutboundText blew up')
|
|
60
57
|
|
|
61
|
-
// Outcome: gate is OPEN again —
|
|
62
|
-
// gate no longer wedges the next inbound.
|
|
58
|
+
// Outcome: gate is OPEN again — the turn map is cleared, so the #1556
|
|
59
|
+
// inbound gate no longer wedges the next inbound.
|
|
63
60
|
expect(gate.activeTurnStartedAt.has(gate.key)).toBe(false)
|
|
64
|
-
expect(gate.claudeBusyKeys.has(gate.key)).toBe(false)
|
|
65
61
|
// The backstop fired exactly once, forwarding the ending turn.
|
|
66
62
|
expect(gate.purgeCalls).toEqual([{ key: gate.key, endingTurn: turn }])
|
|
67
63
|
})
|