switchroom 0.19.2 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/agent-scheduler/index.js +2 -0
  2. package/dist/auth-broker/index.js +109 -7
  3. package/dist/cli/autoaccept-poll.js +2 -0
  4. package/dist/cli/drive-write-pretool.mjs +2 -0
  5. package/dist/cli/ms-365-write-pretool.mjs +2 -0
  6. package/dist/cli/switchroom.js +404 -245
  7. package/dist/host-control/main.js +1 -1
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +8 -0
  10. package/skills/mental-model-curator/SKILL.md +68 -2
  11. package/telegram-plugin/auth-snapshot-format.ts +104 -12
  12. package/telegram-plugin/dist/bridge/bridge.js +8 -2
  13. package/telegram-plugin/dist/gateway/gateway.js +1194 -794
  14. package/telegram-plugin/dist/server.js +8 -2
  15. package/telegram-plugin/flushed-turn-supersede.ts +117 -13
  16. package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
  17. package/telegram-plugin/gateway/auth-command.ts +138 -5
  18. package/telegram-plugin/gateway/gateway.ts +68 -101
  19. package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
  20. package/telegram-plugin/gateway/model-command.ts +203 -1
  21. package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
  22. package/telegram-plugin/gateway/session-model-source.ts +90 -10
  23. package/telegram-plugin/gateway/stream-render.ts +22 -5
  24. package/telegram-plugin/quota-bar-format.ts +60 -12
  25. package/telegram-plugin/reply-owner-resolve.ts +76 -11
  26. package/telegram-plugin/session-tail.ts +27 -3
  27. package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
  28. package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
  29. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
  30. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
  31. package/telegram-plugin/tests/model-command.test.ts +220 -0
  32. package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
  34. package/telegram-plugin/tests/session-model-source.test.ts +142 -0
  35. package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
  36. package/vendor/hindsight-memory/CHANGELOG.md +102 -0
  37. package/vendor/hindsight-memory/README.md +2 -1
  38. package/vendor/hindsight-memory/hooks/hooks.json +12 -0
  39. package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
  40. package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
  41. package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
  42. package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
  43. package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
  44. package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
  45. package/vendor/hindsight-memory/scripts/recall.py +789 -143
  46. package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
  47. package/vendor/hindsight-memory/scripts/retain.py +71 -2
  48. package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
  49. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
  50. package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
  51. package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
  52. package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
  53. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
  54. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
  55. package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
  56. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
  57. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
  60. 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: true,
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: true,
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: true,
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: true,
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: boolean) =>
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 true.
209
- expect(lateSubstantiveReply(true)).toBe(true)
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 = true` at record consumption, so
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: boolean }
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
- owner.answerDelivered = true
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
+ })