switchroom 0.21.10 → 0.21.12
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 +5 -2
- package/skills/dev-protocol/SKILL.md +14 -10
- package/skills/switchroom-release/SKILL.md +11 -5
- package/telegram-plugin/dist/gateway/gateway.js +385 -35
- package/telegram-plugin/format.ts +56 -0
- 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 +55 -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/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 +22 -4
- package/telegram-plugin/render/parse.ts +9 -1
- 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 +76 -3
- package/telegram-plugin/tests/send-reply-golden.test.ts +192 -3
- 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/turn-supersede-finalizes-prior-card.test.ts +1 -1
- package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
- 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
|
+
});
|