switchroom 0.18.19 → 0.18.21
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/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
computeTurnStatus,
|
|
5
|
+
backstopSendOutcome,
|
|
6
|
+
finalizeBackstopSend,
|
|
7
|
+
buildTurnRecord,
|
|
8
|
+
type DeliveryOutcome,
|
|
9
|
+
} from '../gateway/turn-record-status.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* PR B — send-honesty. The turns.jsonl `status` must reflect the REAL send
|
|
13
|
+
* outcome, not the speculative `finalAnswerDelivered` flag the turn-flush
|
|
14
|
+
* backstop sets before its async send runs.
|
|
15
|
+
*
|
|
16
|
+
* These assert the recorded status OUTCOME for each turn shape — the exact
|
|
17
|
+
* string `emitTurnRecord` writes — not merely that a code path ran.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
describe('computeTurnStatus — recorded turn status reflects real outcome', () => {
|
|
21
|
+
it('genuine no-reply turn → no_reply', () => {
|
|
22
|
+
expect(computeTurnStatus({ finalAnswerDelivered: false })).toBe('no_reply')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('synchronous reply-tool delivery (no deliveryOutcome) → complete', () => {
|
|
26
|
+
expect(computeTurnStatus({ finalAnswerDelivered: true })).toBe('complete')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('reply-tool short-circuit suppressed the flush → complete (reply delivered)', () => {
|
|
30
|
+
expect(
|
|
31
|
+
computeTurnStatus({ finalAnswerDelivered: true, deliveryOutcome: 'suppressed' }),
|
|
32
|
+
).toBe('complete')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('fail-safe: undefined outcome + finalAnswerDelivered false never fabricates complete', () => {
|
|
36
|
+
expect(computeTurnStatus({ finalAnswerDelivered: false, deliveryOutcome: undefined })).toBe(
|
|
37
|
+
'no_reply',
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('backstopSendOutcome — resolve outcome from what happened on the wire', () => {
|
|
43
|
+
it('throw → failed', () => {
|
|
44
|
+
expect(backstopSendOutcome({ threw: true, sentCount: 0, chunkCount: 1 })).toBe('failed')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('partial (no throw, short delivery) → failed', () => {
|
|
48
|
+
expect(backstopSendOutcome({ threw: false, sentCount: 1, chunkCount: 2 })).toBe('failed')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('full delivery → delivered', () => {
|
|
52
|
+
expect(backstopSendOutcome({ threw: false, sentCount: 3, chunkCount: 3 })).toBe('delivered')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('Fix 5 — empty split (0 chunks, no throw) → failed, not delivered', () => {
|
|
56
|
+
expect(backstopSendOutcome({ threw: false, sentCount: 0, chunkCount: 0 })).toBe('failed')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fix 3 — WIRING integration. These drive the SAME seams the gateway
|
|
62
|
+
* turn-flush IIFE runs — `finalizeBackstopSend` (the stamp) feeding
|
|
63
|
+
* `buildTurnRecord` (which `emitTurnRecord` serializes verbatim) — and assert
|
|
64
|
+
* the RECORDED status string. If the accounting stamps the wrong branch, or the
|
|
65
|
+
* record builder ever reverted to the speculative `finalAnswerDelivered`
|
|
66
|
+
* ternary, these fail — not just if the pure predicate is wrong.
|
|
67
|
+
*/
|
|
68
|
+
describe('turn-flush wiring → recorded turns.jsonl status', () => {
|
|
69
|
+
const ENDED_AT = 1_700_000_500_000
|
|
70
|
+
const mkTurn = () => ({
|
|
71
|
+
agent: 'test-agent',
|
|
72
|
+
startedAt: ENDED_AT - 5_000,
|
|
73
|
+
toolCallCount: 0,
|
|
74
|
+
turnId: 'turn-abc',
|
|
75
|
+
// speculatively set at gateway.ts flush site BEFORE the send runs:
|
|
76
|
+
finalAnswerDelivered: true,
|
|
77
|
+
deliveryOutcome: undefined as DeliveryOutcome | undefined,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const recordAfterSend = (send: { threw: boolean; sentCount: number; chunkCount: number }) => {
|
|
81
|
+
const turn = mkTurn()
|
|
82
|
+
finalizeBackstopSend(turn, send) // mutates turn.deliveryOutcome — as the IIFE does
|
|
83
|
+
return buildTurnRecord(turn, ENDED_AT)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
it('send SUCCEEDS (all chunks delivered) → complete', () => {
|
|
87
|
+
expect(recordAfterSend({ threw: false, sentCount: 2, chunkCount: 2 }).status).toBe('complete')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('send THROWS (simulated FLOOD_WAIT_ACTIVE) → send_failed, never complete', () => {
|
|
91
|
+
// BUG ORACLE: pre-fix, `finalAnswerDelivered=true` was written BEFORE the
|
|
92
|
+
// send ran and the record read that flag → 'complete' even though the send
|
|
93
|
+
// threw and the user got nothing. Assert the wired outcome is honest.
|
|
94
|
+
const rec = recordAfterSend({ threw: true, sentCount: 0, chunkCount: 1 })
|
|
95
|
+
expect(rec.status).toBe('send_failed')
|
|
96
|
+
expect(rec.status).not.toBe('complete')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('PARTIAL multi-chunk (chunk 1 ok, chunk 2 throws) → send_failed', () => {
|
|
100
|
+
expect(recordAfterSend({ threw: true, sentCount: 1, chunkCount: 3 }).status).toBe('send_failed')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('reply-tool suppressed the flush → complete (reply delivered), via the stamp', () => {
|
|
104
|
+
const turn = mkTurn()
|
|
105
|
+
turn.deliveryOutcome = 'suppressed' // the IIFE's suppressed-branch stamp
|
|
106
|
+
expect(buildTurnRecord(turn, ENDED_AT).status).toBe('complete')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('record carries the honest tuple (tools + duration) alongside status', () => {
|
|
110
|
+
const rec = recordAfterSend({ threw: true, sentCount: 0, chunkCount: 1 })
|
|
111
|
+
expect(rec).toMatchObject({
|
|
112
|
+
status: 'send_failed',
|
|
113
|
+
tools: 0,
|
|
114
|
+
duration_ms: 5_000,
|
|
115
|
+
turn_id: 'turn-abc',
|
|
116
|
+
agent: 'test-agent',
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -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
|
+
})
|