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
@@ -0,0 +1,1275 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import {
3
+ answeredSinceOpen,
4
+ createEscalationSettleGate,
5
+ resolveEscalateSettleMs,
6
+ resolveRerouteMatchWindowMs,
7
+ OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT,
8
+ OBLIGATION_ESCALATE_SETTLE_MS_MAX,
9
+ OBLIGATION_REROUTE_MATCH_MS_MAX,
10
+ REROUTE_MATCH_GRACE_MS,
11
+ } from "../gateway/escalation-staleness.js";
12
+ import {
13
+ createAnswerRouteOverrides,
14
+ answerRouteOverrides,
15
+ } from "../gateway/answer-route-overrides.js";
16
+ import {
17
+ resolveAnswerThreadId,
18
+ isCrossChatAnchor,
19
+ } from "../gateway/answer-thread-resolve.js";
20
+ import { formatReplyRouteLog } from "../gateway/reply-route-log.js";
21
+ import { ObligationLedger } from "../gateway/obligation-ledger.js";
22
+ import { createObligationWiring } from "../gateway/obligation-wiring.js";
23
+
24
+ // Regression suite for the false "⚠️ I may have missed an earlier message"
25
+ // escalation that lands ON TOP of an answer the user already received — and for
26
+ // the opposite failure the fix must not introduce, where a genuinely unanswered
27
+ // message is silently closed by someone else's answer.
28
+ //
29
+ // Both defects were confirmed in marko's gateway-supervisor.log. Only ids,
30
+ // topics and timings are reproduced here; no chat id and no message bodies.
31
+ //
32
+ // 1. REROUTED ANSWER (2026-08-10 06:36). Obligation `…:4#5191` lived in thread
33
+ // 4; the model addressed its reply to thread 4 and the framework's topic
34
+ // authority overrode that to thread 635 — logged
35
+ // `EXPLICIT_OVERRIDDEN(model→4,routed→635)` at 06:36:07.921. The
36
+ // thread-4-keyed staleness check never saw the answer and the nag fired
37
+ // 126 ms later at 06:36:08.047.
38
+ //
39
+ // 2. NO SETTLE (2026-08-10 06:36 and 07:52). The answer was still in flight at
40
+ // the moment the sweep decided — the reply tool had been invoked but the
41
+ // history row did not exist yet. Decision 06:36:08.047 → delivery
42
+ // 06:36:09.281 (1,234 ms) and decision 07:52:34.400 → delivery
43
+ // 07:52:37.209 (2,809 ms).
44
+ //
45
+ // And the two invariants the fix must NOT break:
46
+ //
47
+ // - 2026-08-12 09:08, where the agent genuinely had not answered — its real
48
+ // reply landed 41,331 ms after the escalation decision. That obligation must
49
+ // still escalate.
50
+ // - 2026-08-13 04:03, where obligation `…:4#5462` (thread 4, opened
51
+ // 03:50:02.807) escalated at 04:03:06.140. Its window holds THREE
52
+ // `EXPLICIT_OVERRIDDEN(model→4,routed→3)` records (03:50:44.201,
53
+ // 03:51:43.108, 03:53:11.225) AND a later, unrelated 295-char delivery in
54
+ // thread 3 at 04:02:51.987 answering a DIFFERENT question (`via=origin` to
55
+ // turn `…:3#5480`). Topic 4's message was genuinely unanswered, so this
56
+ // obligation MUST escalate. It is the counter-example that kills both weaker
57
+ // fallbacks: chat-wide, and override-gated-but-cut-at-`openedAt` (which
58
+ // pairs the 03:53 override with the 04:02 delivery). The newest override is
59
+ // 594.8 s stale at the decision, so the freshness window rejects it.
60
+
61
+ const CHAT = "-100999";
62
+ const OBLIGATION_THREAD = 4;
63
+ const ROUTED_THREAD = 635; // the router's `EXPLICIT_OVERRIDDEN(model→4,routed→635)` reroute
64
+ const UNRELATED_THREAD = 3;
65
+ // A separate intended topic for the end-to-end counter-example, so its entries
66
+ // in the process-wide override registry cannot collide with another test's.
67
+ const THREAD_D = 44;
68
+
69
+ /**
70
+ * The history table as the module sees it. `threadId === undefined` from the
71
+ * caller means CHAT scope; `untilMs` is the reroute fallback's UPPER bound —
72
+ * modelled here, not ignored, or every test that depends on it passes vacuously.
73
+ */
74
+ type HistoryStub = (
75
+ chatId: string,
76
+ sinceMs: number,
77
+ threadId?: number | null,
78
+ untilMs?: number,
79
+ ) => boolean;
80
+
81
+ /**
82
+ * Models the real history predicate: an assistant row delivered at `deliveredAt`
83
+ * under `thread`.
84
+ */
85
+ function historyWithAnswer(deliveredAt: number, thread: number | null): HistoryStub {
86
+ return (chatId, sinceMs, threadId, untilMs): boolean => {
87
+ if (chatId !== CHAT) return false;
88
+ if (threadId !== undefined && threadId !== thread) return false;
89
+ if (untilMs != null && deliveredAt > untilMs) return false;
90
+ return deliveredAt >= sinceMs;
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Fan ONE history stub out into the two deps `answeredSinceOpen` takes, exactly
96
+ * as obligation-wiring does: the obligation's own topic is queried open-ended,
97
+ * the reroute fallback is queried bounded at both ends.
98
+ */
99
+ function historyDeps(stub: HistoryStub) {
100
+ return {
101
+ hasOutboundDeliveredSince: (chatId: string, sinceMs: number, threadId?: number | null) =>
102
+ stub(chatId, sinceMs, threadId),
103
+ hasOutboundDeliveredBetween: (
104
+ chatId: string,
105
+ sinceMs: number,
106
+ untilMs: number,
107
+ threadId?: number | null,
108
+ ) => stub(chatId, sinceMs, threadId, untilMs),
109
+ };
110
+ }
111
+
112
+ /** A route-override registry pre-loaded with one recorded reroute. */
113
+ function overridesWithReroute(
114
+ intendedThreadId: number,
115
+ routedThreadId: number | undefined,
116
+ atMs: number,
117
+ chatId = CHAT,
118
+ ) {
119
+ const reg = createAnswerRouteOverrides();
120
+ reg.note({
121
+ chatId,
122
+ enabled: true,
123
+ explicitThreadId: intendedThreadId,
124
+ anchored: true,
125
+ routedThreadId,
126
+ nowMs: atMs,
127
+ });
128
+ return reg;
129
+ }
130
+
131
+ const NO_OVERRIDES = createAnswerRouteOverrides();
132
+
133
+ // `answerRouteOverrides` is a process-wide module singleton and the end-to-end
134
+ // scenarios below note real records into it with wall-clock timestamps. Reset it
135
+ // before every test so no scenario inherits (or silently depends on) another's.
136
+ beforeEach(() => {
137
+ answerRouteOverrides.clear();
138
+ });
139
+
140
+ /** The reroute-fallback deps, with the freshness window the sweep really uses. */
141
+ function stalenessDeps(over: {
142
+ hasOutboundDeliveredSince: HistoryStub;
143
+ routeOverrides: Parameters<typeof answeredSinceOpen>[1]["routeOverrides"];
144
+ nowMs?: number;
145
+ historyEnabled?: boolean;
146
+ rerouteMatchWindowMs?: number;
147
+ }) {
148
+ return {
149
+ historyEnabled: over.historyEnabled ?? true,
150
+ ...historyDeps(over.hasOutboundDeliveredSince),
151
+ routeOverrides: over.routeOverrides,
152
+ anchorMs: over.nowMs ?? 2_000,
153
+ rerouteMatchWindowMs:
154
+ over.rerouteMatchWindowMs ??
155
+ resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT),
156
+ };
157
+ }
158
+
159
+ describe("answerRouteOverrides — records only genuine explicit-thread overrides", () => {
160
+ it("records the reroute the router logs as EXPLICIT_OVERRIDDEN", () => {
161
+ const reg = createAnswerRouteOverrides();
162
+ expect(
163
+ reg.note({
164
+ chatId: CHAT,
165
+ enabled: true,
166
+ explicitThreadId: 4,
167
+ anchored: true,
168
+ routedThreadId: 635,
169
+ nowMs: 1000,
170
+ }),
171
+ ).toBe(true);
172
+ expect(reg.routedOverridesSince(CHAT, 4, 0)).toEqual([{ routedThreadId: 635, atMs: 1000 }]);
173
+ });
174
+
175
+ it("maps a chat-root routing to an explicit NULL thread, never to 'any thread'", () => {
176
+ // 2026-08-10 07:52: `EXPLICIT_OVERRIDDEN(model→3,routed→-)`; recordOutbound
177
+ // writes `thread_id IS NULL` for a threadless send.
178
+ const reg = overridesWithReroute(3, undefined, 1000);
179
+ expect(reg.routedOverridesSince(CHAT, 3, 0)).toEqual([{ routedThreadId: null, atMs: 1000 }]);
180
+ });
181
+
182
+ it("does NOT record when the routing agreed with the model", () => {
183
+ const reg = createAnswerRouteOverrides();
184
+ expect(
185
+ reg.note({
186
+ chatId: CHAT,
187
+ enabled: true,
188
+ explicitThreadId: 4,
189
+ anchored: true,
190
+ routedThreadId: 4,
191
+ nowMs: 1000,
192
+ }),
193
+ ).toBe(false);
194
+ expect(reg.routedOverridesSince(CHAT, 4, 0)).toEqual([]);
195
+ });
196
+
197
+ it("does NOT record when the model named no topic, or no anchor could override it", () => {
198
+ const reg = createAnswerRouteOverrides();
199
+ const base = { chatId: CHAT, enabled: true, routedThreadId: 635, nowMs: 1000 };
200
+ expect(reg.note({ ...base, explicitThreadId: undefined, anchored: true })).toBe(false);
201
+ expect(reg.note({ ...base, explicitThreadId: 4, anchored: false })).toBe(false);
202
+ expect(reg.note({ ...base, explicitThreadId: 4, anchored: true, enabled: false })).toBe(false);
203
+ expect(reg.size()).toBe(0);
204
+ });
205
+
206
+ it("scopes by chat and by intended thread", () => {
207
+ const reg = overridesWithReroute(4, 635, 1000);
208
+ expect(reg.routedOverridesSince("-100777", 4, 0)).toEqual([]);
209
+ expect(reg.routedOverridesSince(CHAT, 3, 0)).toEqual([]);
210
+ });
211
+
212
+ it("ignores an override older than the caller's freshness floor", () => {
213
+ const reg = overridesWithReroute(4, 635, 1000);
214
+ expect(reg.routedOverridesSince(CHAT, 4, 5000)).toEqual([]);
215
+ });
216
+
217
+ it("returns the EARLIEST in-window record per routed thread (the widest correct cutoff)", () => {
218
+ // 2026-08-13 shape: three overrides, same intended topic, same routed topic.
219
+ const reg = createAnswerRouteOverrides();
220
+ for (const atMs of [1000, 1500, 2000]) {
221
+ reg.note({
222
+ chatId: CHAT,
223
+ enabled: true,
224
+ explicitThreadId: 4,
225
+ anchored: true,
226
+ routedThreadId: 3,
227
+ nowMs: atMs,
228
+ });
229
+ }
230
+ expect(reg.routedOverridesSince(CHAT, 4, 0)).toEqual([{ routedThreadId: 3, atMs: 1000 }]);
231
+ // With a later floor, the earliest SURVIVING record wins.
232
+ expect(reg.routedOverridesSince(CHAT, 4, 1600)).toEqual([{ routedThreadId: 3, atMs: 2000 }]);
233
+ });
234
+
235
+ it("clear() drops every record (the process-wide singleton's test reset)", () => {
236
+ const reg = overridesWithReroute(4, 635, 1000);
237
+ expect(reg.routedOverridesSince(CHAT, 4, 0)).toHaveLength(1);
238
+ reg.clear();
239
+ expect(reg.routedOverridesSince(CHAT, 4, 0)).toEqual([]);
240
+ expect(reg.newestOverrideSince(CHAT, 4, 0)).toBeUndefined();
241
+ expect(reg.size()).toBe(0);
242
+ });
243
+
244
+ it("newestOverrideSince returns the LATEST in-window record, not the earliest", () => {
245
+ const reg = createAnswerRouteOverrides();
246
+ for (const atMs of [1000, 1500, 2000]) {
247
+ reg.note({
248
+ chatId: CHAT,
249
+ enabled: true,
250
+ explicitThreadId: 4,
251
+ anchored: true,
252
+ routedThreadId: 3,
253
+ nowMs: atMs,
254
+ });
255
+ }
256
+ // `routedOverridesSince` deliberately yields the earliest (widest cutoff);
257
+ // the diagnostic asks the opposite question — how NEAR the miss was.
258
+ expect(reg.routedOverridesSince(CHAT, 4, 0)[0]?.atMs).toBe(1000);
259
+ expect(reg.newestOverrideSince(CHAT, 4, 0)).toEqual({ routedThreadId: 3, atMs: 2000 });
260
+ expect(reg.newestOverrideSince(CHAT, 4, 2500)).toBeUndefined();
261
+ expect(reg.newestOverrideSince(CHAT, 99, 0)).toBeUndefined();
262
+ });
263
+
264
+ it("bounds both the key count and the per-key history", () => {
265
+ const reg = createAnswerRouteOverrides(3, 2);
266
+ for (let i = 0; i < 20; i++) {
267
+ reg.note({
268
+ chatId: `chat-${i}`,
269
+ enabled: true,
270
+ explicitThreadId: 4,
271
+ anchored: true,
272
+ routedThreadId: 635,
273
+ nowMs: i,
274
+ });
275
+ }
276
+ expect(reg.size()).toBeLessThanOrEqual(3);
277
+ for (let i = 0; i < 20; i++) {
278
+ reg.note({
279
+ chatId: CHAT,
280
+ enabled: true,
281
+ explicitThreadId: 4,
282
+ anchored: true,
283
+ routedThreadId: i,
284
+ nowMs: i,
285
+ });
286
+ }
287
+ expect(reg.routedOverridesSince(CHAT, 4, 0).length).toBeLessThanOrEqual(2);
288
+ });
289
+ });
290
+
291
+ describe("answeredSinceOpen — a REROUTED answer stands the escalation down", () => {
292
+ it("counts an answer the router recorded as rerouted out of this topic (2026-08-10 06:36)", () => {
293
+ // Pre-fix this asked history for thread 4 only, found nothing, and nagged.
294
+ const answered = answeredSinceOpen(
295
+ { chatId: CHAT, openedAt: 1000, threadId: OBLIGATION_THREAD },
296
+ stalenessDeps({
297
+ hasOutboundDeliveredSince: historyWithAnswer(2000, ROUTED_THREAD),
298
+ routeOverrides: overridesWithReroute(OBLIGATION_THREAD, ROUTED_THREAD, 1500),
299
+ }),
300
+ );
301
+ expect(answered).toEqual({ answered: true, via: "reroute", routedThreadId: ROUTED_THREAD });
302
+ });
303
+
304
+ it("counts an answer rerouted to the chat ROOT (2026-08-10 07:52)", () => {
305
+ const answered = answeredSinceOpen(
306
+ { chatId: CHAT, openedAt: 1000, threadId: 3 },
307
+ stalenessDeps({
308
+ hasOutboundDeliveredSince: historyWithAnswer(2000, null),
309
+ routeOverrides: overridesWithReroute(3, undefined, 1500),
310
+ }),
311
+ );
312
+ expect(answered).toEqual({ answered: true, via: "reroute", routedThreadId: null });
313
+ });
314
+
315
+ it("still counts a same-thread answer (the pre-existing behaviour is preserved)", () => {
316
+ const answered = answeredSinceOpen(
317
+ { chatId: CHAT, openedAt: 1000, threadId: OBLIGATION_THREAD },
318
+ stalenessDeps({
319
+ hasOutboundDeliveredSince: historyWithAnswer(2000, OBLIGATION_THREAD),
320
+ routeOverrides: NO_OVERRIDES,
321
+ }),
322
+ );
323
+ expect(answered).toEqual({ answered: true, via: "thread" });
324
+ });
325
+
326
+ it("does NOT count an unrelated answer in ANOTHER topic with no reroute on record (2026-08-13 04:03)", () => {
327
+ // The whole point of the override gate: topic 3's answer to a DIFFERENT
328
+ // question must not close topic 4's genuinely-unanswered obligation.
329
+ const answered = answeredSinceOpen(
330
+ { chatId: CHAT, openedAt: 1000, threadId: OBLIGATION_THREAD },
331
+ stalenessDeps({
332
+ hasOutboundDeliveredSince: historyWithAnswer(2000, UNRELATED_THREAD),
333
+ routeOverrides: NO_OVERRIDES,
334
+ }),
335
+ );
336
+ expect(answered).toEqual({ answered: false, via: null });
337
+ });
338
+
339
+ it("does NOT pair a STALE override with a later unrelated delivery (2026-08-13 04:03)", () => {
340
+ // The real 4#5462 shape, and the one an `openedAt` cutoff gets WRONG.
341
+ // Opened at 0. Overrides model→4,routed→3 at 41s / 100s / 188s. The
342
+ // delivery in thread 3 is at 769s — a DIFFERENT question's answer, 594.8s
343
+ // after the newest override. Decision at 783s.
344
+ const NOW = 783_000;
345
+ const reg = createAnswerRouteOverrides();
346
+ for (const atMs of [41_394, 100_301, 188_418]) {
347
+ reg.note({
348
+ chatId: CHAT,
349
+ enabled: true,
350
+ explicitThreadId: OBLIGATION_THREAD,
351
+ anchored: true,
352
+ routedThreadId: UNRELATED_THREAD,
353
+ nowMs: atMs,
354
+ });
355
+ }
356
+ const answered = answeredSinceOpen(
357
+ { chatId: CHAT, openedAt: 0, threadId: OBLIGATION_THREAD },
358
+ stalenessDeps({
359
+ hasOutboundDeliveredSince: historyWithAnswer(769_180, UNRELATED_THREAD),
360
+ routeOverrides: reg,
361
+ nowMs: NOW,
362
+ }),
363
+ );
364
+ // Cut at `openedAt` this reads {answered: true, via: "reroute"} and the
365
+ // user's message is dropped in silence.
366
+ expect(answered.answered).toBe(false);
367
+ expect(answered.via).toBe(null);
368
+ // …and the rejection is reported rather than silent (594.6s stale).
369
+ expect(answered.staleOverrideAgeMs).toBe(NOW - 188_418);
370
+ });
371
+
372
+ it("DOES follow an override that is still fresh, from the override's own instant", () => {
373
+ // Same shape, but the decision happens while the override is still inside
374
+ // the window — the 2026-08-10 case, re-checked after a settle deferral.
375
+ const NOW = 188_418 + OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT + 1_000;
376
+ const answered = answeredSinceOpen(
377
+ { chatId: CHAT, openedAt: 0, threadId: OBLIGATION_THREAD },
378
+ stalenessDeps({
379
+ hasOutboundDeliveredSince: historyWithAnswer(189_800, UNRELATED_THREAD),
380
+ routeOverrides: overridesWithReroute(OBLIGATION_THREAD, UNRELATED_THREAD, 188_418),
381
+ nowMs: NOW,
382
+ }),
383
+ );
384
+ expect(answered).toEqual({
385
+ answered: true,
386
+ via: "reroute",
387
+ routedThreadId: UNRELATED_THREAD,
388
+ });
389
+ });
390
+
391
+ it("does NOT accept a delivery that PREDATES the override it is paired with", () => {
392
+ // The override is fresh, but the only delivery in the routed thread landed
393
+ // before that routing decision — it cannot be the answer it produced.
394
+ const answered = answeredSinceOpen(
395
+ { chatId: CHAT, openedAt: 1_000, threadId: OBLIGATION_THREAD },
396
+ stalenessDeps({
397
+ hasOutboundDeliveredSince: historyWithAnswer(4_999, ROUTED_THREAD),
398
+ routeOverrides: overridesWithReroute(OBLIGATION_THREAD, ROUTED_THREAD, 5_000),
399
+ nowMs: 6_000,
400
+ }),
401
+ );
402
+ expect(answered.answered).toBe(false);
403
+ });
404
+
405
+ it("does NOT accept a delivery that lands long AFTER a still-fresh override", () => {
406
+ // The other end of the same bound, and the one #4681 was missing. The
407
+ // override is fresh at `anchorMs` (which is the settle gate's `firstAt`, so
408
+ // it stays fresh however long the sweep is starved), but the only delivery
409
+ // in the routed thread landed 90s after that routing decision — far outside
410
+ // the window in which the answer it produced could have landed. It is
411
+ // someone else's answer.
412
+ const WINDOW = resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT);
413
+ const OVERRIDE_AT = 10_000;
414
+ const answered = answeredSinceOpen(
415
+ { chatId: CHAT, openedAt: 1_000, threadId: OBLIGATION_THREAD },
416
+ stalenessDeps({
417
+ hasOutboundDeliveredSince: historyWithAnswer(OVERRIDE_AT + 90_000, ROUTED_THREAD),
418
+ routeOverrides: overridesWithReroute(OBLIGATION_THREAD, ROUTED_THREAD, OVERRIDE_AT),
419
+ // Anchored at the first "unanswered" read, so the override IS fresh.
420
+ nowMs: OVERRIDE_AT + 100,
421
+ }),
422
+ );
423
+ expect(answered.answered).toBe(false);
424
+ // …and one that lands at the very edge of the window still counts, so the
425
+ // bound is a bound and not a blanket refusal.
426
+ expect(
427
+ answeredSinceOpen(
428
+ { chatId: CHAT, openedAt: 1_000, threadId: OBLIGATION_THREAD },
429
+ stalenessDeps({
430
+ hasOutboundDeliveredSince: historyWithAnswer(OVERRIDE_AT + WINDOW, ROUTED_THREAD),
431
+ routeOverrides: overridesWithReroute(OBLIGATION_THREAD, ROUTED_THREAD, OVERRIDE_AT),
432
+ nowMs: OVERRIDE_AT + 100,
433
+ }),
434
+ ),
435
+ ).toEqual({ answered: true, via: "reroute", routedThreadId: ROUTED_THREAD });
436
+ });
437
+
438
+ it("does NOT follow a reroute recorded for a DIFFERENT topic", () => {
439
+ const answered = answeredSinceOpen(
440
+ { chatId: CHAT, openedAt: 1000, threadId: OBLIGATION_THREAD },
441
+ stalenessDeps({
442
+ hasOutboundDeliveredSince: historyWithAnswer(2000, ROUTED_THREAD),
443
+ // The override belongs to topic 3's answer, not topic 4's.
444
+ routeOverrides: overridesWithReroute(UNRELATED_THREAD, ROUTED_THREAD, 1500),
445
+ }),
446
+ );
447
+ expect(answered.answered).toBe(false);
448
+ });
449
+
450
+ it("does NOT follow a reroute that PREDATES the obligation", () => {
451
+ const answered = answeredSinceOpen(
452
+ { chatId: CHAT, openedAt: 5000, threadId: OBLIGATION_THREAD },
453
+ stalenessDeps({
454
+ hasOutboundDeliveredSince: historyWithAnswer(6000, ROUTED_THREAD),
455
+ routeOverrides: overridesWithReroute(OBLIGATION_THREAD, ROUTED_THREAD, 1500),
456
+ nowMs: 6000,
457
+ }),
458
+ );
459
+ expect(answered.answered).toBe(false);
460
+ });
461
+
462
+ it("does NOT count an answer in a DIFFERENT chat", () => {
463
+ const answered = answeredSinceOpen(
464
+ { chatId: "-100777", openedAt: 1000, threadId: OBLIGATION_THREAD },
465
+ stalenessDeps({
466
+ hasOutboundDeliveredSince: historyWithAnswer(2000, ROUTED_THREAD),
467
+ routeOverrides: overridesWithReroute(OBLIGATION_THREAD, ROUTED_THREAD, 1500),
468
+ }),
469
+ );
470
+ expect(answered.answered).toBe(false);
471
+ });
472
+
473
+ it("does NOT count an answer that PREDATES the obligation", () => {
474
+ const answered = answeredSinceOpen(
475
+ { chatId: CHAT, openedAt: 5000, threadId: OBLIGATION_THREAD },
476
+ stalenessDeps({
477
+ hasOutboundDeliveredSince: historyWithAnswer(2000, OBLIGATION_THREAD),
478
+ routeOverrides: NO_OVERRIDES,
479
+ }),
480
+ );
481
+ expect(answered.answered).toBe(false);
482
+ });
483
+
484
+ it("reports WHY the fallback found nothing when a record existed but was stale", () => {
485
+ // Negative-path telemetry. "No override on record" and "an override was on
486
+ // record and the freshness bound rejected it" are operationally different —
487
+ // the second is the bound doing its job (or being mis-tuned), and without a
488
+ // signal the 18.5s window is unmeasurable in production. Same 2026-08-13
489
+ // shape: newest override 188_418, decision 783_000 → 594.6s stale.
490
+ const reg = createAnswerRouteOverrides();
491
+ for (const atMs of [41_394, 100_301, 188_418]) {
492
+ reg.note({
493
+ chatId: CHAT,
494
+ enabled: true,
495
+ explicitThreadId: OBLIGATION_THREAD,
496
+ anchored: true,
497
+ routedThreadId: UNRELATED_THREAD,
498
+ nowMs: atMs,
499
+ });
500
+ }
501
+ const answered = answeredSinceOpen(
502
+ { chatId: CHAT, openedAt: 0, threadId: OBLIGATION_THREAD },
503
+ stalenessDeps({
504
+ hasOutboundDeliveredSince: historyWithAnswer(769_180, UNRELATED_THREAD),
505
+ routeOverrides: reg,
506
+ nowMs: 783_000,
507
+ }),
508
+ );
509
+ expect(answered.answered).toBe(false);
510
+ // The NEWEST rejected record's age — the near-miss the bound must be judged on.
511
+ expect(answered.staleOverrideAgeMs).toBe(783_000 - 188_418);
512
+ });
513
+
514
+ it("reports NO stale-override age when there was simply no record to reject", () => {
515
+ const answered = answeredSinceOpen(
516
+ { chatId: CHAT, openedAt: 1000, threadId: OBLIGATION_THREAD },
517
+ stalenessDeps({
518
+ hasOutboundDeliveredSince: historyWithAnswer(2000, UNRELATED_THREAD),
519
+ routeOverrides: NO_OVERRIDES,
520
+ }),
521
+ );
522
+ expect(answered).toEqual({ answered: false, via: null });
523
+ });
524
+
525
+ it("a ZERO reroute-match window disables the fallback outright (kill switch)", () => {
526
+ // The fallback is the one new mechanism that can silently close a genuinely
527
+ // unanswered obligation, so it needs its own off-switch — and the switch
528
+ // must mean "no fallback", not "a zero-width window an override recorded at
529
+ // this very instant still slips through".
530
+ const answered = answeredSinceOpen(
531
+ { chatId: CHAT, openedAt: 1_000, threadId: OBLIGATION_THREAD },
532
+ stalenessDeps({
533
+ hasOutboundDeliveredSince: historyWithAnswer(6_000, ROUTED_THREAD),
534
+ routeOverrides: overridesWithReroute(OBLIGATION_THREAD, ROUTED_THREAD, 6_000),
535
+ nowMs: 6_000,
536
+ rerouteMatchWindowMs: 0,
537
+ }),
538
+ );
539
+ expect(answered).toEqual({ answered: false, via: null });
540
+ });
541
+
542
+ it("reports not-answered when history is unavailable (never suppresses on doubt)", () => {
543
+ const answered = answeredSinceOpen(
544
+ { chatId: CHAT, openedAt: 1000, threadId: OBLIGATION_THREAD },
545
+ stalenessDeps({
546
+ hasOutboundDeliveredSince: () => true,
547
+ routeOverrides: NO_OVERRIDES,
548
+ historyEnabled: false,
549
+ }),
550
+ );
551
+ expect(answered.answered).toBe(false);
552
+ });
553
+ });
554
+
555
+ describe("createEscalationSettleGate — the nudge waits out an in-flight answer", () => {
556
+ const ID = "-100999:4#5462";
557
+ const OPENED = 111;
558
+
559
+ it("defers the FIRST decision and proceeds only after the settle window", () => {
560
+ const gate = createEscalationSettleGate(8_500);
561
+ expect(gate.shouldDefer(ID, 0, OPENED)).toBe(true); // first look — settle
562
+ expect(gate.shouldDefer(ID, 5_000, OPENED)).toBe(true); // next 5s sweep, still settling
563
+ expect(gate.shouldDefer(ID, 8_499, OPENED)).toBe(true);
564
+ expect(gate.shouldDefer(ID, 8_500, OPENED)).toBe(false); // window elapsed → escalate
565
+ });
566
+
567
+ it("is disabled by a zero window (kill switch → pre-fix immediate escalate)", () => {
568
+ const gate = createEscalationSettleGate(0);
569
+ expect(gate.shouldDefer(ID, 0, OPENED)).toBe(false);
570
+ });
571
+
572
+ it("clear() forgets the episode so a later escalate settles afresh", () => {
573
+ const gate = createEscalationSettleGate(8_500);
574
+ expect(gate.shouldDefer(ID, 0, OPENED)).toBe(true);
575
+ expect(gate.shouldDefer(ID, 9_000, OPENED)).toBe(false);
576
+ gate.clear(ID);
577
+ expect(gate.size()).toBe(0);
578
+ expect(gate.shouldDefer(ID, 10_000, OPENED)).toBe(true); // fresh window, not stale-proceed
579
+ });
580
+
581
+ it("a RE-OPENED obligation under the same origin id gets its own fresh window", () => {
582
+ const gate = createEscalationSettleGate(8_500);
583
+ expect(gate.shouldDefer(ID, 0, OPENED)).toBe(true);
584
+ expect(gate.shouldDefer(ID, 9_000, OPENED)).toBe(false); // episode 1 escalates
585
+ // Re-opened: a new openedAt. Its first decision must NOT inherit episode 1's
586
+ // elapsed window and skip the re-check.
587
+ expect(gate.shouldDefer(ID, 9_100, OPENED + 1)).toBe(true);
588
+ expect(gate.shouldDefer(ID, 17_599, OPENED + 1)).toBe(true);
589
+ expect(gate.shouldDefer(ID, 17_600, OPENED + 1)).toBe(false);
590
+ });
591
+
592
+ it("tracks obligations independently", () => {
593
+ const gate = createEscalationSettleGate(8_500);
594
+ expect(gate.shouldDefer("a", 0, OPENED)).toBe(true);
595
+ expect(gate.shouldDefer("b", 8_000, OPENED)).toBe(true);
596
+ expect(gate.shouldDefer("a", 9_000, OPENED)).toBe(false); // a's window elapsed
597
+ expect(gate.shouldDefer("b", 9_000, OPENED)).toBe(true); // b's has not
598
+ });
599
+
600
+ it("a backwards clock jump re-anchors instead of pinning the gate open forever", () => {
601
+ const gate = createEscalationSettleGate(8_500);
602
+ expect(gate.shouldDefer(ID, 100_000, OPENED)).toBe(true);
603
+ expect(gate.shouldDefer(ID, 10_000, OPENED)).toBe(true); // jumped back → re-anchor
604
+ expect(gate.shouldDefer(ID, 18_500, OPENED)).toBe(false); // one window from the new anchor
605
+ });
606
+
607
+ it("bounds its id map (a long-lived gateway cannot grow it without limit)", () => {
608
+ const gate = createEscalationSettleGate(8_500, 4);
609
+ for (let i = 0; i < 50; i++) gate.shouldDefer(`id-${i}`, i, OPENED);
610
+ expect(gate.size()).toBeLessThanOrEqual(4);
611
+ });
612
+ });
613
+
614
+ describe("resolveEscalateSettleMs", () => {
615
+ it("defaults to the derived window", () => {
616
+ expect(resolveEscalateSettleMs({})).toBe(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT);
617
+ });
618
+ it("honours an explicit 0 kill switch", () => {
619
+ expect(resolveEscalateSettleMs({ SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS: "0" })).toBe(0);
620
+ });
621
+ it("falls back to the default on garbage rather than disabling the guard", () => {
622
+ expect(resolveEscalateSettleMs({ SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS: "soon" })).toBe(
623
+ OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT,
624
+ );
625
+ expect(resolveEscalateSettleMs({ SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS: "-1" })).toBe(
626
+ OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT,
627
+ );
628
+ });
629
+ it("clamps an absurd window so a config typo cannot suppress escalation for a day", () => {
630
+ expect(
631
+ resolveEscalateSettleMs({ SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS: "85000000" }),
632
+ ).toBe(OBLIGATION_ESCALATE_SETTLE_MS_MAX);
633
+ expect(
634
+ resolveEscalateSettleMs({ SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS: "Infinity" }),
635
+ ).toBe(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT); // not finite → default, not clamp
636
+ });
637
+ it("passes a deliberate in-range tuning through untouched", () => {
638
+ expect(resolveEscalateSettleMs({ SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS: "20000" })).toBe(
639
+ 20_000,
640
+ );
641
+ });
642
+ it("treats a WHITESPACE-ONLY value as unset, not as the kill switch", () => {
643
+ // `Number(' ') === 0`, so a bare `=== ''` check lets a stray `KEY=" "` in an
644
+ // env file silently disable the guard with no log. Empty and whitespace must
645
+ // mean the same thing: unset.
646
+ for (const raw of [" ", " ", "\t", "\n"]) {
647
+ expect(resolveEscalateSettleMs({ SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS: raw })).toBe(
648
+ OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT,
649
+ );
650
+ }
651
+ });
652
+ });
653
+
654
+ describe("resolveRerouteMatchWindowMs — how stale an override may be", () => {
655
+ it("tracks the settle window, because the consulting decision comes after it", () => {
656
+ expect(resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT)).toBe(
657
+ OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT + REROUTE_MATCH_GRACE_MS,
658
+ );
659
+ expect(resolveRerouteMatchWindowMs(20_000)).toBe(20_000 + REROUTE_MATCH_GRACE_MS);
660
+ });
661
+
662
+ it("still allows a window with the settle gate killed (decision is immediate)", () => {
663
+ expect(resolveRerouteMatchWindowMs(0)).toBe(REROUTE_MATCH_GRACE_MS);
664
+ expect(resolveRerouteMatchWindowMs(-5_000)).toBe(REROUTE_MATCH_GRACE_MS);
665
+ });
666
+
667
+ it("stays far below the observed false-pairing gap and far above the real ones", () => {
668
+ const w = resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT);
669
+ expect(w).toBeGreaterThan(2_809); // worst real override→delivery lag, with margin
670
+ expect(w).toBeLessThan(594_800); // 2026-08-13's stale-override→unrelated-delivery gap
671
+ });
672
+
673
+ it("has its OWN kill switch — SETTLE_MS=0 does not disable the fallback", () => {
674
+ // The settle gate's kill switch only disables the settle gate. The reroute
675
+ // fallback is a separate mechanism with a separate failure mode (it can
676
+ // close a genuinely unanswered obligation SILENTLY), so it gets its own
677
+ // off-switch. Every sibling guard in this family already has one.
678
+ expect(
679
+ resolveRerouteMatchWindowMs(0, { SWITCHROOM_OBLIGATION_ESCALATE_SETTLE_MS: "0" }),
680
+ ).toBe(REROUTE_MATCH_GRACE_MS); // still live — the settle switch is not this switch
681
+ expect(
682
+ resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT, {
683
+ SWITCHROOM_OBLIGATION_REROUTE_MATCH_MS: "0",
684
+ }),
685
+ ).toBe(0); // and this one really does turn it off
686
+ });
687
+
688
+ it("passes a deliberate override through, and falls back to the derivation on garbage", () => {
689
+ const derived = resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT);
690
+ expect(
691
+ resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT, {
692
+ SWITCHROOM_OBLIGATION_REROUTE_MATCH_MS: "5000",
693
+ }),
694
+ ).toBe(5_000);
695
+ // Whitespace-only is UNSET, not the kill switch: `Number(' ') === 0`, so a
696
+ // bare `=== ''` check lets a stray `KEY=" "` turn the fallback off silently.
697
+ for (const raw of ["", " ", " ", "\t", "\n", "soon", "-1", "Infinity"]) {
698
+ expect(
699
+ resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT, {
700
+ SWITCHROOM_OBLIGATION_REROUTE_MATCH_MS: raw,
701
+ }),
702
+ ).toBe(derived);
703
+ }
704
+ });
705
+
706
+ it("clamps an absurd override so a typo cannot widen the silent-close window for a day", () => {
707
+ expect(
708
+ resolveRerouteMatchWindowMs(OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT, {
709
+ SWITCHROOM_OBLIGATION_REROUTE_MATCH_MS: "85000000",
710
+ }),
711
+ ).toBe(OBLIGATION_REROUTE_MATCH_MS_MAX);
712
+ });
713
+ });
714
+
715
+ // End-to-end through the real sweep: the assertion is the OUTCOME the user sees —
716
+ // did the "I may have missed this" nudge go out, and was the obligation closed?
717
+ describe("obligationSweep escalate branch — nag only when the answer really is missing", () => {
718
+ const ORIGIN = `${CHAT}:${OBLIGATION_THREAD}#5462`;
719
+
720
+ function makeWiring(opts: { ledger: ObligationLedger; hasOutboundDeliveredSince: HistoryStub }) {
721
+ const nudges: string[] = [];
722
+ const deps = {
723
+ OBLIGATION_LEDGER_ENABLED: true,
724
+ HISTORY_ENABLED: true,
725
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS: 0,
726
+ OBLIGATION_ESCALATE_GRACE_MS: 0,
727
+ OBLIGATION_REPRESENT_GRACE_MS: 0,
728
+ OBLIGATION_REPRESENT_MAX: 2,
729
+ OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS: 1,
730
+ OBLIGATION_ESCALATE_MAX: 3,
731
+ OBLIGATION_ESCALATE_SEND_DEADLINE_MS: 10_000,
732
+ obligationLedger: opts.ledger,
733
+ obligationEscalateInFlight: new Set<string>(),
734
+ pendingCrossTurnGate: { set: () => {} },
735
+ pendingInboundBuffer: { depth: () => 0, push: () => true },
736
+ capturedResume: { dispatch: () => {} },
737
+ getCurrentTurn: () => null,
738
+ turnInFlightForGate: () => false,
739
+ agentHasInFlightBackgroundWork: () => false,
740
+ // The REAL history signature the gateway injects — `minChars` 4th,
741
+ // `untilMs` 5th — so obligation-wiring's own bounded-query adapter is under
742
+ // test here, not bypassed by a convenience stub.
743
+ hasOutboundDeliveredSince: (
744
+ chatId: string,
745
+ sinceMs: number,
746
+ threadId?: number | null,
747
+ _minChars?: number,
748
+ untilMs?: number,
749
+ ) => opts.hasOutboundDeliveredSince(chatId, sinceMs, threadId, untilMs),
750
+ findTurnByOriginId: () => undefined,
751
+ bridgeAlive: () => true,
752
+ sendEscalationNudge: (o: { originTurnId: string }) => {
753
+ nudges.push(o.originTurnId);
754
+ return Promise.resolve();
755
+ },
756
+ };
757
+ const wiring = createObligationWiring(
758
+ deps as unknown as Parameters<typeof createObligationWiring>[0],
759
+ );
760
+ return { wiring, nudges };
761
+ }
762
+
763
+ /** Open an obligation whose represent ladder is already exhausted → escalate. */
764
+ function openExhausted(
765
+ ledger: ObligationLedger,
766
+ openedAt: number,
767
+ origin = ORIGIN,
768
+ threadId = OBLIGATION_THREAD,
769
+ ): void {
770
+ ledger.openIfAbsent({
771
+ originTurnId: origin,
772
+ chatId: CHAT,
773
+ threadId,
774
+ messageId: 5462,
775
+ text: "synthetic fixture question about the widget report",
776
+ openedAt,
777
+ });
778
+ ledger.markRepresented(origin);
779
+ ledger.markRepresented(origin); // count == max → decideAtIdle returns 'escalate'
780
+ }
781
+
782
+ it("(b) an answer the router RECORDED as rerouted out of this topic suppresses the nudge", () => {
783
+ // 2026-08-10 06:36 reproduction. Pre-fix: the thread-4 query misses the
784
+ // thread-635 answer and the nag fires. Post-fix: closed silently, no nudge.
785
+ const ledger = new ObligationLedger(2);
786
+ const openedAt = Date.now() - 60_000;
787
+ const { wiring, nudges } = makeWiring({
788
+ ledger,
789
+ hasOutboundDeliveredSince: historyWithAnswer(Date.now() - 14_200, ROUTED_THREAD),
790
+ });
791
+ answerRouteOverrides.note({
792
+ chatId: CHAT,
793
+ enabled: true,
794
+ explicitThreadId: OBLIGATION_THREAD,
795
+ anchored: true,
796
+ routedThreadId: ROUTED_THREAD,
797
+ nowMs: Date.now() - 14_300,
798
+ });
799
+ openExhausted(ledger, openedAt);
800
+
801
+ wiring.obligationSweep();
802
+
803
+ expect(nudges).toEqual([]); // the user is NOT nagged on top of their answer
804
+ expect(ledger.isOpen(ORIGIN)).toBe(false); // closed silently
805
+ });
806
+
807
+ it("(d) a STALE reroute record does NOT license an unrelated later delivery (2026-08-13 04:03)", () => {
808
+ // The blocker this guard exists for, built to the REAL 4#5462 shape — the
809
+ // overrides the incident actually had, not a scenario without them:
810
+ //
811
+ // opened 03:50:02.807 (T-783.3s from the escalate decision)
812
+ // overrides 03:50:44.201 / 03:51:43.108 / 03:53:11.225 model→4,routed→3
813
+ // delivery 04:02:51.987 295 chars, thread 3, a DIFFERENT question
814
+ // decision 04:03:06.140
815
+ //
816
+ // The newest override is 594.8s stale when the sweep decides. Cut at
817
+ // `openedAt`, the fallback pairs it with that unrelated delivery, closes
818
+ // topic 4's obligation silently, and the user's message is dropped with no
819
+ // nudge and no re-present. It must escalate instead.
820
+ const ledger = new ObligationLedger(2);
821
+ const ORIGIN_D = `${CHAT}:${THREAD_D}#5463`;
822
+ const base = Date.now();
823
+ const { wiring, nudges } = makeWiring({
824
+ ledger,
825
+ // A substantive assistant row in topic 3 — and ONLY in topic 3.
826
+ hasOutboundDeliveredSince: historyWithAnswer(base - 14_200, UNRELATED_THREAD),
827
+ });
828
+ // The three real overrides, all long stale by the time the sweep decides.
829
+ for (const ago of [741_900, 683_000, 594_900]) {
830
+ answerRouteOverrides.note({
831
+ chatId: CHAT,
832
+ enabled: true,
833
+ explicitThreadId: THREAD_D,
834
+ anchored: true,
835
+ routedThreadId: UNRELATED_THREAD,
836
+ nowMs: base - ago,
837
+ });
838
+ }
839
+ openExhausted(ledger, base - 783_300, ORIGIN_D, THREAD_D);
840
+
841
+ const realNow = Date.now;
842
+ try {
843
+ for (const offset of [0, 5_000, 10_000]) {
844
+ Date.now = () => base + offset;
845
+ wiring.obligationSweep();
846
+ }
847
+ } finally {
848
+ Date.now = realNow;
849
+ }
850
+
851
+ expect(nudges).toEqual([ORIGIN_D]); // topic 4's message is NOT silently dropped
852
+ });
853
+
854
+ // The leak guard is a PAIR, and deliberately so. Asserting "the registry is
855
+ // empty" on its own passes vacuously the moment the tests that write to the
856
+ // singleton are moved below it — the guard would then be green precisely when
857
+ // it has stopped guarding. Pinning its own polluter immediately above it makes
858
+ // it order-independent: whatever else moves, this assertion always runs one
859
+ // test after a write it can see.
860
+ it("leak guard 1/2 — pollute the process-wide override registry", () => {
861
+ // `answerRouteOverrides` is a module singleton and the scenarios here write
862
+ // to it with wall-clock timestamps, so without a reset (b)'s `CHAT:4` record
863
+ // is still FRESH when (a) and (c) — same topic — run, and (a)/(c) pass only
864
+ // because their history stubs happen to reject the routed thread. Loosen a
865
+ // stub and (b) silently changes another test's outcome.
866
+ answerRouteOverrides.note({
867
+ chatId: CHAT,
868
+ enabled: true,
869
+ explicitThreadId: OBLIGATION_THREAD,
870
+ anchored: true,
871
+ routedThreadId: ROUTED_THREAD,
872
+ nowMs: Date.now(),
873
+ });
874
+ expect(answerRouteOverrides.size()).toBe(1);
875
+ });
876
+
877
+ it("leak guard 2/2 — the NEXT scenario still starts from an EMPTY registry", () => {
878
+ expect(answerRouteOverrides.routedOverridesSince(CHAT, OBLIGATION_THREAD, 0)).toEqual([]);
879
+ expect(answerRouteOverrides.size()).toBe(0);
880
+ });
881
+
882
+ it("(e) a SKIPPED sweep tick does not turn a fresh reroute record stale (freshness anchor)", () => {
883
+ // The freshness bound must be anchored to the instant the decision FIRST
884
+ // read "unanswered" — the settle gate's own `firstAt` — not to whenever the
885
+ // re-check happens to run. A tick is not merely DELAYED, it can be skipped
886
+ // outright: `turnInFlightForGate()` (unbounded), the background-work /
887
+ // session-busy defer (bounded at 20 min), and the escalate/represent graces
888
+ // all sit ABOVE this branch. The 2026-08-13 log shows the sweep deferring
889
+ // for minutes at a time.
890
+ //
891
+ // T+0 the router reroutes topic 45's answer to thread 635
892
+ // T+0.1 first escalate decision — answer still in flight → settle defer
893
+ // T+1.4 the rerouted answer lands in thread 635
894
+ // T+107 the next decision that actually RUNS (ticks in between skipped)
895
+ //
896
+ // Anchored at the re-check, the record is 107s old, the fallback is skipped
897
+ // and the user is nagged on top of the answer they already have — the exact
898
+ // bug this PR exists to fix, surviving for that timing.
899
+ const THREAD_E = 45;
900
+ const ORIGIN_E = `${CHAT}:${THREAD_E}#5464`;
901
+ const ledger = new ObligationLedger(2);
902
+ const base = Date.now();
903
+ const DELIVERED_AT = base + 1_400;
904
+ const { wiring, nudges } = makeWiring({
905
+ ledger,
906
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId, untilMs) =>
907
+ chatId === CHAT &&
908
+ (threadId === undefined || threadId === ROUTED_THREAD) &&
909
+ Date.now() >= DELIVERED_AT &&
910
+ DELIVERED_AT >= sinceMs &&
911
+ (untilMs == null || DELIVERED_AT <= untilMs),
912
+ });
913
+ answerRouteOverrides.note({
914
+ chatId: CHAT,
915
+ enabled: true,
916
+ explicitThreadId: THREAD_E,
917
+ anchored: true,
918
+ routedThreadId: ROUTED_THREAD,
919
+ nowMs: base,
920
+ });
921
+ openExhausted(ledger, base - 60_000, ORIGIN_E, THREAD_E);
922
+
923
+ const realNow = Date.now;
924
+ try {
925
+ Date.now = () => base + 100;
926
+ wiring.obligationSweep(); // decision 1 — answer in flight → settle
927
+ expect(nudges).toEqual([]);
928
+ // …and now the sweep is starved for 107s by the gates above this branch.
929
+ Date.now = () => base + 107_000;
930
+ wiring.obligationSweep();
931
+ } finally {
932
+ Date.now = realNow;
933
+ }
934
+
935
+ expect(nudges).toEqual([]); // no nag on top of the delivered answer
936
+ expect(ledger.isOpen(ORIGIN_E)).toBe(false); // closed silently instead
937
+ });
938
+
939
+ it("(f) a STARVED sweep does not pair a fresh override with a much later unrelated delivery", () => {
940
+ // The counterpart to (e), and the failure mode the `firstAt` anchor opens if
941
+ // the DELIVERY is left unbounded. Anchoring the override's freshness at
942
+ // `firstAt` is correct (see (e)) but it only bounds ONE end: it says the
943
+ // routing decision was recent WHEN THE QUESTION WAS FIRST ASKED. The history
944
+ // query is still read at the RE-CHECK, which the gates above this branch can
945
+ // starve for minutes — so an override that is legitimately fresh licenses a
946
+ // look at a thread across an arbitrarily long stretch of later traffic.
947
+ //
948
+ // T+0 the model's SHORT reply to topic 47 is rerouted to thread 635.
949
+ // Under 200 chars, so it is not a substantive answer and the
950
+ // obligation correctly stays open.
951
+ // T+0.1 first escalate decision — not answered → settle defer.
952
+ // `firstAt` is pinned here for the whole episode.
953
+ // T+90 an unrelated >=200-char answer to a DIFFERENT question lands in
954
+ // thread 635.
955
+ // T+107 the next decision that actually RUNS.
956
+ //
957
+ // Anchored at `firstAt` with an open-ended forward query, T+0's override
958
+ // reads fresh at T+107 and pairs with the T+90 delivery: the obligation is
959
+ // closed `via=reroute` and topic 47's message is never answered and never
960
+ // nudged. It must escalate instead. Bounding the delivery to the override's
961
+ // OWN window keeps (e)'s legitimate 1.4s pairing and kills this 90s one.
962
+ const THREAD_G = 47;
963
+ const ORIGIN_G = `${CHAT}:${THREAD_G}#5466`;
964
+ const ledger = new ObligationLedger(2);
965
+ const base = Date.now();
966
+ const UNRELATED_DELIVERED_AT = base + 90_000;
967
+ const { wiring, nudges } = makeWiring({
968
+ ledger,
969
+ // Only the unrelated T+90 row ever exists, and — like the real table — it
970
+ // is invisible until the instant it is written. Nothing was ever delivered
971
+ // for topic 47: not in its own thread, not in the routed one.
972
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId, untilMs) =>
973
+ chatId === CHAT &&
974
+ (threadId === undefined || threadId === ROUTED_THREAD) &&
975
+ Date.now() >= UNRELATED_DELIVERED_AT &&
976
+ UNRELATED_DELIVERED_AT >= sinceMs &&
977
+ (untilMs == null || UNRELATED_DELIVERED_AT <= untilMs),
978
+ });
979
+ answerRouteOverrides.note({
980
+ chatId: CHAT,
981
+ enabled: true,
982
+ explicitThreadId: THREAD_G,
983
+ anchored: true,
984
+ routedThreadId: ROUTED_THREAD,
985
+ nowMs: base,
986
+ });
987
+ openExhausted(ledger, base - 60_000, ORIGIN_G, THREAD_G);
988
+
989
+ const lines: string[] = [];
990
+ const realWrite = process.stderr.write.bind(process.stderr);
991
+ const realNow = Date.now;
992
+ process.stderr.write = ((chunk: string | Uint8Array) => {
993
+ lines.push(String(chunk));
994
+ return true;
995
+ }) as typeof process.stderr.write;
996
+ try {
997
+ Date.now = () => base + 100;
998
+ wiring.obligationSweep(); // decision 1 — nothing delivered yet → settle
999
+ expect(nudges).toEqual([]);
1000
+ // …starved for 107s by the gates above this branch, exactly as in (e).
1001
+ Date.now = () => base + 107_000;
1002
+ wiring.obligationSweep();
1003
+ } finally {
1004
+ Date.now = realNow;
1005
+ process.stderr.write = realWrite;
1006
+ }
1007
+
1008
+ expect(nudges).toEqual([ORIGIN_G]); // topic 47's message is NOT dropped in silence
1009
+ // …and specifically NOT via the reroute fallback pairing T+0's override with
1010
+ // the T+90 stranger. (`nudges` alone would not distinguish a silent close
1011
+ // from a send that has not resolved yet — the escalate send is async.)
1012
+ expect(
1013
+ lines.filter((l) => l.includes("closed silently") && l.includes(ORIGIN_G)),
1014
+ ).toEqual([]);
1015
+ });
1016
+
1017
+ it("logs the stale-override rejection so the freshness bound is measurable in production", () => {
1018
+ // The `via=reroute` close is logged because the fallback's blast radius must
1019
+ // be observable without a rebuild. The bound that makes the fallback safe
1020
+ // deserves the same: a rejection currently looks identical to "no record".
1021
+ const THREAD_F = 46;
1022
+ const ORIGIN_F = `${CHAT}:${THREAD_F}#5465`;
1023
+ const ledger = new ObligationLedger(2);
1024
+ const base = Date.now();
1025
+ const { wiring } = makeWiring({
1026
+ ledger,
1027
+ hasOutboundDeliveredSince: historyWithAnswer(base - 14_200, UNRELATED_THREAD),
1028
+ });
1029
+ answerRouteOverrides.note({
1030
+ chatId: CHAT,
1031
+ enabled: true,
1032
+ explicitThreadId: THREAD_F,
1033
+ anchored: true,
1034
+ routedThreadId: UNRELATED_THREAD,
1035
+ nowMs: base - 594_900,
1036
+ });
1037
+ openExhausted(ledger, base - 783_300, ORIGIN_F, THREAD_F);
1038
+
1039
+ const lines: string[] = [];
1040
+ const realWrite = process.stderr.write.bind(process.stderr);
1041
+ process.stderr.write = ((chunk: string | Uint8Array) => {
1042
+ lines.push(String(chunk));
1043
+ return true;
1044
+ }) as typeof process.stderr.write;
1045
+ try {
1046
+ wiring.obligationSweep();
1047
+ } finally {
1048
+ process.stderr.write = realWrite;
1049
+ }
1050
+
1051
+ const rejected = lines.find((l) => l.includes("reroute record rejected"));
1052
+ expect(rejected).toBeDefined();
1053
+ expect(rejected).toContain("window=18500ms");
1054
+ expect(rejected).toMatch(/age=59[45]\d{3}ms/);
1055
+ });
1056
+
1057
+ it("(a) an answer that lands while the first escalate decision is settling suppresses the nudge", () => {
1058
+ // 2026-08-10 06:36 / 07:52 reproduction: at the first decision the reply is
1059
+ // in flight and no history row exists yet; it appears ~1-3s later. Pre-fix
1060
+ // the nudge is sent on that very first decision.
1061
+ const ledger = new ObligationLedger(2);
1062
+ const openedAt = Date.now() - 60_000;
1063
+ let answerDelivered = false;
1064
+ const { wiring, nudges } = makeWiring({
1065
+ ledger,
1066
+ // Reads false until the in-flight reply is recorded, then true — in the
1067
+ // obligation's OWN topic, so no reroute record is needed.
1068
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId, untilMs) =>
1069
+ answerDelivered &&
1070
+ chatId === CHAT &&
1071
+ (threadId === undefined || threadId === OBLIGATION_THREAD) &&
1072
+ Date.now() >= sinceMs &&
1073
+ (untilMs == null || Date.now() <= untilMs),
1074
+ });
1075
+ openExhausted(ledger, openedAt);
1076
+
1077
+ wiring.obligationSweep(); // decision 1 — answer still in flight
1078
+ expect(nudges).toEqual([]); // settling, not sent
1079
+ expect(ledger.isOpen(ORIGIN)).toBe(true); // and not lost
1080
+
1081
+ answerDelivered = true; // the reply's history row lands 1.2s later
1082
+ wiring.obligationSweep(); // next sweep re-checks
1083
+
1084
+ expect(nudges).toEqual([]); // still no nag
1085
+ expect(ledger.isOpen(ORIGIN)).toBe(false); // closed silently instead
1086
+ });
1087
+
1088
+ it("(c) a genuinely unanswered obligation STILL escalates once the settle window elapses", () => {
1089
+ // 2026-08-12 09:08: the agent really had not answered (its reply was 41s
1090
+ // away). The guards must not swallow this — the nudge is the whole point.
1091
+ const ledger = new ObligationLedger(2);
1092
+ const { wiring, nudges } = makeWiring({
1093
+ ledger,
1094
+ hasOutboundDeliveredSince: () => false, // nothing was ever delivered
1095
+ });
1096
+ openExhausted(ledger, Date.now() - 600_000);
1097
+
1098
+ wiring.obligationSweep(); // decision 1 — settles
1099
+ expect(nudges).toEqual([]);
1100
+ expect(ledger.isOpen(ORIGIN)).toBe(true); // held open, not dropped
1101
+
1102
+ // A later sweep, past the settle window. The gate reads the wall clock the
1103
+ // sweep passes it, so advance real time by stubbing Date.now.
1104
+ const realNow = Date.now;
1105
+ try {
1106
+ const t = realNow() + OBLIGATION_ESCALATE_SETTLE_MS_DEFAULT + 1_000;
1107
+ Date.now = () => t;
1108
+ wiring.obligationSweep();
1109
+ } finally {
1110
+ Date.now = realNow;
1111
+ }
1112
+
1113
+ expect(nudges).toEqual([ORIGIN]); // the genuine escalation fires
1114
+ });
1115
+
1116
+ it("the settle deferral is real AND bounded — deferred across the window, then sent", () => {
1117
+ const ledger = new ObligationLedger(2);
1118
+ const { wiring, nudges } = makeWiring({ ledger, hasOutboundDeliveredSince: () => false });
1119
+ openExhausted(ledger, Date.now() - 600_000);
1120
+
1121
+ const realNow = Date.now;
1122
+ const seen: string[][] = [];
1123
+ try {
1124
+ const base = realNow();
1125
+ // Sweep every 5s across the settle window, as the real 5s interval does,
1126
+ // recording what the user had received after each tick.
1127
+ for (const offset of [0, 5_000, 10_000]) {
1128
+ Date.now = () => base + offset;
1129
+ wiring.obligationSweep();
1130
+ seen.push([...nudges]);
1131
+ }
1132
+ } finally {
1133
+ Date.now = realNow;
1134
+ }
1135
+ // The deferral actually happened: nothing sent on the first two ticks, both
1136
+ // inside the 8.5s window. (Without the gate the nudge goes out on tick 1.)
1137
+ expect(seen[0]).toEqual([]);
1138
+ expect(seen[1]).toEqual([]);
1139
+ // And it is bounded: escalated on the first tick past the window.
1140
+ expect(seen[2]).toEqual([ORIGIN]);
1141
+ });
1142
+ });
1143
+
1144
+ // ---------------------------------------------------------------------------
1145
+ // Drift guard for the #4680 merge. Before #4680 the gateway computed the
1146
+ // override boolean ONCE: `answerRouteOverrides.note()` returned it and the
1147
+ // telemetry line reused the return value, so the RECORD and the
1148
+ // EXPLICIT_OVERRIDDEN marker could not disagree. #4680 extracted the line into
1149
+ // the pure `formatReplyRouteLog`, which re-derives the predicate from its own
1150
+ // inputs — so there are now TWO copies of the rule and nothing structural
1151
+ // keeping them equal. The record is the load-bearing one: obligation escalation
1152
+ // closes on it. If it drifts from the marker, the log stops being evidence for
1153
+ // what the sweep will do, which is exactly what made the 2026-08-10 incidents
1154
+ // take a week to diagnose.
1155
+ //
1156
+ // This drives BOTH through the REAL router (`resolveAnswerThreadId`), so the
1157
+ // routed thread is not a hand-picked constant, and asserts they agree on every
1158
+ // combination of authority / explicit topic / anchor chat.
1159
+ //
1160
+ // TWO THINGS THIS MATRIX DOES NOT PROVE, and where they are proved instead:
1161
+ // 1. That the gateway CALLS either side. Every case here re-spells
1162
+ // `resolveAnswerThreadWithLog`'s body and notes on its OWN registry
1163
+ // instance, so deleting `answerRouteOverrides.note(...)` from gateway.ts
1164
+ // leaves all 64 green — see `tests/answer-route-side-effect.test.ts`, which
1165
+ // drives the real gateway function against the real singleton.
1166
+ // 2. Individual clauses of the marker's predicate. Driving the REAL router is
1167
+ // what makes the routed thread honest, but it also makes some combinations
1168
+ // unreachable: with authority off (case 8) the router returns the explicit
1169
+ // topic, and a dropped cross-chat anchor falls back to it too, so
1170
+ // `threadId !== explicit` is never true on either arm. Dropping the
1171
+ // `frameworkTopicAuthority` clause, or swapping the filtered anchors for
1172
+ // the raw turns, therefore survives all 64 — both are pinned directly on
1173
+ // the pure formatter in `gateway/reply-route-log.test.ts`.
1174
+ describe("the recorded override and the EXPLICIT_OVERRIDDEN marker cannot drift apart", () => {
1175
+ const TARGET = "-100999";
1176
+ const OTHER = "-100888";
1177
+ const turn = (chatId: string, threadId: number | undefined) => ({
1178
+ turnId: "t1",
1179
+ sessionChatId: chatId,
1180
+ sessionThreadId: threadId,
1181
+ });
1182
+
1183
+ const cases: Array<{
1184
+ name: string;
1185
+ enabled: boolean;
1186
+ explicitThreadId: number | undefined;
1187
+ originTurn: ReturnType<typeof turn> | null;
1188
+ liveTurn: ReturnType<typeof turn> | null;
1189
+ }> = [
1190
+ { name: "explicit 4, same-chat origin in 635 — the General→CRM correction",
1191
+ enabled: true, explicitThreadId: 4, originTurn: turn(TARGET, 635), liveTurn: null },
1192
+ { name: "explicit 4, same-chat LIVE turn in 635",
1193
+ enabled: true, explicitThreadId: 4, originTurn: null, liveTurn: turn(TARGET, 635) },
1194
+ { name: "explicit 4, origin agrees at 4 — no override",
1195
+ enabled: true, explicitThreadId: 4, originTurn: turn(TARGET, 4), liveTurn: null },
1196
+ { name: "explicit 4, CROSS-CHAT origin — anchor dropped (#4680)",
1197
+ enabled: true, explicitThreadId: 4, originTurn: turn(OTHER, 635), liveTurn: null },
1198
+ { name: "explicit 4, cross-chat origin AND cross-chat live",
1199
+ enabled: true, explicitThreadId: 4, originTurn: turn(OTHER, 635), liveTurn: turn(OTHER, 900) },
1200
+ { name: "explicit 4, cross-chat origin but same-chat live in 900",
1201
+ enabled: true, explicitThreadId: 4, originTurn: turn(OTHER, 635), liveTurn: turn(TARGET, 900) },
1202
+ { name: "no explicit topic at all",
1203
+ enabled: true, explicitThreadId: undefined, originTurn: turn(TARGET, 635), liveTurn: null },
1204
+ { name: "explicit 4, same-chat origin in 635, authority DISABLED",
1205
+ enabled: false, explicitThreadId: 4, originTurn: turn(TARGET, 635), liveTurn: null },
1206
+ ];
1207
+
1208
+ for (const c of cases) {
1209
+ it(`agrees: ${c.name}`, () => {
1210
+ // Mirrors gateway.ts `resolveAnswerThreadWithLog` exactly.
1211
+ const originAnchor = isCrossChatAnchor(TARGET, c.originTurn?.sessionChatId)
1212
+ ? null
1213
+ : c.originTurn;
1214
+ const liveAnchor = isCrossChatAnchor(TARGET, c.liveTurn?.sessionChatId) ? null : c.liveTurn;
1215
+ const threadId = resolveAnswerThreadId({
1216
+ explicitThreadId: c.explicitThreadId,
1217
+ originResolved: c.originTurn != null,
1218
+ originThreadId: c.originTurn?.sessionThreadId,
1219
+ liveThreadId: c.liveTurn?.sessionThreadId,
1220
+ liveTurnPresent: c.liveTurn != null,
1221
+ lastEndedResolvedForChat: false,
1222
+ lastEndedThreadIdForChat: undefined,
1223
+ frameworkTopicAuthority: c.enabled,
1224
+ targetChatId: TARGET,
1225
+ originChatId: c.originTurn?.sessionChatId,
1226
+ liveChatId: c.liveTurn?.sessionChatId,
1227
+ });
1228
+ const recorded = createAnswerRouteOverrides().note({
1229
+ chatId: TARGET,
1230
+ enabled: c.enabled,
1231
+ explicitThreadId: c.explicitThreadId,
1232
+ anchored: originAnchor != null || liveAnchor != null,
1233
+ routedThreadId: threadId,
1234
+ nowMs: 1_000,
1235
+ });
1236
+ const marked = formatReplyRouteLog({
1237
+ surface: "reply",
1238
+ chatId: TARGET,
1239
+ threadId,
1240
+ explicitThreadId: c.explicitThreadId,
1241
+ originTurn: c.originTurn,
1242
+ originVia: "echo",
1243
+ liveTurn: c.liveTurn,
1244
+ recovered: null,
1245
+ frameworkTopicAuthority: c.enabled,
1246
+ hasDifferentThreadedRecentTurn: () => false,
1247
+ }).includes("EXPLICIT_OVERRIDDEN");
1248
+ expect(recorded).toBe(marked);
1249
+ });
1250
+ }
1251
+
1252
+ // Not vacuous: at least one case must actually BE an override, or the whole
1253
+ // matrix could agree on `false` forever.
1254
+ it("the matrix contains a real override (guards against agreeing on false)", () => {
1255
+ const overrides = createAnswerRouteOverrides();
1256
+ const threadId = resolveAnswerThreadId({
1257
+ explicitThreadId: 4,
1258
+ originResolved: true,
1259
+ originThreadId: 635,
1260
+ liveThreadId: undefined,
1261
+ liveTurnPresent: false,
1262
+ lastEndedResolvedForChat: false,
1263
+ lastEndedThreadIdForChat: undefined,
1264
+ frameworkTopicAuthority: true,
1265
+ targetChatId: TARGET,
1266
+ originChatId: TARGET,
1267
+ liveChatId: undefined,
1268
+ });
1269
+ expect(threadId).toBe(635);
1270
+ expect(
1271
+ overrides.note({ chatId: TARGET, enabled: true, explicitThreadId: 4, anchored: true,
1272
+ routedThreadId: threadId, nowMs: 1_000 }),
1273
+ ).toBe(true);
1274
+ });
1275
+ });