switchroom 0.19.3 → 0.19.5

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 (32) hide show
  1. package/dist/auth-broker/index.js +104 -11
  2. package/dist/cli/autoaccept-poll.js +8 -2
  3. package/dist/cli/switchroom.js +20 -5
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +1 -1
  6. package/profiles/_base/start.sh.hbs +67 -4
  7. package/telegram-plugin/dist/gateway/gateway.js +524 -293
  8. package/telegram-plugin/gateway/command-format.ts +253 -0
  9. package/telegram-plugin/gateway/gateway-heartbeat.ts +72 -0
  10. package/telegram-plugin/gateway/gateway.ts +97 -255
  11. package/telegram-plugin/gateway/hang-restart-decision.ts +189 -0
  12. package/telegram-plugin/gateway/liveness-wiring.ts +35 -1
  13. package/telegram-plugin/gateway/session-model-file.ts +13 -0
  14. package/telegram-plugin/gateway/stream-render.ts +18 -1
  15. package/telegram-plugin/gateway/turn-active-marker.ts +29 -17
  16. package/telegram-plugin/gateway/worker-feed-dispatch.ts +139 -0
  17. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +87 -36
  18. package/telegram-plugin/hooks/silent-end-scan.mjs +263 -3
  19. package/telegram-plugin/render/line-start-guard.ts +76 -4
  20. package/telegram-plugin/rich-send.ts +8 -1
  21. package/telegram-plugin/tests/command-format.test.ts +212 -0
  22. package/telegram-plugin/tests/gateway-heartbeat.test.ts +70 -0
  23. package/telegram-plugin/tests/hang-restart-decision.test.ts +146 -0
  24. package/telegram-plugin/tests/hang-restart-marker-integration.test.ts +98 -0
  25. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +86 -0
  26. package/telegram-plugin/tests/render/heading-guard.test.ts +114 -0
  27. package/telegram-plugin/tests/render/rich-corpus-seam-regression.test.ts +76 -0
  28. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +63 -0
  29. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +60 -16
  30. package/telegram-plugin/tests/silent-end-single-writer-election.test.ts +193 -0
  31. package/telegram-plugin/tests/silent-end.test.ts +60 -5
  32. package/telegram-plugin/tests/worker-feed-origin-race-defer.test.ts +321 -0
@@ -184,13 +184,20 @@ describe('scanTurnForFinalReply — final-reply detection', () => {
184
184
  expect(r.pendingText).toBe(trailing)
185
185
  })
186
186
 
187
- it('Option A: a SHORT trailing/only fragment does NOT set pendingText (substance floor)', () => {
188
- // A genuinely empty-ish turn: a short plain-text closer under the 200-char
189
- // floor must not be re-delivered as if it were the answer.
190
- const text = jsonl(ENQUEUE, assistantText('ok done, let me know if you need anything else'))
187
+ it('Option A: a SHORT single trailing fragment (zero-reply) IS persisted for the lowered-floor corner', () => {
188
+ // Post duplicate-message fix: a single short plain-text block in the
189
+ // ZERO-reply case is the real short-answer shape, so the scan persists it
190
+ // as pendingText for the capture-divergence corner (gateway captured empty
191
+ // the bridge delivers with a lowered floor). Under the gateway's DEFAULT
192
+ // 200-char floor it is still NOT delivered, so the substance guard holds at
193
+ // the delivery layer.
194
+ const short = 'ok done, let me know if you need anything else'
195
+ const text = jsonl(ENQUEUE, assistantText(short))
191
196
  const r = scanTurnForFinalReply(text)
192
197
  expect(r.decided).toBe('block')
193
- expect(r.pendingText).toBeUndefined()
198
+ expect(r.reason).toBe('no-final-reply')
199
+ expect(r.pendingText).toBe(short)
200
+ expect(r.hasTrailingProse).toBe(true)
194
201
  })
195
202
 
196
203
  it('notification-bearing reply → allow', () => {
@@ -388,14 +395,19 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
388
395
  // "Let me check…" / "Still querying…" narration crossed 200 and was delivered
389
396
  // as if it were the answer. Post-fix only a single block that clears the floor
390
397
  // ON ITS OWN becomes pendingText.
391
- const NARRATION_A = 'Let me check the first data source now — pulling the records and scanning for the relevant rows.' // ~95
392
- const NARRATION_B = 'Still querying; the second source is slower than expected, so hang tight while it finishes loading.' // ~98
393
- const NARRATION_C = 'Almost there, cross-referencing the last set of figures against the ledger before I summarise it.' // ~96
394
-
395
- it('zero-delivery turn of only short narration blocks block WITHOUT pendingText (fails on the old joined-floor code)', () => {
396
- // Combined length of the three blocks is ≥ 200, so the OLD code would join
397
- // them and set pendingText (masquerade). The NEW code sets nothing because
398
- // no single block clears the floor.
398
+ // Genuinely-narration blocks: each matches the opener/trailer heuristic
399
+ // (`isNarrationBlock`) the flush's `selectFlushDeliveryText` uses, so a pure
400
+ // run of them is delivered as NOTHING by the flush and the scan must agree
401
+ // (no pendingText) so the capture-divergence bridge never masquerades a
402
+ // narration run as an answer (#3228 Finding 2).
403
+ const NARRATION_A = 'Let me check the first data source now, pulling the records and scanning the rows…' // opener + …
404
+ const NARRATION_B = "Now let me query the second source; it is slower than expected, hang tight…" // opener + …
405
+ const NARRATION_C = "I'll cross-reference the last set of figures against the ledger before I summarise…" // opener + …
406
+
407
+ it('zero-delivery turn of only NARRATION blocks → block WITHOUT pendingText (#3228 Finding 2 / flush parity)', () => {
408
+ // Combined length of the three blocks is ≥ 200, so the OLD joined-floor code
409
+ // would join them and set pendingText (masquerade). The NEW code mirrors the
410
+ // flush's narration strip: every block is narration → nothing to deliver.
399
411
  expect((NARRATION_A + '\n\n' + NARRATION_B + '\n\n' + NARRATION_C).length)
400
412
  .toBeGreaterThanOrEqual(200)
401
413
  const text = jsonl(
@@ -413,6 +425,35 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
413
425
  expect(r.pendingText).toBeUndefined()
414
426
  })
415
427
 
428
+ it('zero-delivery turn of MULTIPLE sub-200 REAL-CONTENT blocks → JOINED pendingText (review item 3 drop fix)', () => {
429
+ // A real answer split as two ~150-char paragraphs, each individually under
430
+ // the 200-char substance floor. Pre-fix the scan set NO pendingText — and in
431
+ // the capture-divergence-empty corner (gateway captured nothing → flush
432
+ // skips 'empty-text') the bridge then had nothing to deliver and the hook
433
+ // had already allowed the stop → DROPPED ANSWER. Post-fix the scan mirrors
434
+ // the flush's join so the bridge delivers the joined prose.
435
+ const PARA_1 = 'The root cause is a stale cache entry that survives the reload because the invalidation key is derived from the wrong field.' // ~150, real content
436
+ const PARA_2 = 'The fix is to key invalidation off the canonical id so the entry is dropped on every write; I have verified it against the repro.' // ~150, real content
437
+ expect(PARA_1.length).toBeLessThan(200)
438
+ expect(PARA_2.length).toBeLessThan(200)
439
+ const text = jsonl(ENQUEUE, assistantText(PARA_1), assistantText(PARA_2))
440
+ const r = scanTurnForFinalReply(text)
441
+ expect(r.decided).toBe('block')
442
+ expect(r.reason).toBe('no-final-reply')
443
+ // Joined prose, mirroring the flush's selectFlushDeliveryText.
444
+ expect(r.pendingText).toBe(`${PARA_1}\n\n${PARA_2}`)
445
+ expect(r.hasTrailingProse).toBe(true)
446
+ })
447
+
448
+ it('leading narration + a real sub-200 answer block → only the answer is delivered (narration stripped)', () => {
449
+ const NARR = 'Let me pull the numbers first…' // narration opener + …
450
+ const ANSWER = 'Revenue was up 12% quarter-over-quarter, driven mostly by the new enterprise tier.' // ~85, real
451
+ const text = jsonl(ENQUEUE, assistantText(NARR), assistantText(ANSWER))
452
+ const r = scanTurnForFinalReply(text)
453
+ expect(r.decided).toBe('block')
454
+ expect(r.pendingText).toBe(ANSWER)
455
+ })
456
+
416
457
  it('trailing narration after a delivered reply, all sub-floor → block only if a real ≥floor block exists; here → allow, no pendingText', () => {
417
458
  // Each trailing block is sub-floor, so `sawUndeliveredTextAfterAllow` is
418
459
  // false → allow. (The old joined-floor logic never affected the block
@@ -447,9 +488,12 @@ describe('scanTurnForFinalReply — pendingText is a single substantive block, n
447
488
  const text = jsonl(ENQUEUE, assistantText('X'.repeat(n)))
448
489
  return scanTurnForFinalReply(text)
449
490
  }
450
- // 199 → under floor, no pendingText (block still fires for the zero-delivery
451
- // turn, but there is nothing substantive to deliver).
452
- expect(at(199).pendingText).toBeUndefined()
491
+ // 199 → under the 200-char SUBSTANCE floor, but a SINGLE trailing block is
492
+ // the real short-answer shape: post duplicate-message fix the scan persists
493
+ // it as pendingText so the capture-divergence corner can deliver it with a
494
+ // lowered floor. The gateway's default-floor decide still won't deliver it,
495
+ // so the #3228 masquerade guard is unchanged at the delivery layer.
496
+ expect(at(199).pendingText).toBe('X'.repeat(199))
453
497
  // 200 → exactly the floor, delivered.
454
498
  expect(at(200).pendingText).toBe('X'.repeat(200))
455
499
  // 201 → over floor, delivered.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Regression tests for the single-writer election (duplicate-message fix).
3
+ *
4
+ * RCA: when a turn ends with its final answer as PLAIN TRANSCRIPT TEXT (no
5
+ * `reply` tool call), two uncoordinated recovery paths both fired — (A) the
6
+ * gateway turn-end flush (`decideTurnFlush`) delivered the captured prose, and
7
+ * (B) this Stop hook blocked and re-prompted, regenerating a REWORDED reply
8
+ * that defeated the exact-match dedup. The user got two near-identical
9
+ * messages.
10
+ *
11
+ * Fix: the Stop hook is the single elector (`decideStopHookDisposition`). On a
12
+ * would-BLOCK scan it ALLOWS the stop — handing delivery to the gateway's
13
+ * flush / captured-prose bridge — IFF four never-drop gates all hold; otherwise
14
+ * it BLOCKS exactly as today. These tests pin the whole verdict matrix and
15
+ * prove every allow path has a delivery machine and every guard forces BLOCK.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest'
19
+ import {
20
+ decideStopHookDisposition,
21
+ isTurnFlushSafetyEnabledEnv,
22
+ isCapturedProseDeliveryEnabledEnv,
23
+ isGatewayHeartbeatFresh,
24
+ GATEWAY_HEARTBEAT_FRESH_MS,
25
+ } from '../hooks/silent-end-scan.mjs'
26
+ import {
27
+ mkdtempSync,
28
+ writeFileSync,
29
+ utimesSync,
30
+ rmSync,
31
+ } from 'node:fs'
32
+ import { join } from 'node:path'
33
+ import { tmpdir } from 'node:os'
34
+
35
+ // Scan fixtures — the shapes `scanTurnForFinalReply` returns on a would-block.
36
+ const ZERO_REPLY_LONG = {
37
+ decided: 'block' as const,
38
+ reason: 'no-final-reply',
39
+ turnKey: 'c:_',
40
+ hasTrailingProse: true,
41
+ pendingText: 'A'.repeat(300),
42
+ }
43
+ const ZERO_REPLY_SHORT = {
44
+ decided: 'block' as const,
45
+ reason: 'no-final-reply',
46
+ turnKey: 'c:_',
47
+ hasTrailingProse: true,
48
+ pendingText: 'ok done',
49
+ }
50
+ const ZERO_REPLY_NO_PROSE = {
51
+ decided: 'block' as const,
52
+ reason: 'no-final-reply',
53
+ turnKey: 'c:_',
54
+ // hasTrailingProse absent — tool-calls-only turn, nothing for a machine to send.
55
+ }
56
+ const INTERIM_ACK_LONG = {
57
+ decided: 'block' as const,
58
+ reason: 'trailing-text-after-reply',
59
+ turnKey: 'c:_',
60
+ hasTrailingProse: true,
61
+ pendingText: 'B'.repeat(300),
62
+ }
63
+ const INTERIM_ACK_SHORT = {
64
+ decided: 'block' as const,
65
+ reason: 'trailing-text-after-reply',
66
+ turnKey: 'c:_',
67
+ hasTrailingProse: true,
68
+ pendingText: 'thanks!',
69
+ }
70
+
71
+ const ALL_ON = {
72
+ retryCount: 0,
73
+ turnFlushSafetyEnabled: true,
74
+ capturedProseDeliveryEnabled: true,
75
+ gatewayLive: true,
76
+ }
77
+
78
+ describe('decideStopHookDisposition — duplicate repro (#1: zero-reply ≥200)', () => {
79
+ it('zero-reply long answer, flags on, retry 0, live → ALLOW-elected (today it blocks → duplicate)', () => {
80
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON })
81
+ expect(d.action).toBe('allow-elected')
82
+ expect(d.reason).toBe('flush-will-deliver')
83
+ })
84
+ })
85
+
86
+ describe('decideStopHookDisposition — short-answer repro (#2: zero-reply <200)', () => {
87
+ it('zero-reply SHORT answer, flags on, retry 0, live → ALLOW-elected (flush has no length floor)', () => {
88
+ // Today this BLOCKS *and* the flush fires → duplicate. Exactly one machine
89
+ // must win: the flush. The election allows the stop so the hook does not
90
+ // also re-prompt.
91
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_SHORT, ...ALL_ON })
92
+ expect(d.action).toBe('allow-elected')
93
+ })
94
+ })
95
+
96
+ describe('decideStopHookDisposition — never-drop matrix (#3)', () => {
97
+ it('every ALLOW verdict is backed by a delivery machine', () => {
98
+ // Zero-reply → the turn-flush delivers (no length floor); interim-ack ≥200 →
99
+ // the captured-prose bridge delivers. Both are allow.
100
+ expect(decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON }).action).toBe('allow-elected')
101
+ expect(decideStopHookDisposition({ scan: ZERO_REPLY_SHORT, ...ALL_ON }).action).toBe('allow-elected')
102
+ expect(decideStopHookDisposition({ scan: INTERIM_ACK_LONG, ...ALL_ON }).action).toBe('allow-elected')
103
+ })
104
+
105
+ it('turn-flush flag OFF (zero-reply) → BLOCK (the flush will not fire)', () => {
106
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, turnFlushSafetyEnabled: false })
107
+ expect(d.action).toBe('block')
108
+ expect(d.reason).toBe('turn-flush-flag-disabled')
109
+ })
110
+
111
+ it('captured-prose flag OFF (interim-ack) → BLOCK (the bridge will not fire)', () => {
112
+ const d = decideStopHookDisposition({ scan: INTERIM_ACK_LONG, ...ALL_ON, capturedProseDeliveryEnabled: false })
113
+ expect(d.action).toBe('block')
114
+ expect(d.reason).toBe('captured-prose-flag-disabled')
115
+ })
116
+
117
+ it('captured-prose flag OFF (zero-reply) → BLOCK (bridge is the capture-divergence backstop)', () => {
118
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, capturedProseDeliveryEnabled: false })
119
+ expect(d.action).toBe('block')
120
+ expect(d.reason).toBe('captured-prose-flag-disabled')
121
+ })
122
+
123
+ it('retryCount > 0 → BLOCK (a prior failed delivery keeps the #3228 recovery net)', () => {
124
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, retryCount: 1 })
125
+ expect(d.action).toBe('block')
126
+ expect(d.reason).toBe('retry-ladder-in-flight')
127
+ })
128
+
129
+ it('gateway NOT live → BLOCK (never allow into a possibly-dead gateway)', () => {
130
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, gatewayLive: false })
131
+ expect(d.action).toBe('block')
132
+ expect(d.reason).toBe('gateway-liveness-not-fresh')
133
+ })
134
+
135
+ it('interim-ack SHORT (<200) → BLOCK (bridge floor is 200; no duplicate exists today)', () => {
136
+ const d = decideStopHookDisposition({ scan: INTERIM_ACK_SHORT, ...ALL_ON })
137
+ expect(d.action).toBe('block')
138
+ expect(d.reason).toBe('short-trailing-after-reply')
139
+ })
140
+
141
+ it('zero-reply with NO trailing prose (tool-calls only) → BLOCK (nothing to deliver)', () => {
142
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_NO_PROSE, ...ALL_ON })
143
+ expect(d.action).toBe('block')
144
+ expect(d.reason).toBe('no-trailing-prose')
145
+ })
146
+
147
+ it('a non-block scan (allow/unknown) is passed straight through as allow-scan', () => {
148
+ expect(decideStopHookDisposition({ scan: { decided: 'allow', reason: 'final-reply' }, ...ALL_ON }).action).toBe('allow-scan')
149
+ expect(decideStopHookDisposition({ scan: { decided: 'unknown', reason: 'no-turn-start' }, ...ALL_ON }).action).toBe('allow-scan')
150
+ })
151
+
152
+ it('gateway-liveness gate is checked BEFORE the flag/prose gates (dead gateway always blocks)', () => {
153
+ // Even with an otherwise-perfect zero-reply allow, a dead gateway forces block.
154
+ const d = decideStopHookDisposition({ scan: ZERO_REPLY_LONG, ...ALL_ON, gatewayLive: false, retryCount: 0 })
155
+ expect(d.action).toBe('block')
156
+ expect(d.reason).toBe('gateway-liveness-not-fresh')
157
+ })
158
+ })
159
+
160
+ describe('env-flag mirrors stay in sync with the TS source', () => {
161
+ it('turn-flush-safety: default ON; 0/false/off/no disable', () => {
162
+ expect(isTurnFlushSafetyEnabledEnv({})).toBe(true)
163
+ for (const v of ['0', 'false', 'off', 'no', 'FALSE', 'Off']) {
164
+ expect(isTurnFlushSafetyEnabledEnv({ SWITCHROOM_TG_TURN_FLUSH_SAFETY: v })).toBe(false)
165
+ }
166
+ expect(isTurnFlushSafetyEnabledEnv({ SWITCHROOM_TG_TURN_FLUSH_SAFETY: '1' })).toBe(true)
167
+ })
168
+ it('captured-prose: default ON; only exact "0" disables', () => {
169
+ expect(isCapturedProseDeliveryEnabledEnv({})).toBe(true)
170
+ expect(isCapturedProseDeliveryEnabledEnv({ SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY: '0' })).toBe(false)
171
+ expect(isCapturedProseDeliveryEnabledEnv({ SWITCHROOM_TG_CAPTURED_PROSE_DELIVERY: '1' })).toBe(true)
172
+ })
173
+ })
174
+
175
+ describe('isGatewayHeartbeatFresh — liveness gate IO', () => {
176
+ it('fresh heartbeat → true; stale → false; missing → false', () => {
177
+ const dir = mkdtempSync(join(tmpdir(), 'gw-hb-'))
178
+ try {
179
+ // Missing file → not fresh.
180
+ expect(isGatewayHeartbeatFresh(dir)).toBe(false)
181
+ const path = join(dir, 'gateway-heartbeat')
182
+ writeFileSync(path, 'x')
183
+ // Just-written → fresh.
184
+ expect(isGatewayHeartbeatFresh(dir)).toBe(true)
185
+ // Age it past the freshness bound → stale.
186
+ const old = new Date(Date.now() - GATEWAY_HEARTBEAT_FRESH_MS - 10_000)
187
+ utimesSync(path, old, old)
188
+ expect(isGatewayHeartbeatFresh(dir)).toBe(false)
189
+ } finally {
190
+ rmSync(dir, { recursive: true, force: true })
191
+ }
192
+ })
193
+ })
@@ -736,10 +736,15 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
736
736
  expect(decision.reason).toBe('no-state')
737
737
  })
738
738
 
739
- it('a genuinely empty turn (no substantive prose) → hook blocks WITHOUT pendingText decide falls through', () => {
740
- // Short closer under the substance floor: not a dropped answer. The hook
741
- // still blocks (re-prompt), but writes NO pendingText, so the gateway
742
- // takes the existing recordUndeliveredTurnEnd / represent path unchanged.
739
+ it('a short single-block answer → hook persists pendingText but default-floor decide falls through', () => {
740
+ // Single short trailing block under the 200-char substance floor. Post
741
+ // duplicate-message fix the hook persists it as pendingText (so the
742
+ // capture-divergence corner can deliver it with a lowered floor when the
743
+ // gateway captured nothing), but under the DEFAULT floor the gateway's
744
+ // decide returns no-substantive-prose — the normal recordUndeliveredTurnEnd
745
+ // / represent path is unchanged. (No gateway heartbeat file exists in this
746
+ // test's state dir, so the single-writer election also BLOCKS on the
747
+ // liveness gate rather than allowing the stop.)
743
748
  const transcript = writeTranscript([
744
749
  ENQUEUE,
745
750
  { type: 'assistant', message: { content: [{ type: 'text', text: 'ok done' }] } },
@@ -747,10 +752,60 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
747
752
  const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
748
753
  expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
749
754
  const state = readSilentEndState()!
750
- expect(state.pendingText).toBeUndefined()
755
+ expect(state.pendingText).toBe('ok done')
756
+ // Default floor (200) → short prose is not delivered on the normal path.
751
757
  const decision = decideCapturedProseDelivery({ turnKey: 'c:_' })
752
758
  expect(decision.deliver).toBe(false)
753
759
  expect(decision.reason).toBe('no-substantive-prose')
760
+ // Lowered floor (capture-divergence corner) → the short answer IS delivered.
761
+ const lowered = decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 })
762
+ expect(lowered.deliver).toBe(true)
763
+ expect(lowered.text).toBe('ok done')
764
+ })
765
+
766
+ it('review item 3: multi sub-200 REAL blocks + capture-empty corner → bridge delivers JOINED prose (no drop)', () => {
767
+ // A real answer split as two ~150-char paragraphs, each under the 200-char
768
+ // floor. Pre-fix: scan set NO pendingText → in the capture-divergence-empty
769
+ // corner (gateway captured nothing → flush skips 'empty-text') the bridge
770
+ // had nothing → hook already allowed the stop → DROPPED ANSWER. Post-fix
771
+ // the hook persists the JOINED prose so the lowered-floor bridge delivers
772
+ // exactly one message.
773
+ // Each block short enough that the JOINED prose is still under the 200-char
774
+ // default floor — so this also exercises the lowered-floor plumbing.
775
+ const PARA_1 = 'The root cause is a stale cache entry surviving reload.'
776
+ const PARA_2 = 'The fix keys invalidation off the canonical id; verified.'
777
+ const joined = `${PARA_1}\n\n${PARA_2}`
778
+ expect(joined.length).toBeLessThan(200)
779
+ const transcript = writeTranscript([
780
+ ENQUEUE,
781
+ { type: 'assistant', message: { content: [{ type: 'text', text: PARA_1 }] } },
782
+ { type: 'assistant', message: { content: [{ type: 'text', text: PARA_2 }] } },
783
+ ])
784
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
785
+ expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
786
+ const state = readSilentEndState()!
787
+ expect(state.pendingText).toBe(joined)
788
+ // Default floor → not delivered on the normal path (no duplicate risk).
789
+ expect(decideCapturedProseDelivery({ turnKey: 'c:_' }).deliver).toBe(false)
790
+ // Capture-divergence corner (gateway captured empty → lowered floor) →
791
+ // exactly one delivery of the joined answer, instead of a silent drop.
792
+ const lowered = decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 })
793
+ expect(lowered.deliver).toBe(true)
794
+ expect(lowered.text).toBe(joined)
795
+ })
796
+
797
+ it('review item 3: NARRATION-only multi-block → still NO pendingText (no masquerade, #3228 Finding 2)', () => {
798
+ const transcript = writeTranscript([
799
+ ENQUEUE,
800
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Let me check the first source now, scanning the rows…' }] } },
801
+ { type: 'assistant', message: { content: [{ type: 'text', text: "Now let me query the second source; hang tight…" }] } },
802
+ ])
803
+ const r = runHook({ session_id: 's', transcript_path: transcript, hook_event_name: 'Stop' })
804
+ expect(JSON.parse(r.stdout.trim()).decision).toBe('block')
805
+ const state = readSilentEndState()!
806
+ expect(state.pendingText).toBeUndefined()
807
+ // Even at the lowered floor there is nothing to deliver.
808
+ expect(decideCapturedProseDelivery({ turnKey: 'c:_', minChars: 1 }).deliver).toBe(false)
754
809
  })
755
810
 
756
811
  it('a stale pendingText for a DIFFERENT turn is never delivered (turnKey guard)', () => {