switchroom 0.19.48 → 0.20.0

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 (50) hide show
  1. package/dist/agent-scheduler/index.js +18 -1
  2. package/dist/auth-broker/index.js +19 -2
  3. package/dist/buzz-gateway/index.js +9207 -0
  4. package/dist/cli/notion-write-pretool.mjs +18 -1
  5. package/dist/cli/switchroom.js +63 -4
  6. package/dist/host-control/main.js +20 -3
  7. package/dist/vault/approvals/kernel-server.js +19 -2
  8. package/dist/vault/broker/server.js +19 -2
  9. package/package.json +4 -3
  10. package/profiles/_base/start.sh.hbs +78 -1
  11. package/profiles/default/CLAUDE.md.hbs +1 -1
  12. package/skills/dev-protocol/SKILL.md +30 -1
  13. package/skills/switchroom-architecture/SKILL.md +5 -0
  14. package/skills/switchroom-cli/SKILL.md +1 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  16. package/telegram-plugin/dist/gateway/gateway.js +1149 -247
  17. package/telegram-plugin/dist/server.js +7 -4
  18. package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
  19. package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
  20. package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
  21. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  22. package/telegram-plugin/gateway/channel-route.ts +272 -0
  23. package/telegram-plugin/gateway/gateway.ts +73 -81
  24. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  25. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  27. package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
  28. package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
  29. package/telegram-plugin/gateway/stream-render.ts +21 -0
  30. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  31. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  32. package/telegram-plugin/history.ts +15 -0
  33. package/telegram-plugin/llm-error-present.ts +9 -4
  34. package/telegram-plugin/model-unavailable.ts +4 -0
  35. package/telegram-plugin/operator-events.fixtures.json +12 -12
  36. package/telegram-plugin/operator-events.ts +81 -9
  37. package/telegram-plugin/session-tail.ts +7 -1
  38. package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
  39. package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
  40. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  41. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  42. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  43. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  44. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  45. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  46. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  47. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  48. package/telegram-plugin/voice-normalize-text.ts +5 -0
  49. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  50. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -0,0 +1,604 @@
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 { maybeQueueBootBriefing } from '../gateway/boot-briefing-wiring.js'
46
+ import { spoolId } from '../gateway/inbound-spool.js'
47
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
48
+
49
+ let stateDir: string
50
+ let msgId = 0
51
+
52
+ const NOW_MS = 1_754_000_000_000 // fixed "now" (ms)
53
+ const NOW_SEC = Math.floor(NOW_MS / 1000)
54
+ const HOUR = 3600
55
+
56
+ /** Seed one user-inbound row at `ageSec` seconds before NOW. */
57
+ function seedUser(
58
+ chat: string,
59
+ thread: number | null,
60
+ ageSec: number,
61
+ text: string,
62
+ user = 'ken',
63
+ ): void {
64
+ recordInbound({
65
+ chat_id: chat,
66
+ thread_id: thread,
67
+ message_id: ++msgId,
68
+ user,
69
+ user_id: '1',
70
+ ts: NOW_SEC - ageSec,
71
+ text,
72
+ })
73
+ }
74
+
75
+ /** Seed one assistant-outbound row at `ageSec` seconds before NOW. */
76
+ function seedBot(chat: string, thread: number | null, ageSec: number, text: string): void {
77
+ recordOutbound({
78
+ chat_id: chat,
79
+ thread_id: thread,
80
+ message_ids: [++msgId],
81
+ texts: [text],
82
+ ts: NOW_SEC - ageSec,
83
+ })
84
+ }
85
+
86
+ function db(): BriefingDb {
87
+ const h = getHistoryDbForBriefing()
88
+ if (h == null) throw new Error('history not initialised')
89
+ return h
90
+ }
91
+
92
+ beforeEach(() => {
93
+ stateDir = mkdtempSync(join(tmpdir(), 'boot-briefing-test-'))
94
+ initHistory(stateDir, 0)
95
+ msgId = 0
96
+ })
97
+
98
+ afterEach(() => {
99
+ _resetForTests()
100
+ if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
101
+ })
102
+
103
+ describe('collectBriefingSurfaces — surface scoping', () => {
104
+ it('DM shape: one full-depth section per chat, most-recent chat primary, other chat header-only', () => {
105
+ seedUser('111', null, 5 * HOUR, 'older chat question')
106
+ seedBot('111', null, 5 * HOUR - 60, 'older chat answer')
107
+ seedUser('222', null, 2 * HOUR, 'recent chat question')
108
+ seedBot('222', null, 2 * HOUR - 60, 'recent chat answer')
109
+
110
+ const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
111
+ expect(surfaces.length).toBe(2)
112
+ // Primary = the most-recently-active surface, at full depth.
113
+ expect(surfaces[0]!.chatId).toBe('222')
114
+ expect(surfaces[0]!.threadId).toBeNull()
115
+ expect(surfaces[0]!.messages.map((m) => m.text)).toEqual([
116
+ 'recent chat question',
117
+ 'recent chat answer',
118
+ ])
119
+ // Secondary = last message only.
120
+ expect(surfaces[1]!.chatId).toBe('111')
121
+ expect(surfaces[1]!.messages.length).toBe(1)
122
+ expect(surfaces[1]!.messages[0]!.text).toBe('older chat answer')
123
+ })
124
+
125
+ it('forum shape: groups by (chat, thread); primary topic full depth, sibling topic secondary, DM-root separate', () => {
126
+ seedUser('999', 10, 1 * HOUR, 'topic-10 latest ask')
127
+ seedUser('999', 20, 3 * HOUR, 'topic-20 ask')
128
+ seedUser('999', null, 6 * HOUR, 'general (no topic) ask')
129
+
130
+ const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
131
+ expect(surfaces.map((s) => [s.chatId, s.threadId])).toEqual([
132
+ ['999', 10],
133
+ ['999', 20],
134
+ ['999', null],
135
+ ])
136
+ // Topic scoping is real: topic-10's messages never contain topic-20's.
137
+ expect(surfaces[0]!.messages.map((m) => m.text)).toEqual(['topic-10 latest ask'])
138
+ })
139
+
140
+ it('caps the primary section at the depth bound (15), keeping the NEWEST oldest-first', () => {
141
+ for (let i = 0; i < 25; i++) {
142
+ // msg-0 is oldest (25h... no: ages 25..1 minutes)
143
+ seedUser('42', null, (25 - i) * 60, `msg-${i}`)
144
+ }
145
+ const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
146
+ expect(surfaces.length).toBe(1)
147
+ const texts = surfaces[0]!.messages.map((m) => m.text)
148
+ expect(texts.length).toBe(BRIEFING_PRIMARY_DEPTH)
149
+ // Newest 15 (msg-10..msg-24), oldest-first.
150
+ expect(texts[0]).toBe('msg-10')
151
+ expect(texts[texts.length - 1]).toBe('msg-24')
152
+ })
153
+
154
+ it('excludes surfaces with no activity inside the 48h window', () => {
155
+ seedUser('act', null, 47 * HOUR, 'inside window')
156
+ seedUser('stale', null, 49 * HOUR, 'outside window')
157
+ const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
158
+ expect(surfaces.map((s) => s.chatId)).toEqual(['act'])
159
+ })
160
+
161
+ it('elides messages covered by the resume inbound window — on that surface only', () => {
162
+ const interruptStartMs = NOW_MS - 30 * 60 * 1000 // turn started 30 min ago
163
+ seedUser('77', null, 2 * HOUR, 'before the interrupted turn')
164
+ seedUser('77', null, 10 * 60, 'the interrupted request itself') // inside window
165
+ seedUser('88', null, 10 * 60, 'unrelated chat, same recency')
166
+
167
+ const surfaces = collectBriefingSurfaces(db(), {
168
+ nowMs: NOW_MS,
169
+ exclude: { chatId: '77', threadId: null, sinceMs: interruptStartMs },
170
+ })
171
+ const s77 = surfaces.find((s) => s.chatId === '77')!
172
+ const s88 = surfaces.find((s) => s.chatId === '88')!
173
+ // The resumed turn's own message is elided (the resume inbound already
174
+ // carries it); the earlier context survives.
175
+ expect(s77.messages.map((m) => m.text)).toEqual(['before the interrupted turn'])
176
+ // The unrelated surface is untouched by the window.
177
+ expect(s88.messages.map((m) => m.text)).toEqual(['unrelated chat, same recency'])
178
+ })
179
+
180
+ it('drops a surface entirely when the resume window elides all of it', () => {
181
+ seedUser('55', null, 10 * 60, 'only message, inside the resume window')
182
+ const surfaces = collectBriefingSurfaces(db(), {
183
+ nowMs: NOW_MS,
184
+ exclude: { chatId: '55', threadId: null, sinceMs: NOW_MS - 30 * 60 * 1000 },
185
+ })
186
+ expect(surfaces).toEqual([])
187
+ })
188
+
189
+ it('returns [] (never throws) on SQLITE_BUSY / any DB failure', () => {
190
+ const busyDb: BriefingDb = {
191
+ prepare() {
192
+ throw new Error('SQLITE_BUSY: database is locked')
193
+ },
194
+ }
195
+ expect(collectBriefingSurfaces(busyDb, { nowMs: NOW_MS })).toEqual([])
196
+ const busyAll: BriefingDb = {
197
+ prepare: () => ({
198
+ all() {
199
+ throw new Error('SQLITE_BUSY: database is locked')
200
+ },
201
+ }),
202
+ }
203
+ expect(collectBriefingSurfaces(busyAll, { nowMs: NOW_MS })).toEqual([])
204
+ })
205
+ })
206
+
207
+ describe('renderBootBriefing — bounds', () => {
208
+ it('renders nothing for no surfaces', () => {
209
+ expect(renderBootBriefing([], { nowMs: NOW_MS })).toBe('')
210
+ })
211
+
212
+ it('never exceeds the character budget, dropping OLDEST primary messages first', () => {
213
+ for (let i = 0; i < 15; i++) {
214
+ seedUser('9', null, (15 - i) * 60, `padding-${i} ` + 'x'.repeat(380))
215
+ }
216
+ const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
217
+ // Default bounds hold: 15 truncated messages + header fit the budget.
218
+ const full = renderBootBriefing(surfaces, { nowMs: NOW_MS })
219
+ expect(full.length).toBeGreaterThan(0)
220
+ expect(full.length).toBeLessThanOrEqual(BRIEFING_CHAR_BUDGET)
221
+ // Cap enforcement: with a tighter budget the render must trim the
222
+ // OLDEST primary messages first and never exceed the cap.
223
+ const tight = renderBootBriefing(surfaces, { nowMs: NOW_MS, charBudget: 3000 })
224
+ expect(tight.length).toBeGreaterThan(0)
225
+ expect(tight.length).toBeLessThanOrEqual(3000)
226
+ expect(tight).toContain('padding-14') // newest kept
227
+ expect(tight).not.toContain('padding-0 ') // oldest dropped
228
+ })
229
+
230
+ it('truncates each message to the per-message bound', () => {
231
+ seedUser('9', null, 60, 'HEAD-' + 'y'.repeat(2000))
232
+ const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
233
+ const text = renderBootBriefing(surfaces, { nowMs: NOW_MS })
234
+ const line = text.split('\n').find((l) => l.includes('HEAD-'))!
235
+ expect(line.length).toBeLessThanOrEqual(BRIEFING_PER_MESSAGE_MAX_CHARS + 40) // + prefix/label
236
+ expect(line.endsWith('…')).toBe(true)
237
+ })
238
+
239
+ it('renders secondary surfaces as a header + last-message preview and folds in the restart reason', () => {
240
+ seedUser('1', null, 30 * 60, 'primary talk')
241
+ seedUser('2', 7, 5 * HOUR, 'secondary topic message')
242
+ const surfaces = collectBriefingSurfaces(db(), { nowMs: NOW_MS })
243
+ const text = renderBootBriefing(surfaces, { nowMs: NOW_MS, restartReason: 'sigterm' })
244
+ expect(text).toContain('## Active conversation — chat 1')
245
+ expect(text).toContain('## Other recent surfaces')
246
+ expect(text).toContain('chat 2, topic 7')
247
+ expect(text).toContain('secondary topic message')
248
+ expect(text).toContain('ended via: sigterm')
249
+ // Loop-guard contract: the briefing turn must classify as a synthetic
250
+ // boot turn if it is itself interrupted.
251
+ expect(text.startsWith('You just restarted.')).toBe(true)
252
+ })
253
+ })
254
+
255
+ describe('decideBootBriefing — feature flag and suppression', () => {
256
+ const base = { briefingMode: 'gateway', resumeMode: 'handoff', forceFreshMarker: false }
257
+ it('defaults to legacy (no briefing) unless the flag opts in', () => {
258
+ expect(decideBootBriefing({ ...base, briefingMode: undefined }).build).toBe(false)
259
+ expect(decideBootBriefing({ ...base, briefingMode: 'legacy' }).build).toBe(false)
260
+ expect(decideBootBriefing(base).build).toBe(true)
261
+ })
262
+ it('suppresses when --continue/auto may replay the transcript', () => {
263
+ expect(decideBootBriefing({ ...base, resumeMode: 'continue' })).toEqual({
264
+ build: false,
265
+ reason: 'transcript-replay-possible',
266
+ })
267
+ expect(decideBootBriefing({ ...base, resumeMode: 'auto' }).build).toBe(false)
268
+ expect(decideBootBriefing({ ...base, resumeMode: 'none' }).build).toBe(true)
269
+ })
270
+ it('suppresses on a /reset force-fresh boot', () => {
271
+ expect(decideBootBriefing({ ...base, forceFreshMarker: true })).toEqual({
272
+ build: false,
273
+ reason: 'force-fresh',
274
+ })
275
+ })
276
+ })
277
+
278
+ describe('inbound shape + spool dedup', () => {
279
+ it('mints a boot_briefing inbound whose spool id is stable across boots (per chat)', () => {
280
+ const a = buildBootBriefingInbound({ chatId: '5', threadId: null, text: 'brief', nowMs: NOW_MS })
281
+ const b = buildBootBriefingInbound({
282
+ chatId: '5',
283
+ threadId: null,
284
+ text: 'brief again',
285
+ nowMs: NOW_MS + 60_000, // a later boot
286
+ })
287
+ expect(a.meta.source).toBe(BOOT_BRIEFING_SOURCE)
288
+ expect(a.meta.chat_id).toBe('5')
289
+ expect(Number(a.meta.expiresAt)).toBeGreaterThan(NOW_MS)
290
+ // Same spool id despite different synthetic messageIds — a multi-restart
291
+ // sequence collapses to ONE live briefing instead of stacking N.
292
+ expect(spoolId(a)).toBe(spoolId(b))
293
+ expect(spoolId(a)).toBe('s:boot-briefing:5')
294
+ })
295
+
296
+ it('derives the resume-dedup window from a resume inbound meta', () => {
297
+ const resumeMsg = {
298
+ type: 'inbound',
299
+ chatId: '77',
300
+ messageId: 1,
301
+ user: 'switchroom',
302
+ userId: 0,
303
+ ts: NOW_MS,
304
+ text: 'You just restarted.',
305
+ meta: {
306
+ source: 'resume_interrupted',
307
+ chat_id: '77',
308
+ message_thread_id: '12',
309
+ started_at: String(NOW_MS - 1000),
310
+ },
311
+ } as InboundMessage
312
+ expect(excludeWindowFromResumeInbound(resumeMsg)).toEqual({
313
+ chatId: '77',
314
+ threadId: 12,
315
+ sinceMs: NOW_MS - 1000,
316
+ })
317
+ expect(excludeWindowFromResumeInbound(null)).toBeNull()
318
+ })
319
+ })
320
+
321
+ describe('readRestartBreadcrumb', () => {
322
+ it('reads the .restart-reason first line, with SWITCHROOM_PENDING_ENDED_VIA overriding', () => {
323
+ const read = () => 'operator restart\nsecond line'
324
+ expect(
325
+ readRestartBreadcrumb({ restartReasonPath: '/x/.restart-reason', env: {}, readFile: read }),
326
+ ).toBe('operator restart')
327
+ expect(
328
+ readRestartBreadcrumb({
329
+ restartReasonPath: '/x/.restart-reason',
330
+ env: { SWITCHROOM_PENDING_ENDED_VIA: 'timeout' },
331
+ readFile: read,
332
+ }),
333
+ ).toBe('timeout')
334
+ expect(
335
+ readRestartBreadcrumb({
336
+ restartReasonPath: '/missing',
337
+ env: {},
338
+ readFile: () => {
339
+ throw new Error('ENOENT')
340
+ },
341
+ }),
342
+ ).toBeNull()
343
+ })
344
+ })
345
+
346
+ describe('maybeQueueBootBriefing — end-to-end wiring', () => {
347
+ function envFor(mode: string): Record<string, string | undefined> {
348
+ return {
349
+ SWITCHROOM_SESSION_BRIEFING: mode,
350
+ SWITCHROOM_RESUME_MODE: 'handoff',
351
+ SWITCHROOM_AGENT_NAME: 'testagent',
352
+ }
353
+ }
354
+
355
+ it('queues a briefing built from real history rows when the flag is gateway', () => {
356
+ seedUser('321', null, 30 * 60, 'please review the deploy plan')
357
+ seedBot('321', null, 25 * 60, 'on it — reviewing now')
358
+ const puts: Array<{ agent: string; msg: InboundMessage }> = []
359
+ const queued = maybeQueueBootBriefing({
360
+ env: envFor('gateway'),
361
+ stateDir: join(stateDir, 'telegram'),
362
+ resumeMsg: null,
363
+ put: (agent, msg) => puts.push({ agent, msg }),
364
+ log: () => {},
365
+ nowMs: NOW_MS,
366
+ })
367
+ expect(queued).not.toBeNull()
368
+ expect(puts.length).toBe(1)
369
+ expect(puts[0]!.agent).toBe('testagent')
370
+ expect(puts[0]!.msg.meta.source).toBe('boot_briefing')
371
+ expect(puts[0]!.msg.chatId).toBe('321')
372
+ expect(puts[0]!.msg.text).toContain('please review the deploy plan')
373
+ expect(puts[0]!.msg.text).toContain('on it — reviewing now')
374
+ expect(puts[0]!.msg.text.length).toBeLessThanOrEqual(BRIEFING_CHAR_BUDGET)
375
+ })
376
+
377
+ it('queues NOTHING when the flag is legacy (default) — legacy behaviour untouched', () => {
378
+ seedUser('321', null, 30 * 60, 'recent message')
379
+ const puts: unknown[] = []
380
+ const queued = maybeQueueBootBriefing({
381
+ env: envFor('legacy'),
382
+ stateDir: join(stateDir, 'telegram'),
383
+ resumeMsg: null,
384
+ put: (a, m) => puts.push([a, m]),
385
+ log: () => {},
386
+ nowMs: NOW_MS,
387
+ })
388
+ expect(queued).toBeNull()
389
+ expect(puts.length).toBe(0)
390
+ })
391
+
392
+ it('queues nothing when history is empty', () => {
393
+ const puts: unknown[] = []
394
+ const queued = maybeQueueBootBriefing({
395
+ env: envFor('gateway'),
396
+ stateDir: join(stateDir, 'telegram'),
397
+ resumeMsg: null,
398
+ put: (a, m) => puts.push([a, m]),
399
+ log: () => {},
400
+ nowMs: NOW_MS,
401
+ })
402
+ expect(queued).toBeNull()
403
+ expect(puts.length).toBe(0)
404
+ })
405
+
406
+ it('suppresses on a force-fresh (/reset) marker', () => {
407
+ seedUser('321', null, 30 * 60, 'recent message')
408
+ writeFileSync(join(stateDir, '.force-fresh-session'), '')
409
+ const queued = maybeQueueBootBriefing({
410
+ env: envFor('gateway'),
411
+ stateDir: join(stateDir, 'telegram'),
412
+ resumeMsg: null,
413
+ put: () => {
414
+ throw new Error('must not be called')
415
+ },
416
+ log: () => {},
417
+ nowMs: NOW_MS,
418
+ })
419
+ expect(queued).toBeNull()
420
+ })
421
+
422
+ it('suppresses on SWITCHROOM_FORCE_FRESH=1 even when NO marker file exists (env-keyed, race-proof)', () => {
423
+ // The M1 fix: the decision keys on the env snapshot start.sh takes
424
+ // BEFORE forking the gateway, not on fs state at gateway check time. So
425
+ // the /reset boot is suppressed even after the inner pass has already
426
+ // `rm`ed the marker — the exact race the old existsSync check lost.
427
+ seedUser('321', null, 30 * 60, 'recent message')
428
+ expect(existsSync(join(stateDir, '.force-fresh-session'))).toBe(false)
429
+ const queued = maybeQueueBootBriefing({
430
+ env: { ...envFor('gateway'), SWITCHROOM_FORCE_FRESH: '1' },
431
+ stateDir: join(stateDir, 'telegram'),
432
+ resumeMsg: null,
433
+ put: () => {
434
+ throw new Error('must not be called')
435
+ },
436
+ log: () => {},
437
+ nowMs: NOW_MS,
438
+ })
439
+ expect(queued).toBeNull()
440
+ })
441
+
442
+ it('suppresses on SWITCHROOM_FORCE_FRESH=1 regardless of whether the marker is present', () => {
443
+ // Outcome does not depend on fs state at check time: with the env set,
444
+ // the briefing is suppressed whether or not the marker file is on disk.
445
+ seedUser('321', null, 30 * 60, 'recent message')
446
+ writeFileSync(join(stateDir, '.force-fresh-session'), '')
447
+ const queued = maybeQueueBootBriefing({
448
+ env: { ...envFor('gateway'), SWITCHROOM_FORCE_FRESH: '1' },
449
+ stateDir: join(stateDir, 'telegram'),
450
+ resumeMsg: null,
451
+ put: () => {
452
+ throw new Error('must not be called')
453
+ },
454
+ log: () => {},
455
+ nowMs: NOW_MS,
456
+ })
457
+ expect(queued).toBeNull()
458
+ })
459
+
460
+ it('never throws even when put itself throws', () => {
461
+ seedUser('321', null, 30 * 60, 'recent message')
462
+ expect(() =>
463
+ maybeQueueBootBriefing({
464
+ env: envFor('gateway'),
465
+ stateDir: join(stateDir, 'telegram'),
466
+ resumeMsg: null,
467
+ put: () => {
468
+ throw new Error('spool exploded')
469
+ },
470
+ log: () => {},
471
+ nowMs: NOW_MS,
472
+ }),
473
+ ).not.toThrow()
474
+ })
475
+
476
+ it('threads a real resumeMsg end-to-end: elides the interrupted-turn window from the queued briefing on that surface', () => {
477
+ // #4247: every other wiring test passes resumeMsg: null, so the
478
+ // resume-dedup path (interrupted-turn window elided so the boot-resume
479
+ // synthetic and the briefing never double-inject the same messages) was
480
+ // only ever proven at the pure-function level. This drives a real non-null
481
+ // resumeMsg all the way through maybeQueueBootBriefing and asserts the
482
+ // OUTCOME on the queued inbound's text.
483
+ const startedAtMs = NOW_MS - 10 * 60 * 1000 // interrupted turn began 10m ago
484
+ // Surface 321 (the resumed chat): one message BEFORE the interrupted turn
485
+ // (must survive) and one AT/AFTER it (the resume synthetic already covers
486
+ // it — must be elided).
487
+ seedUser('321', null, 30 * 60, 'context from before the interrupted turn')
488
+ seedUser('321', null, 5 * 60, 'the interrupted request itself')
489
+ // Surface 654 (a different chat): same recency window, NOT the resumed
490
+ // surface, so its message must be untouched by the dedup.
491
+ seedUser('654', null, 5 * 60, 'unrelated chat, must remain')
492
+ const resumeMsg = {
493
+ type: 'inbound',
494
+ chatId: '321',
495
+ messageId: 1,
496
+ user: 'switchroom',
497
+ userId: 0,
498
+ ts: NOW_MS,
499
+ text: 'You just restarted — resuming the interrupted turn.',
500
+ meta: {
501
+ source: 'resume_interrupted',
502
+ chat_id: '321',
503
+ started_at: String(startedAtMs),
504
+ },
505
+ } as InboundMessage
506
+ const puts: Array<{ agent: string; msg: InboundMessage }> = []
507
+ const queued = maybeQueueBootBriefing({
508
+ env: envFor('gateway'),
509
+ stateDir: join(stateDir, 'telegram'),
510
+ resumeMsg,
511
+ put: (agent, msg) => puts.push({ agent, msg }),
512
+ log: () => {},
513
+ nowMs: NOW_MS,
514
+ })
515
+ expect(queued).not.toBeNull()
516
+ expect(puts.length).toBe(1)
517
+ const text = puts[0]!.msg.text
518
+ // The pre-interruption message survives; the interrupted request itself is
519
+ // elided (the resume synthetic already re-injects it).
520
+ expect(text).toContain('context from before the interrupted turn')
521
+ expect(text).not.toContain('the interrupted request itself')
522
+ // Dedup is surface-scoped: the unrelated chat is untouched.
523
+ expect(text).toContain('unrelated chat, must remain')
524
+ })
525
+ })
526
+
527
+ describe('maybeQueueBootBriefing — session-generation guard (#4242)', () => {
528
+ function envGen(bootId?: string): Record<string, string | undefined> {
529
+ return {
530
+ SWITCHROOM_SESSION_BRIEFING: 'gateway',
531
+ SWITCHROOM_RESUME_MODE: 'handoff',
532
+ SWITCHROOM_AGENT_NAME: 'testagent',
533
+ ...(bootId != null ? { SWITCHROOM_GATEWAY_BOOT_ID: bootId } : {}),
534
+ }
535
+ }
536
+
537
+ const call = (
538
+ env: Record<string, string | undefined>,
539
+ puts: Array<{ agent: string; msg: InboundMessage }>,
540
+ ) =>
541
+ maybeQueueBootBriefing({
542
+ env,
543
+ stateDir: join(stateDir, 'telegram'),
544
+ resumeMsg: null,
545
+ put: (agent, msg) => puts.push({ agent, msg }),
546
+ log: () => {},
547
+ nowMs: NOW_MS,
548
+ })
549
+
550
+ it('re-mints ONCE per boot generation: a supervisor respawn (same boot id) queues nothing', () => {
551
+ seedUser('321', null, 30 * 60, 'the deploy is half-done')
552
+ const puts: Array<{ agent: string; msg: InboundMessage }> = []
553
+
554
+ // Boot-1: first gateway process of this generation briefs.
555
+ const first = call(envGen('gen-1'), puts)
556
+ expect(first).not.toBeNull()
557
+ expect(puts.length).toBe(1)
558
+ // Generation persisted for the respawn check.
559
+ expect(existsSync(join(stateDir, '.boot-briefing-generation'))).toBe(true)
560
+
561
+ // Respawn: same shell → same SWITCHROOM_GATEWAY_BOOT_ID. The gateway
562
+ // module re-evaluates, but the inner Claude session is still live from
563
+ // boot-1 — re-injecting a "you just rebooted" briefing would be wrong.
564
+ const respawn = call(envGen('gen-1'), puts)
565
+ expect(respawn).toBeNull()
566
+ expect(puts.length).toBe(1) // no second put
567
+ })
568
+
569
+ it('a GENUINE new boot (fresh boot id) briefs again', () => {
570
+ seedUser('321', null, 30 * 60, 'still pending your call')
571
+ const puts: Array<{ agent: string; msg: InboundMessage }> = []
572
+
573
+ expect(call(envGen('gen-1'), puts)).not.toBeNull()
574
+ expect(puts.length).toBe(1)
575
+ // Next real container boot re-derives a different id → not a respawn.
576
+ expect(call(envGen('gen-2'), puts)).not.toBeNull()
577
+ expect(puts.length).toBe(2)
578
+ })
579
+
580
+ it('consumes the generation even when the first boot had nothing to brief (no mid-session brief on respawn)', () => {
581
+ const puts: Array<{ agent: string; msg: InboundMessage }> = []
582
+ // Boot-1: empty history → nothing queued, but the generation is consumed.
583
+ expect(call(envGen('gen-1'), puts)).toBeNull()
584
+ expect(puts.length).toBe(0)
585
+ expect(existsSync(join(stateDir, '.boot-briefing-generation'))).toBe(true)
586
+
587
+ // Messages arrive AFTER the session is live, then the gateway respawns.
588
+ seedUser('321', null, 5 * 60, 'a message that landed mid-session')
589
+ const respawn = call(envGen('gen-1'), puts)
590
+ expect(respawn).toBeNull()
591
+ expect(puts.length).toBe(0) // must NOT brief into the live session
592
+ })
593
+
594
+ it('guard is inert when SWITCHROOM_GATEWAY_BOOT_ID is absent (non-docker / pre-upgrade start.sh keeps legacy behaviour)', () => {
595
+ seedUser('321', null, 30 * 60, 'legacy path message')
596
+ const puts: Array<{ agent: string; msg: InboundMessage }> = []
597
+ // With no boot id, every gateway start briefs as before — no marker
598
+ // written, no suppression.
599
+ expect(call(envGen(undefined), puts)).not.toBeNull()
600
+ expect(call(envGen(undefined), puts)).not.toBeNull()
601
+ expect(puts.length).toBe(2)
602
+ expect(existsSync(join(stateDir, '.boot-briefing-generation'))).toBe(false)
603
+ })
604
+ })