switchroom 0.18.10 → 0.18.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/agent-scheduler/index.js +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2636 -1369
- package/dist/cli/ui/apple-touch-icon.png +0 -0
- package/dist/cli/ui/favicon-32.png +0 -0
- package/dist/cli/ui/favicon.ico +0 -0
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +1248 -342
- package/dist/vault/approvals/kernel-server.js +54 -13
- package/dist/vault/broker/server.js +163 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3603 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -16,6 +16,11 @@ import {
|
|
|
16
16
|
wrapAgentCallbacks,
|
|
17
17
|
parseAgentCallback,
|
|
18
18
|
validateAndWrapAgentKeyboard,
|
|
19
|
+
extractAgentButtonMeta,
|
|
20
|
+
buildButtonConfirmation,
|
|
21
|
+
resolveTapAnnotation,
|
|
22
|
+
escapeHtmlEntities,
|
|
23
|
+
applyTapAnnotationEdit,
|
|
19
24
|
} from '../inline-keyboard-callbacks.js'
|
|
20
25
|
|
|
21
26
|
describe('inline-keyboard-callbacks (#271)', () => {
|
|
@@ -147,4 +152,283 @@ describe('inline-keyboard-callbacks (#271)', () => {
|
|
|
147
152
|
}
|
|
148
153
|
})
|
|
149
154
|
})
|
|
155
|
+
|
|
156
|
+
// ─── #789 button-choice-confirmation ("✅ You chose: X") ────────────────
|
|
157
|
+
describe('inline_keyboard_confirm meta plumbing (#789)', () => {
|
|
158
|
+
it('captures inline_keyboard_confirm into the per-button meta map', () => {
|
|
159
|
+
const meta = extractAgentButtonMeta([
|
|
160
|
+
[{ text: 'Approve', callback_data: 'ok', inline_keyboard_confirm: true }],
|
|
161
|
+
[{ text: 'Hold', callback_data: 'hold', inline_keyboard_confirm: false }],
|
|
162
|
+
])
|
|
163
|
+
expect(meta.get('ok')).toEqual({ inline_keyboard_confirm: true })
|
|
164
|
+
expect(meta.get('hold')).toEqual({ inline_keyboard_confirm: false })
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('strips inline_keyboard_confirm from the wrapped Telegram payload', () => {
|
|
168
|
+
const wrapped = wrapAgentCallbacks([
|
|
169
|
+
[{ text: 'Approve', callback_data: 'ok', inline_keyboard_confirm: true }],
|
|
170
|
+
])
|
|
171
|
+
expect(wrapped[0]![0]).toEqual({ text: 'Approve', callback_data: 'agent:ok' })
|
|
172
|
+
expect('inline_keyboard_confirm' in wrapped[0]![0]!).toBe(false)
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
describe('buildButtonConfirmation (#789)', () => {
|
|
177
|
+
// Fixed clock: 14:05 local. Use a UTC-explicit date and read via the
|
|
178
|
+
// matching timezone accessor so the assertion is host-TZ independent.
|
|
179
|
+
const fixedUtc = new Date('2026-07-11T14:05:00Z')
|
|
180
|
+
|
|
181
|
+
it('renders the default template with a bold, escaped label + HH:MM (utc)', () => {
|
|
182
|
+
const line = buildButtonConfirmation({
|
|
183
|
+
template: '✅ You chose: {label} · {time}',
|
|
184
|
+
label: 'Approve',
|
|
185
|
+
timezone: 'utc',
|
|
186
|
+
escapeLabel: (s) => s,
|
|
187
|
+
now: fixedUtc,
|
|
188
|
+
})
|
|
189
|
+
expect(line).toBe('✅ You chose: <b>Approve</b> · 14:05')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('collapses newlines in the label to a single line', () => {
|
|
193
|
+
const line = buildButtonConfirmation({
|
|
194
|
+
template: '✅ You chose: {label} · {time}',
|
|
195
|
+
label: 'Line one\nLine two',
|
|
196
|
+
timezone: 'utc',
|
|
197
|
+
escapeLabel: (s) => s,
|
|
198
|
+
now: fixedUtc,
|
|
199
|
+
})
|
|
200
|
+
expect(line).toBe('✅ You chose: <b>Line one Line two</b> · 14:05')
|
|
201
|
+
expect(line).not.toContain('\n')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('trims a long label to 60 chars and applies the injected escaper', () => {
|
|
205
|
+
const line = buildButtonConfirmation({
|
|
206
|
+
template: '{label}',
|
|
207
|
+
label: 'x'.repeat(80),
|
|
208
|
+
timezone: 'utc',
|
|
209
|
+
escapeLabel: (s) => `[esc:${s.length}]`,
|
|
210
|
+
now: fixedUtc,
|
|
211
|
+
})
|
|
212
|
+
expect(line).toBe('<b>[esc:60]</b>')
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('resolveTapAnnotation (#789)', () => {
|
|
217
|
+
const escapeLabel = (s: string) => s
|
|
218
|
+
const now = new Date('2026-07-11T09:30:00Z')
|
|
219
|
+
const base = {
|
|
220
|
+
escapeLabel,
|
|
221
|
+
now,
|
|
222
|
+
parseMode: 'html' as const,
|
|
223
|
+
singleUse: true,
|
|
224
|
+
sourceText: 'Deploy to prod?',
|
|
225
|
+
label: 'Approve',
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
it('annotates when the agent default is ON and the keyboard is single-use', () => {
|
|
229
|
+
const r = resolveTapAnnotation({
|
|
230
|
+
...base,
|
|
231
|
+
config: { enabled: true, timezone: 'utc' },
|
|
232
|
+
})
|
|
233
|
+
expect(r.annotate).toBe(true)
|
|
234
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
235
|
+
expect(r.warnParseMode).toBe(false)
|
|
236
|
+
expect(r.warnSingleUseMismatch).toBe(false)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('per-message inline_keyboard_confirm:false overrides an ON agent default', () => {
|
|
240
|
+
const r = resolveTapAnnotation({
|
|
241
|
+
...base,
|
|
242
|
+
config: { enabled: true, timezone: 'utc' },
|
|
243
|
+
perMessageOverride: false,
|
|
244
|
+
})
|
|
245
|
+
expect(r.annotate).toBe(false)
|
|
246
|
+
expect(r.text).toBeUndefined()
|
|
247
|
+
expect(r.warnParseMode).toBe(false)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('per-message inline_keyboard_confirm:true fires even when the default is OFF', () => {
|
|
251
|
+
const r = resolveTapAnnotation({
|
|
252
|
+
...base,
|
|
253
|
+
config: { enabled: false, timezone: 'utc' },
|
|
254
|
+
perMessageOverride: true,
|
|
255
|
+
})
|
|
256
|
+
expect(r.annotate).toBe(true)
|
|
257
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('replaces (not duplicates) a prior annotation on retap', () => {
|
|
261
|
+
const priorBody = 'Deploy to prod?\n\n✅ You chose: <b>Hold</b> · 08:15'
|
|
262
|
+
const r = resolveTapAnnotation({
|
|
263
|
+
...base,
|
|
264
|
+
sourceText: priorBody,
|
|
265
|
+
config: { enabled: true, timezone: 'utc' },
|
|
266
|
+
})
|
|
267
|
+
expect(r.annotate).toBe(true)
|
|
268
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
269
|
+
// Exactly one confirmation line survives.
|
|
270
|
+
expect((r.text!.match(/✅ You chose:/g) ?? []).length).toBe(1)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('strips a prior annotation whose label carried a stray newline (dotAll)', () => {
|
|
274
|
+
const priorBody = 'Deploy to prod?\n\n✅ You chose: <b>Hold\nnow</b> · 08:15'
|
|
275
|
+
const r = resolveTapAnnotation({
|
|
276
|
+
...base,
|
|
277
|
+
sourceText: priorBody,
|
|
278
|
+
config: { enabled: true, timezone: 'utc' },
|
|
279
|
+
})
|
|
280
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('skips annotation + flags parseMode warning on a non-html parse mode', () => {
|
|
284
|
+
const r = resolveTapAnnotation({
|
|
285
|
+
...base,
|
|
286
|
+
parseMode: 'markdownv2',
|
|
287
|
+
config: { enabled: true, timezone: 'utc' },
|
|
288
|
+
})
|
|
289
|
+
expect(r.annotate).toBe(false)
|
|
290
|
+
expect(r.warnParseMode).toBe(true)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('never annotates a re-tappable (single_use:false) keyboard, flags mismatch', () => {
|
|
294
|
+
const r = resolveTapAnnotation({
|
|
295
|
+
...base,
|
|
296
|
+
singleUse: false,
|
|
297
|
+
config: { enabled: true, timezone: 'utc' },
|
|
298
|
+
})
|
|
299
|
+
expect(r.annotate).toBe(false)
|
|
300
|
+
expect(r.warnSingleUseMismatch).toBe(true)
|
|
301
|
+
// No parseMode warning — we bail on single-use before the parse gate.
|
|
302
|
+
expect(r.warnParseMode).toBe(false)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('skips annotation when the source message has no text (caption-only)', () => {
|
|
306
|
+
const r = resolveTapAnnotation({
|
|
307
|
+
...base,
|
|
308
|
+
sourceText: undefined,
|
|
309
|
+
config: { enabled: true, timezone: 'utc' },
|
|
310
|
+
})
|
|
311
|
+
expect(r.annotate).toBe(false)
|
|
312
|
+
expect(r.warnParseMode).toBe(false)
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// ─── real HTML-entity escaping (#789 round-2 blocker) ────────────────────
|
|
317
|
+
describe('escapeHtmlEntities + real-escaper wiring (#789)', () => {
|
|
318
|
+
const now = new Date('2026-07-11T09:30:00Z')
|
|
319
|
+
|
|
320
|
+
it('escapes exactly &, <, > — and nothing else (no markdown garbling)', () => {
|
|
321
|
+
expect(escapeHtmlEntities('a & b < c > d')).toBe('a & b < c > d')
|
|
322
|
+
// The GFM escaper would turn Do_it into Do\_it; the HTML escaper must not.
|
|
323
|
+
expect(escapeHtmlEntities('Do_it `now` *bold*')).toBe('Do_it `now` *bold*')
|
|
324
|
+
// & first: no double-escaping of produced entities.
|
|
325
|
+
expect(escapeHtmlEntities('<')).toBe('&lt;')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('is the DEFAULT escaper: labels with & < > render Telegram-parseable HTML', () => {
|
|
329
|
+
const line = buildButtonConfirmation({
|
|
330
|
+
template: '✅ You chose: {label} · {time}',
|
|
331
|
+
label: 'Yes & <no>',
|
|
332
|
+
timezone: 'utc',
|
|
333
|
+
now,
|
|
334
|
+
})
|
|
335
|
+
expect(line).toBe('✅ You chose: <b>Yes & <no></b> · 09:30')
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('escapes the BASE source text too, not just the label', () => {
|
|
339
|
+
const r = resolveTapAnnotation({
|
|
340
|
+
parseMode: 'html',
|
|
341
|
+
singleUse: true,
|
|
342
|
+
sourceText: 'Deploy <feature/x> & friends?',
|
|
343
|
+
label: 'Approve',
|
|
344
|
+
config: { enabled: true, timezone: 'utc' },
|
|
345
|
+
now,
|
|
346
|
+
})
|
|
347
|
+
expect(r.annotate).toBe(true)
|
|
348
|
+
expect(r.text).toBe(
|
|
349
|
+
'Deploy <feature/x> & friends?\n\n✅ You chose: <b>Approve</b> · 09:30',
|
|
350
|
+
)
|
|
351
|
+
// Only the tags WE emit remain — no raw user-supplied angle brackets.
|
|
352
|
+
expect(r.text!.replace(/<\/?b>/g, '')).not.toMatch(/[<>]/)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('retap dedup matches the annotation as Telegram returns it (plain text, no <b> tags)', () => {
|
|
356
|
+
// Telegram's message.text is plain text — entities are separate — so a
|
|
357
|
+
// real retap sees the prior line WITHOUT tags.
|
|
358
|
+
const r = resolveTapAnnotation({
|
|
359
|
+
parseMode: 'html',
|
|
360
|
+
singleUse: true,
|
|
361
|
+
sourceText: 'Deploy to prod?\n\n✅ You chose: Hold · 08:15',
|
|
362
|
+
label: 'Approve',
|
|
363
|
+
config: { enabled: true, timezone: 'utc' },
|
|
364
|
+
now,
|
|
365
|
+
})
|
|
366
|
+
expect(r.text).toBe('Deploy to prod?\n\n✅ You chose: <b>Approve</b> · 09:30')
|
|
367
|
+
expect((r.text!.match(/✅ You chose:/g) ?? []).length).toBe(1)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('String.replace special patterns ($&, $\') in labels are inert', () => {
|
|
371
|
+
const line = buildButtonConfirmation({
|
|
372
|
+
template: '✅ You chose: {label} · {time}',
|
|
373
|
+
label: "$& $' $` $1",
|
|
374
|
+
timezone: 'utc',
|
|
375
|
+
now,
|
|
376
|
+
})
|
|
377
|
+
expect(line).toBe("✅ You chose: <b>$& $' $` $1</b> · 09:30")
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
// ─── keyboard-strip fallback when the annotate edit rejects (#789) ───────
|
|
382
|
+
describe('applyTapAnnotationEdit (#789)', () => {
|
|
383
|
+
it('annotates via a single editMessageText call on success', async () => {
|
|
384
|
+
const calls: string[] = []
|
|
385
|
+
const outcome = await applyTapAnnotationEdit(
|
|
386
|
+
{
|
|
387
|
+
editMessageText: async (text, other) => {
|
|
388
|
+
calls.push(`text:${text}:${other.parse_mode}`)
|
|
389
|
+
expect(other.reply_markup).toEqual({ inline_keyboard: [] })
|
|
390
|
+
},
|
|
391
|
+
editMessageReplyMarkup: async () => {
|
|
392
|
+
calls.push('markup')
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
'body\n\n✅ You chose: <b>Approve</b> · 09:30',
|
|
396
|
+
)
|
|
397
|
+
expect(outcome).toBe('annotated')
|
|
398
|
+
expect(calls).toEqual(['text:body\n\n✅ You chose: <b>Approve</b> · 09:30:HTML'])
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('strips the keyboard via editMessageReplyMarkup when the edit rejects', async () => {
|
|
402
|
+
const calls: unknown[] = []
|
|
403
|
+
const outcome = await applyTapAnnotationEdit(
|
|
404
|
+
{
|
|
405
|
+
editMessageText: async () => {
|
|
406
|
+
throw new Error('400: Bad Request: can\'t parse entities')
|
|
407
|
+
},
|
|
408
|
+
editMessageReplyMarkup: async other => {
|
|
409
|
+
calls.push(other)
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
'whatever',
|
|
413
|
+
)
|
|
414
|
+
expect(outcome).toBe('stripped-fallback')
|
|
415
|
+
// Single-use protection held: the keyboard WAS stripped.
|
|
416
|
+
expect(calls).toEqual([{ reply_markup: { inline_keyboard: [] } }])
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('reports failed (without throwing) when both edits reject', async () => {
|
|
420
|
+
const outcome = await applyTapAnnotationEdit(
|
|
421
|
+
{
|
|
422
|
+
editMessageText: async () => {
|
|
423
|
+
throw new Error('400')
|
|
424
|
+
},
|
|
425
|
+
editMessageReplyMarkup: async () => {
|
|
426
|
+
throw new Error('400')
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
'whatever',
|
|
430
|
+
)
|
|
431
|
+
expect(outcome).toBe('failed')
|
|
432
|
+
})
|
|
433
|
+
})
|
|
150
434
|
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2975 Stage 2 — gateway-side IPC surface for `check_pre_approved`, the
|
|
3
|
+
* read-only pre-approval query hostd sends before applying the
|
|
4
|
+
* `config_propose_edit` rate limit.
|
|
5
|
+
*
|
|
6
|
+
* Exercises the REAL createIpcServer over a real (tmp) unix socket with a raw
|
|
7
|
+
* `net.createConnection` client — the same hostd → gateway approval-gateway
|
|
8
|
+
* transport. Pins:
|
|
9
|
+
* - wire-shape validation (validateClientMessage);
|
|
10
|
+
* - the wired handler is invoked and its `pre_approved_result` relayed;
|
|
11
|
+
* - FAIL CLOSED: no handler wired (old gateway / mixed-version) → the server
|
|
12
|
+
* itself replies `preApproved: false`.
|
|
13
|
+
*
|
|
14
|
+
* The message is hostd → gateway over the approval-gateway socket; it is NOT
|
|
15
|
+
* reachable via any agent-facing hostd verb (there is no hostd op that emits
|
|
16
|
+
* it), and the handler treats it as read-only.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
19
|
+
import { createConnection } from "node:net";
|
|
20
|
+
import { mkdtempSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import {
|
|
24
|
+
createIpcServer,
|
|
25
|
+
validateClientMessage,
|
|
26
|
+
type IpcServer,
|
|
27
|
+
type IpcClient,
|
|
28
|
+
} from "../gateway/ipc-server.js";
|
|
29
|
+
import type { CheckPreApprovedMessage } from "../gateway/ipc-protocol.js";
|
|
30
|
+
|
|
31
|
+
function tmpSocket(): string {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "ipc-cpa-test-"));
|
|
33
|
+
return join(dir, "test.sock");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function wait(ms: number): Promise<void> {
|
|
37
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
function queryOnce(
|
|
42
|
+
socketPath: string,
|
|
43
|
+
agentName: string,
|
|
44
|
+
correlationId: string,
|
|
45
|
+
unifiedDiff: string,
|
|
46
|
+
): Promise<any> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const sock = createConnection(socketPath);
|
|
49
|
+
let buffer = "";
|
|
50
|
+
sock.on("connect", () => {
|
|
51
|
+
sock.write(
|
|
52
|
+
JSON.stringify({
|
|
53
|
+
type: "check_pre_approved",
|
|
54
|
+
agentName,
|
|
55
|
+
correlationId,
|
|
56
|
+
unifiedDiff,
|
|
57
|
+
} as CheckPreApprovedMessage) + "\n",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
sock.on("data", (chunk) => {
|
|
61
|
+
buffer += chunk.toString("utf8");
|
|
62
|
+
const idx = buffer.indexOf("\n");
|
|
63
|
+
if (idx >= 0) {
|
|
64
|
+
sock.end();
|
|
65
|
+
resolve(JSON.parse(buffer.slice(0, idx)));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
sock.on("error", reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("validateClientMessage — check_pre_approved (#2975 Stage 2)", () => {
|
|
73
|
+
it("accepts a well-formed query", () => {
|
|
74
|
+
expect(
|
|
75
|
+
validateClientMessage({
|
|
76
|
+
type: "check_pre_approved",
|
|
77
|
+
agentName: "klanker",
|
|
78
|
+
correlationId: "abc",
|
|
79
|
+
unifiedDiff: "--- a\n+++ b\n",
|
|
80
|
+
}),
|
|
81
|
+
).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects missing/malformed agentName", () => {
|
|
85
|
+
expect(
|
|
86
|
+
validateClientMessage({ type: "check_pre_approved", correlationId: "abc", unifiedDiff: "x" }),
|
|
87
|
+
).toBe(false);
|
|
88
|
+
expect(
|
|
89
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "Bad Name", correlationId: "abc", unifiedDiff: "x" }),
|
|
90
|
+
).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects missing/oversized correlationId", () => {
|
|
94
|
+
expect(
|
|
95
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", unifiedDiff: "x" }),
|
|
96
|
+
).toBe(false);
|
|
97
|
+
expect(
|
|
98
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", correlationId: "", unifiedDiff: "x" }),
|
|
99
|
+
).toBe(false);
|
|
100
|
+
expect(
|
|
101
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", correlationId: "x".repeat(65), unifiedDiff: "x" }),
|
|
102
|
+
).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects an empty/missing unifiedDiff", () => {
|
|
106
|
+
expect(
|
|
107
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", correlationId: "abc" }),
|
|
108
|
+
).toBe(false);
|
|
109
|
+
expect(
|
|
110
|
+
validateClientMessage({ type: "check_pre_approved", agentName: "klanker", correlationId: "abc", unifiedDiff: "" }),
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("ipc-server — onCheckPreApproved handler (#2975 Stage 2)", () => {
|
|
116
|
+
const servers: IpcServer[] = [];
|
|
117
|
+
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
for (const s of servers) await s.close();
|
|
120
|
+
servers.length = 0;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("fails CLOSED (preApproved:false) when no handler is wired — mixed-version-safe default", async () => {
|
|
124
|
+
const socketPath = tmpSocket();
|
|
125
|
+
const server = createIpcServer({
|
|
126
|
+
socketPath,
|
|
127
|
+
onClientRegistered: () => {},
|
|
128
|
+
onClientDisconnected: () => {},
|
|
129
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
130
|
+
onSessionEvent: () => {},
|
|
131
|
+
onPermissionRequest: () => {},
|
|
132
|
+
onHeartbeat: () => {},
|
|
133
|
+
onScheduleRestart: () => {},
|
|
134
|
+
// onCheckPreApproved intentionally omitted.
|
|
135
|
+
});
|
|
136
|
+
servers.push(server);
|
|
137
|
+
await wait(50);
|
|
138
|
+
|
|
139
|
+
const reply = await queryOnce(socketPath, "klanker", "corr-1", "--- a\n+++ b\n");
|
|
140
|
+
expect(reply).toEqual({ type: "pre_approved_result", correlationId: "corr-1", preApproved: false });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("invokes the wired handler with the query, and relays its reply", async () => {
|
|
144
|
+
const socketPath = tmpSocket();
|
|
145
|
+
let received: CheckPreApprovedMessage | null = null;
|
|
146
|
+
const server = createIpcServer({
|
|
147
|
+
socketPath,
|
|
148
|
+
onClientRegistered: () => {},
|
|
149
|
+
onClientDisconnected: () => {},
|
|
150
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
151
|
+
onSessionEvent: () => {},
|
|
152
|
+
onPermissionRequest: () => {},
|
|
153
|
+
onHeartbeat: () => {},
|
|
154
|
+
onScheduleRestart: () => {},
|
|
155
|
+
onCheckPreApproved: (client: IpcClient, msg: CheckPreApprovedMessage) => {
|
|
156
|
+
received = msg;
|
|
157
|
+
client.send({ type: "pre_approved_result", correlationId: msg.correlationId, preApproved: true });
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
servers.push(server);
|
|
161
|
+
await wait(50);
|
|
162
|
+
|
|
163
|
+
const reply = await queryOnce(socketPath, "klanker", "corr-2", "--- a\n+++ b\n");
|
|
164
|
+
expect(received).toEqual({
|
|
165
|
+
type: "check_pre_approved",
|
|
166
|
+
agentName: "klanker",
|
|
167
|
+
correlationId: "corr-2",
|
|
168
|
+
unifiedDiff: "--- a\n+++ b\n",
|
|
169
|
+
});
|
|
170
|
+
expect(reply).toEqual({ type: "pre_approved_result", correlationId: "corr-2", preApproved: true });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("relays preApproved:false from a wired handler", async () => {
|
|
174
|
+
const socketPath = tmpSocket();
|
|
175
|
+
const server = createIpcServer({
|
|
176
|
+
socketPath,
|
|
177
|
+
onClientRegistered: () => {},
|
|
178
|
+
onClientDisconnected: () => {},
|
|
179
|
+
onToolCall: async () => ({ type: "tool_call_result", id: "x", success: true }),
|
|
180
|
+
onSessionEvent: () => {},
|
|
181
|
+
onPermissionRequest: () => {},
|
|
182
|
+
onHeartbeat: () => {},
|
|
183
|
+
onScheduleRestart: () => {},
|
|
184
|
+
onCheckPreApproved: (client: IpcClient, msg: CheckPreApprovedMessage) => {
|
|
185
|
+
client.send({ type: "pre_approved_result", correlationId: msg.correlationId, preApproved: false });
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
servers.push(server);
|
|
189
|
+
await wait(50);
|
|
190
|
+
|
|
191
|
+
const reply = await queryOnce(socketPath, "klanker", "corr-3", "--- a\n+++ b\n");
|
|
192
|
+
expect(reply).toEqual({ type: "pre_approved_result", correlationId: "corr-3", preApproved: false });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -138,6 +138,129 @@ describe("resolveMentalModelProposal — APPROVE", () => {
|
|
|
138
138
|
});
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
describe("resolveMentalModelProposal — #2975 Stage 1 rate-limit backstop", () => {
|
|
142
|
+
const NOW = 1_700_000_000_000;
|
|
143
|
+
const RETRY_AT = NOW + 55 * 60_000; // hostd's window-open time
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build deps whose dispatchConfigEdit yields `results` in order (one per
|
|
147
|
+
* attempt), plus a manual single-slot scheduler the test fires by hand — a
|
|
148
|
+
* mocked clock, no real timers.
|
|
149
|
+
*/
|
|
150
|
+
function rateDeps(results: Array<Awaited<ReturnType<ResolveDeps["dispatchConfigEdit"]>>>) {
|
|
151
|
+
let call = 0;
|
|
152
|
+
let scheduled: { delayMs: number; fn: () => void | Promise<void> } | null = null;
|
|
153
|
+
const spies = {
|
|
154
|
+
registerPreApproval: vi.fn(),
|
|
155
|
+
clearPreApproval: vi.fn(),
|
|
156
|
+
dispatchConfigEdit: vi.fn(async () => results[Math.min(call++, results.length - 1)]!),
|
|
157
|
+
ensureModel: vi.fn(async () => {}),
|
|
158
|
+
injectInbound: vi.fn(),
|
|
159
|
+
scheduleRetry: vi.fn((delayMs: number, fn: () => void | Promise<void>) => {
|
|
160
|
+
scheduled = { delayMs, fn };
|
|
161
|
+
}),
|
|
162
|
+
editProposalCardRateWindow: vi.fn(),
|
|
163
|
+
notifyPersistFailed: vi.fn(),
|
|
164
|
+
};
|
|
165
|
+
const deps: ResolveDeps = {
|
|
166
|
+
readConfigText: () => CONFIG_NO_MODELS,
|
|
167
|
+
registerPreApproval: spies.registerPreApproval,
|
|
168
|
+
clearPreApproval: spies.clearPreApproval,
|
|
169
|
+
dispatchConfigEdit: spies.dispatchConfigEdit,
|
|
170
|
+
ensureModel: spies.ensureModel,
|
|
171
|
+
injectInbound: spies.injectInbound,
|
|
172
|
+
scheduleRetry: spies.scheduleRetry,
|
|
173
|
+
editProposalCardRateWindow: spies.editProposalCardRateWindow,
|
|
174
|
+
notifyPersistFailed: spies.notifyPersistFailed,
|
|
175
|
+
now: () => NOW,
|
|
176
|
+
};
|
|
177
|
+
return { deps, spies, fireScheduled: () => scheduled!.fn(), getScheduled: () => scheduled };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
it("schedules EXACTLY ONE retry at retry_after, which persists with a byte-identical diff", async () => {
|
|
181
|
+
const { deps, spies, fireScheduled, getScheduled } = rateDeps([
|
|
182
|
+
{ state: "rate_limited", reason: "config_propose_edit rate limit exceeded", retryAtMs: RETRY_AT },
|
|
183
|
+
{ state: "applied" },
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
const out = await resolveMentalModelProposal("approve", pending(), "stage1", "op-42", deps);
|
|
187
|
+
|
|
188
|
+
// The turn is deferred, not lost: a single retry is scheduled at the window.
|
|
189
|
+
expect(out).toEqual({ outcome: "scheduled_retry", retryAtMs: RETRY_AT });
|
|
190
|
+
expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
|
|
191
|
+
expect(getScheduled()!.delayMs).toBe(RETRY_AT - NOW);
|
|
192
|
+
// Card flipped to the "applying at HH:MM" state so the operator sees it.
|
|
193
|
+
expect(spies.editProposalCardRateWindow).toHaveBeenCalledWith(RETRY_AT);
|
|
194
|
+
// First (rate-limited) attempt already registered+cleared its correlation.
|
|
195
|
+
expect(spies.dispatchConfigEdit).toHaveBeenCalledTimes(1);
|
|
196
|
+
const firstDiff = spies.dispatchConfigEdit.mock.calls[0][0].diff;
|
|
197
|
+
expect(spies.registerPreApproval).toHaveBeenCalledWith("coach", firstDiff);
|
|
198
|
+
// Nothing applied yet.
|
|
199
|
+
expect(spies.ensureModel).not.toHaveBeenCalled();
|
|
200
|
+
expect(spies.injectInbound).not.toHaveBeenCalled();
|
|
201
|
+
|
|
202
|
+
// Fire the ONE scheduled retry (mocked clock).
|
|
203
|
+
await fireScheduled();
|
|
204
|
+
|
|
205
|
+
// Persisted on the retry.
|
|
206
|
+
expect(spies.dispatchConfigEdit).toHaveBeenCalledTimes(2);
|
|
207
|
+
const retryDiff = spies.dispatchConfigEdit.mock.calls[1][0].diff;
|
|
208
|
+
// Forge-resistance contract: register + dispatch use IDENTICAL diff bytes,
|
|
209
|
+
// and the retry re-registers the SAME bytes as the first attempt.
|
|
210
|
+
expect(retryDiff).toBe(firstDiff);
|
|
211
|
+
expect(spies.registerPreApproval).toHaveBeenCalledTimes(2);
|
|
212
|
+
expect(spies.registerPreApproval.mock.calls[1]).toEqual(["coach", retryDiff]);
|
|
213
|
+
expect(spies.clearPreApproval).toHaveBeenCalledTimes(2);
|
|
214
|
+
// Applied: ensured + agent woken; NO loud failure.
|
|
215
|
+
expect(spies.ensureModel).toHaveBeenCalledWith(pending().spec);
|
|
216
|
+
expect(spies.injectInbound).toHaveBeenCalledTimes(1);
|
|
217
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
218
|
+
"mental_model_proposal_applied",
|
|
219
|
+
);
|
|
220
|
+
expect(spies.notifyPersistFailed).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("emits a loud operator failure when the single retry ALSO fails — and does NOT re-schedule", async () => {
|
|
224
|
+
const { deps, spies, fireScheduled } = rateDeps([
|
|
225
|
+
{ state: "rate_limited", reason: "rate limit exceeded", retryAtMs: RETRY_AT },
|
|
226
|
+
{ state: "error", reason: "hostd down" },
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
|
|
230
|
+
expect(out.outcome).toBe("scheduled_retry");
|
|
231
|
+
expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
|
|
232
|
+
|
|
233
|
+
await fireScheduled();
|
|
234
|
+
|
|
235
|
+
// The retry failed: loud operator-events notification, exactly once.
|
|
236
|
+
expect(spies.notifyPersistFailed).toHaveBeenCalledTimes(1);
|
|
237
|
+
expect(spies.notifyPersistFailed.mock.calls[0][0]).toContain("hostd down");
|
|
238
|
+
// No second retry — bounded to exactly one.
|
|
239
|
+
expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
|
|
240
|
+
// Agent woken with the failed inbound; nothing ensured.
|
|
241
|
+
expect(spies.ensureModel).not.toHaveBeenCalled();
|
|
242
|
+
expect(spies.injectInbound).toHaveBeenCalledTimes(1);
|
|
243
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
244
|
+
"mental_model_proposal_failed",
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("with no scheduler wired, a rate-limited persist fails loudly instead of silently dropping", async () => {
|
|
249
|
+
const { deps, spies } = rateDeps([
|
|
250
|
+
{ state: "rate_limited", reason: "rate limit exceeded", retryAtMs: RETRY_AT },
|
|
251
|
+
]);
|
|
252
|
+
// Simulate a build without the Stage-1 scheduler dep.
|
|
253
|
+
delete (deps as { scheduleRetry?: unknown }).scheduleRetry;
|
|
254
|
+
|
|
255
|
+
const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
|
|
256
|
+
expect(out).toEqual({ outcome: "failed", reason: "rate limit exceeded" });
|
|
257
|
+
expect(spies.notifyPersistFailed).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
|
|
259
|
+
"mental_model_proposal_failed",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
141
264
|
describe("resolveMentalModelProposal — DENY writes NOTHING", () => {
|
|
142
265
|
it("never reads config, builds a diff, dispatches an edit, or ensures", async () => {
|
|
143
266
|
const { deps, spies } = makeDeps(CONFIG_NO_MODELS);
|
|
@@ -37,7 +37,7 @@ describe('missed-approvals re-offer wiring (#2862)', () => {
|
|
|
37
37
|
|
|
38
38
|
it('appends the miss at TTL auto-deny, anchored to the card origin, gated by the kill switch', () => {
|
|
39
39
|
// Within the pending-permission TTL sweep block.
|
|
40
|
-
const sweep = slice(GATEWAY, '
|
|
40
|
+
const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5400)
|
|
41
41
|
expect(sweep).toContain('if (MISSED_APPROVAL_REOFFER_ENABLED) {')
|
|
42
42
|
expect(sweep).toContain('missedApprovalsStore.add({')
|
|
43
43
|
expect(sweep).toContain('const origin = v.cards[0]')
|
|
@@ -1359,4 +1359,18 @@ describe("isOfflineTrustedModelToken (#3042 blocker 2a)", () => {
|
|
|
1359
1359
|
expect(isOfflineTrustedModelToken("sr-made-up/model")).toBe(false);
|
|
1360
1360
|
expect(isOfflineTrustedModelToken("")).toBe(false);
|
|
1361
1361
|
});
|
|
1362
|
+
|
|
1363
|
+
it("#3043 item 1: accepts case-variant aliases the live path already normalizes (OPUS, Sonnet)", () => {
|
|
1364
|
+
// The live accept path lowercases (isClaudeModel / expandSrAlias) so a
|
|
1365
|
+
// queued `/model OPUS` is accepted live — the persist gate must not then
|
|
1366
|
+
// refuse it with the over-conservative "couldn't verify" card.
|
|
1367
|
+
for (const a of MODEL_ALIASES) {
|
|
1368
|
+
expect(isOfflineTrustedModelToken(a.toUpperCase())).toBe(true);
|
|
1369
|
+
}
|
|
1370
|
+
expect(isOfflineTrustedModelToken("OPUS")).toBe(true);
|
|
1371
|
+
expect(isOfflineTrustedModelToken("Sonnet")).toBe(true);
|
|
1372
|
+
// Curated sr-* alias, upper-cased, still resolves.
|
|
1373
|
+
const [alias] = Object.entries(SR_MODEL_ALIASES)[0];
|
|
1374
|
+
expect(isOfflineTrustedModelToken(alias.toUpperCase())).toBe(true);
|
|
1375
|
+
});
|
|
1362
1376
|
});
|
|
@@ -319,4 +319,25 @@ describe('drainTakenCommands (#3042 blocker 1: an early stop must not drop the r
|
|
|
319
319
|
await drainTakenCommands(batch(), io)
|
|
320
320
|
expect(events).toEqual(['edit:model:persisted:model', 'edit:effort:persisted:effort'])
|
|
321
321
|
})
|
|
322
|
+
|
|
323
|
+
it('#3021: restart-pending is snapshotted at entry → sync loop emptying pendingRestarts mid-drain still defers the SECOND command', async () => {
|
|
324
|
+
// Reproduces the turn-end idle-gate race: the drain is void-dispatched while
|
|
325
|
+
// a synchronous pendingRestarts loop empties the map right after. The first
|
|
326
|
+
// read sees the entry; by the second command the map has been emptied. A
|
|
327
|
+
// naive per-command re-check would let the second command apply live into a
|
|
328
|
+
// session ~100ms from triggerSelfRestart and falsely confirm.
|
|
329
|
+
let restartPresent = true
|
|
330
|
+
const { io, events } = makeIo({
|
|
331
|
+
// takeAll() first read latches true; the sync loop then empties the map,
|
|
332
|
+
// so every subsequent live read returns false.
|
|
333
|
+
restartPending: () => {
|
|
334
|
+
const was = restartPresent
|
|
335
|
+
restartPresent = false // the sync loop deleted the entry after dispatch
|
|
336
|
+
return was
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
await drainTakenCommands(batch(), io)
|
|
340
|
+
// BOTH commands must ride the carriers — the second must NOT apply live.
|
|
341
|
+
expect(events).toEqual(['edit:model:persisted:model', 'edit:effort:persisted:effort'])
|
|
342
|
+
})
|
|
322
343
|
})
|