switchroom 0.20.0 → 0.20.2

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 (29) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +4 -3
  4. package/dist/buzz-gateway/index.js +166 -6
  5. package/dist/cli/notion-write-pretool.mjs +2 -2
  6. package/dist/cli/switchroom.js +24704 -16399
  7. package/dist/host-control/main.js +44 -10
  8. package/dist/vault/approvals/kernel-server.js +4 -3
  9. package/dist/vault/broker/server.js +4 -3
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +79 -10
  12. package/telegram-plugin/dist/gateway/gateway.js +1400 -964
  13. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  14. package/telegram-plugin/gateway/access-store.ts +194 -0
  15. package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
  16. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  17. package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
  18. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  19. package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
  20. package/telegram-plugin/gateway/gateway.ts +43 -123
  21. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  22. package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
  23. package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
  24. package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
  25. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  26. package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
  27. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  28. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  29. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
@@ -757,6 +757,30 @@ export interface VoiceOutPlan {
757
757
  ttsChunks: string[]
758
758
  }
759
759
 
760
+ /**
761
+ * Resolve the Buzz-mirror NIP-10 antecedent key for an outbound answer, or
762
+ * `undefined` to mirror FLAT. Pure + deterministic so the gating is testable
763
+ * without the full send harness. Returns `${chatId}:${replyTo}` only when a
764
+ * genuine, renderable reply antecedent exists; `undefined` when:
765
+ * - there is no antecedent (`replyTo == null`), or
766
+ * - `replyTo` is not a finite number (a non-numeric model `reply_to` coerces to
767
+ * `NaN`; #4301 — never build a bogus `chat:NaN` key the mirror logs as a
768
+ * real miss), or
769
+ * - `replyMode === 'off'` (#4300 — the Telegram copy renders NO reply, so the
770
+ * Buzz mirror must stay flat too; without this the Buzz copy visibly threads
771
+ * while the Telegram copy does not — a surface divergence).
772
+ */
773
+ export function resolveMirrorAntecedentKey(
774
+ chatId: string,
775
+ replyTo: number | undefined,
776
+ replyMode: string,
777
+ ): string | undefined {
778
+ if (replyTo == null || !Number.isFinite(replyTo) || replyMode === 'off') {
779
+ return undefined
780
+ }
781
+ return `${chatId}:${replyTo}`
782
+ }
783
+
760
784
  export interface SendReplyRequest {
761
785
  /** Raw `reply` tool args, exactly as the MCP dispatch received them. */
762
786
  args: Record<string, unknown>
@@ -1545,10 +1569,19 @@ export async function sendReply(
1545
1569
  )
1546
1570
  }
1547
1571
 
1572
+ // #4301: track whether `reply_to` came from the quote-opt-in DEFAULT (the
1573
+ // latest inbound user message) rather than an explicit/model-supplied value.
1574
+ // The default antecedent is never in the Buzz correlation store, so its mirror
1575
+ // lookup always misses — passing this flag lets the mirror log that expected
1576
+ // flat fallback quietly instead of as an eviction "MISS".
1577
+ let antecedentFromQuoteOptInDefault = false
1548
1578
  if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
1549
1579
  try {
1550
1580
  const latest = getLatestInboundMessageId(chat_id, threadId ?? null)
1551
- if (latest != null) reply_to = latest
1581
+ if (latest != null) {
1582
+ reply_to = latest
1583
+ antecedentFromQuoteOptInDefault = true
1584
+ }
1552
1585
  } catch (err) {
1553
1586
  process.stderr.write(`telegram gateway: quote-reply lookup failed: ${(err as Error).message}\n`)
1554
1587
  }
@@ -2619,6 +2652,20 @@ export async function sendReply(
2619
2652
  ownerEchoed,
2620
2653
  hasRecentDifferentOriginTurn,
2621
2654
  telegramMessageKeys: sentIds.map((id) => `${chat_id}:${id}`),
2655
+ // NIP-10 outbound thread continuity: the Telegram message THIS answer
2656
+ // replied to (its finalized `reply_to`, whether model-supplied or the
2657
+ // quote-opt-in default). The mirror resolves it against the durable
2658
+ // correlation store and threads under it only on a HIT (a previously-
2659
+ // mirrored answer); a user inbound / evicted key misses → flat.
2660
+ //
2661
+ // Guards:
2662
+ // - #4300: when `replyMode === 'off'` the Telegram copy renders NO
2663
+ // reply, so DON'T stamp the antecedent — keep the Buzz copy flat too
2664
+ // (surface parity; no threading the Buzz copy visibly threads on).
2665
+ // - #4301: `Number.isFinite` drops a non-numeric `reply_to` (→ `NaN`)
2666
+ // so it never becomes a bogus `chat:NaN` key that logs as a real miss.
2667
+ antecedentTelegramMessageKey: resolveMirrorAntecedentKey(chat_id, reply_to, replyMode),
2668
+ antecedentIsQuoteOptInDefault: antecedentFromQuoteOptInDefault,
2622
2669
  })
2623
2670
  }
2624
2671
  }
@@ -32,9 +32,18 @@ export function writePendingTurnEnv(
32
32
  `SWITCHROOM_PENDING_TURN=true`,
33
33
  `SWITCHROOM_PENDING_TURN_KEY=${pending.turn_key}`,
34
34
  `SWITCHROOM_PENDING_CHAT_ID=${pending.chat_id}`,
35
+ // Tri-state thread signal for the handoff-briefing scope resolver.
36
+ // A pending Turn ALWAYS knows its thread: a numbered forum topic, or
37
+ // NULL (a DM / forum General topic). Emit the numeric id for a topic,
38
+ // and the literal sentinel `NULL` when the thread is genuinely null —
39
+ // so bin/handoff-briefing.sh scopes the reorientation to `thread_id IS
40
+ // NULL` instead of falling back to chat-only (all-threads) scope. An
41
+ // empty value is reserved for "thread unknown"; the writer never emits
42
+ // it, but an older gateway would, and the resolver treats empty as the
43
+ // safe chat-only fallback.
35
44
  pending.thread_id != null
36
45
  ? `SWITCHROOM_PENDING_THREAD_ID=${pending.thread_id}`
37
- : `SWITCHROOM_PENDING_THREAD_ID=`,
46
+ : `SWITCHROOM_PENDING_THREAD_ID=NULL`,
38
47
  pending.last_user_msg_id != null
39
48
  ? `SWITCHROOM_PENDING_USER_MSG_ID=${pending.last_user_msg_id}`
40
49
  : `SWITCHROOM_PENDING_USER_MSG_ID=`,
@@ -42,7 +42,12 @@ import {
42
42
  renderBootBriefing,
43
43
  type BriefingDb,
44
44
  } from '../gateway/boot-briefing-builder.js'
45
- import { maybeQueueBootBriefing } from '../gateway/boot-briefing-wiring.js'
45
+ import {
46
+ maybeQueueBootBriefing,
47
+ fetchHindsightRecall,
48
+ readDailyMemory,
49
+ } from '../gateway/boot-briefing-wiring.js'
50
+ import type { BriefingSurface } from '../gateway/boot-briefing-builder.js'
46
51
  import { spoolId } from '../gateway/inbound-spool.js'
47
52
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
48
53
 
@@ -352,11 +357,11 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
352
357
  }
353
358
  }
354
359
 
355
- it('queues a briefing built from real history rows when the flag is gateway', () => {
360
+ it('queues a briefing built from real history rows when the flag is gateway', async () => {
356
361
  seedUser('321', null, 30 * 60, 'please review the deploy plan')
357
362
  seedBot('321', null, 25 * 60, 'on it — reviewing now')
358
363
  const puts: Array<{ agent: string; msg: InboundMessage }> = []
359
- const queued = maybeQueueBootBriefing({
364
+ const queued = await maybeQueueBootBriefing({
360
365
  env: envFor('gateway'),
361
366
  stateDir: join(stateDir, 'telegram'),
362
367
  resumeMsg: null,
@@ -374,10 +379,10 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
374
379
  expect(puts[0]!.msg.text.length).toBeLessThanOrEqual(BRIEFING_CHAR_BUDGET)
375
380
  })
376
381
 
377
- it('queues NOTHING when the flag is legacy (default) — legacy behaviour untouched', () => {
382
+ it('queues NOTHING when the flag is legacy (default) — legacy behaviour untouched', async () => {
378
383
  seedUser('321', null, 30 * 60, 'recent message')
379
384
  const puts: unknown[] = []
380
- const queued = maybeQueueBootBriefing({
385
+ const queued = await maybeQueueBootBriefing({
381
386
  env: envFor('legacy'),
382
387
  stateDir: join(stateDir, 'telegram'),
383
388
  resumeMsg: null,
@@ -389,9 +394,9 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
389
394
  expect(puts.length).toBe(0)
390
395
  })
391
396
 
392
- it('queues nothing when history is empty', () => {
397
+ it('queues nothing when history is empty', async () => {
393
398
  const puts: unknown[] = []
394
- const queued = maybeQueueBootBriefing({
399
+ const queued = await maybeQueueBootBriefing({
395
400
  env: envFor('gateway'),
396
401
  stateDir: join(stateDir, 'telegram'),
397
402
  resumeMsg: null,
@@ -403,10 +408,10 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
403
408
  expect(puts.length).toBe(0)
404
409
  })
405
410
 
406
- it('suppresses on a force-fresh (/reset) marker', () => {
411
+ it('suppresses on a force-fresh (/reset) marker', async () => {
407
412
  seedUser('321', null, 30 * 60, 'recent message')
408
413
  writeFileSync(join(stateDir, '.force-fresh-session'), '')
409
- const queued = maybeQueueBootBriefing({
414
+ const queued = await maybeQueueBootBriefing({
410
415
  env: envFor('gateway'),
411
416
  stateDir: join(stateDir, 'telegram'),
412
417
  resumeMsg: null,
@@ -419,14 +424,14 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
419
424
  expect(queued).toBeNull()
420
425
  })
421
426
 
422
- it('suppresses on SWITCHROOM_FORCE_FRESH=1 even when NO marker file exists (env-keyed, race-proof)', () => {
427
+ it('suppresses on SWITCHROOM_FORCE_FRESH=1 even when NO marker file exists (env-keyed, race-proof)', async () => {
423
428
  // The M1 fix: the decision keys on the env snapshot start.sh takes
424
429
  // BEFORE forking the gateway, not on fs state at gateway check time. So
425
430
  // the /reset boot is suppressed even after the inner pass has already
426
431
  // `rm`ed the marker — the exact race the old existsSync check lost.
427
432
  seedUser('321', null, 30 * 60, 'recent message')
428
433
  expect(existsSync(join(stateDir, '.force-fresh-session'))).toBe(false)
429
- const queued = maybeQueueBootBriefing({
434
+ const queued = await maybeQueueBootBriefing({
430
435
  env: { ...envFor('gateway'), SWITCHROOM_FORCE_FRESH: '1' },
431
436
  stateDir: join(stateDir, 'telegram'),
432
437
  resumeMsg: null,
@@ -439,12 +444,12 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
439
444
  expect(queued).toBeNull()
440
445
  })
441
446
 
442
- it('suppresses on SWITCHROOM_FORCE_FRESH=1 regardless of whether the marker is present', () => {
447
+ it('suppresses on SWITCHROOM_FORCE_FRESH=1 regardless of whether the marker is present', async () => {
443
448
  // Outcome does not depend on fs state at check time: with the env set,
444
449
  // the briefing is suppressed whether or not the marker file is on disk.
445
450
  seedUser('321', null, 30 * 60, 'recent message')
446
451
  writeFileSync(join(stateDir, '.force-fresh-session'), '')
447
- const queued = maybeQueueBootBriefing({
452
+ const queued = await maybeQueueBootBriefing({
448
453
  env: { ...envFor('gateway'), SWITCHROOM_FORCE_FRESH: '1' },
449
454
  stateDir: join(stateDir, 'telegram'),
450
455
  resumeMsg: null,
@@ -457,9 +462,11 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
457
462
  expect(queued).toBeNull()
458
463
  })
459
464
 
460
- it('never throws even when put itself throws', () => {
465
+ it('never throws even when put itself throws', async () => {
461
466
  seedUser('321', null, 30 * 60, 'recent message')
462
- expect(() =>
467
+ // The internal try/catch swallows put's throw — the promise RESOLVES to
468
+ // null rather than rejecting, so boot is never blocked or crashed.
469
+ await expect(
463
470
  maybeQueueBootBriefing({
464
471
  env: envFor('gateway'),
465
472
  stateDir: join(stateDir, 'telegram'),
@@ -470,10 +477,390 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
470
477
  log: () => {},
471
478
  nowMs: NOW_MS,
472
479
  }),
473
- ).not.toThrow()
480
+ ).resolves.toBeNull()
481
+ })
482
+ })
483
+
484
+ // A minimal primary surface for the pure render tests (no DB needed).
485
+ function primarySurface(text = 'the primary ask'): BriefingSurface {
486
+ return {
487
+ chatId: '321',
488
+ threadId: null,
489
+ lastTs: NOW_SEC - 60,
490
+ messages: [{ role: 'user', user: 'ken', ts: NOW_SEC - 60, text }],
491
+ }
492
+ }
493
+
494
+ describe('renderBootBriefing — Hindsight + daily-memory sections (source 2 + 3 parity)', () => {
495
+ it('renders a Hindsight section with `- text (timestamp)` lines mirroring the shell jq', () => {
496
+ const out = renderBootBriefing([primarySurface()], {
497
+ nowMs: NOW_MS,
498
+ hindsight: [
499
+ { text: 'we agreed to ship the gateway briefing', timestamp: '2026-08-01T10:00:00Z' },
500
+ { text: 'no timestamp here', timestamp: null },
501
+ ],
502
+ })
503
+ expect(out).toContain('## Hindsight recall (recent context)')
504
+ expect(out).toContain('- we agreed to ship the gateway briefing (2026-08-01T10:00:00Z)')
505
+ // No dangling ` (…)` when a result has no timestamp.
506
+ expect(out).toContain('- no timestamp here')
507
+ expect(out).not.toContain('no timestamp here (')
508
+ })
509
+
510
+ it('renders `(no text)` for a blank Hindsight result (shell `.text // "(no text)"`)', () => {
511
+ const out = renderBootBriefing([primarySurface()], {
512
+ nowMs: NOW_MS,
513
+ hindsight: [{ text: ' ', timestamp: null }],
514
+ })
515
+ expect(out).toContain('- (no text)')
516
+ })
517
+
518
+ it('renders a daily-memory section under a dated header', () => {
519
+ const out = renderBootBriefing([primarySurface()], {
520
+ nowMs: NOW_MS,
521
+ dailyMemory: { date: '2026-08-02', content: 'Shipped X. Blocked on Y.' },
522
+ })
523
+ expect(out).toContain("## Today's memory (2026-08-02)")
524
+ expect(out).toContain('Shipped X. Blocked on Y.')
525
+ })
526
+
527
+ it('renders NO Hindsight/daily header when both inputs are absent or empty (no empty headers)', () => {
528
+ const noneOut = renderBootBriefing([primarySurface()], { nowMs: NOW_MS })
529
+ expect(noneOut).not.toContain('## Hindsight recall')
530
+ expect(noneOut).not.toContain("## Today's memory")
531
+
532
+ const emptyOut = renderBootBriefing([primarySurface()], {
533
+ nowMs: NOW_MS,
534
+ hindsight: [],
535
+ dailyMemory: { date: '2026-08-02', content: ' \n ' },
536
+ })
537
+ expect(emptyOut).not.toContain('## Hindsight recall')
538
+ expect(emptyOut).not.toContain("## Today's memory")
539
+ })
540
+
541
+ it('orders sections telegram → hindsight → daily (mirrors bin/handoff-briefing.sh)', () => {
542
+ const out = renderBootBriefing([primarySurface()], {
543
+ nowMs: NOW_MS,
544
+ hindsight: [{ text: 'recall line', timestamp: null }],
545
+ dailyMemory: { date: '2026-08-02', content: 'daily line' },
546
+ })
547
+ const iPrimary = out.indexOf('the primary ask')
548
+ const iHind = out.indexOf('## Hindsight recall')
549
+ const iDaily = out.indexOf("## Today's memory")
550
+ expect(iPrimary).toBeGreaterThan(-1)
551
+ expect(iHind).toBeGreaterThan(iPrimary)
552
+ expect(iDaily).toBeGreaterThan(iHind)
553
+ })
554
+
555
+ it('respects the char budget: an oversized daily memory truncates, never blows the budget', () => {
556
+ const huge = 'x'.repeat(50_000)
557
+ const out = renderBootBriefing([primarySurface()], {
558
+ nowMs: NOW_MS,
559
+ hindsight: [{ text: 'a recall', timestamp: null }],
560
+ dailyMemory: { date: '2026-08-02', content: huge },
561
+ charBudget: BRIEFING_CHAR_BUDGET,
562
+ })
563
+ expect(out.length).toBeLessThanOrEqual(BRIEFING_CHAR_BUDGET)
564
+ // Telegram history keeps priority (present) and the daily section is
565
+ // truncated with an ellipsis rather than dropped or overflowing.
566
+ expect(out).toContain('the primary ask')
567
+ expect(out).toContain("## Today's memory (2026-08-02)")
568
+ expect(out).toContain('…')
569
+ })
570
+
571
+ it('respects the char budget for ASTRAL / non-BMP input and never cuts a surrogate pair', () => {
572
+ // Regression: the char budget is a UTF-16 `.length` bound, but the
573
+ // truncators used to measure CODEPOINTS against it — so an astral-plane
574
+ // daily memory (each 😀 is 1 codepoint but 2 UTF-16 units) overflowed the
575
+ // budget nearly 2x. ASCII-only budget tests can't see this.
576
+ const astral = '😀'.repeat(50_000) // 50k codepoints = 100k UTF-16 units
577
+ const out = renderBootBriefing([primarySurface()], {
578
+ nowMs: NOW_MS,
579
+ hindsight: [{ text: 'a short recall', timestamp: null }],
580
+ dailyMemory: { date: '2026-08-02', content: astral },
581
+ charBudget: BRIEFING_CHAR_BUDGET,
582
+ })
583
+ // The module's own asserted invariant: final UTF-16 length within budget.
584
+ expect(out.length).toBeLessThanOrEqual(BRIEFING_CHAR_BUDGET)
585
+ // And the cut must never bisect a surrogate pair (no lone/broken half).
586
+ const hasLoneSurrogate = (s: string): boolean => {
587
+ for (let i = 0; i < s.length; i++) {
588
+ const c = s.charCodeAt(i)
589
+ if (c >= 0xd800 && c <= 0xdbff) {
590
+ const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0
591
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return true
592
+ i++ // valid pair — skip the low half
593
+ } else if (c >= 0xdc00 && c <= 0xdfff) {
594
+ return true // lone low surrogate
595
+ }
596
+ }
597
+ return false
598
+ }
599
+ expect(hasLoneSurrogate(out)).toBe(false)
600
+ // The daily section is still present (truncated, not dropped).
601
+ expect(out).toContain("## Today's memory (2026-08-02)")
602
+ })
603
+
604
+ it('skips a trailing section entirely when there is not even room for a truncated body', () => {
605
+ // Budget large enough for the telegram slice + a small header, but the
606
+ // daily body cannot fit — the section is skipped whole (no dangling
607
+ // header), and the result still respects the budget.
608
+ const base = renderBootBriefing([primarySurface()], { nowMs: NOW_MS })
609
+ const tightBudget = base.length + 20 // room for neither a real hindsight nor daily body
610
+ const out = renderBootBriefing([primarySurface()], {
611
+ nowMs: NOW_MS,
612
+ dailyMemory: { date: '2026-08-02', content: 'x'.repeat(5000) },
613
+ charBudget: tightBudget,
614
+ })
615
+ expect(out.length).toBeLessThanOrEqual(tightBudget)
616
+ expect(out).not.toContain("## Today's memory")
617
+ })
618
+ })
619
+
620
+ describe('fetchHindsightRecall — graceful-skip paths (source 2 wiring)', () => {
621
+ const okBody = {
622
+ results: [
623
+ { text: 'first memory', timestamp: '2026-08-01T00:00:00Z' },
624
+ { text: 'second memory' },
625
+ ],
626
+ }
627
+
628
+ function jsonResponse(status: number, body: unknown): Response {
629
+ return new Response(JSON.stringify(body), {
630
+ status,
631
+ headers: { 'Content-Type': 'application/json' },
632
+ })
633
+ }
634
+
635
+ const liveEnv = {
636
+ HINDSIGHT_API_URL: 'http://hindsight.internal:8080/',
637
+ HINDSIGHT_BANK_ID: 'agent-bank',
638
+ }
639
+
640
+ it('mirrors the shell request contract (POST recall URL + {query, max_tokens})', async () => {
641
+ let seenUrl = ''
642
+ let seenInit: RequestInit | undefined
643
+ const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => {
644
+ seenUrl = String(url)
645
+ seenInit = init
646
+ return jsonResponse(200, okBody)
647
+ }) as unknown as typeof fetch
648
+ const results = await fetchHindsightRecall(liveEnv, { fetchImpl })
649
+ // Trailing slash trimmed; the exact recall path the bash script hits.
650
+ expect(seenUrl).toBe('http://hindsight.internal:8080/v1/default/banks/agent-bank/memories/recall')
651
+ expect(seenInit?.method).toBe('POST')
652
+ const parsed = JSON.parse(String(seenInit?.body))
653
+ expect(parsed.query).toBe('what was happening recently in our conversation?')
654
+ expect(parsed.max_tokens).toBe(800)
655
+ expect(results).toEqual([
656
+ { text: 'first memory', timestamp: '2026-08-01T00:00:00Z' },
657
+ { text: 'second memory', timestamp: null },
658
+ ])
659
+ })
660
+
661
+ it('returns [] when the env is missing (no HINDSIGHT_API_URL / BANK_ID) — no fetch at all', async () => {
662
+ let called = false
663
+ const fetchImpl = (async () => {
664
+ called = true
665
+ return jsonResponse(200, okBody)
666
+ }) as unknown as typeof fetch
667
+ expect(await fetchHindsightRecall({}, { fetchImpl })).toEqual([])
668
+ expect(await fetchHindsightRecall({ HINDSIGHT_API_URL: 'http://x' }, { fetchImpl })).toEqual([])
669
+ expect(called).toBe(false)
670
+ })
671
+
672
+ it('returns [] on a non-200 response (graceful skip, never throws)', async () => {
673
+ const fetchImpl = (async () => jsonResponse(503, { error: 'down' })) as unknown as typeof fetch
674
+ expect(await fetchHindsightRecall(liveEnv, { fetchImpl })).toEqual([])
675
+ })
676
+
677
+ it('returns [] on a fetch rejection / timeout (AbortError), never throws', async () => {
678
+ const fetchImpl = (async () => {
679
+ throw new DOMException('aborted', 'AbortError')
680
+ }) as unknown as typeof fetch
681
+ expect(await fetchHindsightRecall(liveEnv, { fetchImpl })).toEqual([])
682
+ })
683
+
684
+ it('honours the abort timeout (a slow endpoint yields [] within the budget)', async () => {
685
+ const fetchImpl = (async (_url: unknown, init?: RequestInit) => {
686
+ // Never resolve until aborted — mirrors a hung Hindsight.
687
+ return await new Promise<Response>((_resolve, reject) => {
688
+ init?.signal?.addEventListener('abort', () =>
689
+ reject(new DOMException('aborted', 'AbortError')),
690
+ )
691
+ })
692
+ }) as unknown as typeof fetch
693
+ const start = Date.now()
694
+ const results = await fetchHindsightRecall(liveEnv, { fetchImpl, timeoutMs: 50 })
695
+ expect(results).toEqual([])
696
+ expect(Date.now() - start).toBeLessThan(2000)
697
+ })
698
+
699
+ it('returns [] on malformed JSON (results absent / not an array)', async () => {
700
+ const fetchImpl = (async () => jsonResponse(200, { notResults: 1 })) as unknown as typeof fetch
701
+ expect(await fetchHindsightRecall(liveEnv, { fetchImpl })).toEqual([])
702
+ })
703
+ })
704
+
705
+ describe('readDailyMemory — graceful-skip paths (source 3 wiring)', () => {
706
+ // NOW_MS = 1_754_000_000_000 → 2025-07-31/08-01 depending on tz. Compute the
707
+ // expected date the same way the impl does so the assertion can't drift.
708
+ function expectedDate(tz: string): string {
709
+ return new Intl.DateTimeFormat('en-CA', {
710
+ timeZone: tz,
711
+ year: 'numeric',
712
+ month: '2-digit',
713
+ day: '2-digit',
714
+ }).format(new Date(NOW_MS))
715
+ }
716
+
717
+ it('reads <agentDir>/workspace/memory/<today>.md (correct path, not the shell bug path)', () => {
718
+ const date = expectedDate('UTC')
719
+ let seenPath = ''
720
+ const out = readDailyMemory(
721
+ '/state/agent',
722
+ { SWITCHROOM_TIMEZONE: 'UTC' },
723
+ NOW_MS,
724
+ (p) => {
725
+ seenPath = p
726
+ return '# today\nshipped the parity PR'
727
+ },
728
+ )
729
+ expect(seenPath).toBe(`/state/agent/workspace/memory/${date}.md`)
730
+ expect(out).toEqual({ date, content: '# today\nshipped the parity PR' })
731
+ })
732
+
733
+ it('honours an explicit WORKSPACE_DIR override when set', () => {
734
+ const date = expectedDate('UTC')
735
+ let seenPath = ''
736
+ readDailyMemory(
737
+ '/state/agent',
738
+ { SWITCHROOM_TIMEZONE: 'UTC', WORKSPACE_DIR: '/custom/ws' },
739
+ NOW_MS,
740
+ (p) => {
741
+ seenPath = p
742
+ return 'content'
743
+ },
744
+ )
745
+ expect(seenPath).toBe(`/custom/ws/memory/${date}.md`)
746
+ })
747
+
748
+ it('derives "today" in the agent LOCAL timezone (not UTC)', () => {
749
+ // A far-eastern zone can be a day ahead of UTC at this instant.
750
+ const dateSydney = expectedDate('Australia/Sydney')
751
+ let seenPath = ''
752
+ readDailyMemory('/a', { SWITCHROOM_TIMEZONE: 'Australia/Sydney' }, NOW_MS, (p) => {
753
+ seenPath = p
754
+ return 'x'
755
+ })
756
+ expect(seenPath).toBe(`/a/workspace/memory/${dateSydney}.md`)
757
+ })
758
+
759
+ it('returns null on ENOENT (missing daily file), never throws', () => {
760
+ const out = readDailyMemory('/a', { SWITCHROOM_TIMEZONE: 'UTC' }, NOW_MS, () => {
761
+ const e = new Error('ENOENT') as NodeJS.ErrnoException
762
+ e.code = 'ENOENT'
763
+ throw e
764
+ })
765
+ expect(out).toBeNull()
766
+ })
767
+
768
+ it('returns null on an empty / whitespace-only file (no empty section)', () => {
769
+ expect(
770
+ readDailyMemory('/a', { SWITCHROOM_TIMEZONE: 'UTC' }, NOW_MS, () => ' \n\t '),
771
+ ).toBeNull()
474
772
  })
773
+ })
475
774
 
476
- it('threads a real resumeMsg end-to-end: elides the interrupted-turn window from the queued briefing on that surface', () => {
775
+ describe('maybeQueueBootBriefing end-to-end with Hindsight + daily memory', () => {
776
+ it('folds a live Hindsight recall into the queued briefing (awaited before put)', async () => {
777
+ seedUser('321', null, 30 * 60, 'primary conversation ask')
778
+ const puts: Array<{ agent: string; msg: InboundMessage }> = []
779
+ const fetchImpl = (async () =>
780
+ new Response(
781
+ JSON.stringify({ results: [{ text: 'we were mid-deploy', timestamp: '2026-08-01T00:00:00Z' }] }),
782
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
783
+ )) as unknown as typeof fetch
784
+ const queued = await maybeQueueBootBriefing({
785
+ env: {
786
+ SWITCHROOM_SESSION_BRIEFING: 'gateway',
787
+ SWITCHROOM_RESUME_MODE: 'handoff',
788
+ SWITCHROOM_AGENT_NAME: 'testagent',
789
+ HINDSIGHT_API_URL: 'http://hindsight.internal',
790
+ HINDSIGHT_BANK_ID: 'agent-bank',
791
+ },
792
+ stateDir: join(stateDir, 'telegram'),
793
+ resumeMsg: null,
794
+ put: (agent, msg) => puts.push({ agent, msg }),
795
+ log: () => {},
796
+ nowMs: NOW_MS,
797
+ fetchImpl,
798
+ })
799
+ expect(queued).not.toBeNull()
800
+ expect(puts.length).toBe(1)
801
+ // The section is PRESENT in the enqueued text — proving the fetch was
802
+ // awaited to completion before put, not raced in late.
803
+ expect(puts[0]!.msg.text).toContain('## Hindsight recall (recent context)')
804
+ expect(puts[0]!.msg.text).toContain('- we were mid-deploy (2026-08-01T00:00:00Z)')
805
+ expect(puts[0]!.msg.text).toContain('primary conversation ask')
806
+ expect(puts[0]!.msg.text.length).toBeLessThanOrEqual(BRIEFING_CHAR_BUDGET)
807
+ })
808
+
809
+ it('still queues (telegram-only) when Hindsight fails and no daily file exists', async () => {
810
+ seedUser('321', null, 30 * 60, 'primary conversation ask')
811
+ const puts: Array<{ agent: string; msg: InboundMessage }> = []
812
+ const fetchImpl = (async () => {
813
+ throw new Error('connection refused')
814
+ }) as unknown as typeof fetch
815
+ const queued = await maybeQueueBootBriefing({
816
+ env: {
817
+ SWITCHROOM_SESSION_BRIEFING: 'gateway',
818
+ SWITCHROOM_RESUME_MODE: 'handoff',
819
+ SWITCHROOM_AGENT_NAME: 'testagent',
820
+ HINDSIGHT_API_URL: 'http://hindsight.internal',
821
+ HINDSIGHT_BANK_ID: 'agent-bank',
822
+ },
823
+ stateDir: join(stateDir, 'telegram'),
824
+ resumeMsg: null,
825
+ put: (agent, msg) => puts.push({ agent, msg }),
826
+ log: () => {},
827
+ nowMs: NOW_MS,
828
+ fetchImpl,
829
+ })
830
+ expect(queued).not.toBeNull()
831
+ expect(puts[0]!.msg.text).toContain('primary conversation ask')
832
+ expect(puts[0]!.msg.text).not.toContain('## Hindsight recall')
833
+ expect(puts[0]!.msg.text).not.toContain("## Today's memory")
834
+ })
835
+
836
+ it('does not fetch Hindsight when there is no active surface (no delivery target)', async () => {
837
+ let called = false
838
+ const fetchImpl = (async () => {
839
+ called = true
840
+ return new Response('{}', { status: 200 })
841
+ }) as unknown as typeof fetch
842
+ const queued = await maybeQueueBootBriefing({
843
+ env: {
844
+ SWITCHROOM_SESSION_BRIEFING: 'gateway',
845
+ SWITCHROOM_RESUME_MODE: 'handoff',
846
+ SWITCHROOM_AGENT_NAME: 'testagent',
847
+ HINDSIGHT_API_URL: 'http://hindsight.internal',
848
+ HINDSIGHT_BANK_ID: 'agent-bank',
849
+ },
850
+ stateDir: join(stateDir, 'telegram'),
851
+ resumeMsg: null,
852
+ put: () => {
853
+ throw new Error('must not be called')
854
+ },
855
+ log: () => {},
856
+ nowMs: NOW_MS,
857
+ fetchImpl,
858
+ })
859
+ expect(queued).toBeNull()
860
+ expect(called).toBe(false)
861
+ })
862
+
863
+ it('threads a real resumeMsg end-to-end: elides the interrupted-turn window from the queued briefing on that surface', async () => {
477
864
  // #4247: every other wiring test passes resumeMsg: null, so the
478
865
  // resume-dedup path (interrupted-turn window elided so the boot-resume
479
866
  // synthetic and the briefing never double-inject the same messages) was
@@ -504,8 +891,12 @@ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
504
891
  },
505
892
  } as InboundMessage
506
893
  const puts: Array<{ agent: string; msg: InboundMessage }> = []
507
- const queued = maybeQueueBootBriefing({
508
- env: envFor('gateway'),
894
+ const queued = await maybeQueueBootBriefing({
895
+ env: {
896
+ SWITCHROOM_SESSION_BRIEFING: 'gateway',
897
+ SWITCHROOM_RESUME_MODE: 'handoff',
898
+ SWITCHROOM_AGENT_NAME: 'testagent',
899
+ },
509
900
  stateDir: join(stateDir, 'telegram'),
510
901
  resumeMsg,
511
902
  put: (agent, msg) => puts.push({ agent, msg }),
@@ -547,12 +938,12 @@ describe('maybeQueueBootBriefing — session-generation guard (#4242)', () => {
547
938
  nowMs: NOW_MS,
548
939
  })
549
940
 
550
- it('re-mints ONCE per boot generation: a supervisor respawn (same boot id) queues nothing', () => {
941
+ it('re-mints ONCE per boot generation: a supervisor respawn (same boot id) queues nothing', async () => {
551
942
  seedUser('321', null, 30 * 60, 'the deploy is half-done')
552
943
  const puts: Array<{ agent: string; msg: InboundMessage }> = []
553
944
 
554
945
  // Boot-1: first gateway process of this generation briefs.
555
- const first = call(envGen('gen-1'), puts)
946
+ const first = await call(envGen('gen-1'), puts)
556
947
  expect(first).not.toBeNull()
557
948
  expect(puts.length).toBe(1)
558
949
  // Generation persisted for the respawn check.
@@ -561,43 +952,43 @@ describe('maybeQueueBootBriefing — session-generation guard (#4242)', () => {
561
952
  // Respawn: same shell → same SWITCHROOM_GATEWAY_BOOT_ID. The gateway
562
953
  // module re-evaluates, but the inner Claude session is still live from
563
954
  // boot-1 — re-injecting a "you just rebooted" briefing would be wrong.
564
- const respawn = call(envGen('gen-1'), puts)
955
+ const respawn = await call(envGen('gen-1'), puts)
565
956
  expect(respawn).toBeNull()
566
957
  expect(puts.length).toBe(1) // no second put
567
958
  })
568
959
 
569
- it('a GENUINE new boot (fresh boot id) briefs again', () => {
960
+ it('a GENUINE new boot (fresh boot id) briefs again', async () => {
570
961
  seedUser('321', null, 30 * 60, 'still pending your call')
571
962
  const puts: Array<{ agent: string; msg: InboundMessage }> = []
572
963
 
573
- expect(call(envGen('gen-1'), puts)).not.toBeNull()
964
+ expect(await call(envGen('gen-1'), puts)).not.toBeNull()
574
965
  expect(puts.length).toBe(1)
575
966
  // Next real container boot re-derives a different id → not a respawn.
576
- expect(call(envGen('gen-2'), puts)).not.toBeNull()
967
+ expect(await call(envGen('gen-2'), puts)).not.toBeNull()
577
968
  expect(puts.length).toBe(2)
578
969
  })
579
970
 
580
- it('consumes the generation even when the first boot had nothing to brief (no mid-session brief on respawn)', () => {
971
+ it('consumes the generation even when the first boot had nothing to brief (no mid-session brief on respawn)', async () => {
581
972
  const puts: Array<{ agent: string; msg: InboundMessage }> = []
582
973
  // Boot-1: empty history → nothing queued, but the generation is consumed.
583
- expect(call(envGen('gen-1'), puts)).toBeNull()
974
+ expect(await call(envGen('gen-1'), puts)).toBeNull()
584
975
  expect(puts.length).toBe(0)
585
976
  expect(existsSync(join(stateDir, '.boot-briefing-generation'))).toBe(true)
586
977
 
587
978
  // Messages arrive AFTER the session is live, then the gateway respawns.
588
979
  seedUser('321', null, 5 * 60, 'a message that landed mid-session')
589
- const respawn = call(envGen('gen-1'), puts)
980
+ const respawn = await call(envGen('gen-1'), puts)
590
981
  expect(respawn).toBeNull()
591
982
  expect(puts.length).toBe(0) // must NOT brief into the live session
592
983
  })
593
984
 
594
- it('guard is inert when SWITCHROOM_GATEWAY_BOOT_ID is absent (non-docker / pre-upgrade start.sh keeps legacy behaviour)', () => {
985
+ it('guard is inert when SWITCHROOM_GATEWAY_BOOT_ID is absent (non-docker / pre-upgrade start.sh keeps legacy behaviour)', async () => {
595
986
  seedUser('321', null, 30 * 60, 'legacy path message')
596
987
  const puts: Array<{ agent: string; msg: InboundMessage }> = []
597
988
  // With no boot id, every gateway start briefs as before — no marker
598
989
  // written, no suppression.
599
- expect(call(envGen(undefined), puts)).not.toBeNull()
600
- expect(call(envGen(undefined), puts)).not.toBeNull()
990
+ expect(await call(envGen(undefined), puts)).not.toBeNull()
991
+ expect(await call(envGen(undefined), puts)).not.toBeNull()
601
992
  expect(puts.length).toBe(2)
602
993
  expect(existsSync(join(stateDir, '.boot-briefing-generation'))).toBe(false)
603
994
  })