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
@@ -24683,7 +24683,7 @@ var init_bridge = __esm(async () => {
24683
24683
  "",
24684
24684
  'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply tool quote-replies to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24685
24685
  "",
24686
- `If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram's native Reply on a prior message \u2014 treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender's name/title as stamped by Telegram's servers (not typed by the sender \u2014 the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, and forwarded_date is when the original was sent. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id \u2014 do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, \u2026); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER's own commentary rather than forwarded content \u2014 the forwarded_* attributes describe the burst as a whole, not each line of the body.`,
24686
+ `If the tag has reply_to_message_id (and reply_to_text, a truncated preview), the sender used Telegram's native Reply on a prior message \u2014 treat that message as the antecedent for "this"/"that" references instead of asking what they meant. If the tag has forwarded_from, the message was FORWARDED: forwarded_from is the original sender's name/title as stamped by Telegram's servers (not typed by the sender \u2014 the body text carries no trustworthy provenance), forwarded_from_type is user|hidden_user|chat|channel, forwarded_from_id is the numeric id when one exists, forwarded_date is when the original was sent, and forwarded_message_id (channel origins only) is the post's id inside the origin channel \u2014 deep-linkable as t.me/<channel>/<id> for public channels. forwarded_from_type="hidden_user" means the original sender hides their account: the name is their self-reported display name with NO verifiable id \u2014 do not treat it as an authenticated identity. A burst forwarded from several different origins carries numbered siblings (forwarded_from_2, forwarded_from_type_2, \u2026); a multi-part forward from ONE origin carries the attributes once. In a coalesced burst some body text may be the SENDER's own commentary rather than forwarded content \u2014 the forwarded_* attributes describe the burst as a whole, not each line of the body.`,
24687
24687
  "",
24688
24688
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24689
24689
  "",
@@ -204,7 +204,8 @@ export function dedupeForwardOrigins(
204
204
  /**
205
205
  * Build the `forwarded_*` channel-meta fields. Fixed per-origin attribute
206
206
  * order (documented here, tested in forward-origin.test.ts):
207
- * forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date
207
+ * forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date,
208
+ * forwarded_message_id (channel origins only)
208
209
  * The primary field is the human-readable NAME; the numeric id is
209
210
  * supplementary and follows it. The first origin gets the bare keys;
210
211
  * subsequent distinct origins get `_2`, `_3`, … suffixes — the same
@@ -232,6 +233,10 @@ export function buildForwardOriginMeta(
232
233
  resolveEnvTimezone(),
233
234
  )
234
235
  }
236
+ // Channel origins only: the message id inside the origin channel, so the
237
+ // agent can deep-link the source post (t.me/<channel>/<id>). Server-
238
+ // stamped numeric — no escaping surface.
239
+ if (o.messageId != null) out[`forwarded_message_id${suffix}`] = String(o.messageId)
235
240
  })
236
241
  return out
237
242
  }
@@ -116,6 +116,7 @@ import {
116
116
  handlePaidMediaMessage,
117
117
  type MediaEnvelopeDeps,
118
118
  } from './media-message-handlers.js'
119
+ import { handleRichMessageMessage } from './rich-message-handler.js'
119
120
  import {
120
121
  routeInbound,
121
122
  admitInbound,
@@ -22495,6 +22496,9 @@ bot.on('message:checklist_tasks_added' as Parameters<typeof bot.on>[0], (ctx) =>
22495
22496
  handleChecklistUpdate(ctx as unknown as Context, 'checklist_tasks_added', checklistHandlerDeps)
22496
22497
  })
22497
22498
  bot.on('message:pinned_message', ctx => handlePinnedMessage(ctx, pinnedMessageHandlerDeps))
22499
+ // Bot API 10.1 rich messages (forwarded bot messages carry these with NO
22500
+ // text/caption — see rich-message-handler.ts; MUST precede the catch-all).
22501
+ bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, mediaEnvelopeDeps))
22498
22502
  installUnhandledMessageCatchAll(
22499
22503
  bot,
22500
22504
  (ctx, text) => routeInbound(ctx, text, undefined, undefined, inboundRouterDeps),
@@ -49,6 +49,7 @@
49
49
  import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
50
50
  import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
51
51
  import { richMessage } from '../rich-send.js'
52
+ import { appendShownBlock } from '../shown-ledger.js'
52
53
  import {
53
54
  appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
54
55
  } from '../tool-activity-summary.js'
@@ -186,6 +187,16 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
186
187
  {
187
188
  show: (text) => showNarrativeStep(turn, text),
188
189
  retractShown: (text) => retractNarrativeLine(turn, text),
190
+ // #3513 (correction 4): persist the ephemeral-shown mark keyed by the
191
+ // per-turn nonce so the out-of-process backstops (E3/E4) refuse to
192
+ // re-deliver this card-only narration. Envelope-bearing turns only —
193
+ // `turnId` is null for handback/background/cron, where the structural
194
+ // rule in the shared classifier is the sole guard (should-fix noted in
195
+ // shown-ledger.ts). Skip when null.
196
+ markDurableNarration: (text) => {
197
+ if (turn.turnId == null) return
198
+ appendShownBlock(turn.turnId, text)
199
+ },
189
200
  },
190
201
  {
191
202
  arm: (fn, ms) => {
@@ -1805,7 +1805,7 @@ export async function sendReply(
1805
1805
  // text differs. Gated so an interim-ack edit never journals the turn
1806
1806
  // nonce (which would suppress a later genuine answer for the turn).
1807
1807
  if (isFinalAnswerReply({ text: decision.mergedText, disableNotification: modelDisableNotification })) {
1808
- journalExternalDelivery({ turnNonce: turn?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId })
1808
+ journalExternalDelivery({ turnNonce: turn?.turnId ?? null, text: decision.mergedText, tgMessageId: decision.messageId, replyAlreadyDeliveredThisTurn: true })
1809
1809
  }
1810
1810
 
1811
1811
  silentAnchorEditDone = true
@@ -2372,7 +2372,7 @@ export async function sendReply(
2372
2372
  // won't double-post it) while an interim ack never journals (so a later
2373
2373
  // genuinely-undelivered final answer is delivered by the sweep).
2374
2374
  if (shouldJournalReplySiteDelivery({ text: rawText, disableNotification: modelDisableNotification })) {
2375
- journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1] })
2375
+ journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: true })
2376
2376
  }
2377
2377
  }
2378
2378
  return { content: [{ type: 'text', text: result }] }
@@ -2483,7 +2483,13 @@ export async function deliverCapturedProse(
2483
2483
  outboundDedup.record(chatId, threadId, text, now, registryKey)
2484
2484
  // F1: captured-prose delivery journals + clears under the shared nonce
2485
2485
  // (`originTurnId` === turn.turnId === deriveTurnId, the hook's nonce).
2486
- journalExternalDelivery({ turnNonce: originTurnId, text, tgMessageId: sentIds[sentIds.length - 1] })
2486
+ // #3510/#3511: the bridge only runs when NO genuine final answer was
2487
+ // delivered this turn (`decideTurnEndGate` → 'reprompt' requires
2488
+ // `finalAnswerDelivered === false`), so stamp `false` explicitly. A
2489
+ // journal line from this site following a reply-tool line under the same
2490
+ // nonce (replyAlreadyDeliveredThisTurn:true) is direct, journal-only
2491
+ // proof of a bridge double-send.
2492
+ journalExternalDelivery({ turnNonce: originTurnId, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: false })
2487
2493
  process.stderr.write(
2488
2494
  `telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
2489
2495
  `transcript scan (chat=${chatId} origin=${originTurnId})\n`,
@@ -42,6 +42,7 @@ import {
42
42
  sha256Hex,
43
43
  type OutboxRecord,
44
44
  } from '../outbox.js'
45
+ import { isShownBlock } from '../shown-ledger.js'
45
46
  import { resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
46
47
  import { createRetryApiCall, retryWithThreadFallback } from '../retry-api-call.js'
47
48
 
@@ -115,11 +116,32 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
115
116
  routable: resolved != null,
116
117
  routePrefix,
117
118
  quietMs: deps.quietMs ?? OUTBOX_QUIET_MS,
119
+ // #3513: suppress a record whose text was already surfaced on the ephemeral
120
+ // progress card for this turn (durable shown-ledger). The invariant forbids
121
+ // a second, out-of-process delivery of an ephemeral-shown block.
122
+ shownLedgerHit: isShownBlock(record.turnNonce, record.text, deps.stateDir),
118
123
  })
119
124
 
120
125
  if (decision.action !== 'send' && decision.action !== 'send-delayed') {
121
126
  summary.skipped++
122
- if (decision.action === 'skip-journaled') {
127
+ if (decision.action === 'skip-ephemeral-shown') {
128
+ // Ephemeral-shown (#3513): the block already lives on the progress card.
129
+ // Journal the nonce and drop the record so the sweep never re-scans it
130
+ // and a racing machine also skips — same terminal bookkeeping as a dedup
131
+ // hit, but the reason is "assigned to the ephemeral surface".
132
+ appendDelivered(
133
+ {
134
+ turnNonce: record.turnNonce,
135
+ textSha256: record.textSha256,
136
+ ts: now,
137
+ deliverySource: 'sweep',
138
+ replyAlreadyDeliveredThisTurn: record.replyAlreadyDeliveredThisTurn === true,
139
+ },
140
+ deps.stateDir,
141
+ )
142
+ clearOutboxRecord(record.turnNonce, deps.stateDir)
143
+ log(`outbox-sweep: suppressed ephemeral-shown nonce=${record.turnNonce}\n`)
144
+ } else if (decision.action === 'skip-journaled') {
123
145
  // Already delivered under this nonce by another machine → drop the
124
146
  // pending record. clearOutboxRecord unlinks the `.json` (the pending
125
147
  // file) AND any `.sending` — the pre-fix `removeClaimed` only unlinked
@@ -133,7 +155,13 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
133
155
  // exactly-once, independent of cache lifetime and surviving a restart
134
156
  // (the record is gone from disk, the nonce is durably journaled).
135
157
  appendDelivered(
136
- { turnNonce: record.turnNonce, textSha256: record.textSha256, ts: now },
158
+ {
159
+ turnNonce: record.turnNonce,
160
+ textSha256: record.textSha256,
161
+ ts: now,
162
+ deliverySource: 'sweep',
163
+ replyAlreadyDeliveredThisTurn: record.replyAlreadyDeliveredThisTurn === true,
164
+ },
137
165
  deps.stateDir,
138
166
  )
139
167
  clearOutboxRecord(record.turnNonce, deps.stateDir)
@@ -156,7 +184,18 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
156
184
  decision.text ?? record.text,
157
185
  )
158
186
  appendDelivered(
159
- { turnNonce: record.turnNonce, textSha256: record.textSha256, tgMessageId: messageId, ts: now },
187
+ {
188
+ turnNonce: record.turnNonce,
189
+ textSha256: record.textSha256,
190
+ tgMessageId: messageId,
191
+ ts: now,
192
+ // #3510 instrumentation: a sweep delivery journals its machine and the
193
+ // record's capture-time reply-already-delivered flag, so a duplicate
194
+ // (deliverySource:'sweep' after a reply-tool delivery of the same
195
+ // turn) is provable from the journal alone.
196
+ deliverySource: 'sweep',
197
+ replyAlreadyDeliveredThisTurn: record.replyAlreadyDeliveredThisTurn === true,
198
+ },
160
199
  deps.stateDir,
161
200
  )
162
201
  removeClaimed(record.turnNonce, deps.stateDir)
@@ -266,14 +305,43 @@ export function startOutboxSweep(deps: {
266
305
  * Best-effort; never throws. A null/empty nonce is ignored (nothing to journal).
267
306
  */
268
307
  export function journalExternalDelivery(
269
- args: { turnNonce: string | null; text: string; tgMessageId?: number },
308
+ args: {
309
+ turnNonce: string | null
310
+ text: string
311
+ tgMessageId?: number
312
+ /**
313
+ * #3510 instrumentation: whether a qualifying reply had already delivered
314
+ * this turn at the time of this delivery. Reply-send sites pass `true`
315
+ * (they ARE that delivery); the captured-prose bridge omits it (its turn
316
+ * shape is decided upstream). Journaled so a later sweep entry under the
317
+ * same nonce is provably a double-send from the journal alone.
318
+ */
319
+ replyAlreadyDeliveredThisTurn?: boolean
320
+ /**
321
+ * #3513 follow-up: which backstop/machine delivered. Defaults to
322
+ * `'reply-tool'` (the E0/E3 reply-path callers). The turn-flush backstop
323
+ * (E1/E2) passes `'flush'` so `backstopAlreadyDelivered` recognises it as a
324
+ * prior backstop and E3/E4 skip a duplicate durably (across a crash between
325
+ * the flush send and its journal write), not just via the in-memory dedup.
326
+ */
327
+ deliverySource?: 'sweep' | 'reply-tool' | 'flush'
328
+ },
270
329
  stateDir?: string,
271
330
  now: number = Date.now(),
272
331
  ): void {
273
332
  const nonce = args.turnNonce
274
333
  if (nonce == null || nonce === '') return
275
334
  appendDelivered(
276
- { turnNonce: nonce, textSha256: sha256Hex(args.text), tgMessageId: args.tgMessageId, ts: now },
335
+ {
336
+ turnNonce: nonce,
337
+ textSha256: sha256Hex(args.text),
338
+ tgMessageId: args.tgMessageId,
339
+ ts: now,
340
+ deliverySource: args.deliverySource ?? 'reply-tool',
341
+ ...(args.replyAlreadyDeliveredThisTurn == null
342
+ ? {}
343
+ : { replyAlreadyDeliveredThisTurn: args.replyAlreadyDeliveredThisTurn }),
344
+ },
277
345
  stateDir,
278
346
  )
279
347
  clearOutboxRecord(nonce, stateDir)
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Rich-message inbound handler (forwarded-body drop fix).
3
+ *
4
+ * THE BUG THIS CLOSES (evidence: carrie history.db row message_id=944,
5
+ * 2026-07-24 06:03; gateway-supervisor.log update_id=417526125
6
+ * `content_keys=[forward_from,forward_date,rich_message]`): a message whose
7
+ * content is a Bot API 10.1 `rich_message` — typically a FORWARDED bot
8
+ * message, since the gateway itself sends everything via `sendRichMessage` —
9
+ * carries NO top-level `text` or `caption`. It therefore matched none of the
10
+ * registered `message:text` / `:photo` / `:caption` handlers, fell through to
11
+ * the terminal catch-all (`unhandled-message.ts`), and the agent received the
12
+ * placeholder `(unhandled message content: forward_from)` instead of the real
13
+ * body. The forwarded content was silently dropped.
14
+ *
15
+ * grammy ^1.44 DOES ship a `message:rich_message` filter (see
16
+ * `@grammyjs/types` `RichMessage` / `RichBlock` / `RichText`) — the gateway
17
+ * simply never registered it. This module renders the inbound block tree to
18
+ * plain markdown-ish text deterministically and routes it through the SAME
19
+ * inbound pipeline as `message:text` (access gating + forward-origin parsing
20
+ * happen downstream, unchanged — origin metadata stays on the trusted
21
+ * `<channel>`-attr lane per #3162; nothing here injects provenance into the
22
+ * body).
23
+ *
24
+ * Registration stays in gateway.ts (order pinned by
25
+ * gateway-handler-registration-wiring.test.ts): `message:rich_message` is
26
+ * registered BEFORE the terminal catch-all, so the catch-all no longer sees
27
+ * these messages at all. `planUnhandledMessage` also imports
28
+ * `extractRichMessageText` as belt-and-braces for any future shape that
29
+ * carries a `rich_message` alongside unknown content.
30
+ */
31
+
32
+ import type { Context, Filter } from 'grammy'
33
+ import type { MediaEnvelopeDeps } from './media-message-handlers.js'
34
+
35
+ /**
36
+ * Delivered body when a rich message renders to nothing extractable (e.g. a
37
+ * pure media/divider composition). Distinct from the catch-all's
38
+ * `(unhandled message content: …)` placeholder — this one names the actual
39
+ * situation and still yields a turn (fail-toward-delivery).
40
+ */
41
+ export const RICH_MESSAGE_EMPTY_TEXT = '(rich message with no extractable text)'
42
+
43
+ /** Recursion guard: the Bot API caps rich messages at 16 nesting levels; a
44
+ * hostile/malformed payload deeper than this is truncated, not stack-overflowed. */
45
+ const MAX_DEPTH = 32
46
+
47
+ interface RichTextNode {
48
+ type?: string
49
+ text?: unknown
50
+ expression?: string
51
+ alternative_text?: string
52
+ name?: string
53
+ }
54
+
55
+ /**
56
+ * Flatten a `RichText` union (string | RichText[] | typed node) to plain
57
+ * text. Inline formatting (bold/italic/…) is dropped — the agent needs the
58
+ * CONTENT; re-marking inline spans risks re-introducing accidental-formatting
59
+ * classes the render guards exist to kill. Leaf specials keep their readable
60
+ * payload: custom emoji → alternative text, math → LaTeX source, anchors →
61
+ * nothing.
62
+ */
63
+ export function renderRichText(rt: unknown, depth = 0): string {
64
+ if (depth > MAX_DEPTH || rt == null) return ''
65
+ if (typeof rt === 'string') return rt
66
+ if (Array.isArray(rt)) return rt.map(t => renderRichText(t, depth + 1)).join('')
67
+ if (typeof rt !== 'object') return ''
68
+ const node = rt as RichTextNode
69
+ if (node.type === 'custom_emoji') return node.alternative_text ?? ''
70
+ if (node.type === 'mathematical_expression') {
71
+ return typeof node.expression === 'string' ? node.expression : ''
72
+ }
73
+ if (node.type === 'anchor') return ''
74
+ // Every other RichText node (bold, italic, url, mention, code, …) wraps a
75
+ // `text: RichText` payload — recurse into it.
76
+ if ('text' in node) return renderRichText(node.text, depth + 1)
77
+ return ''
78
+ }
79
+
80
+ interface RichBlockNode {
81
+ type?: string
82
+ text?: unknown
83
+ credit?: unknown
84
+ language?: string
85
+ expression?: string
86
+ summary?: unknown
87
+ blocks?: unknown
88
+ items?: Array<{ label?: string; blocks?: unknown; has_checkbox?: boolean; is_checked?: boolean }>
89
+ cells?: Array<Array<{ text?: unknown }>>
90
+ caption?: { text?: unknown; credit?: unknown } | unknown
91
+ }
92
+
93
+ function renderCaption(caption: unknown, depth: number): string {
94
+ if (caption == null || typeof caption !== 'object') return renderRichText(caption, depth)
95
+ const c = caption as { text?: unknown; credit?: unknown }
96
+ const text = renderRichText(c.text, depth)
97
+ const credit = renderRichText(c.credit, depth)
98
+ return [text, credit ? `— ${credit}` : ''].filter(Boolean).join(' ')
99
+ }
100
+
101
+ function renderBlocks(blocks: unknown, depth: number): string {
102
+ if (depth > MAX_DEPTH || !Array.isArray(blocks)) return ''
103
+ return blocks
104
+ .map(b => renderRichBlock(b, depth + 1))
105
+ .filter(s => s.length > 0)
106
+ .join('\n')
107
+ }
108
+
109
+ /** Render one `RichBlock` to a plain-text line/paragraph. Unknown block types
110
+ * degrade to their `text` / nested `blocks` when present (fail-toward-content:
111
+ * a future block type should surface its words, not vanish). */
112
+ export function renderRichBlock(block: unknown, depth = 0): string {
113
+ if (depth > MAX_DEPTH || block == null || typeof block !== 'object') return ''
114
+ const b = block as RichBlockNode
115
+ switch (b.type) {
116
+ case 'paragraph':
117
+ case 'heading':
118
+ case 'footer':
119
+ return renderRichText(b.text, depth)
120
+ case 'pre': {
121
+ const body = renderRichText(b.text, depth)
122
+ return body.length > 0 ? `\`\`\`${b.language ?? ''}\n${body}\n\`\`\`` : ''
123
+ }
124
+ case 'divider':
125
+ return '---'
126
+ case 'mathematical_expression':
127
+ return typeof b.expression === 'string' ? b.expression : ''
128
+ case 'anchor':
129
+ return ''
130
+ case 'list': {
131
+ if (!Array.isArray(b.items)) return ''
132
+ return b.items
133
+ .map(item => {
134
+ const body = renderBlocks(item?.blocks, depth)
135
+ const check = item?.has_checkbox ? (item.is_checked ? '[x] ' : '[ ] ') : ''
136
+ const label = typeof item?.label === 'string' && item.label.length > 0 ? item.label : '-'
137
+ return body.length > 0 ? `${label} ${check}${body}` : ''
138
+ })
139
+ .filter(s => s.length > 0)
140
+ .join('\n')
141
+ }
142
+ case 'blockquote': {
143
+ const body = renderBlocks(b.blocks, depth)
144
+ const credit = renderRichText(b.credit, depth)
145
+ const quoted = body
146
+ .split('\n')
147
+ .map(line => `> ${line}`)
148
+ .join('\n')
149
+ return [quoted, credit ? `> — ${credit}` : ''].filter(s => s.length > 0).join('\n')
150
+ }
151
+ case 'pullquote': {
152
+ const body = renderRichText(b.text, depth)
153
+ const credit = renderRichText(b.credit, depth)
154
+ return [body, credit ? `— ${credit}` : ''].filter(Boolean).join(' ')
155
+ }
156
+ case 'details': {
157
+ const summary = renderRichText(b.summary, depth)
158
+ const body = renderBlocks(b.blocks, depth)
159
+ return [summary, body].filter(s => s.length > 0).join('\n')
160
+ }
161
+ case 'collage':
162
+ case 'slideshow': {
163
+ const body = renderBlocks(b.blocks, depth)
164
+ const caption = renderCaption(b.caption, depth)
165
+ return [body, caption].filter(s => s.length > 0).join('\n')
166
+ }
167
+ case 'table': {
168
+ if (!Array.isArray(b.cells)) return ''
169
+ const rows = b.cells
170
+ .map(row =>
171
+ Array.isArray(row)
172
+ ? row.map(cell => renderRichText(cell?.text, depth)).join(' | ')
173
+ : '',
174
+ )
175
+ .filter(s => s.length > 0)
176
+ .join('\n')
177
+ const caption = renderRichText((b as { caption?: unknown }).caption, depth)
178
+ return [caption, rows].filter(s => s.length > 0).join('\n')
179
+ }
180
+ case 'map':
181
+ case 'animation':
182
+ case 'audio':
183
+ case 'photo':
184
+ case 'video':
185
+ case 'voice_note': {
186
+ // Media blocks: the binary itself is not downloaded here (a forwarded
187
+ // rich message's media has no coalescing/attachment plumbing yet — see
188
+ // PR description follow-up); the caption text IS the readable content.
189
+ const caption = renderCaption(b.caption, depth)
190
+ const tag = `[${b.type}]`
191
+ return caption.length > 0 ? `${tag} ${caption}` : tag
192
+ }
193
+ case 'thinking':
194
+ return ''
195
+ default: {
196
+ // Unknown/future block type — surface any text-ish payload it carries.
197
+ const text = renderRichText(b.text, depth)
198
+ if (text.length > 0) return text
199
+ return renderBlocks(b.blocks, depth)
200
+ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Extract the readable text of an inbound `message.rich_message`, or
206
+ * `undefined` when nothing extractable exists. Accepts `unknown` — the value
207
+ * arrives from the Telegram wire and must never throw the handler.
208
+ */
209
+ export function extractRichMessageText(rich: unknown): string | undefined {
210
+ if (rich == null || typeof rich !== 'object') return undefined
211
+ const blocks = (rich as { blocks?: unknown }).blocks
212
+ const rendered = renderBlocks(blocks, 0)
213
+ .replace(/\n{3,}/g, '\n\n')
214
+ .trim()
215
+ return rendered.length > 0 ? rendered : undefined
216
+ }
217
+
218
+ /**
219
+ * `message:rich_message` handler — same dispatch surface as the cluster-A
220
+ * media-envelope handlers: renders the body and hands it to the normal
221
+ * coalescing inbound pipeline (access gating + forward-origin parsing
222
+ * downstream, identical to `message:text`).
223
+ */
224
+ export async function handleRichMessageMessage(
225
+ ctx: Filter<Context, 'message:rich_message'>,
226
+ deps: MediaEnvelopeDeps,
227
+ ): Promise<void> {
228
+ try {
229
+ const text = extractRichMessageText(ctx.message.rich_message) ?? RICH_MESSAGE_EMPTY_TEXT
230
+ deps.log(`telegram gateway: inbound rich_message from chat=${ctx.chat?.id ?? '?'} chars=${text.length}\n`)
231
+ await deps.handleInbound(ctx, text, undefined)
232
+ } catch (err) {
233
+ deps.log(`telegram gateway: rich_message handler error: ${(err as Error).message}\n`)
234
+ }
235
+ }
@@ -59,11 +59,15 @@ import { normalizeOutboundBody } from './outbound-send-path.js'
59
59
  import { resolveEnvTimezone } from '../shared/local-time.js'
60
60
  import { hasOutboundDeliveredSince, recordOutbound } from '../history.js'
61
61
  import { isReplyTool } from '../narrative-dedup.js'
62
+ import { isEphemeralTool } from '../hooks/narration-classify.mjs'
63
+ import { backstopAlreadyDelivered } from '../outbox.js'
64
+ import { journalExternalDelivery } from './outbox-sweep.js'
62
65
  import { NarrativeFlushController } from '../narrative-flush.js'
63
66
  import { recordTurnEnd, recordTurnStart } from '../registry/turns-schema.js'
64
67
  import { retryWithThreadFallback } from '../retry-api-call.js'
65
68
  import { richMessage } from '../rich-send.js'
66
69
  import { emitRuntimeMetric } from '../runtime-metrics.js'
70
+ import { isShownBlock } from '../shown-ledger.js'
67
71
  import { CAPTURED_PROSE_MIN_CHARS, clearSilentEndState, decideCapturedProseDelivery, recordUndeliveredTurnEnd, silentEndFallbackText, writeSilentEndState } from '../silent-end.js'
68
72
  import { logStreamingEvent } from '../streaming-metrics.js'
69
73
  import { appendActivityLabel } from '../tool-activity-summary.js'
@@ -583,11 +587,21 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
583
587
  case 'tool_use': {
584
588
  const turn = getCurrentTurn()
585
589
  if (turn == null) return
586
- // PR A the model resumed work (surface or otherwise). Cancel any pending
587
- // answer-ready quiescence flush: the turn is no longer quiescent. (Fire-time
588
- // re-verification would also catch this, but disarming here avoids a wasted
589
- // wakeup and matches the design's disarm-on-tool requirement.)
590
- clearAnswerReadyFlushTimeout(turn)
590
+ // #3513 follow-up (MF1 + MF4b) a TURN-CONTINUING tool_use (any tool NOT
591
+ // in the ephemeral surface set) is the deterministic signal that every text
592
+ // block captured so far in this turn was intra-turn narration, not the
593
+ // terminal answer. Retro-mark ALL existing captured blocks
594
+ // (`capturedBlockMeta.fill(true)`) so the cross-message shape
595
+ // ([text-only message] → [tool_use in the NEXT message]) is actually seen
596
+ // by `selectBackstopDelivery` — the per-message `!ev.lastInMessage` push at
597
+ // the `text` case under-detects it. The SAME ephemeral gate protects the
598
+ // E2 answer-ready fast path: a trailing ephemeral tool (answer, then
599
+ // react / pin / typing / edit / delete) must neither mark the answer
600
+ // interim NOR disarm the quiescence flush.
601
+ if (!isEphemeralTool(ev.toolName)) {
602
+ turn.capturedBlockMeta.fill(true)
603
+ clearAnswerReadyFlushTimeout(turn)
604
+ }
591
605
  // Narrative-dedup gate step 2 (JSONL-text-narrative primitive): a
592
606
  // narrative block was pending; this tool_use is the lookahead event
593
607
  // that decides it. reply/stream_reply with near-identical text ⇒
@@ -678,8 +692,13 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
678
692
  // PR A — a tool_label (real-time, ~250 ms) means the model is producing
679
693
  // work right now: cancel any pending answer-ready quiescence flush (the
680
694
  // turn is not quiescent). Fires ahead of the JSONL tool_use, so it disarms
681
- // the timer at the earliest deterministic point.
682
- clearAnswerReadyFlushTimeout(turn)
695
+ // the timer at the earliest deterministic point. MF4b: an EPHEMERAL surface
696
+ // tool (react / pin / typing / edit / delete fired after a terminal answer)
697
+ // must NOT disarm the quiescence flush — gate on the same ephemeral check as
698
+ // the tool_use reducer so answer-then-react keeps the E2 fast path.
699
+ if (!isEphemeralTool(ev.toolName)) {
700
+ clearAnswerReadyFlushTimeout(turn)
701
+ }
683
702
  // SECONDARY FIX: an active tool_label means the model is producing work
684
703
  // right now — re-arm the orphaned-reply fuse so a multi-phase tool turn
685
704
  // (write → compile → test → fix) that regularly emits labels doesn't let
@@ -826,6 +845,20 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
826
845
  // negation is the draft-then-send narration signal the turn-flush strip
827
846
  // uses (`selectFlushDeliveryText`) to keep a real answer intact instead
828
847
  // of truncating a paragraph that merely opens with "Let me explain…".
848
+ //
849
+ // NOTE (naming/approximation, #3515 review nit): downstream this value is
850
+ // consumed as `followedByToolUse`, but `!ev.lastInMessage` is a CONSERVATIVE
851
+ // APPROXIMATION of that predicate, not an exact match. It is true when the
852
+ // block is not the last block in its assistant message OR when a tool_use
853
+ // follows it in the same turn — i.e. it can over-flag: a block that is
854
+ // genuinely last-in-message may still be marked true. Over-approximation is
855
+ // SAFE here by construction: a `true` flag only ever makes a block a
856
+ // candidate for structural-narration suppression (selectFlushDeliveryText /
857
+ // isStructuralNarration), so the worst case is suppressing MORE narration —
858
+ // it can never promote an answer into the drop path. A real terminal answer
859
+ // is protected independently (it is never followed by a tool and survives
860
+ // the strip). Do not "tighten" this to the exact predicate expecting a
861
+ // behavioural change: the runtime value is deliberately conservative.
829
862
  turn.capturedBlockMeta.push(!ev.lastInMessage)
830
863
  // Narrative-dedup gate step 1 (JSONL-text-narrative primitive):
831
864
  // stage this text block for one lookahead step. If a previous block
@@ -1739,6 +1772,24 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1739
1772
  } catch {}
1740
1773
  }
1741
1774
 
1775
+ // #3513 follow-up (MF2) — DURABLE exactly-once-among-backstops read.
1776
+ // The in-memory `backstopDeliveryLedger` (guard 5, below) only sees
1777
+ // fires within THIS process; it cannot see a prior backstop delivery
1778
+ // that landed in an earlier process (e.g. the Stop-hook captured-prose
1779
+ // bridge E3, or the outbox sweep E4, delivered this turn's answer, then
1780
+ // the gateway restarted and re-ran turn-flush). `backstopAlreadyDelivered`
1781
+ // scans the durable delivered-keys journal counting ONLY prior BACKSTOP
1782
+ // deliveries (sweep / flush / non-E0 reply-tool) for this nonce — it does
1783
+ // NOT count an explicit E0 reply (#3510 recap), so a legitimate later
1784
+ // explicit reply is never blocked by this guard. If a backstop already
1785
+ // delivered, this fire is a durable no-op.
1786
+ if (backstopAlreadyDelivered(turn.turnId, STATE_DIR)) {
1787
+ process.stderr.write(
1788
+ `telegram gateway: turn-flush skipped — turn ${turn.turnId} already delivered by a prior backstop (durable journal)\n`,
1789
+ )
1790
+ return
1791
+ }
1792
+
1742
1793
  // #3276 guard 5 — double-fire guard. If this turn already claimed the
1743
1794
  // delivery latch (a prior backstop fire — e.g. answer-ready quiescence
1744
1795
  // followed by the turn-end backstop for the same turn), do NOT deliver
@@ -1869,6 +1920,33 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1869
1920
  chunkCount,
1870
1921
  cardMessageId: backstopCardMessageId,
1871
1922
  })
1923
+ // #3513 follow-up (MF2) — DURABLE exactly-once-among-backstops
1924
+ // WRITE. Journal this backstop delivery to the delivered-keys
1925
+ // journal with `deliverySource:'flush'` so a later backstop in a
1926
+ // DIFFERENT process (the Stop-hook bridge E3 or the outbox sweep E4,
1927
+ // which read `backstopAlreadyDelivered`) recognises this turn's
1928
+ // answer as already delivered and skips a duplicate durably — not
1929
+ // only via the in-memory ledger, which does not survive a crash
1930
+ // between this send and the next process's backstop. Journal ONLY on
1931
+ // a receipt-gated `delivered` success. Best-effort: a journal-write
1932
+ // failure must never demote the successful delivery.
1933
+ if (delivered) {
1934
+ try {
1935
+ journalExternalDelivery(
1936
+ {
1937
+ turnNonce: turn.turnId,
1938
+ text: capturedText,
1939
+ tgMessageId: sentIds.length > 0 ? sentIds[0] : undefined,
1940
+ deliverySource: 'flush',
1941
+ },
1942
+ STATE_DIR,
1943
+ )
1944
+ } catch (err) {
1945
+ process.stderr.write(
1946
+ `telegram gateway: turn-flush delivered but journal write failed (non-fatal): ${(err as Error).message}\n`,
1947
+ )
1948
+ }
1949
+ }
1872
1950
  if (OBLIGATION_LEDGER_ENABLED) {
1873
1951
  if (delivered) {
1874
1952
  obligationLedger.close(turn.turnId)
@@ -2012,14 +2090,28 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2012
2090
  const proseMinChars =
2013
2091
  !turn.replyCalled && gatewayCapturedEmpty ? 1 : CAPTURED_PROSE_MIN_CHARS
2014
2092
  const proseDecision = CAPTURED_PROSE_DELIVERY_ENABLED
2015
- ? decideCapturedProseDelivery({
2016
- turnKey: tKey,
2017
- // Per-turn nonce (#3228 Finding 3) — the persisted record must
2018
- // belong to THIS turn, not a stale carryover from a prior turn
2019
- // on the same chat/thread (tKey is not per-turn unique).
2020
- turnId: turn.turnId,
2021
- minChars: proseMinChars,
2022
- })
2093
+ ? decideCapturedProseDelivery(
2094
+ {
2095
+ turnKey: tKey,
2096
+ // Per-turn nonce (#3228 Finding 3) the persisted record must
2097
+ // belong to THIS turn, not a stale carryover from a prior turn
2098
+ // on the same chat/thread (tKey is not per-turn unique).
2099
+ turnId: turn.turnId,
2100
+ minChars: proseMinChars,
2101
+ },
2102
+ {
2103
+ // #3513 (correction 1): refuse to bridge a block already
2104
+ // surfaced on the ephemeral card for this turn (shown-ledger).
2105
+ isBlockShown: (nonce, text) => isShownBlock(nonce ?? null, text),
2106
+ // #3513 follow-up (MF2): refuse to bridge a turn a prior
2107
+ // backstop (turn-flush E1/E2 flush, or the outbox sweep E4)
2108
+ // already delivered — durable exactly-once-among-backstops,
2109
+ // scoped to backstop deliveries only (never an explicit E0
2110
+ // reply, so a genuine later reply is unaffected).
2111
+ backstopDeliveredNonceHit: (nonce) =>
2112
+ backstopAlreadyDelivered(nonce ?? '', STATE_DIR),
2113
+ },
2114
+ )
2023
2115
  : { deliver: false as const, reason: 'no-state' as const }
2024
2116
  if (proseDecision.deliver && proseDecision.text != null) {
2025
2117
  // Deliver the recovered answer directly. This runs async and owns