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.
- package/dist/cli/switchroom.js +143 -27
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +1152 -296
- package/telegram-plugin/format.ts +74 -1
- package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
- package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
- package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
- package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
- package/telegram-plugin/gateway/gateway.ts +61 -68
- package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
- package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
- package/telegram-plugin/gateway/reply-route-log.ts +118 -0
- package/telegram-plugin/gateway/speech-capture.ts +158 -0
- package/telegram-plugin/gateway/stream-render.ts +1 -1
- package/telegram-plugin/history.ts +21 -0
- package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
- package/telegram-plugin/render/html-fold.ts +372 -0
- package/telegram-plugin/render/parse.ts +578 -29
- package/telegram-plugin/render/render.ts +14 -13
- package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
- package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
- package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
- package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
- package/telegram-plugin/tests/history.test.ts +78 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
- package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
- package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
- package/telegram-plugin/tests/render/parse.test.ts +9 -5
- package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
- package/telegram-plugin/tests/speech-capture.test.ts +296 -0
- package/telegram-plugin/tests/status-pin.test.ts +2 -2
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
- package/telegram-plugin/tests/telegram-format.test.ts +52 -0
- package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
- package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
- package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
- package/telegram-plugin/tts-normalize.ts +47 -9
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
- package/telegram-plugin/voice-normalize-text.ts +48 -9
- 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.
|
|
3913
|
-
* the
|
|
3914
|
-
*
|
|
3915
|
-
*
|
|
3916
|
-
*
|
|
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).
|
|
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
|
-
|
|
3948
|
-
|
|
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
|
-
|
|
4002
|
-
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
+
}
|