switchroom 0.18.28 → 0.18.30

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 (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Regression tests for the terminal catch-all message handler + diagnostic
3
+ * update tap (#3300).
4
+ *
5
+ * THE CLASS OF BUG: the gateway registered ONLY content-specific handlers —
6
+ * `bot.on('message:text')`, `:photo`, `:document`, … `:paid_media`. grammy
7
+ * ^1.44 routes `bot.on` as filtering middleware (`on → filter(pred, handler)
8
+ * → branch(pred, handler, pass)`, verified in grammy/out/composer.js); when
9
+ * NO registered filter matches an inbound `message`, grammy SILENTLY drops
10
+ * it — no log, no ack, no history row. Live signature (2026-07-16, klanker
11
+ * DM): message_id 19090 was allocated between an outbound reply and the next
12
+ * inbound with ZERO gateway trace while polling stayed healthy.
13
+ *
14
+ * THE FIX under test is the REAL production module
15
+ * (`gateway/unhandled-message.ts`) — the same `installUpdateTap` /
16
+ * `installUnhandledMessageCatchAll` gateway.ts wires — driven through a real
17
+ * grammy 1.44 Bot via `bot.handleUpdate`, with a real `bot.on('message:text')`
18
+ * registered BEFORE the catch-all exactly like the gateway's registration
19
+ * order. (gateway.ts itself is a side-effecting module that cannot be
20
+ * imported into a unit test — the extraction into unhandled-message.ts exists
21
+ * precisely so the registered composer path is testable; a structural suite
22
+ * below guards that gateway.ts actually wires it in that order.)
23
+ *
24
+ * Outcomes asserted:
25
+ * (b) an unregistered content type reaches the catch-all, is logged, and
26
+ * yields a turn with the placeholder text naming the content type;
27
+ * (c) a plain text message is consumed by the specific handler and does
28
+ * NOT double-handle in the catch-all;
29
+ * service-noise messages (forum topic lifecycle etc.) are logged but do
30
+ * NOT become turns;
31
+ * the tap observes every update and rate-limits with a suppression summary.
32
+ */
33
+
34
+ import { describe, it, expect, beforeEach } from 'vitest'
35
+ import { readFileSync } from 'node:fs'
36
+ import { Bot, type Context } from 'grammy'
37
+ import type { Update } from 'grammy/types'
38
+ import { makeMessageUpdate, resetUpdateCounters } from './update-factory.js'
39
+ import {
40
+ installUpdateTap,
41
+ installUnhandledMessageCatchAll,
42
+ planUnhandledMessage,
43
+ SERVICE_NOISE_KEYS,
44
+ TAP_MAX_LINES_PER_MINUTE,
45
+ } from '../gateway/unhandled-message.js'
46
+
47
+ interface DeliveredTurn {
48
+ via: 'text' | 'catch-all'
49
+ text: string
50
+ update_id: number
51
+ }
52
+
53
+ /**
54
+ * Wire a real grammy Bot in the gateway's registration order using the REAL
55
+ * production install functions: tap first, specific `message:text` handler,
56
+ * terminal catch-all LAST. `onInbound` records what would flow into
57
+ * handleInboundCoalesced.
58
+ */
59
+ function buildHarness() {
60
+ const delivered: DeliveredTurn[] = []
61
+ const logLines: string[] = []
62
+
63
+ const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
64
+ bot.botInfo = {
65
+ id: 999,
66
+ is_bot: true,
67
+ first_name: 'TestBot',
68
+ username: 'test_bot',
69
+ can_join_groups: true,
70
+ can_read_all_group_messages: false,
71
+ supports_inline_queries: false,
72
+ can_connect_to_business: false,
73
+ has_main_web_app: false,
74
+ }
75
+
76
+ // REAL production tap (same call gateway.ts makes).
77
+ installUpdateTap(bot, line => logLines.push(line))
78
+
79
+ // Specific handler — representative of the gateway's message:text
80
+ // registration, placed BEFORE the catch-all exactly as in gateway.ts.
81
+ bot.on('message:text', async ctx => {
82
+ delivered.push({ via: 'text', text: ctx.message.text, update_id: ctx.update.update_id })
83
+ })
84
+
85
+ // REAL production catch-all, registered LAST (same call gateway.ts makes).
86
+ installUnhandledMessageCatchAll(
87
+ bot,
88
+ async (ctx: Context, text: string) => {
89
+ delivered.push({ via: 'catch-all', text, update_id: ctx.update.update_id })
90
+ },
91
+ line => logLines.push(line),
92
+ )
93
+
94
+ return { bot, delivered, logLines }
95
+ }
96
+
97
+ /** A message update carrying content that no `message:*` filter covers. */
98
+ function makeContentUpdate(update_id: number, content: Record<string, unknown>): Update {
99
+ return {
100
+ update_id,
101
+ message: {
102
+ message_id: 5000 + update_id,
103
+ chat: { id: 777, type: 'private' },
104
+ from: { id: 777, is_bot: false, first_name: 'Test' },
105
+ date: Math.floor(Date.now() / 1000),
106
+ ...content,
107
+ },
108
+ } as unknown as Update
109
+ }
110
+
111
+ describe('terminal catch-all — real module on a real grammy 1.44 composer (#3300)', () => {
112
+ beforeEach(() => resetUpdateCounters())
113
+
114
+ it('(c) a plain-text message is consumed by message:text and does NOT reach the catch-all', async () => {
115
+ const { bot, delivered, logLines } = buildHarness()
116
+ await bot.handleUpdate(makeMessageUpdate({ text: 'hello brief', update_id: 42 }))
117
+
118
+ expect(delivered).toHaveLength(1)
119
+ expect(delivered[0]).toMatchObject({ via: 'text', text: 'hello brief' })
120
+ // No catch-all log line — specific handler won (no double-handling).
121
+ expect(logLines.filter(l => l.includes('catch-all inbound'))).toHaveLength(0)
122
+ })
123
+
124
+ it('(b) an unregistered content type reaches the catch-all, is logged, and yields a placeholder turn', async () => {
125
+ const { bot, delivered, logLines } = buildHarness()
126
+ await bot.handleUpdate(makeContentUpdate(99, { unknown_future_type: { some: 'payload' } }))
127
+
128
+ // Logged with content KEYS + ids only (never the payload body).
129
+ const catchAllLines = logLines.filter(l => l.includes('catch-all inbound'))
130
+ expect(catchAllLines).toHaveLength(1)
131
+ expect(catchAllLines[0]).toContain('update_id=99')
132
+ expect(catchAllLines[0]).toContain('content_keys=[unknown_future_type]')
133
+ expect(catchAllLines[0]).not.toContain('payload')
134
+
135
+ // Produced a delivered turn with placeholder text naming the content type.
136
+ expect(delivered).toHaveLength(1)
137
+ expect(delivered[0]).toMatchObject({
138
+ via: 'catch-all',
139
+ text: '(unhandled message content: unknown_future_type)',
140
+ })
141
+ })
142
+
143
+ it('a caption on an unhandled content type is used as the turn text (best-effort)', async () => {
144
+ const { bot, delivered } = buildHarness()
145
+ await bot.handleUpdate(
146
+ makeContentUpdate(120, { some_new_media: { id: 'x' }, caption: 'read this brief' }),
147
+ )
148
+ expect(delivered).toHaveLength(1)
149
+ expect(delivered[0]).toMatchObject({ via: 'catch-all', text: 'read this brief' })
150
+ })
151
+
152
+ it('known-noise service messages are LOGGED but produce NO agent turn', async () => {
153
+ const { bot, delivered, logLines } = buildHarness()
154
+ await bot.handleUpdate(
155
+ makeContentUpdate(130, { forum_topic_created: { name: 'spam topic', icon_color: 1 } }),
156
+ )
157
+ await bot.handleUpdate(
158
+ makeContentUpdate(131, { new_chat_members: [{ id: 5, is_bot: false, first_name: 'X' }] }),
159
+ )
160
+
161
+ // No turns delivered — service noise never reaches the agent.
162
+ expect(delivered).toHaveLength(0)
163
+ // But both were explicitly logged (never silently dropped).
164
+ const noiseLines = logLines.filter(l => l.includes('action=log-only'))
165
+ expect(noiseLines).toHaveLength(2)
166
+ expect(noiseLines[0]).toContain('content_keys=[forum_topic_created]')
167
+ expect(noiseLines[1]).toContain('content_keys=[new_chat_members]')
168
+ })
169
+
170
+ it('the diagnostic tap observes EVERY update (pass-through, both routed and caught)', async () => {
171
+ const { bot, logLines } = buildHarness()
172
+ await bot.handleUpdate(makeMessageUpdate({ text: 'routed', update_id: 1 }))
173
+ await bot.handleUpdate(makeContentUpdate(2, { unknown_future_type: {} }))
174
+
175
+ const rx = logLines.filter(l => l.includes('rx update_id='))
176
+ expect(rx).toHaveLength(2)
177
+ expect(rx[0]).toContain('rx update_id=1 type=message')
178
+ expect(rx[1]).toContain('rx update_id=2 type=message')
179
+ })
180
+ })
181
+
182
+ describe('planUnhandledMessage — service-noise classification', () => {
183
+ it('every SERVICE_NOISE_KEYS entry classifies log-only', () => {
184
+ for (const key of SERVICE_NOISE_KEYS) {
185
+ const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, [key]: {} })
186
+ expect(plan.action, `key ${key} should be log-only`).toBe('log-only')
187
+ }
188
+ })
189
+
190
+ it('an unknown content type classifies as a turn (fail-toward-delivery)', () => {
191
+ const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, mystery_type: {} })
192
+ expect(plan).toMatchObject({
193
+ action: 'turn',
194
+ text: '(unhandled message content: mystery_type)',
195
+ })
196
+ })
197
+
198
+ it('a message mixing noise with real content still becomes a turn', () => {
199
+ const plan = planUnhandledMessage({
200
+ message_id: 1, chat: {}, date: 0,
201
+ boost_added: {}, mystery_media: {}, caption: 'look',
202
+ })
203
+ expect(plan).toMatchObject({ action: 'turn', text: 'look' })
204
+ })
205
+ })
206
+
207
+ describe('installUpdateTap — rate limit', () => {
208
+ it('caps lines per minute and emits ONE suppression summary on window rollover', async () => {
209
+ const logLines: string[] = []
210
+ let now = 1_000_000
211
+ const mw: Array<(ctx: unknown, next: () => Promise<void>) => Promise<void>> = []
212
+ const fakeBot = { use: (fn: (ctx: unknown, next: () => Promise<void>) => Promise<void>) => { mw.push(fn) } }
213
+ installUpdateTap(fakeBot as never, l => logLines.push(l), () => now)
214
+
215
+ const fire = (update_id: number) =>
216
+ mw[0]({ update: { update_id }, message: undefined } as never, async () => {})
217
+
218
+ for (let i = 0; i < TAP_MAX_LINES_PER_MINUTE + 50; i++) await fire(i)
219
+ expect(logLines.filter(l => l.includes('rx update_id='))).toHaveLength(TAP_MAX_LINES_PER_MINUTE)
220
+
221
+ // Roll the window: the 50 suppressed lines surface as ONE summary.
222
+ now += 61_000
223
+ await fire(9999)
224
+ const summaries = logLines.filter(l => l.includes('suppressed 50 update lines'))
225
+ expect(summaries).toHaveLength(1)
226
+ // And logging resumes in the fresh window.
227
+ expect(logLines.filter(l => l.includes('rx update_id=9999'))).toHaveLength(1)
228
+ })
229
+ })
230
+
231
+ // ─── Structural guard on the real gateway.ts wiring ────────────────────────
232
+ // The functional harness above proves the composer contract using the REAL
233
+ // production install functions; this guards that gateway.ts wires those same
234
+ // functions in the required order. Hardened beyond a naive regex: it fails
235
+ // if the install call disappears (e.g. refactored away) OR if ANY
236
+ // `bot.on('message…')` registration appears after the catch-all install.
237
+ describe('gateway.ts catch-all registration invariant', () => {
238
+ const SRC = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
239
+
240
+ it('imports and installs the real catch-all + tap from unhandled-message.ts', () => {
241
+ expect(SRC).toContain("from './unhandled-message.js'")
242
+ expect(SRC).toContain('installUpdateTap(bot,')
243
+ expect(SRC).toContain('installUnhandledMessageCatchAll(')
244
+ })
245
+
246
+ it('the catch-all install is AFTER every message handler registration', () => {
247
+ const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
248
+ expect(installIdx).toBeGreaterThan(0)
249
+ const after = SRC.slice(installIdx)
250
+ // No message-filter registration of any spelling may follow the terminal
251
+ // catch-all — grammy ordering is the no-double-handling guarantee.
252
+ // (`message_reaction` etc. are DIFFERENT update types, not `message`
253
+ // filters — the pattern requires `message` exactly or `message:<sub>`.)
254
+ expect(after).not.toMatch(/bot\.on\(\s*['"`]message(:|['"`])/)
255
+ // And there must be exactly one catch-all install (no duplicate turns).
256
+ expect(SRC.indexOf('installUnhandledMessageCatchAll(', installIdx + 1)).toBe(-1)
257
+ })
258
+
259
+ it('routes the catch-all through handleInboundCoalesced (same gating + forward-origin path)', () => {
260
+ const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
261
+ const body = SRC.slice(installIdx, installIdx + 400)
262
+ expect(body).toMatch(/handleInboundCoalesced\(ctx, text, undefined\)/)
263
+ })
264
+ })
@@ -30,10 +30,15 @@ import {
30
30
  FORWARDED_FROM_NAME_MAX,
31
31
  type ForwardOriginInfo,
32
32
  } from '../gateway/forward-origin.js'
33
+ import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
33
34
 
34
35
  // Synthetic fixtures only — no real Telegram ids/names (check-no-pii-secrets).
35
36
  const DATE = 1750000000 // unix seconds
36
- const DATE_ISO = new Date(DATE * 1000).toISOString()
37
+ // switchroom #tz-fix: forwarded_date is now the agent's LOCAL am/pm wall clock
38
+ // (NOT UTC ISO), so it can't compete with the local-time hint. Compute the
39
+ // expected value through the SAME helper production uses, so the assertion is
40
+ // deterministic under whatever TZ the runner env carries.
41
+ const DATE_LOCAL = fmtLocalStamp(DATE * 1000, resolveEnvTimezone())
37
42
 
38
43
  function userOrigin(overrides: Partial<{
39
44
  first_name: string
@@ -204,7 +209,7 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
204
209
  forwarded_from: 'Ada Lovelace (@adalove)',
205
210
  forwarded_from_type: 'user',
206
211
  forwarded_from_id: '42',
207
- forwarded_date: DATE_ISO,
212
+ forwarded_date: DATE_LOCAL,
208
213
  })
209
214
  })
210
215
 
@@ -245,6 +250,28 @@ describe('buildForwardOriginMeta — channel-tag attrs', () => {
245
250
  it('no origins → empty record (no attrs on a normal message)', () => {
246
251
  expect(buildForwardOriginMeta([])).toEqual({})
247
252
  })
253
+
254
+ // switchroom #tz-fix (deterministic outcome): under a real configured zone
255
+ // the forwarded_date the MODEL sees is LOCAL am/pm with NO "UTC" / trailing-Z.
256
+ it('renders forwarded_date as LOCAL am/pm — never a UTC ISO string', () => {
257
+ const prevTz = process.env.SWITCHROOM_TIMEZONE
258
+ const prevTZ = process.env.TZ
259
+ process.env.SWITCHROOM_TIMEZONE = 'Australia/Melbourne'
260
+ delete process.env.TZ
261
+ try {
262
+ const meta = buildForwardOriginMeta([{ name: 'Ada', type: 'user', id: 42, date: DATE }])
263
+ const d = meta.forwarded_date!
264
+ // e.g. "Sunday 2025-06-15 08:26 PM AEST" — weekday, ISO date, am/pm, abbrev.
265
+ expect(d).toMatch(/ (?:AM|PM) [A-Za-z]{2,5}$/)
266
+ expect(d).not.toContain('UTC')
267
+ expect(d.endsWith('Z')).toBe(false)
268
+ } finally {
269
+ if (prevTz === undefined) delete process.env.SWITCHROOM_TIMEZONE
270
+ else process.env.SWITCHROOM_TIMEZONE = prevTz
271
+ if (prevTZ === undefined) delete process.env.TZ
272
+ else process.env.TZ = prevTZ
273
+ }
274
+ })
248
275
  })
249
276
 
250
277
  describe('coalesced bursts — dedupe + numbered siblings', () => {
@@ -264,7 +291,7 @@ describe('coalesced bursts — dedupe + numbered siblings', () => {
264
291
  expect(meta.forwarded_from).toBe('Alice Q (@aliceq)')
265
292
  expect(meta.forwarded_from_2).toBeUndefined()
266
293
  // First occurrence wins — the emitted date is the first part's.
267
- expect(meta.forwarded_date).toBe(DATE_ISO)
294
+ expect(meta.forwarded_date).toBe(DATE_LOCAL)
268
295
  })
269
296
 
270
297
  it('multi-origin burst: first origin bare, second gets _2 keys in order', () => {
@@ -1,10 +1,11 @@
1
1
  /**
2
- * Structural pins for the session-scoped /model wiring in gateway.ts
3
- * (reference/rfcs/session-model-stickiness.md §0.1, rev 4consume-once).
2
+ * Structural pins for the DETERMINISTIC /model wiring in gateway.ts
3
+ * (reference/rfcs/session-model-stickiness.md §0.05, rev 5every switch
4
+ * relaunches through the consume-once carrier; the inject/scrape path retired).
4
5
  *
5
6
  * The behaviour lives in un-exported inline closures (buildModelDeps's
6
- * scheduleModelRelaunch/scheduleRestart, the typed/menu recorders, and the
7
- * boot re-hydration block inside the startup IIFE), so — mirroring the other
7
+ * scheduleModelRelaunch / scheduleModelDefaultRelaunch / scheduleRestart, and
8
+ * the boot re-hydration block inside the startup IIFE), so — mirroring the other
8
9
  * gateway-*.test.ts source-pins — we assert on the source structure. The
9
10
  * end-to-end boot behaviour is exercised in tests/scaffold.session-model.test.ts
10
11
  * (rendered start.sh), the file helpers in session-model-file.test.ts, and the
@@ -42,7 +43,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
42
43
  it('writes the carrier via writeSessionModelFile before dispatching the restart', () => {
43
44
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
44
45
  expect(idx).toBeGreaterThan(0)
45
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
46
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
46
47
  const writeIdx = win.indexOf('writeSessionModelFile(')
47
48
  const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
48
49
  expect(writeIdx).toBeGreaterThan(0)
@@ -51,7 +52,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
51
52
 
52
53
  it('sets the in-memory session-model override before dispatching the restart', () => {
53
54
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
54
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
55
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
55
56
  const setIdx = win.indexOf('sessionModelSource.setOverride(model)')
56
57
  const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
57
58
  expect(setIdx).toBeGreaterThan(0)
@@ -60,7 +61,7 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
60
61
 
61
62
  it('rolls back the prior carrier content (not just deletion) on a non-in-flight dispatch failure', () => {
62
63
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
63
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
64
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
64
65
  expect(win).toContain('const prevFileRaw = readSessionModelFileRaw(agentDir)')
65
66
  expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
66
67
  expect(win).toContain("!== 'restart_in_flight'")
@@ -68,84 +69,134 @@ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrie
68
69
 
69
70
  it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
70
71
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
71
- const win = GATEWAY_SRC.slice(idx, idx + 1800)
72
+ const win = GATEWAY_SRC.slice(idx, idx + 2600)
72
73
  expect(win).toContain('await deps.scheduleRestart(reason)')
73
74
  })
74
75
  })
75
76
 
76
- describe('gateway: menu callback carrier handling (session-scoped)', () => {
77
- it('a live Claude selection records the in-memory override but writes NO carrier', () => {
78
- const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
79
- expect(idx).toBeGreaterThan(0)
80
- const win = GATEWAY_SRC.slice(idx, idx + 900)
81
- // The first (live-switch) block sets the override but no longer persists.
82
- expect(win).toContain('sessionModelSource.setOverride(outcome.selectedModel)')
83
- const overrideIdx = win.indexOf('sessionModelSource.setOverride(outcome.selectedModel)')
84
- const nextClearIdx = win.indexOf('outcome.clearedDefault')
85
- const writeBetween = win.slice(overrideIdx, nextClearIdx)
86
- expect(writeBetween).not.toContain('writeSessionModelFile(')
77
+ describe('gateway: the retired scrape-recorders are GONE (rev 5 inversion)', () => {
78
+ it('recordTypedModelSwitch and recordModelMenuSideEffects no longer exist', () => {
79
+ // INVERTED from rev 4: these helpers recorded a scrape-derived selectedModel
80
+ // and drove the sr-to-claude special case. Every switch now relaunches, so
81
+ // they are deleted their presence would mean the retired path survived.
82
+ expect(GATEWAY_SRC).not.toContain('function recordTypedModelSwitch')
83
+ expect(GATEWAY_SRC).not.toContain('function recordModelMenuSideEffects')
87
84
  })
88
85
 
89
- it('a confirmed "Default" selection CLEARS the carrier', () => {
90
- const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
91
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
92
- expect(win).toContain('outcome.clearedDefault')
93
- expect(win).toContain('clearSessionModelFile(smDir)')
86
+ it('no isSrToClaudeTransition wiring (every switch relaunches — no distinct transition)', () => {
87
+ expect(GATEWAY_SRC).not.toContain('isSrToClaudeTransition')
94
88
  })
95
89
 
96
- it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
97
- const idx = GATEWAY_SRC.indexOf('const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))')
90
+ it('buildModelDeps wires neither the inject nor the select terminal-driver dep', () => {
91
+ const idx = GATEWAY_SRC.indexOf('function buildModelDeps')
98
92
  expect(idx).toBeGreaterThan(0)
99
- const win = GATEWAY_SRC.slice(idx, idx + 1400)
100
- expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
101
- expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
93
+ const win = GATEWAY_SRC.slice(idx, idx + 4000)
94
+ expect(win).not.toContain('inject: injectSlashCommandImpl')
95
+ expect(win).not.toContain('select: (a, label) => selectModel')
102
96
  })
97
+ })
103
98
 
104
- it('the sr-to-claude transition (a relaunch) writes the carrier, and clears it on a Default tap', () => {
105
- const idx = GATEWAY_SRC.indexOf('isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)')
99
+ describe('gateway: scheduleModelDefaultRelaunch (G1 clear + revert relaunch)', () => {
100
+ it('clears the carrier + override and mirrors scheduleModelRelaunch rollback', () => {
101
+ const idx = GATEWAY_SRC.indexOf('scheduleModelDefaultRelaunch: async')
106
102
  expect(idx).toBeGreaterThan(0)
107
- const win = GATEWAY_SRC.slice(idx, idx + 3600)
108
- expect(win).toMatch(/writeSessionModelFile\(\s*agentDir,\s*token/)
109
- expect(win).toContain('clearSessionModelFile(agentDir)')
110
- expect(win).toContain("triggerSelfRestart(agentName, 'sr-to-claude-model-switch'")
103
+ const win = GATEWAY_SRC.slice(idx, idx + 900)
104
+ const clearIdx = win.indexOf('clearSessionModelFile(agentDir)')
105
+ const overrideIdx = win.indexOf('sessionModelSource.setOverride(null)')
106
+ const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
107
+ expect(clearIdx).toBeGreaterThan(0)
108
+ expect(overrideIdx).toBeGreaterThan(clearIdx)
109
+ expect(restartIdx).toBeGreaterThan(overrideIdx)
110
+ // G1 rollback on a non-in-flight dispatch failure.
111
+ expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
112
+ expect(win).toContain("!== 'restart_in_flight'")
111
113
  })
112
114
  })
113
115
 
114
- describe('gateway: typed /model is session-scoped (live Claude switch writes no carrier)', () => {
115
- it('the Claude path sets the in-memory override but never persists a carrier', () => {
116
- const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
116
+ describe('gateway: the live callback dispatcher routes every switch tap to the handler', () => {
117
+ it('calls handleModelMenuCallback and no longer post-processes a scrape outcome', () => {
118
+ const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
117
119
  expect(idx).toBeGreaterThan(0)
118
- const win = GATEWAY_SRC.slice(idx, idx + 1400)
119
- expect(win).toContain('sessionModelSource.setOverride(reply.selectedModel)')
120
- // No durable carrier write on the live-switch path.
121
- expect(win).not.toContain('writeSessionModelFile(')
122
- })
123
-
124
- it('`/model default` clears the carrier + in-memory override (silent-switch path must not resurrect)', () => {
125
- const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
126
- const win = GATEWAY_SRC.slice(idx, idx + 1400)
127
- expect(win).toContain("requested?.toLowerCase() === 'default'")
128
- expect(win).toContain('clearSessionModelFile(smDir)')
129
- expect(win).toContain('sessionModelSource.setOverride(null)')
130
- // The file-clear is not gated on a positive confirmation.
131
- const clearIdx = win.indexOf('if (smDir) clearSessionModelFile(smDir)')
132
- const gatedOverrideIdx = win.indexOf('if (reply.selectedModel) sessionModelSource.setOverride(null)')
133
- expect(clearIdx).toBeGreaterThan(0)
134
- expect(gatedOverrideIdx).toBeGreaterThan(clearIdx)
120
+ const win = GATEWAY_SRC.slice(idx, idx + 600)
121
+ expect(win).not.toContain('recordModelMenuSideEffects')
122
+ })
123
+
124
+ it('the typed dispatcher relays the handler reply directly (no recordTypedModelSwitch)', () => {
125
+ const idx = GATEWAY_SRC.indexOf('const reply = await handleModelCommand(parsed, deps)')
126
+ expect(idx).toBeGreaterThan(0)
127
+ const win = GATEWAY_SRC.slice(idx, idx + 400)
128
+ expect(win).not.toContain('recordTypedModelSwitch')
129
+ expect(win).toContain('switchroomReply(ctx, reply.text')
135
130
  })
136
131
  })
137
132
 
138
- describe('gateway boot: session-model re-hydration + alert relay', () => {
139
- it('re-hydrates the override from .active-session-model', () => {
133
+ describe('gateway boot: session-model re-hydration + confirmation + alert relay', () => {
134
+ it('re-hydrates the override from .active-session-model (launched !== configured)', () => {
140
135
  const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
141
136
  expect(idx).toBeGreaterThan(0)
142
- const win = GATEWAY_SRC.slice(idx - 200, idx + 1400)
143
- expect(win).toMatch(/launched\.length > 0 && launched !== configured \? launched : null/)
137
+ const win = GATEWAY_SRC.slice(idx - 200, idx + 3200)
138
+ // F1: `launched !== configured` is the deterministic apply-boot signal.
139
+ expect(win).toContain('const isApplyBoot = launched.length > 0 && launched !== configured')
140
+ expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null)')
144
141
  expect(win).toContain('resolveMainModel(raw ?? undefined)')
145
142
  })
146
143
 
144
+ it('logs the applied model for diagnosability (F1)', () => {
145
+ expect(GATEWAY_SRC).toContain('gw /model relaunch applied agent=')
146
+ expect(GATEWAY_SRC).toContain('gw /model relaunch scheduled agent=')
147
+ })
148
+
149
+ it('sends ONE switch-confirmation from the ACTUAL launched model, keyed on the /model reason (F1/N4)', () => {
150
+ const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
151
+ expect(idx).toBeGreaterThan(0)
152
+ const win = GATEWAY_SRC.slice(idx, idx + 5200)
153
+ // Keyed on the deterministic /model switch reason, so it also fires on a
154
+ // launched===configured apply-boot (/model default) — N4. Never optimistic.
155
+ expect(win).toContain('if (modelSwitchReason != null && modelSwitchMarkerChat)')
156
+ expect(win).toContain('✅ Now running')
157
+ // N4: the launched===configured branch still confirms.
158
+ expect(win).toContain('(the configured default)')
159
+ })
160
+
161
+ it('warns instead of a green ✅ when a non-default switch silently reverted to the default (silent-revert fix)', () => {
162
+ const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
163
+ const win = GATEWAY_SRC.slice(idx, idx + 5200)
164
+ // The confirmation card is derived from the pure classifier, not an inline
165
+ // isApplyBoot ternary — so a reverted non-default switch yields the ⚠️ card.
166
+ expect(win).toContain('classifyModelSwitchConfirmation({')
167
+ expect(win).toContain("confirmation.kind === 'applied'")
168
+ expect(win).toContain("confirmation.kind === 'not-applied'")
169
+ expect(win).toContain("⚠️ Your switch to")
170
+ expect(win).toContain("didn't apply")
171
+ // LOW-3: the re-issue hint interpolates the target inside backticks so a
172
+ // token containing Markdown metachars can't italicize / 400 the send.
173
+ expect(win).toContain('Re-issue \\`/model ${confirmation.target}\\`')
174
+ })
175
+
176
+ it('dedups the not-applied card against a tailored .session-model-alert (LOW-2)', () => {
177
+ const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
178
+ const win = GATEWAY_SRC.slice(idx, idx + 5200)
179
+ // When start.sh wrote a specific alert for this revert, the classifier's
180
+ // generic not-applied card is suppressed (the alert relay is the message).
181
+ expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
182
+ expect(win).toContain("confirmation.kind === 'not-applied' && hasSessionModelAlert")
183
+ })
184
+
185
+ it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
186
+ expect(GATEWAY_SRC).toContain("cleanMarker.reason.startsWith('user: /model')")
187
+ expect(GATEWAY_SRC).toContain('let modelSwitchReason: string | null = null')
188
+ })
189
+
190
+ it('N3: the generic boot card is suppressed on a /model apply-boot (one card per switch)', () => {
191
+ const idx = GATEWAY_SRC.indexOf('const suppressBootCardForModelSwitch')
192
+ expect(idx).toBeGreaterThan(0)
193
+ const win = GATEWAY_SRC.slice(idx, idx + 800)
194
+ expect(win).toContain('modelSwitchReason != null && modelSwitchMarkerChat != null')
195
+ expect(win).toContain('else if (target)')
196
+ })
197
+
147
198
  it('consumes the .session-model-alert sentinel, notifies ALL operators, and deletes it', () => {
148
- const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.session-model-alert')")
199
+ const idx = GATEWAY_SRC.indexOf("const alertPath = join(smAgentDir, '.session-model-alert')")
149
200
  expect(idx).toBeGreaterThan(0)
150
201
  const win = GATEWAY_SRC.slice(idx, idx + 1100)
151
202
  expect(win).toContain('unlinkSync(alertPath)')
@@ -16,6 +16,7 @@ import {
16
16
  hasOutboundDeliveredSince,
17
17
  hasOutboundWithText,
18
18
  normalizeDeliveryText,
19
+ verifyHistoryWritable,
19
20
  _resetForTests,
20
21
  } from '../history.js'
21
22
 
@@ -963,3 +964,90 @@ describe('hasOutboundWithText (durable text-identity oracle)', () => {
963
964
  expect(hasOutboundWithText('1', 'repeated answer', null, 250_000)).toBe(true)
964
965
  })
965
966
  })
967
+
968
+ // ── 2026-07-16 incident hardening: writer durability across restart + surfacing
969
+ // swallowed insert failures. Root cause: turn-flush deliveries (18944/18958)
970
+ // reached Telegram but were absent from history.db, blinding
971
+ // getRecentOutboundCount / hasOutboundDeliveredSince. The gateway had already
972
+ // logged "history capture enabled" on both restarts, so DB-open success was
973
+ // NOT proof the row-insert path worked. These tests pin the durable fix.
974
+ describe('history writer durability (2026-07-16 incident)', () => {
975
+ it('verifyHistoryWritable proves the INSERT path works on a live DB', () => {
976
+ initHistory(stateDir, 30)
977
+ const res = verifyHistoryWritable()
978
+ expect(res.ok).toBe(true)
979
+ // The self-check must leave NO sentinel residue behind.
980
+ expect(getRecentOutboundCount('__history_selfcheck__', 86_400)).toBe(0)
981
+ })
982
+
983
+ it('verifyHistoryWritable reports not-ok before init (no silent success)', () => {
984
+ // No initHistory() this test — the writer is uninitialised.
985
+ const res = verifyHistoryWritable()
986
+ expect(res.ok).toBe(false)
987
+ expect(res.error).toMatch(/initHistory/)
988
+ })
989
+
990
+ // The core recovery contract: recording must survive a shutdown + reinit
991
+ // (a gateway restart, which nulls the module singleton and re-opens the same
992
+ // file). Rows written before AND after the boundary must all be queryable.
993
+ it('recording continues across a simulated restart (reinit of the same DB)', () => {
994
+ const nowSec = Math.floor(Date.now() / 1000)
995
+ initHistory(stateDir, 30)
996
+ recordOutbound({ chat_id: '9', thread_id: null, message_ids: [100], texts: ['before restart'], ts: nowSec - 60 })
997
+ // Simulate a gateway restart: close + forget the singleton, then reinit
998
+ // against the SAME stateDir (fresh process, db=null → re-open).
999
+ _resetForTests()
1000
+ initHistory(stateDir, 30)
1001
+ // Boot self-check must still pass against the existing, populated file.
1002
+ expect(verifyHistoryWritable().ok).toBe(true)
1003
+ recordOutbound({ chat_id: '9', thread_id: null, message_ids: [101], texts: ['after restart'], ts: nowSec })
1004
+ const rows = query({ chat_id: '9' })
1005
+ expect(rows.map((r) => r.message_id)).toEqual([100, 101])
1006
+ // The backstop suppression counter (the surface blinded by the incident)
1007
+ // must see BOTH the pre- and post-restart outbounds.
1008
+ expect(getRecentOutboundCount('9', 10_000_000_000)).toBe(2)
1009
+ })
1010
+
1011
+ // The exact incident shape: a malformed send result yields an invalid
1012
+ // message_id. OLD behaviour: the NOT NULL PRIMARY KEY throws inside the tx and
1013
+ // the caller's `catch {}` swallows it — the row is lost AND invisible. NEW
1014
+ // behaviour: the invalid chunk is filtered + logged, valid chunks still land,
1015
+ // and no throw escapes to be swallowed.
1016
+ it('drops an invalid message_id chunk loudly but records the valid ones (no silent total loss)', () => {
1017
+ initHistory(stateDir, 30)
1018
+ const errs: string[] = []
1019
+ const orig = process.stderr.write.bind(process.stderr)
1020
+ // @ts-expect-error narrow test shim over the write overloads
1021
+ process.stderr.write = (chunk: string) => { errs.push(String(chunk)); return true }
1022
+ try {
1023
+ recordOutbound({
1024
+ chat_id: '9',
1025
+ thread_id: null,
1026
+ // chunk 0 is a malformed (undefined) id; chunk 1 is real.
1027
+ message_ids: [undefined as unknown as number, 200],
1028
+ texts: ['lost chunk', 'kept chunk'],
1029
+ ts: 3000,
1030
+ })
1031
+ } finally {
1032
+ process.stderr.write = orig
1033
+ }
1034
+ // The valid chunk is recorded (delivery accounting is NOT silently zeroed).
1035
+ const rows = query({ chat_id: '9' })
1036
+ expect(rows.map((r) => r.message_id)).toEqual([200])
1037
+ // The drop was surfaced loudly, not swallowed.
1038
+ expect(errs.join('')).toMatch(/invalid message_id/)
1039
+ })
1040
+
1041
+ it('recordOutbound with an all-invalid id set no-ops without throwing', () => {
1042
+ initHistory(stateDir, 30)
1043
+ expect(() =>
1044
+ recordOutbound({
1045
+ chat_id: '9',
1046
+ thread_id: null,
1047
+ message_ids: [NaN, null as unknown as number],
1048
+ texts: ['a', 'b'],
1049
+ }),
1050
+ ).not.toThrow()
1051
+ expect(getRecentOutboundCount('9', 10_000_000_000)).toBe(0)
1052
+ })
1053
+ })