switchroom 0.19.48 → 0.20.1
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 +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- 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 +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- 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
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- package/vendor/hindsight-memory/scripts/recall.py +7 -2
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome tests for the gateway boot briefing
|
|
3
|
+
* (`session_continuity.briefing: gateway`).
|
|
4
|
+
*
|
|
5
|
+
* Runs under `bun test` (vitest-excluded): the collector is exercised
|
|
6
|
+
* against the REAL history schema — rows are seeded through history.ts's
|
|
7
|
+
* own writers (`recordInbound` / `recordOutbound`) into a real bun:sqlite
|
|
8
|
+
* DB, so the surface-scoping SQL is proven against the production table,
|
|
9
|
+
* not a hand-rolled fixture schema.
|
|
10
|
+
*
|
|
11
|
+
* What must be provable here (each test would fail on the bug it guards):
|
|
12
|
+
* - surface scoping: DM vs forum-topic selection, primary-vs-secondary
|
|
13
|
+
* - the 48h activity window and the 15-message primary depth
|
|
14
|
+
* - the hard character budget (newest kept, oldest dropped)
|
|
15
|
+
* - per-message truncation
|
|
16
|
+
* - resume-inbound dedup (interrupted-turn window elided, only on the
|
|
17
|
+
* resumed surface)
|
|
18
|
+
* - SQLITE_BUSY / any DB failure → EMPTY briefing, never a throw
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
22
|
+
import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'fs'
|
|
23
|
+
import { tmpdir } from 'os'
|
|
24
|
+
import { join } from 'path'
|
|
25
|
+
import {
|
|
26
|
+
initHistory,
|
|
27
|
+
recordInbound,
|
|
28
|
+
recordOutbound,
|
|
29
|
+
getHistoryDbForBriefing,
|
|
30
|
+
_resetForTests,
|
|
31
|
+
} from '../history.js'
|
|
32
|
+
import {
|
|
33
|
+
BRIEFING_CHAR_BUDGET,
|
|
34
|
+
BRIEFING_PER_MESSAGE_MAX_CHARS,
|
|
35
|
+
BRIEFING_PRIMARY_DEPTH,
|
|
36
|
+
BOOT_BRIEFING_SOURCE,
|
|
37
|
+
buildBootBriefingInbound,
|
|
38
|
+
collectBriefingSurfaces,
|
|
39
|
+
decideBootBriefing,
|
|
40
|
+
excludeWindowFromResumeInbound,
|
|
41
|
+
readRestartBreadcrumb,
|
|
42
|
+
renderBootBriefing,
|
|
43
|
+
type BriefingDb,
|
|
44
|
+
} from '../gateway/boot-briefing-builder.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'
|
|
51
|
+
import { spoolId } from '../gateway/inbound-spool.js'
|
|
52
|
+
import type { InboundMessage } from '../gateway/ipc-protocol.js'
|
|
53
|
+
|
|
54
|
+
let stateDir: string
|
|
55
|
+
let msgId = 0
|
|
56
|
+
|
|
57
|
+
const NOW_MS = 1_754_000_000_000 // fixed "now" (ms)
|
|
58
|
+
const NOW_SEC = Math.floor(NOW_MS / 1000)
|
|
59
|
+
const HOUR = 3600
|
|
60
|
+
|
|
61
|
+
/** Seed one user-inbound row at `ageSec` seconds before NOW. */
|
|
62
|
+
function seedUser(
|
|
63
|
+
chat: string,
|
|
64
|
+
thread: number | null,
|
|
65
|
+
ageSec: number,
|
|
66
|
+
text: string,
|
|
67
|
+
user = 'ken',
|
|
68
|
+
): void {
|
|
69
|
+
recordInbound({
|
|
70
|
+
chat_id: chat,
|
|
71
|
+
thread_id: thread,
|
|
72
|
+
message_id: ++msgId,
|
|
73
|
+
user,
|
|
74
|
+
user_id: '1',
|
|
75
|
+
ts: NOW_SEC - ageSec,
|
|
76
|
+
text,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Seed one assistant-outbound row at `ageSec` seconds before NOW. */
|
|
81
|
+
function seedBot(chat: string, thread: number | null, ageSec: number, text: string): void {
|
|
82
|
+
recordOutbound({
|
|
83
|
+
chat_id: chat,
|
|
84
|
+
thread_id: thread,
|
|
85
|
+
message_ids: [++msgId],
|
|
86
|
+
texts: [text],
|
|
87
|
+
ts: NOW_SEC - ageSec,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function db(): BriefingDb {
|
|
92
|
+
const h = getHistoryDbForBriefing()
|
|
93
|
+
if (h == null) throw new Error('history not initialised')
|
|
94
|
+
return h
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
stateDir = mkdtempSync(join(tmpdir(), 'boot-briefing-test-'))
|
|
99
|
+
initHistory(stateDir, 0)
|
|
100
|
+
msgId = 0
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
afterEach(() => {
|
|
104
|
+
_resetForTests()
|
|
105
|
+
if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('collectBriefingSurfaces — surface scoping', () => {
|
|
109
|
+
it('DM shape: one full-depth section per chat, most-recent chat primary, other chat header-only', () => {
|
|
110
|
+
seedUser('111', null, 5 * HOUR, 'older chat question')
|
|
111
|
+
seedBot('111', null, 5 * HOUR - 60, 'older chat answer')
|
|
112
|
+
seedUser('222', null, 2 * HOUR, 'recent chat question')
|
|
113
|
+
seedBot('222', null, 2 * HOUR - 60, 'recent chat answer')
|
|
114
|
+
|
|
115
|
+
const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
|
|
116
|
+
expect(surfaces.length).toBe(2)
|
|
117
|
+
// Primary = the most-recently-active surface, at full depth.
|
|
118
|
+
expect(surfaces[0]!.chatId).toBe('222')
|
|
119
|
+
expect(surfaces[0]!.threadId).toBeNull()
|
|
120
|
+
expect(surfaces[0]!.messages.map((m) => m.text)).toEqual([
|
|
121
|
+
'recent chat question',
|
|
122
|
+
'recent chat answer',
|
|
123
|
+
])
|
|
124
|
+
// Secondary = last message only.
|
|
125
|
+
expect(surfaces[1]!.chatId).toBe('111')
|
|
126
|
+
expect(surfaces[1]!.messages.length).toBe(1)
|
|
127
|
+
expect(surfaces[1]!.messages[0]!.text).toBe('older chat answer')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('forum shape: groups by (chat, thread); primary topic full depth, sibling topic secondary, DM-root separate', () => {
|
|
131
|
+
seedUser('999', 10, 1 * HOUR, 'topic-10 latest ask')
|
|
132
|
+
seedUser('999', 20, 3 * HOUR, 'topic-20 ask')
|
|
133
|
+
seedUser('999', null, 6 * HOUR, 'general (no topic) ask')
|
|
134
|
+
|
|
135
|
+
const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
|
|
136
|
+
expect(surfaces.map((s) => [s.chatId, s.threadId])).toEqual([
|
|
137
|
+
['999', 10],
|
|
138
|
+
['999', 20],
|
|
139
|
+
['999', null],
|
|
140
|
+
])
|
|
141
|
+
// Topic scoping is real: topic-10's messages never contain topic-20's.
|
|
142
|
+
expect(surfaces[0]!.messages.map((m) => m.text)).toEqual(['topic-10 latest ask'])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('caps the primary section at the depth bound (15), keeping the NEWEST oldest-first', () => {
|
|
146
|
+
for (let i = 0; i < 25; i++) {
|
|
147
|
+
// msg-0 is oldest (25h... no: ages 25..1 minutes)
|
|
148
|
+
seedUser('42', null, (25 - i) * 60, `msg-${i}`)
|
|
149
|
+
}
|
|
150
|
+
const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
|
|
151
|
+
expect(surfaces.length).toBe(1)
|
|
152
|
+
const texts = surfaces[0]!.messages.map((m) => m.text)
|
|
153
|
+
expect(texts.length).toBe(BRIEFING_PRIMARY_DEPTH)
|
|
154
|
+
// Newest 15 (msg-10..msg-24), oldest-first.
|
|
155
|
+
expect(texts[0]).toBe('msg-10')
|
|
156
|
+
expect(texts[texts.length - 1]).toBe('msg-24')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('excludes surfaces with no activity inside the 48h window', () => {
|
|
160
|
+
seedUser('act', null, 47 * HOUR, 'inside window')
|
|
161
|
+
seedUser('stale', null, 49 * HOUR, 'outside window')
|
|
162
|
+
const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
|
|
163
|
+
expect(surfaces.map((s) => s.chatId)).toEqual(['act'])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('elides messages covered by the resume inbound window — on that surface only', () => {
|
|
167
|
+
const interruptStartMs = NOW_MS - 30 * 60 * 1000 // turn started 30 min ago
|
|
168
|
+
seedUser('77', null, 2 * HOUR, 'before the interrupted turn')
|
|
169
|
+
seedUser('77', null, 10 * 60, 'the interrupted request itself') // inside window
|
|
170
|
+
seedUser('88', null, 10 * 60, 'unrelated chat, same recency')
|
|
171
|
+
|
|
172
|
+
const surfaces = collectBriefingSurfaces(db(), {
|
|
173
|
+
nowMs: NOW_MS,
|
|
174
|
+
exclude: { chatId: '77', threadId: null, sinceMs: interruptStartMs },
|
|
175
|
+
})
|
|
176
|
+
const s77 = surfaces.find((s) => s.chatId === '77')!
|
|
177
|
+
const s88 = surfaces.find((s) => s.chatId === '88')!
|
|
178
|
+
// The resumed turn's own message is elided (the resume inbound already
|
|
179
|
+
// carries it); the earlier context survives.
|
|
180
|
+
expect(s77.messages.map((m) => m.text)).toEqual(['before the interrupted turn'])
|
|
181
|
+
// The unrelated surface is untouched by the window.
|
|
182
|
+
expect(s88.messages.map((m) => m.text)).toEqual(['unrelated chat, same recency'])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('drops a surface entirely when the resume window elides all of it', () => {
|
|
186
|
+
seedUser('55', null, 10 * 60, 'only message, inside the resume window')
|
|
187
|
+
const surfaces = collectBriefingSurfaces(db(), {
|
|
188
|
+
nowMs: NOW_MS,
|
|
189
|
+
exclude: { chatId: '55', threadId: null, sinceMs: NOW_MS - 30 * 60 * 1000 },
|
|
190
|
+
})
|
|
191
|
+
expect(surfaces).toEqual([])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('returns [] (never throws) on SQLITE_BUSY / any DB failure', () => {
|
|
195
|
+
const busyDb: BriefingDb = {
|
|
196
|
+
prepare() {
|
|
197
|
+
throw new Error('SQLITE_BUSY: database is locked')
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
expect(collectBriefingSurfaces(busyDb, { nowMs: NOW_MS })).toEqual([])
|
|
201
|
+
const busyAll: BriefingDb = {
|
|
202
|
+
prepare: () => ({
|
|
203
|
+
all() {
|
|
204
|
+
throw new Error('SQLITE_BUSY: database is locked')
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
}
|
|
208
|
+
expect(collectBriefingSurfaces(busyAll, { nowMs: NOW_MS })).toEqual([])
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('renderBootBriefing — bounds', () => {
|
|
213
|
+
it('renders nothing for no surfaces', () => {
|
|
214
|
+
expect(renderBootBriefing([], { nowMs: NOW_MS })).toBe('')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('never exceeds the character budget, dropping OLDEST primary messages first', () => {
|
|
218
|
+
for (let i = 0; i < 15; i++) {
|
|
219
|
+
seedUser('9', null, (15 - i) * 60, `padding-${i} ` + 'x'.repeat(380))
|
|
220
|
+
}
|
|
221
|
+
const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
|
|
222
|
+
// Default bounds hold: 15 truncated messages + header fit the budget.
|
|
223
|
+
const full = renderBootBriefing(surfaces, { nowMs: NOW_MS })
|
|
224
|
+
expect(full.length).toBeGreaterThan(0)
|
|
225
|
+
expect(full.length).toBeLessThanOrEqual(BRIEFING_CHAR_BUDGET)
|
|
226
|
+
// Cap enforcement: with a tighter budget the render must trim the
|
|
227
|
+
// OLDEST primary messages first and never exceed the cap.
|
|
228
|
+
const tight = renderBootBriefing(surfaces, { nowMs: NOW_MS, charBudget: 3000 })
|
|
229
|
+
expect(tight.length).toBeGreaterThan(0)
|
|
230
|
+
expect(tight.length).toBeLessThanOrEqual(3000)
|
|
231
|
+
expect(tight).toContain('padding-14') // newest kept
|
|
232
|
+
expect(tight).not.toContain('padding-0 ') // oldest dropped
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('truncates each message to the per-message bound', () => {
|
|
236
|
+
seedUser('9', null, 60, 'HEAD-' + 'y'.repeat(2000))
|
|
237
|
+
const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
|
|
238
|
+
const text = renderBootBriefing(surfaces, { nowMs: NOW_MS })
|
|
239
|
+
const line = text.split('\n').find((l) => l.includes('HEAD-'))!
|
|
240
|
+
expect(line.length).toBeLessThanOrEqual(BRIEFING_PER_MESSAGE_MAX_CHARS + 40) // + prefix/label
|
|
241
|
+
expect(line.endsWith('…')).toBe(true)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('renders secondary surfaces as a header + last-message preview and folds in the restart reason', () => {
|
|
245
|
+
seedUser('1', null, 30 * 60, 'primary talk')
|
|
246
|
+
seedUser('2', 7, 5 * HOUR, 'secondary topic message')
|
|
247
|
+
const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
|
|
248
|
+
const text = renderBootBriefing(surfaces, { nowMs: NOW_MS, restartReason: 'sigterm' })
|
|
249
|
+
expect(text).toContain('## Active conversation — chat 1')
|
|
250
|
+
expect(text).toContain('## Other recent surfaces')
|
|
251
|
+
expect(text).toContain('chat 2, topic 7')
|
|
252
|
+
expect(text).toContain('secondary topic message')
|
|
253
|
+
expect(text).toContain('ended via: sigterm')
|
|
254
|
+
// Loop-guard contract: the briefing turn must classify as a synthetic
|
|
255
|
+
// boot turn if it is itself interrupted.
|
|
256
|
+
expect(text.startsWith('You just restarted.')).toBe(true)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('decideBootBriefing — feature flag and suppression', () => {
|
|
261
|
+
const base = { briefingMode: 'gateway', resumeMode: 'handoff', forceFreshMarker: false }
|
|
262
|
+
it('defaults to legacy (no briefing) unless the flag opts in', () => {
|
|
263
|
+
expect(decideBootBriefing({ ...base, briefingMode: undefined }).build).toBe(false)
|
|
264
|
+
expect(decideBootBriefing({ ...base, briefingMode: 'legacy' }).build).toBe(false)
|
|
265
|
+
expect(decideBootBriefing(base).build).toBe(true)
|
|
266
|
+
})
|
|
267
|
+
it('suppresses when --continue/auto may replay the transcript', () => {
|
|
268
|
+
expect(decideBootBriefing({ ...base, resumeMode: 'continue' })).toEqual({
|
|
269
|
+
build: false,
|
|
270
|
+
reason: 'transcript-replay-possible',
|
|
271
|
+
})
|
|
272
|
+
expect(decideBootBriefing({ ...base, resumeMode: 'auto' }).build).toBe(false)
|
|
273
|
+
expect(decideBootBriefing({ ...base, resumeMode: 'none' }).build).toBe(true)
|
|
274
|
+
})
|
|
275
|
+
it('suppresses on a /reset force-fresh boot', () => {
|
|
276
|
+
expect(decideBootBriefing({ ...base, forceFreshMarker: true })).toEqual({
|
|
277
|
+
build: false,
|
|
278
|
+
reason: 'force-fresh',
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('inbound shape + spool dedup', () => {
|
|
284
|
+
it('mints a boot_briefing inbound whose spool id is stable across boots (per chat)', () => {
|
|
285
|
+
const a = buildBootBriefingInbound({ chatId: '5', threadId: null, text: 'brief', nowMs: NOW_MS })
|
|
286
|
+
const b = buildBootBriefingInbound({
|
|
287
|
+
chatId: '5',
|
|
288
|
+
threadId: null,
|
|
289
|
+
text: 'brief again',
|
|
290
|
+
nowMs: NOW_MS + 60_000, // a later boot
|
|
291
|
+
})
|
|
292
|
+
expect(a.meta.source).toBe(BOOT_BRIEFING_SOURCE)
|
|
293
|
+
expect(a.meta.chat_id).toBe('5')
|
|
294
|
+
expect(Number(a.meta.expiresAt)).toBeGreaterThan(NOW_MS)
|
|
295
|
+
// Same spool id despite different synthetic messageIds — a multi-restart
|
|
296
|
+
// sequence collapses to ONE live briefing instead of stacking N.
|
|
297
|
+
expect(spoolId(a)).toBe(spoolId(b))
|
|
298
|
+
expect(spoolId(a)).toBe('s:boot-briefing:5')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('derives the resume-dedup window from a resume inbound meta', () => {
|
|
302
|
+
const resumeMsg = {
|
|
303
|
+
type: 'inbound',
|
|
304
|
+
chatId: '77',
|
|
305
|
+
messageId: 1,
|
|
306
|
+
user: 'switchroom',
|
|
307
|
+
userId: 0,
|
|
308
|
+
ts: NOW_MS,
|
|
309
|
+
text: 'You just restarted.',
|
|
310
|
+
meta: {
|
|
311
|
+
source: 'resume_interrupted',
|
|
312
|
+
chat_id: '77',
|
|
313
|
+
message_thread_id: '12',
|
|
314
|
+
started_at: String(NOW_MS - 1000),
|
|
315
|
+
},
|
|
316
|
+
} as InboundMessage
|
|
317
|
+
expect(excludeWindowFromResumeInbound(resumeMsg)).toEqual({
|
|
318
|
+
chatId: '77',
|
|
319
|
+
threadId: 12,
|
|
320
|
+
sinceMs: NOW_MS - 1000,
|
|
321
|
+
})
|
|
322
|
+
expect(excludeWindowFromResumeInbound(null)).toBeNull()
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('readRestartBreadcrumb', () => {
|
|
327
|
+
it('reads the .restart-reason first line, with SWITCHROOM_PENDING_ENDED_VIA overriding', () => {
|
|
328
|
+
const read = () => 'operator restart\nsecond line'
|
|
329
|
+
expect(
|
|
330
|
+
readRestartBreadcrumb({ restartReasonPath: '/x/.restart-reason', env: {}, readFile: read }),
|
|
331
|
+
).toBe('operator restart')
|
|
332
|
+
expect(
|
|
333
|
+
readRestartBreadcrumb({
|
|
334
|
+
restartReasonPath: '/x/.restart-reason',
|
|
335
|
+
env: { SWITCHROOM_PENDING_ENDED_VIA: 'timeout' },
|
|
336
|
+
readFile: read,
|
|
337
|
+
}),
|
|
338
|
+
).toBe('timeout')
|
|
339
|
+
expect(
|
|
340
|
+
readRestartBreadcrumb({
|
|
341
|
+
restartReasonPath: '/missing',
|
|
342
|
+
env: {},
|
|
343
|
+
readFile: () => {
|
|
344
|
+
throw new Error('ENOENT')
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
).toBeNull()
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
describe('maybeQueueBootBriefing — end-to-end wiring', () => {
|
|
352
|
+
function envFor(mode: string): Record<string, string | undefined> {
|
|
353
|
+
return {
|
|
354
|
+
SWITCHROOM_SESSION_BRIEFING: mode,
|
|
355
|
+
SWITCHROOM_RESUME_MODE: 'handoff',
|
|
356
|
+
SWITCHROOM_AGENT_NAME: 'testagent',
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
it('queues a briefing built from real history rows when the flag is gateway', async () => {
|
|
361
|
+
seedUser('321', null, 30 * 60, 'please review the deploy plan')
|
|
362
|
+
seedBot('321', null, 25 * 60, 'on it — reviewing now')
|
|
363
|
+
const puts: Array<{ agent: string; msg: InboundMessage }> = []
|
|
364
|
+
const queued = await maybeQueueBootBriefing({
|
|
365
|
+
env: envFor('gateway'),
|
|
366
|
+
stateDir: join(stateDir, 'telegram'),
|
|
367
|
+
resumeMsg: null,
|
|
368
|
+
put: (agent, msg) => puts.push({ agent, msg }),
|
|
369
|
+
log: () => {},
|
|
370
|
+
nowMs: NOW_MS,
|
|
371
|
+
})
|
|
372
|
+
expect(queued).not.toBeNull()
|
|
373
|
+
expect(puts.length).toBe(1)
|
|
374
|
+
expect(puts[0]!.agent).toBe('testagent')
|
|
375
|
+
expect(puts[0]!.msg.meta.source).toBe('boot_briefing')
|
|
376
|
+
expect(puts[0]!.msg.chatId).toBe('321')
|
|
377
|
+
expect(puts[0]!.msg.text).toContain('please review the deploy plan')
|
|
378
|
+
expect(puts[0]!.msg.text).toContain('on it — reviewing now')
|
|
379
|
+
expect(puts[0]!.msg.text.length).toBeLessThanOrEqual(BRIEFING_CHAR_BUDGET)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('queues NOTHING when the flag is legacy (default) — legacy behaviour untouched', async () => {
|
|
383
|
+
seedUser('321', null, 30 * 60, 'recent message')
|
|
384
|
+
const puts: unknown[] = []
|
|
385
|
+
const queued = await maybeQueueBootBriefing({
|
|
386
|
+
env: envFor('legacy'),
|
|
387
|
+
stateDir: join(stateDir, 'telegram'),
|
|
388
|
+
resumeMsg: null,
|
|
389
|
+
put: (a, m) => puts.push([a, m]),
|
|
390
|
+
log: () => {},
|
|
391
|
+
nowMs: NOW_MS,
|
|
392
|
+
})
|
|
393
|
+
expect(queued).toBeNull()
|
|
394
|
+
expect(puts.length).toBe(0)
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('queues nothing when history is empty', async () => {
|
|
398
|
+
const puts: unknown[] = []
|
|
399
|
+
const queued = await maybeQueueBootBriefing({
|
|
400
|
+
env: envFor('gateway'),
|
|
401
|
+
stateDir: join(stateDir, 'telegram'),
|
|
402
|
+
resumeMsg: null,
|
|
403
|
+
put: (a, m) => puts.push([a, m]),
|
|
404
|
+
log: () => {},
|
|
405
|
+
nowMs: NOW_MS,
|
|
406
|
+
})
|
|
407
|
+
expect(queued).toBeNull()
|
|
408
|
+
expect(puts.length).toBe(0)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('suppresses on a force-fresh (/reset) marker', async () => {
|
|
412
|
+
seedUser('321', null, 30 * 60, 'recent message')
|
|
413
|
+
writeFileSync(join(stateDir, '.force-fresh-session'), '')
|
|
414
|
+
const queued = await maybeQueueBootBriefing({
|
|
415
|
+
env: envFor('gateway'),
|
|
416
|
+
stateDir: join(stateDir, 'telegram'),
|
|
417
|
+
resumeMsg: null,
|
|
418
|
+
put: () => {
|
|
419
|
+
throw new Error('must not be called')
|
|
420
|
+
},
|
|
421
|
+
log: () => {},
|
|
422
|
+
nowMs: NOW_MS,
|
|
423
|
+
})
|
|
424
|
+
expect(queued).toBeNull()
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('suppresses on SWITCHROOM_FORCE_FRESH=1 even when NO marker file exists (env-keyed, race-proof)', async () => {
|
|
428
|
+
// The M1 fix: the decision keys on the env snapshot start.sh takes
|
|
429
|
+
// BEFORE forking the gateway, not on fs state at gateway check time. So
|
|
430
|
+
// the /reset boot is suppressed even after the inner pass has already
|
|
431
|
+
// `rm`ed the marker — the exact race the old existsSync check lost.
|
|
432
|
+
seedUser('321', null, 30 * 60, 'recent message')
|
|
433
|
+
expect(existsSync(join(stateDir, '.force-fresh-session'))).toBe(false)
|
|
434
|
+
const queued = await maybeQueueBootBriefing({
|
|
435
|
+
env: { ...envFor('gateway'), SWITCHROOM_FORCE_FRESH: '1' },
|
|
436
|
+
stateDir: join(stateDir, 'telegram'),
|
|
437
|
+
resumeMsg: null,
|
|
438
|
+
put: () => {
|
|
439
|
+
throw new Error('must not be called')
|
|
440
|
+
},
|
|
441
|
+
log: () => {},
|
|
442
|
+
nowMs: NOW_MS,
|
|
443
|
+
})
|
|
444
|
+
expect(queued).toBeNull()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('suppresses on SWITCHROOM_FORCE_FRESH=1 regardless of whether the marker is present', async () => {
|
|
448
|
+
// Outcome does not depend on fs state at check time: with the env set,
|
|
449
|
+
// the briefing is suppressed whether or not the marker file is on disk.
|
|
450
|
+
seedUser('321', null, 30 * 60, 'recent message')
|
|
451
|
+
writeFileSync(join(stateDir, '.force-fresh-session'), '')
|
|
452
|
+
const queued = await maybeQueueBootBriefing({
|
|
453
|
+
env: { ...envFor('gateway'), SWITCHROOM_FORCE_FRESH: '1' },
|
|
454
|
+
stateDir: join(stateDir, 'telegram'),
|
|
455
|
+
resumeMsg: null,
|
|
456
|
+
put: () => {
|
|
457
|
+
throw new Error('must not be called')
|
|
458
|
+
},
|
|
459
|
+
log: () => {},
|
|
460
|
+
nowMs: NOW_MS,
|
|
461
|
+
})
|
|
462
|
+
expect(queued).toBeNull()
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('never throws even when put itself throws', async () => {
|
|
466
|
+
seedUser('321', null, 30 * 60, 'recent message')
|
|
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(
|
|
470
|
+
maybeQueueBootBriefing({
|
|
471
|
+
env: envFor('gateway'),
|
|
472
|
+
stateDir: join(stateDir, 'telegram'),
|
|
473
|
+
resumeMsg: null,
|
|
474
|
+
put: () => {
|
|
475
|
+
throw new Error('spool exploded')
|
|
476
|
+
},
|
|
477
|
+
log: () => {},
|
|
478
|
+
nowMs: NOW_MS,
|
|
479
|
+
}),
|
|
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()
|
|
772
|
+
})
|
|
773
|
+
})
|
|
774
|
+
|
|
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 () => {
|
|
864
|
+
// #4247: every other wiring test passes resumeMsg: null, so the
|
|
865
|
+
// resume-dedup path (interrupted-turn window elided so the boot-resume
|
|
866
|
+
// synthetic and the briefing never double-inject the same messages) was
|
|
867
|
+
// only ever proven at the pure-function level. This drives a real non-null
|
|
868
|
+
// resumeMsg all the way through maybeQueueBootBriefing and asserts the
|
|
869
|
+
// OUTCOME on the queued inbound's text.
|
|
870
|
+
const startedAtMs = NOW_MS - 10 * 60 * 1000 // interrupted turn began 10m ago
|
|
871
|
+
// Surface 321 (the resumed chat): one message BEFORE the interrupted turn
|
|
872
|
+
// (must survive) and one AT/AFTER it (the resume synthetic already covers
|
|
873
|
+
// it — must be elided).
|
|
874
|
+
seedUser('321', null, 30 * 60, 'context from before the interrupted turn')
|
|
875
|
+
seedUser('321', null, 5 * 60, 'the interrupted request itself')
|
|
876
|
+
// Surface 654 (a different chat): same recency window, NOT the resumed
|
|
877
|
+
// surface, so its message must be untouched by the dedup.
|
|
878
|
+
seedUser('654', null, 5 * 60, 'unrelated chat, must remain')
|
|
879
|
+
const resumeMsg = {
|
|
880
|
+
type: 'inbound',
|
|
881
|
+
chatId: '321',
|
|
882
|
+
messageId: 1,
|
|
883
|
+
user: 'switchroom',
|
|
884
|
+
userId: 0,
|
|
885
|
+
ts: NOW_MS,
|
|
886
|
+
text: 'You just restarted — resuming the interrupted turn.',
|
|
887
|
+
meta: {
|
|
888
|
+
source: 'resume_interrupted',
|
|
889
|
+
chat_id: '321',
|
|
890
|
+
started_at: String(startedAtMs),
|
|
891
|
+
},
|
|
892
|
+
} as InboundMessage
|
|
893
|
+
const puts: Array<{ agent: string; msg: InboundMessage }> = []
|
|
894
|
+
const queued = await maybeQueueBootBriefing({
|
|
895
|
+
env: {
|
|
896
|
+
SWITCHROOM_SESSION_BRIEFING: 'gateway',
|
|
897
|
+
SWITCHROOM_RESUME_MODE: 'handoff',
|
|
898
|
+
SWITCHROOM_AGENT_NAME: 'testagent',
|
|
899
|
+
},
|
|
900
|
+
stateDir: join(stateDir, 'telegram'),
|
|
901
|
+
resumeMsg,
|
|
902
|
+
put: (agent, msg) => puts.push({ agent, msg }),
|
|
903
|
+
log: () => {},
|
|
904
|
+
nowMs: NOW_MS,
|
|
905
|
+
})
|
|
906
|
+
expect(queued).not.toBeNull()
|
|
907
|
+
expect(puts.length).toBe(1)
|
|
908
|
+
const text = puts[0]!.msg.text
|
|
909
|
+
// The pre-interruption message survives; the interrupted request itself is
|
|
910
|
+
// elided (the resume synthetic already re-injects it).
|
|
911
|
+
expect(text).toContain('context from before the interrupted turn')
|
|
912
|
+
expect(text).not.toContain('the interrupted request itself')
|
|
913
|
+
// Dedup is surface-scoped: the unrelated chat is untouched.
|
|
914
|
+
expect(text).toContain('unrelated chat, must remain')
|
|
915
|
+
})
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
describe('maybeQueueBootBriefing — session-generation guard (#4242)', () => {
|
|
919
|
+
function envGen(bootId?: string): Record<string, string | undefined> {
|
|
920
|
+
return {
|
|
921
|
+
SWITCHROOM_SESSION_BRIEFING: 'gateway',
|
|
922
|
+
SWITCHROOM_RESUME_MODE: 'handoff',
|
|
923
|
+
SWITCHROOM_AGENT_NAME: 'testagent',
|
|
924
|
+
...(bootId != null ? { SWITCHROOM_GATEWAY_BOOT_ID: bootId } : {}),
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const call = (
|
|
929
|
+
env: Record<string, string | undefined>,
|
|
930
|
+
puts: Array<{ agent: string; msg: InboundMessage }>,
|
|
931
|
+
) =>
|
|
932
|
+
maybeQueueBootBriefing({
|
|
933
|
+
env,
|
|
934
|
+
stateDir: join(stateDir, 'telegram'),
|
|
935
|
+
resumeMsg: null,
|
|
936
|
+
put: (agent, msg) => puts.push({ agent, msg }),
|
|
937
|
+
log: () => {},
|
|
938
|
+
nowMs: NOW_MS,
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
it('re-mints ONCE per boot generation: a supervisor respawn (same boot id) queues nothing', async () => {
|
|
942
|
+
seedUser('321', null, 30 * 60, 'the deploy is half-done')
|
|
943
|
+
const puts: Array<{ agent: string; msg: InboundMessage }> = []
|
|
944
|
+
|
|
945
|
+
// Boot-1: first gateway process of this generation briefs.
|
|
946
|
+
const first = await call(envGen('gen-1'), puts)
|
|
947
|
+
expect(first).not.toBeNull()
|
|
948
|
+
expect(puts.length).toBe(1)
|
|
949
|
+
// Generation persisted for the respawn check.
|
|
950
|
+
expect(existsSync(join(stateDir, '.boot-briefing-generation'))).toBe(true)
|
|
951
|
+
|
|
952
|
+
// Respawn: same shell → same SWITCHROOM_GATEWAY_BOOT_ID. The gateway
|
|
953
|
+
// module re-evaluates, but the inner Claude session is still live from
|
|
954
|
+
// boot-1 — re-injecting a "you just rebooted" briefing would be wrong.
|
|
955
|
+
const respawn = await call(envGen('gen-1'), puts)
|
|
956
|
+
expect(respawn).toBeNull()
|
|
957
|
+
expect(puts.length).toBe(1) // no second put
|
|
958
|
+
})
|
|
959
|
+
|
|
960
|
+
it('a GENUINE new boot (fresh boot id) briefs again', async () => {
|
|
961
|
+
seedUser('321', null, 30 * 60, 'still pending your call')
|
|
962
|
+
const puts: Array<{ agent: string; msg: InboundMessage }> = []
|
|
963
|
+
|
|
964
|
+
expect(await call(envGen('gen-1'), puts)).not.toBeNull()
|
|
965
|
+
expect(puts.length).toBe(1)
|
|
966
|
+
// Next real container boot re-derives a different id → not a respawn.
|
|
967
|
+
expect(await call(envGen('gen-2'), puts)).not.toBeNull()
|
|
968
|
+
expect(puts.length).toBe(2)
|
|
969
|
+
})
|
|
970
|
+
|
|
971
|
+
it('consumes the generation even when the first boot had nothing to brief (no mid-session brief on respawn)', async () => {
|
|
972
|
+
const puts: Array<{ agent: string; msg: InboundMessage }> = []
|
|
973
|
+
// Boot-1: empty history → nothing queued, but the generation is consumed.
|
|
974
|
+
expect(await call(envGen('gen-1'), puts)).toBeNull()
|
|
975
|
+
expect(puts.length).toBe(0)
|
|
976
|
+
expect(existsSync(join(stateDir, '.boot-briefing-generation'))).toBe(true)
|
|
977
|
+
|
|
978
|
+
// Messages arrive AFTER the session is live, then the gateway respawns.
|
|
979
|
+
seedUser('321', null, 5 * 60, 'a message that landed mid-session')
|
|
980
|
+
const respawn = await call(envGen('gen-1'), puts)
|
|
981
|
+
expect(respawn).toBeNull()
|
|
982
|
+
expect(puts.length).toBe(0) // must NOT brief into the live session
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
it('guard is inert when SWITCHROOM_GATEWAY_BOOT_ID is absent (non-docker / pre-upgrade start.sh keeps legacy behaviour)', async () => {
|
|
986
|
+
seedUser('321', null, 30 * 60, 'legacy path message')
|
|
987
|
+
const puts: Array<{ agent: string; msg: InboundMessage }> = []
|
|
988
|
+
// With no boot id, every gateway start briefs as before — no marker
|
|
989
|
+
// written, no suppression.
|
|
990
|
+
expect(await call(envGen(undefined), puts)).not.toBeNull()
|
|
991
|
+
expect(await call(envGen(undefined), puts)).not.toBeNull()
|
|
992
|
+
expect(puts.length).toBe(2)
|
|
993
|
+
expect(existsSync(join(stateDir, '.boot-briefing-generation'))).toBe(false)
|
|
994
|
+
})
|
|
995
|
+
})
|