switchroom 0.18.30 → 0.18.32

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +4 -2
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +708 -255
  5. package/dist/host-control/main.js +5 -3
  6. package/dist/vault/approvals/kernel-server.js +4 -2
  7. package/dist/vault/broker/server.js +4 -2
  8. package/package.json +3 -2
  9. package/profiles/_base/start.sh.hbs +142 -7
  10. package/telegram-plugin/dist/gateway/gateway.js +25870 -25007
  11. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  12. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  13. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  14. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  15. package/telegram-plugin/gateway/gateway.ts +5479 -7069
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  17. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  18. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  19. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  20. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  21. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  22. package/telegram-plugin/pending-user-notice.ts +59 -13
  23. package/telegram-plugin/subagent-watcher.ts +111 -28
  24. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  25. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  26. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +16 -6
  27. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +3 -3
  28. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  29. package/telegram-plugin/tests/emission-authority-facade.test.ts +29 -19
  30. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  31. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +18 -7
  32. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +249 -0
  33. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  34. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  35. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +303 -0
  36. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -3
  37. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  38. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  39. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  40. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  41. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +4 -4
  42. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  43. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +9 -3
  44. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  45. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  46. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +2 -1
  47. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  48. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  49. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  50. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +3 -2
  51. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  52. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +291 -0
  53. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  54. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  55. package/telegram-plugin/tests/turn-flush-safety.test.ts +8 -6
  56. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +112 -0
  57. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  58. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  59. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +126 -0
  60. package/telegram-plugin/tool-activity-summary.ts +27 -3
  61. package/telegram-plugin/worker-activity-feed.ts +24 -0
  62. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  63. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  64. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  65. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  66. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  67. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -35,6 +35,60 @@ import {
35
35
  import { scrubVoice } from '../text-voice-scrub.js'
36
36
  import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
37
37
 
38
+ // ── send-orchestration façade imports (#2996 P2) ──
39
+ // Pure/deterministic helpers are imported; stateful or side-effecting gateway
40
+ // surfaces are injected via SendReplyGatewayDeps (see the DI contract below).
41
+ import { statSync } from 'fs'
42
+ import { extname } from 'path'
43
+ import { GrammyError, InputFile, type Bot, type Context } from 'grammy'
44
+ import {
45
+ combineReadBackResults,
46
+ type ReadBackResult,
47
+ } from './backstop-delivery.js'
48
+ import {
49
+ escapeMarkdown,
50
+ } from '../format.js'
51
+ import { richMessage } from '../rich-send.js'
52
+ import { resolveChatIdFallback } from './chat-id-fallback.js'
53
+ import { isFinalAnswerReply, isSubstantiveFinalReply } from '../final-answer-detect.js'
54
+ import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
55
+ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
56
+ import { decideSupersedeCorrection, type FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
57
+ import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
58
+ import { deriveTelegraphTitle } from '../telegraph.js'
59
+ import {
60
+ mintVoiceOnDemandToken,
61
+ buildListenKeyboard,
62
+ mayInjectListenButton,
63
+ type VoiceOnDemandCache,
64
+ } from '../voice-ondemand.js'
65
+ import { eagerVoiceEnabled, type PreSynthQueue } from '../voice-presynth.js'
66
+ import { validateInlineKeyboard, type AnyButton } from '../telegram-button-constraints.js'
67
+ import {
68
+ wrapAgentCallbacks,
69
+ redactAgentKeyboard,
70
+ extractAgentButtonMeta,
71
+ type AgentButtonMeta,
72
+ } from '../inline-keyboard-callbacks.js'
73
+ import { classifyPhotoFile, rerouteResultSuffix } from '../photo-precheck.js'
74
+ import { retryWithThreadFallback, isPhotoDimensionRejectError, type RetryCallOpts } from '../retry-api-call.js'
75
+ import { logStreamingEvent } from '../streaming-metrics.js'
76
+ import type { RuntimeMetricEvent } from '../runtime-metrics.js'
77
+ import {
78
+ settleCapturedProseDelivery,
79
+ silentEndFallbackText,
80
+ type SilentEndDeps,
81
+ type CapturedProseSendOutcome,
82
+ } from '../silent-end.js'
83
+ import type { OutboundDedupCache } from '../recent-outbound-dedup.js'
84
+ import type { DraftStreamHandle } from '../draft-stream.js'
85
+ import type { EmissionAuthority } from './emission-authority.js'
86
+ import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
87
+ // Type-only import from the gateway (erased at compile — no runtime cycle):
88
+ // CurrentTurn/Access are the gateway's own turn + access.json shapes, so the
89
+ // injected closures type EXACTLY as their gateway definitions.
90
+ import type { CurrentTurn, Access } from './gateway.js'
91
+
38
92
  /** The redactor the caller injects. In gateway this is `redactOutboundText`,
39
93
  * which wraps `redact()` and logs (never the secret value) when a mask fires.
40
94
  * Injected rather than imported so the redaction structural-wiring test
@@ -375,3 +429,1961 @@ export async function sendReplyChunks(
375
429
 
376
430
  return { threadId, previewMessageId }
377
431
  }
432
+
433
+ // ─── Read-back confirmation probe (#3278) ─────────────────────────────────
434
+ //
435
+ // A returned message_id proves Telegram ACCEPTED a send, not that the message
436
+ // is VISIBLE: a server-side accept-then-silently-discard (flood/anti-spam)
437
+ // returns a fresh id yet the user sees nothing. The only echo primitive the Bot
438
+ // API offers (there is no getMessage) is a no-op `editMessageText` against the
439
+ // returned id — a `400 message to edit not found` proves absence, an edit-ok /
440
+ // `message is not modified` proves presence. This primitive classifies that
441
+ // probe per id and combines a chunk's ids into one verdict; the backstop
442
+ // delivery orchestrator (`runBackstopDelivery`) consumes the verdict to decide
443
+ // confirm / demote-and-re-send / leave-unconfirmed. Scoped to the RARE backstop
444
+ // path only — the hot reply-tool send path issues ZERO probes (#3278 §1.3/§1.5).
445
+
446
+ /**
447
+ * Classify a single-id read-back `editMessageText` FAILURE into a
448
+ * {@link ReadBackResult}. Pure over the thrown error:
449
+ * - `400 message to edit not found` ⇒ `absent` (positive drop → safe re-send)
450
+ * - `400 message is not modified` ⇒ `exists` (a no-op edit → message present)
451
+ * - anything else (429 / 5xx / network / thread-not-found / parse) ⇒ `ambiguous`
452
+ * (never re-send — a re-send would risk a duplicate).
453
+ *
454
+ * A read-back that RESOLVES (the edit applied) means the message existed and is
455
+ * classified `exists` by the caller — this helper only maps the error branch.
456
+ */
457
+ export function classifyReadBackError(err: unknown): ReadBackResult {
458
+ if (err instanceof GrammyError && err.error_code === 400) {
459
+ const d = (err.description || '').toLowerCase()
460
+ if (d.includes('message to edit not found')) return 'absent'
461
+ if (d.includes('not modified')) return 'exists'
462
+ }
463
+ return 'ambiguous'
464
+ }
465
+
466
+ /** Injected per-id probe for {@link createBackstopReadBackProbe}. Resolves to the
467
+ * chunk-id's {@link ReadBackResult} — the gateway wires this to a no-op
468
+ * `editMessageText` paced through the send gate (cosmetic priority, so it sheds
469
+ * under a flood window and never storms); a test supplies a scripted fake. */
470
+ export interface BackstopReadBackDeps {
471
+ /** Probe ONE landed message id (edit it to `body`, its identical rich render). */
472
+ probeId: (messageId: number, body: unknown) => Promise<ReadBackResult>
473
+ /** rich-markdown wrapper (`richMessage`) — the probe edits to the SAME body the
474
+ * chunk was sent with, so an existing message returns "not modified" (no
475
+ * visible mutation) rather than actually changing the delivered answer. */
476
+ richMessage: (s: string) => unknown
477
+ }
478
+
479
+ /**
480
+ * Build the `readBack` dep injected into `runBackstopDelivery` (#3278). For each
481
+ * landed chunk it probes EVERY message id (a length-resplit chunk lands >1) and
482
+ * combines them (guard A7 — the chunk is confirmed only if ALL ids exist; ANY
483
+ * positive absence ⇒ re-send the whole chunk). An empty id set is `ambiguous`
484
+ * (nothing to probe → fabricate neither a confirmation nor a demotion).
485
+ */
486
+ export function createBackstopReadBackProbe(
487
+ deps: BackstopReadBackDeps,
488
+ ): (chunkIndex: number, messageIds: readonly number[], text: string) => Promise<ReadBackResult> {
489
+ return async (_chunkIndex, messageIds, text) => {
490
+ if (messageIds.length === 0) return 'ambiguous'
491
+ const body = deps.richMessage(text)
492
+ const perId: ReadBackResult[] = []
493
+ for (const id of messageIds) {
494
+ perId.push(await deps.probeId(id, body))
495
+ }
496
+ return combineReadBackResults(perId)
497
+ }
498
+ }
499
+
500
+ /** Injected wiring for {@link createBackstopReadBack}. The gateway supplies its
501
+ * raw send primitives; a test can drive the whole probe with fakes. */
502
+ export interface BackstopReadBackWiring {
503
+ /** Raw `editMessageText` with the chat_id baked in by the caller — edits the
504
+ * message to `body` with `apiOpts`; resolves grammy's result or throws the
505
+ * raw grammy error (NOT swallowed, so not-found/not-modified stay
506
+ * distinguishable via {@link classifyReadBackError}). */
507
+ editMessageText: (messageId: number, body: unknown, apiOpts: unknown) => Promise<unknown>
508
+ /** Send-gate wrapper — the probe routes through it at COSMETIC priority so it
509
+ * sheds under a flood window (never storms) and paces like every other send. */
510
+ gate: <T>(fn: () => Promise<T>, opts: RetryCallOpts) => Promise<T>
511
+ /** `SEND_GATE_SHED` sentinel detector: a shed probe is `ambiguous` (never a
512
+ * re-send), NOT a false `exists`. */
513
+ isShed: (r: unknown) => boolean
514
+ /** rich-markdown wrapper (`richMessage`). */
515
+ richMessage: (s: string) => unknown
516
+ chatId: string
517
+ threadId: number | undefined
518
+ }
519
+
520
+ /**
521
+ * Build the complete #3278 `readBack` dep for `runBackstopDelivery`, owning the
522
+ * per-id probe: a no-op `editMessageText` to the message's IDENTICAL body,
523
+ * paced cosmetic through the send gate. A resolved edit (or a `not modified`
524
+ * 400) ⇒ `exists`; a `message to edit not found` 400 ⇒ `absent`; a gate shed,
525
+ * 429/5xx/network, or any other throw ⇒ `ambiguous` (never re-send). Per-chunk
526
+ * ids are combined by {@link createBackstopReadBackProbe} (guard A7).
527
+ */
528
+ export function createBackstopReadBack(
529
+ w: BackstopReadBackWiring,
530
+ ): (chunkIndex: number, messageIds: readonly number[], text: string) => Promise<ReadBackResult> {
531
+ const probeId = async (messageId: number, body: unknown): Promise<ReadBackResult> => {
532
+ // Match the SEND's link_preview_options so editing to the identical body is
533
+ // a genuine no-op ("message is not modified") and never visibly mutates the
534
+ // delivered answer. editMessageText targets a globally-unique message_id, so
535
+ // no message_thread_id is needed on the edit itself.
536
+ const editApiOpts = { link_preview_options: { is_disabled: true } }
537
+ // Retry/gate metadata (RetryCallOpts) — keys the gate's per-message edit
538
+ // floor + cosmetic shedding; NOT sent to Telegram as API params.
539
+ const gateOpts: RetryCallOpts = {
540
+ chat_id: w.chatId,
541
+ verb: 'backstop.readback',
542
+ priorityClass: 'cosmetic',
543
+ messageId,
544
+ editPayload: body,
545
+ ...(w.threadId != null ? { threadId: w.threadId } : {}),
546
+ }
547
+ try {
548
+ const r = await w.gate(() => w.editMessageText(messageId, body, editApiOpts), gateOpts)
549
+ return w.isShed(r) ? 'ambiguous' : 'exists'
550
+ } catch (err) {
551
+ return classifyReadBackError(err)
552
+ }
553
+ }
554
+ return createBackstopReadBackProbe({ probeId, richMessage: w.richMessage })
555
+ }
556
+
557
+ // ─── Send-orchestration façade (#2996 P2, Amendments 9/10) ────────────────
558
+ //
559
+ // `executeReply`'s full orchestration body and the `deliverCapturedProse`
560
+ // silent-end-recovery send path, relocated VERBATIM from gateway.ts so the
561
+ // send path is one invocable, testable primitive (see
562
+ // send-reply-golden.test.ts). gateway.ts keeps thin wrappers that pin the
563
+ // turn at entry and inject the live singletons + closures.
564
+ //
565
+ // DI contract (plan Amendment 9 — supersedes the original P2 contract):
566
+ // - `outboundDedup` is THE one live instance (#546 dedup cache). A re-`new`
567
+ // anywhere in this module is a hard review-fail: the stream-render (P4)
568
+ // surface records into the SAME cache, and a second instance reinstates
569
+ // the duplicate-reply class (Amendment 1).
570
+ // - BOTH the entry-pinned turn (`req.turn`, #1664 attribution) AND a live
571
+ // `getCurrentTurn()` accessor are provided. Each call site keeps its
572
+ // original pin-vs-live choice VERBATIM — the live re-reads (over-ping
573
+ // block, card-finalize, silent-anchor, dedup registry keys) are
574
+ // load-bearing: a turn that ends mid-send must skip card-finalize.
575
+ // Collapsing to pin-everywhere is a forbidden behavior change.
576
+ // - `backstopDeliveryLedger` and `sendGate` are deliberately NOT injected:
577
+ // 0 references in this body (the ledger is the P4 stream surface's dep;
578
+ // the send gate lives inside the injected `robustApiCall`).
579
+ // - Pure helpers are IMPORTED; anything stateful, side-effecting, or
580
+ // gateway-configured is INJECTED (so the golden harness can fake it).
581
+ //
582
+ // The bodies below are byte-identical to the pre-move gateway.ts inline
583
+ // bodies except for the 8 enumerated pin-vs-live spellings
584
+ // (`currentTurn` -> `getCurrentTurn()` / `req.turn` /
585
+ // `getLastActiveTurnChatId()`) and the deps destructure preamble. Do not
586
+ // "clean up" while moving — bugfixes ship as separate PRs.
587
+
588
+ /** The subset of the gateway's voice-out plan the send path consumes. */
589
+ export interface VoiceOutPlan {
590
+ engine: 'kokoro' | 'openai'
591
+ voice?: string
592
+ speed: number
593
+ apiKeyRef?: string
594
+ replyMode: 'voice+text' | 'voice-only' | 'on-demand'
595
+ ttsChunks: string[]
596
+ }
597
+
598
+ export interface SendReplyRequest {
599
+ /** Raw `reply` tool args, exactly as the MCP dispatch received them. */
600
+ args: Record<string, unknown>
601
+ /** #1664 — the turn pinned at executeReply entry by the gateway wrapper.
602
+ * Late writes (finalAnswerDelivered) attribute to THIS turn even if the
603
+ * module-scope turn rolls over mid-call. */
604
+ turn: CurrentTurn | null
605
+ }
606
+
607
+ /** Gateway dependencies for {@link sendReply}. Function members use method
608
+ * syntax deliberately (bivariant) so the gateway's more-precisely-typed
609
+ * closures are assignable without adapter noise. */
610
+ export interface SendReplyGatewayDeps {
611
+ // ── shared singletons — the ONE live instance each (Amendment 1/9) ──
612
+ outboundDedup: OutboundDedupCache
613
+ flushedTurnSupersede: FlushedTurnSupersedeRegistry
614
+ firstTextReplyLogged: Set<string>
615
+ suppressPtyPreview: Set<string>
616
+ activeDraftStreams: Map<string, DraftStreamHandle>
617
+ lastPtyPreviewByChat: Map<string, string>
618
+ voiceOnDemandCache: VoiceOnDemandCache
619
+ voicePreSynthQueue: PreSynthQueue
620
+ /** cross-turn pending-async ambient ticker (module-singleton namespace). */
621
+ pendingProgress: {
622
+ clearPending(key: string, reason: string): void
623
+ noteOutbound(key: string, anchor: { messageId: number; text: string; literalText: boolean }): void
624
+ }
625
+ /** outbound-gap / TTFO KPI tracker (module-singleton namespace). */
626
+ signalTracker: {
627
+ noteOutbound(key: string, at: number): void
628
+ noteSignal(key: string, at: number): void
629
+ }
630
+ /** silence-poke clock (module-singleton namespace). */
631
+ silencePoke: { noteOutbound(key: string, at: number): void }
632
+
633
+ // ── turn identity (Amendment 9: pin-vs-live preserved per call site) ──
634
+ getCurrentTurn(): CurrentTurn | null
635
+ getLastActiveTurnChatId(): string | undefined
636
+
637
+ // ── gateway config values ──
638
+ HISTORY_ENABLED: boolean
639
+ TURN_ORIGIN_ROUTING_ENABLED: boolean
640
+ AUTOCLASSIFY_MIDTURN_SHADOW: boolean
641
+ MAX_ATTACHMENT_BYTES: number
642
+ MAX_CHUNK_LIMIT: number
643
+ PHOTO_EXTS: Set<string>
644
+
645
+ // ── bot + retry policy (the send gate lives inside robustApiCall) ──
646
+ lockedBot: Bot<Context>
647
+ robustApiCall<T>(fn: () => Promise<T>, opts?: RetryCallOpts): Promise<T>
648
+ swallowingApiCall(fn: () => Promise<unknown>, meta: RetryCallOpts): Promise<unknown>
649
+
650
+ // ── gateway closures ──
651
+ loadAccess(): Access
652
+ redactOutboundText(text: string, site: string): string
653
+ assertAllowedChat(chatId: string | number): void
654
+ assertSendable(f: string): void
655
+ statusKey(chatId: string, threadId?: number | null): string
656
+ streamKey(chatId: string, threadId?: number | null): string
657
+ resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): CurrentTurn | null
658
+ findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
659
+ findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
660
+ resolveAnswerThreadWithLog(
661
+ chatId: string,
662
+ explicitThreadId: number | undefined,
663
+ originTurn: CurrentTurn | null,
664
+ originVia: 'echo' | 'quoted' | null,
665
+ liveTurn: CurrentTurn | null,
666
+ surface: 'reply' | 'stream_reply',
667
+ ): number | undefined
668
+ resolveThreadId(chatId: string, explicit?: string | number | null): number | undefined
669
+ getLatestInboundMessageId(chatId: string, threadId: number | null): number | null | undefined
670
+ recordOutbound(rec: {
671
+ chat_id: string
672
+ thread_id: number | null
673
+ message_ids: number[]
674
+ texts: string[]
675
+ attachment_kinds?: (string | null)[]
676
+ }): void
677
+ emissionAuthorityFor(turn: CurrentTurn): EmissionAuthority
678
+ clearActivitySummary(turn: CurrentTurn, finalHtmlOverride?: string | null): void
679
+ startTypingLoop(chatId: string, threadId?: number | null): void
680
+ stopTypingLoop(chatId: string, threadId?: number | null): void
681
+ logOutbound(
682
+ path: 'reply' | 'edit',
683
+ chatId: string,
684
+ messageId: number | null,
685
+ chars: number,
686
+ extra?: string,
687
+ ): void
688
+ closeObligationOnSubstantiveReply(
689
+ args: Record<string, unknown>,
690
+ liveTurn: CurrentTurn | null | undefined,
691
+ routedOriginTurn?: CurrentTurn | null,
692
+ ): void
693
+ finalizeStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done'): void
694
+ releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void
695
+ reapQueuedStatus(chatId: string, thread: number | undefined): void
696
+ noteAgentOutputAt(key: string, ts: number): void
697
+ rememberAgentButtonMeta(chatId: string | number, messageId: number, meta: Map<string, AgentButtonMeta>): void
698
+ resolveVoiceOutPlan(voiceOut: Access['voice_out'], replyText: string): VoiceOutPlan | null
699
+ synthesizeVoiceOut(plan: {
700
+ engine: 'kokoro' | 'openai'
701
+ voice?: string
702
+ speed?: number
703
+ apiKeyRef?: string
704
+ ttsText: string
705
+ }): Promise<Uint8Array | null>
706
+ publishToTelegraph(text: string, shortName: string, authorName?: string): Promise<string | null>
707
+ clearSilentEndState(key: string): void
708
+ emitRuntimeMetric(m: RuntimeMetricEvent): void
709
+ shadowEmit(ev: { kind: 'modelOutbound'; key: _ChatKey; at: number }): void
710
+ progressDriver: { recordOutboundDelivered(chatId: string, threadId?: string): void } | null
711
+ }
712
+
713
+ /**
714
+ * The reply send orchestration — `executeReply`'s body, verbatim.
715
+ * See the section comment above for the DI contract and the enumerated
716
+ * verbatim deviations.
717
+ */
718
+ export async function sendReply(
719
+ deps: SendReplyGatewayDeps,
720
+ req: SendReplyRequest,
721
+ ): Promise<{ content: Array<{ type: string; text: string }> }> {
722
+ const {
723
+ outboundDedup, flushedTurnSupersede, firstTextReplyLogged, suppressPtyPreview,
724
+ activeDraftStreams, lastPtyPreviewByChat, voiceOnDemandCache, voicePreSynthQueue,
725
+ pendingProgress, signalTracker, silencePoke,
726
+ getCurrentTurn, getLastActiveTurnChatId, progressDriver,
727
+ HISTORY_ENABLED, TURN_ORIGIN_ROUTING_ENABLED, AUTOCLASSIFY_MIDTURN_SHADOW,
728
+ MAX_ATTACHMENT_BYTES, MAX_CHUNK_LIMIT, PHOTO_EXTS,
729
+ lockedBot, robustApiCall, swallowingApiCall,
730
+ loadAccess, redactOutboundText, assertAllowedChat, assertSendable,
731
+ statusKey, streamKey,
732
+ resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
733
+ resolveAnswerThreadWithLog, resolveThreadId,
734
+ getLatestInboundMessageId, recordOutbound,
735
+ emissionAuthorityFor, clearActivitySummary,
736
+ startTypingLoop, stopTypingLoop, logOutbound,
737
+ closeObligationOnSubstantiveReply, finalizeStatusReaction,
738
+ releaseTurnBufferGate, reapQueuedStatus, noteAgentOutputAt,
739
+ rememberAgentButtonMeta, resolveVoiceOutPlan, synthesizeVoiceOut,
740
+ publishToTelegraph, clearSilentEndState, emitRuntimeMetric, shadowEmit,
741
+ } = deps
742
+ const args = req.args
743
+ // #1664 — pin the turn this reply belongs to at entry. The
744
+ // finalAnswerDelivered write near the end of this function runs after
745
+ // several awaits; turn-pinning (the #1067 pattern used across the
746
+ // gateway) keeps the write attributed to THIS turn rather than reading
747
+ // module-scope currentTurn, which a future refactor could let roll over
748
+ // mid-call. (#2996 P2: the pin is taken by the gateway wrapper at call
749
+ // entry and passed in `req.turn` — same read, same position.)
750
+ const turn = req.turn
751
+ const _rawChatId = String(args.chat_id ?? '')
752
+ if (!_rawChatId) throw new Error('reply: chat_id is required')
753
+ // Non-Claude models (e.g. Gemini via LiteLLM sr-* routing) sometimes pass a
754
+ // chat_id that is not in the allowlist — either an int/string mismatch, or
755
+ // the model echoing the wrong identifier from context. When the raw value
756
+ // fails the allowlist check, fall back to the active (or last-known) turn's
757
+ // validated sessionChatId so the reply still lands correctly.
758
+ // Tier 1: live turn (currentTurn was non-null at reply entry).
759
+ // Tier 2: last-known turn (survives silence poke — Bug D fix; currentTurn is
760
+ // null because clearTurnStarted fired ≥5 min of model silence, but the model
761
+ // finally called reply after the poke).
762
+ const chat_id = (() => {
763
+ const resolved = resolveChatIdFallback(
764
+ _rawChatId,
765
+ loadAccess(),
766
+ turn?.sessionChatId,
767
+ getLastActiveTurnChatId(),
768
+ turn != null,
769
+ )
770
+ if (resolved.tier !== 'raw') {
771
+ process.stderr.write(
772
+ `telegram gateway: reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
773
+ `routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
774
+ )
775
+ }
776
+ return resolved.chatId // raw tier → let assertAllowedChat throw the human-readable error
777
+ })()
778
+ const rawText = args.text as string | undefined
779
+ if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
780
+ // Repair LLM JSON-escape bungles, then promote lone prose paragraph breaks
781
+ // into GFM hard breaks so the rich path doesn't collapse them (lists/tables/
782
+ // code are left untouched — see normalizeParagraphBreaks).
783
+ // Outbound text pipeline (#2996 §3B): normalize → redact → punctuation/bold
784
+ // → voice-scrub, extracted verbatim into outbound-send-path.ts. The order is
785
+ // load-bearing (secret scrub BEFORE the punctuation/bold normalizers so a
786
+ // secret with an em-dash or `**` is matched literally; voice scrub last so
787
+ // retries see the scrubbed dedup key). The metric side effect (fired on a
788
+ // non-zero voice-scrub replacement) stays here — the pure module returns the
789
+ // replacement count and the gateway emits.
790
+ const _normalized = normalizeOutboundBody(rawText, 'reply', redactOutboundText)
791
+ let text = _normalized.text
792
+ if (_normalized.voiceReplaced > 0) {
793
+ emitRuntimeMetric({
794
+ kind: 'voice_scrub_applied',
795
+ chatKey: statusKey(chat_id, args.message_thread_id != null
796
+ ? Number(args.message_thread_id) : undefined),
797
+ replaced: _normalized.voiceReplaced,
798
+ site: 'reply',
799
+ })
800
+ }
801
+ process.stderr.write(`telegram channel: reply: invoked chatId=${chat_id} charCount=${text.length} preview=${JSON.stringify(text.slice(0, 80))}\n`)
802
+ // #2527: emit time_to_first_text_reply_ms on the FIRST text reply of each
803
+ // turn so operators can see how long users waited for any visible output.
804
+ // Only fires once per turn (firstTextReplyLogged guards the repeat).
805
+ if (turn != null) {
806
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
807
+ const replyKey = statusKey(chat_id, threadId)
808
+ if (!firstTextReplyLogged.has(replyKey)) {
809
+ firstTextReplyLogged.add(replyKey)
810
+ logStreamingEvent({
811
+ kind: 'turn_reply_timing',
812
+ chatId: chat_id,
813
+ threadId,
814
+ turnId: turn.turnId,
815
+ timeToFirstTextReplyMs: Date.now() - turn.gatewayReceiveAt,
816
+ })
817
+ }
818
+ }
819
+
820
+ // #546 dedup check: was this content just sent via turn-flush or
821
+ // a sibling reply path? Skip the actual send and return a
822
+ // plausible tool result so claude-code's retry loop closes
823
+ // cleanly. NOTE: only fires when content matches; legitimate
824
+ // late-replies with different content sail through.
825
+ {
826
+ const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
827
+ const dup = outboundDedup.check(chat_id, replyThreadId, text, Date.now(), getCurrentTurn()?.registryKey ?? null)
828
+ if (dup != null) {
829
+ process.stderr.write(
830
+ `telegram gateway: reply: deduped (#546) chatId=${chat_id} ` +
831
+ `ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}\n`,
832
+ )
833
+ return { content: [{ type: 'text', text: 'sent (deduped — same content sent via earlier path)' }] }
834
+ }
835
+ }
836
+
837
+ // 2026-07 duplicate-reply fix — turnId-keyed supersede (consumption side).
838
+ // If an earlier turn-flush (answer-ready quiescence OR the turn-end backstop)
839
+ // already posted THIS turn's terminal text and the model's REAL `reply` for
840
+ // the same turn is landing now (it was still composing the tool call when the
841
+ // flush fired, or claude-code replayed the tool_call after a bridge
842
+ // reconnect), delete the flushed message(s) so the canonical reply below
843
+ // delivers exactly one clean message instead of a second one. Keyed on the
844
+ // per-turn `turnId` nonce, so it fires even when the flushed narration+answer
845
+ // blob differs from the clean answer-only reply — the containment case the
846
+ // exact-text `outboundDedup` above structurally cannot catch. A reply for a
847
+ // DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
848
+ // so a fresh turn's answer is never clobbered.
849
+ //
850
+ // Reply-flicker fix: rather than delete the flushed message(s) HERE (which
851
+ // makes the user see delete+replace when the canonical reply sends fresh
852
+ // below), we DEFER the correction to the send site. Once chunk count / files /
853
+ // preview are known, `decideSupersedeCorrection` picks edit-in-place (edit the
854
+ // single flushed message into the canonical reply — no flicker, no re-ping)
855
+ // when the reply fits one plain-text message, and falls back to the legacy
856
+ // delete+resend otherwise. `supersedeFlushIds` carries the ids forward.
857
+ let supersedeFlushIds: number[] = []
858
+ {
859
+ const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
860
+ // 2026-07 double-reply-on-DM fix (Part 1) — resolve the turn this reply
861
+ // belongs to by IDENTITY, via the SAME full chain the thread-router uses
862
+ // (`resolveReplyOwnerTurn`): live `currentTurn`, then the model-echoed
863
+ // `origin_turn_id`, then the framework-owned quoted message id, then the
864
+ // chat's most-recently-ended turn. The prior chain stopped at
865
+ // `currentTurn ?? findTurnByOriginId`, so a DM late reply — `currentTurn`
866
+ // nulled by the flush's synthetic turn_end AND no `origin_turn_id` (a
867
+ // supergroup-only field) — resolved to a null owner. `decideSupersede`
868
+ // deliberately never lets a null live turn supersede a turnId-bearing flush
869
+ // record, so message A survived AND the reply shipped message B (the exact
870
+ // double-send). The quoted / latest-ended recoveries are precisely what the
871
+ // router already did for the same reply, so unifying here makes the two
872
+ // resolvers agree and the late-reply supersede fires by identity.
873
+ const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
874
+ const resolvedTurnId = ownerTurn?.turnId ?? null
875
+ const decision = flushedTurnSupersede.take(
876
+ chat_id,
877
+ replyThreadId,
878
+ { liveTurnId: resolvedTurnId, now: Date.now() },
879
+ )
880
+ if (decision.supersede) {
881
+ process.stderr.write(
882
+ `telegram gateway: reply: superseding flushed turn message(s) ` +
883
+ `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
884
+ )
885
+ // Deferred: the correction (edit-in-place vs delete+resend) is decided at
886
+ // the send site once chunk count / files / preview are known.
887
+ supersedeFlushIds = decision.deleteMessageIds
888
+ // Set the answer-delivered latch NOW, at record consumption — BEFORE the
889
+ // arg-validation throws between here and the correction site (file
890
+ // too-large ~L13699, inline_keyboard invalid ~L13782). Without this, a
891
+ // late reply that supersedes a flush AND carries an oversized file /
892
+ // invalid keyboard would throw before the correction runs (message A
893
+ // neither deleted nor edited), the model would retry `reply`, and — the
894
+ // supersede record already consumed by `take()` above — the retry would
895
+ // fall into the else/no-record branch below with no latch set, so
896
+ // suppression wouldn't fire and a fresh B would ship alongside the stale
897
+ // narration A (both visible). Latching here mirrors the else-branch's own
898
+ // `answerDelivered = true` and closes that resurrection window: the retry
899
+ // resolves the same ended owner turn, sees the latch, and is suppressed —
900
+ // exactly one message ever ships. The latch is idempotent and the normal
901
+ // (no-throw) path is unaffected: the correction below still ships B once.
902
+ if (ownerTurn != null) ownerTurn.answerDelivered = true
903
+ } else {
904
+ // 2026-07 double-reply-on-DM fix (Part 2) — answer-delivered race latch.
905
+ // Supersede found no record. Either there was no flush (normal reply), or
906
+ // the flush FIRED but has not yet recorded its message ids (the residual
907
+ // pre-record race Part 1's supersede cannot reach). The flush sets
908
+ // `answerDelivered = true` synchronously at fire time (before its async
909
+ // send AND before `record`), and it persists on the ended turn — so when
910
+ // this LATE, substantive reply resolves its owner turn and sees the latch
911
+ // already set, the flush's message A is already on its way out and this
912
+ // reply would ship a duplicate. Suppress it. Scoped to the substantive
913
+ // ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor and the late-reply case so an
914
+ // interim sub-floor ack, a chunked multi-part answer, or a legitimate
915
+ // second in-turn substantive reply (live `currentTurn`) is never
916
+ // suppressed. `isSubstantiveFinalReply` reduces to the ≥200-char test on
917
+ // the `reply` path (no `done`); pass the model's original notification
918
+ // intent to mirror the #2533 decoupling call shape.
919
+ const replySubstantive = isSubstantiveFinalReply({
920
+ text: rawText,
921
+ disableNotification: args.disable_notification === true,
922
+ })
923
+ const suppressByLatch = decideAnswerLatchSuppression({
924
+ superseded: false,
925
+ replySubstantive,
926
+ isLateReply: turn == null,
927
+ ownerAnswerDelivered: ownerTurn?.answerDelivered ?? false,
928
+ })
929
+ if (suppressByLatch) {
930
+ process.stderr.write(
931
+ `telegram gateway: reply: suppressed by answer-delivered latch ` +
932
+ `(flush already delivered this turn's answer) chatId=${chat_id} ` +
933
+ `ownerTurnId=${JSON.stringify(resolvedTurnId)}\n`,
934
+ )
935
+ return { content: [{ type: 'text', text: 'sent (deduped — answer already delivered via turn-flush)' }] }
936
+ }
937
+ // A substantive answer is going out via this reply — set the latch on its
938
+ // owner turn so a later bridge-replayed / reworded duplicate of the same
939
+ // answer is caught by the branch above.
940
+ if (replySubstantive && ownerTurn != null) {
941
+ ownerTurn.answerDelivered = true
942
+ }
943
+ }
944
+ }
945
+
946
+ const files = (args.files as string[] | undefined) ?? []
947
+ const quoteOptIn = args.quote !== false
948
+ let reply_to = args.reply_to != null ? Number(args.reply_to) : undefined
949
+ const protectContent = args.protect_content === true
950
+ const quoteText = args.quote_text as string | undefined
951
+ const access = loadAccess()
952
+ // Outbound TTS plan (PR-C2). Resolved once here (engine gating + mode +
953
+ // plain-text TTS input); synthesis happens just before the send so a
954
+ // voice-only reply can suppress the text chunk loop on success. Voice is
955
+ // fully best-effort — every failure below falls back to the text reply.
956
+ const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
957
+ const configParseMode = access.parseMode ?? 'html'
958
+ const format = (args.format as string | undefined) ?? configParseMode
959
+ const disableLinkPreview = args.disable_web_page_preview != null
960
+ ? Boolean(args.disable_web_page_preview)
961
+ : (access.disableLinkPreview ?? true)
962
+ // #1122 conversational pacing: mid-turn updates pass disable_notification:true
963
+ // so only the final answer pings the device. Default false (pings) so
964
+ // existing call-sites and the typical "final answer" reply keep their
965
+ // current behaviour without an explicit flag.
966
+ let disableNotification = args.disable_notification === true
967
+ // #2527/#1664 — the over-ping safety net below may downgrade
968
+ // `disableNotification` ping→silent for ANTI-SPAM (one ping per turn). That
969
+ // delivery-channel decision must NOT pollute final-answer CLASSIFICATION: a
970
+ // final answer the model intended to ping is STILL the final answer even when
971
+ // the framework silences the actual ping. Classify on the model's original
972
+ // intent (what executeReply already does), so an over-ping-silenced
973
+ // final answer sets finalAnswerDelivered=true — fixing both a spurious
974
+ // silent-end re-prompt and a false 'undelivered' (😐) terminal reaction.
975
+ const modelDisableNotification = args.disable_notification === true
976
+
977
+ // #1675 over-ping safety net. The conversational-pacing contract
978
+ // (`reference/rfcs/conversational-pacing.md` beat 5) says EXACTLY ONE
979
+ // device ping per turn — the final answer. The model sometimes
980
+ // violates this by sending a substantive answer pinged + a wrap-up
981
+ // ("Delivered all three steps…", "Sent.", or meta-narration) ALSO
982
+ // pinged. Both messages then fire notifications. The fleet UAT on
983
+ // 2026-05-23 reproduced this (Step 3 + Delivered both pinged, two
984
+ // beeps for a turn that should have produced one). Framework owns
985
+ // the safety net: once the turn has emitted ONE pinged reply, every
986
+ // subsequent reply call in the same turn auto-downgrades to silent
987
+ // (disable_notification: true). Model intent ("I want this loud")
988
+ // is honoured for the first ping; subsequent pings are demoted with
989
+ // a stderr log so operators can see the safety net engage.
990
+ //
991
+ // The slot is claimed BEFORE the actual send to keep the logic
992
+ // sequential — a send that fails part-way leaves firstPingAt set
993
+ // and subsequent pings would be silenced. Acceptable trade-off (a
994
+ // failed first ping is an edge case; the alternative — claim after
995
+ // send — races concurrent reply calls).
996
+ // Tracks whether the over-ping safety net coerced this reply
997
+ // from ping→silent. Threaded into the silent-anchor predicate
998
+ // below: a demoted final-answer reply must NOT merge into the
999
+ // silent preamble bubble; it lands as a fresh silent bubble so
1000
+ // the user can still find it (see #1674 / silent-anchor follow-up).
1001
+ let wasOverPingSuppressed = false
1002
+ {
1003
+ const turn = getCurrentTurn()
1004
+ if (turn != null) {
1005
+ const now = Date.now()
1006
+ // Notification ownership (R8 / PR-2): on the `reply` path,
1007
+ // substantiveness is purely the ≥200-char (or `done`) backstop —
1008
+ // `isSubstantiveFinalReply` is `done === true || text.length >= 200`
1009
+ // and ignores the notification flag entirely. `reply` carries no
1010
+ // `done`, so it reduces to the ≥200-char length test. We still pass
1011
+ // `modelDisableNotification` (the MODEL's original intent, not the
1012
+ // possibly-downgraded `disableNotification`) to mirror the #2533
1013
+ // final-answer decoupling call shape, but that arg does NOT
1014
+ // participate in classification here — it is inert on this path.
1015
+ const replySubstantive = isSubstantiveFinalReply({
1016
+ text: rawText,
1017
+ disableNotification: modelDisableNotification,
1018
+ })
1019
+ // PR-4c: the over-ping DECISION relocates into the emission-authority
1020
+ // façade, behind the kill-switch (default OFF), the same structural way
1021
+ // PR-4b moved the OPEN gate. `decideOverPing` is already pure, so PR-4c
1022
+ // extracts NOTHING new — it relocates the *call* into the façade's enabled
1023
+ // branch and keeps the *effects* (stderr, metric, the atomic
1024
+ // `firstPingAt`/`firstPingWasSubstantive` pair-set, the
1025
+ // `disableNotification`/`wasOverPingSuppressed` outer-scope writes) HERE,
1026
+ // parameterized by the decision the façade hands back via `applyDecision`.
1027
+ //
1028
+ // - Disabled branch runs `disabledOverPing()` — its own LITERAL
1029
+ // `decideOverPing(...)` call + the full effects block, VERBATIM from
1030
+ // PR-4b-base (the disabled-path-is-byte-identical proof).
1031
+ // - Enabled branch: the façade computes the decision and hands it to
1032
+ // `applyOverPingDecision(decision)`, which performs the IDENTICAL
1033
+ // effects. Same pure inputs ⇒ same decision ⇒ flag-ON ≡ flag-OFF ≡ base.
1034
+ //
1035
+ // The effects block is shared between both thunks by closing over `decision`
1036
+ // — but the disabled thunk computes it via its OWN literal `decideOverPing(`
1037
+ // first, so the disabled path never depends on the façade for the decision.
1038
+ const applyOverPingDecision = (decision: OverPingDecision): void => {
1039
+ if (decision.suppress) {
1040
+ process.stderr.write(
1041
+ `telegram gateway: reply over-ping safety net — ` +
1042
+ `downgrading disable_notification:false → true ` +
1043
+ `(chat=${chat_id} thread=${args.message_thread_id ?? '-'} ` +
1044
+ `firstPingAt=${turn.firstPingAt} sinceFirstPing_ms=${decision.sinceFirstPingMs})\n`,
1045
+ )
1046
+ // Observability: surface to the unified runtime-metrics
1047
+ // fan-out so the cadence dashboard can track fleet-wide
1048
+ // over-ping rate (leading indicator of model pacing drift).
1049
+ emitRuntimeMetric({
1050
+ kind: 'over_ping_suppressed',
1051
+ key: statusKey(chat_id, args.message_thread_id != null
1052
+ ? Number(args.message_thread_id) : undefined),
1053
+ sinceFirstPingMs: decision.sinceFirstPingMs ?? 0,
1054
+ })
1055
+ disableNotification = true
1056
+ wasOverPingSuppressed = true
1057
+ } else if (decision.claimSlot) {
1058
+ // Claim (first ping) OR upgrade (substantive answer pinging over an
1059
+ // ack's slot). Set firstPingAt AND firstPingWasSubstantive ATOMICALLY
1060
+ // (no await between) so a racing second reply reads a consistent pair.
1061
+ turn.firstPingAt = now
1062
+ turn.firstPingWasSubstantive = replySubstantive
1063
+ if (decision.upgrade) {
1064
+ process.stderr.write(
1065
+ `telegram gateway: reply over-ping safety net — ` +
1066
+ `UPGRADE: substantive answer pings over an ack's slot ` +
1067
+ `(chat=${chat_id} thread=${args.message_thread_id ?? '-'})\n`,
1068
+ )
1069
+ }
1070
+ }
1071
+ }
1072
+ emissionAuthorityFor(turn).claimOrDowngradePing(
1073
+ { modelRequestedPing: !disableNotification, substantive: replySubstantive },
1074
+ {
1075
+ firstPingAt: turn.firstPingAt,
1076
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
1077
+ nowMs: now,
1078
+ },
1079
+ applyOverPingDecision,
1080
+ () => {
1081
+ // Disabled-path: literal `decideOverPing(` + effects, VERBATIM base.
1082
+ const decision = decideOverPing({
1083
+ modelRequestedPing: !disableNotification,
1084
+ firstPingAt: turn.firstPingAt,
1085
+ substantive: replySubstantive,
1086
+ firstPingWasSubstantive: turn.firstPingWasSubstantive,
1087
+ nowMs: now,
1088
+ })
1089
+ applyOverPingDecision(decision)
1090
+ },
1091
+ )
1092
+ }
1093
+ }
1094
+
1095
+ // Telegraph publish (#579). When the reply text is long enough AND
1096
+ // the agent has telegraph enabled in access.json, publish to
1097
+ // Telegraph + replace the local text with a single-message link.
1098
+ // Telegram renders the link as a native Instant View card.
1099
+ // Failure paths fall through to normal HTML chunking, so a flaky
1100
+ // Telegraph backend never breaks the reply path.
1101
+ const tg = access.telegraph
1102
+ const tgThreshold = tg?.threshold ?? 3000
1103
+ if (tg?.enabled && files.length === 0 && text.length > tgThreshold) {
1104
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME ?? 'switchroom-agent'
1105
+ const shortName = tg.short_name ?? agentSlug
1106
+ const url = await publishToTelegraph(text, shortName, tg.author_name)
1107
+ if (url != null) {
1108
+ const title = deriveTelegraphTitle(text)
1109
+ // Replace the local text with a one-line link. The first line
1110
+ // is the chosen title (so the user sees the topic at a glance);
1111
+ // the second line is the URL Telegram will Instant-View.
1112
+ text = `**${escapeMarkdown(title)}**\n${url}`
1113
+ }
1114
+ // url null → fall through and chunk; the user still gets the
1115
+ // long reply, just split across messages.
1116
+ }
1117
+
1118
+ // Single rich-markdown path (#2669). The only fork is `format:'text'`,
1119
+ // a literal/no-markdown send: the body bypasses the rich parser entirely
1120
+ // (plain `sendMessage`, no rich wrapper). Everything else — the default —
1121
+ // ships the raw GFM markdown via `sendRichMessage`. `effectiveText` is the
1122
+ // raw text either way (no HTML/MarkdownV2 rendering happens here anymore).
1123
+ const literalText = format === 'text'
1124
+ // Paragraph-spacing fix (rich-message regression after #2669). The rich GFM
1125
+ // renderer collapses a `\n\n` gap TIGHT, so multi-paragraph replies render
1126
+ // jammed together — unlike the old HTML path. Inject a visible blank-line
1127
+ // spacer into prose `\n\n` gaps on the rich path only. The literal
1128
+ // (`format:'text'`) path must stay byte-exact, so it is left untouched.
1129
+ const effectiveText: string = computeEffectiveText(text, literalText)
1130
+
1131
+ assertAllowedChat(chat_id)
1132
+
1133
+ // Thread resolution precedence (ANSWER path, component 3 — turn-origin
1134
+ // routing): (1) explicit message_thread_id the model passed; else
1135
+ // (2) the ORIGIN turn's thread — the turn that OWNS this reply, matched
1136
+ // by origin_turn_id (the meta field the model echoes back). This is
1137
+ // authoritative even after `currentTurn` has flipped to a successor (the
1138
+ // Brevo→Meta late-reply bug). Else (3) the live turn's thread (legacy
1139
+ // #1664 fallback when no origin turn is resolvable). Answer paths
1140
+ // DELIBERATELY do NOT fall through to chatThreadMap last-seen — that
1141
+ // heuristic is what mis-routed a late reply to whichever topic most
1142
+ // recently received a message. DM: every tier is undefined → unchanged.
1143
+ // Kill switch off → exact legacy resolveThreadId precedence.
1144
+ // Hoist the resolved origin turn so the obligation-close path (below) can
1145
+ // pass it into resolveCloseTarget as routedOriginId, closing re-presented
1146
+ // obligations even when the model omitted origin_turn_id (Fix 1/2).
1147
+ let replyRoutedOriginTurn: CurrentTurn | null = null
1148
+ let threadId: number | undefined
1149
+ if (TURN_ORIGIN_ROUTING_ENABLED) {
1150
+ const explicit = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
1151
+ // Origin precedence: model echo first (authoritative), then the
1152
+ // framework-owned quoted message_id (deterministic, no model thread
1153
+ // assertion) as a fallback when the model omitted the echo.
1154
+ const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
1155
+ const quotedTurn = echoedTurn == null ? findTurnByQuotedMessageId(chat_id, args.reply_to) : null
1156
+ const originTurn = echoedTurn ?? quotedTurn
1157
+ replyRoutedOriginTurn = originTurn ?? null
1158
+ threadId = resolveAnswerThreadWithLog(
1159
+ chat_id,
1160
+ Number.isFinite(explicit as number) ? (explicit as number) : undefined,
1161
+ originTurn,
1162
+ originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted',
1163
+ turn,
1164
+ 'reply',
1165
+ )
1166
+ } else {
1167
+ threadId = resolveThreadId(
1168
+ chat_id,
1169
+ (args.message_thread_id as string | undefined) ??
1170
+ (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined),
1171
+ )
1172
+ }
1173
+
1174
+ if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
1175
+ try {
1176
+ const latest = getLatestInboundMessageId(chat_id, threadId ?? null)
1177
+ if (latest != null) reply_to = latest
1178
+ } catch (err) {
1179
+ process.stderr.write(`telegram gateway: quote-reply lookup failed: ${(err as Error).message}\n`)
1180
+ }
1181
+ }
1182
+
1183
+ for (const f of files) {
1184
+ assertSendable(f)
1185
+ const st = statSync(f)
1186
+ if (st.size > MAX_ATTACHMENT_BYTES) {
1187
+ throw new Error(`file too large: ${f} (${(st.size / 1024 / 1024).toFixed(1)}MB, max 50MB)`)
1188
+ }
1189
+ }
1190
+
1191
+ const limit = Math.max(1, Math.min(access.textChunkLimit ?? RICH_MESSAGE_MAX_CHARS, MAX_CHUNK_LIMIT))
1192
+ const replyMode = access.replyToMode ?? 'first'
1193
+ const chunks = computeReplyChunks({
1194
+ effectiveText,
1195
+ literalText,
1196
+ limit,
1197
+ chunkMode: access.chunkMode ?? 'length',
1198
+ })
1199
+ const sentIds: number[] = []
1200
+
1201
+ // Outbound TTS synthesis (PR-C2). Done BEFORE the text send so a
1202
+ // voice-only reply can suppress the text chunks on success. ONE voice note
1203
+ // per response for the kokoro path (ttsChunks is a single element — the
1204
+ // whole normalized reply — synthesized in one /tts call). The OpenAI path
1205
+ // may still produce several ordered notes (one per chunk) because of its
1206
+ // input cap. Ken is often on a bike/driving and can't read the screen, so
1207
+ // the full answer must be SPOKEN. Best-effort: a chunk that fails to
1208
+ // synthesize is skipped; if NOTHING synthesizes the text path proceeds
1209
+ // unchanged so the answer is never dropped.
1210
+ // on-demand is a LOCAL-engine (kokoro) feature only: the tap handler
1211
+ // synthesizes via the local sidecar, so a Listen button is only meaningful
1212
+ // when the resolved engine is kokoro. resolveVoiceOutPlan already gated the
1213
+ // local host verdict for kokoro, so engine==='kokoro' here implies the
1214
+ // sidecar is available. For engine==='openai' + reply_mode='on-demand' we do
1215
+ // NOT inject a button (its taps would dead-end on the local sidecar) — we
1216
+ // fall through to the normal immediate-synth path so the openai reply behaves
1217
+ // exactly like a normal openai voice reply.
1218
+ const useOnDemandButton =
1219
+ voiceOutPlan != null &&
1220
+ voiceOutPlan.replyMode === 'on-demand' &&
1221
+ voiceOutPlan.engine === 'kokoro'
1222
+
1223
+ const voiceOggs: Uint8Array[] = []
1224
+ // Skip reply-time synthesis ONLY when we're actually deferring to a Listen
1225
+ // button (kokoro on-demand). An openai on-demand config still synthesizes
1226
+ // immediately below.
1227
+ if (voiceOutPlan != null && !useOnDemandButton) {
1228
+ for (const chunkText of voiceOutPlan.ttsChunks) {
1229
+ const ogg = await synthesizeVoiceOut({
1230
+ engine: voiceOutPlan.engine,
1231
+ voice: voiceOutPlan.voice,
1232
+ speed: voiceOutPlan.speed,
1233
+ apiKeyRef: voiceOutPlan.apiKeyRef,
1234
+ ttsText: chunkText,
1235
+ })
1236
+ // Skip a failed chunk but keep going — a partial spoken answer still
1237
+ // beats silence; full-fail (no oggs at all) falls back to text below.
1238
+ if (ogg != null) voiceOggs.push(ogg)
1239
+ }
1240
+ if (voiceOggs.length < voiceOutPlan.ttsChunks.length) {
1241
+ process.stderr.write(
1242
+ `telegram gateway: voice-out: synthesized ${voiceOggs.length}/${voiceOutPlan.ttsChunks.length} voice-note chunk(s)\n`,
1243
+ )
1244
+ }
1245
+ }
1246
+ // Suppress the text body ONLY when voice-only AND we synthesized the FULL
1247
+ // set of chunks (every part of the answer is spoken). A partial or total
1248
+ // synthesis failure leaves the text path running so the answer still
1249
+ // lands in full — never drop the user's answer silently.
1250
+ const suppressText =
1251
+ voiceOutPlan?.replyMode === 'voice-only' &&
1252
+ voiceOutPlan.ttsChunks.length > 0 &&
1253
+ voiceOggs.length === voiceOutPlan.ttsChunks.length
1254
+
1255
+ // #271: validate inline_keyboard and namespace any callback_data with
1256
+ // the `agent:` prefix so the gateway's callback_query dispatcher can
1257
+ // round-trip taps back to this agent without colliding with
1258
+ // infrastructure prefixes (auth:/op:/vd:/vg:/aq:/perm:). URL buttons
1259
+ // pass through unchanged. Attached to the LAST chunk only so buttons
1260
+ // appear on the final visible message.
1261
+ let replyMarkup: { inline_keyboard: AnyButton[][] } | undefined
1262
+ let replyButtonMeta: Map<string, AgentButtonMeta> | undefined
1263
+ const rawKeyboard = args.inline_keyboard as AnyButton[][] | undefined
1264
+ if (rawKeyboard != null) {
1265
+ const validationErrors = validateInlineKeyboard(rawKeyboard)
1266
+ if (validationErrors.length > 0) {
1267
+ const summary = validationErrors
1268
+ .map((e) => `${e.path}.${e.field}: ${e.reason}`)
1269
+ .join('; ')
1270
+ throw new Error(`inline_keyboard validation failed: ${summary}`)
1271
+ }
1272
+ // #3148 fast-follow: mask any secret an agent put in a visible button
1273
+ // `text` label, its `ack_text` toast, or a `copy_text.text` clipboard
1274
+ // payload BEFORE the keyboard is sent — the same outbound scrub the reply
1275
+ // `text` body uses. `callback_data` (the routing key) is left exact.
1276
+ // Feeding BOTH the meta extraction and the callback wrap from the redacted
1277
+ // copy means the stashed toast, the tap echo (`button_text`), and the
1278
+ // "✅ You chose: <label>" annotation (#789) all read already-masked bytes.
1279
+ const redactedKeyboard = redactAgentKeyboard(rawKeyboard, (s) =>
1280
+ redactOutboundText(s, 'reply_inline_keyboard'),
1281
+ )
1282
+ replyButtonMeta = extractAgentButtonMeta(redactedKeyboard)
1283
+ replyMarkup = { inline_keyboard: wrapAgentCallbacks(redactedKeyboard) }
1284
+ }
1285
+
1286
+ // on-demand voice: append a single '🔊 Listen' button that synthesizes the
1287
+ // spoken reply only when tapped. The button carries a RAW `voice:<token>`
1288
+ // callback_data (NOT wrapped with the agent: prefix) so the dispatcher
1289
+ // handles it internally and never routes it to the agent as an inbound.
1290
+ //
1291
+ // Collision gate: inject ONLY when the reply carries no agent-authored
1292
+ // buttons. If the agent supplied its own keyboard, the callback dispatcher's
1293
+ // single_use strip (keyboardIsSingleUse) governs that whole message; adding
1294
+ // a foreign single_use:false button would flip that message to a mixed
1295
+ // keyboard and defeat the agent's double-fire protection. Keep it simple —
1296
+ // agent buttons present → skip the Listen button for this message.
1297
+ //
1298
+ // useOnDemandButton already gates on engine==='kokoro': an openai on-demand
1299
+ // config never reaches here (it synthesized immediately above), so a Listen
1300
+ // button is never minted for an engine whose taps would dead-end on the
1301
+ // local sidecar.
1302
+ if (
1303
+ useOnDemandButton &&
1304
+ voiceOutPlan!.ttsChunks.length > 0 &&
1305
+ voiceOutPlan!.ttsChunks[0]!.length > 0
1306
+ ) {
1307
+ if (!mayInjectListenButton(rawKeyboard)) {
1308
+ process.stderr.write(
1309
+ 'telegram gateway: voice-out on-demand: agent supplied inline_keyboard — skipping Listen button (single_use collision gate)\n',
1310
+ )
1311
+ } else {
1312
+ // Token is intentionally GLOBAL (not chat-keyed): under the single-tenant
1313
+ // invariant the operator is the only authorized sender across all chats,
1314
+ // and the tap handler re-checks access.allowFrom before synthesizing, so
1315
+ // a token needs no per-chat scoping to be safe.
1316
+ const token = mintVoiceOnDemandToken()
1317
+ voiceOnDemandCache.put(token, {
1318
+ // ttsChunks[0] is already normalizeForSpeech(reply) (kokoro path).
1319
+ text: voiceOutPlan.ttsChunks[0]!,
1320
+ ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
1321
+ speed: voiceOutPlan.speed,
1322
+ })
1323
+ // The keyboard stays after the tap (never stripped) so it can be
1324
+ // replayed. The gate above guarantees this is the ONLY button on the
1325
+ // message, so keeping it is safe (no agent buttons to protect).
1326
+ replyMarkup = buildListenKeyboard(token)
1327
+ // #2763 eager pre-synthesis: kick a background synth of the same
1328
+ // payload so the Listen tap attaches the pre-made file instantly.
1329
+ // Local-engine only (useOnDemandButton already gates engine==='kokoro'
1330
+ // — the cloud engine never eager-synthesizes). enqueue() is a
1331
+ // synchronous array push; the drain is deferred + async and never
1332
+ // awaited here, so the text sends below are never delayed and a synth
1333
+ // failure can never affect message delivery (the tap just falls back
1334
+ // to the lazy path).
1335
+ if (eagerVoiceEnabled()) {
1336
+ voicePreSynthQueue.enqueue({
1337
+ token,
1338
+ text: voiceOutPlan.ttsChunks[0]!,
1339
+ ...(voiceOutPlan.voice != null ? { voice: voiceOutPlan.voice } : {}),
1340
+ speed: voiceOutPlan.speed,
1341
+ })
1342
+ }
1343
+ }
1344
+ }
1345
+
1346
+ const replySKey = streamKey(chat_id, threadId)
1347
+ suppressPtyPreview.add(replySKey)
1348
+ let previewMessageId: number | null = null
1349
+ const openStream = activeDraftStreams.get(replySKey)
1350
+ if (openStream && !openStream.isFinal()) {
1351
+ await openStream.finalize().catch(() => {})
1352
+ previewMessageId = openStream.getMessageId()
1353
+ activeDraftStreams.delete(replySKey)
1354
+ lastPtyPreviewByChat.delete(replySKey)
1355
+ }
1356
+
1357
+ // Pre-alloc placeholder consumption removed in #553 PR 5 — the
1358
+ // gateway no longer pre-allocates a draft on inbound, so reply tools
1359
+ // simply render fresh. The 👀 reaction (#568) and typing indicator
1360
+ // (#585) provide the visual gap that the placeholder used to fill.
1361
+
1362
+ const deleteStalePreview = async (id: number): Promise<void> => {
1363
+ // #1075: operates on a message in a (possibly-deleted) thread. The
1364
+ // "delete not found" case is already swallowed by robustApiCall; on
1365
+ // THREAD_NOT_FOUND, swallowingApiCall logs + returns undefined so the
1366
+ // reply flow continues with a fresh send.
1367
+ await swallowingApiCall(
1368
+ () => lockedBot.api.deleteMessage(chat_id, id),
1369
+ { chat_id, verb: 'reply.deleteStalePreview' },
1370
+ )
1371
+ }
1372
+
1373
+ logStreamingEvent({
1374
+ kind: 'reply_called',
1375
+ chatId: chat_id,
1376
+ charCount: effectiveText.length,
1377
+ replacedPreview: previewMessageId != null,
1378
+ previewMessageId,
1379
+ })
1380
+ // #1122 KPI: a `reply` always produces a fresh user-visible outbound
1381
+ // message — count it for the outbound-gap / TTFO KPI AND reset the
1382
+ // silence-poke clock so the next poke is measured from this send.
1383
+ signalTracker.noteOutbound(statusKey(chat_id, threadId), Date.now())
1384
+ silencePoke.noteOutbound(statusKey(chat_id, threadId), Date.now())
1385
+ // Mid-turn auto-classify recency clock: the agent just produced visible output
1386
+ // in this chat/thread (cross-turn, unlike silencePoke's per-turn lastOutboundAt).
1387
+ // Only maintained when the shadow flag is on → truly zero overhead by default.
1388
+ if (AUTOCLASSIFY_MIDTURN_SHADOW) noteAgentOutputAt(statusKey(chat_id, threadId), Date.now())
1389
+ // PR3b-cutover: feed lastOutboundAt to the delivery machine so its
1390
+ // TTL `tick` suppresses the fallback for a long-but-active turn
1391
+ // (model streaming past 5 min) — parity with silencePoke's own
1392
+ // suppression, so the cutover gate doesn't clear a live turn.
1393
+ shadowEmit({ kind: 'modelOutbound', key: statusKey(chat_id, threadId) as _ChatKey, at: Date.now() })
1394
+ // #1741 — only clear silent-end state on a plausibly-final reply.
1395
+ // An interim ack (disable_notification:true, short text, no done)
1396
+ // must NOT clear the state file; otherwise a turn that ends with
1397
+ // ack-only + answer-as-transcript leaves no state for the Stop
1398
+ // hook to act on if `turn_end` never lands (the `turn_duration`
1399
+ // system event is unreliable for trivial-prompt turns — see the
1400
+ // executeReply finalize comments). Final-answer replies still
1401
+ // clear; the main turn-end path also re-writes the state when
1402
+ // finalAnswerDelivered=false, so this is a belt-and-braces gate
1403
+ // for the turn_end-missing case (#1741).
1404
+ if (isFinalAnswerReply({ text: rawText, disableNotification: modelDisableNotification })) {
1405
+ clearSilentEndState(statusKey(chat_id, threadId))
1406
+ }
1407
+
1408
+ // Lever 2 (design §9 lever 2): finalize the activity card BEFORE the reply
1409
+ // chunks send, so the card keeps its (lower) message_id and the reply is
1410
+ // structurally last on screen. ONLY for a *substantive* final — for an ack
1411
+ // (non-substantive) do NOTHING: finalizing an ack early would
1412
+ // close → reopen → emit MORE messages (the #2141 ack-then-work feed, R3).
1413
+ // `clearActivitySummary` edits the existing card in place (no new send) and
1414
+ // nulls `activityMessageId`; combined with the sticky latch set here it
1415
+ // prevents any post-reply re-OPEN below the answer. Idempotent with the
1416
+ // tool_use-event clear at the first-reply handoff (the existing backstop).
1417
+ {
1418
+ const finalizeTurn = getCurrentTurn()
1419
+ if (
1420
+ finalizeTurn != null
1421
+ && isSubstantiveFinalReply({ text: rawText, disableNotification: modelDisableNotification })
1422
+ ) {
1423
+ // PR-4a: routed through the emission-authority façade (no-op delegates —
1424
+ // the latch-set and the finalize run exactly as before).
1425
+ const ea = emissionAuthorityFor(finalizeTurn)
1426
+ ea.markSubstantiveFinalDelivered(() => {
1427
+ finalizeTurn.finalAnswerEverDelivered = true
1428
+ finalizeTurn.finalAnswerDeliveredAt = Date.now()
1429
+ })
1430
+ ea.finalizeCard(() => {
1431
+ clearActivitySummary(finalizeTurn)
1432
+ })
1433
+ }
1434
+ }
1435
+
1436
+ // Reply-flicker fix — apply the deferred flushed-turn correction now that
1437
+ // chunk count / files / preview are all resolved. `edit-in-place` reuses the
1438
+ // single-message edit lane below (`previewMessageId`): it edits the flushed
1439
+ // message A into the canonical reply B with the SAME rich rendering a fresh
1440
+ // reply uses, and `sendReplyChunks` falls back to delete+resend on any edit
1441
+ // 400 (message too old / uneditable / gone) — so exactly one message with the
1442
+ // canonical content always survives. We also forgo the quote (an edit can't
1443
+ // carry a reply_parameters quote) by clearing `reply_to`, so the quote-delete
1444
+ // guard just below does NOT delete our edit target. `delete-resend` keeps the
1445
+ // legacy behaviour (delete the flushed message(s), then send fresh below).
1446
+ if (supersedeFlushIds.length > 0) {
1447
+ const correction = decideSupersedeCorrection({
1448
+ flushMessageIds: supersedeFlushIds,
1449
+ chunkCount: chunks.length,
1450
+ hasFiles: files.length > 0,
1451
+ suppressText,
1452
+ hasOpenPreview: previewMessageId != null,
1453
+ })
1454
+ if (correction.mode === 'edit-in-place') {
1455
+ previewMessageId = correction.editMessageId
1456
+ reply_to = undefined
1457
+ process.stderr.write(
1458
+ `telegram gateway: reply: superseding flushed message via edit-in-place ` +
1459
+ `chatId=${chat_id} id=${correction.editMessageId}\n`,
1460
+ )
1461
+ } else {
1462
+ for (const id of correction.deleteMessageIds) {
1463
+ await swallowingApiCall(
1464
+ () => lockedBot.api.deleteMessage(chat_id, id),
1465
+ { chat_id, verb: 'reply.supersedeFlushed' },
1466
+ )
1467
+ }
1468
+ }
1469
+ }
1470
+
1471
+ if (previewMessageId != null && reply_to != null && replyMode !== 'off') {
1472
+ await deleteStalePreview(previewMessageId)
1473
+ previewMessageId = null
1474
+ }
1475
+
1476
+ startTypingLoop(chat_id, threadId ?? null)
1477
+
1478
+ // #1677 silent-reply auto-edit. Consecutive silent replies within
1479
+ // a turn edit a single anchor message instead of stacking new
1480
+ // bubbles. We branch BEFORE the chunk loop so the single-chunk
1481
+ // common case takes an editMessageText path; everything else
1482
+ // (multi-chunk, ping, files, buttons) falls through to fresh send
1483
+ // and either captures a new anchor or doesn't, per the predicate.
1484
+ let silentAnchorEditDone = false
1485
+ {
1486
+ const turn = getCurrentTurn()
1487
+ // Skip the silent-anchor merge when a flushed-turn supersede is active: an
1488
+ // edit-in-place correction has re-pointed `previewMessageId` at the flushed
1489
+ // message (the chunk loop below edits it), and merging into a prior silent
1490
+ // anchor instead would orphan the flushed message A (leaving BOTH A and the
1491
+ // anchor visible — the exact duplicate this supersede exists to prevent).
1492
+ if (turn != null && chunks.length === 1 && supersedeFlushIds.length === 0) {
1493
+ const decision = decideSilentReplyAnchor({
1494
+ effectivelySilent: disableNotification,
1495
+ anchorMessageId: turn.silentAnchorMessageId,
1496
+ anchorText: turn.silentAnchorText,
1497
+ newReplyText: effectiveText,
1498
+ hasFiles: files.length > 0,
1499
+ hasButtons: replyMarkup != null,
1500
+ wasOverPingSuppressed,
1501
+ })
1502
+ if (decision.kind === 'edit-anchor') {
1503
+ const editParams: {
1504
+ message_thread_id?: number
1505
+ link_preview_options?: { is_disabled: boolean }
1506
+ } = {
1507
+ link_preview_options: { is_disabled: disableLinkPreview },
1508
+ }
1509
+ if (threadId != null) editParams.message_thread_id = threadId
1510
+ try {
1511
+ await robustApiCall(
1512
+ () =>
1513
+ lockedBot.api.editMessageText(
1514
+ chat_id,
1515
+ decision.messageId,
1516
+ literalText ? decision.mergedText : richMessage(decision.mergedText),
1517
+ editParams,
1518
+ ),
1519
+ {
1520
+ chat_id,
1521
+ verb: 'reply.silent-anchor-edit',
1522
+ ...(threadId != null ? { threadId } : {}),
1523
+ },
1524
+ )
1525
+ turn.silentAnchorText = decision.mergedText
1526
+ sentIds.push(decision.messageId)
1527
+ logOutbound(
1528
+ 'edit',
1529
+ chat_id,
1530
+ decision.messageId,
1531
+ decision.mergedText.length,
1532
+ 'silent-anchor-merge',
1533
+ )
1534
+ process.stderr.write(
1535
+ `telegram gateway: silent-reply auto-edit — ` +
1536
+ `chat=${chat_id} anchor=${decision.messageId} ` +
1537
+ `merged_len=${decision.mergedText.length}\n`,
1538
+ )
1539
+
1540
+ // #1679 — side effects the chunk-loop completion path runs.
1541
+ // The edit-anchor branch returns early below, so these must
1542
+ // be wired here too. Skipping them silently causes:
1543
+ // - cross-turn ambient (`pending-work-progress.ts`) holds
1544
+ // a stale anchor text and OVERWRITES the model's
1545
+ // accumulated silent content with `still working (Nm)`
1546
+ // when async work is in flight (this is the load-bearing
1547
+ // fix);
1548
+ // - SQLite history (`get_recent_messages`) misses the
1549
+ // silent-anchor content;
1550
+ // - #1664 silent-end re-prompt fires even when the
1551
+ // accumulated silent content qualifies as substantive;
1552
+ // - retries within the dedup window may double-send.
1553
+ // #1760 primary fix — clear any stale prior-turn ticker
1554
+ // before re-anchoring on this silent-reply edit. See the
1555
+ // matching comment at the executeReply finalize site below.
1556
+ pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
1557
+ pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
1558
+ messageId: decision.messageId,
1559
+ text: decision.mergedText,
1560
+ literalText,
1561
+ })
1562
+ if (HISTORY_ENABLED) {
1563
+ try {
1564
+ recordOutbound({
1565
+ chat_id,
1566
+ thread_id: threadId ?? null,
1567
+ message_ids: [decision.messageId],
1568
+ texts: [decision.mergedText],
1569
+ attachment_kinds: [null],
1570
+ })
1571
+ } catch (histErr) {
1572
+ process.stderr.write(
1573
+ `telegram gateway: history recordOutbound (silent-anchor-edit) failed: ${histErr instanceof Error ? histErr.message : String(histErr)}\n`,
1574
+ )
1575
+ }
1576
+ }
1577
+ if (
1578
+ turn != null
1579
+ && isFinalAnswerReply({
1580
+ text: decision.mergedText,
1581
+ disableNotification: modelDisableNotification,
1582
+ })
1583
+ ) {
1584
+ turn.finalAnswerDelivered = true
1585
+ // Feed-reopen refinement: a substantive merged silent-anchor
1586
+ // answer must NOT re-open the feed on post-answer housekeeping.
1587
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({
1588
+ text: decision.mergedText,
1589
+ disableNotification: modelDisableNotification,
1590
+ })
1591
+ // Sticky ordering latch (lever 1): a substantive final closes the
1592
+ // card OPEN gate for the rest of the turn. NEVER cleared by reopen.
1593
+ if (turn.finalAnswerSubstantive) turn.finalAnswerEverDelivered = true
1594
+ if (turn.finalAnswerSubstantive && turn.finalAnswerDeliveredAt == null) turn.finalAnswerDeliveredAt = Date.now()
1595
+ if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
1596
+ }
1597
+ outboundDedup.record(
1598
+ chat_id,
1599
+ threadId,
1600
+ decision.mergedText,
1601
+ Date.now(),
1602
+ turn?.registryKey ?? null,
1603
+ )
1604
+
1605
+ silentAnchorEditDone = true
1606
+ } catch (err) {
1607
+ // Edit failed (e.g. message deleted, rate limit exhausted,
1608
+ // parse error). Fall through to fresh-send below — the
1609
+ // anchor will be overwritten by whatever lands.
1610
+ process.stderr.write(
1611
+ `telegram gateway: silent-reply auto-edit failed, ` +
1612
+ `falling back to fresh send: ${err instanceof Error ? err.message : String(err)}\n`,
1613
+ )
1614
+ }
1615
+ }
1616
+ }
1617
+ }
1618
+
1619
+ if (silentAnchorEditDone) {
1620
+ // Skip the chunk loop entirely — the anchor edit IS the send.
1621
+ // Match the normal exit path: stop typing, then return.
1622
+ stopTypingLoop(chat_id, threadId ?? null)
1623
+ return {
1624
+ content: [
1625
+ {
1626
+ type: 'text',
1627
+ text: `edited (id: ${sentIds[0]})`,
1628
+ },
1629
+ ],
1630
+ }
1631
+ }
1632
+
1633
+ // #2996 step 1 — the chunk-send loop (with its THREAD_NOT_FOUND / oversize
1634
+ // re-split / parse-reject fallback ladder and partial-failure contract) is
1635
+ // relocated verbatim to outbound-send-path.ts's `sendReplyChunks` so it is
1636
+ // unit-testable against a fake bot API (gateway.ts is not importable —
1637
+ // Bun.listen + boot logic run at import). The raw `bot.api.*` calls stay HERE
1638
+ // as thin injected adapters (retry wrapping + allow-raw-bot-api markers
1639
+ // preserved), so the module is bot-agnostic and the check-bot-api-wrapping
1640
+ // allowlist is unchanged. The caller still builds the per-chunk option shape
1641
+ // (byte-identical). `sentIds` is threaded by reference; `threadId` /
1642
+ // `previewMessageId` come back for the file-send + history code below.
1643
+ const chunkSendDeps: ReplyChunkSendDeps = {
1644
+ sendRich: (opts, body, tid) =>
1645
+ robustApiCall(
1646
+ // allow-raw-bot-api: injected chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
1647
+ () => lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
1648
+ // #3084 PR 2: the final reply is CRITICAL — never shed; degraded mode
1649
+ // fails fast (structured flood_wait) instead of blocking the MCP reply.
1650
+ { threadId: tid, chat_id, priorityClass: 'critical' },
1651
+ ),
1652
+ sendLiteral: (opts, txt, tid) =>
1653
+ robustApiCall(
1654
+ // allow-raw-bot-api: injected chunk-loop adapter — literal format:'text' send routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
1655
+ () => lockedBot.api.sendMessage(chat_id, txt, opts as never),
1656
+ { threadId: tid, chat_id, priorityClass: 'critical' },
1657
+ ),
1658
+ sendLiteralRaw: (opts, txt) =>
1659
+ // allow-raw-bot-api: literal last-resort fallback (plaintext parse-reject / length re-split); wrapping would re-enter the parse/length policy that just rejected the payload
1660
+ lockedBot.api.sendMessage(chat_id, txt, opts as never),
1661
+ sendRichRaw: (opts, body) =>
1662
+ // allow-raw-bot-api: rich length-error re-split last resort; wrapping would re-enter the chunk-loop's own classification on an already-classified length failure
1663
+ lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
1664
+ editPreview: (mid, body, opts, tid) =>
1665
+ robustApiCall(
1666
+ // allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
1667
+ () => lockedBot.api.editMessageText(chat_id, mid, body as never, opts as never),
1668
+ // Finalizing the reply into the preview message — still CRITICAL (this
1669
+ // IS the answer). Pass messageId/editPayload so the gate's per-message
1670
+ // floor + no-op skip engage on the edit (part3-design §4/§5, PR1 L1).
1671
+ { threadId: tid, chat_id, priorityClass: 'critical', messageId: mid, editPayload: body },
1672
+ ),
1673
+ richMessage,
1674
+ logOutbound,
1675
+ deleteStalePreview,
1676
+ stderr: (s: string) => { process.stderr.write(s) },
1677
+ }
1678
+ try {
1679
+ const _sendResult = await sendReplyChunks(chunkSendDeps, {
1680
+ chatId: chat_id,
1681
+ chunks,
1682
+ literalText,
1683
+ suppressText,
1684
+ threadId,
1685
+ previewMessageId,
1686
+ sentIds,
1687
+ buildSendOpts: (i, isLastChunk, tid) => {
1688
+ const shouldReplyTo =
1689
+ reply_to != null && replyMode !== 'off' && (replyMode === 'all' || i === 0)
1690
+ return {
1691
+ ...(shouldReplyTo
1692
+ ? {
1693
+ reply_parameters: {
1694
+ message_id: reply_to,
1695
+ ...(quoteText != null ? { quote: { text: quoteText, position: 0 } } : {}),
1696
+ },
1697
+ }
1698
+ : {}),
1699
+ ...(tid != null ? { message_thread_id: tid } : {}),
1700
+ ...(disableLinkPreview ? { link_preview_options: { is_disabled: true } } : {}),
1701
+ ...(replyMarkup != null && isLastChunk ? { reply_markup: replyMarkup } : {}),
1702
+ ...(protectContent ? { protect_content: true } : {}),
1703
+ ...(disableNotification ? { disable_notification: true } : {}),
1704
+ }
1705
+ },
1706
+ buildPreviewEditOpts: (isLastChunk) => {
1707
+ const editOpts: Record<string, unknown> = {}
1708
+ if (disableLinkPreview) editOpts.link_preview_options = { is_disabled: true }
1709
+ if (replyMarkup != null && isLastChunk) editOpts.reply_markup = replyMarkup
1710
+ return editOpts
1711
+ },
1712
+ })
1713
+ threadId = _sendResult.threadId
1714
+ previewMessageId = _sendResult.previewMessageId
1715
+ } catch (err) {
1716
+ const msg = err instanceof Error ? err.message : String(err)
1717
+ throw new Error(`reply failed after ${sentIds.length} of ${chunks.length} chunk(s) sent: ${msg}`)
1718
+ } finally {
1719
+ stopTypingLoop(chat_id, threadId ?? null)
1720
+ }
1721
+
1722
+ // Outbound voice notes (PR-C2). Sent IN ORDER, AFTER the text body
1723
+ // (voice+text) or INSTEAD of it (voice-only, where suppressText skipped
1724
+ // the chunk loop). A long reply produces SEVERAL notes — each spoken in
1725
+ // sequence so the whole answer is heard, never truncated. Best-effort and
1726
+ // fully non-fatal: a sendVoice failure must NEVER break the text reply
1727
+ // path. In voice-only mode a failure would leave the user with an
1728
+ // incomplete spoken answer, so on the FIRST send failure we recover by
1729
+ // sending the full text once and stop sending further notes.
1730
+ for (let v = 0; v < voiceOggs.length; v++) {
1731
+ const oggBytes = voiceOggs[v]!
1732
+ const voiceOpts: Record<string, unknown> = {
1733
+ // Quote the user's message only on the FIRST voice note (mirrors the
1734
+ // text chunk loop's first-chunk reply behaviour).
1735
+ ...(v === 0 && reply_to != null && replyMode !== 'off'
1736
+ ? { reply_parameters: { message_id: reply_to } }
1737
+ : {}),
1738
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
1739
+ ...(disableNotification ? { disable_notification: true } : {}),
1740
+ }
1741
+ try {
1742
+ const sentVoice = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
1743
+ robustApiCall,
1744
+ (tid) => {
1745
+ const opts = { ...voiceOpts }
1746
+ if (tid != null) opts.message_thread_id = tid
1747
+ else delete opts.message_thread_id
1748
+ // allow-raw-bot-api: adapter callback INSIDE retryWithThreadFallback→robustApiCall; the THREAD_NOT_FOUND fallback is handled by the wrapper.
1749
+ return lockedBot.api.sendVoice(chat_id, new InputFile(Buffer.from(oggBytes)), opts as never)
1750
+ },
1751
+ { threadId, chat_id, verb: 'sendVoice' },
1752
+ )
1753
+ sentIds.push(sentVoice.message_id)
1754
+ logOutbound(
1755
+ 'reply',
1756
+ chat_id,
1757
+ sentVoice.message_id,
1758
+ voiceOutPlan?.ttsChunks[v]?.length ?? 0,
1759
+ `voice-note=${v + 1}/${voiceOggs.length}`,
1760
+ )
1761
+ } catch (err) {
1762
+ const msg = err instanceof Error ? err.message : String(err)
1763
+ process.stderr.write(
1764
+ `telegram gateway: voice-out: sendVoice ${v + 1}/${voiceOggs.length} failed (non-fatal): ${msg}\n`,
1765
+ )
1766
+ // voice-only fell over mid-stream with text suppressed → recover by
1767
+ // sending the FULL text body once so the answer still lands, then
1768
+ // stop sending further notes (the text now carries everything).
1769
+ if (suppressText) {
1770
+ try {
1771
+ const opts: Record<string, unknown> = {
1772
+ ...(reply_to != null && replyMode !== 'off'
1773
+ ? { reply_parameters: { message_id: reply_to } }
1774
+ : {}),
1775
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
1776
+ ...(disableNotification ? { disable_notification: true } : {}),
1777
+ }
1778
+ // allow-raw-bot-api: voice-only recovery fallback — the rich path already ran/was skipped; send the source text plainly so the answer is never lost.
1779
+ const sent = await lockedBot.api.sendMessage(chat_id, effectiveText, opts as never)
1780
+ sentIds.push(sent.message_id)
1781
+ logOutbound('reply', chat_id, sent.message_id, effectiveText.length, 'voice-only-text-recovery')
1782
+ } catch (textErr) {
1783
+ process.stderr.write(
1784
+ `telegram gateway: voice-out: voice-only text recovery ALSO failed: ${textErr instanceof Error ? textErr.message : String(textErr)}\n`,
1785
+ )
1786
+ }
1787
+ break
1788
+ }
1789
+ }
1790
+ }
1791
+
1792
+ // #710: remember per-button agent meta (ack_text / single_use) keyed
1793
+ // by the message that actually carries the keyboard — that's the last
1794
+ // text chunk, since the keyboard is attached only on isLastChunk.
1795
+ if (replyButtonMeta != null && replyButtonMeta.size > 0 && sentIds.length >= chunks.length) {
1796
+ const keyboardMsgId = sentIds[chunks.length - 1]
1797
+ if (typeof keyboardMsgId === 'number') {
1798
+ rememberAgentButtonMeta(chat_id, keyboardMsgId, replyButtonMeta)
1799
+ }
1800
+ }
1801
+
1802
+ // #1445 cross-turn pending-async ambient. Capture the last text
1803
+ // chunk as the anchor — if this turn ends with a pending async
1804
+ // dispatch, the framework edits THIS message in place every 60s
1805
+ // with a `— still working (Nm)` suffix until the user re-engages.
1806
+ // Multi-chunk replies: anchor is the LAST chunk (edits append to
1807
+ // the visually-trailing message; earlier chunks are left intact).
1808
+ if (sentIds.length === chunks.length && chunks.length > 0) {
1809
+ const anchorMsgId = sentIds[chunks.length - 1]
1810
+ if (typeof anchorMsgId === 'number') {
1811
+ // #1760 primary fix — clear any stale prior-turn ticker BEFORE
1812
+ // re-anchoring. The canonical teardown wires (turn_end,
1813
+ // subagent_handback, inbound) can be missed (e.g. SDK turn_end
1814
+ // event dropped, as in the #1760 live evidence). Tearing down on
1815
+ // every reply-finalize is idempotent and resilient: it's a no-op
1816
+ // when nothing is active, and drops a stale ambient before the
1817
+ // new turn captures its anchor.
1818
+ pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
1819
+ pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
1820
+ messageId: anchorMsgId,
1821
+ text: chunks[chunks.length - 1],
1822
+ literalText,
1823
+ })
1824
+ }
1825
+ }
1826
+
1827
+ // #1677 silent-reply auto-edit — anchor capture for the FIRST
1828
+ // silent reply of a turn (or the silent reply that replaced the
1829
+ // anchor on overflow). Only captures for the single-chunk,
1830
+ // silent, no-files, no-buttons happy path; the edit-anchor path
1831
+ // earlier in this function handles SUBSEQUENT silent replies by
1832
+ // editing. The next silent reply this turn will see the captured
1833
+ // anchor and edit it in place.
1834
+ if (
1835
+ chunks.length === 1
1836
+ && disableNotification
1837
+ && files.length === 0
1838
+ && replyMarkup == null
1839
+ && sentIds.length === 1
1840
+ ) {
1841
+ const turn = getCurrentTurn()
1842
+ if (turn != null) {
1843
+ turn.silentAnchorMessageId = sentIds[0]!
1844
+ turn.silentAnchorText = effectiveText
1845
+ }
1846
+ }
1847
+
1848
+ // #3033 layer-2 (pre-send validation): probe each photo-extension file
1849
+ // BEFORE it hits the wire and pre-route any that Telegram's photo path
1850
+ // would reject (extreme aspect ratio, width+height over cap, >10MB) as
1851
+ // documents. One bad photo fails a WHOLE sendMediaGroup album with an
1852
+ // opaque 400 — catching it here keeps the good files deliverable and
1853
+ // sends the offender as a document on the first attempt. Probe
1854
+ // failures keep the photo route; the reactive #3022 fallback backstops.
1855
+ const photoPrecheck = new Map<string, ReturnType<typeof classifyPhotoFile>>()
1856
+ // #3038 polish: files that ultimately went out as documents despite a
1857
+ // photo extension (precheck reroute or reactive fallback). Feeds two
1858
+ // honesty surfaces: the reply tool result suffix (so the agent doesn't
1859
+ // claim an inline image rendered) and attachment_kinds history (record
1860
+ // what was actually sent).
1861
+ const documentReroutes: Array<{ path: string; reason: string }> = []
1862
+ const sentAsDocument = new Set<string>()
1863
+ for (const f of files) {
1864
+ if (!PHOTO_EXTS.has(extname(f).toLowerCase())) continue
1865
+ const cls = classifyPhotoFile(f)
1866
+ photoPrecheck.set(f, cls)
1867
+ if (cls.route === 'document') {
1868
+ documentReroutes.push({ path: f, reason: cls.reason })
1869
+ sentAsDocument.add(f)
1870
+ process.stderr.write(
1871
+ `telegram gateway: photo-precheck rerouting ${f} as document (${cls.reason})\n`,
1872
+ )
1873
+ }
1874
+ }
1875
+ const sendableAsPhoto = (f: string) =>
1876
+ PHOTO_EXTS.has(extname(f).toLowerCase()) && photoPrecheck.get(f)?.route !== 'document'
1877
+
1878
+ // #273: when files is 2-10 photos, batch them into a single
1879
+ // sendMediaGroup album rather than N separate sendPhoto calls. The
1880
+ // user's device fires one notification for the album instead of N
1881
+ // (notification-budget protection per the issue's JTBD note). Falls
1882
+ // back to the per-file path for any non-all-photo set.
1883
+ //
1884
+ // #3038 known tradeoff: ONE precheck-rerouted photo in the set drops
1885
+ // the WHOLE album to per-file sends — the user gets N notifications
1886
+ // instead of 1. Deliberate: sendMediaGroup can't mix photo and
1887
+ // document media, and a partial album plus a stray document is more
1888
+ // confusing than N files. Revisit only if mixed albums become common.
1889
+ const allPhotos = files.length >= 2 && files.length <= 10
1890
+ && files.every(sendableAsPhoto)
1891
+ // #1075: thread-id-bearing file sends. Mirror the chunk-loop's
1892
+ // THREAD_NOT_FOUND fallback (deleted topic → drop the thread and
1893
+ // resend on the main chat) so an attachment-bearing reply doesn't
1894
+ // crash when the user deletes the topic mid-flight.
1895
+ const replyParams =
1896
+ reply_to != null && replyMode !== 'off' ? { reply_parameters: { message_id: reply_to } } : {}
1897
+ // Send one file as a document, routed through the same thread-fallback
1898
+ // policy as the photo path. `InputFile` streams are single-use, so a
1899
+ // document retry after a failed sendPhoto must build a FRESH InputFile
1900
+ // from the path. Returns the sent message (with its echoed thread id).
1901
+ const sendAsDocument = (f: string) =>
1902
+ retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
1903
+ robustApiCall,
1904
+ (tid) => {
1905
+ const baseOpts = {
1906
+ ...replyParams,
1907
+ ...(tid != null ? { message_thread_id: tid } : {}),
1908
+ }
1909
+ // allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware document fallback
1910
+ return lockedBot.api.sendDocument(chat_id, new InputFile(f), baseOpts)
1911
+ },
1912
+ { threadId, chat_id, verb: 'sendDocument' },
1913
+ )
1914
+
1915
+ if (allPhotos) {
1916
+ const media = files.map((f) => ({
1917
+ type: 'photo' as const,
1918
+ media: new InputFile(f),
1919
+ }))
1920
+ let sent: Array<{ message_id: number; message_thread_id?: number }>
1921
+ try {
1922
+ sent = await retryWithThreadFallback(
1923
+ robustApiCall,
1924
+ (tid) => {
1925
+ const baseOpts = {
1926
+ ...replyParams,
1927
+ ...(tid != null ? { message_thread_id: tid } : {}),
1928
+ }
1929
+ return lockedBot.api.sendMediaGroup(chat_id, media, baseOpts)
1930
+ },
1931
+ { threadId, chat_id, verb: 'sendMediaGroup' },
1932
+ )
1933
+ } catch (err) {
1934
+ // Graceful fallback: one bad photo in the album (e.g. a tall phone
1935
+ // screenshot → PHOTO_INVALID_DIMENSIONS) fails the WHOLE media group.
1936
+ // Rather than surface an error, re-send each file individually as a
1937
+ // document so the user still receives all of them. See #klanker
1938
+ // 2026-07-10 incident + isPhotoDimensionRejectError.
1939
+ if (!isPhotoDimensionRejectError(err)) throw err
1940
+ process.stderr.write(
1941
+ `telegram gateway: sendMediaGroup rejected the photo album ` +
1942
+ `(${err instanceof Error ? err.message : String(err)}); ` +
1943
+ `falling back to per-file sendDocument\n`,
1944
+ )
1945
+ sent = []
1946
+ const albumReason = 'Telegram rejected the photo album; whole album re-sent as documents'
1947
+ for (const f of files) {
1948
+ sent.push(await sendAsDocument(f))
1949
+ sentAsDocument.add(f)
1950
+ documentReroutes.push({ path: f, reason: albumReason })
1951
+ }
1952
+ }
1953
+ if (threadId != null) {
1954
+ // If the fallback dropped the thread id, propagate that decision
1955
+ // to subsequent calls in this reply (no further retries needed).
1956
+ // We can't observe which branch resolved cleanly, so peek at the
1957
+ // first sent message: Telegram echoes message_thread_id only when
1958
+ // present in the request. Absent → fallback fired.
1959
+ const first = sent[0] as { message_thread_id?: number } | undefined
1960
+ if (first && first.message_thread_id == null) threadId = undefined
1961
+ }
1962
+ for (const m of sent) sentIds.push(m.message_id)
1963
+ } else {
1964
+ for (const f of files) {
1965
+ const input = new InputFile(f)
1966
+ // Photo-ext files that failed the pre-send probe route straight to
1967
+ // sendDocument (see photoPrecheck above) instead of bouncing a 400.
1968
+ const isPhoto = sendableAsPhoto(f)
1969
+ let sent: { message_id: number; message_thread_id?: number }
1970
+ try {
1971
+ sent = await retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
1972
+ robustApiCall,
1973
+ (tid) => {
1974
+ const baseOpts = {
1975
+ ...replyParams,
1976
+ ...(tid != null ? { message_thread_id: tid } : {}),
1977
+ }
1978
+ return isPhoto
1979
+ ? lockedBot.api.sendPhoto(chat_id, input, baseOpts)
1980
+ : lockedBot.api.sendDocument(chat_id, input, baseOpts)
1981
+ },
1982
+ { threadId, chat_id, verb: isPhoto ? 'sendPhoto' : 'sendDocument' },
1983
+ )
1984
+ } catch (err) {
1985
+ // Graceful fallback: an image Telegram won't accept as a photo
1986
+ // (dimensions out of range / too large — a tall phone screenshot
1987
+ // is the canonical trigger, PHOTO_INVALID_DIMENSIONS) is re-sent
1988
+ // as a document so the user still receives the file instead of
1989
+ // getting nothing. Non-photo-dimension errors propagate as before.
1990
+ if (!(isPhoto && isPhotoDimensionRejectError(err))) throw err
1991
+ process.stderr.write(
1992
+ `telegram gateway: sendPhoto rejected ${f} ` +
1993
+ `(${err instanceof Error ? err.message : String(err)}); ` +
1994
+ `falling back to sendDocument\n`,
1995
+ )
1996
+ sent = await sendAsDocument(f)
1997
+ sentAsDocument.add(f)
1998
+ documentReroutes.push({ path: f, reason: 'Telegram rejected it as a photo; re-sent as document' })
1999
+ }
2000
+ // Mirror the threadId-clear above so the *next* file in the
2001
+ // loop skips the doomed thread without paying for another
2002
+ // round trip + retry.
2003
+ if (threadId != null && sent.message_thread_id == null) {
2004
+ threadId = undefined
2005
+ }
2006
+ sentIds.push(sent.message_id)
2007
+ }
2008
+ }
2009
+
2010
+ // #3038: surface photo→document reroutes in the tool result — the
2011
+ // agent otherwise only "sees" success and may tell the user an inline
2012
+ // image rendered when it went out as a file attachment.
2013
+ const result = (sentIds.length === 1
2014
+ ? `sent (id: ${sentIds[0]})`
2015
+ : `sent ${sentIds.length} parts (ids: ${sentIds.join(', ')})`)
2016
+ + rerouteResultSuffix(documentReroutes)
2017
+
2018
+ if (HISTORY_ENABLED && sentIds.length > 0) {
2019
+ try {
2020
+ const fileCount = files.length
2021
+ const textCount = sentIds.length - fileCount
2022
+ const texts: string[] = []
2023
+ const attachKinds: (string | null)[] = []
2024
+ for (let i = 0; i < textCount; i++) { texts.push(chunks[i] ?? ''); attachKinds.push(null) }
2025
+ for (let i = 0; i < fileCount; i++) {
2026
+ const f = files[i] ?? ''
2027
+ const ext = extname(f).toLowerCase()
2028
+ // #3038: record what was ACTUALLY sent — a photo-extension file
2029
+ // rerouted to sendDocument (precheck or reactive fallback) is a
2030
+ // 'document' in history, not a 'photo' from its raw extension.
2031
+ const kind = PHOTO_EXTS.has(ext) && !sentAsDocument.has(f) ? 'photo' : 'document'
2032
+ texts.push(`(${kind}: ${f})`)
2033
+ attachKinds.push(kind)
2034
+ }
2035
+ recordOutbound({ chat_id, thread_id: threadId ?? null, message_ids: sentIds, texts, attachment_kinds: attachKinds })
2036
+ } catch (err) {
2037
+ process.stderr.write(`telegram gateway: history recordOutbound (reply) failed: ${err}\n`)
2038
+ }
2039
+ }
2040
+
2041
+ // Issue #137: signal to the progress driver that an actual outbound
2042
+ // landed, so a turn-end with replyToolCalled=true but zero deliveries
2043
+ // can render the "⚠️ Reply attempted but not delivered" variant.
2044
+ if (sentIds.length > 0) {
2045
+ try {
2046
+ progressDriver?.recordOutboundDelivered(
2047
+ chat_id,
2048
+ threadId != null ? String(threadId) : undefined,
2049
+ )
2050
+ } catch { /* best-effort signal */ }
2051
+ // #203: fresh sendMessage from reply tool is a user-visible signal.
2052
+ signalTracker.noteSignal(statusKey(chat_id, threadId), Date.now())
2053
+ // #1713: the reply tool is a NON-EVENT for the status reaction
2054
+ // WHEN IT'S AN INTERIM ACK. The reaction reflects current turn
2055
+ // activity, not delivery state — interim acks must not collapse
2056
+ // the working-state ladder to 👍.
2057
+ //
2058
+ // #1728 carve-out (2026-05-24): when this reply IS the final
2059
+ // answer (`isFinalAnswerReply` returns true — same classifier
2060
+ // #1664 uses for silent-end re-prompt gating), it IS effectively
2061
+ // turn-end and we MUST finalize here. Rationale: Claude Code's
2062
+ // `turn_duration` system event is unreliable for the trivial-
2063
+ // prompt happy path (driver sends "what's 2+2", model replies
2064
+ // "4", no `turn_duration` ever lands in the JSONL session tail).
2065
+ // Pre-#1718 this wedge was masked by the legacy
2066
+ // `endStatusReaction` shim running unconditionally on every
2067
+ // reply (outcome='done'); #1718 removed that call site
2068
+ // intending `turn_end` to be the sole terminal trigger. The
2069
+ // contract was right in spirit but `turn_end` doesn't fire 100%
2070
+ // of the time, so the buffer gate (activeTurnStartedAt) stays
2071
+ // set forever and every subsequent inbound gets `held mid-turn`
2072
+ // and never delivered. v0.13.27 shipped + reverted on this
2073
+ // failure mode (#1728).
2074
+ //
2075
+ // Net contract:
2076
+ // - interim ack reply (isFinalAnswerReply === false)
2077
+ // → non-event, no reaction finalize, buffer gate stays
2078
+ // - final-answer reply (isFinalAnswerReply === true)
2079
+ // → finalize reaction (debounced 👍) + release buffer
2080
+ // gate via purgeReactionTracking (called inside
2081
+ // finalizeStatusReaction). currentTurn stays alive so
2082
+ // a subsequent `turn_end` still cleans up its share
2083
+ // idempotently.
2084
+ //
2085
+ // #1664 — `turn.finalAnswerDelivered = true` keeps the silent-
2086
+ // end re-prompt from spuriously firing on a delivered final.
2087
+ if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification: modelDisableNotification })) {
2088
+ turn.finalAnswerDelivered = true
2089
+ // Feed-reopen refinement: track whether this final was substantive
2090
+ // (≥200 chars or stream-done — not a short pinging ack) so post-answer
2091
+ // housekeeping tool work does NOT re-open the feed / trip silent-end.
2092
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification: modelDisableNotification })
2093
+ // Sticky ordering latch (lever 1): set once a SUBSTANTIVE final lands;
2094
+ // never cleared by reopen. The card OPEN gate keys on this, not the
2095
+ // mutable finalAnswerDelivered above (which reopen toggles).
2096
+ if (turn.finalAnswerSubstantive) turn.finalAnswerEverDelivered = true
2097
+ if (turn.finalAnswerSubstantive && turn.finalAnswerDeliveredAt == null) turn.finalAnswerDeliveredAt = Date.now()
2098
+ // #1728: release the buffer gate + emit terminal 👍. Mid-turn
2099
+ // acks bypass this branch and remain non-events for the
2100
+ // reaction (preserves #1713). The full turn-state teardown
2101
+ // (nulling `currentTurn`, the per-turn cleanup) still runs in
2102
+ // the `turn_end` handler when it lands; this only fires the
2103
+ // observable side effects that #1718 deferred unconditionally.
2104
+ finalizeStatusReaction(chat_id, threadId, 'done')
2105
+ // PR2: close this origin's obligation on a SUBSTANTIVE final answer
2106
+ // (after finalize so the reaction guard test's anchor window is stable).
2107
+ if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, replyRoutedOriginTurn)
2108
+ }
2109
+ // v0.13.30 follow-up — release the buffer gate on EVERY reply
2110
+ // finalize, not just on `isFinalAnswerReply`. The narrow
2111
+ // `finalizeStatusReaction` path above misses short replies that
2112
+ // set `disable_notification: true` (the model mis-classifies a
2113
+ // genuine answer as an interim ack — e.g. "4" for "what's
2114
+ // 2+2"). Pre-fix the gate stayed set forever and every later
2115
+ // inbound logged `held mid-turn ... will flush on turn-
2116
+ // complete` — but turn-complete never came because Claude
2117
+ // Code's `turn_duration` system event doesn't reliably land
2118
+ // for trivial-prompt turns. v0.13.30 UAT showed the regression
2119
+ // (msg 1873 reply at 13:02:46, msg 1874 held at 13:03:04, gate
2120
+ // never released).
2121
+ //
2122
+ // The reaction controller stays alive (preserves #1713
2123
+ // bidirectional ladder + the steer-vs-queue logic at
2124
+ // gateway.ts:8322 which reads `activeStatusReactions`). Only
2125
+ // the buffer gate flips.
2126
+ //
2127
+ // Component 1: pass the turn so the serialize gate sees this turn's
2128
+ // `finalAnswerDelivered` (set just above for final-answer replies).
2129
+ // An interim ack leaves it false → the cross-topic buffer does NOT
2130
+ // drain yet; the real answer's reply releases it.
2131
+ releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined)
2132
+ // Component 5: the final answer landed — reap the queued-status
2133
+ // placeholder for THIS turn's topic. Key on the turn's own session
2134
+ // thread (where the placeholder was posted / promoted), not the
2135
+ // answer's possibly-overridden threadId.
2136
+ if (turn?.finalAnswerDelivered === true) {
2137
+ reapQueuedStatus(turn.sessionChatId, turn.sessionThreadId)
2138
+ }
2139
+ }
2140
+
2141
+ process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(',')}] chunks=${chunks.length}\n`)
2142
+ // #546 dedup record: future reply / stream_reply / turn-flush
2143
+ // calls with this same content within DEFAULT_DEDUP_TTL_MS will
2144
+ // be suppressed.
2145
+ if (sentIds.length > 0) {
2146
+ outboundDedup.record(chat_id, threadId, text, Date.now(), getCurrentTurn()?.registryKey ?? null)
2147
+ }
2148
+ return { content: [{ type: 'text', text: result }] }
2149
+ }
2150
+
2151
+ /** Gateway dependencies for {@link deliverCapturedProse}. */
2152
+ export interface DeliverCapturedProseDeps {
2153
+ /** THE one live dedup instance — shared with {@link sendReply} and the
2154
+ * stream-render surface (Amendment 1/9). */
2155
+ outboundDedup: OutboundDedupCache
2156
+ bot: Bot<Context>
2157
+ robustApiCall<T>(fn: () => Promise<T>, opts?: RetryCallOpts): Promise<T>
2158
+ redactOutboundText(text: string, site: string): string
2159
+ recordOutbound(rec: {
2160
+ chat_id: string
2161
+ thread_id: number | null
2162
+ message_ids: number[]
2163
+ texts: string[]
2164
+ }): void
2165
+ HISTORY_ENABLED: boolean
2166
+ OBLIGATION_LEDGER_ENABLED: boolean
2167
+ obligationLedger: { close(originTurnId: string): void }
2168
+ clearSilentEndState(key: string): void
2169
+ recordUndeliveredTurnEnd(
2170
+ args: { chatId: string; threadId: number | null; turnKey: string },
2171
+ deps?: SilentEndDeps,
2172
+ ): { exhausted: boolean }
2173
+ hasOutboundDeliveredSince(
2174
+ chatId: string,
2175
+ sinceMs: number,
2176
+ threadId: number | null | undefined,
2177
+ minCount?: number,
2178
+ ): boolean
2179
+ }
2180
+
2181
+ /**
2182
+ * Silent-end recovery delivery (#3228) — `deliverCapturedProse`'s body,
2183
+ * verbatim. A separate inline send path from the reply orchestration (it
2184
+ * delivers the model's terminal prose recovered from the transcript scan when
2185
+ * the turn ended silently); routed into this module so it shares the ONE
2186
+ * injected `outboundDedup` instance and the golden harness (Amendment 2).
2187
+ */
2188
+ export async function deliverCapturedProse(
2189
+ deps: DeliverCapturedProseDeps,
2190
+ args: {
2191
+ chatId: string
2192
+ threadId: number | undefined
2193
+ statusKeyStr: string
2194
+ registryKey: string | null
2195
+ originTurnId: string
2196
+ text: string
2197
+ /** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
2198
+ turnDurationMs?: number
2199
+ },
2200
+ ): Promise<void> {
2201
+ const {
2202
+ outboundDedup, bot, robustApiCall, redactOutboundText, recordOutbound,
2203
+ HISTORY_ENABLED, OBLIGATION_LEDGER_ENABLED, obligationLedger,
2204
+ clearSilentEndState, recordUndeliveredTurnEnd, hasOutboundDeliveredSince,
2205
+ } = deps
2206
+ const { chatId, threadId, statusKeyStr, registryKey, originTurnId, text, turnDurationMs } = args
2207
+ const now = Date.now()
2208
+ // #3228 Finding 1 — the three settlement points (sent / skipped-dedup /
2209
+ // failed) all funnel through the pure `settleCapturedProseDelivery` core so
2210
+ // the failure posture is deterministic and unit-tested. `outcome` is set on
2211
+ // each branch and applied ONCE at the bottom.
2212
+ let outcome: CapturedProseSendOutcome
2213
+ const already = outboundDedup.check(chatId, threadId, text, now, registryKey)
2214
+ if (already == null) {
2215
+ let out = normalizeParagraphBreaks(repairEscapedWhitespace(text))
2216
+ out = redactOutboundText(out, 'captured_prose')
2217
+ const chunks = splitMarkdownChunks(out, RICH_MESSAGE_MAX_CHARS)
2218
+ const sentIds: number[] = []
2219
+ try {
2220
+ let liveThreadId: number | undefined = threadId
2221
+ for (const c of chunks) {
2222
+ const sent = await retryWithThreadFallback(
2223
+ robustApiCall,
2224
+ (tid) => {
2225
+ // Built as a variable (not an inline literal) so excess-property
2226
+ // checks don't reject `link_preview_options` on sendRichMessage's
2227
+ // narrow Other<> type — mirrors the turn-flush send site.
2228
+ const opts = {
2229
+ link_preview_options: { is_disabled: true },
2230
+ ...(tid != null ? { message_thread_id: tid } : {}),
2231
+ }
2232
+ return bot.api.sendRichMessage(chatId, richMessage(c), opts)
2233
+ },
2234
+ { threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose.sendMessage' },
2235
+ )
2236
+ if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
2237
+ liveThreadId = undefined
2238
+ }
2239
+ sentIds.push(sent.message_id)
2240
+ }
2241
+ if (HISTORY_ENABLED && sentIds.length > 0) {
2242
+ try {
2243
+ recordOutbound({
2244
+ chat_id: chatId,
2245
+ thread_id: threadId ?? null,
2246
+ message_ids: sentIds,
2247
+ texts: chunks,
2248
+ })
2249
+ } catch {}
2250
+ }
2251
+ // Record what we just sent so a late reply / stream_reply retry with the
2252
+ // same content is deduped at its send site (the #546 dedup cache).
2253
+ outboundDedup.record(chatId, threadId, text, now, registryKey)
2254
+ process.stderr.write(
2255
+ `telegram gateway: captured-prose delivery — sent ${out.length} chars recovered from ` +
2256
+ `transcript scan (chat=${chatId} origin=${originTurnId})\n`,
2257
+ )
2258
+ outcome = 'sent'
2259
+ } catch (err) {
2260
+ // #3228 Finding 1 — the send threw, so the answer did NOT reach the user.
2261
+ // The `failed` outcome routes to `settleCapturedProseDelivery`'s recovery
2262
+ // path (recordUndelivered), NOT the close/clear path. This is load-bearing:
2263
+ // the shared turn-end teardown (endCurrentTurnAtomic →
2264
+ // decideObligationTurnEnd) already closes the obligation whenever
2265
+ // `replyCalled === true` — exactly the interim-ack case that routes here —
2266
+ // so "leaving the obligation open" is not a real net. Arming the Stop-hook
2267
+ // re-prompt makes the failed send recoverable instead of silently lost.
2268
+ process.stderr.write(
2269
+ `telegram gateway: captured-prose delivery failed: ${(err as Error).message} — ` +
2270
+ `arming the silent-end re-prompt net (recordUndeliveredTurnEnd) so the ` +
2271
+ `answer is recoverable (chat=${chatId} origin=${originTurnId})\n`,
2272
+ )
2273
+ outcome = 'failed'
2274
+ }
2275
+ } else {
2276
+ process.stderr.write(
2277
+ `telegram gateway: captured-prose delivery skipped — this answer already went out ` +
2278
+ `(dedup age=${already.ageMs}ms chat=${chatId} origin=${originTurnId}); settling bookkeeping\n`,
2279
+ )
2280
+ outcome = 'skipped-dedup'
2281
+ }
2282
+ // Apply the settlement bookkeeping through the pure core (#3228 Finding 1):
2283
+ // sent / skipped-dedup → close obligation + clear state (answer is with the
2284
+ // user, so represent + exhausted fallback must not fire)
2285
+ // failed → arm the Stop-hook re-prompt net (recordUndelivered),
2286
+ // do NOT close/clear.
2287
+ const settlement = settleCapturedProseDelivery(outcome, {
2288
+ closeObligation: () => {
2289
+ if (OBLIGATION_LEDGER_ENABLED) {
2290
+ try { obligationLedger.close(originTurnId) } catch {}
2291
+ }
2292
+ },
2293
+ clearState: () => clearSilentEndState(statusKeyStr),
2294
+ recordUndelivered: () => {
2295
+ try {
2296
+ const silentEndDeps: SilentEndDeps | undefined = HISTORY_ENABLED
2297
+ ? {
2298
+ hasOutboundDeliveredSince: (cid, sinceMs, tid) =>
2299
+ hasOutboundDeliveredSince(cid, sinceMs, tid, 1),
2300
+ }
2301
+ : undefined
2302
+ return recordUndeliveredTurnEnd(
2303
+ { chatId, threadId: threadId ?? null, turnKey: statusKeyStr },
2304
+ silentEndDeps,
2305
+ )
2306
+ } catch (netErr) {
2307
+ process.stderr.write(
2308
+ `telegram gateway: captured-prose recovery-net arm failed: ${
2309
+ (netErr as Error).message
2310
+ } (chat=${chatId} origin=${originTurnId})\n`,
2311
+ )
2312
+ // Could not even record the undelivered turn — do NOT claim exhaustion
2313
+ // (firing a fallback we can't justify). Fail safe: leave recovery to
2314
+ // the obligation represent / next Stop hook.
2315
+ return { exhausted: false }
2316
+ }
2317
+ },
2318
+ })
2319
+
2320
+ // Exhaustion-boundary gap (#3228): the send FAILED on the attempt where the
2321
+ // Stop-hook re-prompt budget was already spent, so recordUndeliveredTurnEnd
2322
+ // cleared the state and the re-prompt can no longer recover the answer — and
2323
+ // the obligation was already closed by the interim-ack teardown. Without this
2324
+ // the user gets NEITHER the answer NOR the apology. Deliver a user-facing
2325
+ // fallback, preferring the REAL answer as plain text (a non-rich send often
2326
+ // survives the markdown/parse error the rich send threw on) before the
2327
+ // generic apology.
2328
+ if (outcome === 'failed' && settlement.exhausted) {
2329
+ process.stderr.write(
2330
+ `telegram gateway: WARN captured-prose exhausted-boundary fallback — rich send ` +
2331
+ `failed with the re-prompt budget already spent; attempting a plain-text ` +
2332
+ `delivery of the recovered answer before the generic apology ` +
2333
+ `(chat=${chatId} origin=${originTurnId})\n`,
2334
+ )
2335
+ const plain = redactOutboundText(text, 'captured_prose')
2336
+ const plainChunks = splitMarkdownChunks(plain, RICH_MESSAGE_MAX_CHARS)
2337
+ try {
2338
+ let liveThreadId: number | undefined = threadId
2339
+ for (const c of plainChunks) {
2340
+ // Plain sendMessage — NO parse_mode / rich rendering — so a markdown
2341
+ // construct that made sendRichMessage 400 is sent verbatim instead.
2342
+ const sent = await retryWithThreadFallback(
2343
+ robustApiCall,
2344
+ (tid) =>
2345
+ bot.api.sendMessage(
2346
+ chatId,
2347
+ c,
2348
+ tid != null ? { message_thread_id: tid } : {},
2349
+ ),
2350
+ { threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose-plain-fallback.sendMessage' },
2351
+ )
2352
+ if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
2353
+ liveThreadId = undefined
2354
+ }
2355
+ }
2356
+ // The real answer reached the user via plain text — record it so a late
2357
+ // reply-tool retry with the same content is deduped at its send site.
2358
+ outboundDedup.record(chatId, threadId, text, Date.now(), registryKey)
2359
+ process.stderr.write(
2360
+ `telegram gateway: captured-prose recovered via plain-text fallback ` +
2361
+ `(chat=${chatId} origin=${originTurnId})\n`,
2362
+ )
2363
+ } catch (plainErr) {
2364
+ // Plain text ALSO failed — post the generic apology so the turn is never
2365
+ // silent (mirrors the non-captured exhausted path, gateway turn_end #1161).
2366
+ process.stderr.write(
2367
+ `telegram gateway: captured-prose plain-text fallback ALSO failed: ${
2368
+ (plainErr as Error).message
2369
+ } — posting the generic silent-end apology (chat=${chatId} origin=${originTurnId})\n`,
2370
+ )
2371
+ void retryWithThreadFallback(
2372
+ robustApiCall,
2373
+ (tid) =>
2374
+ bot.api.sendMessage(
2375
+ chatId,
2376
+ silentEndFallbackText(turnDurationMs),
2377
+ tid != null ? { message_thread_id: tid } : {},
2378
+ ),
2379
+ { threadId, chat_id: chatId, verb: 'captured-prose-apology-fallback.sendMessage' },
2380
+ ).catch((err) => {
2381
+ process.stderr.write(
2382
+ `telegram gateway: captured-prose apology fallback send failed: ${
2383
+ err instanceof Error ? err.message : String(err)
2384
+ }\n`,
2385
+ )
2386
+ })
2387
+ }
2388
+ }
2389
+ }