switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Boot-reconcile perf regression: at boot the subagent-watcher must NOT do the
3
+ * full initial `readSubTail` (a whole-transcript parse + per-record sqlite
4
+ * liveness/backfill round-trips) for provably-dead prior-session workers.
5
+ *
6
+ * Production incident (2026-07-19): on a busy 24/7 agent ~1177 dead `running`
7
+ * JSONLs (last written days-to-weeks ago) each got the full register → whole-
8
+ * file read → liveness/backfill → staleness → FSWatcher-decision treatment on
9
+ * every boot. On a slow disk that O(filesize) work delayed the gateway
10
+ * answering Telegram by ~7 minutes after each restart, so the agent looked
11
+ * dead. The staleness verdict (`last write > inflightPromoteMaxAgeMs`) was
12
+ * already reached, but only AFTER the expensive read.
13
+ *
14
+ * The fix replaces the full read for a stale-by-mtime historical entry with a
15
+ * BOUNDED TAIL PROBE. Outcomes asserted here (not code paths):
16
+ * 1. running-at-boot-stale (no turn_end): registered historical, NO watcher,
17
+ * NOT swept (nothing terminal to sweep), and the disk read is bounded to
18
+ * the tail window even for a huge file (never a byte-0 whole-transcript
19
+ * read).
20
+ * 2. done-at-boot-stale (turn_end present): still reaches terminal cleanup —
21
+ * `onTerminalCleanup` fires after the grace window so the worker-feed row
22
+ * is swept (the regression the coordinator caught: a done-at-boot orphan
23
+ * must NOT be silently left as a ghost feed row).
24
+ * 3. fresh in-flight-at-boot: takes the full path — its JSONL IS read from
25
+ * byte 0 and it DOES get a per-file FSWatcher (no over-pruning).
26
+ */
27
+
28
+ import { describe, it, expect, vi } from 'vitest'
29
+ import type * as fs from 'fs'
30
+ import { startSubagentWatcher } from '../subagent-watcher.js'
31
+
32
+ interface FakeWatcher { path: string; close: ReturnType<typeof vi.fn> }
33
+ interface ReadCall { position: number; length: number }
34
+
35
+ const AGENT_DIR = '/home/user/.switchroom/agents/myagent'
36
+ const PROJECTS = `${AGENT_DIR}/.claude/projects`
37
+ const PROJECT = `${PROJECTS}/proj`
38
+ const SESSION = `${PROJECT}/sess`
39
+ const SUBAGENTS = `${SESSION}/subagents`
40
+ const NOW = 100_000_000
41
+
42
+ const turnEndLine = () => JSON.stringify({ type: 'system', subtype: 'turn_duration', duration_ms: 100 })
43
+ const toolUseLine = () =>
44
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Read', input: {} }] } })
45
+
46
+ /**
47
+ * Harness with per-path read tracking (position + length of every readSync, the
48
+ * proxy for "how much of the transcript did boot read") and FSWatcher tracking.
49
+ * `reportedSize` can far exceed the synthetic tail content, so a test can assert
50
+ * that only the tail window — not the whole (huge) file — was read.
51
+ */
52
+ function makeHarness(opts: {
53
+ fileName: string
54
+ ageMs: number
55
+ reportedSize: number
56
+ /** Synthetic bytes returned when the file is read (represents its tail). */
57
+ tail: Buffer
58
+ }) {
59
+ const jsonl = `${SUBAGENTS}/${opts.fileName}`
60
+ const reads: ReadCall[] = []
61
+ const watchers: FakeWatcher[] = []
62
+ const timeouts: Array<{ fn: () => void; fireAt: number; ref: number }> = []
63
+ const terminalCleanups: string[] = []
64
+ let currentTime = NOW
65
+ let nextRef = 1
66
+
67
+ const mockFs = {
68
+ existsSync: ((p: fs.PathLike) => {
69
+ const ps = String(p)
70
+ return ps === PROJECTS || ps === PROJECT || ps === SESSION || ps === SUBAGENTS || ps === jsonl
71
+ }) as typeof fs.existsSync,
72
+ readdirSync: ((p: fs.PathLike) => {
73
+ const ps = String(p)
74
+ if (ps === PROJECTS) return ['proj']
75
+ if (ps === PROJECT) return ['sess']
76
+ if (ps === SESSION) return ['subagents']
77
+ if (ps === SUBAGENTS) return [opts.fileName]
78
+ return []
79
+ }) as unknown as typeof fs.readdirSync,
80
+ statSync: ((p: fs.PathLike) => {
81
+ if (String(p) !== jsonl) return { size: 0, mtimeMs: currentTime } as fs.Stats
82
+ return { size: opts.reportedSize, mtimeMs: currentTime - opts.ageMs } as fs.Stats
83
+ }) as typeof fs.statSync,
84
+ openSync: (() => 7) as unknown as typeof fs.openSync,
85
+ closeSync: (() => undefined) as typeof fs.closeSync,
86
+ readSync: ((
87
+ _fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
88
+ ): number => {
89
+ reads.push({ position: position ?? 0, length })
90
+ const src = opts.tail.subarray(0, Math.min(length, opts.tail.length))
91
+ src.copy(buf as Buffer, offset)
92
+ return src.length
93
+ }) as unknown as typeof fs.readSync,
94
+ watch: ((p: fs.PathLike) => {
95
+ const w: FakeWatcher = { path: String(p), close: vi.fn() }
96
+ watchers.push(w)
97
+ return w as unknown as fs.FSWatcher
98
+ }) as unknown as typeof fs.watch,
99
+ }
100
+
101
+ const watcher = startSubagentWatcher({
102
+ agentDir: AGENT_DIR,
103
+ fs: mockFs,
104
+ now: () => currentTime,
105
+ setInterval: () => ({ ref: 0 }),
106
+ clearInterval: () => {},
107
+ setTimeout: (fn: () => void, ms: number) => {
108
+ const ref = nextRef++
109
+ timeouts.push({ fn, fireAt: currentTime + ms, ref })
110
+ return { ref }
111
+ },
112
+ clearTimeout: (handle) => {
113
+ const { ref } = handle as { ref: number }
114
+ const idx = timeouts.findIndex((t) => t.ref === ref)
115
+ if (idx !== -1) timeouts.splice(idx, 1)
116
+ },
117
+ onTerminalCleanup: (agentId: string) => { terminalCleanups.push(agentId) },
118
+ })
119
+
120
+ const advance = (ms: number): void => {
121
+ currentTime += ms
122
+ for (;;) {
123
+ timeouts.sort((a, b) => a.fireAt - b.fireAt)
124
+ const next = timeouts[0]
125
+ if (!next || next.fireAt > currentTime) break
126
+ timeouts.shift()
127
+ next.fn()
128
+ }
129
+ }
130
+
131
+ return {
132
+ watcher,
133
+ reads,
134
+ advance,
135
+ terminalCleanups,
136
+ jsonl,
137
+ fileWatchers: () => watchers.filter((w) => w.path === jsonl),
138
+ }
139
+ }
140
+
141
+ describe('subagent-watcher boot reconcile: skip full read for dead prior-session workers', () => {
142
+ it('running-at-boot-stale: historical, no watcher, bounded tail read (not whole transcript), not swept', () => {
143
+ const agentId = 'deadrunning'
144
+ // A huge (50 MB) JSONL last written 3 days ago whose tail has NO turn_end.
145
+ const tail = Buffer.from(`${toolUseLine()}\n${toolUseLine()}\n${toolUseLine()}\n`, 'utf-8')
146
+ const h = makeHarness({
147
+ fileName: `agent-${agentId}.jsonl`,
148
+ ageMs: 3 * 24 * 3600_000,
149
+ reportedSize: 50_000_000,
150
+ tail,
151
+ })
152
+
153
+ const entry = h.watcher.getRegistry().get(agentId)
154
+ expect(entry?.historical).toBe(true)
155
+ expect(entry?.state).toBe('running') // never promoted, never done
156
+ expect(h.fileWatchers()).toHaveLength(0) // no FD leak for a dead worker
157
+ expect(h.terminalCleanups).not.toContain(agentId) // nothing terminal to sweep
158
+
159
+ // The disk read was BOUNDED to the tail window — the 50 MB file was NEVER
160
+ // read from byte 0 (that whole-transcript read is the ~7-min boot cost).
161
+ expect(h.reads.length).toBeGreaterThan(0)
162
+ for (const r of h.reads) {
163
+ expect(r.position).toBeGreaterThan(0) // tail offset, not byte 0
164
+ expect(r.length).toBeLessThanOrEqual(512 * 1024)
165
+ }
166
+
167
+ h.watcher.stop()
168
+ })
169
+
170
+ it('done-at-boot-stale: still reaches terminal cleanup (onTerminalCleanup fires → feed row swept)', () => {
171
+ const agentId = 'deaddone'
172
+ // A stale JSONL whose tail ends in a turn_duration line → done at boot.
173
+ const tail = Buffer.from(`${toolUseLine()}\n${turnEndLine()}\n`, 'utf-8')
174
+ const h = makeHarness({
175
+ fileName: `agent-${agentId}.jsonl`,
176
+ ageMs: 3 * 24 * 3600_000,
177
+ reportedSize: 50_000_000,
178
+ tail,
179
+ })
180
+
181
+ const entry = h.watcher.getRegistry().get(agentId)
182
+ expect(entry?.historical).toBe(true)
183
+ expect(entry?.state).toBe('done') // detected terminal via the probe
184
+ expect(h.fileWatchers()).toHaveLength(0) // no watcher for a done-at-boot worker
185
+ expect(h.terminalCleanups).toHaveLength(0) // not yet — waits out the grace window
186
+
187
+ // Fire the scheduled terminal cleanup (30s grace) → onTerminalCleanup so the
188
+ // gateway sweeps the ghost feed row. THIS is the regression guard.
189
+ h.advance(30_000)
190
+ expect(h.terminalCleanups).toContain(agentId)
191
+
192
+ // Still bounded — the probe read only the tail, never the whole 50 MB file.
193
+ for (const r of h.reads) {
194
+ expect(r.position).toBeGreaterThan(0)
195
+ expect(r.length).toBeLessThanOrEqual(512 * 1024)
196
+ }
197
+
198
+ h.watcher.stop()
199
+ })
200
+
201
+ it('fresh in-flight-at-boot: full path — read from byte 0 and given an FSWatcher (no over-pruning)', () => {
202
+ const agentId = 'liveone'
203
+ const tail = Buffer.from(`${toolUseLine()}\n`, 'utf-8')
204
+ const h = makeHarness({
205
+ fileName: `agent-${agentId}.jsonl`,
206
+ ageMs: 30_000, // 30s old — inside the freshness window
207
+ reportedSize: tail.length,
208
+ tail,
209
+ })
210
+
211
+ // Full path: the JSONL is read from byte 0 (the whole transcript) and a
212
+ // per-file FSWatcher is opened so live transitions are observed.
213
+ expect(h.reads.some((r) => r.position === 0)).toBe(true)
214
+ expect(h.fileWatchers()).toHaveLength(1)
215
+
216
+ h.watcher.stop()
217
+ })
218
+ })
@@ -59,6 +59,7 @@ function makeHarness(opts: { agentId?: string } = {}) {
59
59
  const progressCalls: ProgressCall[] = []
60
60
  const finishCalls: Array<{ agentId: string; outcome: string }> = []
61
61
  const terminalCleanupCalls: string[] = []
62
+ const resumeCalls: Array<{ agentId: string; description: string }> = []
62
63
  const logs: string[] = []
63
64
 
64
65
  const agentDir = '/home/user/.switchroom/agents/myagent'
@@ -133,6 +134,7 @@ function makeHarness(opts: { agentId?: string } = {}) {
133
134
  progressCalls.push({ agentId: id, skeleton: skeleton === true, progressLine, toolCount }),
134
135
  onFinish: ({ agentId: id, outcome }) => finishCalls.push({ agentId: id, outcome }),
135
136
  onTerminalCleanup: (id) => terminalCleanupCalls.push(id),
137
+ onResume: (id, description) => resumeCalls.push({ agentId: id, description }),
136
138
  now: () => currentTime,
137
139
  setInterval: (fn, ms) => {
138
140
  const ref = nextRef++
@@ -191,6 +193,7 @@ function makeHarness(opts: { agentId?: string } = {}) {
191
193
  progressCalls,
192
194
  finishCalls,
193
195
  terminalCleanupCalls,
196
+ resumeCalls,
194
197
  logs,
195
198
  advance,
196
199
  watcher,
@@ -240,6 +243,13 @@ describe('subagent-watcher resume re-registration (issue #3315)', () => {
240
243
  expect(revived!.historical).toBe(false)
241
244
  expect(h.logs.some((l) => l.includes('resumed after terminal cleanup') && l.includes(agentId))).toBe(true)
242
245
 
246
+ // Issue #3373: onResume fired exactly once for the resumed worker, BEFORE
247
+ // registration re-fires onProgress — this is what clears the feed's terminal
248
+ // `finalized` latch so the resumed cues re-surface a card instead of being
249
+ // swallowed. (The feed-side re-surface behaviour is covered in
250
+ // worker-activity-feed.test.ts.)
251
+ expect(h.resumeCalls.filter((c) => c.agentId === agentId)).toHaveLength(1)
252
+
243
253
  // Progress cues resume (the card updates again) — the user-visible fix.
244
254
  expect(h.progressCalls.length).toBeGreaterThan(progressBeforeResume)
245
255
  expect(h.progressCalls.slice(progressBeforeResume).some((c) => c.agentId === agentId)).toBe(true)
@@ -285,6 +295,10 @@ describe('subagent-watcher resume re-registration (issue #3315)', () => {
285
295
  expect(h.progressCalls).toHaveLength(progressAfterCleanup) // no card churn
286
296
  // Cleanup fired exactly once — no leaked/duplicated terminal sweep.
287
297
  expect(h.terminalCleanupCalls.filter((id) => id === agentId)).toHaveLength(1)
298
+ // Issue #3373 anti-zombie: a genuinely-finished worker that never grew past
299
+ // its terminal boundary must NEVER fire onResume — otherwise the feed's
300
+ // finalized latch would be cleared and a dead worker could zombie-resurface.
301
+ expect(h.resumeCalls.filter((c) => c.agentId === agentId)).toHaveLength(0)
288
302
 
289
303
  h.watcher.stop()
290
304
  })
@@ -647,6 +647,36 @@ describe('startSubagentWatcher', () => {
647
647
  h.poll()
648
648
  // The draft is staged, not yet resolved — no cue yet.
649
649
  // The very next event is a reply tool with matching text → SUPPRESS.
650
+ // #3231: feed the PREFIXED prod wire shape (mcp__…__stream_reply), the
651
+ // form real jsonl actually carries. Before the isReplyTool fix a bare
652
+ // REPLY_TOOLS.has() missed this and the draft was WRONGLY surfaced.
653
+ appendFileSync(jsonlPath, buildJSONL({
654
+ type: 'assistant',
655
+ message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
656
+ }))
657
+ h.poll()
658
+ expect(narrativeCues.length).toBe(0)
659
+ })
660
+
661
+ it('narrative gate: a draft-then-reply sub_agent_text is SUPPRESSED with a BARE reply tool name too (#3231)', () => {
662
+ // Symmetric with the prefixed case above: bare 'stream_reply' can still
663
+ // arrive from non-MCP sources, so isReplyTool must match it as well.
664
+ const narrativeCues: string[] = []
665
+ const agentDir = join(tmpRoot, 'agent')
666
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
667
+ mkdirSync(subagentsDir, { recursive: true })
668
+ const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
669
+ const h = startWatcherSync({
670
+ agentDir,
671
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
672
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
673
+ },
674
+ })
675
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo path')))
676
+ h.poll()
677
+ const answer = 'The repo is at /home/user/code/switchroom.'
678
+ appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText(answer)))
679
+ h.poll()
650
680
  appendFileSync(jsonlPath, buildJSONL({
651
681
  type: 'assistant',
652
682
  message: { content: [{ type: 'tool_use', name: 'stream_reply', id: 'r1', input: { text: answer } }] },
@@ -786,10 +816,11 @@ describe('startSubagentWatcher', () => {
786
816
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
787
817
  h.poll()
788
818
  const answer = 'The fix touches three files and adds a unit test for the double-Done case.'
789
- // Final tool of the turn is stream_reply carrying the answer.
819
+ // Final tool of the turn is stream_reply carrying the answer. #3231: use
820
+ // the PREFIXED prod wire shape so lastReplyText capture (isReplyTool) fires.
790
821
  appendFileSync(jsonlPath, buildJSONL({
791
822
  type: 'assistant',
792
- message: { content: [{ type: 'tool_use', name: 'stream_reply', id: 'r1', input: { text: answer } }] },
823
+ message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
793
824
  }))
794
825
  h.poll()
795
826
  // Trailing text block (separate message) that drafts the delivered answer.
@@ -819,9 +850,10 @@ describe('startSubagentWatcher', () => {
819
850
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
820
851
  h.poll()
821
852
  const answer = 'The fix touches three files and adds a unit test for the double-Done case.'
853
+ // #3231: prefixed prod wire shape.
822
854
  appendFileSync(jsonlPath, buildJSONL({
823
855
  type: 'assistant',
824
- message: { content: [{ type: 'tool_use', name: 'stream_reply', id: 'r1', input: { text: answer } }] },
856
+ message: { content: [{ type: 'tool_use', name: 'mcp__switchroom-telegram__stream_reply', id: 'r1', input: { text: answer } }] },
825
857
  }))
826
858
  h.poll()
827
859
  appendFileSync(jsonlPath, buildJSONL(subAgentAssistantText('Done — cleaning up the worktree now.')))
@@ -261,6 +261,19 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
261
261
  new URL('../gateway/gateway.ts', import.meta.url),
262
262
  'utf8',
263
263
  )
264
+ // #2996 P4-A: the turn_flush backstop (redact → normalize → scrub on
265
+ // `capturedText`) moved VERBATIM into stream-render.ts with
266
+ // handleSessionEvent. The reply path's identical `...(text))` wrapper lives
267
+ // in outbound-send-path.ts (`normalizeOutboundBody`, #2996 P2). Parity is
268
+ // pinned across the two extracted homes.
269
+ const streamSrc = readFileSync(
270
+ new URL('../gateway/stream-render.ts', import.meta.url),
271
+ 'utf8',
272
+ )
273
+ const replyModuleSrc = readFileSync(
274
+ new URL('../gateway/outbound-send-path.ts', import.meta.url),
275
+ 'utf8',
276
+ )
264
277
 
265
278
  it('reply path: normalizes AFTER redact and BEFORE the voice scrub', () => {
266
279
  // #2996: the reply-path entry pipeline moved into outbound-send-path.ts
@@ -290,13 +303,13 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
290
303
  })
291
304
 
292
305
  it('turn-flush backstop: applies the SAME normalization in the SAME slot (REDS if the line is removed)', () => {
293
- const redactIdx = gatewaySrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
306
+ const redactIdx = streamSrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
294
307
  // The normalization call the reply path uses, verbatim, on the turn_flush
295
308
  // variable. This indexOf is what returns -1 (→ assertion fails) if the
296
309
  // `stripExcessBold(normalizePunctuation(capturedText))` line is deleted
297
310
  // from the turn_flush branch.
298
- const normIdx = gatewaySrc.indexOf('stripExcessBold(normalizePunctuation(capturedText))', redactIdx)
299
- const scrubIdx = gatewaySrc.indexOf('scrubVoice(capturedText)', redactIdx)
311
+ const normIdx = streamSrc.indexOf('stripExcessBold(normalizePunctuation(capturedText))', redactIdx)
312
+ const scrubIdx = streamSrc.indexOf('scrubVoice(capturedText)', redactIdx)
300
313
  expect(redactIdx).toBeGreaterThan(0)
301
314
  expect(normIdx).toBeGreaterThan(redactIdx) // normalize AFTER the turn_flush redact
302
315
  expect(scrubIdx).toBeGreaterThan(normIdx) // ...and BEFORE the voice scrub — mirrors reply
@@ -305,8 +318,8 @@ describe('#2798 turn-flush punctuation/bold parity with reply', () => {
305
318
  it('both send sites share the identical `stripExcessBold(normalizePunctuation(` wrapper', () => {
306
319
  // Parity, structurally: the exact normalization wrapper the reply path uses
307
320
  // is the one the turn_flush branch uses — same call, not a lookalike.
308
- expect(gatewaySrc).toContain('stripExcessBold(normalizePunctuation(text))')
309
- expect(gatewaySrc).toContain('stripExcessBold(normalizePunctuation(capturedText))')
321
+ expect(replyModuleSrc).toContain('stripExcessBold(normalizePunctuation(text))')
322
+ expect(streamSrc).toContain('stripExcessBold(normalizePunctuation(capturedText))')
310
323
  })
311
324
 
312
325
  // Behavioural coverage (kept from the original suite): reconstruct the
@@ -678,3 +691,168 @@ describe('selectFlushDeliveryText — deliver the terminal answer, strip only na
678
691
  }
679
692
  })
680
693
  })
694
+
695
+ // ── #3237: STRUCTURAL block-provenance discriminator ───────────────────────
696
+ // The opener heuristic cannot tell a narration preamble from a real answer
697
+ // paragraph that merely opens with "Let me explain…". The structural flag
698
+ // (`followedByToolUse`, = `!lastInMessage`) can: a narration preamble is
699
+ // followed by a tool_use in its message; a terminal answer paragraph is not.
700
+ describe('selectFlushDeliveryText — structural provenance (followedByToolUse) #3237', () => {
701
+ // The exact real-answer shape from #3237: a genuine two-paragraph answer
702
+ // whose FIRST block opens with a narration phrase, written as plain text with
703
+ // NO tool calls (the model forgot the reply tool → flush path). Structure
704
+ // says neither block was followed by a tool_use, so nothing is narration.
705
+ const p1 =
706
+ 'Let me explain how the flush path works: it fires at turn_end when the ' +
707
+ 'model never called the reply tool, joining the captured assistant text ' +
708
+ 'blocks with a paragraph break so the answer renders with real separation.'
709
+ const p2 =
710
+ 'The terminal block is always delivered, so the answer is never fully ' +
711
+ 'lost — but the opening paragraph could be dropped by the opener-only strip.'
712
+
713
+ it('#3237 regression: a real 2-paragraph answer opening with "Let me explain…" is NOT truncated when structure says neither block was followed by a tool_use', () => {
714
+ // With NO structural signal (legacy string[]) the opener strip DROPS p1.
715
+ expect(selectFlushDeliveryText([p1, p2])).toBe(p2) // documents the bug
716
+ // With structure present ([false,false]) the full answer is delivered.
717
+ const out = selectFlushDeliveryText([p1, p2], [false, false])
718
+ expect(out).toBe(`${p1}\n\n${p2}`)
719
+ expect(out).toContain('Let me explain how the flush path works')
720
+ })
721
+
722
+ it('duplicate-reply fix preserved: a narration block FOLLOWED by a tool_use is stripped, terminal answer delivered', () => {
723
+ const narration = 'Let me pull the numbers together before I answer.'
724
+ const realAnswer = 'Revenue was 4.2M, up 12% year over year.'
725
+ // followedByToolUse[0]=true (the model drafted then acted), [1]=false (terminal).
726
+ const out = selectFlushDeliveryText([narration, realAnswer], [true, false])
727
+ expect(out).toBe(realAnswer)
728
+ expect(out).not.toContain('Let me pull the numbers')
729
+ })
730
+
731
+ it('structure alone drives the strip — a preamble the opener regex would MISS is still stripped when it was followed by a tool_use', () => {
732
+ // "Here are the figures" matches no NARRATION_OPENER, but a tool_use
733
+ // followed it → structural narration → stripped.
734
+ const preamble = 'Here are the figures you asked about.'
735
+ const realAnswer = 'The three services are all green.'
736
+ const out = selectFlushDeliveryText([preamble, realAnswer], [true, false])
737
+ expect(out).toBe(realAnswer)
738
+ expect(out).not.toContain('Here are the figures')
739
+ })
740
+
741
+ it('structure WINS over the opener heuristic — a "Let me…" opener NOT followed by a tool_use is kept (never truncated)', () => {
742
+ const first = 'Let me walk you through the three steps in order.'
743
+ const second = 'Step one is to branch off fresh main.'
744
+ // Opener-only would strip `first`; structure ([false,false]) keeps it.
745
+ expect(selectFlushDeliveryText([first, second], [false, false])).toBe(
746
+ `${first}\n\n${second}`,
747
+ )
748
+ })
749
+
750
+ it('per-block fallback: an UNDEFINED entry falls back to the opener heuristic for that block only', () => {
751
+ const narration = 'Let me check that.'
752
+ const realAnswer = 'Done — all green.'
753
+ // meta[0] undefined → fall back to isNarrationBlock("Let me check that.") = true → stripped.
754
+ const out = selectFlushDeliveryText([narration, realAnswer], [undefined, false])
755
+ expect(out).toBe(realAnswer)
756
+ })
757
+
758
+ it('flag-absent back-compat: omitting the meta array reproduces today\'s opener-strip EXACTLY', () => {
759
+ // Every legacy case behaves identically with no meta argument.
760
+ expect(selectFlushDeliveryText(['Let me check.', 'the answer'])).toBe('the answer')
761
+ expect(selectFlushDeliveryText(['Checking now…', 'The build is green.'])).toBe(
762
+ 'The build is green.',
763
+ )
764
+ expect(selectFlushDeliveryText([p1, p2])).toBe(p2) // opener strip drops p1
765
+ const first = 'B'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
766
+ const last = 'C'.repeat(FLUSH_SUBSTANTIVE_MIN_CHARS + 5)
767
+ expect(selectFlushDeliveryText([first, last])).toBe(`${first}\n\n${last}`)
768
+ })
769
+
770
+ it('meta stays ALIGNED across the empty-block filter (zip-before-filter)', () => {
771
+ // An empty block between narration and answer must not shift the meta index.
772
+ const narration = 'Let me look that up.'
773
+ const realAnswer = 'The value is 42.'
774
+ // blocks[1] is empty (dropped); meta must still map [narration→true, ''→_, answer→false].
775
+ const out = selectFlushDeliveryText([narration, ' ', realAnswer], [true, false, false])
776
+ expect(out).toBe(realAnswer)
777
+ expect(out).not.toContain('Let me look that up')
778
+ })
779
+
780
+ it('cross-message shape: narration (msg1, tool_use after) + answer (msg2, terminal) → narration stripped', () => {
781
+ // lastInMessage semantics are per-message; a narration that is the last text
782
+ // of msg1 but has a tool_use after it in msg1 is followedByToolUse=true.
783
+ const narration = 'Let me search the codebase for the symbol.'
784
+ const realAnswer = 'It is defined in session-tail.ts.'
785
+ const out = selectFlushDeliveryText([narration, realAnswer], [true, false])
786
+ expect(out).toBe(realAnswer)
787
+ })
788
+
789
+ it('decideTurnFlush plumbs capturedBlockMeta through to the strip (#3237 end-to-end)', () => {
790
+ // Real-answer case: structure keeps both paragraphs.
791
+ const keep = decideTurnFlush({
792
+ chatId: 'chat1',
793
+ replyCalled: false,
794
+ capturedText: [p1, p2],
795
+ capturedBlockMeta: [false, false],
796
+ })
797
+ expect(keep.kind).toBe('flush')
798
+ if (keep.kind === 'flush') expect(keep.text).toBe(`${p1}\n\n${p2}`)
799
+
800
+ // Narration case: structure strips the preamble.
801
+ const strip = decideTurnFlush({
802
+ chatId: 'chat1',
803
+ replyCalled: false,
804
+ capturedText: ['Let me pull the numbers.', 'Revenue was 4.2M.'],
805
+ capturedBlockMeta: [true, false],
806
+ })
807
+ expect(strip.kind).toBe('flush')
808
+ if (strip.kind === 'flush') expect(strip.text).toBe('Revenue was 4.2M.')
809
+ })
810
+
811
+ // Regression for the post-#3338 review finding: a SUBSTANTIAL real-content
812
+ // block (≥ FLUSH_SUBSTANTIVE_MIN_CHARS, NOT narration-shaped) that happens to
813
+ // be followed by a tool_use must be KEPT — the model can write a real answer
814
+ // paragraph, then call a memory/verify tool, then a short wrap-up. Trusting
815
+ // followedByToolUse ALONE (the pre-fix behaviour) dropped the real answer and
816
+ // delivered only the wrap-up. The structural strip is gated by the substantive
817
+ // floor, so the substantial block survives.
818
+ it('substantial real-content block followed by a tool_use is KEPT (structural flag gated by the substantive floor)', () => {
819
+ const realAnswer =
820
+ 'The migration completed cleanly across all five shards: each shard was ' +
821
+ 'drained, the schema applied under an advisory lock, and traffic cut back ' +
822
+ 'over with zero dropped requests. The new index brought the p99 query time ' +
823
+ 'down from 840ms to 60ms, and the old columns are now safe to remove in ' +
824
+ 'the next release once the read path is confirmed off them entirely.'
825
+ expect(realAnswer.length).toBeGreaterThanOrEqual(FLUSH_SUBSTANTIVE_MIN_CHARS)
826
+ // Not narration-shaped: no first-person "about to do X" opener, no
827
+ // trailing ellipsis/colon — so only the structural flag could strip it.
828
+ const wrapUp = 'Saved that to memory.'
829
+ // meta[0]=true (a memory tool_use followed the real answer), [1]=false.
830
+ const out = selectFlushDeliveryText([realAnswer, wrapUp], [true, false])
831
+ expect(out).toBe(`${realAnswer}\n\n${wrapUp}`)
832
+ expect(out).toContain('The migration completed cleanly')
833
+
834
+ // End-to-end through decideTurnFlush.
835
+ const decision = decideTurnFlush({
836
+ chatId: 'chat1',
837
+ replyCalled: false,
838
+ capturedText: [realAnswer, wrapUp],
839
+ capturedBlockMeta: [true, false],
840
+ })
841
+ expect(decision.kind).toBe('flush')
842
+ if (decision.kind === 'flush') {
843
+ expect(decision.text).toBe(`${realAnswer}\n\n${wrapUp}`)
844
+ }
845
+ })
846
+
847
+ // A SHORT block followed by a tool_use is still stripped via the length floor
848
+ // (the pre-#3237 "short preamble the opener misses" behaviour is preserved):
849
+ // it is below FLUSH_SUBSTANTIVE_MIN_CHARS, so the gated strip still fires.
850
+ it('short block followed by a tool_use is still stripped (length-floor branch of the gate)', () => {
851
+ const shortPreamble = 'Here are the figures you asked about.'
852
+ expect(shortPreamble.length).toBeLessThan(FLUSH_SUBSTANTIVE_MIN_CHARS)
853
+ const realAnswer = 'The three services are all green.'
854
+ const out = selectFlushDeliveryText([shortPreamble, realAnswer], [true, false])
855
+ expect(out).toBe(realAnswer)
856
+ expect(out).not.toContain('Here are the figures')
857
+ })
858
+ })
@@ -22,6 +22,11 @@ import { readFileSync } from 'node:fs'
22
22
  import { resolve } from 'node:path'
23
23
 
24
24
  const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
25
+ // #2996 P4-A: the turn-flush / turn_end / `thinking`-arm code moved VERBATIM
26
+ // into stream-render.ts with handleSessionEvent. Windows that used to live in
27
+ // the gateway switch are grepped from the extracted module now.
28
+ const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
29
+ const gatewayAndStreamSrc = gatewaySrc + '\n' + streamSrc
25
30
 
26
31
  function between(src: string, startMarker: string, endMarker: string): string {
27
32
  const after = src.split(startMarker)[1] ?? ''
@@ -42,7 +47,7 @@ describe('S1 wiring — turn-flush suppression call site', () => {
42
47
  // The whole suppression branch: predicate call through the end of the
43
48
  // `if (suppress)` early-return.
44
49
  const branch = between(
45
- gatewaySrc,
50
+ streamSrc,
46
51
  'const { shouldSuppressTurnFlush }',
47
52
  '// #3276 guard 5',
48
53
  )
@@ -72,7 +77,7 @@ describe('S1 wiring — turn-flush suppression call site', () => {
72
77
  it('the old predicate is gone from the whole gateway turn-flush path', () => {
73
78
  // getRecentOutboundCount must not be consulted anywhere in gateway.ts —
74
79
  // any reintroduction re-opens the chat-wide suppression class.
75
- expect(codeOnly(gatewaySrc)).not.toMatch(/getRecentOutboundCount/)
80
+ expect(codeOnly(gatewayAndStreamSrc)).not.toMatch(/getRecentOutboundCount/)
76
81
  })
77
82
  })
78
83
 
@@ -81,7 +86,7 @@ describe('S2 wiring — thinking events re-arm the answer-ready quiescence flush
81
86
  // Without this, "prose → >1s thinking pause → trailing NO_REPLY" lets the
82
87
  // quiescence timer fire mid-pause and deliver a turn the model intended
83
88
  // silent. Scope to the thinking case arm only.
84
- const arm = between(gatewaySrc, "case 'thinking': {", "case 'tool_use': {")
89
+ const arm = between(streamSrc, "case 'thinking': {", "case 'tool_use': {")
85
90
  expect(arm.length).toBeGreaterThan(50)
86
91
  expect(codeOnly(arm)).toMatch(/resetAnswerReadyFlushTimeout\(\)/)
87
92
  })
@@ -107,6 +112,6 @@ describe('S4 wiring — flushed answers quote-anchor to the inbound they answer'
107
112
  it('the turn-flush call site passes the turn’s inbound id (bare for synthesized turns)', () => {
108
113
  // The anchor must be the message this TURN answers — turn.sourceMessageId
109
114
  // is null for cron/handback turns, which therefore still send bare.
110
- expect(codeOnly(gatewaySrc)).toMatch(/replyToMessageId:\s*turn\.sourceMessageId/)
115
+ expect(codeOnly(gatewayAndStreamSrc)).toMatch(/replyToMessageId:\s*turn\.sourceMessageId/)
111
116
  })
112
117
  })
@@ -48,7 +48,10 @@ import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
48
48
  const gatewaySrc =
49
49
  readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8') +
50
50
  '\n' +
51
- readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8')
51
+ readFileSync(resolve(__dirname, '..', 'gateway', 'callback-query-handlers.ts'), 'utf-8') +
52
+ '\n' +
53
+ // P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
54
+ readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-interceptors.ts'), 'utf-8')
52
55
 
53
56
  function sliceAccessApproveBlock(): string {
54
57
  const fn =
@@ -102,7 +105,10 @@ describe('handleVaultRequestAccessCallback — posture branch', () => {
102
105
  expect(approveBlock).toMatch(/passphrase-for-access-approve/)
103
106
  // Pinned: the queued-drain path passes the typed passphrase via
104
107
  // the new attestation shape `{ kind: 'passphrase', passphrase }`.
105
- expect(gatewaySrc).toMatch(/performVaultAccessApproval\(ctx, stagedAccess, item\.stageId, item\.senderId, \{ kind: ['"]passphrase['"], passphrase \}\)/)
108
+ // P7 PR-8: the queued-drain call site moved into interceptVault
109
+ // (inbound-interceptors.ts) — ctx is `p.ctx` and the handler resolves via
110
+ // the lazy deps accessor. Same attestation shape pinned.
111
+ expect(gatewaySrc).toMatch(/performVaultAccessApproval\((?:p\.)?ctx, stagedAccess, item\.stageId, item\.senderId, \{ kind: ['"]passphrase['"], passphrase \}\)/)
106
112
  })
107
113
  })
108
114
 
@@ -29,7 +29,10 @@ import { resolve } from "node:path";
29
29
  const gatewaySrc =
30
30
  readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
31
31
  "\n" +
32
- readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8");
32
+ readFileSync(resolve(__dirname, "..", "gateway", "callback-query-handlers.ts"), "utf-8") +
33
+ "\n" +
34
+ // P7 PR-8 (#2996): vault pending-op intercept moved to inbound-interceptors.ts.
35
+ readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
33
36
 
34
37
  function extractPerformBlock(): string {
35
38
  const start = gatewaySrc.indexOf("async function performVaultAccessApproval");