switchroom 0.19.13 → 0.19.15

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 (35) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +4 -2
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  6. package/telegram-plugin/dist/gateway/gateway.js +1027 -509
  7. package/telegram-plugin/dist/server.js +1 -1
  8. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  9. package/telegram-plugin/gateway/gateway.ts +4 -0
  10. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  11. package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
  12. package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
  13. package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
  14. package/telegram-plugin/gateway/stream-render.ts +107 -15
  15. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  16. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  17. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  18. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +33 -7
  19. package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
  20. package/telegram-plugin/narrative-flush.ts +35 -0
  21. package/telegram-plugin/outbox.ts +87 -0
  22. package/telegram-plugin/shown-ledger.ts +145 -0
  23. package/telegram-plugin/silent-end.ts +42 -0
  24. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  25. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  26. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  27. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  28. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  29. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  30. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
  31. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
  32. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  33. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  34. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  35. package/telegram-plugin/turn-flush-safety.ts +66 -53
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Outcome regression: forwarded-message body must reach the agent — never a
3
+ * placeholder (carrie history.db row message_id=944, 2026-07-24 06:03).
4
+ *
5
+ * THE BUG: a forwarded BOT message arrives as Bot API 10.1 `rich_message`
6
+ * content (the gateway sends everything via sendRichMessage) with NO
7
+ * top-level text/caption. Live gateway log (carrie, update_id=417526125):
8
+ * content_keys=[forward_from,forward_date,rich_message] action=turn
9
+ * It matched no registered `message:*` handler, fell to the terminal
10
+ * catch-all, and the agent received
11
+ * "(unhandled message content: forward_from)"
12
+ * instead of the forwarded token list. Two defects composed:
13
+ * 1. no `message:rich_message` registration (grammy ^1.44 supports it);
14
+ * 2. legacy `forward_*` wire keys were not in MESSAGE_ENVELOPE_KEYS, so
15
+ * the placeholder was mislabeled with provenance, not content.
16
+ *
17
+ * These tests drive a REAL grammy 1.44 Bot through the REAL production
18
+ * modules (rich-message-handler + unhandled-message catch-all) in the
19
+ * gateway's registration order and assert the DELIVERED text — the outcome
20
+ * the agent actually receives. The key regression test was verified to FAIL
21
+ * on unfixed main (delivered text was the placeholder) and pass here.
22
+ */
23
+
24
+ import { describe, it, expect, beforeEach } from 'vitest'
25
+ import { Bot, type Context } from 'grammy'
26
+ import type { Update } from 'grammy/types'
27
+ import { makeMessageUpdate, resetUpdateCounters } from './update-factory.js'
28
+ import { installUnhandledMessageCatchAll } from '../gateway/unhandled-message.js'
29
+ import {
30
+ handleRichMessageMessage,
31
+ extractRichMessageText,
32
+ RICH_MESSAGE_EMPTY_TEXT,
33
+ } from '../gateway/rich-message-handler.js'
34
+ import type { MediaEnvelopeDeps } from '../gateway/media-message-handlers.js'
35
+ import { parseForwardOrigin, buildForwardOriginMeta } from '../gateway/forward-origin.js'
36
+
37
+ const UNHANDLED_PLACEHOLDER_RE = /^\(unhandled message content: /
38
+
39
+ interface Delivered {
40
+ via: string
41
+ text: string
42
+ }
43
+
44
+ /** Wire a real grammy Bot in the gateway's registration order: message:text,
45
+ * message:rich_message (real production handler), terminal catch-all LAST. */
46
+ function buildHarness() {
47
+ const delivered: Delivered[] = []
48
+ const logLines: string[] = []
49
+ const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
50
+ bot.botInfo = {
51
+ id: 999,
52
+ is_bot: true,
53
+ first_name: 'TestBot',
54
+ username: 'test_bot',
55
+ can_join_groups: true,
56
+ can_read_all_group_messages: false,
57
+ supports_inline_queries: false,
58
+ can_connect_to_business: false,
59
+ has_main_web_app: false,
60
+ }
61
+
62
+ bot.on('message:text', async ctx => {
63
+ delivered.push({ via: 'text', text: ctx.message.text })
64
+ })
65
+ const richDeps: MediaEnvelopeDeps = {
66
+ handleInbound: async (_ctx, text) => {
67
+ delivered.push({ via: 'rich_message', text })
68
+ },
69
+ handleAckOnly: async () => {},
70
+ handleRefusal: async () => {},
71
+ log: line => logLines.push(line),
72
+ }
73
+ bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, richDeps))
74
+ installUnhandledMessageCatchAll(
75
+ bot,
76
+ async (_ctx: Context, text: string) => {
77
+ delivered.push({ via: 'catch-all', text })
78
+ },
79
+ line => logLines.push(line),
80
+ )
81
+ return { bot, delivered, logLines }
82
+ }
83
+
84
+ /** The forwarded-bot-message shape observed live (update_id=417526125):
85
+ * forward_origin (modern) + legacy forward_from/forward_date siblings +
86
+ * rich_message content, NO top-level text/caption. */
87
+ function makeForwardedRichUpdate(update_id: number, blocks: unknown[]): Update {
88
+ const originUser = { id: 8500000001, is_bot: true, first_name: 'Klanker', username: 'meken_klanker_bot' }
89
+ return {
90
+ update_id,
91
+ message: {
92
+ message_id: 944,
93
+ chat: { id: -1004223464247, type: 'supergroup', title: 'ProductOS', is_forum: true },
94
+ from: { id: 777, is_bot: false, first_name: 'Ken' },
95
+ date: 1784837003,
96
+ forward_origin: { type: 'user', date: 1784830000, sender_user: originUser },
97
+ forward_from: originUser,
98
+ forward_date: 1784830000,
99
+ rich_message: { blocks },
100
+ },
101
+ } as unknown as Update
102
+ }
103
+
104
+ const TOKEN_LIST_BLOCKS = [
105
+ { type: 'paragraph', text: 'Try these tokens' },
106
+ {
107
+ type: 'list',
108
+ items: [
109
+ { label: '-', blocks: [{ type: 'paragraph', text: [{ type: 'code', text: 'tok_alpha_123' }] }] },
110
+ { label: '-', blocks: [{ type: 'paragraph', text: [{ type: 'code', text: 'tok_beta_456' }] }] },
111
+ ],
112
+ },
113
+ ]
114
+
115
+ describe('forwarded rich (bot) message — the row-944 regression oracle', () => {
116
+ beforeEach(() => resetUpdateCounters())
117
+
118
+ it('delivers the REAL forwarded body — not the unhandled placeholder, not empty', async () => {
119
+ const { bot, delivered } = buildHarness()
120
+ await bot.handleUpdate(makeForwardedRichUpdate(417526125, TOKEN_LIST_BLOCKS))
121
+
122
+ expect(delivered).toHaveLength(1)
123
+ const turn = delivered[0]
124
+ // Bug-catcher oracle: real content in, real content out.
125
+ expect(turn.text.length).toBeGreaterThan(0)
126
+ expect(turn.text).not.toMatch(UNHANDLED_PLACEHOLDER_RE)
127
+ expect(turn.text).not.toContain('forward_from')
128
+ // The actual forwarded body the agent must receive.
129
+ expect(turn.text).toContain('Try these tokens')
130
+ expect(turn.text).toContain('tok_alpha_123')
131
+ expect(turn.text).toContain('tok_beta_456')
132
+ })
133
+
134
+ it('forward-origin metadata CO-ARRIVES on the trusted attr lane (not injected into the body)', async () => {
135
+ const { bot, delivered } = buildHarness()
136
+ const update = makeForwardedRichUpdate(417526126, TOKEN_LIST_BLOCKS)
137
+ await bot.handleUpdate(update)
138
+
139
+ // The same server-stamped forward_origin the enqueue path parses
140
+ // (gateway.ts → parseForwardOrigin → buildForwardOriginMeta → meta attrs).
141
+ const origin = parseForwardOrigin(
142
+ (update as unknown as { message: { forward_origin: never } }).message.forward_origin,
143
+ )
144
+ const meta = buildForwardOriginMeta([origin!])
145
+ expect(meta.forwarded_from).toBe('Klanker (@meken_klanker_bot)')
146
+ expect(meta.forwarded_from_type).toBe('user')
147
+ expect(meta.forwarded_date).toBeDefined()
148
+ // Trusted-lane separation (#3162): the delivered BODY carries no
149
+ // provenance strings — those live only in the channel attrs.
150
+ expect(delivered[0].text).not.toContain('Klanker')
151
+ })
152
+
153
+ it('a rich message with genuinely no extractable text still yields an honest turn', async () => {
154
+ const { bot, delivered } = buildHarness()
155
+ await bot.handleUpdate(
156
+ makeForwardedRichUpdate(417526127, [{ type: 'divider' }, { type: 'anchor', name: 'top' }]),
157
+ )
158
+ expect(delivered).toHaveLength(1)
159
+ // Divider renders as ---; a fully empty tree gets the honest fallback.
160
+ expect(delivered[0].via).toBe('rich_message')
161
+ expect(delivered[0].text).not.toMatch(UNHANDLED_PLACEHOLDER_RE)
162
+ })
163
+
164
+ it('a truly empty rich message delivers the named fallback, never the mislabeled placeholder', async () => {
165
+ const { bot, delivered } = buildHarness()
166
+ await bot.handleUpdate(makeForwardedRichUpdate(417526128, []))
167
+ expect(delivered).toHaveLength(1)
168
+ expect(delivered[0].text).toBe(RICH_MESSAGE_EMPTY_TEXT)
169
+ })
170
+
171
+ it('a NON-forwarded rich message (bot-to-bot, rich DM) also delivers its body', async () => {
172
+ const { bot, delivered } = buildHarness()
173
+ const update = {
174
+ update_id: 417526129,
175
+ message: {
176
+ message_id: 950,
177
+ chat: { id: 777, type: 'private' },
178
+ from: { id: 777, is_bot: false, first_name: 'Ken' },
179
+ date: 1784837100,
180
+ rich_message: { blocks: [{ type: 'heading', size: 2, text: 'Release plan' }, { type: 'paragraph', text: 'Ship Friday.' }] },
181
+ },
182
+ } as unknown as Update
183
+ await bot.handleUpdate(update)
184
+ expect(delivered[0].text).toContain('Release plan')
185
+ expect(delivered[0].text).toContain('Ship Friday.')
186
+ })
187
+ })
188
+
189
+ describe('working shapes stay unchanged (no regression on existing forwards)', () => {
190
+ beforeEach(() => resetUpdateCounters())
191
+
192
+ it('plain user-text forward: full body via message:text + co-arriving origin meta', async () => {
193
+ const { bot, delivered } = buildHarness()
194
+ const update = makeMessageUpdate({ text: 'here is the brief I forwarded', update_id: 7001 })
195
+ const msg = (update as unknown as { message: Record<string, unknown> }).message
196
+ msg.forward_origin = {
197
+ type: 'user',
198
+ date: 1_700_000_000,
199
+ sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
200
+ }
201
+ await bot.handleUpdate(update)
202
+
203
+ expect(delivered).toHaveLength(1)
204
+ expect(delivered[0]).toMatchObject({ via: 'text', text: 'here is the brief I forwarded' })
205
+ const meta = buildForwardOriginMeta([parseForwardOrigin(msg.forward_origin as never)!])
206
+ expect(meta.forwarded_from).toBe('Ken Thompson')
207
+ expect(meta.forwarded_date).toBeDefined()
208
+ })
209
+
210
+ it('hidden_user forward: full body, type=hidden_user, NO forwarded_from_id', async () => {
211
+ const { bot, delivered } = buildHarness()
212
+ const update = makeMessageUpdate({ text: 'anon tip: check the logs', update_id: 7002 })
213
+ const msg = (update as unknown as { message: Record<string, unknown> }).message
214
+ msg.forward_origin = { type: 'hidden_user', date: 1_700_000_000, sender_user_name: 'Mystery Sender' }
215
+ await bot.handleUpdate(update)
216
+
217
+ expect(delivered[0].text).toBe('anon tip: check the logs')
218
+ const meta = buildForwardOriginMeta([parseForwardOrigin(msg.forward_origin as never)!])
219
+ expect(meta.forwarded_from).toBe('Mystery Sender')
220
+ expect(meta.forwarded_from_type).toBe('hidden_user')
221
+ expect(meta.forwarded_from_id).toBeUndefined()
222
+ })
223
+
224
+ it('channel caption/media forward: caption is the body and forwarded_message_id deep-links the post (G2)', () => {
225
+ // The photo handler's body contract is `caption ?? '(photo)'`
226
+ // (photo-message-handler.ts) — assert the origin-meta side here.
227
+ const origin = parseForwardOrigin({
228
+ type: 'channel',
229
+ date: 1_700_000_000,
230
+ message_id: 555,
231
+ chat: { id: -100400500, type: 'channel', title: 'Release Notes', username: 'relnotes' } as never,
232
+ })
233
+ const meta = buildForwardOriginMeta([origin!])
234
+ expect(meta.forwarded_from).toBe('Release Notes (@relnotes)')
235
+ expect(meta.forwarded_from_type).toBe('channel')
236
+ expect(meta.forwarded_message_id).toBe('555')
237
+ })
238
+ })
239
+
240
+ describe('rich block rendering — deterministic text extraction', () => {
241
+ it('renders headings, lists with checkboxes, pre blocks, quotes, and tables', () => {
242
+ const text = extractRichMessageText({
243
+ blocks: [
244
+ { type: 'heading', size: 1, text: 'Plan' },
245
+ {
246
+ type: 'list',
247
+ items: [
248
+ { label: '1.', blocks: [{ type: 'paragraph', text: 'first' }], has_checkbox: true, is_checked: true },
249
+ { label: '2.', blocks: [{ type: 'paragraph', text: 'second' }], has_checkbox: true },
250
+ ],
251
+ },
252
+ { type: 'pre', language: 'python', text: 'print(1)' },
253
+ { type: 'blockquote', blocks: [{ type: 'paragraph', text: 'quoted line' }], credit: 'someone' },
254
+ { type: 'table', cells: [[{ text: 'A', align: 'left', valign: 'top' }, { text: 'B', align: 'left', valign: 'top' }]] },
255
+ { type: 'photo', photo: [], caption: { text: 'the screenshot' } },
256
+ ],
257
+ })
258
+ expect(text).toContain('Plan')
259
+ expect(text).toContain('1. [x] first')
260
+ expect(text).toContain('2. [ ] second')
261
+ expect(text).toContain('```python\nprint(1)\n```')
262
+ expect(text).toContain('> quoted line')
263
+ expect(text).toContain('> — someone')
264
+ expect(text).toContain('A | B')
265
+ expect(text).toContain('[photo] the screenshot')
266
+ })
267
+
268
+ it('flattens nested inline rich text (bold/url/custom emoji/math) to content', () => {
269
+ const text = extractRichMessageText({
270
+ blocks: [{
271
+ type: 'paragraph',
272
+ text: [
273
+ 'see ',
274
+ { type: 'bold', text: [{ type: 'url', text: 'the docs', url: 'https://x' }] },
275
+ ' ',
276
+ { type: 'custom_emoji', custom_emoji_id: '1', alternative_text: '👍' },
277
+ ' ',
278
+ { type: 'mathematical_expression', expression: 'E=mc^2' },
279
+ ],
280
+ }],
281
+ })
282
+ expect(text).toBe('see the docs 👍 E=mc^2')
283
+ })
284
+
285
+ it('an unknown future block type surfaces its text instead of vanishing', () => {
286
+ expect(extractRichMessageText({ blocks: [{ type: 'hologram', text: 'future words' }] }))
287
+ .toBe('future words')
288
+ })
289
+
290
+ it('malformed / hostile payloads return undefined instead of throwing', () => {
291
+ expect(extractRichMessageText(undefined)).toBeUndefined()
292
+ expect(extractRichMessageText('nope')).toBeUndefined()
293
+ expect(extractRichMessageText({ blocks: 'nope' })).toBeUndefined()
294
+ // Deep self-nesting stops at the recursion cap, no stack overflow.
295
+ const deep: { type: string; blocks: unknown[] } = { type: 'blockquote', blocks: [] }
296
+ let cur = deep
297
+ for (let i = 0; i < 200; i++) {
298
+ const next = { type: 'blockquote', blocks: [] as unknown[] }
299
+ cur.blocks.push(next)
300
+ cur = next
301
+ }
302
+ cur.blocks.push({ type: 'paragraph', text: 'buried' })
303
+ expect(() => extractRichMessageText({ blocks: [deep] })).not.toThrow()
304
+ })
305
+ })
@@ -107,6 +107,7 @@ const GOLDEN_REGISTRATIONS: readonly string[] = [
107
107
  'on:message:checklist_tasks_done',
108
108
  'on:message:checklist_tasks_added',
109
109
  'on:message:pinned_message',
110
+ 'on:message:rich_message',
110
111
  'helper:installUnhandledMessageCatchAll',
111
112
  'on:message_reaction',
112
113
  'catch',
@@ -0,0 +1,352 @@
1
+ /**
2
+ * narration-leak-3513.test.ts — outcome-asserting regression suite for
3
+ * switchroom#3513 ("agent intent-narration leaks into the final Telegram reply
4
+ * as a real chat message").
5
+ *
6
+ * The single-surface invariant: every trailing plain-text block is assigned once
7
+ * to exactly one surface — delivered chat answer / ephemeral progress card /
8
+ * suppressed — by ONE shared classifier, and NO delivery path (E1 turn-flush,
9
+ * E2 quiescence, E3 captured-prose bridge, E4 outbox sweep) ever emits a block
10
+ * classified as structural narration; the one genuine unsent answer still
11
+ * delivers exactly once.
12
+ *
13
+ * Every test here asserts an OUTCOME (was the narration delivered? was the real
14
+ * answer delivered?), not merely that a code path ran, and each is written to be
15
+ * RED on the pre-fix code (which had two hand-synced regex classifiers and no
16
+ * structural/ledger guard).
17
+ */
18
+
19
+ import { describe, it, expect, vi } from 'vitest'
20
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+
24
+ import {
25
+ isStructuralNarration,
26
+ isNarrationBlock,
27
+ ledgerHashHex,
28
+ SUBSTANTIVE_MIN_CHARS,
29
+ } from '../hooks/narration-classify.mjs'
30
+ import { selectFlushDeliveryText, FLUSH_SUBSTANTIVE_MIN_CHARS } from '../turn-flush-safety.js'
31
+ import { decideOutboxSweep } from '../outbox.js'
32
+ import { decideCapturedProseDelivery } from '../silent-end.js'
33
+ import { appendShownBlock, isShownBlock, readShownHashes } from '../shown-ledger.js'
34
+ import { NarrativeFlushController } from '../narrative-flush.js'
35
+
36
+ const NARRATION = 'Now let me check the gateway logs to see what happened.'
37
+ const REAL_ANSWER =
38
+ 'The gateway crashed because the outbox sweep tried to deliver a record whose ' +
39
+ 'turnNonce had already been journaled, and the dedup cache had been evicted, so ' +
40
+ 'the exactly-once guard fell back to the text-dedup path which returned false. ' +
41
+ 'The fix is to check the shown-ledger before the quiet window, not after.'
42
+
43
+ // A substantive real message that the model happens to precede a NON-delivering
44
+ // tool with (react / pin / typing). Must NOT be treated as narration.
45
+ const SUBSTANTIVE_BEFORE_REACT = REAL_ANSWER
46
+
47
+ describe('shared classifier — the ONE source of truth (correction 2 + 3)', () => {
48
+ it('short/narration-shaped block followed by a tool IS structural narration', () => {
49
+ expect(isStructuralNarration(NARRATION, true)).toBe(true)
50
+ expect(isStructuralNarration('On it — pulling the numbers…', true)).toBe(true)
51
+ expect(isStructuralNarration('', true)).toBe(true)
52
+ })
53
+
54
+ it('SUBSTANTIVE block followed by a tool is NOT narration (react/pin/typing still delivers)', () => {
55
+ // The #3237 asymmetry: a real answer preceding react/pin/typing must deliver.
56
+ expect(SUBSTANTIVE_BEFORE_REACT.length).toBeGreaterThan(SUBSTANTIVE_MIN_CHARS)
57
+ expect(isStructuralNarration(SUBSTANTIVE_BEFORE_REACT, true)).toBe(false)
58
+ })
59
+
60
+ it('a terminal block (nothing followed it) is NEVER structural narration', () => {
61
+ // followedByToolUse false OR absent → never structural (could be the answer).
62
+ expect(isStructuralNarration(NARRATION, false)).toBe(false)
63
+ expect(isStructuralNarration(NARRATION, undefined)).toBe(false)
64
+ expect(isStructuralNarration('Yes.', false)).toBe(false)
65
+ })
66
+
67
+ it('FLUSH_SUBSTANTIVE_MIN_CHARS is wired to the shared constant (no drift)', () => {
68
+ expect(FLUSH_SUBSTANTIVE_MIN_CHARS).toBe(SUBSTANTIVE_MIN_CHARS)
69
+ })
70
+ })
71
+
72
+ describe('E1/E2 turn-flush — narration never becomes the delivered answer', () => {
73
+ it('drops a trailing narration block that a tool followed, keeps the real answer', () => {
74
+ const out = selectFlushDeliveryText([REAL_ANSWER, NARRATION], [false, true])
75
+ expect(out).toContain(REAL_ANSWER)
76
+ expect(out).not.toContain(NARRATION)
77
+ })
78
+
79
+ it('drops a SHORT non-regex block a tool followed (the class the old regex missed)', () => {
80
+ // "The logs show three errors." matches NO wording heuristic (no opener, no
81
+ // trailing ellipsis/colon) — the pre-fix regex-only classifier would have
82
+ // DELIVERED it. It is structural narration only because a tool followed it
83
+ // AND it is under the substantive floor. This is the core #3513 discriminator.
84
+ const shortNonRegex = 'The logs show three errors.'
85
+ expect(isNarrationBlock(shortNonRegex)).toBe(false)
86
+ expect(shortNonRegex.length).toBeLessThan(SUBSTANTIVE_MIN_CHARS)
87
+ const out = selectFlushDeliveryText([REAL_ANSWER, shortNonRegex], [false, true])
88
+ expect(out).toContain(REAL_ANSWER)
89
+ expect(out).not.toContain(shortNonRegex)
90
+ })
91
+
92
+ it('zero-reply, narration-only turn (all blocks followed by tools) flushes NOTHING', () => {
93
+ const out = selectFlushDeliveryText(['Let me pull the logs.', NARRATION], [true, true])
94
+ expect(out).toBe('')
95
+ })
96
+
97
+ it('a genuine terminal answer with no provenance still flushes (fail-open)', () => {
98
+ // Provenance-less single block — conservative: deliver it.
99
+ expect(selectFlushDeliveryText(['Here are the results:'])).toBe('Here are the results:')
100
+ })
101
+
102
+ it('a SUBSTANTIVE answer followed by a non-delivering tool STILL flushes', () => {
103
+ const out = selectFlushDeliveryText([SUBSTANTIVE_BEFORE_REACT], [true])
104
+ expect(out).toBe(SUBSTANTIVE_BEFORE_REACT)
105
+ })
106
+ })
107
+
108
+ describe('E4 outbox sweep — a ledger-marked block is never re-delivered (correction 1)', () => {
109
+ const base = {
110
+ now: 10_000_000,
111
+ deliveredNonces: new Set<string>(),
112
+ textAlreadyDelivered: false,
113
+ routable: true,
114
+ quietMs: 0,
115
+ }
116
+
117
+ it('skips a record whose text was shown on the ephemeral card', () => {
118
+ const d = decideOutboxSweep({
119
+ ...base,
120
+ record: { turnNonce: 'c:_#5', text: NARRATION, createdAt: 0 },
121
+ shownLedgerHit: true,
122
+ })
123
+ expect(d.action).toBe('skip-ephemeral-shown')
124
+ expect(d.text).toBeUndefined()
125
+ })
126
+
127
+ it('delivers a real answer that was NOT shown on the card', () => {
128
+ const d = decideOutboxSweep({
129
+ ...base,
130
+ record: { turnNonce: 'c:_#5', text: REAL_ANSWER, createdAt: base.now },
131
+ shownLedgerHit: false,
132
+ })
133
+ expect(d.action).toBe('send')
134
+ expect(d.text).toContain(REAL_ANSWER)
135
+ })
136
+
137
+ it('the ledger hit is checked AFTER journaled (exactly-once wins) but BEFORE quiet', () => {
138
+ // Already-journaled record short-circuits regardless of ledger state.
139
+ const journaled = decideOutboxSweep({
140
+ ...base,
141
+ deliveredNonces: new Set(['c:_#5']),
142
+ record: { turnNonce: 'c:_#5', text: NARRATION, createdAt: 0 },
143
+ shownLedgerHit: true,
144
+ })
145
+ expect(journaled.action).toBe('skip-journaled')
146
+ })
147
+ })
148
+
149
+ describe('E4 outbox sweep × durable shown-ledger — end-to-end wiring (correction 1 + 4)', () => {
150
+ // Integration test: exercise the REAL ledger round-trip (append/markShown +
151
+ // isShownBlock/isBlockShown) keyed by turnNonce THROUGH the outbox-sweep
152
+ // decision, mirroring the production composition at
153
+ // gateway/outbox-sweep.ts:122 (`shownLedgerHit: isShownBlock(record.turnNonce,
154
+ // record.text, deps.stateDir)`). Unlike the unit tests above that pass a
155
+ // hand-set `shownLedgerHit` boolean, this drives the flag FROM the durable
156
+ // ledger file — so if the ledger wiring is removed (append becomes a no-op, or
157
+ // decideOutboxSweep stops consulting the hit), the skip assertion goes RED.
158
+ const base = {
159
+ now: 10_000_000,
160
+ deliveredNonces: new Set<string>(),
161
+ textAlreadyDelivered: false,
162
+ routable: true,
163
+ quietMs: 0,
164
+ }
165
+
166
+ it('a block marked shown for its turnNonce is skipped; a non-shown block delivers', () => {
167
+ const dir = mkdtempSync(join(tmpdir(), 'outbox-ledger-'))
168
+ try {
169
+ const nonce = 'c:_#42'
170
+ // markShown: the mid-turn narrative paint appended this block to the
171
+ // durable ledger for THIS turn's nonce.
172
+ appendShownBlock(nonce, NARRATION, dir)
173
+
174
+ // Sweep the SAME (nonce, text): the production wiring derives the flag from
175
+ // the ledger, so the record must be skipped as ephemeral-shown.
176
+ const shown = decideOutboxSweep({
177
+ ...base,
178
+ record: { turnNonce: nonce, text: NARRATION, createdAt: 0 },
179
+ shownLedgerHit: isShownBlock(nonce, NARRATION, dir),
180
+ })
181
+ expect(isShownBlock(nonce, NARRATION, dir)).toBe(true)
182
+ expect(shown.action).toBe('skip-ephemeral-shown')
183
+ expect(shown.text).toBeUndefined()
184
+
185
+ // A NON-shown block (the genuine unsent answer, never appended) still
186
+ // delivers exactly once — the ledger miss falls through to send.
187
+ const notShown = decideOutboxSweep({
188
+ ...base,
189
+ record: { turnNonce: nonce, text: REAL_ANSWER, createdAt: base.now },
190
+ shownLedgerHit: isShownBlock(nonce, REAL_ANSWER, dir),
191
+ })
192
+ expect(isShownBlock(nonce, REAL_ANSWER, dir)).toBe(false)
193
+ expect(notShown.action).toBe('send')
194
+ expect(notShown.text).toContain(REAL_ANSWER)
195
+
196
+ // Cross-turn isolation: the SAME text under a DIFFERENT turnNonce is not a
197
+ // ledger hit, so it delivers (no cross-turn suppression through the sweep).
198
+ const otherTurn = decideOutboxSweep({
199
+ ...base,
200
+ record: { turnNonce: 'c:_#43', text: NARRATION, createdAt: base.now },
201
+ shownLedgerHit: isShownBlock('c:_#43', NARRATION, dir),
202
+ })
203
+ expect(otherTurn.action).toBe('send')
204
+ } finally {
205
+ rmSync(dir, { recursive: true, force: true })
206
+ }
207
+ })
208
+ })
209
+
210
+ describe('E3 captured-prose bridge — a shown block is refused (correction 1)', () => {
211
+ /** Write a real silent-end-pending.json into a temp stateDir. */
212
+ function withState(text: string, run: (deps: { stateDir: string }) => void): void {
213
+ const dir = mkdtempSync(join(tmpdir(), 'silent-end-'))
214
+ try {
215
+ writeFileSync(
216
+ join(dir, 'silent-end-pending.json'),
217
+ JSON.stringify({
218
+ chatId: 'c',
219
+ threadId: null,
220
+ turnKey: 'c:_',
221
+ turnId: 'c:_#5',
222
+ pendingText: text,
223
+ retryCount: 0,
224
+ timestamp: Date.now(),
225
+ }),
226
+ )
227
+ run({ stateDir: dir })
228
+ } finally {
229
+ rmSync(dir, { recursive: true, force: true })
230
+ }
231
+ }
232
+
233
+ it('refuses to bridge prose already surfaced on the card', () => {
234
+ withState(REAL_ANSWER, ({ stateDir }) => {
235
+ const d = decideCapturedProseDelivery(
236
+ { turnKey: 'c:_', turnId: 'c:_#5', minChars: 10 },
237
+ { stateDir, isBlockShown: () => true },
238
+ )
239
+ expect(d.deliver).toBe(false)
240
+ expect(d.reason).toBe('ephemeral-shown')
241
+ })
242
+ })
243
+
244
+ it('bridges a genuine unsent answer that was NOT shown (empty-capture divergence)', () => {
245
+ withState(REAL_ANSWER, ({ stateDir }) => {
246
+ const d = decideCapturedProseDelivery(
247
+ { turnKey: 'c:_', turnId: 'c:_#5', minChars: 10 },
248
+ { stateDir, isBlockShown: () => false },
249
+ )
250
+ expect(d.deliver).toBe(true)
251
+ if (d.deliver) expect(d.text).toContain(REAL_ANSWER)
252
+ })
253
+ })
254
+ })
255
+
256
+ describe('durable shown-ledger — round trip + envelope-less no-op (correction 4)', () => {
257
+ it('marks then recognises a structural narration block for its turnNonce', () => {
258
+ const dir = mkdtempSync(join(tmpdir(), 'shown-ledger-'))
259
+ try {
260
+ appendShownBlock('c:_#7', NARRATION, dir)
261
+ expect(isShownBlock('c:_#7', NARRATION, dir)).toBe(true)
262
+ // A different turn's nonce must NOT match (no cross-turn suppression).
263
+ expect(isShownBlock('c:_#8', NARRATION, dir)).toBe(false)
264
+ // The stored hash matches the shared classifier hash.
265
+ expect(readShownHashes('c:_#7', dir).has(ledgerHashHex(NARRATION))).toBe(true)
266
+ } finally {
267
+ rmSync(dir, { recursive: true, force: true })
268
+ }
269
+ })
270
+
271
+ it('a null turnNonce (envelope-less handback/cron) is never written', () => {
272
+ const dir = mkdtempSync(join(tmpdir(), 'shown-ledger-'))
273
+ try {
274
+ appendShownBlock(null, NARRATION, dir)
275
+ expect(isShownBlock(null, NARRATION, dir)).toBe(false)
276
+ expect(readShownHashes(null, dir).size).toBe(0)
277
+ } finally {
278
+ rmSync(dir, { recursive: true, force: true })
279
+ }
280
+ })
281
+ })
282
+
283
+ /** Fake at-most-one scheduler mirroring the real unref'd setTimeout wiring. */
284
+ class FakeScheduler {
285
+ private fn: (() => void) | null = null
286
+ arm(fn: () => void): void {
287
+ this.fn = fn
288
+ }
289
+ disarm(): void {
290
+ this.fn = null
291
+ }
292
+ fire(): void {
293
+ const fn = this.fn
294
+ this.fn = null
295
+ fn?.()
296
+ }
297
+ }
298
+
299
+ describe('narrative controller — durable-mark ONLY mid-turn narration (correction 4)', () => {
300
+ function make() {
301
+ const show = vi.fn<[string], void>()
302
+ const retractShown = vi.fn<[string], void>()
303
+ const markDurableNarration = vi.fn<[string], void>()
304
+ const scheduler = new FakeScheduler()
305
+ const ctrl = new NarrativeFlushController(
306
+ { show, retractShown, markDurableNarration },
307
+ scheduler,
308
+ 250,
309
+ )
310
+ return { ctrl, show, markDurableNarration, scheduler }
311
+ }
312
+
313
+ it('marks a block SHOWN mid-turn because another narration block followed it (stage)', () => {
314
+ const { ctrl, show, markDurableNarration } = make()
315
+ ctrl.stage('First, let me check the logs…')
316
+ ctrl.stage('Now scanning the outbox…') // lookahead → shows + marks the first
317
+ expect(show).toHaveBeenCalledWith('First, let me check the logs…')
318
+ expect(markDurableNarration).toHaveBeenCalledWith('First, let me check the logs…')
319
+ })
320
+
321
+ it('marks a block SHOWN mid-turn because a tool followed it (resolveOnTool)', () => {
322
+ const { ctrl, markDurableNarration } = make()
323
+ ctrl.stage(NARRATION)
324
+ ctrl.resolveOnTool('Bash', { command: 'grep' }) // non-reply tool lookahead
325
+ expect(markDurableNarration).toHaveBeenCalledWith(NARRATION)
326
+ })
327
+
328
+ it('NEVER marks the timer-painted terminal block (could be the real answer)', () => {
329
+ const { ctrl, show, markDurableNarration, scheduler } = make()
330
+ ctrl.stage(REAL_ANSWER)
331
+ scheduler.fire() // timer paints the LAST block early
332
+ expect(show).toHaveBeenCalledWith(REAL_ANSWER)
333
+ expect(markDurableNarration).not.toHaveBeenCalled()
334
+ })
335
+
336
+ it('NEVER marks the turn-end paint (terminal, could be the real answer)', () => {
337
+ const { ctrl, show, markDurableNarration } = make()
338
+ ctrl.stage(REAL_ANSWER)
339
+ ctrl.flushAtTurnEnd('') // no reply delivered → shows trailing prose, but never marks
340
+ expect(show).toHaveBeenCalledWith(REAL_ANSWER)
341
+ expect(markDurableNarration).not.toHaveBeenCalled()
342
+ })
343
+
344
+ it('does NOT mark a SUBSTANTIVE block even mid-turn (substance-gated, correction 3)', () => {
345
+ const { ctrl, markDurableNarration } = make()
346
+ ctrl.stage(SUBSTANTIVE_BEFORE_REACT)
347
+ ctrl.resolveOnTool('react', { emoji: '👍' }) // non-delivering tool
348
+ // Followed by a tool, but substantive → not structural narration → not marked,
349
+ // so a backstop could still deliver it if it were the genuine unsent answer.
350
+ expect(markDurableNarration).not.toHaveBeenCalled()
351
+ })
352
+ })