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.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +2 -2
- package/dist/auth-broker/index.js +4 -3
- package/dist/buzz-gateway/index.js +166 -6
- package/dist/cli/notion-write-pretool.mjs +2 -2
- package/dist/cli/switchroom.js +24704 -16399
- package/dist/host-control/main.js +44 -10
- package/dist/vault/approvals/kernel-server.js +4 -3
- package/dist/vault/broker/server.js +4 -3
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -10
- package/telegram-plugin/dist/gateway/gateway.js +1400 -964
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
- package/telegram-plugin/gateway/gateway.ts +43 -123
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- 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)
|
|
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 {
|
|
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
|
-
|
|
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
|
-
).
|
|
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
|
-
|
|
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:
|
|
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
|
})
|