switchroom 0.18.19 → 0.18.20

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.
Files changed (47) hide show
  1. package/dist/cli/ms-365-write-pretool.mjs +92 -20
  2. package/dist/cli/switchroom.js +36 -6
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/telegram-plugin/answer-ready-flush.ts +187 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1073 -182
  7. package/telegram-plugin/format.ts +179 -20
  8. package/telegram-plugin/gateway/cron-session.ts +32 -0
  9. package/telegram-plugin/gateway/gateway.ts +775 -105
  10. package/telegram-plugin/gateway/idle-clear.ts +170 -0
  11. package/telegram-plugin/gateway/inject-handler.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
  13. package/telegram-plugin/gateway/turn-record-status.ts +134 -0
  14. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
  15. package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
  16. package/telegram-plugin/narrative-flush.ts +181 -0
  17. package/telegram-plugin/pending-work-progress.ts +65 -1
  18. package/telegram-plugin/session-tail.ts +6 -1
  19. package/telegram-plugin/silent-end.ts +182 -0
  20. package/telegram-plugin/stream-reply-handler.ts +14 -5
  21. package/telegram-plugin/subagent-watcher.ts +244 -81
  22. package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
  23. package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
  24. package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
  25. package/telegram-plugin/tests/format-consistency.test.ts +54 -34
  26. package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
  27. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  28. package/telegram-plugin/tests/idle-clear.test.ts +315 -37
  29. package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
  30. package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
  31. package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
  32. package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
  33. package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
  34. package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
  35. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
  36. package/telegram-plugin/tests/silent-end.test.ts +296 -0
  37. package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
  38. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +218 -0
  39. package/telegram-plugin/tests/telegram-format.test.ts +36 -23
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
  41. package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
  42. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
  43. package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
  44. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +125 -0
  45. package/telegram-plugin/tool-activity-summary.ts +78 -16
  46. package/telegram-plugin/turn-flush-safety.ts +4 -4
  47. package/telegram-plugin/worker-activity-feed.ts +181 -30
@@ -4,7 +4,7 @@ import {
4
4
  type BotApiForWorkerFeed,
5
5
  type WorkerActivityView,
6
6
  } from '../worker-activity-feed.js'
7
- import { renderCombinedWorkerFeed } from '../tool-activity-summary.js'
7
+ import { renderCombinedWorkerFeed, combinedHistoryDepth } from '../tool-activity-summary.js'
8
8
  import { STATUS_CARD_CHAR_BUDGET } from '../status-no-truncate.js'
9
9
  import { createSendGate, isSendGateShed, type Clock } from '../send-gate.js'
10
10
 
@@ -489,4 +489,221 @@ describe('renderCombinedWorkerFeed (pure)', () => {
489
489
  it('returns null for an empty worker set', () => {
490
490
  expect(renderCombinedWorkerFeed([], { maxRows: 8 })).toBeNull()
491
491
  })
492
+
493
+ // ── Adaptive density: per-worker rolling history within a line budget ──────
494
+ const rowH = (i: number, history: string[]) => ({
495
+ description: `task number ${i}`,
496
+ elapsedMs: 12_000 + i * 1000,
497
+ toolCount: i,
498
+ currentStep: history[history.length - 1] ?? '',
499
+ historyLines: history,
500
+ })
501
+
502
+ // A history line rendered as a PRIOR (done) step in the single-worker idiom.
503
+ const struck = (s: string) => `~~_✓ ${s}_~~`
504
+ // A history line rendered as the NEWEST in-progress step.
505
+ const current = (s: string) => `**→ ${s}**`
506
+
507
+ it('with 2 workers paints each worker MULTIPLE history lines with the ✓/→ strikethrough idiom', () => {
508
+ const body = renderCombinedWorkerFeed(
509
+ [
510
+ rowH(1, ['a first', 'a second', 'a third']),
511
+ rowH(2, ['b first', 'b second', 'b third']),
512
+ ],
513
+ { maxRows: 8 },
514
+ )!
515
+ // Prior steps struck-through, newest bold — same idiom as the single card.
516
+ expect(body).toContain(struck('a first'))
517
+ expect(body).toContain(struck('a second'))
518
+ expect(body).toContain(current('a third'))
519
+ expect(body).toContain(struck('b first'))
520
+ expect(body).toContain(struck('b second'))
521
+ expect(body).toContain(current('b third'))
522
+ // This is the regression assertion: the OLD single-line-only render would
523
+ // have shown only 'a third'/'b third' as `→ _step_`, never the earlier
524
+ // struck lines. Prove the trail is restored.
525
+ expect(body).toContain('a first')
526
+ expect(body).toContain('b first')
527
+ })
528
+
529
+ it('degrades to ONE history line per worker at a large fan-out and stays within the body budget', () => {
530
+ const rows = Array.from({ length: 6 }, (_, i) =>
531
+ rowH(i, [`w${i} oldest`, `w${i} middle`, `w${i} newest`]),
532
+ )
533
+ const body = renderCombinedWorkerFeed(rows, { maxRows: 8 })!
534
+ // Only the newest step of each worker survives — the earlier lines are
535
+ // dropped by the per-worker depth clamp (floor((13-6)/6)=1).
536
+ for (let i = 0; i < 6; i++) {
537
+ expect(body).toContain(current(`w${i} newest`))
538
+ expect(body).not.toContain(`w${i} oldest`)
539
+ expect(body).not.toContain(`w${i} middle`)
540
+ }
541
+ // Total body lines (worker headers + history) stay within the budget: 6
542
+ // header lines + 6 history lines = 12 ≤ MAX_COMBINED_BODY_LINES (13). Count
543
+ // only the per-worker body lines (exclude the top count line + any spill).
544
+ const bodyLines = body
545
+ .split('\n')
546
+ .map((l) => l.trim())
547
+ .filter((l) => l.length > 0)
548
+ const headerAndHistory = bodyLines.filter(
549
+ (l) => !l.startsWith('🛠') && !l.includes('more working'),
550
+ )
551
+ expect(headerAndHistory.length).toBeLessThanOrEqual(13)
552
+ })
553
+
554
+ it('exposes the deterministic depth formula (2→5, 3→3, 4→2, 6→1)', () => {
555
+ expect(combinedHistoryDepth(2)).toBe(5)
556
+ expect(combinedHistoryDepth(3)).toBe(3)
557
+ expect(combinedHistoryDepth(4)).toBe(2)
558
+ expect(combinedHistoryDepth(6)).toBe(1)
559
+ expect(combinedHistoryDepth(8)).toBe(1)
560
+ })
561
+ })
562
+
563
+ /**
564
+ * Worker-feed ghost-leak (immortal/unpinned/buried card) — outcome tests.
565
+ *
566
+ * Root cause: the feed removed a worker's row ONLY from the gateway's
567
+ * `onFinish` handler. Terminal paths that never fire `onFinish` (the watcher's
568
+ * JSONL-vanished `onFileVanished` → `cleanupTerminalAgent`, and boot done-at-
569
+ * boot orphans) left the row in the feed forever — the shared card never
570
+ * emptied, so it never collapsed/unpinned and heartbeat-edited indefinitely
571
+ * while buried up-chat. The fix wires feed removal to the watcher's
572
+ * authoritative terminal sweep (`terminate`, driven by `onTerminalCleanup`)
573
+ * PLUS a backstop TTL sweep. These assert the OUTCOMES, not the code paths.
574
+ */
575
+ function ghostHarness(opts: { staleWorkerTtlMs?: number; now: () => number }) {
576
+ const edits: { messageId: number; text: string }[] = []
577
+ const sends: { text: string }[] = []
578
+ const pins: { messageId: number | null }[] = []
579
+ let seq = 500
580
+ const bot: BotApiForWorkerFeed = {
581
+ sendMessage: async (_chatId, text) => {
582
+ sends.push({ text })
583
+ return { message_id: seq++ }
584
+ },
585
+ editMessageText: async (_chatId, messageId, text) => {
586
+ edits.push({ messageId, text })
587
+ return true
588
+ },
589
+ }
590
+ const feed = createWorkerActivityFeed({
591
+ bot,
592
+ now: opts.now,
593
+ minEditIntervalMs: 0,
594
+ heartbeatTickMs: 1000,
595
+ firstPaintMinMs: 0,
596
+ setInterval: () => 0,
597
+ clearInterval: () => {},
598
+ staleWorkerTtlMs: opts.staleWorkerTtlMs,
599
+ reconcilePin: ({ messageId }) => pins.push({ messageId }),
600
+ })
601
+ return { feed, edits, sends, pins }
602
+ }
603
+ async function drain(): Promise<void> {
604
+ for (let i = 0; i < 10; i++) await new Promise((r) => setImmediate(r))
605
+ }
606
+
607
+ describe('worker-feed ghost-leak — deterministic terminal removal + backstop', () => {
608
+ it('finish() on the LAST worker removes its row, collapses to the terminal summary, and UNPINS', async () => {
609
+ let t = 0
610
+ const { feed, sends, edits, pins } = ghostHarness({ now: () => t })
611
+ await feed.update('a', 'chat', view('task a', 'reading files', 0))
612
+ await drain()
613
+ expect(sends.length).toBe(1)
614
+ expect(feed.size).toBe(1)
615
+ // Painting the group pins the shared message.
616
+ expect(pins.at(-1)?.messageId).not.toBeNull()
617
+
618
+ t = 5000
619
+ await feed.finish('a', {
620
+ description: 'task a',
621
+ lastTool: null,
622
+ toolCount: 3,
623
+ latestSummary: 'all done',
624
+ elapsedMs: 5000,
625
+ state: 'done',
626
+ })
627
+ await drain()
628
+ // Row gone → the active set empties.
629
+ expect(feed.size).toBe(0)
630
+ // Collapsed to a terminal summary (a distinct edit landed, showing 'done').
631
+ expect(edits.length).toBeGreaterThan(0)
632
+ expect(edits.at(-1)?.text).toContain('done')
633
+ // And UNPINNED (group empty → reconcilePin messageId null).
634
+ expect(pins.at(-1)?.messageId).toBeNull()
635
+ })
636
+
637
+ it('terminate() (authoritative onTerminalCleanup sweep) reaps a worker whose onFinish NEVER fired — collapses + unpins', async () => {
638
+ let t = 0
639
+ const { feed, edits, pins } = ghostHarness({ now: () => t })
640
+ await feed.update('b', 'chat', view('task b', 'running a command', 0))
641
+ await drain()
642
+ expect(feed.size).toBe(1)
643
+ expect(pins.at(-1)?.messageId).not.toBeNull()
644
+
645
+ // Simulate the watcher's JSONL-vanished sweep: cleanupTerminalAgent → this,
646
+ // with NO onFinish ever delivered.
647
+ t = 3000
648
+ await feed.terminate('b')
649
+ await drain()
650
+ expect(feed.size).toBe(0)
651
+ expect(pins.at(-1)?.messageId).toBeNull()
652
+ // The card stopped editing: no further heartbeat edits after termination.
653
+ const after = edits.length
654
+ t = 20000
655
+ feed.heartbeatTick()
656
+ await drain()
657
+ expect(edits.length).toBe(after)
658
+ })
659
+
660
+ it('backstop TTL sweep force-reaps a leaked slot (terminal signal never delivered), then the card collapses + unpins', async () => {
661
+ let t = 0
662
+ const { feed, edits, pins } = ghostHarness({ staleWorkerTtlMs: 1000, now: () => t })
663
+ await feed.update('c', 'chat', view('task c', 'thinking', 0))
664
+ await drain()
665
+ expect(feed.size).toBe(1)
666
+
667
+ // No finish, no terminate — the worker is a pure leak. Advance past the TTL.
668
+ t = 2500
669
+ feed.heartbeatTick()
670
+ await drain()
671
+ expect(feed.size).toBe(0)
672
+ expect(pins.at(-1)?.messageId).toBeNull()
673
+
674
+ // Immortality closed: subsequent heartbeats produce no further edits.
675
+ const after = edits.length
676
+ t = 10000
677
+ feed.heartbeatTick()
678
+ await drain()
679
+ expect(edits.length).toBe(after)
680
+ })
681
+
682
+ it('a still-live worker (fresh update within the TTL) is NOT reaped by the backstop sweep', async () => {
683
+ let t = 0
684
+ const { feed } = ghostHarness({ staleWorkerTtlMs: 1000, now: () => t })
685
+ await feed.update('d', 'chat', view('task d', 's0', 0))
686
+ await drain()
687
+ // A fresh cue just before the sweep keeps it live.
688
+ t = 900
689
+ await feed.update('d', 'chat', view('task d', 's1', 900))
690
+ await drain()
691
+ t = 1500
692
+ feed.heartbeatTick()
693
+ await drain()
694
+ // Still tracked — the sweep only reaps rows silent PAST the TTL.
695
+ expect(feed.size).toBe(1)
696
+ })
697
+
698
+ it('no-op re-render is skipped (byte-identical body → no redundant edit)', async () => {
699
+ let t = 0
700
+ const { feed, edits } = ghostHarness({ now: () => t })
701
+ await feed.update('e', 'chat', view('task e', 'same step', 0))
702
+ await drain()
703
+ const afterPaint = edits.length // first paint is a send, not an edit
704
+ // Identical view (same elapsed → byte-identical rendered body): dedup skips.
705
+ await feed.update('e', 'chat', view('task e', 'same step', 0))
706
+ await drain()
707
+ expect(edits.length).toBe(afterPaint)
708
+ })
492
709
  })
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Integration guard for the worker-feed ghost-leak fix (PR #3226 review,
3
+ * Finding 1). The unit tests in `worker-feed-coalesce.test.ts` call
4
+ * `feed.terminate()` DIRECTLY — they prove the feed collapses/unpins when
5
+ * asked, but they do NOT prove the two halves of the actual wire that was
6
+ * broken:
7
+ *
8
+ * (a) the subagent-watcher FIRES `onTerminalCleanup` from its authoritative
9
+ * `cleanupTerminalAgent` sweep — on BOTH the JSONL-vanished path
10
+ * (`onFileVanished` → `cleanupTerminalAgent`) AND the boot done-at-boot
11
+ * orphan path — the exact paths that never fire `onFinish`; and
12
+ * (b) wiring that callback to `workerActivityFeed.terminate` removes the row
13
+ * and collapses/unpins the shared card end-to-end.
14
+ *
15
+ * These drive the REAL `startSubagentWatcher` (with a mock fs) wired to a REAL
16
+ * `createWorkerActivityFeed` exactly as the gateway wires them
17
+ * (`onTerminalCleanup: (id) => feed.terminate(id)`). If a future refactor drops
18
+ * the `config.onTerminalCleanup(agentId)` call in `cleanupTerminalAgent`, the
19
+ * callback never fires, the feed row survives, and these go RED — the
20
+ * regression the divergence-based leak represented.
21
+ */
22
+ import { describe, it, expect, vi } from 'vitest'
23
+ import * as fs from 'fs'
24
+ import { startSubagentWatcher } from '../subagent-watcher.js'
25
+ import {
26
+ createWorkerActivityFeed,
27
+ type BotApiForWorkerFeed,
28
+ type WorkerActivityView,
29
+ } from '../worker-activity-feed.js'
30
+
31
+ function buildJSONL(...lines: object[]): string {
32
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
33
+ }
34
+ const subAgentUserMsg = (t: string) => ({ type: 'user', message: { content: [{ type: 'text', text: t }] } })
35
+ const subAgentTurnEnd = () => ({ type: 'system', subtype: 'turn_duration', duration_ms: 100 })
36
+
37
+ function view(desc: string, step: string, elapsedMs: number): WorkerActivityView {
38
+ return { description: desc, lastTool: null, toolCount: 2, latestSummary: step, elapsedMs, state: 'running' }
39
+ }
40
+
41
+ /** A real feed with a fake bot + reconcilePin capture, driven off `now`. */
42
+ function makeFeed(now: () => number) {
43
+ const sends: { text: string }[] = []
44
+ const edits: { messageId: number; text: string }[] = []
45
+ const pins: { messageId: number | null }[] = []
46
+ let seq = 700
47
+ const bot: BotApiForWorkerFeed = {
48
+ sendMessage: async (_c, text) => {
49
+ sends.push({ text })
50
+ return { message_id: seq++ }
51
+ },
52
+ editMessageText: async (_c, messageId, text) => {
53
+ edits.push({ messageId, text })
54
+ return true
55
+ },
56
+ }
57
+ const feed = createWorkerActivityFeed({
58
+ bot,
59
+ now,
60
+ minEditIntervalMs: 0,
61
+ heartbeatTickMs: 1000,
62
+ firstPaintMinMs: 0,
63
+ setInterval: () => 0,
64
+ clearInterval: () => {},
65
+ reconcilePin: ({ messageId }) => pins.push({ messageId }),
66
+ })
67
+ return { feed, sends, edits, pins }
68
+ }
69
+ async function drain(): Promise<void> {
70
+ for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r))
71
+ }
72
+
73
+ /**
74
+ * A mock fs over a single subagents dir with one worker JSONL. `vanished`
75
+ * flips the file-read calls to throw ENOENT (Claude Code reaped the parent
76
+ * session's `subagents/` dir) to exercise the vanished terminal path.
77
+ */
78
+ function mockWatcherFs(opts: {
79
+ agentDir: string
80
+ fileName: string
81
+ content: Buffer
82
+ vanished: () => boolean
83
+ }) {
84
+ const projectsRoot = `${opts.agentDir}/.claude/projects`
85
+ const projectDir = `${projectsRoot}/mock-cwd`
86
+ const sessionDir = `${projectDir}/sess`
87
+ const subagentsDir = `${sessionDir}/subagents`
88
+ const filePath = `${subagentsDir}/${opts.fileName}`
89
+ let lastOpened: string | null = null
90
+ const enoent = (): never => {
91
+ const e = new Error('ENOENT') as NodeJS.ErrnoException
92
+ e.code = 'ENOENT'
93
+ throw e
94
+ }
95
+ const mock = {
96
+ existsSync: ((p: fs.PathLike) => {
97
+ const ps = String(p)
98
+ if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
99
+ return ps === filePath && !opts.vanished()
100
+ }) as typeof fs.existsSync,
101
+ readdirSync: ((p: fs.PathLike) => {
102
+ const ps = String(p)
103
+ if (ps === projectsRoot) return ['mock-cwd']
104
+ if (ps === projectDir) return ['sess']
105
+ if (ps === sessionDir) return ['subagents']
106
+ if (ps === subagentsDir) return opts.vanished() ? [] : [opts.fileName]
107
+ return []
108
+ }) as unknown as typeof fs.readdirSync,
109
+ statSync: ((p: fs.PathLike) => {
110
+ if (opts.vanished()) return enoent()
111
+ return { size: opts.content.length, mtimeMs: 0 } as fs.Stats
112
+ }) as typeof fs.statSync,
113
+ openSync: ((p: fs.PathLike) => {
114
+ if (opts.vanished()) return enoent()
115
+ lastOpened = String(p)
116
+ return 7
117
+ }) as unknown as typeof fs.openSync,
118
+ closeSync: (() => { lastOpened = null }) as typeof fs.closeSync,
119
+ readSync: ((_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null): number => {
120
+ if (opts.vanished() || lastOpened == null) return 0
121
+ const src = opts.content.slice(position ?? 0, (position ?? 0) + length)
122
+ src.copy(buf as Buffer, offset)
123
+ return src.length
124
+ }) as unknown as typeof fs.readSync,
125
+ watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
126
+ }
127
+ return { mock, filePath }
128
+ }
129
+
130
+ /** Deterministic clock + injectable timers shared by watcher + feed. */
131
+ function makeClock() {
132
+ let currentTime = 1_000_000
133
+ const intervals: Array<{ fn: () => void; ms: number; fireAt: number }> = []
134
+ const timeouts: Array<{ fn: () => void; fireAt: number; ref: number }> = []
135
+ let nextRef = 1
136
+ const now = () => currentTime
137
+ const advance = (ms: number): void => {
138
+ currentTime += ms
139
+ for (;;) {
140
+ timeouts.sort((a, b) => a.fireAt - b.fireAt)
141
+ const next = timeouts[0]
142
+ if (!next || next.fireAt > currentTime) break
143
+ timeouts.shift()
144
+ next.fn()
145
+ }
146
+ for (const iv of intervals) {
147
+ while (iv.fireAt <= currentTime) {
148
+ iv.fn()
149
+ iv.fireAt += iv.ms
150
+ }
151
+ }
152
+ }
153
+ const timers = {
154
+ setInterval: (fn: () => void, ms: number) => {
155
+ intervals.push({ fn, ms, fireAt: currentTime + ms })
156
+ return { ref: 0 }
157
+ },
158
+ clearInterval: () => {},
159
+ setTimeout: (fn: () => void, ms: number) => {
160
+ const ref = nextRef++
161
+ timeouts.push({ fn, fireAt: currentTime + ms, ref })
162
+ return { ref }
163
+ },
164
+ clearTimeout: (handle: { ref: number }) => {
165
+ const idx = timeouts.findIndex((t) => t.ref === handle.ref)
166
+ if (idx !== -1) timeouts.splice(idx, 1)
167
+ },
168
+ }
169
+ return { now, advance, timers }
170
+ }
171
+
172
+ describe('worker-feed ghost-leak — watcher terminal sweep → feed removal (integration)', () => {
173
+ it('boot done-at-boot orphan: cleanupTerminalAgent fires onTerminalCleanup → feed row removed, card collapsed + unpinned', async () => {
174
+ const agentDir = '/home/user/.switchroom/agents/x'
175
+ const { now, advance, timers } = makeClock()
176
+ const { feed, pins } = makeFeed(now)
177
+
178
+ // The worker was live in the feed (its progress had surfaced there).
179
+ await feed.update('boot1', 'chat', view('task boot1', 'reading', 0))
180
+ await drain()
181
+ expect(feed.size).toBe(1)
182
+ expect(pins.at(-1)?.messageId).not.toBeNull() // pinned
183
+
184
+ const { mock } = mockWatcherFs({
185
+ agentDir,
186
+ fileName: 'agent-boot1.jsonl',
187
+ // Already `done` at boot (turn_end present) → registerAgent schedules a
188
+ // terminal cleanup with NO onFinish (the boot-orphan bypass path).
189
+ content: Buffer.from(buildJSONL(subAgentUserMsg('done task'), subAgentTurnEnd()), 'utf-8'),
190
+ vanished: () => false,
191
+ })
192
+ const watcher = startSubagentWatcher({
193
+ agentDir,
194
+ fs: mock,
195
+ now,
196
+ // Wire EXACTLY as the gateway does.
197
+ onTerminalCleanup: (agentId) => { void feed.terminate(agentId) },
198
+ ...timers,
199
+ })
200
+ expect(watcher.getRegistry().has('boot1')).toBe(true)
201
+
202
+ // Fire the scheduled terminal cleanup (30s grace) → onTerminalCleanup.
203
+ advance(30_000)
204
+ await drain()
205
+
206
+ expect(feed.size).toBe(0) // row removed by the sweep
207
+ expect(pins.at(-1)?.messageId).toBeNull() // card collapsed + UNPINNED
208
+ watcher.stop()
209
+ })
210
+
211
+ it('JSONL-vanished path: onFileVanished → cleanupTerminalAgent → onTerminalCleanup → feed row removed + unpinned', async () => {
212
+ const agentDir = '/home/user/.switchroom/agents/y'
213
+ const { now, advance, timers } = makeClock()
214
+ const { feed, pins } = makeFeed(now)
215
+
216
+ await feed.update('van1', 'chat', view('task van1', 'running a command', 0))
217
+ await drain()
218
+ expect(feed.size).toBe(1)
219
+
220
+ let vanished = false
221
+ const { mock } = mockWatcherFs({
222
+ agentDir,
223
+ fileName: 'agent-van1.jsonl',
224
+ // Running at boot (no turn_end) — stays registered; the poll loop reads
225
+ // it defensively, so when the file vanishes the read throws ENOENT and
226
+ // the watcher takes onFileVanished → cleanupTerminalAgent.
227
+ content: Buffer.from(buildJSONL(subAgentUserMsg('long task')), 'utf-8'),
228
+ vanished: () => vanished,
229
+ })
230
+ const captured: string[] = []
231
+ const watcher = startSubagentWatcher({
232
+ agentDir,
233
+ fs: mock,
234
+ now,
235
+ onTerminalCleanup: (agentId) => {
236
+ captured.push(agentId)
237
+ void feed.terminate(agentId)
238
+ },
239
+ ...timers,
240
+ })
241
+ expect(watcher.getRegistry().has('van1')).toBe(true)
242
+
243
+ // The parent session ends → Claude Code reaps subagents/ → next poll read
244
+ // hits ENOENT.
245
+ vanished = true
246
+ advance(2_000) // drive the rescan/poll interval
247
+ await drain()
248
+
249
+ expect(captured).toContain('van1') // vanished path funnels here
250
+ expect(feed.size).toBe(0) // wired removal happened
251
+ expect(pins.at(-1)?.messageId).toBeNull() // unpinned
252
+ watcher.stop()
253
+ })
254
+ })
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Residual B regression: a worker cleaned up by the TTL / authoritative
3
+ * `terminate` sweep — i.e. reaped / vanished / crashed WITHOUT ever delivering
4
+ * a clean `onFinish` result — must render a TRUTHFUL terminal state, NOT "done".
5
+ *
6
+ * `terminateWorker` synthesises the recap when NO clean finish arrived (a clean
7
+ * `onFinish` removes the row first, making `terminate` a no-op; an errored
8
+ * worker goes through `onFinish(outcome:'failed')`). So a row still present at
9
+ * terminate time genuinely ended without a result → it must render as the
10
+ * `incomplete` terminal ("incomplete · …"), never "done".
11
+ *
12
+ * OUTCOME assertions on the finalized card body (RED if `terminate` reverts to
13
+ * `state:'done'`), applied to the generic worker row so the guarantee holds at
14
+ * every nesting level (the feed keys rows by agentId, depth-agnostic):
15
+ * - a solo reaped worker's terminal card reads `incomplete`, never `done`;
16
+ * - a genuinely-`finish`ed worker still reads `done` with its result.
17
+ */
18
+ import { describe, it, expect } from 'vitest'
19
+ import {
20
+ createWorkerActivityFeed,
21
+ renderWorkerActivity,
22
+ type BotApiForWorkerFeed,
23
+ type WorkerActivityView,
24
+ } from '../worker-activity-feed.js'
25
+
26
+ function makeFeed(now: () => number) {
27
+ const sends: { text: string }[] = []
28
+ const edits: { messageId: number; text: string }[] = []
29
+ let seq = 900
30
+ const bot: BotApiForWorkerFeed = {
31
+ sendMessage: async (_c, text) => { sends.push({ text }); return { message_id: seq++ } },
32
+ editMessageText: async (_c, messageId, text) => { edits.push({ messageId, text }); return true },
33
+ }
34
+ const feed = createWorkerActivityFeed({
35
+ bot,
36
+ now,
37
+ minEditIntervalMs: 0,
38
+ firstPaintMinMs: 0,
39
+ setInterval: () => 0,
40
+ clearInterval: () => {},
41
+ })
42
+ return { feed, sends, edits }
43
+ }
44
+ const runningView = (desc: string, step: string, elapsedMs: number): WorkerActivityView => ({
45
+ description: desc, lastTool: null, toolCount: 3, latestSummary: step, elapsedMs, state: 'running',
46
+ })
47
+ async function drain(): Promise<void> {
48
+ for (let i = 0; i < 12; i++) await new Promise((r) => setImmediate(r))
49
+ }
50
+
51
+ describe('worker terminal state is truthful for reaped workers (Residual B)', () => {
52
+ it('a reaped/vanished worker (terminate, no clean finish) renders `incomplete`, never `done`', async () => {
53
+ let clock = 1000
54
+ const { feed, edits } = makeFeed(() => clock)
55
+ clock = 1000
56
+ await feed.update('w', 'chat', runningView('background job', 'doing work', 1000))
57
+ await drain()
58
+ clock = 5000
59
+ // Authoritative sweep / TTL backstop: no onFinish ever arrived.
60
+ await feed.terminate('w')
61
+ await drain()
62
+
63
+ const last = edits[edits.length - 1].text
64
+ expect(last, 'reaped worker must NOT read as done').not.toMatch(/\bdone\b/)
65
+ expect(last, 'reaped worker reads the truthful incomplete terminal').toContain('incomplete')
66
+ expect(feed.size).toBe(0)
67
+ })
68
+
69
+ it('a genuinely finished worker still renders `done` with its result', async () => {
70
+ let clock = 1000
71
+ const { feed, edits } = makeFeed(() => clock)
72
+ await feed.update('w', 'chat', runningView('background job', 'doing work', 1000))
73
+ await drain()
74
+ clock = 2000
75
+ await feed.finish('w', {
76
+ description: 'background job',
77
+ lastTool: null,
78
+ toolCount: 5,
79
+ latestSummary: 'the delivered result paragraph',
80
+ elapsedMs: 2000,
81
+ state: 'done',
82
+ })
83
+ await drain()
84
+ const last = edits[edits.length - 1].text
85
+ expect(last).toContain('done')
86
+ expect(last).toContain('the delivered result paragraph')
87
+ expect(last).not.toContain('incomplete')
88
+ })
89
+
90
+ it('renderWorkerActivity renders the `incomplete` state as a finished card without a fabricated result', () => {
91
+ const card = renderWorkerActivity({
92
+ description: 'background job',
93
+ lastTool: null,
94
+ toolCount: 3,
95
+ latestSummary: '', // reaped: no result text
96
+ elapsedMs: 4000,
97
+ state: 'incomplete',
98
+ })
99
+ expect(card).toContain('incomplete')
100
+ expect(card).not.toMatch(/\bdone\b/)
101
+ // No fabricated result paragraph (latestSummary empty) → no ✅ result block.
102
+ expect(card).not.toContain('✅')
103
+ })
104
+
105
+ it('renderWorkerActivity NEVER renders a result/⚠️ block for `incomplete`, even with a non-empty latestSummary (deterministic guard, not caller-discipline)', () => {
106
+ // Latent-trap regression guard: the live call site (terminateWorker) always
107
+ // passes latestSummary:'' for a reaped worker, but a future/direct caller
108
+ // could pass stray text. The truthful-no-result invariant must be enforced
109
+ // in the renderer — an `incomplete` worker produced no result, so it must
110
+ // never fabricate a ⚠️-prefixed result paragraph regardless of the summary.
111
+ const card = renderWorkerActivity({
112
+ description: 'background job',
113
+ lastTool: null,
114
+ toolCount: 3,
115
+ latestSummary: 'this looks like a real result but the worker never finished',
116
+ elapsedMs: 4000,
117
+ state: 'incomplete',
118
+ })
119
+ expect(card).toContain('incomplete')
120
+ // The renderer maps a non-`done` finished result to the ⚠️ emoji; the guard
121
+ // must suppress that block entirely for `incomplete`.
122
+ expect(card).not.toContain('⚠️')
123
+ expect(card).not.toContain('this looks like a real result')
124
+ })
125
+ })