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.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +4 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +1027 -509
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +4 -0
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -3
- package/telegram-plugin/gateway/outbox-sweep.ts +73 -5
- package/telegram-plugin/gateway/rich-message-handler.ts +235 -0
- package/telegram-plugin/gateway/stream-render.ts +107 -15
- package/telegram-plugin/gateway/unhandled-message.ts +14 -0
- package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
- package/telegram-plugin/hooks/narration-classify.mjs +210 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +33 -7
- package/telegram-plugin/hooks/silent-end-scan.mjs +171 -85
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +87 -0
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
- package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
- package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +600 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +19 -11
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
- package/telegram-plugin/tests/silent-end.test.ts +7 -1
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- 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,
|
|
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
|
-
|
|
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-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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: {
|
|
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
|
-
{
|
|
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
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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
|