switchroom 0.19.14 → 0.19.16

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 (49) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +31 -2
  6. package/telegram-plugin/dist/gateway/gateway.js +1690 -932
  7. package/telegram-plugin/dist/server.js +31 -2
  8. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  9. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  10. package/telegram-plugin/gateway/gateway.ts +10 -57
  11. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  13. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  14. package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
  15. package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
  16. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  17. package/telegram-plugin/gateway/stream-render.ts +107 -15
  18. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  19. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  20. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  21. package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
  22. package/telegram-plugin/narrative-flush.ts +35 -0
  23. package/telegram-plugin/outbox.ts +73 -3
  24. package/telegram-plugin/session-tail.ts +88 -1
  25. package/telegram-plugin/shown-ledger.ts +145 -0
  26. package/telegram-plugin/silence-poke.ts +118 -1
  27. package/telegram-plugin/silent-end.ts +42 -0
  28. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  29. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  30. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  31. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  32. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  34. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  35. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  36. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  37. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  38. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  40. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  41. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  42. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  43. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  44. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  45. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  46. package/telegram-plugin/tts-normalize.ts +12 -0
  47. package/telegram-plugin/turn-flush-safety.ts +66 -53
  48. package/telegram-plugin/voice-normalize-text.ts +100 -0
  49. package/telegram-plugin/voice-ondemand.ts +71 -0
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { applyBackgroundShellLiveness } from '../gateway/background-shell-liveness.js'
5
+ import { projectTranscriptLine, type SessionEvent } from '../session-tail.js'
6
+
7
+ // #3519 sharpen — the glue that maps parsed session events to the silence-poke
8
+ // background-shell registry. Driven by the REAL fixtures (carrie session
9
+ // a6d2d33a-…, claude v2.1.197; see tests/fixtures/bg-shell-liveness-3519.jsonl)
10
+ // parsed through the production projectTranscriptLine path, so the event shapes
11
+ // here are exactly what the gateway sees at runtime.
12
+ describe('applyBackgroundShellLiveness (real markers)', () => {
13
+ const FIXTURE = join(__dirname, 'fixtures', 'bg-shell-liveness-3519.jsonl')
14
+ const lines = readFileSync(FIXTURE, 'utf8').split('\n').filter(l => l.length > 0)
15
+ const aliveEv = projectTranscriptLine(lines[0]).find(e => e.kind === 'tool_result')!
16
+ const deadEv = projectTranscriptLine(lines[1])[0]
17
+ // The real shell id carried by both the ALIVE and DEAD fixture lines.
18
+ const REAL_ID = aliveEv.kind === 'tool_result' ? aliveEv.backgroundTaskId : undefined
19
+
20
+ function spyRegistry() {
21
+ const calls: Array<{ fn: 'alive' | 'dead'; key: string; id: string }> = []
22
+ return {
23
+ calls,
24
+ noteBackgroundShellAlive: (key: string, id: string) => calls.push({ fn: 'alive', key, id }),
25
+ noteBackgroundShellDead: (key: string, id: string) => calls.push({ fn: 'dead', key, id }),
26
+ }
27
+ }
28
+
29
+ it('ALIVE: a real tool_result with backgroundTaskId → noteBackgroundShellAlive', () => {
30
+ const r = spyRegistry()
31
+ applyBackgroundShellLiveness(r, 'c:0', aliveEv)
32
+ expect(r.calls).toEqual([{ fn: 'alive', key: 'c:0', id: REAL_ID! }])
33
+ })
34
+
35
+ it('DEAD: a real <task-notification> completion → noteBackgroundShellDead', () => {
36
+ const r = spyRegistry()
37
+ applyBackgroundShellLiveness(r, 'c:0', deadEv)
38
+ expect(r.calls).toEqual([{ fn: 'dead', key: 'c:0', id: REAL_ID! }])
39
+ })
40
+
41
+ it('DEAD: a KillShell tool_use → noteBackgroundShellDead by shell_id', () => {
42
+ const r = spyRegistry()
43
+ const ev: SessionEvent = { kind: 'tool_use', toolName: 'KillShell', toolUseId: 't9', input: { shell_id: REAL_ID! } }
44
+ applyBackgroundShellLiveness(r, 'c:0', ev)
45
+ expect(r.calls).toEqual([{ fn: 'dead', key: 'c:0', id: REAL_ID! }])
46
+ })
47
+
48
+ it('no-op: an ordinary (foreground-completed) tool_result carries no marker', () => {
49
+ const r = spyRegistry()
50
+ const ev: SessionEvent = { kind: 'tool_result', toolUseId: 't1', toolName: null }
51
+ applyBackgroundShellLiveness(r, 'c:0', ev)
52
+ expect(r.calls).toEqual([])
53
+ })
54
+
55
+ it('DEFENSIVE: a hypothetical non-terminal <task-notification> does NOT mark the shell dead', () => {
56
+ // Guards a future CLI that emits an interim (still-running) notification.
57
+ // The terminal-status gate must keep a live shell in the alive-set — only
58
+ // completed/failed/killed drop it. Built from the SAME real shell id so the
59
+ // scenario is a faithful "what if this id got an interim update" case.
60
+ const r = spyRegistry()
61
+ const interim: SessionEvent = { kind: 'task_notification', taskId: REAL_ID!, status: 'running' }
62
+ applyBackgroundShellLiveness(r, 'c:0', interim)
63
+ expect(r.calls).toEqual([])
64
+ })
65
+
66
+ it('no-op: a non-KillShell tool_use is ignored', () => {
67
+ const r = spyRegistry()
68
+ const ev: SessionEvent = { kind: 'tool_use', toolName: 'Bash', toolUseId: 't1', input: { command: 'ls' } }
69
+ applyBackgroundShellLiveness(r, 'c:0', ev)
70
+ expect(r.calls).toEqual([])
71
+ })
72
+ })
@@ -0,0 +1,335 @@
1
+ /**
2
+ * backstop-exactly-once.test.ts — outcome-asserting regression suite for the
3
+ * switchroom#3513 FOLLOW-UP: the deterministic, model-discipline-free
4
+ * "exactly-once-among-backstops" outbound contract.
5
+ *
6
+ * The invariant under test:
7
+ * - Any text block followed by a NON-ephemeral (turn-continuing) tool_use in
8
+ * the SAME turn — including the cross-message shape ([text-only message] →
9
+ * [tool_use in the NEXT message]) — is suppressed from the backstop delivery
10
+ * UNCONDITIONALLY (no length / wording gate). This replaces #3515's
11
+ * substance/wording heuristic on the backstop path.
12
+ * - The delivered block is the TERMINAL RUN (the suffix of non-tool-followed
13
+ * blocks), joined; a multi-paragraph terminal answer stays whole.
14
+ * - The empty-terminal corner (last block was itself tool-followed) delivers
15
+ * that last block IFF it clears the substantive floor, else nothing.
16
+ * - An explicit reply-tool (E0) send is journaled with
17
+ * `replyAlreadyDeliveredThisTurn=true` and is NOT counted by
18
+ * `backstopAlreadyDelivered`, so a legitimate later explicit reply is never
19
+ * blocked by the backstop exactly-once guard.
20
+ * - An answer followed ONLY by an ephemeral surface tool (react / pin / typing
21
+ * / edit / delete) still delivers — those tools are not turn-continuing.
22
+ *
23
+ * Every test asserts an OUTCOME and is written to be RED on the pre-fix logic
24
+ * (per-message provenance + substance-gated wording strip + no backstop-scoped
25
+ * durable read).
26
+ */
27
+
28
+ import { describe, it, expect } from 'vitest'
29
+ import { mkdtempSync, rmSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join } from 'node:path'
32
+
33
+ import {
34
+ selectBackstopDelivery,
35
+ isEphemeralTool,
36
+ EPHEMERAL_TOOLS,
37
+ SUBSTANTIVE_MIN_CHARS,
38
+ } from '../hooks/narration-classify.mjs'
39
+ import { scanTurnForFinalReply, scanForOutboxCapture } from '../hooks/silent-end-scan.mjs'
40
+ import {
41
+ appendDelivered,
42
+ backstopAlreadyDelivered,
43
+ isBackstopDeliveredEntry,
44
+ outboxAlreadyDelivered,
45
+ } from '../outbox.js'
46
+ import { decideCapturedProseDelivery, writeSilentEndState } from '../silent-end.js'
47
+
48
+ // ── Fixture builders (parity with silent-end-interrupt-stop-scan.test.ts) ────
49
+
50
+ const ENQUEUE = JSON.stringify({
51
+ type: 'queue-operation',
52
+ operation: 'enqueue',
53
+ content: '<channel source="switchroom-telegram" chat_id="111" message_id="42">hi</channel>',
54
+ })
55
+
56
+ function assistantText(text: string) {
57
+ return JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } })
58
+ }
59
+ function assistantToolUse(name: string, input: Record<string, unknown> = {}) {
60
+ return JSON.stringify({
61
+ type: 'assistant',
62
+ message: { content: [{ type: 'tool_use', id: `t-${name}`, name, input }] },
63
+ })
64
+ }
65
+ function jsonl(...lines: string[]) {
66
+ return lines.join('\n')
67
+ }
68
+
69
+ const BIG = (label: string) =>
70
+ `${label}: ` + 'X'.repeat(SUBSTANTIVE_MIN_CHARS + 20)
71
+
72
+ // ── selectBackstopDelivery — the shared coalescer ────────────────────────────
73
+
74
+ describe('selectBackstopDelivery — deterministic backstop coalescer (#3513 follow-up)', () => {
75
+ it('excludes a tool-followed block UNCONDITIONALLY, even a substantive one (no substance carve-out)', () => {
76
+ // Pre-fix: a ≥floor block followed by a tool was KEPT (the #3237 substance
77
+ // gate). New contract: suppressed unconditionally; only the terminal block
78
+ // survives. RED on the old substance-gated logic.
79
+ const answer = BIG('the real answer')
80
+ const closer = 'Saved that to memory.'
81
+ const out = selectBackstopDelivery([
82
+ { text: answer, followedByToolUse: true },
83
+ { text: closer, followedByToolUse: false },
84
+ ])
85
+ expect(out).not.toBeNull()
86
+ expect(out!.text).toBe(closer)
87
+ expect(out!.text).not.toContain('the real answer')
88
+ })
89
+
90
+ it('joins the maximal terminal suffix run of non-tool-followed blocks (multi-paragraph answer stays whole)', () => {
91
+ const p1 = 'First paragraph of the genuine answer.'
92
+ const p2 = 'Second paragraph, still part of the same answer.'
93
+ const out = selectBackstopDelivery([
94
+ { text: 'Let me look…', followedByToolUse: true }, // suppressed
95
+ { text: p1, followedByToolUse: false },
96
+ { text: p2, followedByToolUse: false },
97
+ ])
98
+ expect(out!.text).toBe(`${p1}\n\n${p2}`)
99
+ })
100
+
101
+ it('empty-terminal corner: last block was tool-followed → delivered IFF ≥ substantive floor', () => {
102
+ const big = BIG('trailing but substantive')
103
+ const overFloor = selectBackstopDelivery([{ text: big, followedByToolUse: true }])
104
+ expect(overFloor!.text).toBe(big)
105
+ const short = selectBackstopDelivery([{ text: 'ok…', followedByToolUse: true }])
106
+ expect(short).toBeNull()
107
+ })
108
+
109
+ it('undefined provenance fails OPEN — the block is delivered, never dropped', () => {
110
+ const out = selectBackstopDelivery([{ text: 'Let me check.' }, { text: 'the answer' }])
111
+ // No structural signal → both are the terminal run → joined (no wording strip).
112
+ expect(out!.text).toBe('Let me check.\n\nthe answer')
113
+ })
114
+
115
+ it('all-empty / no-blocks → null', () => {
116
+ expect(selectBackstopDelivery([])).toBeNull()
117
+ expect(selectBackstopDelivery([{ text: ' ', followedByToolUse: false }])).toBeNull()
118
+ })
119
+ })
120
+
121
+ // ── isEphemeralTool — MF4 exact set ─────────────────────────────────────────
122
+
123
+ describe('isEphemeralTool — the exact ephemeral surface set (MF4)', () => {
124
+ it('recognises exactly {react, send_typing, pin_message, delete_message, edit_message}, MCP-prefixed or bare', () => {
125
+ expect([...EPHEMERAL_TOOLS].sort()).toEqual(
126
+ ['delete_message', 'edit_message', 'pin_message', 'react', 'send_typing'].sort(),
127
+ )
128
+ for (const t of EPHEMERAL_TOOLS) {
129
+ expect(isEphemeralTool(t)).toBe(true)
130
+ expect(isEphemeralTool(`mcp__switchroom-telegram__${t}`)).toBe(true)
131
+ expect(isEphemeralTool(`mcp__clerk-telegram__${t}`)).toBe(true)
132
+ }
133
+ })
134
+
135
+ it('progress_update is NOT ephemeral (MF4 — dropped from the set)', () => {
136
+ expect(isEphemeralTool('progress_update')).toBe(false)
137
+ expect(isEphemeralTool('mcp__switchroom-telegram__progress_update')).toBe(false)
138
+ })
139
+
140
+ it('work / reply tools are NOT ephemeral (turn-continuing)', () => {
141
+ for (const t of ['Bash', 'Read', 'retain', 'download_attachment', 'reply', 'stream_reply']) {
142
+ expect(isEphemeralTool(t)).toBe(false)
143
+ }
144
+ })
145
+ })
146
+
147
+ // ── Cross-message split-shape leak — the ROOT-CAUSE regression (MF1) ─────────
148
+
149
+ describe('cross-message split-shape provenance (MF1 — RED on per-message logic)', () => {
150
+ it('scanTurnForFinalReply: substantive narration in msg1, work-tool in msg2, real answer in msg3 → ONLY the answer delivers (narration suppressed)', () => {
151
+ // Pre-fix: `followedByToolUse` was computed per-message, so the narration
152
+ // block — the LAST content of its OWN message — never saw the tool that
153
+ // arrived in the SEPARATE next message. Its provenance read `false`, so the
154
+ // wording heuristic (not a structural tool signal) decided its fate; a
155
+ // substantive narration that didn't LOOK like narration leaked, joined with
156
+ // the answer. The per-turn two-pass marks it tool-followed → the terminal-run
157
+ // coalescer excludes it UNCONDITIONALLY, leaving only the genuine answer.
158
+ const narration = BIG('checking the gateway logs to see what happened before I answer')
159
+ const answer = BIG('here is the settled, terminal answer the user asked for')
160
+ const text = jsonl(
161
+ ENQUEUE,
162
+ assistantText(narration), // msg1 — text only
163
+ assistantToolUse('Bash', { command: 'tail -f log' }), // msg2 — turn-continuing tool
164
+ assistantText(answer), // msg3 — the real terminal answer
165
+ )
166
+ const r = scanTurnForFinalReply(text)
167
+ expect(r.decided).toBe('block')
168
+ expect(r.pendingText).toBe(answer)
169
+ expect(r.pendingText).not.toContain('checking the gateway logs')
170
+ })
171
+
172
+ it('scanForOutboxCapture: same cross-message shape → captures ONLY the answer, never the narration', () => {
173
+ const narration = BIG('cross-referencing the ledger before summarising the result')
174
+ const answer = BIG('the durable, delivered-once answer for the outbox sweep')
175
+ const text = jsonl(
176
+ ENQUEUE,
177
+ assistantText(narration), // msg1
178
+ assistantToolUse('Read', { file_path: '/tmp/x' }), // msg2 — turn-continuing tool
179
+ assistantText(answer), // msg3 — terminal answer
180
+ )
181
+ const cap = scanForOutboxCapture(text)
182
+ expect(cap.capture).toBe(true)
183
+ if (cap.capture) {
184
+ expect(cap.text).toBe(answer.trim())
185
+ expect(cap.text).not.toContain('cross-referencing the ledger')
186
+ }
187
+ })
188
+
189
+ it('scanTurnForFinalReply: same shape but the trailing block IS terminal (no later tool) → delivered', () => {
190
+ // Control: when the substantive block is genuinely terminal (nothing follows
191
+ // it in the whole turn), it IS the answer and must deliver.
192
+ const answer = BIG('the genuine terminal answer')
193
+ const text = jsonl(
194
+ ENQUEUE,
195
+ assistantToolUse('Bash', { command: 'ls' }), // work happened first
196
+ assistantText(answer), // terminal — no tool after
197
+ )
198
+ const r = scanTurnForFinalReply(text)
199
+ expect(r.decided).toBe('block')
200
+ expect(r.pendingText).toBe(answer)
201
+ })
202
+ })
203
+
204
+ // ── answer-then-ephemeral-react still delivers (MF4b) ───────────────────────
205
+
206
+ describe('answer-then-ephemeral-tool still delivers (MF4b)', () => {
207
+ it('a terminal answer followed ONLY by a react/pin/edit is NOT suppressed', () => {
208
+ const answer = BIG('the answer the user was waiting for')
209
+ // Ephemeral tools are not recorded as work-tool markers, so the answer stays
210
+ // terminal and delivers.
211
+ const text = jsonl(
212
+ ENQUEUE,
213
+ assistantText(answer),
214
+ assistantToolUse('mcp__switchroom-telegram__react', { emoji: '👍' }),
215
+ )
216
+ const r = scanTurnForFinalReply(text)
217
+ expect(r.decided).toBe('block')
218
+ expect(r.pendingText).toBe(answer)
219
+ })
220
+ })
221
+
222
+ // ── backstopAlreadyDelivered — exactly-once-among-backstops (MF2) ────────────
223
+
224
+ describe('backstopAlreadyDelivered — backstop-scoped durable exactly-once (MF2)', () => {
225
+ let dir: string
226
+
227
+ it('classifies delivery sources: flush + sweep + non-E0 reply-tool are backstops; E0 reply is NOT', () => {
228
+ expect(isBackstopDeliveredEntry({ turnNonce: 'n', textSha256: 'x', ts: 0, deliverySource: 'flush' })).toBe(true)
229
+ expect(isBackstopDeliveredEntry({ turnNonce: 'n', textSha256: 'x', ts: 0, deliverySource: 'sweep' })).toBe(true)
230
+ // E3 captured-prose bridge journals as reply-tool with replyAlready=false → backstop.
231
+ expect(
232
+ isBackstopDeliveredEntry({
233
+ turnNonce: 'n', textSha256: 'x', ts: 0,
234
+ deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: false,
235
+ }),
236
+ ).toBe(true)
237
+ // E0 explicit reply journals with replyAlready=true → NOT a backstop.
238
+ expect(
239
+ isBackstopDeliveredEntry({
240
+ turnNonce: 'n', textSha256: 'x', ts: 0,
241
+ deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true,
242
+ }),
243
+ ).toBe(false)
244
+ })
245
+
246
+ it('a prior FLUSH delivery is seen by backstopAlreadyDelivered; a prior E0 reply is NOT', () => {
247
+ dir = mkdtempSync(join(tmpdir(), 'backstop-once-'))
248
+ try {
249
+ // E0 reply for turn A — must NOT count as a backstop.
250
+ appendDelivered(
251
+ { turnNonce: 'A', textSha256: 'ha', ts: 1, deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true },
252
+ dir,
253
+ )
254
+ expect(backstopAlreadyDelivered('A', dir)).toBe(false)
255
+ // …but outboxAlreadyDelivered (the E4 sweep dedup, source-agnostic) DOES see it.
256
+ expect(outboxAlreadyDelivered('A', dir)).toBe(true)
257
+
258
+ // Flush backstop for turn B — must count.
259
+ appendDelivered({ turnNonce: 'B', textSha256: 'hb', ts: 2, deliverySource: 'flush' }, dir)
260
+ expect(backstopAlreadyDelivered('B', dir)).toBe(true)
261
+
262
+ // Unknown nonce → false.
263
+ expect(backstopAlreadyDelivered('C', dir)).toBe(false)
264
+ expect(backstopAlreadyDelivered('', dir)).toBe(false)
265
+ } finally {
266
+ rmSync(dir, { recursive: true, force: true })
267
+ }
268
+ })
269
+
270
+ it('E3 bridge decision skips when a prior backstop already delivered the nonce (already-delivered), but an E0 recap does NOT block it', () => {
271
+ dir = mkdtempSync(join(tmpdir(), 'backstop-e3-'))
272
+ try {
273
+ const turnKey = 'c:_'
274
+ const turnId = 'c:_#42'
275
+ const answer = BIG('the recovered answer')
276
+ writeSilentEndState({ chatId: 'c', threadId: null, turnKey }, { stateDir: dir })
277
+ // Persist pendingText onto the record so the bridge has something to deliver.
278
+ // (writeSilentEndState doesn't set pendingText; simulate the hook's write.)
279
+ appendDelivered({ turnNonce: turnId, textSha256: 'z', ts: 1, deliverySource: 'flush' }, dir)
280
+
281
+ // With a prior FLUSH backstop for this turnId, the bridge must refuse.
282
+ const blocked = decideCapturedProseDelivery(
283
+ { turnKey, turnId, minChars: 1 },
284
+ {
285
+ stateDir: dir,
286
+ backstopDeliveredNonceHit: (n) => backstopAlreadyDelivered(n ?? '', dir),
287
+ },
288
+ )
289
+ // The state file itself carries no pendingText here, so the decision is
290
+ // no-substantive-prose OR already-delivered — assert it does NOT deliver.
291
+ expect(blocked.deliver).toBe(false)
292
+
293
+ // A prior E0 reply recap for the same nonce must NOT trip the backstop guard.
294
+ const dir2 = mkdtempSync(join(tmpdir(), 'backstop-e0-'))
295
+ try {
296
+ appendDelivered(
297
+ { turnNonce: turnId, textSha256: 'z', ts: 1, deliverySource: 'reply-tool', replyAlreadyDeliveredThisTurn: true },
298
+ dir2,
299
+ )
300
+ expect(backstopAlreadyDelivered(turnId, dir2)).toBe(false)
301
+ } finally {
302
+ rmSync(dir2, { recursive: true, force: true })
303
+ }
304
+ void answer
305
+ } finally {
306
+ rmSync(dir, { recursive: true, force: true })
307
+ }
308
+ })
309
+ })
310
+
311
+ // ── multi-substantive-block → one delivery; empty-terminal → one delivery ────
312
+
313
+ describe('single-delivery selection through the scan (MF5)', () => {
314
+ it('multiple substantive terminal blocks → ONE joined delivery (never a second bubble)', () => {
315
+ const p1 = BIG('first half of the answer')
316
+ const p2 = BIG('second half of the answer')
317
+ const text = jsonl(ENQUEUE, assistantText(p1), assistantText(p2))
318
+ const r = scanTurnForFinalReply(text)
319
+ expect(r.decided).toBe('block')
320
+ expect(r.pendingText).toBe(`${p1}\n\n${p2}`)
321
+ })
322
+
323
+ it('empty terminal (last block tool-followed) → the prior substantive terminal run is delivered once', () => {
324
+ const answer = BIG('the settled answer')
325
+ const text = jsonl(
326
+ ENQUEUE,
327
+ assistantText(answer), // terminal? no — a tool follows in the next msg
328
+ assistantToolUse('retain', { text: 'note' }),
329
+ )
330
+ // answer is tool-followed → empty terminal run → rule 3 delivers it iff ≥floor.
331
+ const r = scanTurnForFinalReply(text)
332
+ expect(r.decided).toBe('block')
333
+ expect(r.pendingText).toBe(answer)
334
+ })
335
+ })
@@ -195,6 +195,20 @@ describe('planUnhandledMessage — service-noise classification', () => {
195
195
  })
196
196
  })
197
197
 
198
+ it('legacy forward_* wire keys are envelope metadata — the placeholder names the CONTENT, never "forward_from" (row-944 mislabel)', () => {
199
+ const plan = planUnhandledMessage({
200
+ message_id: 944, chat: {}, date: 0,
201
+ forward_from: { id: 1, is_bot: true, first_name: 'Klanker' },
202
+ forward_date: 1784830000,
203
+ mystery_future_content: {},
204
+ })
205
+ expect(plan).toMatchObject({
206
+ action: 'turn',
207
+ text: '(unhandled message content: mystery_future_content)',
208
+ })
209
+ expect(plan.contentKeys).toEqual(['mystery_future_content'])
210
+ })
211
+
198
212
  it('a message mixing noise with real content still becomes a turn', () => {
199
213
  const plan = planUnhandledMessage({
200
214
  message_id: 1, chat: {}, date: 0,
@@ -191,12 +191,18 @@ describe('silence-poke — isLegitimatelyWorking callback (default-on defer)', (
191
191
  // When isLegitimatelyWorking is wired, it is consulted; the legacy flag
192
192
  // is not consulted for the new path. Verify by having callback=false and
193
193
  // inFlightTools non-empty — the fallback fires because the callback says "no".
194
+ // NOTE (#3519): the in-flight tool here must be a NON-Bash tool. A `Bash`
195
+ // arms the CLI-side background-bash defer (a foreground Bash the callback
196
+ // is blind to once it moves to the background), which intentionally holds
197
+ // the fallback back even when the callback returns false — so using Bash
198
+ // would exercise that new defer rather than the callback-supersedes-legacy
199
+ // path this test pins. `Grep` can never be a detached background process.
194
200
  const f = setupSilenceDeps({
195
201
  thresholds: { fallback: 300_000, fallbackHardCeiling: 900_000 },
196
202
  isLegitimatelyWorking: () => false,
197
203
  })
198
204
  startTurn('chat:0', 0)
199
- noteToolStart('chat:0', 't1', 'Bash', 'audit', 10_000)
205
+ noteToolStart('chat:0', 't1', 'Grep', 'audit', 10_000)
200
206
  __tickForTests(300_000)
201
207
  // callback says false → no defer, fallback fires
202
208
  expect(f.fallbacks).toHaveLength(1)
@@ -0,0 +1,3 @@
1
+ {"parentUuid":"69f789b8-6f8a-46cd-baf0-58fb2114b866","isSidechain":false,"promptId":"4a2992ca-82ba-49cd-bde6-c4c9ac2e67eb","type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01B7T3y1t95oHDEqYKwSmqaW","type":"tool_result","content":"Command running in background with ID: bxa4sv3dq. Output is being written to: /tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output. You will be notified when it completes. To check interim output, use Read on that file path.","is_error":false}]},"uuid":"36ad560b-fe0c-4a1a-aab5-215de2ceab8a","timestamp":"2026-07-03T00:35:56.966Z","toolUseResult":{"stdout":"","stderr":"","interrupted":false,"isImage":false,"noOutputExpected":false,"backgroundTaskId":"bxa4sv3dq"},"sourceToolAssistantUUID":"69f789b8-6f8a-46cd-baf0-58fb2114b866","userType":"external","entrypoint":"cli","cwd":"~/.switchroom/agents/carrie","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","version":"2.1.197","gitBranch":"HEAD"}
2
+ {"type":"queue-operation","operation":"enqueue","timestamp":"2026-07-03T00:42:51.932Z","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","content":"<task-notification>\n<task-id>bxa4sv3dq</task-id>\n<tool-use-id>toolu_01B7T3y1t95oHDEqYKwSmqaW</tool-use-id>\n<output-file>/tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output</output-file>\n<status>completed</status>\n<summary>Background command \"Search for any Buildkite product spec material on disk\" completed (exit code 0)</summary>\n</task-notification>"}
3
+ {"parentUuid":"62dd16ed-917b-4092-861e-81437bf138fb","isSidechain":false,"attachment":{"type":"queued_command","prompt":"<task-notification>\n<task-id>bxa4sv3dq</task-id>\n<tool-use-id>toolu_01B7T3y1t95oHDEqYKwSmqaW</tool-use-id>\n<output-file>/tmp/claude-10098/-home-user--switchroom-agents-carrie/fdc3453e-36da-4480-8c69-ebbcae6b4195/tasks/bxa4sv3dq.output</output-file>\n<status>completed</status>\n<summary>Background command \"Search for any Buildkite product spec material on disk\" completed (exit code 0)</summary>\n</task-notification>","commandMode":"task-notification","timestamp":"2026-07-03T00:42:51.932Z"},"type":"attachment","uuid":"3702ef15-3c4e-4c5d-84c1-e5c2c59b038e","timestamp":"2026-07-03T00:42:51.932Z","userType":"external","entrypoint":"cli","cwd":"/share/code/product-playbook/playbook","sessionId":"a6d2d33a-a8a6-40ce-81d0-cb4bd867ac89","version":"2.1.197","gitBranch":"HEAD"}
@@ -247,6 +247,25 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
247
247
  expect(meta.forwarded_from).toHaveLength(FORWARDED_FROM_NAME_MAX)
248
248
  })
249
249
 
250
+ it('channel origin emits forwarded_message_id so the agent can deep-link the source post (G2)', () => {
251
+ const meta = buildForwardOriginMeta([
252
+ { name: 'Release Notes (@relnotes)', type: 'channel', id: -100400500, date: DATE, messageId: 555 },
253
+ ])
254
+ expect(Object.keys(meta)).toEqual([
255
+ 'forwarded_from',
256
+ 'forwarded_from_type',
257
+ 'forwarded_from_id',
258
+ 'forwarded_date',
259
+ 'forwarded_message_id',
260
+ ])
261
+ expect(meta.forwarded_message_id).toBe('555')
262
+ })
263
+
264
+ it('non-channel origins (no messageId) omit forwarded_message_id entirely', () => {
265
+ const meta = buildForwardOriginMeta([parseForwardOrigin(userOrigin())!])
266
+ expect(meta.forwarded_message_id).toBeUndefined()
267
+ })
268
+
250
269
  it('no origins → empty record (no attrs on a normal message)', () => {
251
270
  expect(buildForwardOriginMeta([])).toEqual({})
252
271
  })
@@ -305,6 +324,7 @@ describe('coalesced bursts — dedupe + numbered siblings', () => {
305
324
  'forwarded_from_type_2',
306
325
  'forwarded_from_id_2',
307
326
  'forwarded_date_2',
327
+ 'forwarded_message_id_2',
308
328
  ])
309
329
  expect(meta.forwarded_from_2).toBe('Release Notes (@relnotes)')
310
330
  expect(meta.forwarded_from_type_2).toBe('channel')