switchroom 0.14.61 → 0.14.63
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 +73 -62
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +2617 -2081
- package/telegram-plugin/gateway/auth-broker-client.ts +18 -8
- package/telegram-plugin/gateway/auto-classify-mid-turn.ts +119 -0
- package/telegram-plugin/gateway/escalation-drive.ts +79 -0
- package/telegram-plugin/gateway/gateway.ts +448 -43
- package/telegram-plugin/gateway/microsoft-connect-flow.ts +226 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +45 -3
- package/telegram-plugin/gateway/with-deadline.ts +43 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +32 -12
- package/telegram-plugin/tests/auto-classify-mid-turn.test.ts +87 -0
- package/telegram-plugin/tests/escalation-drive.test.ts +123 -0
- package/telegram-plugin/tests/microsoft-connect-flow.test.ts +185 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +85 -25
- package/telegram-plugin/tests/obligation-ledger.test.ts +92 -0
- package/telegram-plugin/tests/with-deadline.test.ts +61 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
startMicrosoftConnect,
|
|
5
|
+
runMicrosoftConnectPoll,
|
|
6
|
+
} from '../gateway/microsoft-connect-flow.js'
|
|
7
|
+
import { DEFAULT_MICROSOFT_CLIENT_ID } from '../../src/auth/default-oauth-clients.js'
|
|
8
|
+
import type {
|
|
9
|
+
MicrosoftDeviceCodeResponse,
|
|
10
|
+
MicrosoftOAuthClientConfig,
|
|
11
|
+
MicrosoftTokenResponse,
|
|
12
|
+
} from '../../src/microsoft/oauth.js'
|
|
13
|
+
|
|
14
|
+
const PERSONAL_MSA_TID = '9188040d-6c67-4c5b-b112-36a304b66dad'
|
|
15
|
+
|
|
16
|
+
const DEVICE: MicrosoftDeviceCodeResponse = {
|
|
17
|
+
device_code: 'dc-abc',
|
|
18
|
+
user_code: 'ABCD-EFGH',
|
|
19
|
+
verification_uri: 'https://microsoft.com/devicelogin',
|
|
20
|
+
expires_in: 900,
|
|
21
|
+
interval: 5,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function fakeIdToken(payload: Record<string, unknown>): string {
|
|
25
|
+
const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
|
26
|
+
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('startMicrosoftConnect', () => {
|
|
30
|
+
it('uses the shipped default client_id + default scopes when there is no config', async () => {
|
|
31
|
+
let seen: MicrosoftOAuthClientConfig | undefined
|
|
32
|
+
const res = await startMicrosoftConnect({
|
|
33
|
+
requestDeviceCode: async (cfg) => {
|
|
34
|
+
seen = cfg
|
|
35
|
+
return DEVICE
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
expect(res.kind).toBe('started')
|
|
39
|
+
if (res.kind === 'started') {
|
|
40
|
+
expect(res.source).toBe('default')
|
|
41
|
+
expect(res.clientId).toBe(DEFAULT_MICROSOFT_CLIENT_ID)
|
|
42
|
+
expect(res.scopes).toContain('offline_access')
|
|
43
|
+
expect(res.device.user_code).toBe('ABCD-EFGH')
|
|
44
|
+
}
|
|
45
|
+
expect(seen?.client_id).toBe(DEFAULT_MICROSOFT_CLIENT_ID)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('refuses a vaulted BYO client (gateway can\'t resolve vault refs)', async () => {
|
|
49
|
+
const res = await startMicrosoftConnect({
|
|
50
|
+
configClientId: 'vault:microsoft-oauth-client-id',
|
|
51
|
+
requestDeviceCode: async () => DEVICE,
|
|
52
|
+
})
|
|
53
|
+
expect(res.kind).toBe('byo-vault')
|
|
54
|
+
if (res.kind === 'byo-vault') {
|
|
55
|
+
expect(res.ref).toBe('vault:microsoft-oauth-client-id')
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('uses a literal BYO config client_id (source=config)', async () => {
|
|
60
|
+
const res = await startMicrosoftConnect({
|
|
61
|
+
configClientId: 'byo-literal-123',
|
|
62
|
+
requestDeviceCode: async () => DEVICE,
|
|
63
|
+
})
|
|
64
|
+
expect(res.kind).toBe('started')
|
|
65
|
+
if (res.kind === 'started') {
|
|
66
|
+
expect(res.source).toBe('config')
|
|
67
|
+
expect(res.clientId).toBe('byo-literal-123')
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns an error when the device-code request fails', async () => {
|
|
72
|
+
const res = await startMicrosoftConnect({
|
|
73
|
+
requestDeviceCode: async () => {
|
|
74
|
+
throw new Error('device_code request failed (400): boom')
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
expect(res.kind).toBe('error')
|
|
78
|
+
if (res.kind === 'error') expect(res.message).toContain('boom')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('runMicrosoftConnectPoll', () => {
|
|
83
|
+
const flow = {
|
|
84
|
+
device: DEVICE,
|
|
85
|
+
clientId: 'client-1',
|
|
86
|
+
scopes: ['offline_access', 'Mail.ReadWrite'],
|
|
87
|
+
cancelled: false,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function tokens(o: Partial<MicrosoftTokenResponse> = {}): MicrosoftTokenResponse {
|
|
91
|
+
return {
|
|
92
|
+
access_token: 'at',
|
|
93
|
+
refresh_token: 'rt',
|
|
94
|
+
expires_in: 3600,
|
|
95
|
+
token_type: 'Bearer',
|
|
96
|
+
scope: 'Mail.ReadWrite',
|
|
97
|
+
...o,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
it('connects: registers the account via the broker keyed by the authenticated email', async () => {
|
|
102
|
+
const id_token = fakeIdToken({
|
|
103
|
+
tid: PERSONAL_MSA_TID,
|
|
104
|
+
oid: 'oid-1',
|
|
105
|
+
preferred_username: 'Lisa@Outlook.com',
|
|
106
|
+
})
|
|
107
|
+
const calls: Array<{ label: string; opts: unknown; refreshToken: string }> = []
|
|
108
|
+
const res = await runMicrosoftConnectPoll(flow, {
|
|
109
|
+
pollDeviceToken: async () => tokens({ id_token }),
|
|
110
|
+
addAccount: async (label, creds, opts) => {
|
|
111
|
+
calls.push({
|
|
112
|
+
label,
|
|
113
|
+
opts,
|
|
114
|
+
refreshToken: creds.microsoftOauth.refreshToken,
|
|
115
|
+
})
|
|
116
|
+
return { label }
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
expect(res.kind).toBe('connected')
|
|
120
|
+
if (res.kind === 'connected') {
|
|
121
|
+
expect(res.account).toBe('lisa@outlook.com')
|
|
122
|
+
expect(res.accountType).toBe('personal')
|
|
123
|
+
}
|
|
124
|
+
expect(calls).toHaveLength(1)
|
|
125
|
+
expect(calls[0].label).toBe('lisa@outlook.com')
|
|
126
|
+
expect(calls[0].opts).toEqual({ provider: 'microsoft', replace: true })
|
|
127
|
+
expect(calls[0].refreshToken).toBe('rt')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('cancelled: does NOT register if the flow was cancelled during the poll', async () => {
|
|
131
|
+
let added = false
|
|
132
|
+
const res = await runMicrosoftConnectPoll(
|
|
133
|
+
{ ...flow, cancelled: true },
|
|
134
|
+
{
|
|
135
|
+
pollDeviceToken: async () => tokens(),
|
|
136
|
+
addAccount: async () => {
|
|
137
|
+
added = true
|
|
138
|
+
return { label: 'x' }
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
)
|
|
142
|
+
expect(res.kind).toBe('cancelled')
|
|
143
|
+
expect(added).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('no-refresh-token: refuses to register an un-refreshable account', async () => {
|
|
147
|
+
let added = false
|
|
148
|
+
const res = await runMicrosoftConnectPoll(flow, {
|
|
149
|
+
pollDeviceToken: async () => tokens({ refresh_token: undefined }),
|
|
150
|
+
addAccount: async () => {
|
|
151
|
+
added = true
|
|
152
|
+
return { label: 'x' }
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
expect(res.kind).toBe('no-refresh-token')
|
|
156
|
+
expect(added).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('failed: surfaces a poll error (e.g. a work account rejected at /common)', async () => {
|
|
160
|
+
const res = await runMicrosoftConnectPoll(flow, {
|
|
161
|
+
pollDeviceToken: async () => {
|
|
162
|
+
throw new Error('Token poll failed: AADSTS9001023 work account')
|
|
163
|
+
},
|
|
164
|
+
addAccount: async () => ({ label: 'x' }),
|
|
165
|
+
})
|
|
166
|
+
expect(res.kind).toBe('failed')
|
|
167
|
+
if (res.kind === 'failed') expect(res.message).toContain('AADSTS9001023')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('failed: refuses to register when Microsoft returns no id_token (no account identity)', async () => {
|
|
171
|
+
let added = false
|
|
172
|
+
const res = await runMicrosoftConnectPoll(flow, {
|
|
173
|
+
// Has a refresh token but no id_token → no resolvable email to key
|
|
174
|
+
// the broker account by; must NOT register a label-less account.
|
|
175
|
+
pollDeviceToken: async () => tokens({ id_token: undefined }),
|
|
176
|
+
addAccount: async () => {
|
|
177
|
+
added = true
|
|
178
|
+
return { label: 'x' }
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
expect(res.kind).toBe('failed')
|
|
182
|
+
if (res.kind === 'failed') expect(res.message).toContain('account identity')
|
|
183
|
+
expect(added).toBe(false)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -7,30 +7,30 @@ import {
|
|
|
7
7
|
} from "../gateway/obligation-store.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* REGRESSION GUARD — not the proof.
|
|
11
11
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* The actual determinism argument is closed-form and lives WITH the code: the
|
|
13
|
+
* ledger is a finite FSM with a total transition function and a strictly-
|
|
14
|
+
* decreasing measure μ = (REPRESENT_MAX - representCount) + (ESCALATE_MAX -
|
|
15
|
+
* escalateAttempts) ⇒ every OPEN reaches a terminal (see the proof comment on
|
|
16
|
+
* obligationSweep in gateway.ts and the ledger methods in obligation-ledger.ts).
|
|
17
|
+
* A total state-machine proof also found — and a fix closed — the one liveness
|
|
18
|
+
* hole this kind of SAMPLING test structurally cannot reach: a hung escalation
|
|
19
|
+
* send leaking the in-flight flag (now bounded by withDeadline; guarded by
|
|
20
|
+
* with-deadline.test.ts). The lesson stands: a random-schedule test only
|
|
21
|
+
* exercises the behaviours its model encodes; it is evidence, never the proof.
|
|
16
22
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* Over thousands of random schedules it asserts the invariant holds and the
|
|
30
|
-
* engine always terminates (no infinite loop). The COALESCED PARTIAL-ANSWER
|
|
31
|
-
* residual is deliberately NOT modelled — it is the one honest hard limit (a
|
|
32
|
-
* turn-keyed ledger cannot see "answered half" without parsing model prose) and
|
|
33
|
-
* is mitigated by coalescing policy, not the ledger.
|
|
23
|
+
* What this file still earns its keep doing: drive the REAL ObligationLedger +
|
|
24
|
+
* REAL durable snapshot store over many random {model-behaviour × timing ×
|
|
25
|
+
* restart} schedules to catch a regression that breaks the FSM invariant
|
|
26
|
+
* (no silent drop, no double-ask of an answered message, bounded termination).
|
|
27
|
+
* It models the lifecycle SYNCHRONOUSLY (open at receipt; close at turn_end on a
|
|
28
|
+
* delivered answer; bounded represent→escalate; restart = hydrate from snapshot)
|
|
29
|
+
* — so it does NOT and cannot cover async/coupling liveness (hung send, gate
|
|
30
|
+
* never opening, drain wedging); those are proven/bounded in the code, not here.
|
|
31
|
+
* The coalesced PARTIAL-ANSWER residual is also out of model — the one honest
|
|
32
|
+
* hard limit (a turn-keyed ledger can't see "answered half" without parsing the
|
|
33
|
+
* model's prose), mitigated by coalescing policy, not the ledger.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
// Mirrors the gateway constants under test.
|
|
@@ -89,12 +89,18 @@ interface Sim {
|
|
|
89
89
|
steps: number;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function runSchedule(msgs: Msg[], seed: number): Sim {
|
|
92
|
+
function runSchedule(msgs: Msg[], seed: number, graceMs = 0): Sim {
|
|
93
93
|
const PATH = "/state/agent/telegram/obligations.json";
|
|
94
94
|
const store = memStore();
|
|
95
95
|
let ledger = new ObligationLedger(MAX_REPRESENTS, {
|
|
96
96
|
onChange: (snap) => persistObligations(PATH, store.fs, snap),
|
|
97
97
|
});
|
|
98
|
+
// Virtual monotonic clock (only meaningful when graceMs>0). Advances every
|
|
99
|
+
// step by more than one sweep tick so the grace window deterministically
|
|
100
|
+
// expires within the step budget — proving grace DELAYS but never PREVENTS a
|
|
101
|
+
// terminal (no livelock).
|
|
102
|
+
let clock = 1_000_000;
|
|
103
|
+
const SWEEP_TICK = 5_000;
|
|
98
104
|
const r = rng(seed);
|
|
99
105
|
|
|
100
106
|
const pending = [...msgs]; // not yet received
|
|
@@ -109,11 +115,18 @@ function runSchedule(msgs: Msg[], seed: number): Sim {
|
|
|
109
115
|
};
|
|
110
116
|
|
|
111
117
|
// Run one turn for an obligation; close if the model answers on this attempt.
|
|
118
|
+
// If it does NOT answer and grace is on, stamp the turn-end clock (mirrors the
|
|
119
|
+
// gateway's endCurrentTurnAtomic !finalAnswerDelivered branch) so the next
|
|
120
|
+
// decideAtIdle({now, graceMs}) waits out the grace before re-presenting.
|
|
112
121
|
const deliverTurn = (id: string) => {
|
|
113
122
|
const had = (turnsHad.get(id) ?? 0);
|
|
114
123
|
const attemptIndex = had; // 0-based
|
|
115
124
|
turnsHad.set(id, had + 1);
|
|
116
|
-
if (byId.get(id)!.answerOnAttempt === attemptIndex)
|
|
125
|
+
if (byId.get(id)!.answerOnAttempt === attemptIndex) {
|
|
126
|
+
close(id, "answered");
|
|
127
|
+
} else if (graceMs > 0 && ledger.isOpen(id)) {
|
|
128
|
+
ledger.noteTurnEnded(id, clock);
|
|
129
|
+
}
|
|
117
130
|
};
|
|
118
131
|
|
|
119
132
|
const ESC_IN_FLIGHT = new Set<string>(); // mirrors the gateway's concurrency guard (no-op in a sync model)
|
|
@@ -139,7 +152,14 @@ function runSchedule(msgs: Msg[], seed: number): Sim {
|
|
|
139
152
|
});
|
|
140
153
|
deliverTurn(m.id); // original turn (attempt 0)
|
|
141
154
|
} else if (open) {
|
|
142
|
-
const decision =
|
|
155
|
+
const decision =
|
|
156
|
+
graceMs > 0 ? ledger.decideAtIdle({ now: clock, graceMs }) : ledger.decideAtIdle();
|
|
157
|
+
if (decision.action === "none") {
|
|
158
|
+
// Every open obligation is within its grace window — the sweep waits.
|
|
159
|
+
// Advance the clock so grace deterministically expires; no livelock.
|
|
160
|
+
clock += SWEEP_TICK;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
143
163
|
const o = decision.obligation as Obligation;
|
|
144
164
|
// INVARIANT (no double-ask): a terminated obligation must never resurface.
|
|
145
165
|
expect(terminals.has(o.originTurnId)).toBe(false);
|
|
@@ -169,6 +189,9 @@ function runSchedule(msgs: Msg[], seed: number): Sim {
|
|
|
169
189
|
});
|
|
170
190
|
ledger.hydrate(loadObligations(PATH, store.fs));
|
|
171
191
|
}
|
|
192
|
+
// Advance the virtual clock every step so any stamped grace window
|
|
193
|
+
// deterministically expires within the step budget.
|
|
194
|
+
clock += SWEEP_TICK;
|
|
172
195
|
}
|
|
173
196
|
|
|
174
197
|
return { terminals, steps };
|
|
@@ -220,6 +243,43 @@ describe("obligation determinism — every inbound reaches a terminal, no silent
|
|
|
220
243
|
}
|
|
221
244
|
});
|
|
222
245
|
|
|
246
|
+
it("holds across 3000 schedules WITH the escalate-grace window on (grace delays, never prevents a terminal)", () => {
|
|
247
|
+
const ANSWER = [0, 1, 2, 3, 99];
|
|
248
|
+
const ESCFAIL = [0, 1, 2, 3, 5];
|
|
249
|
+
const GRACE_MS = 45_000;
|
|
250
|
+
for (let seed = 1; seed <= 3000; seed++) {
|
|
251
|
+
const r = rng(seed * 7919);
|
|
252
|
+
const n = 1 + Math.floor(r() * 5);
|
|
253
|
+
const msgs: Msg[] = [];
|
|
254
|
+
for (let i = 0; i < n; i++) {
|
|
255
|
+
const msgId = seed * 100 + i;
|
|
256
|
+
msgs.push({
|
|
257
|
+
id: `c:3#${msgId}`,
|
|
258
|
+
msgId,
|
|
259
|
+
answerOnAttempt: pick(ANSWER, r),
|
|
260
|
+
escalateFailsFor: pick(ESCFAIL, r),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
// Same enumeration as the no-grace proof, but the ledger now runs the grace
|
|
264
|
+
// path: every non-answering turn stamps noteTurnEnded and the sweep waits
|
|
265
|
+
// out the window before acting. The terminal each message reaches must be
|
|
266
|
+
// IDENTICAL to the no-grace run — grace only delays.
|
|
267
|
+
const { terminals, steps } = runSchedule(msgs, seed * 104729, GRACE_MS);
|
|
268
|
+
expect(steps).toBeLessThan(10_000); // still terminates (no grace livelock)
|
|
269
|
+
for (const m of msgs) {
|
|
270
|
+
const t = terminals.get(m.id);
|
|
271
|
+
expect(t, `grace seed=${seed} msg=${m.id} answer=${m.answerOnAttempt} escFail=${m.escalateFailsFor}`).toBeDefined();
|
|
272
|
+
if (m.answerOnAttempt <= MAX_REPRESENTS) {
|
|
273
|
+
expect(t).toBe("answered");
|
|
274
|
+
} else if (m.escalateFailsFor < ESCALATE_MAX) {
|
|
275
|
+
expect(t).toBe("escalation-delivered");
|
|
276
|
+
} else {
|
|
277
|
+
expect(t).toBe("escalation-give-up");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
223
283
|
it("a delivered-but-unanswered obligation survives a restart and is escalated, not lost", () => {
|
|
224
284
|
// Deterministic single case: model NEVER answers, escalation succeeds first try,
|
|
225
285
|
// with a restart forced mid-life via a seed that triggers the 0.15 branch.
|
|
@@ -225,6 +225,20 @@ describe("ObligationLedger — durability hooks + escalate-attempt counter", ()
|
|
|
225
225
|
expect(L.decideAtIdle().action).toBe("escalate");
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
+
it("interrupt-cancel semantics: closing the in-flight turn's obligation removes it from the sweep, sibling untouched", () => {
|
|
229
|
+
// Mirrors cancelInterruptedObligation: an `!` interrupt SIGINT-kills the
|
|
230
|
+
// in-flight turn and closes its obligation, so the sweep can't later
|
|
231
|
+
// re-present/escalate the question the user explicitly redirected away from.
|
|
232
|
+
// A queued SIBLING obligation must survive.
|
|
233
|
+
const L = new ObligationLedger();
|
|
234
|
+
L.openIfAbsent(input("c:3#700", 1000)); // the in-flight turn's message
|
|
235
|
+
L.openIfAbsent(input("c:3#701", 1001)); // a queued sibling
|
|
236
|
+
expect(L.close("c:3#700")).toBe(true); // interrupt cancels the in-flight one
|
|
237
|
+
expect(L.isOpen("c:3#700")).toBe(false);
|
|
238
|
+
expect(L.decideAtIdle().obligation?.originTurnId).toBe("c:3#701"); // sibling still actionable
|
|
239
|
+
expect(L.close("c:3#700")).toBe(false); // re-close / unknown is a safe no-op
|
|
240
|
+
});
|
|
241
|
+
|
|
228
242
|
it("hydrate skips malformed rows", () => {
|
|
229
243
|
const L = new ObligationLedger();
|
|
230
244
|
L.hydrate([
|
|
@@ -234,3 +248,81 @@ describe("ObligationLedger — durability hooks + escalate-attempt counter", ()
|
|
|
234
248
|
expect(L.size()).toBe(1);
|
|
235
249
|
});
|
|
236
250
|
});
|
|
251
|
+
|
|
252
|
+
describe("ObligationLedger — escalate-grace window (over-escalation fix)", () => {
|
|
253
|
+
function input(id: string, openedAt: number) {
|
|
254
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
it("no opts (or graceMs<=0) → pre-grace behaviour: acts immediately", () => {
|
|
258
|
+
const L = new ObligationLedger();
|
|
259
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
260
|
+
L.noteTurnEnded("c:3#1", 5000); // a turn just ended
|
|
261
|
+
expect(L.decideAtIdle().action).toBe("represent"); // no grace → act now
|
|
262
|
+
expect(L.decideAtIdle({ now: 5001, graceMs: 0 }).action).toBe("represent"); // graceMs 0 → off
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("skips an obligation whose turn ended < graceMs ago (the trailing-answer window)", () => {
|
|
266
|
+
const L = new ObligationLedger();
|
|
267
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
268
|
+
L.noteTurnEnded("c:3#1", 5000);
|
|
269
|
+
// 30s after turn-end, grace 45s → still within grace → wait (none)
|
|
270
|
+
expect(L.decideAtIdle({ now: 35000, graceMs: 45000 }).action).toBe("none");
|
|
271
|
+
// 46s after turn-end → out of grace → act
|
|
272
|
+
expect(L.decideAtIdle({ now: 51000, graceMs: 45000 }).action).toBe("represent");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("an obligation that never had a turn end (still-queued) is always eligible — no trailing answer to wait for", () => {
|
|
276
|
+
const L = new ObligationLedger();
|
|
277
|
+
L.openIfAbsent(input("c:3#1", 1000)); // no noteTurnEnded
|
|
278
|
+
expect(L.decideAtIdle({ now: 1001, graceMs: 45000 }).action).toBe("represent");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("picks the oldest ELIGIBLE: a newer in-grace obligation does not block an older out-of-grace one", () => {
|
|
282
|
+
const L = new ObligationLedger();
|
|
283
|
+
L.openIfAbsent(input("c:3#1", 1000)); // older
|
|
284
|
+
L.openIfAbsent(input("c:3#2", 2000)); // newer
|
|
285
|
+
L.noteTurnEnded("c:3#1", 5000); // older's turn ended at 5000 (out of grace by now)
|
|
286
|
+
L.noteTurnEnded("c:3#2", 60000); // newer's turn ended at 60000 (in grace)
|
|
287
|
+
const d = L.decideAtIdle({ now: 70000, graceMs: 45000 });
|
|
288
|
+
// older (#1) ended 65s ago → eligible; newer (#2) ended 10s ago → in grace.
|
|
289
|
+
// pick oldest eligible = #1.
|
|
290
|
+
expect(d.action).toBe("represent");
|
|
291
|
+
expect(d.obligation?.originTurnId).toBe("c:3#1");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("returns none when EVERY open obligation is within grace", () => {
|
|
295
|
+
const L = new ObligationLedger();
|
|
296
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
297
|
+
L.openIfAbsent(input("c:3#2", 2000));
|
|
298
|
+
L.noteTurnEnded("c:3#1", 60000);
|
|
299
|
+
L.noteTurnEnded("c:3#2", 61000);
|
|
300
|
+
expect(L.decideAtIdle({ now: 65000, graceMs: 45000 }).action).toBe("none");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("noteTurnEnded is a no-op for an unknown/closed obligation and persists when it applies", () => {
|
|
304
|
+
const snapshots: Obligation[][] = [];
|
|
305
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
306
|
+
L.openIfAbsent(input("c:3#1", 1000)); // persist #1
|
|
307
|
+
L.noteTurnEnded("nope", 5000); // unknown → no persist
|
|
308
|
+
expect(snapshots.length).toBe(1);
|
|
309
|
+
L.noteTurnEnded("c:3#1", 5000); // applies → persist
|
|
310
|
+
expect(snapshots.length).toBe(2);
|
|
311
|
+
expect(snapshots[1][0].lastTurnEndedAt).toBe(5000);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("grace still terminates the ladder: represent → escalate once each is out of grace", () => {
|
|
315
|
+
const L = new ObligationLedger(2);
|
|
316
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
317
|
+
// turn 0 ended at 1000; out of grace by t=50000
|
|
318
|
+
L.noteTurnEnded("c:3#1", 1000);
|
|
319
|
+
expect(L.decideAtIdle({ now: 50000, graceMs: 45000 }).action).toBe("represent");
|
|
320
|
+
L.markRepresented("c:3#1");
|
|
321
|
+
L.noteTurnEnded("c:3#1", 50000); // re-present turn ended
|
|
322
|
+
expect(L.decideAtIdle({ now: 96000, graceMs: 45000 }).action).toBe("represent");
|
|
323
|
+
L.markRepresented("c:3#1");
|
|
324
|
+
L.noteTurnEnded("c:3#1", 96000);
|
|
325
|
+
// representCount now 2 == max → escalate (once out of grace)
|
|
326
|
+
expect(L.decideAtIdle({ now: 142000, graceMs: 45000 }).action).toBe("escalate");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { withDeadline } from "../gateway/with-deadline.js";
|
|
3
|
+
|
|
4
|
+
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
|
|
6
|
+
describe("withDeadline — bounds the obligation escalation send so a hang can't leak the in-flight flag", () => {
|
|
7
|
+
it("resolves with the inner value when the promise settles before the deadline", async () => {
|
|
8
|
+
await expect(withDeadline(Promise.resolve("ok"), 1000, "timed out")).resolves.toBe("ok");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("rejects with the inner error when the promise rejects before the deadline", async () => {
|
|
12
|
+
await expect(withDeadline(Promise.reject(new Error("boom")), 1000, "timed out")).rejects.toThrow(
|
|
13
|
+
"boom",
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("rejects with the timeout message when the promise NEVER settles (the hang case)", async () => {
|
|
18
|
+
// The whole point: a promise that never resolves/rejects (a stalled send)
|
|
19
|
+
// must still settle the chain so the caller's .finally clears the in-flight flag.
|
|
20
|
+
const neverSettles = new Promise<string>(() => {});
|
|
21
|
+
await expect(withDeadline(neverSettles, 20, "obligation escalation send timed out")).rejects.toThrow(
|
|
22
|
+
"obligation escalation send timed out",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("the .finally chained after it ALWAYS runs even when the inner promise hangs", async () => {
|
|
27
|
+
// This mirrors the gateway's obligationEscalateInFlight clear: it lives in a
|
|
28
|
+
// .finally on withDeadline(...), and must fire within the deadline regardless.
|
|
29
|
+
let flagCleared = false;
|
|
30
|
+
await withDeadline(new Promise<void>(() => {}), 20, "timed out")
|
|
31
|
+
.catch(() => {})
|
|
32
|
+
.finally(() => {
|
|
33
|
+
flagCleared = true;
|
|
34
|
+
});
|
|
35
|
+
expect(flagCleared).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("a hung-then-late-rejecting inner promise does not produce an unhandled rejection", async () => {
|
|
39
|
+
const seen: unknown[] = [];
|
|
40
|
+
const onUnhandled = (reason: unknown) => seen.push(reason);
|
|
41
|
+
process.on("unhandledRejection", onUnhandled);
|
|
42
|
+
try {
|
|
43
|
+
const lateReject = new Promise<void>((_, reject) => {
|
|
44
|
+
setTimeout(() => reject(new Error("late-zombie-rejection")), 30);
|
|
45
|
+
});
|
|
46
|
+
// withDeadline rejects at 10ms; the inner promise rejects later at 30ms.
|
|
47
|
+
await withDeadline(lateReject, 10, "timed out").catch(() => {});
|
|
48
|
+
await tick(60); // let the late rejection fire
|
|
49
|
+
} finally {
|
|
50
|
+
process.off("unhandledRejection", onUnhandled);
|
|
51
|
+
}
|
|
52
|
+
expect(seen.some((r) => r instanceof Error && r.message === "late-zombie-rejection")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("clears its timer on a fast settle (no dangling work keeps the loop alive)", async () => {
|
|
56
|
+
// Sanity: a fast resolve settles immediately, not after the deadline.
|
|
57
|
+
const start = Date.now();
|
|
58
|
+
await withDeadline(Promise.resolve(1), 5000, "timed out");
|
|
59
|
+
expect(Date.now() - start).toBeLessThan(500);
|
|
60
|
+
});
|
|
61
|
+
});
|