switchroom 0.19.14 → 0.19.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +31 -2
- package/telegram-plugin/dist/gateway/gateway.js +1690 -932
- package/telegram-plugin/dist/server.js +31 -2
- package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
- package/telegram-plugin/gateway/forward-origin.ts +6 -1
- package/telegram-plugin/gateway/gateway.ts +10 -57
- package/telegram-plugin/gateway/narrative-lane.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
- package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
- package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
- package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -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-scan.mjs +136 -82
- package/telegram-plugin/narrative-flush.ts +35 -0
- package/telegram-plugin/outbox.ts +73 -3
- package/telegram-plugin/session-tail.ts +88 -1
- package/telegram-plugin/shown-ledger.ts +145 -0
- package/telegram-plugin/silence-poke.ts +118 -1
- package/telegram-plugin/silent-end.ts +42 -0
- package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -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/feed-survival.test.ts +7 -1
- package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
- package/telegram-plugin/tests/forward-origin.test.ts +20 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -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-sweep-listen-button.test.ts +253 -0
- package/telegram-plugin/tests/session-tail.test.ts +91 -1
- package/telegram-plugin/tests/silence-poke.test.ts +280 -0
- 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/tts-normalize.test.ts +66 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
- package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
- package/telegram-plugin/tts-normalize.ts +12 -0
- package/telegram-plugin/turn-flush-safety.ts +66 -53
- package/telegram-plugin/voice-normalize-text.ts +100 -0
- package/telegram-plugin/voice-ondemand.ts +71 -0
|
@@ -17365,6 +17365,29 @@ function extractToolResultErrorText(content) {
|
|
|
17365
17365
|
}
|
|
17366
17366
|
return "";
|
|
17367
17367
|
}
|
|
17368
|
+
function parseBackgroundTaskId(obj) {
|
|
17369
|
+
const tur = obj.toolUseResult;
|
|
17370
|
+
if (typeof tur === "object" && tur != null) {
|
|
17371
|
+
const id = tur.backgroundTaskId;
|
|
17372
|
+
if (typeof id === "string" && id.length > 0)
|
|
17373
|
+
return id;
|
|
17374
|
+
}
|
|
17375
|
+
return null;
|
|
17376
|
+
}
|
|
17377
|
+
function parseBackgroundLaunchString(content) {
|
|
17378
|
+
const text = typeof content === "string" ? content : extractToolResultErrorText(content);
|
|
17379
|
+
const m = text.match(/Command running in background with ID: (\w+)/);
|
|
17380
|
+
return m != null ? m[1] : null;
|
|
17381
|
+
}
|
|
17382
|
+
function parseTaskNotification(content) {
|
|
17383
|
+
if (!content.includes("<task-notification>"))
|
|
17384
|
+
return null;
|
|
17385
|
+
const idM = content.match(/<task-id>([^<]+)<\/task-id>/);
|
|
17386
|
+
const stM = content.match(/<status>([^<]+)<\/status>/);
|
|
17387
|
+
if (idM == null || stM == null)
|
|
17388
|
+
return null;
|
|
17389
|
+
return { taskId: idM[1].trim(), status: stM[1].trim() };
|
|
17390
|
+
}
|
|
17368
17391
|
function projectAssistantTextBlocks(content, make) {
|
|
17369
17392
|
const out = new Map;
|
|
17370
17393
|
let lastToolUseIdx = -1;
|
|
@@ -17422,6 +17445,10 @@ function projectTranscriptLine(line) {
|
|
|
17422
17445
|
const op = obj.operation;
|
|
17423
17446
|
if (op === "enqueue") {
|
|
17424
17447
|
const content = obj.content ?? "";
|
|
17448
|
+
const notif = parseTaskNotification(content);
|
|
17449
|
+
if (notif != null) {
|
|
17450
|
+
return [{ kind: "task_notification", taskId: notif.taskId, status: notif.status }];
|
|
17451
|
+
}
|
|
17425
17452
|
const { chatId, messageId, threadId } = parseChannelMeta(content);
|
|
17426
17453
|
return [{ kind: "enqueue", chatId, messageId, threadId, rawContent: content }];
|
|
17427
17454
|
}
|
|
@@ -17479,6 +17506,7 @@ function projectTranscriptLine(line) {
|
|
|
17479
17506
|
const content = message?.content;
|
|
17480
17507
|
if (!Array.isArray(content))
|
|
17481
17508
|
return [];
|
|
17509
|
+
const backgroundTaskId = parseBackgroundTaskId(obj);
|
|
17482
17510
|
const events = [];
|
|
17483
17511
|
for (const c of content) {
|
|
17484
17512
|
if (c.type === "tool_result") {
|
|
@@ -17488,7 +17516,8 @@ function projectTranscriptLine(line) {
|
|
|
17488
17516
|
toolUseId: c.tool_use_id ?? "",
|
|
17489
17517
|
toolName: null,
|
|
17490
17518
|
isError,
|
|
17491
|
-
errorText: isError ? extractToolResultErrorText(c.content) : undefined
|
|
17519
|
+
errorText: isError ? extractToolResultErrorText(c.content) : undefined,
|
|
17520
|
+
backgroundTaskId: backgroundTaskId ?? parseBackgroundLaunchString(c.content) ?? undefined
|
|
17492
17521
|
});
|
|
17493
17522
|
}
|
|
17494
17523
|
}
|
|
@@ -24683,7 +24712,7 @@ var init_bridge = __esm(async () => {
|
|
|
24683
24712
|
"",
|
|
24684
24713
|
'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
24714
|
"",
|
|
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,
|
|
24715
|
+
`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
24716
|
"",
|
|
24688
24717
|
`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
24718
|
"",
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// #3519 sharpen — background-shell liveness signal wiring.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from gateway.ts (the file is under a hard line ratchet — see
|
|
4
|
+
// switchroom#2996). Maps parsed session events to the silence-poke
|
|
5
|
+
// background-shell registry so the 300s silence-fallback defers ONLY while a
|
|
6
|
+
// CLI-side background shell is proven alive, and recovers at ~300s once it
|
|
7
|
+
// finishes. Called once per session event from the gateway's event loop.
|
|
8
|
+
//
|
|
9
|
+
// The three real, deterministic markers (all proven from captured transcript
|
|
10
|
+
// data — carrie session a6d2d33a-…, claude v2.1.197; see the fixture at
|
|
11
|
+
// telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl):
|
|
12
|
+
// • ALIVE — a tool_result carrying `backgroundTaskId` (a foreground Bash the
|
|
13
|
+
// CLI auto-moved to the background past its ~120s window, or an explicit
|
|
14
|
+
// run_in_background:true). session-tail resolves it from the structured
|
|
15
|
+
// `toolUseResult.backgroundTaskId` field, with the launch string as a
|
|
16
|
+
// secondary.
|
|
17
|
+
// • DEAD (completed/failed) — the CLI's proactive `<task-notification>`,
|
|
18
|
+
// projected as a `task_notification` event.
|
|
19
|
+
// • DEAD (explicit) — a `KillShell` tool_use naming the shell via
|
|
20
|
+
// `input.shell_id`.
|
|
21
|
+
import type { SessionEvent } from '../session-tail.js'
|
|
22
|
+
|
|
23
|
+
/** The subset of silence-poke this wiring drives (kept narrow for testability). */
|
|
24
|
+
export interface BackgroundShellRegistry {
|
|
25
|
+
noteBackgroundShellAlive(key: string, shellId: string): void
|
|
26
|
+
noteBackgroundShellDead(key: string, shellId: string): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Terminal `<task-notification>` statuses that genuinely mean the shell ended. */
|
|
30
|
+
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed'])
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Apply the background-shell liveness signal carried by `ev` (if any) to the
|
|
34
|
+
* registry for `key`. A no-op for events that carry no marker, so the caller
|
|
35
|
+
* can invoke it unconditionally on every session event.
|
|
36
|
+
*/
|
|
37
|
+
export function applyBackgroundShellLiveness(
|
|
38
|
+
registry: BackgroundShellRegistry,
|
|
39
|
+
key: string,
|
|
40
|
+
ev: SessionEvent,
|
|
41
|
+
): void {
|
|
42
|
+
if (ev.kind === 'tool_result') {
|
|
43
|
+
if (ev.backgroundTaskId != null && ev.backgroundTaskId.length > 0) {
|
|
44
|
+
registry.noteBackgroundShellAlive(key, ev.backgroundTaskId)
|
|
45
|
+
}
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (ev.kind === 'task_notification') {
|
|
49
|
+
// Defensive terminal-status gate: real captured data proves every genuine
|
|
50
|
+
// parseable <task-notification> carries a terminal status, so today ANY
|
|
51
|
+
// parsed notification is safe to treat as DEAD. This guards a hypothetical
|
|
52
|
+
// FUTURE CLI that emits an interim (non-terminal) notification for a still-
|
|
53
|
+
// running shell — we must not drop such a shell from the alive-set early.
|
|
54
|
+
if (ev.taskId.length > 0 && TERMINAL_STATUSES.has(ev.status)) {
|
|
55
|
+
registry.noteBackgroundShellDead(key, ev.taskId)
|
|
56
|
+
}
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (ev.kind === 'tool_use' && ev.toolName === 'KillShell') {
|
|
60
|
+
const killId = (ev.input as { shell_id?: string } | undefined)?.shell_id
|
|
61
|
+
if (typeof killId === 'string' && killId.length > 0) {
|
|
62
|
+
registry.noteBackgroundShellDead(key, killId)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -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
|
}
|
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
import {
|
|
61
61
|
VoiceOnDemandCache,
|
|
62
62
|
} from '../voice-ondemand.js'
|
|
63
|
+
import { makeOutboxListenMarkupResolver } from './outbox-listen-markup.js'
|
|
63
64
|
import {
|
|
64
65
|
PreSynthQueue,
|
|
65
66
|
sweepVoiceCacheDir,
|
|
@@ -116,6 +117,7 @@ import {
|
|
|
116
117
|
handlePaidMediaMessage,
|
|
117
118
|
type MediaEnvelopeDeps,
|
|
118
119
|
} from './media-message-handlers.js'
|
|
120
|
+
import { handleRichMessageMessage } from './rich-message-handler.js'
|
|
119
121
|
import {
|
|
120
122
|
routeInbound,
|
|
121
123
|
admitInbound,
|
|
@@ -274,7 +276,6 @@ import { createSessionModelSource } from './session-model-source.js'
|
|
|
274
276
|
import { runSilentTurnHeartbeatTick } from '../feed-heartbeat-climb.js'
|
|
275
277
|
import { REPLY_TOOLS } from '../narrative-dedup.js'
|
|
276
278
|
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
|
|
277
|
-
import { toolLabel } from '../tool-labels.js'
|
|
278
279
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
279
280
|
import { createTurnTypingLoop } from './turn-typing-loop.js'
|
|
280
281
|
import {
|
|
@@ -750,6 +751,7 @@ import { createDeliveryConfirmWiring } from './delivery-confirm-wiring.js'
|
|
|
750
751
|
import { createObligationWiring } from './obligation-wiring.js'
|
|
751
752
|
// #2996 P8 PR-C3 — the extracted silence-poke wiring.
|
|
752
753
|
import { buildSilencePokeOptions } from './liveness-wiring.js'
|
|
754
|
+
import { applySilencePokeSessionEvent } from './silence-poke-session-event.js'
|
|
753
755
|
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
754
756
|
import { dispatchEffects } from './inbound-delivery-machine-dispatch.js'
|
|
755
757
|
import { maybeFireWarmup } from './prefix-warmup.js'
|
|
@@ -9849,7 +9851,7 @@ function runDeliveryConfirmSweep(): void {
|
|
|
9849
9851
|
const _deliveryConfirmSweep = isGatewayMain ? setInterval(runDeliveryConfirmSweep, DELIVERY_CONFIRM_SWEEP_MS) : undefined
|
|
9850
9852
|
_deliveryConfirmSweep?.unref?.()
|
|
9851
9853
|
|
|
9852
|
-
startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook
|
|
9854
|
+
startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers (../outbox.ts)
|
|
9853
9855
|
|
|
9854
9856
|
// #1445 cross-turn pending-async ambient. When a turn ends after the
|
|
9855
9857
|
// model dispatched background async work (Agent / Task / Bash run-in-
|
|
@@ -10870,61 +10872,7 @@ if (isGatewayMain) ipcServer = createIpcServer({
|
|
|
10870
10872
|
// (thinking vs working, plus the longest-running in-flight tool).
|
|
10871
10873
|
if (currentTurn != null) {
|
|
10872
10874
|
const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId)
|
|
10873
|
-
|
|
10874
|
-
silencePoke.noteThinking(key, Date.now())
|
|
10875
|
-
} else if (ev.kind === 'tool_use') {
|
|
10876
|
-
// #1292: track in-flight tool calls so the 300s framework
|
|
10877
|
-
// fallback message can name the actual observable (e.g.
|
|
10878
|
-
// "running Grep \"foo\" for 4m") instead of the dishonest
|
|
10879
|
-
// generic "still working… no update in 5 min" when the agent
|
|
10880
|
-
// is clearly busy on tool calls. Telegram-surface tools are
|
|
10881
|
-
// excluded — their job IS the outbound message, the silence
|
|
10882
|
-
// clock resets via noteOutbound when they fire. Sub-agent
|
|
10883
|
-
// tool_use events (kind='sub_agent_tool_use') intentionally
|
|
10884
|
-
// NOT tracked: the parent's Task tool_use is already on the
|
|
10885
|
-
// map and represents the user-observable wait.
|
|
10886
|
-
if (
|
|
10887
|
-
ev.toolUseId != null
|
|
10888
|
-
&& ev.toolUseId.length > 0
|
|
10889
|
-
&& !isTelegramSurfaceTool(ev.toolName)
|
|
10890
|
-
) {
|
|
10891
|
-
const label = toolLabel(
|
|
10892
|
-
ev.toolName,
|
|
10893
|
-
ev.input,
|
|
10894
|
-
/*preamble*/ undefined,
|
|
10895
|
-
ev.precomputedLabel,
|
|
10896
|
-
)
|
|
10897
|
-
silencePoke.noteToolStart(
|
|
10898
|
-
key,
|
|
10899
|
-
ev.toolUseId,
|
|
10900
|
-
ev.toolName,
|
|
10901
|
-
label.length > 0 ? label : null,
|
|
10902
|
-
Date.now(),
|
|
10903
|
-
)
|
|
10904
|
-
// #1445 cross-turn pending-async ambient. Mark the chat as
|
|
10905
|
-
// having dispatched background work this turn so a turn_end
|
|
10906
|
-
// that follows activates the edit-in-place ambient line.
|
|
10907
|
-
// Covers `Agent` / `Task` (the harness-managed async path
|
|
10908
|
-
// — handback channel turn clears it) and `Bash` with
|
|
10909
|
-
// run_in_background:true (model is expected to poll
|
|
10910
|
-
// BashOutput; the ambient ticks until next inbound or the
|
|
10911
|
-
// 30-min budget cap).
|
|
10912
|
-
const evInput = ev.input as { run_in_background?: boolean } | undefined
|
|
10913
|
-
if (
|
|
10914
|
-
ev.toolName === 'Agent'
|
|
10915
|
-
|| ev.toolName === 'Task'
|
|
10916
|
-
|| (ev.toolName === 'Bash' && evInput?.run_in_background === true)
|
|
10917
|
-
) {
|
|
10918
|
-
pendingProgress.noteAsyncDispatch(key)
|
|
10919
|
-
}
|
|
10920
|
-
}
|
|
10921
|
-
} else if (ev.kind === 'tool_result') {
|
|
10922
|
-
// #1292: drain the in-flight entry. Idempotent on unknown ids
|
|
10923
|
-
// (covers Telegram-surface tools we skipped at start time).
|
|
10924
|
-
if (ev.toolUseId != null && ev.toolUseId.length > 0) {
|
|
10925
|
-
silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
|
|
10926
|
-
}
|
|
10927
|
-
}
|
|
10875
|
+
applySilencePokeSessionEvent(silencePoke, pendingProgress, key, ev)
|
|
10928
10876
|
}
|
|
10929
10877
|
},
|
|
10930
10878
|
|
|
@@ -22495,6 +22443,11 @@ bot.on('message:checklist_tasks_added' as Parameters<typeof bot.on>[0], (ctx) =>
|
|
|
22495
22443
|
handleChecklistUpdate(ctx as unknown as Context, 'checklist_tasks_added', checklistHandlerDeps)
|
|
22496
22444
|
})
|
|
22497
22445
|
bot.on('message:pinned_message', ctx => handlePinnedMessage(ctx, pinnedMessageHandlerDeps))
|
|
22446
|
+
// Bot API 10.1 rich messages (forwarded bot messages carry these with NO
|
|
22447
|
+
// text/caption — see rich-message-handler.ts; MUST precede the catch-all).
|
|
22448
|
+
// Pure forwarded body text, no attachment → coalesce like `message:text`:
|
|
22449
|
+
// bind `handleInbound` to `handleInboundCoalesced`, not the bare one (#3516).
|
|
22450
|
+
bot.on('message:rich_message', ctx => handleRichMessageMessage(ctx, { ...mediaEnvelopeDeps, handleInbound: handleInboundCoalesced }))
|
|
22498
22451
|
installUnhandledMessageCatchAll(
|
|
22499
22452
|
bot,
|
|
22500
22453
|
(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) => {
|
|
@@ -65,9 +65,8 @@ import {
|
|
|
65
65
|
import { decideAnswerLatchSuppression, type ReplyOwnerTier } from '../reply-owner-resolve.js'
|
|
66
66
|
import { deriveTelegraphTitle } from '../telegraph.js'
|
|
67
67
|
import {
|
|
68
|
-
mintVoiceOnDemandToken,
|
|
69
|
-
buildListenKeyboard,
|
|
70
68
|
mayInjectListenButton,
|
|
69
|
+
planListenButton,
|
|
71
70
|
type VoiceOnDemandCache,
|
|
72
71
|
} from '../voice-ondemand.js'
|
|
73
72
|
import { eagerVoiceEnabled, type PreSynthQueue } from '../voice-presynth.js'
|
|
@@ -1495,31 +1494,34 @@ export async function sendReply(
|
|
|
1495
1494
|
// config never reaches here (it synthesized immediately above), so a Listen
|
|
1496
1495
|
// button is never minted for an engine whose taps would dead-end on the
|
|
1497
1496
|
// local sidecar.
|
|
1498
|
-
if (
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
if (
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1497
|
+
if (useOnDemandButton) {
|
|
1498
|
+
// planListenButton is the SHARED decision (also used by the durable-outbox
|
|
1499
|
+
// safety net, outbox-sweep.ts) — it enforces the empty-TTS guard and the
|
|
1500
|
+
// agent-keyboard collision gate. Null = don't inject.
|
|
1501
|
+
const listenPlan = planListenButton({ voiceOutPlan, rawKeyboard })
|
|
1502
|
+
if (listenPlan == null) {
|
|
1503
|
+
// Log the collision gate ONLY when the agent keyboard is the ACTUAL
|
|
1504
|
+
// reason we skipped — i.e. there IS speakable text. When ttsChunks is
|
|
1505
|
+
// empty the empty-TTS guard is what returned null (there was nothing to
|
|
1506
|
+
// speak), so a "skipping — agent supplied inline_keyboard" line would
|
|
1507
|
+
// misattribute the reason.
|
|
1508
|
+
const hasSpeakableText =
|
|
1509
|
+
voiceOutPlan!.ttsChunks.length > 0 && voiceOutPlan!.ttsChunks[0]!.length > 0
|
|
1510
|
+
if (hasSpeakableText && !mayInjectListenButton(rawKeyboard)) {
|
|
1511
|
+
process.stderr.write(
|
|
1512
|
+
'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
|
|
1513
|
+
)
|
|
1514
|
+
}
|
|
1507
1515
|
} else {
|
|
1508
1516
|
// Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
|
|
1509
1517
|
// invariant the operator is the only authorized sender across all chats,
|
|
1510
1518
|
// and the tap handler re-checks access.allowFrom before synthesizing, so
|
|
1511
1519
|
// a token needs no per-chat scoping to be safe.
|
|
1512
|
-
|
|
1513
|
-
voiceOnDemandCache.put(token, {
|
|
1514
|
-
// ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
|
|
1515
|
-
text: voiceOutPlan.ttsChunks[0]!,
|
|
1516
|
-
...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
|
|
1517
|
-
speed: voiceOutPlan.speed,
|
|
1518
|
-
})
|
|
1520
|
+
voiceOnDemandCache.put(listenPlan.token, listenPlan.payload)
|
|
1519
1521
|
// The keyboard stays after the tap (never stripped) so it can be
|
|
1520
1522
|
// replayed. The gate above guarantees this is the ONLY button on the
|
|
1521
1523
|
// message, so keeping it is safe (no agent buttons to protect).
|
|
1522
|
-
replyMarkup =
|
|
1524
|
+
replyMarkup = listenPlan.replyMarkup
|
|
1523
1525
|
// #2763 eager pre-synthesis: kick a background synth of the same
|
|
1524
1526
|
// payload so the Listen tap attaches the pre-made file instantly.
|
|
1525
1527
|
// Local-engine only (useOnDemandButton already gates engine==='kokoro'
|
|
@@ -1530,10 +1532,10 @@ export async function sendReply(
|
|
|
1530
1532
|
// to the lazy path).
|
|
1531
1533
|
if (eagerVoiceEnabled()) {
|
|
1532
1534
|
voicePreSynthQueue.enqueue({
|
|
1533
|
-
token,
|
|
1534
|
-
text:
|
|
1535
|
-
...(
|
|
1536
|
-
speed:
|
|
1535
|
+
token: listenPlan.token,
|
|
1536
|
+
text: listenPlan.payload.text,
|
|
1537
|
+
...(listenPlan.payload.voice != null ? { voice: listenPlan.payload.voice } : {}),
|
|
1538
|
+
speed: listenPlan.payload.speed,
|
|
1537
1539
|
})
|
|
1538
1540
|
}
|
|
1539
1541
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outbox-listen-markup.ts — wires the shared `planListenButton` decision into
|
|
3
|
+
* the durable-outbox safety-net delivery (outbox-sweep.ts), so a net-delivered
|
|
4
|
+
* final answer carries the SAME 🔊 Listen button / voice-out keyboard the normal
|
|
5
|
+
* `sendReply` path injects (switchroom #3502 regression: the sweep sent captured
|
|
6
|
+
* prose via a RAW `bot.api.sendMessage` with no voice-out resolution, dropping
|
|
7
|
+
* the button).
|
|
8
|
+
*
|
|
9
|
+
* Extracted from gateway.ts so the wiring lives in a module (the #2996 gateway
|
|
10
|
+
* anti-inflation ratchet) and is independently testable.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
planListenButton,
|
|
15
|
+
type ListenButtonVoiceOutPlan,
|
|
16
|
+
type VoiceOnDemandPayload,
|
|
17
|
+
} from '../voice-ondemand.js'
|
|
18
|
+
import type { OutboxDeliveryMarkup } from './outbox-sweep.js'
|
|
19
|
+
|
|
20
|
+
/** The minimal side-effecting deps the resolver needs — mirrors what `sendReply`
|
|
21
|
+
* does on the normal path (cache put + eager pre-synth), kept as an injected
|
|
22
|
+
* surface so gateway.ts passes its own singletons and this stays unit-testable. */
|
|
23
|
+
export interface OutboxListenMarkupDeps {
|
|
24
|
+
/** Resolve the agent's voice-out plan for a reply body (loadAccess().voice_out
|
|
25
|
+
* → resolveVoiceOutPlan). Returns null when voice-out is off / not applicable. */
|
|
26
|
+
resolveVoiceOutPlan: (replyText: string) => ListenButtonVoiceOutPlan | null
|
|
27
|
+
/** Persist a minted token so a Listen tap can resynthesize. A thin closure
|
|
28
|
+
* (not the cache instance) so the gateway can pass it before its module-scope
|
|
29
|
+
* `voiceOnDemandCache` const is initialized (this factory runs at wiring time,
|
|
30
|
+
* earlier in gateway.ts than the cache declaration). */
|
|
31
|
+
cachePut: (token: string, payload: VoiceOnDemandPayload) => void
|
|
32
|
+
/** True when eager pre-synth is enabled (kill switch off). */
|
|
33
|
+
eagerVoiceEnabled: () => boolean
|
|
34
|
+
/** Enqueue an eager pre-synth job (best-effort, off the critical path). */
|
|
35
|
+
enqueuePreSynth: (job: { token: string; text: string; voice?: string; speed: number }) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the `resolveReplyMarkup` callback for `startOutboxSweep`. Returns the
|
|
40
|
+
* Listen keyboard (and persists the token + eager-synth job) when kokoro
|
|
41
|
+
* on-demand voice-out is enabled and the empty-TTS / collision gates pass;
|
|
42
|
+
* otherwise undefined → the sweep delivers plain.
|
|
43
|
+
*
|
|
44
|
+
* A net-delivered captured-prose answer never carries an agent keyboard, so
|
|
45
|
+
* `rawKeyboard` is always undefined here; `planListenButton` still enforces the
|
|
46
|
+
* empty-TTS guard and the kokoro-on-demand gate.
|
|
47
|
+
*/
|
|
48
|
+
export function makeOutboxListenMarkupResolver(
|
|
49
|
+
deps: OutboxListenMarkupDeps,
|
|
50
|
+
): (chatId: string, threadId: number | null, text: string) => OutboxDeliveryMarkup | undefined {
|
|
51
|
+
return (_chatId, _threadId, text) => {
|
|
52
|
+
const voiceOutPlan = deps.resolveVoiceOutPlan(text)
|
|
53
|
+
const listenPlan = planListenButton({ voiceOutPlan, rawKeyboard: undefined })
|
|
54
|
+
if (listenPlan == null) return undefined
|
|
55
|
+
const payload: VoiceOnDemandPayload = listenPlan.payload
|
|
56
|
+
deps.cachePut(listenPlan.token, payload)
|
|
57
|
+
if (deps.eagerVoiceEnabled()) {
|
|
58
|
+
deps.enqueuePreSynth({
|
|
59
|
+
token: listenPlan.token,
|
|
60
|
+
text: payload.text,
|
|
61
|
+
...(payload.voice != null ? { voice: payload.voice } : {}),
|
|
62
|
+
speed: payload.speed,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return listenPlan.replyMarkup
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -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
|
|
@@ -222,39 +244,113 @@ export const OUTBOX_SWEEP_INTERVAL_MS = 5_000
|
|
|
222
244
|
* (`resolveSubagentOriginTurnKey`) then the last-real-inbound fallback (H3).
|
|
223
245
|
* Kill switch: `SWITCHROOM_TG_OUTBOX_DELIVERY=0`.
|
|
224
246
|
*/
|
|
247
|
+
/** A Telegram inline keyboard the sweep attaches to the FINAL delivered chunk
|
|
248
|
+
* (the 🔊 Listen button / voice-out keyboard — switchroom #3502 regression fix,
|
|
249
|
+
* so a net-delivered answer keeps the same button a `sendReply` answer gets).
|
|
250
|
+
* Shaped to match `planListenButton().replyMarkup`. */
|
|
251
|
+
export type OutboxDeliveryMarkup = {
|
|
252
|
+
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Minimal bot-api surface the sweep needs to deliver text. */
|
|
256
|
+
type OutboxSendBot = {
|
|
257
|
+
api: {
|
|
258
|
+
sendMessage: (
|
|
259
|
+
chatId: string,
|
|
260
|
+
text: string,
|
|
261
|
+
opts: object,
|
|
262
|
+
) => Promise<{ message_id?: number }>
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build the chunked `send` the sweep hands to {@link sweepOutbox}. Extracted +
|
|
268
|
+
* exported so the reply-markup attachment (the #3502 fix) is unit-testable
|
|
269
|
+
* without a live gateway or timer.
|
|
270
|
+
*
|
|
271
|
+
* `resolveReplyMarkup` resolves the voice-out Listen button / keyboard for a
|
|
272
|
+
* delivery (null/undefined → no button). It is applied to the FINAL chunk ONLY
|
|
273
|
+
* so the button lands on the last visible message, exactly like the normal
|
|
274
|
+
* `sendReply` path (buttons attach to the last chunk there too). This makes a
|
|
275
|
+
* safety-net-delivered final answer indistinguishable from a normally-delivered
|
|
276
|
+
* one instead of silently dropping the button.
|
|
277
|
+
*/
|
|
278
|
+
export function createOutboxSend(deps: {
|
|
279
|
+
getBot: () => OutboxSendBot | undefined
|
|
280
|
+
retry: Parameters<typeof retryWithThreadFallback>[0]
|
|
281
|
+
resolveReplyMarkup?: (
|
|
282
|
+
chatId: string,
|
|
283
|
+
threadId: number | null,
|
|
284
|
+
text: string,
|
|
285
|
+
) => OutboxDeliveryMarkup | undefined
|
|
286
|
+
}): OutboxSweepDeps['send'] {
|
|
287
|
+
return async (chatId, threadId, text) => {
|
|
288
|
+
const bot = deps.getBot()
|
|
289
|
+
if (bot == null) throw new Error('outbox-sweep: bot unavailable')
|
|
290
|
+
// Empty text → nothing to deliver. Telegram rejects an empty message body,
|
|
291
|
+
// so sending one chunk of '' would throw every tick and wedge the sweep in
|
|
292
|
+
// a permanent retry (the record never journals → never clears). Return
|
|
293
|
+
// early, matching the pre-refactor loop's zero-chunk behaviour.
|
|
294
|
+
if (text.length === 0) return undefined
|
|
295
|
+
// Resolve the Listen button / keyboard ONCE from the full answer text; it
|
|
296
|
+
// rides only on the final chunk below.
|
|
297
|
+
const replyMarkup = deps.resolveReplyMarkup?.(chatId, threadId, text)
|
|
298
|
+
// Chunk to Telegram's 4096-char ceiling; each chunk goes through the
|
|
299
|
+
// standard retry / flood-wait / thread-fallback wrapper. A thrown send
|
|
300
|
+
// propagates so the sweep releases the claim and retries next tick (the
|
|
301
|
+
// record is never journaled → never lost).
|
|
302
|
+
let lastId: number | undefined
|
|
303
|
+
const chunkCount = Math.ceil(text.length / 4000)
|
|
304
|
+
for (let i = 0, idx = 0; i < text.length; i += 4000, idx++) {
|
|
305
|
+
const chunk = text.slice(i, i + 4000)
|
|
306
|
+
const isLast = idx === chunkCount - 1
|
|
307
|
+
const res = await retryWithThreadFallback(
|
|
308
|
+
deps.retry,
|
|
309
|
+
(tid) => {
|
|
310
|
+
const base = tid != null ? { message_thread_id: tid } : {}
|
|
311
|
+
// Button on the LAST chunk only (final visible message).
|
|
312
|
+
const opts =
|
|
313
|
+
isLast && replyMarkup != null ? { ...base, reply_markup: replyMarkup } : base
|
|
314
|
+
return bot.api.sendMessage(chatId, chunk, opts)
|
|
315
|
+
},
|
|
316
|
+
{ threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
|
|
317
|
+
)
|
|
318
|
+
lastId = res?.message_id
|
|
319
|
+
}
|
|
320
|
+
return lastId
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
225
324
|
export function startOutboxSweep(deps: {
|
|
226
325
|
isGatewayMain: boolean
|
|
227
326
|
stateDir: string
|
|
228
|
-
getBot: () =>
|
|
327
|
+
getBot: () => OutboxSendBot | undefined
|
|
229
328
|
getTurnsDb: () => Parameters<typeof resolveSubagentOriginTurnKey>[0] | null
|
|
230
329
|
dedupCheck: (chatId: string, threadId: number | undefined, text: string) => boolean
|
|
330
|
+
/** Resolve the voice-out Listen button / keyboard for a net delivery. Wired
|
|
331
|
+
* by the gateway (loadAccess → resolveVoiceOutPlan → planListenButton +
|
|
332
|
+
* cache put + eager pre-synth). Absent → deliver plain (legacy behaviour). */
|
|
333
|
+
resolveReplyMarkup?: (
|
|
334
|
+
chatId: string,
|
|
335
|
+
threadId: number | null,
|
|
336
|
+
text: string,
|
|
337
|
+
) => OutboxDeliveryMarkup | undefined
|
|
231
338
|
log?: (line: string) => void
|
|
232
339
|
}): ReturnType<typeof setInterval> | undefined {
|
|
233
340
|
if (!deps.isGatewayMain || process.env.SWITCHROOM_TG_OUTBOX_DELIVERY === '0') return undefined
|
|
234
341
|
const retry = createRetryApiCall({ log: deps.log })
|
|
342
|
+
const send = createOutboxSend({
|
|
343
|
+
getBot: deps.getBot,
|
|
344
|
+
retry,
|
|
345
|
+
...(deps.resolveReplyMarkup != null ? { resolveReplyMarkup: deps.resolveReplyMarkup } : {}),
|
|
346
|
+
})
|
|
235
347
|
const tick = () => {
|
|
236
348
|
const bot = deps.getBot()
|
|
237
349
|
if (bot == null) return
|
|
238
350
|
void sweepOutbox({
|
|
239
351
|
stateDir: deps.stateDir,
|
|
240
352
|
log: deps.log,
|
|
241
|
-
send
|
|
242
|
-
// Chunk to Telegram's 4096-char ceiling; each chunk goes through the
|
|
243
|
-
// standard retry / flood-wait / thread-fallback wrapper. A thrown send
|
|
244
|
-
// propagates so the sweep releases the claim and retries next tick (the
|
|
245
|
-
// record is never journaled → never lost).
|
|
246
|
-
let lastId: number | undefined
|
|
247
|
-
for (let i = 0; i < text.length; i += 4000) {
|
|
248
|
-
const chunk = text.slice(i, i + 4000)
|
|
249
|
-
const res = await retryWithThreadFallback(
|
|
250
|
-
retry,
|
|
251
|
-
(tid) => bot.api.sendMessage(chatId, chunk, tid != null ? { message_thread_id: tid } : {}),
|
|
252
|
-
{ threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
|
|
253
|
-
)
|
|
254
|
-
lastId = res?.message_id
|
|
255
|
-
}
|
|
256
|
-
return lastId
|
|
257
|
-
},
|
|
353
|
+
send,
|
|
258
354
|
textAlreadyDelivered: (chatId, threadId, text) => deps.dedupCheck(chatId, threadId ?? undefined, text),
|
|
259
355
|
registryChainLookup: (taskId) => {
|
|
260
356
|
const db = deps.getTurnsDb()
|
|
@@ -295,6 +391,14 @@ export function journalExternalDelivery(
|
|
|
295
391
|
* same nonce is provably a double-send from the journal alone.
|
|
296
392
|
*/
|
|
297
393
|
replyAlreadyDeliveredThisTurn?: boolean
|
|
394
|
+
/**
|
|
395
|
+
* #3513 follow-up: which backstop/machine delivered. Defaults to
|
|
396
|
+
* `'reply-tool'` (the E0/E3 reply-path callers). The turn-flush backstop
|
|
397
|
+
* (E1/E2) passes `'flush'` so `backstopAlreadyDelivered` recognises it as a
|
|
398
|
+
* prior backstop and E3/E4 skip a duplicate durably (across a crash between
|
|
399
|
+
* the flush send and its journal write), not just via the in-memory dedup.
|
|
400
|
+
*/
|
|
401
|
+
deliverySource?: 'sweep' | 'reply-tool' | 'flush'
|
|
298
402
|
},
|
|
299
403
|
stateDir?: string,
|
|
300
404
|
now: number = Date.now(),
|
|
@@ -307,7 +411,7 @@ export function journalExternalDelivery(
|
|
|
307
411
|
textSha256: sha256Hex(args.text),
|
|
308
412
|
tgMessageId: args.tgMessageId,
|
|
309
413
|
ts: now,
|
|
310
|
-
deliverySource: 'reply-tool',
|
|
414
|
+
deliverySource: args.deliverySource ?? 'reply-tool',
|
|
311
415
|
...(args.replyAlreadyDeliveredThisTurn == null
|
|
312
416
|
? {}
|
|
313
417
|
: { replyAlreadyDeliveredThisTurn: args.replyAlreadyDeliveredThisTurn }),
|