switchroom 0.18.6 → 0.18.7
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 +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +269 -56
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +1937 -580
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1071 -130
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +9 -0
|
@@ -37,14 +37,19 @@ describe('boot promotion — in-flight worker replays its activity through onPro
|
|
|
37
37
|
let currentTime = 100_000
|
|
38
38
|
// The file EXISTS AT BOOT with real in-flight activity: a prompt and two
|
|
39
39
|
// tool steps, still running (no turn_end). mtime is fresh (30s ago) so
|
|
40
|
-
// the freshness gate
|
|
41
|
-
|
|
40
|
+
// the freshness gate makes it ELIGIBLE — but fix #5 also requires actual
|
|
41
|
+
// post-boot growth before promoting (mtime freshness alone can't
|
|
42
|
+
// distinguish this from a worker killed moments before the restart).
|
|
43
|
+
// `content` grows below via the captured fs.watch callback to supply
|
|
44
|
+
// that confirmation.
|
|
45
|
+
let content = Buffer.from(buildJSONL(
|
|
42
46
|
{ type: 'user', message: { content: [{ type: 'text', text: 'long research task' }] } },
|
|
43
47
|
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
|
|
44
48
|
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'npm test' } }] } },
|
|
45
49
|
), 'utf-8')
|
|
46
50
|
|
|
47
51
|
let lastOpened: string | null = null
|
|
52
|
+
let watchCb: (() => void) | null = null
|
|
48
53
|
const mockFs = {
|
|
49
54
|
existsSync: ((p: fs.PathLike) => {
|
|
50
55
|
const ps = String(p)
|
|
@@ -69,7 +74,10 @@ describe('boot promotion — in-flight worker replays its activity through onPro
|
|
|
69
74
|
src.copy(buf as Buffer, offset)
|
|
70
75
|
return src.length
|
|
71
76
|
}) as unknown as typeof fs.readSync,
|
|
72
|
-
watch: ((
|
|
77
|
+
watch: ((_p: fs.PathLike, cb?: () => void) => {
|
|
78
|
+
watchCb = cb ?? null
|
|
79
|
+
return { close: vi.fn() } as unknown as fs.FSWatcher
|
|
80
|
+
}) as unknown as typeof fs.watch,
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
const progressCalls: Array<{ agentId: string; progressLine?: string; toolCount: number }> = []
|
|
@@ -86,10 +94,25 @@ describe('boot promotion — in-flight worker replays its activity through onPro
|
|
|
86
94
|
},
|
|
87
95
|
})
|
|
88
96
|
|
|
97
|
+
// Fix #5: registration alone (mtime-fresh, no observed growth yet) must
|
|
98
|
+
// NOT promote — it stays historical/pending until growth confirms it.
|
|
99
|
+
const preGrowthEntry = watcher.getRegistry().get(agentId)
|
|
100
|
+
expect(preGrowthEntry?.historical).toBe(true)
|
|
101
|
+
expect(preGrowthEntry?.bootPromotionPending).toBeDefined()
|
|
102
|
+
|
|
103
|
+
// Simulate the worker taking one more real step post-boot: the JSONL
|
|
104
|
+
// grows, and Claude Code's fs notification fires.
|
|
105
|
+
content = Buffer.concat([content, Buffer.from(buildJSONL(
|
|
106
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'still going' }] } },
|
|
107
|
+
), 'utf-8')])
|
|
108
|
+
currentTime += 1000
|
|
109
|
+
watchCb?.()
|
|
110
|
+
|
|
89
111
|
const entry = watcher.getRegistry().get(agentId)
|
|
90
112
|
expect(entry).toBeDefined()
|
|
91
|
-
// Promoted live
|
|
113
|
+
// Promoted live now that post-boot growth was actually observed.
|
|
92
114
|
expect(entry?.historical).toBe(false)
|
|
115
|
+
expect(entry?.bootPromotionPending).toBeUndefined()
|
|
93
116
|
// Replay rebuilt the entry WITHOUT double-counting the initial read.
|
|
94
117
|
expect(entry?.toolCount).toBe(2)
|
|
95
118
|
expect(entry?.lastTool?.name).toBe('Bash')
|
|
@@ -102,6 +125,86 @@ describe('boot promotion — in-flight worker replays its activity through onPro
|
|
|
102
125
|
watcher.stop()
|
|
103
126
|
})
|
|
104
127
|
|
|
128
|
+
it('fix #5: a running-at-boot file with fresh mtime but NO post-boot growth is never promoted (avoids a stale completed handback)', () => {
|
|
129
|
+
const agentId = 'inflight-no-growth-01'
|
|
130
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
131
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
132
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
133
|
+
const sessionDir = `${projectDir}/sess`
|
|
134
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
135
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
136
|
+
|
|
137
|
+
let currentTime = 100_000
|
|
138
|
+
// Fresh mtime (30s old) but the file NEVER grows again — indistinguishable
|
|
139
|
+
// from a worker that was killed moments before this restart.
|
|
140
|
+
const content = Buffer.from(buildJSONL(
|
|
141
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'task that got killed' }] } },
|
|
142
|
+
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/repo/a.ts' } }] } },
|
|
143
|
+
), 'utf-8')
|
|
144
|
+
|
|
145
|
+
let lastOpened: string | null = null
|
|
146
|
+
const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
|
|
147
|
+
const mockFs = {
|
|
148
|
+
existsSync: ((p: fs.PathLike) => {
|
|
149
|
+
const ps = String(p)
|
|
150
|
+
return ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir || ps === jsonlPath
|
|
151
|
+
}) as typeof fs.existsSync,
|
|
152
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
153
|
+
const ps = String(p)
|
|
154
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
155
|
+
if (ps === projectDir) return ['sess']
|
|
156
|
+
if (ps === sessionDir) return ['subagents']
|
|
157
|
+
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
158
|
+
return []
|
|
159
|
+
}) as unknown as typeof fs.readdirSync,
|
|
160
|
+
statSync: (() => ({ size: content.length, mtimeMs: 100_000 - 30_000 }) as fs.Stats) as typeof fs.statSync,
|
|
161
|
+
openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
|
|
162
|
+
closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
|
|
163
|
+
readSync: ((
|
|
164
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
165
|
+
): number => {
|
|
166
|
+
if (lastOpened !== jsonlPath) return 0
|
|
167
|
+
const src = content.slice(position ?? 0, (position ?? 0) + length)
|
|
168
|
+
src.copy(buf as Buffer, offset)
|
|
169
|
+
return src.length
|
|
170
|
+
}) as unknown as typeof fs.readSync,
|
|
171
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const finishCalls: Array<{ agentId: string; outcome: string }> = []
|
|
175
|
+
const watcher = startSubagentWatcher({
|
|
176
|
+
agentDir,
|
|
177
|
+
fs: mockFs,
|
|
178
|
+
inflightPromoteMaxAgeMs: 60_000, // small bound so the test can cross it
|
|
179
|
+
now: () => currentTime,
|
|
180
|
+
setInterval: (fn, ms) => {
|
|
181
|
+
intervals.push({ fn, ms, fireAt: currentTime + ms })
|
|
182
|
+
return { ref: 0 }
|
|
183
|
+
},
|
|
184
|
+
clearInterval: () => {},
|
|
185
|
+
setTimeout: () => ({ ref: 0 }),
|
|
186
|
+
clearTimeout: () => {},
|
|
187
|
+
onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const entryAtBoot = watcher.getRegistry().get(agentId)
|
|
191
|
+
expect(entryAtBoot?.historical).toBe(true)
|
|
192
|
+
expect(entryAtBoot?.bootPromotionPending).toBeDefined()
|
|
193
|
+
|
|
194
|
+
// Advance well past the promotion window WITHOUT the file ever growing.
|
|
195
|
+
currentTime += 120_000
|
|
196
|
+
for (const iv of intervals) if (iv.fireAt <= currentTime) iv.fn()
|
|
197
|
+
|
|
198
|
+
const entry = watcher.getRegistry().get(agentId)
|
|
199
|
+
// Never promoted: still historical, no completed/failed handback ever
|
|
200
|
+
// synthesised from these pre-restart bytes.
|
|
201
|
+
expect(entry?.historical).toBe(true)
|
|
202
|
+
expect(entry?.bootPromotionPending).toBeUndefined() // gave up after the deadline
|
|
203
|
+
expect(finishCalls).toHaveLength(0)
|
|
204
|
+
|
|
205
|
+
watcher.stop()
|
|
206
|
+
})
|
|
207
|
+
|
|
105
208
|
it('a STALE running-at-boot file stays historical and fires no onProgress (no replay spam)', () => {
|
|
106
209
|
const agentId = 'stale-at-boot-01'
|
|
107
210
|
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
@@ -224,14 +224,22 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
|
|
|
224
224
|
expect(h.finishCalls[0].agentId).toBe('gap1-complete')
|
|
225
225
|
expect(h.finishCalls[0].outcome).toBe('completed') // pre-fix: 'orphan' → dropped
|
|
226
226
|
expect(h.finishCalls[0].resultText).toContain('root cause')
|
|
227
|
-
//
|
|
228
|
-
|
|
227
|
+
// Fix #5: promotion now requires an observed post-boot growth event
|
|
228
|
+
// (mtime freshness alone is refused) — the `append` above supplied it,
|
|
229
|
+
// and the promotion is logged so the path is observable in prod.
|
|
230
|
+
expect(h.logs.some((l) => l.includes('confirmed live') && l.includes('post-boot JSONL growth'))).toBe(true)
|
|
229
231
|
})
|
|
230
232
|
|
|
231
|
-
it('an in-flight-at-boot worker that dies silently is rescued by stall synthesis', () => {
|
|
233
|
+
it('an in-flight-at-boot worker that dies silently (after proving liveness) is rescued by stall synthesis', () => {
|
|
232
234
|
// Pre-fix, historical entries were skipped by stall detection, so a
|
|
233
235
|
// worker that crossed a restart and then went silent sat running
|
|
234
236
|
// forever — no handback ever. After promotion it gets the safety net.
|
|
237
|
+
//
|
|
238
|
+
// Fix #5: promotion additionally requires proof of post-boot liveness —
|
|
239
|
+
// a single real step (e.g. a narrative line) grows the JSONL past its
|
|
240
|
+
// boot-time snapshot, confirming this is NOT a worker killed moments
|
|
241
|
+
// before the restart. Only THEN does it get the stall-synthesis safety
|
|
242
|
+
// net when it subsequently goes silent for good.
|
|
235
243
|
const h = makeHarness({
|
|
236
244
|
agentId: 'gap1-silent',
|
|
237
245
|
bootLines: [subAgentUserMsg('bg task')],
|
|
@@ -239,6 +247,11 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
|
|
|
239
247
|
silentStallTerminalMs: 120_000,
|
|
240
248
|
})
|
|
241
249
|
|
|
250
|
+
h.append(subAgentText('still investigating'))
|
|
251
|
+
h.advance(600) // one poll observes the post-boot growth → promotes
|
|
252
|
+
|
|
253
|
+
expect(h.watcher.getRegistry().get('gap1-silent')?.historical).toBe(false)
|
|
254
|
+
|
|
242
255
|
h.advance(62_000) // stall threshold crossed
|
|
243
256
|
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
244
257
|
h.advance(121_000) // silent-stall terminal window elapses → synthesis
|
|
@@ -247,6 +260,30 @@ describe('Gap 1 — background worker in-flight across a gateway restart', () =>
|
|
|
247
260
|
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
248
261
|
})
|
|
249
262
|
|
|
263
|
+
it('fix #5: an in-flight-at-boot worker that NEVER shows post-boot growth is left historical (no stale completed handback)', () => {
|
|
264
|
+
// The bug this closes: a worker killed <15min before a restart is
|
|
265
|
+
// byte-for-byte indistinguishable from a live one by mtime alone. Absent
|
|
266
|
+
// any actual post-boot growth, it must NOT be promoted — so it can never
|
|
267
|
+
// synthesise a false 'completed' handback from the pre-restart bytes.
|
|
268
|
+
const h = makeHarness({
|
|
269
|
+
agentId: 'gap1-killed-before-restart',
|
|
270
|
+
bootLines: [subAgentUserMsg('bg task')],
|
|
271
|
+
stallThresholdMs: 60_000,
|
|
272
|
+
silentStallTerminalMs: 120_000,
|
|
273
|
+
inflightPromoteMaxAgeMs: 60_000,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// No append — the file never grows again after boot.
|
|
277
|
+
h.advance(62_000)
|
|
278
|
+
h.advance(121_000)
|
|
279
|
+
h.advance(600_000) // well past the promotion window too
|
|
280
|
+
|
|
281
|
+
expect(h.watcher.getRegistry().get('gap1-killed-before-restart')?.historical).toBe(true)
|
|
282
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
283
|
+
expect(h.finishCalls).toHaveLength(0)
|
|
284
|
+
expect(h.logs.some((l) => l.includes('never observed post-boot JSONL growth'))).toBe(true)
|
|
285
|
+
})
|
|
286
|
+
|
|
250
287
|
it('a worker already DONE at boot stays suppressed (no spurious replay)', () => {
|
|
251
288
|
// The legitimate use of `historical`: a worker that finished in a prior
|
|
252
289
|
// session must NOT re-fire a handback on every restart. This is the
|
|
@@ -298,7 +335,8 @@ describe('Gap 1 freshness gate — v0.14.24 stale-replay regression', () => {
|
|
|
298
335
|
|
|
299
336
|
expect(h.finishCalls).toHaveLength(1)
|
|
300
337
|
expect(h.finishCalls[0].outcome).toBe('completed')
|
|
301
|
-
|
|
338
|
+
// Fix #5: the append above supplies the required post-boot growth proof.
|
|
339
|
+
expect(h.logs.some((l) => l.includes('confirmed live'))).toBe(true)
|
|
302
340
|
})
|
|
303
341
|
|
|
304
342
|
it('kill-switch (bootPromoteEnabled=false) suppresses even a fresh running-at-boot worker', () => {
|
|
@@ -265,6 +265,53 @@ describe('backfillJsonlAgentId — overlapping windows / hook precedence (#2081)
|
|
|
265
265
|
})
|
|
266
266
|
})
|
|
267
267
|
|
|
268
|
+
// ─── Fix #3: ambiguous fuzzy backfill refuses to link ────────────────────────
|
|
269
|
+
// Legacy meta.json (no toolUseId) falls back to the fuzzy (agentType,
|
|
270
|
+
// description) match. With N unlinked rows sharing that key, a bare
|
|
271
|
+
// `ORDER BY started_at DESC LIMIT 1` assigned by start-order, not true
|
|
272
|
+
// correspondence — a genuine mislink for a cross-topic identical dispatch.
|
|
273
|
+
// The fix: link only when the candidate is UNAMBIGUOUS (exactly one match);
|
|
274
|
+
// otherwise leave jsonl_agent_id NULL and let the marker/window path
|
|
275
|
+
// attribute it.
|
|
276
|
+
describe('backfillJsonlAgentId — fix #3: ambiguous fuzzy match refused', () => {
|
|
277
|
+
it('two unlinked rows sharing (agent_type, description) → fuzzy link refused, row stays NULL', () => {
|
|
278
|
+
insertSub({ id: 'toolu_amb_1', agentType: 'general-purpose', description: 'Run the tests', startedAt: 1000 })
|
|
279
|
+
insertSub({ id: 'toolu_amb_2', agentType: 'general-purpose', description: 'Run the tests', startedAt: 2000 })
|
|
280
|
+
|
|
281
|
+
// Legacy meta.json — no toolUseId, forces the fuzzy fallback path.
|
|
282
|
+
const jsonlPath = writeMeta('general-purpose', 'Run the tests')
|
|
283
|
+
const logs: string[] = []
|
|
284
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_ambiguous', (m) => logs.push(m))
|
|
285
|
+
|
|
286
|
+
// Neither candidate gets mis-assigned — both remain unlinked.
|
|
287
|
+
expect(readSub('toolu_amb_1')?.jsonl_agent_id ?? null).toBeNull()
|
|
288
|
+
expect(readSub('toolu_amb_2')?.jsonl_agent_id ?? null).toBeNull()
|
|
289
|
+
expect(logs.some((l) => l.includes('ambiguous') && l.includes('agentstem_ambiguous'))).toBe(true)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('a SINGLE unambiguous candidate still links normally (no regression)', () => {
|
|
293
|
+
insertSub({ id: 'toolu_unamb', agentType: 'general-purpose', description: 'Unique task', startedAt: 1000 })
|
|
294
|
+
|
|
295
|
+
const jsonlPath = writeMeta('general-purpose', 'Unique task')
|
|
296
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_unambiguous')
|
|
297
|
+
|
|
298
|
+
expect(readSub('toolu_unamb')?.jsonl_agent_id).toBe('agentstem_unambiguous')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('three unlinked rows sharing the same key → still refused (not just a 2-row edge case)', () => {
|
|
302
|
+
insertSub({ id: 'toolu_amb3_1', agentType: 'researcher', description: 'Investigate', startedAt: 1000 })
|
|
303
|
+
insertSub({ id: 'toolu_amb3_2', agentType: 'researcher', description: 'Investigate', startedAt: 2000 })
|
|
304
|
+
insertSub({ id: 'toolu_amb3_3', agentType: 'researcher', description: 'Investigate', startedAt: 3000 })
|
|
305
|
+
|
|
306
|
+
const jsonlPath = writeMeta('researcher', 'Investigate')
|
|
307
|
+
backfillJsonlAgentId(db, jsonlPath, 'agentstem_amb3')
|
|
308
|
+
|
|
309
|
+
expect(readSub('toolu_amb3_1')?.jsonl_agent_id ?? null).toBeNull()
|
|
310
|
+
expect(readSub('toolu_amb3_2')?.jsonl_agent_id ?? null).toBeNull()
|
|
311
|
+
expect(readSub('toolu_amb3_3')?.jsonl_agent_id ?? null).toBeNull()
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
268
315
|
// ─── #2506: null meta.json guard ─────────────────────────────────────────────
|
|
269
316
|
// JSON.parse('null') succeeds and returns null. Before the fix, the enclosing
|
|
270
317
|
// try/catch only covered the read+parse, so execution fell through to
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #9a — `terminatedAgentIds` (the re-discovery dedup guard added on
|
|
3
|
+
* every `cleanupTerminalAgent`, read in `scanSubagentsDir`'s skip-check)
|
|
4
|
+
* grew unboundedly: only ever cleared wholesale in `stop()`. This pins the
|
|
5
|
+
* bound: once the cap is hit, the OLDEST id is evicted on each new insert
|
|
6
|
+
* (Set preserves insertion order), while the dedup guarantee for recently-
|
|
7
|
+
* terminated ids still holds.
|
|
8
|
+
*
|
|
9
|
+
* `terminatedAgentIdsCap` is a config override so the test can exercise
|
|
10
|
+
* eviction with a handful of entries instead of the production cap
|
|
11
|
+
* (a few thousand).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
15
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
16
|
+
import * as fs from 'fs'
|
|
17
|
+
|
|
18
|
+
function buildJSONL(...lines: object[]): string {
|
|
19
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
20
|
+
}
|
|
21
|
+
function subAgentUserMsg(promptText: string) {
|
|
22
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
23
|
+
}
|
|
24
|
+
function subAgentTurnEnd() {
|
|
25
|
+
return { type: 'system', subtype: 'turn_duration', duration_ms: 100 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('terminatedAgentIds cap (fix #9a)', () => {
|
|
29
|
+
it('evicts the oldest terminated id once the cap is hit, but keeps dedup for recent ids', () => {
|
|
30
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
31
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
32
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
33
|
+
const sessionDir = `${projectDir}/sess`
|
|
34
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
35
|
+
|
|
36
|
+
// 4 agents, already fully done (turn_end present) at "boot" — each will
|
|
37
|
+
// schedule a terminal cleanup immediately.
|
|
38
|
+
const agentIds = ['agent-a', 'agent-b', 'agent-c', 'agent-d']
|
|
39
|
+
const fileNames = agentIds.map((id) => `agent-${id}.jsonl`)
|
|
40
|
+
const filePaths = new Map(agentIds.map((id) => [id, `${subagentsDir}/agent-${id}.jsonl`]))
|
|
41
|
+
// Files visible to a directory scan — stay present throughout (a real
|
|
42
|
+
// gateway's JSONL files don't disappear on cleanup either; only the
|
|
43
|
+
// in-memory `terminatedAgentIds` dedup guard decides re-registration).
|
|
44
|
+
const visibleFiles = [...fileNames]
|
|
45
|
+
|
|
46
|
+
const content = Buffer.from(buildJSONL(subAgentUserMsg('done task'), subAgentTurnEnd()), 'utf-8')
|
|
47
|
+
|
|
48
|
+
let lastOpened: string | null = null
|
|
49
|
+
const mockFs = {
|
|
50
|
+
existsSync: ((p: fs.PathLike) => {
|
|
51
|
+
const ps = String(p)
|
|
52
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
53
|
+
return [...filePaths.values()].includes(ps)
|
|
54
|
+
}) as typeof fs.existsSync,
|
|
55
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
56
|
+
const ps = String(p)
|
|
57
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
58
|
+
if (ps === projectDir) return ['sess']
|
|
59
|
+
if (ps === sessionDir) return ['subagents']
|
|
60
|
+
if (ps === subagentsDir) return visibleFiles
|
|
61
|
+
return []
|
|
62
|
+
}) as unknown as typeof fs.readdirSync,
|
|
63
|
+
statSync: (() => ({ size: content.length, mtimeMs: 0 }) as fs.Stats) as typeof fs.statSync,
|
|
64
|
+
openSync: ((p: fs.PathLike) => { lastOpened = String(p); return 7 }) as unknown as typeof fs.openSync,
|
|
65
|
+
closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
|
|
66
|
+
readSync: ((
|
|
67
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
68
|
+
): number => {
|
|
69
|
+
if (lastOpened == null) return 0
|
|
70
|
+
const src = content.slice(position ?? 0, (position ?? 0) + length)
|
|
71
|
+
src.copy(buf as Buffer, offset)
|
|
72
|
+
return src.length
|
|
73
|
+
}) as unknown as typeof fs.readSync,
|
|
74
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let currentTime = 1_000_000
|
|
78
|
+
const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
|
|
79
|
+
const timeouts: Array<{ fn: () => void; ms: number; fireAt: number; ref: number }> = []
|
|
80
|
+
let nextTimeoutRef = 1
|
|
81
|
+
|
|
82
|
+
const advance = (ms: number): void => {
|
|
83
|
+
currentTime += ms
|
|
84
|
+
for (;;) {
|
|
85
|
+
timeouts.sort((a, b) => a.fireAt - b.fireAt)
|
|
86
|
+
const next = timeouts[0]
|
|
87
|
+
if (!next || next.fireAt > currentTime) break
|
|
88
|
+
timeouts.shift()
|
|
89
|
+
next.fn()
|
|
90
|
+
}
|
|
91
|
+
for (const iv of intervals) {
|
|
92
|
+
while (iv.fireAt <= currentTime) {
|
|
93
|
+
iv.fn()
|
|
94
|
+
iv.fireAt += iv.ms
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const watcher = startSubagentWatcher({
|
|
100
|
+
agentDir,
|
|
101
|
+
fs: mockFs,
|
|
102
|
+
terminatedAgentIdsCap: 3,
|
|
103
|
+
now: () => currentTime,
|
|
104
|
+
setInterval: (fn, ms) => {
|
|
105
|
+
intervals.push({ fn, ms, fireAt: currentTime + ms })
|
|
106
|
+
return { ref: 0 }
|
|
107
|
+
},
|
|
108
|
+
clearInterval: () => {},
|
|
109
|
+
setTimeout: (fn, ms) => {
|
|
110
|
+
const ref = nextTimeoutRef++
|
|
111
|
+
timeouts.push({ fn, ms, fireAt: currentTime + ms, ref })
|
|
112
|
+
return { ref }
|
|
113
|
+
},
|
|
114
|
+
clearTimeout: (handle) => {
|
|
115
|
+
const { ref } = handle as { ref: number }
|
|
116
|
+
const idx = timeouts.findIndex((t) => t.ref === ref)
|
|
117
|
+
if (idx !== -1) timeouts.splice(idx, 1)
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// All 4 discovered at boot, already `done` → each schedules a terminal
|
|
122
|
+
// cleanup TERMINAL_CLEANUP_GRACE_MS (30s) out, in discovery order.
|
|
123
|
+
for (const id of agentIds) {
|
|
124
|
+
expect(watcher.getRegistry().has(id)).toBe(true)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fire all 4 scheduled cleanups (in order a, b, c, d). With cap=3, the
|
|
128
|
+
// 4th cleanup (d) evicts the OLDEST tracked id (a) before inserting d.
|
|
129
|
+
// `advance()` also drives the rescan interval (default 1s) forward as
|
|
130
|
+
// part of the same clock jump, so by the time it returns the poll
|
|
131
|
+
// loop has ALSO already rediscovered the just-evicted agent-a (its
|
|
132
|
+
// JSONL file is still on disk throughout — nothing "disappears" for a
|
|
133
|
+
// real gateway either) — that's the observable proof the eviction
|
|
134
|
+
// took effect, not a separate step.
|
|
135
|
+
advance(30_000)
|
|
136
|
+
|
|
137
|
+
// agent-a was evicted from `terminatedAgentIds` (cap=3, it was the
|
|
138
|
+
// oldest insert) → the dedup guard no longer suppresses it, so the
|
|
139
|
+
// rescan re-discovered and re-registered it.
|
|
140
|
+
expect(watcher.getRegistry().has('agent-a')).toBe(true)
|
|
141
|
+
// agent-b, agent-c, agent-d are still within the cap window → the
|
|
142
|
+
// dedup guard still suppresses their re-discovery even though their
|
|
143
|
+
// JSONL files are also still sitting on disk.
|
|
144
|
+
expect(watcher.getRegistry().has('agent-b')).toBe(false)
|
|
145
|
+
expect(watcher.getRegistry().has('agent-c')).toBe(false)
|
|
146
|
+
expect(watcher.getRegistry().has('agent-d')).toBe(false)
|
|
147
|
+
|
|
148
|
+
watcher.stop()
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -519,6 +519,60 @@ describe('startSubagentWatcher', () => {
|
|
|
519
519
|
expect(toolTick?.latestSummary).toBe('')
|
|
520
520
|
})
|
|
521
521
|
|
|
522
|
+
it('captures message.model into entry.currentModel and threads it onto onProgress', () => {
|
|
523
|
+
const progress: Array<{ model?: string }> = []
|
|
524
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
525
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
526
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
527
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
528
|
+
|
|
529
|
+
const h = startWatcherSync({
|
|
530
|
+
agentDir,
|
|
531
|
+
onProgress: ({ model }) => { progress.push({ model }) },
|
|
532
|
+
})
|
|
533
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research the competitors')))
|
|
534
|
+
h.poll()
|
|
535
|
+
|
|
536
|
+
// Assistant line carrying a resolved model + a tool_use (drives an
|
|
537
|
+
// onProgress tick). The model event is projected first, so the entry's
|
|
538
|
+
// currentModel is set before the tool tick fires.
|
|
539
|
+
appendFileSync(jsonlPath, buildJSONL({
|
|
540
|
+
type: 'assistant',
|
|
541
|
+
message: {
|
|
542
|
+
model: 'claude-opus-4-8',
|
|
543
|
+
content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: { file_path: '/x/CLAUDE.md' } }],
|
|
544
|
+
},
|
|
545
|
+
}))
|
|
546
|
+
h.poll()
|
|
547
|
+
|
|
548
|
+
expect(h.watcher.getRegistry().get('deadbeef')?.currentModel).toBe('claude-opus-4-8')
|
|
549
|
+
const modelled = progress.find((p) => p.model != null)
|
|
550
|
+
expect(modelled?.model).toBe('claude-opus-4-8')
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
it('ignores a synthetic model sentinel, keeping the last real model', () => {
|
|
554
|
+
const agentDir = join(tmpRoot, 'agent')
|
|
555
|
+
const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
|
|
556
|
+
mkdirSync(subagentsDir, { recursive: true })
|
|
557
|
+
const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
|
|
558
|
+
|
|
559
|
+
const h = startWatcherSync({ agentDir })
|
|
560
|
+
writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Research')))
|
|
561
|
+
h.poll()
|
|
562
|
+
appendFileSync(jsonlPath, buildJSONL({
|
|
563
|
+
type: 'assistant',
|
|
564
|
+
message: { model: 'claude-opus-4-8', content: [{ type: 'tool_use', name: 'Read', id: 'r1', input: {} }] },
|
|
565
|
+
}))
|
|
566
|
+
h.poll()
|
|
567
|
+
// A compaction/synthetic line — must NOT clobber the last real model.
|
|
568
|
+
appendFileSync(jsonlPath, buildJSONL({
|
|
569
|
+
type: 'assistant',
|
|
570
|
+
message: { model: '<synthetic>', content: [{ type: 'tool_use', name: 'Bash', id: 'b1', input: {} }] },
|
|
571
|
+
}))
|
|
572
|
+
h.poll()
|
|
573
|
+
expect(h.watcher.getRegistry().get('deadbeef')?.currentModel).toBe('claude-opus-4-8')
|
|
574
|
+
})
|
|
575
|
+
|
|
522
576
|
it('narrative gate: a draft-then-reply sub_agent_text is SUPPRESSED (no progress cue)', () => {
|
|
523
577
|
// The worker composes its answer as a text block, then calls
|
|
524
578
|
// stream_reply with near-identical text. The narrative cue must be
|
|
@@ -723,6 +723,28 @@ describe("renderActivityHeader — two-line header builder", () => {
|
|
|
723
723
|
const [h1] = renderActivityHeader("🤖", "Agent", "run a_b & c*d", 5_000, 1, "running");
|
|
724
724
|
expect(h1).toContain("run a\\_b & c\\*d");
|
|
725
725
|
});
|
|
726
|
+
|
|
727
|
+
it("appends the friendly live model to the running metrics line", () => {
|
|
728
|
+
const [, h2] = renderActivityHeader("🤖", "Agent", "", 120_000, 14, "running", "claude-opus-4-8");
|
|
729
|
+
expect(h2).toBe("_2m00s · 14 tools · opus 4.8_");
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("appends the friendly live model to the done metrics line", () => {
|
|
733
|
+
const [, h2] = renderActivityHeader("🤖", "Agent", "", 65_000, 3, "done", "claude-sonnet-5");
|
|
734
|
+
expect(h2).toBe("_done · 3 tools · 1m05s · sonnet 5_");
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("shows an sr-* model id verbatim on the metrics line", () => {
|
|
738
|
+
const [, h2] = renderActivityHeader("🛠", "Worker", "run tests", 10_000, 2, "running", "sr-glm-5");
|
|
739
|
+
expect(h2).toBe("_10s · 2 tools · sr-glm-5_");
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("omits the model tag for a sentinel / absent value", () => {
|
|
743
|
+
const [, h2none] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running");
|
|
744
|
+
expect(h2none).toBe("_15s · 7 tools_");
|
|
745
|
+
const [, h2synth] = renderActivityHeader("🤖", "Agent", "", 15_000, 7, "running", "<synthetic>");
|
|
746
|
+
expect(h2synth).toBe("_15s · 7 tools_");
|
|
747
|
+
});
|
|
726
748
|
});
|
|
727
749
|
|
|
728
750
|
describe("agent flat path routes through the shared step-feed primitive", () => {
|
|
@@ -815,6 +837,21 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
|
|
|
815
837
|
expect(out).toContain("**→ Searching memory**");
|
|
816
838
|
});
|
|
817
839
|
|
|
840
|
+
it("threads the header model through the flat and nested feed paths", () => {
|
|
841
|
+
const header: SessionActivityHeader = {
|
|
842
|
+
label: "Agent",
|
|
843
|
+
elapsedMs: 120_000,
|
|
844
|
+
toolCount: 14,
|
|
845
|
+
state: "running",
|
|
846
|
+
model: "claude-opus-4-8",
|
|
847
|
+
};
|
|
848
|
+
const flat = renderActivityFeed(["Searching memory"], false, "", undefined, header)!;
|
|
849
|
+
expect(flat).toContain("_2m00s · 14 tools · opus 4.8_");
|
|
850
|
+
// Nested path (with a child line) carries the same model tag.
|
|
851
|
+
const nested = renderActivityFeedWithNested(["Reading"], ["nested step"], false, "", undefined, header)!;
|
|
852
|
+
expect(nested).toContain("· opus 4.8_");
|
|
853
|
+
});
|
|
854
|
+
|
|
818
855
|
it("prepends the done header when final=true", () => {
|
|
819
856
|
const header: SessionActivityHeader = {
|
|
820
857
|
label: "Agent",
|
|
@@ -64,6 +64,29 @@ describe('createTypingWrapper', () => {
|
|
|
64
64
|
expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
+
it('fix #7: does NOT stop typing when the first of two parallel tools resolves while the second is still running', () => {
|
|
68
|
+
const deps = makeDeps()
|
|
69
|
+
const w = createTypingWrapper(deps)
|
|
70
|
+
// Two overlapping tool_use blocks on the same lane. The first fires the
|
|
71
|
+
// loop immediately; the second (still in-flight) uses the debounce.
|
|
72
|
+
w.onToolUse('t1', 'chat-A', 'Bash')
|
|
73
|
+
w.onToolUse('t2', 'chat-A', 'Read')
|
|
74
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
|
|
75
|
+
// Let t2's debounce fire so it's a live, started entry on the lane too.
|
|
76
|
+
vi.advanceTimersByTime(500)
|
|
77
|
+
expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
|
|
78
|
+
|
|
79
|
+
// t1 resolves first — with the pre-fix boolean Set, this deleted the
|
|
80
|
+
// lane and stopped the loop even though t2 is still running.
|
|
81
|
+
w.onToolResult('t1')
|
|
82
|
+
expect(deps.stopTypingLoop).not.toHaveBeenCalled()
|
|
83
|
+
|
|
84
|
+
// t2 resolves too — now the lane's ref-count hits zero and the loop stops.
|
|
85
|
+
w.onToolResult('t2')
|
|
86
|
+
expect(deps.stopTypingLoop).toHaveBeenCalledTimes(1)
|
|
87
|
+
expect(deps.stopTypingLoop).toHaveBeenCalledWith('chat-A', null)
|
|
88
|
+
})
|
|
89
|
+
|
|
67
90
|
it('starts then stops typing when a single slow tool completes', () => {
|
|
68
91
|
const deps = makeDeps()
|
|
69
92
|
const w = createTypingWrapper(deps)
|
|
@@ -86,6 +86,17 @@ describe('renderWorkerActivity', () => {
|
|
|
86
86
|
expect(out).not.toContain('<code>')
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
+
it('renders the friendly live model on the worker metrics line', () => {
|
|
90
|
+
const out = renderWorkerActivity(view({ model: 'claude-sonnet-5' }))
|
|
91
|
+
expect(out).toContain('_10s · 3 tools · sonnet 5_')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('omits the model tag when the worker model is unknown', () => {
|
|
95
|
+
const out = renderWorkerActivity(view())
|
|
96
|
+
expect(out).toContain('_10s · 3 tools_')
|
|
97
|
+
expect(out).not.toContain('· sonnet')
|
|
98
|
+
})
|
|
99
|
+
|
|
89
100
|
it('shows a "starting…" line when no step has run yet', () => {
|
|
90
101
|
const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
|
|
91
102
|
expect(out).toContain('🛠 **Worker**')
|