switchroom 0.21.9 → 0.21.11

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 (46) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -2
  4. package/telegram-plugin/dist/gateway/gateway.js +1152 -296
  5. package/telegram-plugin/format.ts +74 -1
  6. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  7. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  8. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  9. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  10. package/telegram-plugin/gateway/gateway.ts +61 -68
  11. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  12. package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
  13. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  14. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  15. package/telegram-plugin/gateway/speech-capture.ts +158 -0
  16. package/telegram-plugin/gateway/stream-render.ts +1 -1
  17. package/telegram-plugin/history.ts +21 -0
  18. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  19. package/telegram-plugin/render/html-fold.ts +372 -0
  20. package/telegram-plugin/render/parse.ts +578 -29
  21. package/telegram-plugin/render/render.ts +14 -13
  22. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  23. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  24. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  25. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  26. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  27. package/telegram-plugin/tests/history.test.ts +78 -0
  28. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  29. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  30. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
  31. package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
  32. package/telegram-plugin/tests/render/parse.test.ts +9 -5
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
  34. package/telegram-plugin/tests/speech-capture.test.ts +296 -0
  35. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  36. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  37. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  38. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  39. package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
  40. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  41. package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
  42. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  43. package/telegram-plugin/tts-normalize.ts +47 -9
  44. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  45. package/telegram-plugin/voice-normalize-text.ts +48 -9
  46. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -737,7 +737,9 @@ import {
737
737
  type CardDrainGateCtx,
738
738
  } from './emission-authority.js'
739
739
  import { CurrentTurnMap } from './current-turn-map.js'
740
- import { resolveAnswerThreadId } from './answer-thread-resolve.js'
740
+ import { resolveAnswerThreadId, isCrossChatAnchor } from './answer-thread-resolve.js'
741
+ import { answerRouteOverrides } from './answer-route-overrides.js'
742
+ import { formatReplyRouteLog } from './reply-route-log.js'
741
743
  import { latestTurnForChat } from './latest-turn-lookup.js'
742
744
  import { decideObligationTurnEnd } from './obligation-turn-end.js'
743
745
  import { maybeRotate, resolveAgentStateDir, resolveTurnsJsonlPath } from './turns-jsonl-rotate.js'
@@ -3909,25 +3911,22 @@ function resolveReplyOwnerTurn(
3909
3911
  * 2026-06-05 triage showed reply routing was the blind spot: `reply: invoked`
3910
3912
  * logged only chat + char count, so a late reply landing in the wrong topic was
3911
3913
  * invisible without hand-correlating raw tg-post threads against turn-lifecycle
3912
- * timestamps. This wrapper logs, per reply: which precedence tier won (`via`),
3913
- * the resolved thread, the origin turn + its thread, and whether the reply was
3914
- * late (turn already ended). `via=recovered` marks a late reply this fix saved
3915
- * from General; `via=quoted` marks an origin recovered from the framework-owned
3916
- * quoted message_id (no model echo); `UNROUTED` flags a supergroup reply that
3917
- * resolved to no topic with NO owner turn to attribute it to (genuinely lost).
3918
- * `MISROUTE_RISK` flags the irreducible determinism residual: a no-echo,
3919
- * no-quote reply that fell to the LIVE turn while a DIFFERENT topic recently had
3920
- * a turn — the framework cannot tell which topic the bare reply answers, so the
3921
- * routing MIGHT be wrong (HOLE a). It is observability only (the reply still
3922
- * routes to the live turn) — it makes the one case that is genuinely
3923
- * model-dependent visible instead of silently mis-routed. A General-topic turn
3924
- * legitimately has no thread, so its replies are NOT UNROUTED-flagged.
3914
+ * timestamps. The line itself — tier (`via`), resolved thread, owner turn, and
3915
+ * the RECOVERED / QUOTED / UNROUTED / MISROUTE_RISK / CROSS_CHAT_ANCHOR_DROPPED
3916
+ * / EXPLICIT_OVERRIDDEN markers — is built by the pure `formatReplyRouteLog`
3917
+ * (`reply-route-log.ts`), which documents each marker. This wrapper binds the
3918
+ * gateway's stateful lookups and owns the routing call.
3925
3919
  *
3926
3920
  * `originVia` distinguishes how the origin turn was resolved: 'echo' (model
3927
3921
  * echoed origin_turn_id), 'quoted' (framework recovered it from args.reply_to),
3928
3922
  * or null (no origin turn). It only affects the `via` label, never the routing.
3923
+ *
3924
+ * Exported ONLY as a test seam: the `answerRouteOverrides.note()` call below is
3925
+ * a SIDE EFFECT no pure module carries, so nothing else can prove it still
3926
+ * happens (`answer-route-side-effect.test.ts`). No production caller — the
3927
+ * gateway still hands it to `sendReply` through the deps object.
3929
3928
  */
3930
- function resolveAnswerThreadWithLog(
3929
+ export function resolveAnswerThreadWithLog(
3931
3930
  chatId: string,
3932
3931
  explicitThreadId: number | undefined,
3933
3932
  originTurn: CurrentTurn | null,
@@ -3935,17 +3934,25 @@ function resolveAnswerThreadWithLog(
3935
3934
  liveTurn: CurrentTurn | null,
3936
3935
  surface: 'reply' | 'stream_reply',
3937
3936
  ): number | undefined {
3937
+ // Cross-chat anchor guard (2026-08-13, bug c in `answer-thread-resolve.ts`):
3938
+ // an anchor turn owned by a DIFFERENT chat must not lend its topic id to this
3939
+ // send. `resolveAnswerThreadId` drops it from the ROUTING (below); these
3940
+ // locals apply the same predicate so the recovery tier and the telemetry
3941
+ // agree with what routed.
3942
+ const originAnchor = isCrossChatAnchor(chatId, originTurn?.sessionChatId) ? null : originTurn
3943
+ const liveAnchor = isCrossChatAnchor(chatId, liveTurn?.sessionChatId) ? null : liveTurn
3938
3944
  // Recover ONLY for a genuinely LATE reply — no live turn at all. Gating on
3939
3945
  // `liveTurn?.sessionThreadId == null` (the original) also fired for a
3940
3946
  // threadless DM that still had a live turn, marking every DM reply
3941
3947
  // `via=recovered`/RECOVERED in the telemetry (routing result unchanged —
3942
3948
  // DM → undefined — but it drowned the real supergroup recoveries the marker
3943
- // exists to surface). `liveTurn == null` is the precise late-reply condition.
3949
+ // exists to surface). No live turn is the precise late-reply condition — and
3950
+ // a cross-chat live turn is, for THIS chat, no live turn.
3944
3951
  const recovered =
3945
3952
  LATE_REPLY_TOPIC_RECOVERY_ENABLED &&
3946
3953
  explicitThreadId == null &&
3947
- originTurn == null &&
3948
- liveTurn == null
3954
+ originAnchor == null &&
3955
+ liveAnchor == null
3949
3956
  ? findLatestTurnForChat(chatId, { endedOnly: false })
3950
3957
  : null
3951
3958
  const threadId = resolveAnswerThreadId({
@@ -3957,58 +3964,44 @@ function resolveAnswerThreadWithLog(
3957
3964
  lastEndedResolvedForChat: recovered != null,
3958
3965
  lastEndedThreadIdForChat: recovered?.sessionThreadId,
3959
3966
  frameworkTopicAuthority: REPLY_TOPIC_AUTHORITY_ENABLED,
3967
+ targetChatId: chatId,
3968
+ originChatId: originTurn?.sessionChatId,
3969
+ liveChatId: liveTurn?.sessionChatId,
3970
+ })
3971
+ // RECORD the explicit-thread override. `formatReplyRouteLog` re-derives the
3972
+ // same predicate for its EXPLICIT_OVERRIDDEN marker, but that one is a pure
3973
+ // string — this call is the SIDE EFFECT the marker used to carry, and it is
3974
+ // load-bearing: obligation escalation reads this registry to tell "topic A's
3975
+ // answer landed in B" from "something unrelated landed in B" (predicate +
3976
+ // rationale: answer-route-overrides.ts). Deleting it would make the escalate
3977
+ // branch nag on top of answers the user already got.
3978
+ //
3979
+ // `anchored` uses the CROSS-CHAT-FILTERED anchors, not the raw turns: an
3980
+ // anchor owned by another chat is dropped from the routing (#4680), so it
3981
+ // overrode nothing and must not be recorded as if it had. This keeps the
3982
+ // record, the log marker, and what actually routed in agreement.
3983
+ answerRouteOverrides.note({
3984
+ chatId,
3985
+ enabled: REPLY_TOPIC_AUTHORITY_ENABLED,
3986
+ explicitThreadId,
3987
+ anchored: originAnchor != null || liveAnchor != null,
3988
+ routedThreadId: threadId,
3989
+ nowMs: Date.now(),
3960
3990
  })
3961
- // `via` reflects the ACTIVE precedence so telemetry matches routing.
3962
- const via = REPLY_TOPIC_AUTHORITY_ENABLED
3963
- ? (originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
3964
- : liveTurn != null ? 'live'
3965
- : explicitThreadId != null ? 'explicit'
3966
- : recovered != null ? 'recovered'
3967
- : 'none')
3968
- : (explicitThreadId != null ? 'explicit'
3969
- : originTurn != null ? (originVia === 'quoted' ? 'quoted' : 'origin')
3970
- : liveTurn?.sessionThreadId != null ? 'live'
3971
- : recovered != null ? 'recovered'
3972
- : 'none')
3973
- // Observability: the model passed an explicit topic but a framework anchor
3974
- // (origin/live) overrode it. This is the deterministic correction that fixes
3975
- // the General→CRM misroute; surface it so the model's topic-grabbing is
3976
- // visible rather than silent.
3977
- const explicitOverridden =
3978
- REPLY_TOPIC_AUTHORITY_ENABLED &&
3979
- explicitThreadId != null &&
3980
- (originTurn != null || liveTurn != null) &&
3981
- threadId !== explicitThreadId
3982
- const ownerTurn = originTurn ?? recovered ?? liveTurn
3983
- const isSupergroup = chatId.startsWith('-100')
3984
- // UNROUTED = a supergroup reply that resolved to NO topic with NO owner turn
3985
- // to attribute it to (genuinely lost). A General-topic turn legitimately has
3986
- // no thread, so a reply owned by it resolving to `-` is CORRECT, not lost —
3987
- // gate on `ownerTurn == null` so General replies don't false-alarm (found by
3988
- // the multi-topic UAT stress, 2026-06-05).
3989
- const unrouted = isSupergroup && threadId == null && ownerTurn == null
3990
- // MISROUTE_RISK = the irreducible determinism residual (HOLE a). A no-echo,
3991
- // no-quote reply fell to the LIVE turn (via=live), but a DIFFERENT topic
3992
- // recently had a turn for this chat — so this bare reply MIGHT belong to that
3993
- // other topic and we cannot tell without the model's echo. Observability only;
3994
- // routing is unchanged. This is the one case framework state cannot
3995
- // disambiguate, surfaced instead of silently mis-routed.
3996
- const misrouteRisk =
3997
- isSupergroup &&
3998
- via === 'live' &&
3999
- hasDifferentThreadedRecentTurn(chatId, liveTurn?.sessionThreadId)
4000
3991
  process.stderr.write(
4001
- `telegram gateway: reply-route surface=${surface} chat=${chatId} ` +
4002
- `resolved_thread=${threadId ?? '-'} via=${via} late=${liveTurn == null} ` +
4003
- `originTurn=${ownerTurn?.turnId ?? '-'} origin_thread=${ownerTurn?.sessionThreadId ?? '-'}` +
4004
- (via === 'recovered' ? ' RECOVERED' : '') +
4005
- (via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
4006
- (unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
4007
- (misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
4008
- (explicitOverridden
4009
- ? ` EXPLICIT_OVERRIDDEN(model→${explicitThreadId},routed→${threadId ?? '-'})`
4010
- : '') +
4011
- '\n',
3992
+ formatReplyRouteLog({
3993
+ surface,
3994
+ chatId,
3995
+ threadId,
3996
+ explicitThreadId,
3997
+ originTurn,
3998
+ originVia,
3999
+ liveTurn,
4000
+ recovered,
4001
+ frameworkTopicAuthority: REPLY_TOPIC_AUTHORITY_ENABLED,
4002
+ hasDifferentThreadedRecentTurn: (liveThreadId) =>
4003
+ hasDifferentThreadedRecentTurn(chatId, liveThreadId),
4004
+ }),
4012
4005
  )
4013
4006
  return threadId
4014
4007
  }
@@ -23,6 +23,13 @@ import { buildObligationRepresentInbound, type Obligation } from './obligation-l
23
23
  import { driveEscalation } from './escalation-drive.js'
24
24
  import { shouldSuppressRepresent } from './represent-guard.js'
25
25
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
26
+ import {
27
+ answeredSinceOpen,
28
+ createEscalationSettleGate,
29
+ resolveEscalateSettleMs,
30
+ resolveRerouteMatchWindowMs,
31
+ } from './escalation-staleness.js'
32
+ import { answerRouteOverrides } from './answer-route-overrides.js'
26
33
  import { parkedTurnStartCount } from './stream-render.js'
27
34
 
28
35
  export function createObligationWiring(deps: ObligationWiringDeps) {
@@ -50,6 +57,16 @@ export function createObligationWiring(deps: ObligationWiringDeps) {
50
57
  sendEscalationNudge,
51
58
  } = deps
52
59
 
60
+ // Escalation settle gate (see escalation-staleness.ts). One instance per
61
+ // wiring — the sweep is the only caller, and the gate must remember the FIRST
62
+ // escalate decision for an obligation across sweep ticks.
63
+ const escalateSettleMs = resolveEscalateSettleMs()
64
+ const escalateSettleGate = createEscalationSettleGate(escalateSettleMs)
65
+ // How stale an EXPLICIT_OVERRIDDEN record may be and still license a look in
66
+ // the thread it names. Derived from the settle window, because the decision
67
+ // that consults it is the one AFTER the gate. See escalation-staleness.ts.
68
+ const rerouteMatchWindowMs = resolveRerouteMatchWindowMs(escalateSettleMs)
69
+
53
70
  /**
54
71
  * PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
55
72
  * (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
@@ -86,7 +103,12 @@ function closeObligationOnSubstantiveReply(
86
103
  ? routedOriginTurn.turnId
87
104
  : null
88
105
  const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId)
89
- if (target != null) obligationLedger.close(target)
106
+ if (target != null) {
107
+ obligationLedger.close(target)
108
+ // The settle gate must forget every terminal, not just the escalate-branch
109
+ // ones, or a closed obligation's entry lives on until FIFO eviction.
110
+ escalateSettleGate.clear(target)
111
+ }
90
112
  }
91
113
 
92
114
  /**
@@ -145,6 +167,7 @@ function cancelInterruptedObligation(): void {
145
167
  const turn = getCurrentTurn()
146
168
  if (turn == null) return
147
169
  if (obligationLedger.close(turn.turnId)) {
170
+ escalateSettleGate.clear(turn.turnId)
148
171
  process.stderr.write(
149
172
  `telegram gateway: obligation cancelled by interrupt origin=${turn.turnId}\n`,
150
173
  )
@@ -244,6 +267,7 @@ function obligationSweep(): void {
244
267
  process.stderr.write(
245
268
  `telegram gateway: obligation closed silently — reply delivered since last represent (no re-fire) origin=${o.originTurnId}\n`,
246
269
  )
270
+ escalateSettleGate.clear(o.originTurnId)
247
271
  obligationLedger.close(o.originTurnId)
248
272
  return
249
273
  }
@@ -304,13 +328,72 @@ function obligationSweep(): void {
304
328
  )
305
329
  return
306
330
  }
307
- if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
331
+ // SCOPE: the obligation's own topic, PLUS — only while the router's
332
+ // `EXPLICIT_OVERRIDDEN(model→N,routed→M)` record is still FRESH, and only
333
+ // across that record's own instant + the match window — the topic it names.
334
+ // Deliberately neither chat-wide, nor cut at `openedAt`, nor open-ended
335
+ // forward: all three close a genuinely unanswered obligation in silence. Field
336
+ // evidence for each in escalation-staleness.ts.
337
+ //
338
+ // FRESHNESS ANCHOR: the settle gate's `firstAt` for this obligation when a
339
+ // window is already open, else this decision's `now`. NOT the re-check
340
+ // instant — every gate above (`turnInFlightForGate`, the background-work /
341
+ // session-busy defer, the escalate and represent graces) can SKIP sweep ticks
342
+ // outright, so the decision that consults the record may run minutes after the
343
+ // one that deferred, and a record that was fresh when the question was first
344
+ // asked would read as stale purely because the sweep was starved. That same
345
+ // starvation is why the DELIVERY needs its own upper bound: the anchor makes
346
+ // the override read fresh at a re-check minutes later, and without a forward
347
+ // bound it would then reach the whole of that gap. See `#4681` / form (iii).
348
+ const answered = answeredSinceOpen(o, {
349
+ historyEnabled: HISTORY_ENABLED,
350
+ hasOutboundDeliveredSince,
351
+ // The reroute fallback's query, bounded at BOTH ends. `minChars` stays at
352
+ // the history default (200 — the escalate branch's substantive floor); only
353
+ // the upper time bound is added.
354
+ hasOutboundDeliveredBetween: (chatId, sinceMs, untilMs, threadId) =>
355
+ hasOutboundDeliveredSince(chatId, sinceMs, threadId, undefined, untilMs),
356
+ routeOverrides: answerRouteOverrides,
357
+ anchorMs: escalateSettleGate.firstAt(o.originTurnId, o.openedAt) ?? now,
358
+ rerouteMatchWindowMs: rerouteMatchWindowMs,
359
+ })
360
+ if (answered.answered) {
361
+ // The two scopes log DISTINGUISHABLY: a `via=reroute` close is the widened
362
+ // path, so its blast radius is measurable in production without a rebuild.
363
+ const scope =
364
+ answered.via === 'reroute'
365
+ ? `via=reroute routed_thread=${answered.routedThreadId ?? '-'}`
366
+ : 'via=thread'
308
367
  process.stderr.write(
309
- `telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
368
+ `telegram gateway: obligation closed silently — outbound delivered since open ${scope} origin=${o.originTurnId}\n`,
310
369
  )
370
+ escalateSettleGate.clear(o.originTurnId)
311
371
  obligationLedger.close(o.originTurnId)
312
372
  return
313
373
  }
374
+ if (answered.staleOverrideAgeMs != null) {
375
+ // NEGATIVE-PATH telemetry for the freshness bound. A reroute WAS on record
376
+ // for this obligation and the bound threw it away — indistinguishable from
377
+ // "no reroute at all" without this line, which makes the bound (the whole
378
+ // correctness argument for the fallback) unmeasurable in production. A near
379
+ // miss here is the signal that the window is mis-tuned.
380
+ process.stderr.write(
381
+ `telegram gateway: obligation reroute record rejected age=${answered.staleOverrideAgeMs}ms ` +
382
+ `window=${rerouteMatchWindowMs}ms origin=${o.originTurnId}\n`,
383
+ )
384
+ }
385
+ // SETTLE. The check above is a point-in-time read, and the answer is often
386
+ // still IN FLIGHT at this instant (reply tool invoked, history row not yet
387
+ // written) — confirmed lags of 1.23s and 2.81s between this decision and the
388
+ // answer landing. Defer the first decision and re-check on a later sweep, so
389
+ // the nudge only goes out when "unanswered" held across the settle window.
390
+ // Bounded: a genuinely unanswered obligation escalates one window later.
391
+ if (escalateSettleGate.shouldDefer(o.originTurnId, now, o.openedAt)) {
392
+ process.stderr.write(
393
+ `telegram gateway: obligation escalation deferred — settling (re-checking for an in-flight answer) origin=${o.originTurnId}\n`,
394
+ )
395
+ return
396
+ }
314
397
  // Proceed with escalation: send ONE operator-visible nudge and close the
315
398
  // obligation ONLY AFTER it actually lands. This inverts the old
316
399
  // close-before-send (which silently dropped the terminal whenever the send
@@ -337,6 +420,11 @@ function obligationSweep(): void {
337
420
  maxAttempts: OBLIGATION_ESCALATE_MAX,
338
421
  deadlineMs: OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
339
422
  })
423
+ // Terminal for this settle episode. A send that FAILS leaves the obligation
424
+ // open and re-drives on a later sweep; that retry then opens its own fresh
425
+ // settle window, which is the same "re-check before nagging" guarantee, not a
426
+ // regression. Keeps the gate map from retaining closed obligations.
427
+ escalateSettleGate.clear(o.originTurnId)
340
428
  }
341
429
 
342
430
  return {
@@ -43,6 +43,7 @@ import {
43
43
  isQuoteRejectionError,
44
44
  sendOptsHaveQuote,
45
45
  } from '../reply-quote.js'
46
+ import { captureSpeechText } from './speech-capture.js'
46
47
 
47
48
  // ── send-orchestration façade imports (#2996 P2) ──
48
49
  // Pure/deterministic helpers are imported; stateful or side-effecting gateway
@@ -84,6 +85,15 @@ import {
84
85
  formatInternalSuppression,
85
86
  AUDIENCE_INTERNAL,
86
87
  } from '../hooks/audience-classify.mjs'
88
+ // Bug (c) cross-chat anchor guard. Pure module, no cycle — the SAME predicate
89
+ // the routing (`resolveAnswerThreadId`), the gateway's recovery tier and the
90
+ // `reply-route` telemetry use, so the kill-switch branch below cannot drift
91
+ // from them.
92
+ import { isCrossChatAnchor } from './answer-thread-resolve.js'
93
+ // The ONE `reply-route` line builder — shared with `resolveAnswerThreadWithLog`
94
+ // in gateway.ts so the flag-on and kill-switch branches can never emit two
95
+ // different spellings of the same drop.
96
+ import { formatReplyRouteLog } from './reply-route-log.js'
87
97
  import { queueFloodBlockedReply } from './flood-reply-queue.js'
88
98
  import { resolveChatIdFallback } from './chat-id-fallback.js'
89
99
  import { getBuzzMirror } from './buzz-mirror.js'
@@ -1376,6 +1386,12 @@ export async function sendReply(
1376
1386
  // plain-text TTS input); synthesis happens just before the send so a
1377
1387
  // voice-only reply can suppress the text chunk loop on success. Voice is
1378
1388
  // fully best-effort — every failure below falls back to the text reply.
1389
+ // Raw-corpus capture (TTS redesign PR-0, flag-gated, off by default): `text`
1390
+ // here IS Stage A's future input — capture it byte-for-byte BEFORE the
1391
+ // resolve call, ahead of any TTS normalisation, so the redesign's property
1392
+ // tests can validate against real markdown instead of synthetic fixtures
1393
+ // only. See telegram-plugin/gateway/speech-capture.ts.
1394
+ captureSpeechText(text)
1379
1395
  const voiceOutPlan = resolveVoiceOutPlan(access.voice_out, text)
1380
1396
  const configParseMode = access.parseMode ?? 'html'
1381
1397
  const format = (args.format as string | undefined) ?? configParseMode
@@ -1587,11 +1603,56 @@ export async function sendReply(
1587
1603
  'reply',
1588
1604
  )
1589
1605
  } else {
1606
+ // Cross-chat anchor guard (bug c, `answer-thread-resolve.ts`). This legacy
1607
+ // branch does NOT go through `resolveAnswerThreadWithLog`, so the drop that
1608
+ // `resolveAnswerThreadId` performs above never runs here — without this the
1609
+ // pinned turn's thread is passed through even when the turn belongs to a
1610
+ // DIFFERENT chat, handing chat B's topic id to a send into chat A and
1611
+ // earning a `400 Bad Request: message thread not found`. The kill switch
1612
+ // restores the legacy PRECEDENCE, not the wrong-chat topic id: that is an
1613
+ // API error, not a routing policy a kill switch should be able to
1614
+ // reinstate. Uses the SAME exported predicate as the routing, the recovery
1615
+ // tier and the telemetry, so no second copy of the rule can drift.
1616
+ const anchorCrossChat = isCrossChatAnchor(chat_id, turn?.sessionChatId)
1617
+ const anchorThreadId = anchorCrossChat ? undefined : turn?.sessionThreadId
1590
1618
  threadId = resolveThreadId(
1591
1619
  chat_id,
1592
1620
  (args.message_thread_id as string | undefined) ??
1593
- (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined),
1621
+ (anchorThreadId != null ? anchorThreadId : undefined),
1594
1622
  )
1623
+ if (anchorCrossChat) {
1624
+ // Telemetry parity with the flag-ON branch, which emits this marker via
1625
+ // `resolveAnswerThreadWithLog`. Without it a `TURN_ORIGIN_ROUTING=0`
1626
+ // deployment still performs the drop but records nothing — losing the
1627
+ // audit trail the rest of the cross-chat fix is built on, on exactly the
1628
+ // branch most likely to be running when something is already suspected
1629
+ // wrong. Reuses the ONE authoritative formatter (`reply-route-log.ts`)
1630
+ // rather than a second, driftable spelling of the same line.
1631
+ const explicitRaw =
1632
+ args.message_thread_id != null ? Number(args.message_thread_id) : undefined
1633
+ process.stderr.write(
1634
+ formatReplyRouteLog({
1635
+ surface: 'reply',
1636
+ chatId: chat_id,
1637
+ threadId,
1638
+ explicitThreadId: Number.isFinite(explicitRaw as number)
1639
+ ? (explicitRaw as number)
1640
+ : undefined,
1641
+ // The legacy branch resolves no origin turn at all — the pinned live
1642
+ // turn IS the anchor that was dropped.
1643
+ originTurn: null,
1644
+ originVia: null,
1645
+ liveTurn: turn,
1646
+ recovered: null,
1647
+ frameworkTopicAuthority: false,
1648
+ // MISROUTE_RISK requires `via === 'live'`, and the formatter nulls a
1649
+ // cross-chat live anchor before computing `via` — so on this arm
1650
+ // (reached only when the anchor IS cross-chat) the predicate is
1651
+ // unreachable and the constant cannot change the emitted line.
1652
+ hasDifferentThreadedRecentTurn: () => false,
1653
+ }),
1654
+ )
1655
+ }
1595
1656
  }
1596
1657
 
1597
1658
  // #4301: track whether `reply_to` came from the quote-opt-in DEFAULT (the
@@ -0,0 +1,134 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { formatReplyRouteLog, type ReplyRouteLogInput } from './reply-route-log.js'
3
+
4
+ const CHAT_A = '12345678' // DM — the reply target
5
+ const CHAT_B = '-1001234567890' // forum supergroup — where the anchor lives
6
+
7
+ function line(over: Partial<ReplyRouteLogInput> = {}): string {
8
+ return formatReplyRouteLog({
9
+ surface: 'reply',
10
+ chatId: CHAT_A,
11
+ threadId: undefined,
12
+ explicitThreadId: undefined,
13
+ originTurn: null,
14
+ originVia: null,
15
+ liveTurn: null,
16
+ recovered: null,
17
+ frameworkTopicAuthority: true,
18
+ hasDifferentThreadedRecentTurn: () => false,
19
+ ...over,
20
+ })
21
+ }
22
+
23
+ describe('formatReplyRouteLog', () => {
24
+ it('CROSS_CHAT_ANCHOR_DROPPED names BOTH chat ids and the topic that was dropped', () => {
25
+ const out = line({
26
+ liveTurn: { turnId: `${CHAT_B}:635#5388`, sessionChatId: CHAT_B, sessionThreadId: 635 },
27
+ })
28
+ expect(out).toContain('CROSS_CHAT_ANCHOR_DROPPED(')
29
+ expect(out).toContain(`target=${CHAT_A}`)
30
+ expect(out).toContain(`live_chat=${CHAT_B},live_thread=635`)
31
+ expect(out).toContain('routed→-')
32
+ })
33
+
34
+ it('a dropped cross-chat anchor is NOT counted as the routing tier (via=none, not via=live)', () => {
35
+ const out = line({
36
+ chatId: CHAT_B,
37
+ liveTurn: { turnId: 'x', sessionChatId: '999', sessionThreadId: 7 },
38
+ })
39
+ expect(out).toContain('via=none')
40
+ expect(out).toContain('late=true')
41
+ })
42
+
43
+ it('a dropped cross-chat anchor does NOT raise the UNROUTED alarm (it is a cross-chat send, not a lost reply)', () => {
44
+ const out = line({
45
+ chatId: CHAT_B, // supergroup target, resolved to no topic
46
+ liveTurn: { turnId: 'x', sessionChatId: CHAT_A, sessionThreadId: undefined },
47
+ })
48
+ expect(out).not.toContain('UNROUTED')
49
+ expect(out).toContain('CROSS_CHAT_ANCHOR_DROPPED(')
50
+ })
51
+
52
+ it('a SAME-chat live anchor is untouched: via=live, no cross-chat marker', () => {
53
+ const out = line({
54
+ chatId: CHAT_B,
55
+ threadId: 4,
56
+ liveTurn: { turnId: `${CHAT_B}:4#1`, sessionChatId: CHAT_B, sessionThreadId: 4 },
57
+ })
58
+ expect(out).toContain('via=live')
59
+ expect(out).toContain('resolved_thread=4')
60
+ expect(out).not.toContain('CROSS_CHAT_ANCHOR_DROPPED')
61
+ })
62
+
63
+ it('an origin anchor from another chat is dropped and reported with its own chat id', () => {
64
+ const out = line({
65
+ originTurn: { turnId: `${CHAT_B}:2#9`, sessionChatId: CHAT_B, sessionThreadId: 2 },
66
+ originVia: 'echo',
67
+ explicitThreadId: 11,
68
+ threadId: 11, // explicit is the only remaining signal
69
+ })
70
+ expect(out).toContain(`origin_chat=${CHAT_B},origin_thread=2`)
71
+ expect(out).toContain('via=explicit')
72
+ expect(out).not.toContain('EXPLICIT_OVERRIDDEN')
73
+ })
74
+
75
+ it('UNROUTED still fires for a genuine no-owner supergroup reply (guard did not weaken it)', () => {
76
+ expect(line({ chatId: CHAT_B })).toContain('UNROUTED(supergroup→no-topic)')
77
+ })
78
+
79
+ it('EXPLICIT_OVERRIDDEN still fires when a SAME-chat anchor beats the model explicit', () => {
80
+ const out = line({
81
+ chatId: CHAT_B,
82
+ explicitThreadId: 99,
83
+ threadId: 4,
84
+ originTurn: { turnId: `${CHAT_B}:4#1`, sessionChatId: CHAT_B, sessionThreadId: 4 },
85
+ originVia: 'echo',
86
+ })
87
+ expect(out).toContain('EXPLICIT_OVERRIDDEN(model→99,routed→4)')
88
+ expect(out).toContain('via=origin')
89
+ })
90
+
91
+ // The two `explicitOverridden` clauses the escalation drift matrix
92
+ // (`tests/escalation-staleness.test.ts`) cannot pin, because it drives the
93
+ // REAL router: with authority off the router returns the explicit topic, and a
94
+ // dropped cross-chat anchor falls back to it too — so `threadId !== explicit`
95
+ // is never true on either arm there. Both combinations are reachable only by
96
+ // calling this pure formatter directly, and without these two cases both
97
+ // mutants (drop the authority clause; read the RAW turns instead of the
98
+ // cross-chat-filtered anchors) survive all 64 matrix cases.
99
+ it('authority DISABLED never claims the framework overrode the model, even when ' +
100
+ 'the routed thread differs from the explicit one', () => {
101
+ const out = line({
102
+ chatId: CHAT_B,
103
+ frameworkTopicAuthority: false,
104
+ explicitThreadId: 99,
105
+ threadId: 4,
106
+ originTurn: { turnId: `${CHAT_B}:4#1`, sessionChatId: CHAT_B, sessionThreadId: 4 },
107
+ originVia: 'echo',
108
+ })
109
+ expect(out).not.toContain('EXPLICIT_OVERRIDDEN')
110
+ })
111
+
112
+ it('EXPLICIT_OVERRIDDEN reads the FILTERED anchors — a cross-chat origin overrode ' +
113
+ 'nothing, so it must not be reported as if it had', () => {
114
+ const out = line({
115
+ chatId: CHAT_B,
116
+ explicitThreadId: 99,
117
+ // The target chat's own last-seen topic, not the dropped anchor's.
118
+ threadId: 4,
119
+ originTurn: { turnId: 'x', sessionChatId: CHAT_A, sessionThreadId: 635 },
120
+ originVia: 'echo',
121
+ })
122
+ expect(out).toContain('CROSS_CHAT_ANCHOR_DROPPED(')
123
+ expect(out).not.toContain('EXPLICIT_OVERRIDDEN')
124
+ })
125
+
126
+ it('MISROUTE_RISK reads the FILTERED live anchor — a cross-chat live turn cannot raise it', () => {
127
+ const out = line({
128
+ chatId: CHAT_B,
129
+ liveTurn: { turnId: 'x', sessionChatId: CHAT_A, sessionThreadId: 3 },
130
+ hasDifferentThreadedRecentTurn: () => true,
131
+ })
132
+ expect(out).not.toContain('MISROUTE_RISK')
133
+ })
134
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `reply-route` telemetry line — the pure formatter behind the gateway's
3
+ * `resolveAnswerThreadWithLog`.
4
+ *
5
+ * The 2026-06-05 triage showed reply routing was the blind spot: `reply:
6
+ * invoked` logged only chat + char count, so a late reply landing in the wrong
7
+ * topic was invisible without hand-correlating raw tg-post threads against
8
+ * turn-lifecycle timestamps. This module builds, per reply: which precedence
9
+ * tier won (`via`), the resolved thread, the owner turn + its thread, whether
10
+ * the reply was late (turn already ended), and the alarm markers.
11
+ *
12
+ * Extracted out of `gateway.ts` (#2996 anti-inflation ratchet) so the string
13
+ * building — which is pure — is unit-testable and the gateway keeps only the
14
+ * stateful wiring.
15
+ *
16
+ * Markers:
17
+ * - `RECOVERED` — a late reply this fix saved from General.
18
+ * - `QUOTED(framework-origin)` — origin recovered from the framework-owned
19
+ * quoted message_id (no model echo).
20
+ * - `UNROUTED` — a supergroup reply that resolved to NO topic
21
+ * with NO owner turn to attribute it to (genuinely lost). A General-topic
22
+ * turn legitimately has no thread, so a reply owned by it resolving to `-`
23
+ * is CORRECT, not lost — hence the `ownerTurn == null` gate (found by the
24
+ * multi-topic UAT stress, 2026-06-05).
25
+ * - `MISROUTE_RISK` — the irreducible determinism residual (HOLE
26
+ * a): a no-echo, no-quote reply that fell to the LIVE turn while a DIFFERENT
27
+ * topic recently had a turn. Observability only; routing is unchanged.
28
+ * - `CROSS_CHAT_ANCHOR_DROPPED` — an anchor turn belonging to another chat was
29
+ * ignored (see `answer-thread-resolve.ts` bug c). Carries BOTH chat ids so
30
+ * cross-chat replies stay auditable.
31
+ * - `EXPLICIT_OVERRIDDEN` — the model passed an explicit topic and a
32
+ * framework anchor overrode it (the General→CRM correction).
33
+ */
34
+
35
+ import { isCrossChatAnchor } from './answer-thread-resolve.js'
36
+
37
+ /** The structural slice of a turn this formatter reads (`CurrentTurn`). */
38
+ export interface RouteLogTurn {
39
+ turnId?: string
40
+ sessionChatId: string
41
+ sessionThreadId: number | undefined
42
+ }
43
+
44
+ export interface ReplyRouteLogInput {
45
+ surface: 'reply' | 'stream_reply'
46
+ /** Chat the reply is being SENT to. */
47
+ chatId: string
48
+ /** What `resolveAnswerThreadId` actually resolved. */
49
+ threadId: number | undefined
50
+ explicitThreadId: number | undefined
51
+ /** RAW anchors as looked up — the cross-chat filter is applied here, with the
52
+ * same predicate the resolver used, so `via` can never disagree with it. */
53
+ originTurn: RouteLogTurn | null
54
+ originVia: 'echo' | 'quoted' | null
55
+ liveTurn: RouteLogTurn | null
56
+ recovered: RouteLogTurn | null
57
+ frameworkTopicAuthority: boolean
58
+ /** Lazy: only consulted for the MISROUTE_RISK arm, which is the sole caller
59
+ * that needs the bounded recent-turn scan. */
60
+ hasDifferentThreadedRecentTurn: (liveThreadId: number | undefined) => boolean
61
+ }
62
+
63
+ export function formatReplyRouteLog(i: ReplyRouteLogInput): string {
64
+ const originCrossChat = isCrossChatAnchor(i.chatId, i.originTurn?.sessionChatId)
65
+ const liveCrossChat = isCrossChatAnchor(i.chatId, i.liveTurn?.sessionChatId)
66
+ const crossChatDropped = originCrossChat || liveCrossChat
67
+ const originAnchor = originCrossChat ? null : i.originTurn
68
+ const liveAnchor = liveCrossChat ? null : i.liveTurn
69
+ const explicit = i.explicitThreadId
70
+ // `via` reflects the ACTIVE precedence so telemetry matches routing.
71
+ const via = i.frameworkTopicAuthority
72
+ ? (originAnchor != null ? (i.originVia === 'quoted' ? 'quoted' : 'origin')
73
+ : liveAnchor != null ? 'live'
74
+ : explicit != null ? 'explicit'
75
+ : i.recovered != null ? 'recovered'
76
+ : 'none')
77
+ : (explicit != null ? 'explicit'
78
+ : originAnchor != null ? (i.originVia === 'quoted' ? 'quoted' : 'origin')
79
+ : liveAnchor?.sessionThreadId != null ? 'live'
80
+ : i.recovered != null ? 'recovered'
81
+ : 'none')
82
+ const explicitOverridden =
83
+ i.frameworkTopicAuthority &&
84
+ explicit != null &&
85
+ (originAnchor != null || liveAnchor != null) &&
86
+ i.threadId !== explicit
87
+ const ownerTurn = originAnchor ?? i.recovered ?? liveAnchor
88
+ const isSupergroup = i.chatId.startsWith('-100')
89
+ // A dropped cross-chat anchor is NOT a lost reply — the send is a cross-chat
90
+ // one and the main chat is where it belongs — so it does not raise UNROUTED;
91
+ // CROSS_CHAT_ANCHOR_DROPPED already explains the line.
92
+ const unrouted = isSupergroup && i.threadId == null && ownerTurn == null && !crossChatDropped
93
+ const misrouteRisk =
94
+ isSupergroup && via === 'live' && i.hasDifferentThreadedRecentTurn(liveAnchor?.sessionThreadId)
95
+ return (
96
+ `telegram gateway: reply-route surface=${i.surface} chat=${i.chatId} ` +
97
+ `resolved_thread=${i.threadId ?? '-'} via=${via} late=${liveAnchor == null} ` +
98
+ `originTurn=${ownerTurn?.turnId ?? '-'} origin_thread=${ownerTurn?.sessionThreadId ?? '-'}` +
99
+ (via === 'recovered' ? ' RECOVERED' : '') +
100
+ (via === 'quoted' ? ' QUOTED(framework-origin)' : '') +
101
+ (unrouted ? ' UNROUTED(supergroup→no-topic)' : '') +
102
+ (misrouteRisk ? ' MISROUTE_RISK(no-echo→live-successor)' : '') +
103
+ (crossChatDropped
104
+ ? ` CROSS_CHAT_ANCHOR_DROPPED(target=${i.chatId}` +
105
+ (originCrossChat
106
+ ? `,origin_chat=${i.originTurn?.sessionChatId},origin_thread=${i.originTurn?.sessionThreadId ?? '-'}`
107
+ : '') +
108
+ (liveCrossChat
109
+ ? `,live_chat=${i.liveTurn?.sessionChatId},live_thread=${i.liveTurn?.sessionThreadId ?? '-'}`
110
+ : '') +
111
+ `,routed→${i.threadId ?? '-'})`
112
+ : '') +
113
+ (explicitOverridden
114
+ ? ` EXPLICIT_OVERRIDDEN(model→${explicit},routed→${i.threadId ?? '-'})`
115
+ : '') +
116
+ '\n'
117
+ )
118
+ }