switchroom 0.19.2 → 0.19.3
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 +2 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -34,8 +34,10 @@ import {
|
|
|
34
34
|
resolveReplyOwnerTurnId,
|
|
35
35
|
decideAnswerLatchSuppression,
|
|
36
36
|
type ReplyOwnerCandidates,
|
|
37
|
+
type AnswerDeliveredLatch,
|
|
37
38
|
} from '../reply-owner-resolve.js'
|
|
38
39
|
import { FlushedTurnSupersedeRegistry, DEFAULT_SUPERSEDE_TTL_MS } from '../flushed-turn-supersede.js'
|
|
40
|
+
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
39
41
|
|
|
40
42
|
const NONE: ReplyOwnerCandidates = {
|
|
41
43
|
liveTurnId: null,
|
|
@@ -129,13 +131,13 @@ describe('Part 1 end-to-end: unified resolver drives the flush supersede', () =>
|
|
|
129
131
|
|
|
130
132
|
describe('decideAnswerLatchSuppression — race backstop (Part 2)', () => {
|
|
131
133
|
it('RACE: a late substantive reply lands in the flush post-fire pre-record ' +
|
|
132
|
-
'window (no supersede record yet) → SUPPRESSED by the latch', () => {
|
|
134
|
+
'window (no supersede record yet) → SUPPRESSED by the flush-armed latch', () => {
|
|
133
135
|
expect(
|
|
134
136
|
decideAnswerLatchSuppression({
|
|
135
137
|
superseded: false,
|
|
136
138
|
replySubstantive: true,
|
|
137
139
|
isLateReply: true,
|
|
138
|
-
ownerAnswerDelivered:
|
|
140
|
+
ownerAnswerDelivered: 'flush',
|
|
139
141
|
}),
|
|
140
142
|
).toBe(true)
|
|
141
143
|
})
|
|
@@ -147,7 +149,7 @@ describe('decideAnswerLatchSuppression — race backstop (Part 2)', () => {
|
|
|
147
149
|
superseded: true,
|
|
148
150
|
replySubstantive: true,
|
|
149
151
|
isLateReply: true,
|
|
150
|
-
ownerAnswerDelivered:
|
|
152
|
+
ownerAnswerDelivered: 'flush',
|
|
151
153
|
}),
|
|
152
154
|
).toBe(false)
|
|
153
155
|
})
|
|
@@ -159,7 +161,7 @@ describe('decideAnswerLatchSuppression — race backstop (Part 2)', () => {
|
|
|
159
161
|
superseded: false,
|
|
160
162
|
replySubstantive: false,
|
|
161
163
|
isLateReply: true,
|
|
162
|
-
ownerAnswerDelivered:
|
|
164
|
+
ownerAnswerDelivered: 'flush',
|
|
163
165
|
}),
|
|
164
166
|
).toBe(false)
|
|
165
167
|
})
|
|
@@ -171,7 +173,7 @@ describe('decideAnswerLatchSuppression — race backstop (Part 2)', () => {
|
|
|
171
173
|
superseded: false,
|
|
172
174
|
replySubstantive: true,
|
|
173
175
|
isLateReply: false,
|
|
174
|
-
ownerAnswerDelivered:
|
|
176
|
+
ownerAnswerDelivered: 'flush',
|
|
175
177
|
}),
|
|
176
178
|
).toBe(false)
|
|
177
179
|
})
|
|
@@ -195,7 +197,7 @@ describe('F1 — flush send failure must NOT suppress the late reply (zero-messa
|
|
|
195
197
|
// latch armed would make a genuine late reply suppress itself → the user gets
|
|
196
198
|
// ZERO messages. The gateway's send-failure catch resets `answerDelivered =
|
|
197
199
|
// false`; these assert the resulting coordination outcome at the pure core.
|
|
198
|
-
const lateSubstantiveReply = (ownerAnswerDelivered:
|
|
200
|
+
const lateSubstantiveReply = (ownerAnswerDelivered: AnswerDeliveredLatch) =>
|
|
199
201
|
decideAnswerLatchSuppression({
|
|
200
202
|
superseded: false,
|
|
201
203
|
replySubstantive: true,
|
|
@@ -205,8 +207,8 @@ describe('F1 — flush send failure must NOT suppress the late reply (zero-messa
|
|
|
205
207
|
|
|
206
208
|
it('WITHOUT the catch-reset (latch still armed) the late reply is suppressed ' +
|
|
207
209
|
'— the zero-message bug', () => {
|
|
208
|
-
// Models the buggy state: flush armed the latch, send failed, latch left
|
|
209
|
-
expect(lateSubstantiveReply(
|
|
210
|
+
// Models the buggy state: flush armed the latch, send failed, latch left armed.
|
|
211
|
+
expect(lateSubstantiveReply('flush')).toBe(true)
|
|
210
212
|
})
|
|
211
213
|
|
|
212
214
|
it('WITH the catch-reset (answerDelivered=false) the late reply DELIVERS', () => {
|
|
@@ -293,15 +295,15 @@ describe('F2 — recency-bound the destructive latest-ended supersede tier', ()
|
|
|
293
295
|
* consumed — the retry takes the no-record branch. Without a latch the retry
|
|
294
296
|
* would ship a fresh B alongside the stale narration A (both visible).
|
|
295
297
|
*
|
|
296
|
-
* The fix latches `ownerTurn.answerDelivered =
|
|
297
|
-
* the retry (resolving the SAME ended owner turn) is caught by
|
|
298
|
+
* The fix latches `ownerTurn.answerDelivered = 'flush'` at record consumption,
|
|
299
|
+
* so the retry (resolving the SAME ended owner turn) is caught by
|
|
298
300
|
* `decideAnswerLatchSuppression` and suppressed — exactly one message survives.
|
|
299
301
|
*/
|
|
300
302
|
describe('reply-flicker edit-in-place — mid-path throw + retry never resurrects the duplicate (L1)', () => {
|
|
301
303
|
const CHAT = '636363'
|
|
302
304
|
|
|
303
305
|
/** Minimal model of the ended owner turn atom the gateway mutates + reads. */
|
|
304
|
-
interface OwnerTurn { turnId: string; answerDelivered:
|
|
306
|
+
interface OwnerTurn { turnId: string; answerDelivered: AnswerDeliveredLatch }
|
|
305
307
|
|
|
306
308
|
/**
|
|
307
309
|
* Drive the two gateway `reply` calls the incident produces, threading the
|
|
@@ -325,8 +327,9 @@ describe('reply-flicker edit-in-place — mid-path throw + retry never resurrect
|
|
|
325
327
|
const d1 = reg.take(CHAT, undefined, { liveTurnId: owner.turnId, now: now + 10 })
|
|
326
328
|
const firstSuperseded = d1.supersede
|
|
327
329
|
if (d1.supersede && setLatchOnSupersede) {
|
|
328
|
-
// The fix: latch at consumption, BEFORE the arg-validation throw.
|
|
329
|
-
|
|
330
|
+
// The fix: latch at consumption, BEFORE the arg-validation throw. Tagged
|
|
331
|
+
// 'flush' (#3426) — a flush record existed for this turn.
|
|
332
|
+
owner.answerDelivered = 'flush'
|
|
330
333
|
}
|
|
331
334
|
// Arg-validation throw fires here (oversized file / invalid keyboard):
|
|
332
335
|
// the correction never runs → A is neither deleted nor edited, B not sent.
|
|
@@ -367,3 +370,244 @@ describe('reply-flicker edit-in-place — mid-path throw + retry never resurrect
|
|
|
367
370
|
expect(r.visible).toEqual(['A(flush-narration)', 'B(retry-reply)'])
|
|
368
371
|
})
|
|
369
372
|
})
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* #3426 — async sub-agent handback silently dropped by a stale answer-delivered
|
|
376
|
+
* latch.
|
|
377
|
+
*
|
|
378
|
+
* ## The incident these tests pin (overlord, chat 12345, turn …#10473)
|
|
379
|
+
*
|
|
380
|
+
* 11:28:00 interim ack `reply` (321 chars — ABOVE the ≥200 substantive
|
|
381
|
+
* floor) lands in the LIVE turn; the gateway records the answer on
|
|
382
|
+
* the turn atom (outbound-send-path no-record branch tail).
|
|
383
|
+
* 11:28:10 turn_end (replyCalled=true, finalAnswer=true); the atom persists
|
|
384
|
+
* in recentTurnsById with endedAt stamped.
|
|
385
|
+
* 11:28:51 the async sub-agent completes and the agent's handback `reply`
|
|
386
|
+
* (1365 chars, GENUINELY DIFFERENT content) lands with NO live
|
|
387
|
+
* gateway turn (a sub-agent completion is not a new inbound, so no
|
|
388
|
+
* new turn atom exists). The owner resolves to the ENDED ack turn
|
|
389
|
+
* via the latest-ended tier (41 s ≤ the 60 s supersede TTL), whose
|
|
390
|
+
* boolean latch was still armed → the handback was suppressed as a
|
|
391
|
+
* "flush duplicate" and a false "deduped" success returned. The
|
|
392
|
+
* user never saw the sub-agent's findings.
|
|
393
|
+
*
|
|
394
|
+
* ## The fix
|
|
395
|
+
*
|
|
396
|
+
* The latch is SOURCE-TAGGED (`AnswerDeliveredLatch`): 'flush' when the
|
|
397
|
+
* turn-flush backstop armed it (the two races the suppression exists for),
|
|
398
|
+
* 'reply' when a normally-delivered reply carried the answer.
|
|
399
|
+
* `decideAnswerLatchSuppression` suppresses a late reply ONLY for a
|
|
400
|
+
* flush-armed latch — a 'reply'-armed latch never suppresses, so the
|
|
401
|
+
* dispatch → ack → turn_end → handback sequence always delivers. Genuine
|
|
402
|
+
* byte-identical replays of the delivered reply remain covered by the
|
|
403
|
+
* content-keyed #546 outbound dedup, asserted below. (Honest bound: the dedup
|
|
404
|
+
* TTL is anchored at reply RECORD time, the latest-ended owner tier at
|
|
405
|
+
* `endedAt` — a replay landing in the gap between those 60 s windows now
|
|
406
|
+
* delivers as a rare duplicate rather than being suppressed; a conscious
|
|
407
|
+
* trade against the silent handback drop. See the `AnswerDeliveredLatch`
|
|
408
|
+
* docblock.)
|
|
409
|
+
*/
|
|
410
|
+
describe('#3426 — async sub-agent handback after an interim-ack turn', () => {
|
|
411
|
+
const CHAT = '12345'
|
|
412
|
+
const ACK_TURN = '12345:_#10473'
|
|
413
|
+
/** ms between turn_end (11:28:10) and the handback landing (11:28:51). */
|
|
414
|
+
const HANDBACK_AGE_MS = 41_000
|
|
415
|
+
|
|
416
|
+
interface OwnerTurn { turnId: string; answerDelivered: AnswerDeliveredLatch }
|
|
417
|
+
|
|
418
|
+
/** The OLD (pre-#3426) decision, reproduced verbatim for the red-on-main
|
|
419
|
+
* contrast: the latch was a plain boolean, so ANY armed latch suppressed a
|
|
420
|
+
* late substantive reply regardless of which path armed it. */
|
|
421
|
+
function oldBooleanLatchSuppression(input: {
|
|
422
|
+
superseded: boolean
|
|
423
|
+
replySubstantive: boolean
|
|
424
|
+
isLateReply: boolean
|
|
425
|
+
ownerAnswerDelivered: boolean
|
|
426
|
+
}): boolean {
|
|
427
|
+
if (input.superseded) return false
|
|
428
|
+
if (!input.replySubstantive) return false
|
|
429
|
+
if (!input.isLateReply) return false
|
|
430
|
+
return input.ownerAnswerDelivered
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Drive the incident timeline through the SAME pure cores the gateway runs.
|
|
435
|
+
* `latchSemantics` toggles the fixed source-tagged decision ('tagged') vs
|
|
436
|
+
* the pre-fix boolean decision ('boolean' — red-on-main contrast).
|
|
437
|
+
* Returns what the user ends up seeing.
|
|
438
|
+
*/
|
|
439
|
+
function runDispatchAckHandback(latchSemantics: 'tagged' | 'boolean'): {
|
|
440
|
+
delivered: string[]
|
|
441
|
+
ownerId: string | null
|
|
442
|
+
handbackSuppressed: boolean
|
|
443
|
+
} {
|
|
444
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
445
|
+
const now = 1_000_000
|
|
446
|
+
const owner: OwnerTurn = { turnId: ACK_TURN, answerDelivered: false }
|
|
447
|
+
const delivered: string[] = []
|
|
448
|
+
|
|
449
|
+
// ── 1. Interim ack lands IN the live turn (substantive: 321 ≥ 200). ──
|
|
450
|
+
// Live turn ⇒ isLateReply=false ⇒ never suppressed; the gateway then
|
|
451
|
+
// arms the latch on the owner turn (outbound-send-path no-record tail).
|
|
452
|
+
const ackSuppressed = decideAnswerLatchSuppression({
|
|
453
|
+
superseded: false,
|
|
454
|
+
replySubstantive: true,
|
|
455
|
+
isLateReply: false, // currentTurn is live at ack time
|
|
456
|
+
ownerAnswerDelivered: owner.answerDelivered,
|
|
457
|
+
})
|
|
458
|
+
expect(ackSuppressed).toBe(false)
|
|
459
|
+
delivered.push('ack("Kicked off a fable researcher…")')
|
|
460
|
+
owner.answerDelivered = 'reply' // the fixed arm site tags the source
|
|
461
|
+
|
|
462
|
+
// ── 2. turn_end — the atom persists (endedAt stamped), latch still armed. ──
|
|
463
|
+
|
|
464
|
+
// ── 3. The async handback lands 41 s later with NO live gateway turn. ──
|
|
465
|
+
// DM ⇒ no origin_turn_id; no quote recovery; the latest-ended tier
|
|
466
|
+
// resolves the ENDED ack turn as owner (41 s ≤ 60 s TTL) — the latch IS
|
|
467
|
+
// genuinely reachable, which is exactly how the incident happened.
|
|
468
|
+
const ownerId = resolveReplyOwnerTurnId({
|
|
469
|
+
liveTurnId: null,
|
|
470
|
+
originTurnId: null,
|
|
471
|
+
quotedTurnId: null,
|
|
472
|
+
latestEndedTurnId: owner.turnId,
|
|
473
|
+
latestEndedAgeMs: HANDBACK_AGE_MS,
|
|
474
|
+
latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
|
|
475
|
+
})
|
|
476
|
+
expect(ownerId).toBe(ACK_TURN)
|
|
477
|
+
|
|
478
|
+
// No flush ever fired this turn ⇒ no supersede record to take.
|
|
479
|
+
const supersede = reg.take(CHAT, undefined, { liveTurnId: ownerId, now: now + HANDBACK_AGE_MS })
|
|
480
|
+
expect(supersede.supersede).toBe(false)
|
|
481
|
+
|
|
482
|
+
const handbackSuppressed = latchSemantics === 'tagged'
|
|
483
|
+
? decideAnswerLatchSuppression({
|
|
484
|
+
superseded: false,
|
|
485
|
+
replySubstantive: true, // 1365 chars
|
|
486
|
+
isLateReply: true, // no live gateway turn at handback time
|
|
487
|
+
ownerAnswerDelivered: owner.answerDelivered,
|
|
488
|
+
})
|
|
489
|
+
: oldBooleanLatchSuppression({
|
|
490
|
+
superseded: false,
|
|
491
|
+
replySubstantive: true,
|
|
492
|
+
isLateReply: true,
|
|
493
|
+
// Pre-fix the latch was `true` (a bare boolean, source-blind).
|
|
494
|
+
ownerAnswerDelivered: owner.answerDelivered !== false,
|
|
495
|
+
})
|
|
496
|
+
if (!handbackSuppressed) delivered.push('handback("Fable\'s back. The /model fix…")')
|
|
497
|
+
return { delivered, ownerId, handbackSuppressed }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
it('CORE REGRESSION (the fix): the handback is NOT suppressed — the user ' +
|
|
501
|
+
'receives BOTH the interim ack AND the sub-agent findings', () => {
|
|
502
|
+
const r = runDispatchAckHandback('tagged')
|
|
503
|
+
expect(r.handbackSuppressed).toBe(false)
|
|
504
|
+
expect(r.delivered).toEqual([
|
|
505
|
+
'ack("Kicked off a fable researcher…")',
|
|
506
|
+
'handback("Fable\'s back. The /model fix…")',
|
|
507
|
+
])
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('RED-ON-MAIN CONTRAST: the pre-fix boolean latch suppresses the handback ' +
|
|
511
|
+
'— the silent drop (only the ack ever reaches the user)', () => {
|
|
512
|
+
const r = runDispatchAckHandback('boolean')
|
|
513
|
+
// Owner resolution is identical in both worlds — the ended ack turn.
|
|
514
|
+
expect(r.ownerId).toBe(ACK_TURN)
|
|
515
|
+
expect(r.handbackSuppressed).toBe(true)
|
|
516
|
+
expect(r.delivered).toEqual(['ack("Kicked off a fable researcher…")'])
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('pure core: a reply-armed latch never suppresses a late substantive reply', () => {
|
|
520
|
+
expect(
|
|
521
|
+
decideAnswerLatchSuppression({
|
|
522
|
+
superseded: false,
|
|
523
|
+
replySubstantive: true,
|
|
524
|
+
isLateReply: true,
|
|
525
|
+
ownerAnswerDelivered: 'reply',
|
|
526
|
+
}),
|
|
527
|
+
).toBe(false)
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('flush race NOT reopened: the same late-landing shape against a ' +
|
|
531
|
+
'FLUSH-armed latch is still suppressed (the Part 2 backstop holds)', () => {
|
|
532
|
+
// Same timeline shape, but the turn's answer went out via the turn-flush
|
|
533
|
+
// backstop (post-fire pre-record window): latch = 'flush', no record yet.
|
|
534
|
+
expect(
|
|
535
|
+
decideAnswerLatchSuppression({
|
|
536
|
+
superseded: false,
|
|
537
|
+
replySubstantive: true,
|
|
538
|
+
isLateReply: true,
|
|
539
|
+
ownerAnswerDelivered: 'flush',
|
|
540
|
+
}),
|
|
541
|
+
).toBe(true)
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('double-send NOT reopened: a byte-identical replay of the delivered ' +
|
|
545
|
+
'reply is still caught by the #546 content dedup (60 s TTL)', () => {
|
|
546
|
+
const dedup = new OutboundDedupCache()
|
|
547
|
+
const now = 5_000_000
|
|
548
|
+
const answer =
|
|
549
|
+
'Fable is back. The /model fix landed in release 1.0.128 and the fallback ' +
|
|
550
|
+
'behaviour matches what we saw in the transcripts yesterday evening.'
|
|
551
|
+
// The reply path records what it sent (with its turn key)…
|
|
552
|
+
dedup.record(CHAT, undefined, answer, now, ACK_TURN)
|
|
553
|
+
// …and a late byte-identical replay (no live turn ⇒ null turnKey, which
|
|
554
|
+
// matches any recorded entry) is deduped before the latch is ever consulted.
|
|
555
|
+
const replay = dedup.check(CHAT, undefined, answer, now + HANDBACK_AGE_MS, null)
|
|
556
|
+
expect(replay).not.toBeNull()
|
|
557
|
+
// While the DIFFERENT-content handback sails past the content dedup…
|
|
558
|
+
const handback = dedup.check(
|
|
559
|
+
CHAT, undefined,
|
|
560
|
+
'Completely different sub-agent findings text, long enough to clear the dedup floor.',
|
|
561
|
+
now + HANDBACK_AGE_MS, null,
|
|
562
|
+
)
|
|
563
|
+
expect(handback).toBeNull()
|
|
564
|
+
})
|
|
565
|
+
})
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* #3429 — content evidence in the answer-delivered latch.
|
|
569
|
+
*
|
|
570
|
+
* A flush-armed latch used to suppress ANY late substantive reply resolving
|
|
571
|
+
* the flush-delivered ended turn. With the supersede path now declining
|
|
572
|
+
* new-content handbacks (they must send FRESH, not edit the flushed message in
|
|
573
|
+
* place), those handbacks fall through to this latch — which would have
|
|
574
|
+
* converted the #3429 silent edit into a #3426-style silent drop. The
|
|
575
|
+
* `replyMatchesFlushedAnswer` evidence closes that: positive FALSE (the reply
|
|
576
|
+
* is NOT the flushed answer) never suppresses; TRUE or unknown (null/omitted)
|
|
577
|
+
* preserves the #2996 Part 2 flush-race backstop exactly as before.
|
|
578
|
+
*/
|
|
579
|
+
describe('#3429 — flush-armed latch with content evidence', () => {
|
|
580
|
+
const base = {
|
|
581
|
+
superseded: false,
|
|
582
|
+
replySubstantive: true,
|
|
583
|
+
isLateReply: true,
|
|
584
|
+
ownerAnswerDelivered: 'flush' as const,
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
it('CORE: positive new-content evidence (matches === false) is NEVER suppressed ' +
|
|
588
|
+
'— the handback after a flush-delivered turn delivers fresh', () => {
|
|
589
|
+
expect(decideAnswerLatchSuppression({ ...base, replyMatchesFlushedAnswer: false })).toBe(false)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('the flushed answer landing again (matches === true) is still suppressed ' +
|
|
593
|
+
'(the flush race the latch exists for)', () => {
|
|
594
|
+
expect(decideAnswerLatchSuppression({ ...base, replyMatchesFlushedAnswer: true })).toBe(true)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
it('unknown evidence (null) keeps the conservative pre-#3429 suppression', () => {
|
|
598
|
+
expect(decideAnswerLatchSuppression({ ...base, replyMatchesFlushedAnswer: null })).toBe(true)
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
it('omitted evidence keeps the conservative pre-#3429 suppression (back-compat)', () => {
|
|
602
|
+
expect(decideAnswerLatchSuppression(base)).toBe(true)
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('evidence never overrides the reply-armed/unarmed rules (still no suppression)', () => {
|
|
606
|
+
expect(
|
|
607
|
+
decideAnswerLatchSuppression({ ...base, ownerAnswerDelivered: 'reply', replyMatchesFlushedAnswer: true }),
|
|
608
|
+
).toBe(false)
|
|
609
|
+
expect(
|
|
610
|
+
decideAnswerLatchSuppression({ ...base, ownerAnswerDelivered: false, replyMatchesFlushedAnswer: true }),
|
|
611
|
+
).toBe(false)
|
|
612
|
+
})
|
|
613
|
+
})
|
|
@@ -569,3 +569,157 @@ describe('structural wiring — the singleton + the gateway entry point (#2996 P
|
|
|
569
569
|
expect(win.match(/getCurrentTurn\(\)/g)!.length).toBeGreaterThanOrEqual(5)
|
|
570
570
|
})
|
|
571
571
|
})
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* #3429 — GATEWAY-LEVEL regression: an async handback landing after a
|
|
575
|
+
* flush-delivered turn ENDED must produce a FRESH, notifying message — never a
|
|
576
|
+
* silent edit-in-place of the prior turn's flushed message.
|
|
577
|
+
*
|
|
578
|
+
* This drives the REAL `sendReply` body (the exact gateway send path: the
|
|
579
|
+
* supersede consumption at outbound-send-path.ts ~874-905 and the deferred
|
|
580
|
+
* `decideSupersedeCorrection` edit-in-place lane at ~1462-1477) against the
|
|
581
|
+
* fake bot API, with a REAL `FlushedTurnSupersedeRegistry` seeded exactly the
|
|
582
|
+
* way the turn-flush backstop seeds it (record + 'flush'-armed ended owner
|
|
583
|
+
* turn). The incident (msgs 10482/10486, 2026-07-20): the handback resolved
|
|
584
|
+
* the flush-delivered ENDED turn as owner via the latest-ended tier (inside
|
|
585
|
+
* the 60 s TTL), consumed the supersede record, and EDITED the flushed message
|
|
586
|
+
* in place — Telegram edits never push-notify, so genuinely new content failed
|
|
587
|
+
* to surface client-side. Red on pre-fix code: these first asserts see an
|
|
588
|
+
* `editMessageText` call and no fresh send.
|
|
589
|
+
*
|
|
590
|
+
* The turn-end-funnel flag (SWITCHROOM_TURN_END_FUNNEL_V2, default ON) does
|
|
591
|
+
* not fork this path — the flush branch records into the SAME registry and the
|
|
592
|
+
* reply path consumes it identically under both settings — so this covers the
|
|
593
|
+
* v2 funnel default.
|
|
594
|
+
*/
|
|
595
|
+
describe('#3429 — post-turn-end handback vs flush-delivered supersede (real send path)', () => {
|
|
596
|
+
const OWNER_TURN_ID = `${CHAT}:_#ended-40`
|
|
597
|
+
const FLUSH_MSG_ID = 4242
|
|
598
|
+
const FLUSHED_TEXT =
|
|
599
|
+
'Checking the fleet status now.\n\n' +
|
|
600
|
+
'All twelve agents are healthy: the gateway, the vault broker, and the approval kernel ' +
|
|
601
|
+
'all report green health checks, and no container has restarted in the last twenty-four hours. ' +
|
|
602
|
+
'Nothing needs attention right now.'
|
|
603
|
+
const CANONICAL_REPLY =
|
|
604
|
+
'All twelve agents are healthy: the gateway, the vault broker, and the approval kernel ' +
|
|
605
|
+
'all report green health checks, and no container has restarted in the last twenty-four hours. ' +
|
|
606
|
+
'Nothing needs attention right now.'
|
|
607
|
+
const HANDBACK =
|
|
608
|
+
'Worker handback: the researcher sub-agent finished the migration audit. It found three ' +
|
|
609
|
+
'schema drifts in the staging database, wrote the full report to the shared drive, and ' +
|
|
610
|
+
'opened PR #3430 with the corrective migration. Tests are green and it is ready for review.'
|
|
611
|
+
|
|
612
|
+
/** An ENDED, flush-delivered owner turn — exactly the atom state the
|
|
613
|
+
* turn-flush fire site leaves in `recentTurnsById` (latch 'flush', flushed
|
|
614
|
+
* text stamped, endedAt within the supersede TTL). */
|
|
615
|
+
function makeFlushDeliveredEndedTurn(): CurrentTurn {
|
|
616
|
+
return {
|
|
617
|
+
turnId: OWNER_TURN_ID,
|
|
618
|
+
sessionChatId: CHAT,
|
|
619
|
+
answerDelivered: 'flush',
|
|
620
|
+
flushedAnswerText: FLUSHED_TEXT,
|
|
621
|
+
endedAt: Date.now() - 30_000,
|
|
622
|
+
replyCalled: false,
|
|
623
|
+
finalAnswerDelivered: true,
|
|
624
|
+
finalAnswerSubstantive: true,
|
|
625
|
+
} as unknown as CurrentTurn
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function seedFlushRecord(h: ReturnType<typeof makeHarness>, owner: CurrentTurn): void {
|
|
629
|
+
h.deps.flushedTurnSupersede.record(
|
|
630
|
+
CHAT,
|
|
631
|
+
undefined,
|
|
632
|
+
{ turnId: owner.turnId, messageIds: [FLUSH_MSG_ID], text: FLUSHED_TEXT },
|
|
633
|
+
Date.now(),
|
|
634
|
+
)
|
|
635
|
+
// The late reply resolves the ENDED flush-delivered turn as its owner
|
|
636
|
+
// (latest-ended tier — no live turn, no origin echo, no quote in a DM).
|
|
637
|
+
h.deps.resolveReplyOwnerTurn = () => owner
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
it('CORE REGRESSION (red pre-fix): a handback with genuinely NEW content sends a ' +
|
|
641
|
+
'FRESH message — no silent edit-in-place of the flushed message', async () => {
|
|
642
|
+
const h = makeHarness()
|
|
643
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
644
|
+
seedFlushRecord(h, owner)
|
|
645
|
+
|
|
646
|
+
// The handback lands LATE: req.turn = null (no live gateway turn — a
|
|
647
|
+
// sub-agent completion is not a new inbound).
|
|
648
|
+
const res = await sendReply(h.deps, req(HANDBACK))
|
|
649
|
+
|
|
650
|
+
// A NEW client-visible message shipped (fresh send ⇒ Telegram push
|
|
651
|
+
// notifies)…
|
|
652
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
653
|
+
expect(fresh).toHaveLength(1)
|
|
654
|
+
expect(fresh[0]!.text).toContain('migration audit')
|
|
655
|
+
expect(fresh[0]!.message_id).not.toBe(FLUSH_MSG_ID)
|
|
656
|
+
// …and the flushed message was NEITHER edited nor deleted (edits never
|
|
657
|
+
// re-notify — the pre-fix silent path).
|
|
658
|
+
expect(h.calls.filter((c) => c.method === 'editMessageText')).toHaveLength(0)
|
|
659
|
+
expect(h.calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
|
|
660
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
661
|
+
|
|
662
|
+
// The supersede record was NOT consumed: the turn's own canonical replay
|
|
663
|
+
// could still correct the flushed message afterwards.
|
|
664
|
+
expect(
|
|
665
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
666
|
+
liveTurnId: OWNER_TURN_ID,
|
|
667
|
+
replyText: CANONICAL_REPLY,
|
|
668
|
+
now: Date.now(),
|
|
669
|
+
}).supersede,
|
|
670
|
+
).toBe(true)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it('PRESERVED: the turn\'s OWN canonical late replay (same answer, contained in ' +
|
|
674
|
+
'the flushed blob) still supersedes via edit-in-place — no duplicate bubble', async () => {
|
|
675
|
+
const h = makeHarness()
|
|
676
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
677
|
+
seedFlushRecord(h, owner)
|
|
678
|
+
|
|
679
|
+
const res = await sendReply(h.deps, req(CANONICAL_REPLY))
|
|
680
|
+
|
|
681
|
+
// The single flushed message was edited into the canonical reply…
|
|
682
|
+
const edits = h.calls.filter((c) => c.method === 'editMessageText')
|
|
683
|
+
expect(edits).toHaveLength(1)
|
|
684
|
+
expect(edits[0]!.message_id).toBe(FLUSH_MSG_ID)
|
|
685
|
+
expect(edits[0]!.text).toContain('All twelve agents are healthy')
|
|
686
|
+
// …and NO second bubble shipped (the #3236/#2996 duplicate class stays closed).
|
|
687
|
+
expect(h.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
688
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
689
|
+
// Record consumed — a retry cannot re-target the now-corrected message.
|
|
690
|
+
expect(
|
|
691
|
+
h.deps.flushedTurnSupersede.peek(CHAT, undefined, {
|
|
692
|
+
liveTurnId: OWNER_TURN_ID,
|
|
693
|
+
now: Date.now(),
|
|
694
|
+
}).reason,
|
|
695
|
+
).toBe('no-record')
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('PRE-RECORD RACE, new content: flush fired (latch armed + text stamped) but ' +
|
|
699
|
+
'record not yet written — the handback still delivers FRESH, not suppressed', async () => {
|
|
700
|
+
const h = makeHarness()
|
|
701
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
702
|
+
// NO registry record (the post-fire pre-record window); owner resolution
|
|
703
|
+
// still recovers the ended flush-armed turn.
|
|
704
|
+
h.deps.resolveReplyOwnerTurn = () => owner
|
|
705
|
+
|
|
706
|
+
const res = await sendReply(h.deps, req(HANDBACK))
|
|
707
|
+
|
|
708
|
+
const fresh = h.calls.filter((c) => c.method === 'sendRichMessage')
|
|
709
|
+
expect(fresh).toHaveLength(1)
|
|
710
|
+
expect(fresh[0]!.text).toContain('migration audit')
|
|
711
|
+
expect(res.content[0]!.text).toMatch(/^sent \(id: \d+\)$/)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
it('PRE-RECORD RACE, same answer: the flushed answer landing again in the race ' +
|
|
715
|
+
'window is still suppressed (the #2996 Part 2 backstop holds)', async () => {
|
|
716
|
+
const h = makeHarness()
|
|
717
|
+
const owner = makeFlushDeliveredEndedTurn()
|
|
718
|
+
h.deps.resolveReplyOwnerTurn = () => owner
|
|
719
|
+
|
|
720
|
+
const res = await sendReply(h.deps, req(FLUSHED_TEXT))
|
|
721
|
+
|
|
722
|
+
expect(h.calls).toHaveLength(0) // nothing sent, nothing edited
|
|
723
|
+
expect(res.content[0]!.text).toContain('deduped')
|
|
724
|
+
})
|
|
725
|
+
})
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { describe, it, expect } from 'vitest'
|
|
12
12
|
import { createSessionModelSource } from '../gateway/session-model-source.js'
|
|
13
|
+
import type { SessionModelDivergence } from '../gateway/session-model-source.js'
|
|
14
|
+
import { servedModelMatchesRequested } from '../gateway/model-command.js'
|
|
13
15
|
|
|
14
16
|
describe('createSessionModelSource — freshest observation wins', () => {
|
|
15
17
|
it('returns null when neither source has reported', () => {
|
|
@@ -76,3 +78,143 @@ describe('createSessionModelSource — freshest observation wins', () => {
|
|
|
76
78
|
expect(s.getOverride()).toBe('sr-glm-5')
|
|
77
79
|
})
|
|
78
80
|
})
|
|
81
|
+
|
|
82
|
+
// ── #3427 item 4: requested-vs-served divergence tripwire ────────────────────
|
|
83
|
+
//
|
|
84
|
+
// `--fallback-model` masks an invalid requested id: the override carries the
|
|
85
|
+
// requested token while claude silently serves the fallback. The FIRST live
|
|
86
|
+
// transcript observation of the post-relaunch session is the earliest
|
|
87
|
+
// deterministic verification point — these assert the handler FIRES on a
|
|
88
|
+
// mismatch (with the right payload), fires at most once per armed override,
|
|
89
|
+
// and — the #3437 H1/H2 false-positive contract — NEVER fires from a
|
|
90
|
+
// command-time (unarmed) override set or from a first-attach replay line.
|
|
91
|
+
|
|
92
|
+
describe('createSessionModelSource — divergence tripwire (#3427)', () => {
|
|
93
|
+
function armed() {
|
|
94
|
+
const fired: SessionModelDivergence[] = []
|
|
95
|
+
const s = createSessionModelSource({ servedMatchesRequested: servedModelMatchesRequested })
|
|
96
|
+
s.setDivergenceHandler((d) => fired.push(d))
|
|
97
|
+
return { s, fired }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
it('fires ONCE with requested+served when the first post-boot line serves a different model', () => {
|
|
101
|
+
const { s, fired } = armed()
|
|
102
|
+
s.setOverride('claude-sonnet-9', { verify: true }) // boot rehydration: invalid id, fallback will substitute
|
|
103
|
+
s.noteTranscriptModel('claude-opus-4-8') // first assistant line: the fallback
|
|
104
|
+
expect(fired).toEqual([{ requested: 'claude-sonnet-9', served: 'claude-opus-4-8' }])
|
|
105
|
+
// Subsequent lines do not re-fire (once per armed override).
|
|
106
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
107
|
+
expect(fired).toHaveLength(1)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does NOT fire when the served model satisfies the requested token (alias → full id)', () => {
|
|
111
|
+
const { s, fired } = armed()
|
|
112
|
+
s.setOverride('sonnet', { verify: true })
|
|
113
|
+
s.noteTranscriptModel('claude-sonnet-5')
|
|
114
|
+
expect(fired).toHaveLength(0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('verification is consumed by the FIRST live observation — a later different model is a normal switch, not a divergence', () => {
|
|
118
|
+
const { s, fired } = armed()
|
|
119
|
+
s.setOverride('claude-opus-4-8', { verify: true })
|
|
120
|
+
s.noteTranscriptModel('claude-opus-4-8') // verified OK
|
|
121
|
+
s.noteTranscriptModel('claude-sonnet-5') // e.g. a native in-session switch
|
|
122
|
+
expect(fired).toHaveLength(0)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('re-arms on every verify-set', () => {
|
|
126
|
+
const { s, fired } = armed()
|
|
127
|
+
s.setOverride('claude-opus-4-8', { verify: true })
|
|
128
|
+
s.noteTranscriptModel('claude-opus-4-8') // ok
|
|
129
|
+
s.setOverride('claude-sonnet-9', { verify: true }) // next apply-boot, bogus id
|
|
130
|
+
s.noteTranscriptModel('claude-opus-4-8') // fallback again
|
|
131
|
+
expect(fired).toEqual([{ requested: 'claude-sonnet-9', served: 'claude-opus-4-8' }])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('clearing the override disarms (nothing to verify)', () => {
|
|
135
|
+
const { s, fired } = armed()
|
|
136
|
+
s.setOverride('claude-sonnet-9', { verify: true })
|
|
137
|
+
s.setOverride(null)
|
|
138
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
139
|
+
expect(fired).toHaveLength(0)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// H1 (#3437 review blocker): the command-time scheduleModelRelaunch record —
|
|
143
|
+
// setOverride(model) with NO verify — must not arm. The prior boot may have
|
|
144
|
+
// left the handler registered and its own arm consumed; an assistant line in
|
|
145
|
+
// the pre-restart window is served by the OLD model and would otherwise
|
|
146
|
+
// false-accuse a perfectly valid NEW token.
|
|
147
|
+
it('H1: a command-time setOverride (no verify) NEVER arms — no false DIVERGENCE in the pre-restart window', () => {
|
|
148
|
+
const { s, fired } = armed()
|
|
149
|
+
// Prior apply-boot: armed, verified OK on the first line.
|
|
150
|
+
s.setOverride('claude-opus-4-8', { verify: true })
|
|
151
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
152
|
+
// Operator issues /model <valid new id>; scheduleModelRelaunch records it
|
|
153
|
+
// pre-restart (status honesty) WITHOUT verify.
|
|
154
|
+
s.setOverride('claude-sonnet-5')
|
|
155
|
+
// The still-running OLD session emits another assistant line before the
|
|
156
|
+
// restart lands — served by the OLD model. Must NOT fire.
|
|
157
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
158
|
+
expect(fired).toHaveLength(0)
|
|
159
|
+
// …and the override record itself is intact for /status honesty.
|
|
160
|
+
expect(s.getOverride()).toBe('claude-sonnet-5')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('H1: a verify-arm is CLEARED by a later plain set (the newest write wins, unarmed)', () => {
|
|
164
|
+
const { s, fired } = armed()
|
|
165
|
+
s.setOverride('claude-sonnet-9', { verify: true }) // armed, not yet verified
|
|
166
|
+
s.setOverride('claude-haiku-4-5') // command-time re-set before any line
|
|
167
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
168
|
+
expect(fired).toHaveLength(0)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// H2 (#3437 review blocker): the session-tail's first-attach replay delivers
|
|
172
|
+
// the PRIOR session's in-flight turn AFTER boot — OLD-model lines. They must
|
|
173
|
+
// neither fire nor consume verification; the first LIVE line still verifies.
|
|
174
|
+
it('H2: replayed observations neither fire nor consume — the first LIVE line still verifies (valid switch, no card)', () => {
|
|
175
|
+
const { s, fired } = armed()
|
|
176
|
+
s.setOverride('claude-sonnet-5', { verify: true }) // apply-boot onto a VALID id
|
|
177
|
+
// Boot replay of the pre-relaunch in-flight turn (old model claude-opus-4-8).
|
|
178
|
+
s.noteTranscriptModel('claude-opus-4-8', { replayed: true })
|
|
179
|
+
s.noteTranscriptModel('claude-opus-4-8', { replayed: true })
|
|
180
|
+
expect(fired).toHaveLength(0) // the false-positive path the review flagged
|
|
181
|
+
// First LIVE line of the new session: the requested model. Verified clean.
|
|
182
|
+
s.noteTranscriptModel('claude-sonnet-5')
|
|
183
|
+
expect(fired).toHaveLength(0)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('H2: a genuinely bogus id still fires on the first LIVE line after replay', () => {
|
|
187
|
+
const { s, fired } = armed()
|
|
188
|
+
s.setOverride('claude-sonnet-9', { verify: true })
|
|
189
|
+
s.noteTranscriptModel('claude-opus-4-8', { replayed: true }) // replayed old-turn line: ignored
|
|
190
|
+
expect(fired).toHaveLength(0)
|
|
191
|
+
s.noteTranscriptModel('claude-opus-4-8') // first LIVE line: the fallback
|
|
192
|
+
expect(fired).toEqual([{ requested: 'claude-sonnet-9', served: 'claude-opus-4-8' }])
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('replayed observations still update /status freshness exactly as before', () => {
|
|
196
|
+
const { s } = armed()
|
|
197
|
+
s.setOverride('claude-sonnet-5', { verify: true })
|
|
198
|
+
s.noteTranscriptModel('claude-opus-4-8', { replayed: true })
|
|
199
|
+
// Freshness contract unchanged: the newer observation (transcript) wins.
|
|
200
|
+
expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('never fires without a comparator (default construction) — old callers unchanged', () => {
|
|
204
|
+
const fired: SessionModelDivergence[] = []
|
|
205
|
+
const s = createSessionModelSource()
|
|
206
|
+
s.setDivergenceHandler((d) => fired.push(d))
|
|
207
|
+
s.setOverride('claude-sonnet-9', { verify: true })
|
|
208
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
209
|
+
expect(fired).toHaveLength(0)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('a handler that clears the override does not break resolution', () => {
|
|
213
|
+
const s = createSessionModelSource({ servedMatchesRequested: servedModelMatchesRequested })
|
|
214
|
+
s.setDivergenceHandler(() => s.setOverride(null))
|
|
215
|
+
s.setOverride('claude-sonnet-9', { verify: true })
|
|
216
|
+
s.noteTranscriptModel('claude-opus-4-8')
|
|
217
|
+
expect(s.getOverride()).toBeNull()
|
|
218
|
+
expect(s.resolve()).toEqual({ model: 'claude-opus-4-8', source: 'transcript' })
|
|
219
|
+
})
|
|
220
|
+
})
|