switchroom 0.18.20 → 0.18.22

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 (25) hide show
  1. package/dist/cli/switchroom.js +24 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  5. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  6. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  7. package/profiles/coding/CLAUDE.md.hbs +1 -1
  8. package/telegram-plugin/dist/gateway/gateway.js +268 -61
  9. package/telegram-plugin/flushed-turn-supersede.ts +230 -0
  10. package/telegram-plugin/gateway/gateway.ts +88 -1
  11. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  12. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  13. package/telegram-plugin/subagent-watcher.ts +86 -1
  14. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +206 -0
  15. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  16. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  17. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  18. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +7 -5
  19. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  20. package/telegram-plugin/tests/turn-flush-safety.test.ts +71 -0
  21. package/telegram-plugin/tests/worker-activity-feed.test.ts +13 -8
  22. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +196 -0
  23. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +40 -0
  24. package/telegram-plugin/turn-flush-safety.ts +74 -1
  25. package/telegram-plugin/worker-activity-feed.ts +155 -45
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Unit coverage for the turnId-keyed flushed-turn supersede registry
3
+ * (2026-07 duplicate-reply fix).
4
+ *
5
+ * The regression these tests pin: the answer-ready quiescence flush (or the
6
+ * turn-end backstop) posts a turn's terminal text as a Telegram message, then
7
+ * the model's REAL `reply` tool call for the SAME turn lands ~10 s later and
8
+ * ships a SECOND message. The pre-existing `OutboundDedupCache` misses this
9
+ * because it matches on EXACT text equality — a `narration\n\nanswer` flush
10
+ * never equals the clean `answer`-only reply.
11
+ *
12
+ * Identity-only supersede (adversarial-review HIGH fix): supersede fires ONLY
13
+ * when the landing reply is positively attributable to the flushed turn by
14
+ * turnId. A reply with an UNRESOLVED turn (`liveTurnId == null`) never deletes a
15
+ * turnId-bearing record — we never delete a message we can't attribute to the
16
+ * reply's own turn. The lane holds a per-turnId map, so two concurrent turns can
17
+ * each flush a message and each turn's reply supersedes ONLY its own.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ decideSupersede,
23
+ FlushedTurnSupersedeRegistry,
24
+ DEFAULT_SUPERSEDE_TTL_MS,
25
+ type FlushedTurnRecord,
26
+ } from '../flushed-turn-supersede.js'
27
+
28
+ const rec = (over: Partial<FlushedTurnRecord> = {}): FlushedTurnRecord => ({
29
+ turnId: 'turn-A',
30
+ messageIds: [101, 102],
31
+ text: 'narration\n\nthe real answer',
32
+ ts: 1_000_000,
33
+ ...over,
34
+ })
35
+
36
+ describe('decideSupersede — the duplicate-reply decision core', () => {
37
+ it('supersedes when the reply is attributed to the SAME turn as the flush', () => {
38
+ // The common late-replay dup: the gateway resolves the reply's turnId (from
39
+ // origin_turn_id) even after currentTurn cleared, so it matches by identity.
40
+ const d = decideSupersede(rec({ turnId: 'turn-A' }), {
41
+ liveTurnId: 'turn-A',
42
+ now: 1_000_000 + 10_000,
43
+ })
44
+ expect(d.supersede).toBe(true)
45
+ expect(d.deleteMessageIds).toEqual([101, 102])
46
+ expect(d.reason).toBe('supersede')
47
+ })
48
+
49
+ it('does NOT supersede a reply belonging to a DIFFERENT turn', () => {
50
+ // A different turn is the resolved owner — it must never delete this turn's
51
+ // message. This is the guard that keeps the fix from eating a legit answer.
52
+ const d = decideSupersede(rec({ turnId: 'turn-A' }), {
53
+ liveTurnId: 'turn-B',
54
+ now: 1_000_000 + 500,
55
+ })
56
+ expect(d.supersede).toBe(false)
57
+ expect(d.deleteMessageIds).toEqual([])
58
+ expect(d.reason).toBe('different-turn')
59
+ })
60
+
61
+ it('does NOT supersede a turnId-bearing record when the reply turn is UNRESOLVED (liveTurnId == null)', () => {
62
+ // HIGH-finding core guard: a late reply we cannot attribute to a turn must
63
+ // NEVER delete a message that positively belongs to some turn. Pre-fix the
64
+ // null branch superseded ANY record — deleting a possibly-different turn's
65
+ // legitimate message.
66
+ const d = decideSupersede(rec({ turnId: 'turn-A' }), {
67
+ liveTurnId: null,
68
+ now: 1_000_000 + 10_000,
69
+ })
70
+ expect(d.supersede).toBe(false)
71
+ expect(d.reason).toBe('different-turn')
72
+ })
73
+
74
+ it('does NOT supersede once the record is past its TTL', () => {
75
+ const d = decideSupersede(rec(), {
76
+ liveTurnId: 'turn-A',
77
+ now: 1_000_000 + DEFAULT_SUPERSEDE_TTL_MS + 1,
78
+ })
79
+ expect(d.supersede).toBe(false)
80
+ expect(d.reason).toBe('expired')
81
+ })
82
+
83
+ it('returns no-record when there is nothing to supersede', () => {
84
+ const d = decideSupersede(undefined, { liveTurnId: null, now: 1_000_000 })
85
+ expect(d.supersede).toBe(false)
86
+ expect(d.reason).toBe('no-record')
87
+ })
88
+
89
+ it('a null-turnId record is superseded ONLY by an equally-unresolved (null) reply', () => {
90
+ // A synthetic/no-nonce flush: only a reply that ALSO has no resolvable turn
91
+ // matches it — it never clobbers a turn we CAN identify.
92
+ const live = decideSupersede(rec({ turnId: null }), {
93
+ liveTurnId: 'turn-Z',
94
+ now: 1_000_000,
95
+ })
96
+ expect(live.supersede).toBe(false)
97
+ expect(live.reason).toBe('different-turn')
98
+
99
+ const unresolved = decideSupersede(rec({ turnId: null }), {
100
+ liveTurnId: null,
101
+ now: 1_000_000,
102
+ })
103
+ expect(unresolved.supersede).toBe(true)
104
+ })
105
+ })
106
+
107
+ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () => {
108
+ it('records a flush and supersedes the same turn`s later reply end to end', () => {
109
+ const reg = new FlushedTurnSupersedeRegistry()
110
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [7, 8], text: 'x' }, 1000)
111
+ const d = reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 5000 })
112
+ expect(d.supersede).toBe(true)
113
+ expect(d.deleteMessageIds).toEqual([7, 8])
114
+ })
115
+
116
+ it('take() CONSUMES the matched record so a replayed reply does not double-delete', () => {
117
+ const reg = new FlushedTurnSupersedeRegistry()
118
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [7], text: 'x' }, 1000)
119
+ const first = reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 2000 })
120
+ expect(first.supersede).toBe(true)
121
+ // Replay of the same reply — the flushed message is already gone.
122
+ const second = reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 2001 })
123
+ expect(second.supersede).toBe(false)
124
+ expect(second.reason).toBe('no-record')
125
+ })
126
+
127
+ it('peek() does NOT consume — repeated peeks keep returning supersede', () => {
128
+ const reg = new FlushedTurnSupersedeRegistry()
129
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [7], text: 'x' }, 1000)
130
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 2000 }).supersede).toBe(true)
131
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 2001 }).supersede).toBe(true)
132
+ })
133
+
134
+ it('does not record a flush that posted zero messages', () => {
135
+ const reg = new FlushedTurnSupersedeRegistry()
136
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [], text: 'x' }, 1000)
137
+ expect(reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 2000 }).supersede).toBe(false)
138
+ })
139
+
140
+ it('keys per chat|thread — a flush in one thread never supersedes a reply in another', () => {
141
+ const reg = new FlushedTurnSupersedeRegistry()
142
+ reg.record('chat1', 42, { turnId: 'turn-A', messageIds: [7], text: 'x' }, 1000)
143
+ // different thread, same chat
144
+ expect(reg.take('chat1', 99, { liveTurnId: 'turn-A', now: 2000 }).supersede).toBe(false)
145
+ // correct thread supersedes
146
+ expect(reg.take('chat1', 42, { liveTurnId: 'turn-A', now: 2000 }).supersede).toBe(true)
147
+ })
148
+
149
+ // ---- HIGH finding regression: wrong-delete via a null-turn late reply. ----
150
+ describe('HIGH regression — two concurrent turns on one lane', () => {
151
+ it('turn A`s late reply supersedes ONLY A`s flush, never turn B`s legitimate message', () => {
152
+ const reg = new FlushedTurnSupersedeRegistry()
153
+ // Turn A flushes msg 100.
154
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [100], text: 'a' }, 1000)
155
+ // Turn B flushes msg 200 (B's real answer message).
156
+ reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [200], text: 'b' }, 1100)
157
+
158
+ // Turn A's real reply lands late, resolved (via origin_turn_id) to turn A.
159
+ const dA = reg.take('chat1', undefined, { liveTurnId: 'turn-A', now: 1200 })
160
+ expect(dA.supersede).toBe(true)
161
+ expect(dA.deleteMessageIds).toEqual([100]) // NOT [200] — B's message is untouched.
162
+
163
+ // B's own message is still independently supersedable by B's reply — proof
164
+ // A's reply did not consume or clobber B's record.
165
+ const dB = reg.take('chat1', undefined, { liveTurnId: 'turn-B', now: 1300 })
166
+ expect(dB.supersede).toBe(true)
167
+ expect(dB.deleteMessageIds).toEqual([200])
168
+ })
169
+
170
+ it('an UNRESOLVED (null-turn) late reply does NOT delete a different turn`s legitimate message', () => {
171
+ // This is the exact wrong-delete the review flagged. Pre-fix: single lane
172
+ // slot overwritten to {turn-B,[200]} + promiscuous null branch → the null
173
+ // reply superseded and DELETED msg 200 (B's good answer). Post-fix: a null
174
+ // liveTurnId matches no turnId-bearing record, so nothing is deleted.
175
+ const reg = new FlushedTurnSupersedeRegistry()
176
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [100], text: 'a' }, 1000)
177
+ reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [200], text: 'b' }, 1100)
178
+
179
+ const d = reg.take('chat1', undefined, { liveTurnId: null, now: 1200 })
180
+ expect(d.supersede).toBe(false)
181
+ expect(d.deleteMessageIds).toEqual([])
182
+
183
+ // Both records survive — neither turn's message was wrongly deleted.
184
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 1300 }).supersede).toBe(true)
185
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-B', now: 1300 }).supersede).toBe(true)
186
+ })
187
+ })
188
+
189
+ it('size() evicts expired records and prunes emptied lanes', () => {
190
+ const reg = new FlushedTurnSupersedeRegistry({ ttlMs: 1000 })
191
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
192
+ reg.record('chat1', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 1000)
193
+ expect(reg.size(1500)).toBe(2)
194
+ expect(reg.size(3000)).toBe(0)
195
+ })
196
+
197
+ it('record() actively sweeps expired records (LOW-2 GC — no orphan accumulation)', () => {
198
+ const reg = new FlushedTurnSupersedeRegistry({ ttlMs: 1000 })
199
+ reg.record('chat1', undefined, { turnId: 'turn-A', messageIds: [1], text: 'a' }, 1000)
200
+ // A much later flush on a DIFFERENT lane sweeps the now-expired turn-A record.
201
+ reg.record('chat2', undefined, { turnId: 'turn-B', messageIds: [2], text: 'b' }, 5000)
202
+ // Only the fresh record remains; the orphan was swept, not left to leak.
203
+ expect(reg.size(5000)).toBe(1)
204
+ expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 5000 }).reason).toBe('no-record')
205
+ })
206
+ })
@@ -267,6 +267,26 @@ describe('nested (depth-2+) worker — end-to-end visibility harness', () => {
267
267
  expect(lastChild.text).not.toContain('starting…')
268
268
  expect(lastChild.text).toContain('index.ts')
269
269
 
270
+ // #3233 — DELIBERATE orchestrator-suppression behaviour. The depth-1
271
+ // 'depth-1 orchestrator' parent ran ZERO tools of its own: it only
272
+ // DISPATCHED the nested child. The skeleton first-paint cue (#3231) fires
273
+ // on every no-growth poll, so a naive implementation gives that pure
274
+ // orchestrator its OWN persistent "starting…" worker-feed row — redundant
275
+ // clutter, because the child's row already carries the liveness. The
276
+ // watcher suppresses the skeleton cue for any entry that has dispatched a
277
+ // child (parent_agent_id linkage), so the orchestrator NEVER earns a
278
+ // "starting…" row while its child provides the live signal. This is NOT a
279
+ // "0 own tools" rule (that would re-break the 205s-blackout incident this
280
+ // PR fixes — a leaf that blocks on its FIRST tool also has 0 completed
281
+ // tools and MUST still paint); it keys strictly on the parent/child link.
282
+ const allFeed = [...h.bot.sent, ...h.bot.edits]
283
+ const orchestratorStartingRows = allFeed.filter(
284
+ (m) => m.text.includes('depth-1 orchestrator') && m.text.includes('starting…'),
285
+ )
286
+ expect(orchestratorStartingRows.length).toBe(0)
287
+ // …while the child (a genuine leaf) DID surface real, live tool activity.
288
+ expect(childMsgs.some((m) => m.text.includes('index.ts'))).toBe(true)
289
+
270
290
  // More tool activity → climbing tool count, still live.
271
291
  h.appendWorker('child01', toolUse('t2', 'Bash', { command: 'ls -la /repo' }))
272
292
  h.advance(1000)
@@ -264,6 +264,36 @@ describe('decideSubagentProgress', () => {
264
264
  if (!d.deliver) expect(d.reason).toBe('foreground')
265
265
  })
266
266
 
267
+ // #3233 — worker-feed-DISABLED legacy path: a contentless skeleton liveness
268
+ // cue must NOT be relayed as a synthesized "still working" inbound (that
269
+ // would be a blank card). It degrades to a no-op, deterministically, BEFORE
270
+ // bucketing so it can never advance the bucket tracker.
271
+ it('skeleton liveness cue is dropped (no blank card) even when every other gate would pass', () => {
272
+ // Same input that DELIVERS in the happy-path test above (bucket 1, chat
273
+ // resolves) — only `skeleton` flips it off. Empty summary mirrors the real
274
+ // skeleton cue.
275
+ const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '' }))
276
+ expect(d.deliver).toBe(false)
277
+ if (!d.deliver) expect(d.reason).toBe('skeleton-liveness')
278
+ })
279
+
280
+ it('skeleton suppression fires before bucketing — a background skeleton at bucket>=1 never delivers', () => {
281
+ // ≤1 relay per interval is trivially satisfied: skeleton cues deliver ZERO
282
+ // inbounds regardless of how many no-growth polls fire within a bucket.
283
+ for (const elapsedMs of [7 * 60 * 1000, 8 * 60 * 1000, 9 * 60 * 1000]) {
284
+ const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '', elapsedMs }))
285
+ expect(d.deliver, `elapsedMs=${elapsedMs}`).toBe(false)
286
+ }
287
+ })
288
+
289
+ it('a NON-skeleton cue with identical inputs still delivers (guard is skeleton-scoped, not summary-scoped)', () => {
290
+ // Red-on-regression companion: proves the drop keys on `skeleton`, not on
291
+ // the empty summary — a real tool-only cue (empty prose summary) still
292
+ // delivers, so the guard cannot silently swallow genuine progress.
293
+ const d = decideSubagentProgress(baseInput({ skeleton: false, latestSummary: '' }))
294
+ expect(d.deliver).toBe(true)
295
+ })
296
+
267
297
  it('falls back to owner chat when fleet chat is empty', () => {
268
298
  const d = decideSubagentProgress(baseInput({ fleetChatId: '' }))
269
299
  expect(d.deliver).toBe(true)
@@ -0,0 +1,171 @@
1
+ /**
2
+ * First-paint independence (#3231) regression.
3
+ *
4
+ * LIVE BUG (a57fbf, 2026-07-13): an async foreground sub-agent registered at
5
+ * 13:17:27, did two Bash calls, then its first tool BLOCKED for ~99s (no JSONL
6
+ * growth). Its spawning turn ended at 13:17:48 while it kept running, so there
7
+ * was neither a live parent turn to nest into NOR a worker-feed row to paint —
8
+ * and because the worker card is driven ONLY by growth-triggered onProgress
9
+ * cues, NOTHING surfaced. The card did not appear until 13:20:52, 205s after
10
+ * registration, on the next growth event that happened to route to the feed.
11
+ *
12
+ * The watcher's contract fix: a running, non-historical entry must emit a
13
+ * growth-INDEPENDENT skeleton liveness cue on every no-growth poll — an empty
14
+ * step line carrying the entry's real current state — so the gateway can paint
15
+ * (and keep alive) the card from registration onward, regardless of whether the
16
+ * worker's JSONL is currently growing. The gateway routes it: inert on the
17
+ * foreground-nest path (empty child), row-creating on the orphan/background
18
+ * worker-feed path.
19
+ *
20
+ * These assert OUTCOMES on the onProgress cue stream with an INJECTED clock.
21
+ * A skeleton cue is identified by its explicit `skeleton: true` discriminator
22
+ * (it also carries an empty `latestSummary` and no `progressLine`).
23
+ *
24
+ * RED-ON-REGRESSION: before the fix, readSubTail early-returns on a no-growth
25
+ * poll BEFORE firing any onProgress cue, so ZERO skeleton cues are emitted and
26
+ * every assertion below fails — reproducing the invisible-card window.
27
+ */
28
+
29
+ import { describe, it, expect, afterEach } from 'vitest'
30
+ import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs'
31
+ import { tmpdir } from 'os'
32
+ import { join } from 'path'
33
+ import { startSubagentWatcher } from '../subagent-watcher.js'
34
+
35
+ function buildJSONL(...lines: object[]): string {
36
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
37
+ }
38
+ function subAgentUserMsg(promptText: string) {
39
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
40
+ }
41
+ function subAgentToolUse(name: string, id: string) {
42
+ return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
43
+ }
44
+
45
+ interface Cue { agentId: string; progressLine?: string; latestSummary: string; elapsedMs: number; skeleton?: boolean }
46
+
47
+ describe('sub-agent card first-paint independence (#3231)', () => {
48
+ let tmpRoot = ''
49
+ const started: Array<ReturnType<typeof startSubagentWatcher>> = []
50
+
51
+ afterEach(() => {
52
+ while (started.length) {
53
+ try { started.pop()?.stop() } catch { /* ignore */ }
54
+ }
55
+ if (tmpRoot) {
56
+ try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
57
+ tmpRoot = ''
58
+ }
59
+ })
60
+
61
+ const RESCAN_MS = 1000
62
+
63
+ function startWatcher(agentDir: string) {
64
+ let currentTime = 100_000
65
+ const cues: Cue[] = []
66
+ const intervals: Array<{ fn: () => void; ref: number }> = []
67
+ let nextRef = 1
68
+ const watcher = startSubagentWatcher({
69
+ agentDir,
70
+ onFinish: () => {},
71
+ onProgress: ({ agentId, progressLine, latestSummary, elapsedMs, skeleton }) => {
72
+ cues.push({ agentId, progressLine, latestSummary, elapsedMs, skeleton })
73
+ },
74
+ stallThresholdMs: 600_000,
75
+ silentSynthesisStallThresholdMs: 600_000,
76
+ rescanMs: RESCAN_MS,
77
+ now: () => currentTime,
78
+ setInterval: (fn) => { const ref = nextRef++; intervals.push({ fn, ref }); return { ref } },
79
+ clearInterval: (handle) => {
80
+ const { ref } = handle as { ref: number }
81
+ const idx = intervals.findIndex((i) => i.ref === ref)
82
+ if (idx !== -1) intervals.splice(idx, 1)
83
+ },
84
+ setTimeout: () => ({ ref: nextRef++ }),
85
+ clearTimeout: () => {},
86
+ log: () => {},
87
+ })
88
+ started.push(watcher)
89
+ return {
90
+ watcher,
91
+ cues,
92
+ poll: () => intervals[0]?.fn(),
93
+ advance: (ms: number) => { currentTime += ms },
94
+ now: () => currentTime,
95
+ }
96
+ }
97
+
98
+ const skeletonCues = (cues: Cue[]): Cue[] =>
99
+ cues.filter((c) => c.skeleton === true)
100
+
101
+ function makeSubagentDir(root: string): string {
102
+ const agentDir = join(root, 'agent')
103
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
104
+ mkdirSync(subagentsDir, { recursive: true })
105
+ return agentDir
106
+ }
107
+
108
+ it('emits a growth-independent skeleton cue on the first no-growth poll after registration', () => {
109
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-firstpaint-'))
110
+ const agentDir = makeSubagentDir(tmpRoot)
111
+ const jsonlPath = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents', 'agent-deadbeef.jsonl')
112
+
113
+ // Start the watcher on an empty subagents dir, THEN the worker spawns — the
114
+ // real async Agent-tool path (the JSONL appears post-boot, so the entry is
115
+ // live/non-historical, not a boot-time rediscovery). Only its prompt is on
116
+ // disk, no assistant output yet (it is "thinking"): the pre-content window
117
+ // the user stares at.
118
+ const h = startWatcher(agentDir)
119
+ h.poll() // boot scan over the empty dir
120
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
121
+ h.advance(RESCAN_MS)
122
+ h.poll() // discover + register as a live worker
123
+
124
+ // Next poll: the JSONL has NOT grown. Pre-fix, readSubTail early-returns and
125
+ // NO cue fires — the card is invisible. Post-fix, a skeleton cue surfaces so
126
+ // the gateway can paint the card without waiting for the worker's output.
127
+ h.advance(RESCAN_MS)
128
+ h.poll()
129
+
130
+ const skel = skeletonCues(h.cues)
131
+ expect(skel.length, 'a skeleton cue must fire on a no-growth poll').toBeGreaterThanOrEqual(1)
132
+ // Tight bound vs the ~205s live behaviour: first cue is within a couple polls
133
+ // of registration, NOT minutes.
134
+ expect(skel[0].elapsedMs).toBeLessThanOrEqual(2 * RESCAN_MS)
135
+ })
136
+
137
+ it('keeps surfacing skeleton cues while a worker is silent after an early tool burst (blocked first tool)', () => {
138
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-firstpaint-silent-'))
139
+ const agentDir = makeSubagentDir(tmpRoot)
140
+ const jsonlPath = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents', 'agent-a57fbf00.jsonl')
141
+
142
+ const h = startWatcher(agentDir)
143
+ h.poll() // boot scan over the empty dir
144
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Debug delegation regression')))
145
+ h.advance(RESCAN_MS)
146
+ h.poll() // discover + register as a live worker
147
+
148
+ // The worker does two quick Bash calls (the a57fbf shape) …
149
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
150
+ h.advance(RESCAN_MS)
151
+ h.poll()
152
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b2')))
153
+ h.advance(RESCAN_MS)
154
+ h.poll()
155
+
156
+ // … then its tool BLOCKS: no JSONL growth for a long stretch (~30 polls).
157
+ // The card must NOT go dark — a skeleton cue must fire on essentially every
158
+ // no-growth poll so the feed row is created/kept-alive and the heartbeat can
159
+ // climb the elapsed. Pre-fix, zero cues fire across the entire silent window.
160
+ const before = h.cues.length
161
+ for (let i = 0; i < 30; i++) {
162
+ h.advance(RESCAN_MS)
163
+ h.poll()
164
+ }
165
+ const duringSilence = h.cues.slice(before)
166
+ const skel = duringSilence.filter((c) => c.skeleton === true)
167
+ expect(skel.length, 'silent worker must keep emitting skeleton cues').toBeGreaterThanOrEqual(20)
168
+ // No skeleton cue ever fabricates content — the step line stays empty.
169
+ for (const c of skel) expect(c.latestSummary).toBe('')
170
+ })
171
+ })
@@ -39,7 +39,7 @@ function subAgentToolUse(name: string, id: string) {
39
39
  return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
40
40
  }
41
41
 
42
- interface Cue { progressLine?: string; latestSummary: string }
42
+ interface Cue { progressLine?: string; latestSummary: string; skeleton?: boolean }
43
43
 
44
44
  describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
45
45
  let tmpRoot = ''
@@ -64,8 +64,8 @@ describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
64
64
  const watcher = startSubagentWatcher({
65
65
  agentDir,
66
66
  onFinish: () => {},
67
- onProgress: ({ agentId, progressLine, latestSummary }) => {
68
- cues.push({ agentId, progressLine, latestSummary })
67
+ onProgress: ({ agentId, progressLine, latestSummary, skeleton }) => {
68
+ cues.push({ agentId, progressLine, latestSummary, skeleton })
69
69
  },
70
70
  stallThresholdMs: 600_000,
71
71
  silentSynthesisStallThresholdMs: 600_000,
@@ -91,8 +91,10 @@ describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
91
91
  }
92
92
 
93
93
  function narrativeCues(cues: Cue[]): string[] {
94
- // Narrative cues carry NO progressLine; tool-label cues do.
95
- return cues.filter((c) => c.progressLine == null).map((c) => c.latestSummary)
94
+ // Narrative cues carry NO progressLine; tool-label cues do. Skeleton
95
+ // liveness cues (#3231) also carry no progressLine but an empty summary —
96
+ // exclude them here so this counts only real narrative content.
97
+ return cues.filter((c) => !c.skeleton && c.progressLine == null).map((c) => c.latestSummary)
96
98
  }
97
99
 
98
100
  it('paints a parked opening narration after the flush window with NO tool event', () => {
@@ -584,9 +584,10 @@ describe('startSubagentWatcher', () => {
584
584
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
585
585
  const h = startWatcherSync({
586
586
  agentDir,
587
- onProgress: ({ progressLine, latestSummary }) => {
587
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
588
588
  // Narrative ticks carry NO progressLine (tool ticks do); record them.
589
- if (progressLine == null) narrativeCues.push(latestSummary)
589
+ // Skeleton liveness cues (#3231) are not narrative — exclude them.
590
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
590
591
  },
591
592
  })
592
593
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo path')))
@@ -614,8 +615,8 @@ describe('startSubagentWatcher', () => {
614
615
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
615
616
  const h = startWatcherSync({
616
617
  agentDir,
617
- onProgress: ({ progressLine, latestSummary }) => {
618
- if (progressLine == null) narrativeCues.push(latestSummary)
618
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
619
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
619
620
  },
620
621
  })
621
622
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
@@ -647,8 +648,8 @@ describe('startSubagentWatcher', () => {
647
648
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
648
649
  const h = startWatcherSync({
649
650
  agentDir,
650
- onProgress: ({ progressLine, latestSummary }) => {
651
- allCues.push({ progressLine, latestSummary })
651
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
652
+ if (!skeleton) allCues.push({ progressLine, latestSummary })
652
653
  },
653
654
  })
654
655
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
@@ -698,8 +699,8 @@ describe('startSubagentWatcher', () => {
698
699
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
699
700
  const h = startWatcherSync({
700
701
  agentDir,
701
- onProgress: ({ progressLine, latestSummary }) => {
702
- if (progressLine == null) narrativeCues.push(latestSummary)
702
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
703
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
703
704
  },
704
705
  })
705
706
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
@@ -728,8 +729,8 @@ describe('startSubagentWatcher', () => {
728
729
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
729
730
  const h = startWatcherSync({
730
731
  agentDir,
731
- onProgress: ({ progressLine, latestSummary }) => {
732
- if (progressLine == null) narrativeCues.push(latestSummary)
732
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
733
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
733
734
  },
734
735
  })
735
736
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
@@ -761,8 +762,8 @@ describe('startSubagentWatcher', () => {
761
762
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
762
763
  const h = startWatcherSync({
763
764
  agentDir,
764
- onProgress: ({ progressLine, latestSummary }) => {
765
- if (progressLine == null) narrativeCues.push(latestSummary)
765
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
766
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
766
767
  },
767
768
  })
768
769
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
@@ -21,6 +21,8 @@ import {
21
21
  isCompositeSilentNoise,
22
22
  endsWithSilentMarker,
23
23
  isTurnFlushSafetyEnabled,
24
+ selectFlushDeliveryText,
25
+ FLUSH_SUBSTANTIVE_MIN_CHARS,
24
26
  } from '../turn-flush-safety.js'
25
27
  // Rich-message send-path primitives (Bot API 10.1, #2669/#2692). The #2798
26
28
  // regression suite below reconstructs the exact gateway turn-flush render
@@ -571,3 +573,72 @@ describe('isTurnFlushSafetyEnabled', () => {
571
573
  }
572
574
  })
573
575
  })
576
+
577
+ describe('selectFlushDeliveryText — deliver the terminal answer, strip only narration', () => {
578
+ const answer = 'A'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 20)
579
+
580
+ it('delivers ONLY the terminal answer when narration precedes the answer', () => {
581
+ // The duplicate-reply root cause: the flush fired while the model was still
582
+ // composing its reply, so capturedText held intent-narration blocks THEN
583
+ // the composed answer. Pre-fix the flush dumped the whole blob; post-fix it
584
+ // delivers only the answer. This test FAILS on the pre-fix `join('\n\n')`.
585
+ const blocks = ["Let me check that.", "I'll look it up now.", answer]
586
+ const out = selectFlushDeliveryText(blocks)
587
+ expect(out).toBe(answer)
588
+ expect(out).not.toContain('Let me check')
589
+ expect(out).not.toContain("I'll look it up")
590
+ })
591
+
592
+ // MEDIUM finding (adversarial review): a LONG (>=200) narration block FOLLOWED
593
+ // by a SHORT (<200) real answer. The pre-fix `find last block >= 200` returned
594
+ // the narration and DROPPED the short real answer. The terminal block is the
595
+ // answer regardless of length. FAILS on the pre-fix threshold scan.
596
+ it('delivers a SHORT real answer that follows a LONG (>=200) narration block', () => {
597
+ const verboseNarration =
598
+ 'Let me pull the numbers together before I answer — ' +
599
+ 'X'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS)
600
+ const realAnswer = 'Revenue was 4.2M, up 12% year over year.' // < 200 chars
601
+ expect(verboseNarration.length).toBeGreaterThanOrEqual(FLUSH_SUBSTANTIVE_MIN_CHARS)
602
+ expect(realAnswer.length).toBeLessThan(FLUSH_SUBSTANTIVE_MIN_CHARS)
603
+ const out = selectFlushDeliveryText([verboseNarration, realAnswer])
604
+ expect(out).toBe(realAnswer)
605
+ expect(out).not.toContain('Let me pull the numbers')
606
+ })
607
+
608
+ it('keeps the FULL joined text when an earlier block is real content, never truncating to the last paragraph', () => {
609
+ // Two substantive blocks, neither an intent-narration opener: this is a
610
+ // genuine multi-paragraph answer written as several blocks. Delivering only
611
+ // the last paragraph would drop real content — keep the whole thing.
612
+ const first = 'B'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
613
+ const last = 'C'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
614
+ expect(selectFlushDeliveryText([first, last])).toBe(`${first}\n\n${last}`)
615
+ })
616
+
617
+ it('keeps short two-paragraph answers joined (short legit answer preserved)', () => {
618
+ const blocks = ['First short paragraph.', 'Second short paragraph.']
619
+ expect(selectFlushDeliveryText(blocks)).toBe('First short paragraph.\n\nSecond short paragraph.')
620
+ })
621
+
622
+ it('a single block is delivered verbatim regardless of length', () => {
623
+ expect(selectFlushDeliveryText(['short answer'])).toBe('short answer')
624
+ expect(selectFlushDeliveryText([answer])).toBe(answer)
625
+ })
626
+
627
+ it('trims and drops empty blocks', () => {
628
+ expect(selectFlushDeliveryText([' ', '', ' hi '])).toBe('hi')
629
+ expect(selectFlushDeliveryText([])).toBe('')
630
+ })
631
+
632
+ it('decideTurnFlush delivers the narrowed answer, not the whole blob', () => {
633
+ const decision = decideTurnFlush({
634
+ chatId: 'chat1',
635
+ replyCalled: false,
636
+ capturedText: ['Let me check.', 'Now let me compose the reply.', answer],
637
+ })
638
+ expect(decision.kind).toBe('flush')
639
+ if (decision.kind === 'flush') {
640
+ expect(decision.text).toBe(answer)
641
+ expect(decision.text).not.toContain('Let me check')
642
+ }
643
+ })
644
+ })