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.
Files changed (49) hide show
  1. package/dist/cli/switchroom.js +1 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +1 -1
  5. package/telegram-plugin/dist/bridge/bridge.js +31 -2
  6. package/telegram-plugin/dist/gateway/gateway.js +1690 -932
  7. package/telegram-plugin/dist/server.js +31 -2
  8. package/telegram-plugin/gateway/background-shell-liveness.ts +65 -0
  9. package/telegram-plugin/gateway/forward-origin.ts +6 -1
  10. package/telegram-plugin/gateway/gateway.ts +10 -57
  11. package/telegram-plugin/gateway/narrative-lane.ts +11 -0
  12. package/telegram-plugin/gateway/outbound-send-path.ts +25 -23
  13. package/telegram-plugin/gateway/outbox-listen-markup.ts +67 -0
  14. package/telegram-plugin/gateway/outbox-sweep.ts +124 -20
  15. package/telegram-plugin/gateway/rich-message-handler.ts +241 -0
  16. package/telegram-plugin/gateway/silence-poke-session-event.ts +89 -0
  17. package/telegram-plugin/gateway/stream-render.ts +107 -15
  18. package/telegram-plugin/gateway/unhandled-message.ts +14 -0
  19. package/telegram-plugin/hooks/narration-classify.d.mts +23 -0
  20. package/telegram-plugin/hooks/narration-classify.mjs +210 -0
  21. package/telegram-plugin/hooks/silent-end-scan.mjs +136 -82
  22. package/telegram-plugin/narrative-flush.ts +35 -0
  23. package/telegram-plugin/outbox.ts +73 -3
  24. package/telegram-plugin/session-tail.ts +88 -1
  25. package/telegram-plugin/shown-ledger.ts +145 -0
  26. package/telegram-plugin/silence-poke.ts +118 -1
  27. package/telegram-plugin/silent-end.ts +42 -0
  28. package/telegram-plugin/tests/background-shell-liveness.test.ts +72 -0
  29. package/telegram-plugin/tests/backstop-exactly-once.test.ts +335 -0
  30. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +14 -0
  31. package/telegram-plugin/tests/feed-survival.test.ts +7 -1
  32. package/telegram-plugin/tests/fixtures/bg-shell-liveness-3519.jsonl +3 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +20 -0
  34. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +290 -0
  35. package/telegram-plugin/tests/forwarded-rich-message.test.ts +305 -0
  36. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +1 -0
  37. package/telegram-plugin/tests/narration-leak-3513.test.ts +352 -0
  38. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +253 -0
  39. package/telegram-plugin/tests/session-tail.test.ts +91 -1
  40. package/telegram-plugin/tests/silence-poke.test.ts +280 -0
  41. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +42 -13
  42. package/telegram-plugin/tests/silent-end.test.ts +7 -1
  43. package/telegram-plugin/tests/tts-normalize.test.ts +66 -0
  44. package/telegram-plugin/tests/turn-flush-safety.test.ts +35 -3
  45. package/telegram-plugin/tests/voice-normalize-text.test.ts +82 -1
  46. package/telegram-plugin/tts-normalize.ts +12 -0
  47. package/telegram-plugin/turn-flush-safety.ts +66 -53
  48. package/telegram-plugin/voice-normalize-text.ts +100 -0
  49. 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, 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.`,
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-captured prose (../outbox.ts)
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
- if (ev.kind === 'thinking') {
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
- useOnDemandButton &&
1500
- voiceOutPlan!.ttsChunks.length > 0 &&
1501
- voiceOutPlan!.ttsChunks[0]!.length > 0
1502
- ) {
1503
- if (!mayInjectListenButton(rawKeyboard)) {
1504
- process.stderr.write(
1505
- 'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
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
- const token = mintVoiceOnDemandToken()
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 = buildListenKeyboard(token)
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: voiceOutPlan.ttsChunks[0]!,
1535
- ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
1536
- speed: voiceOutPlan.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-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
@@ -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: () => { api: { sendMessage: (chatId: string, text: string, opts: object) => Promise<{ message_id?: number }> } } | undefined
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: async (chatId, threadId, text) => {
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 }),