switchroom 0.18.15 → 0.18.17

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 (40) hide show
  1. package/dist/agent-scheduler/index.js +3 -0
  2. package/dist/auth-broker/index.js +432 -10
  3. package/dist/cli/notion-write-pretool.mjs +3 -0
  4. package/dist/cli/switchroom.js +50 -1
  5. package/dist/host-control/main.js +4 -1
  6. package/dist/vault/approvals/kernel-server.js +3 -0
  7. package/dist/vault/broker/server.js +3 -0
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +81 -139
  10. package/telegram-plugin/dist/gateway/gateway.js +386 -259
  11. package/telegram-plugin/draft-stream.ts +78 -3
  12. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +3 -4
  13. package/telegram-plugin/gateway/effort-command.ts +9 -7
  14. package/telegram-plugin/gateway/gateway.ts +265 -220
  15. package/telegram-plugin/gateway/litellm-local-notice-wiring.ts +200 -0
  16. package/telegram-plugin/gateway/model-command.ts +96 -18
  17. package/telegram-plugin/gateway/pending-session-command.ts +10 -8
  18. package/telegram-plugin/gateway/session-model-file.ts +38 -172
  19. package/telegram-plugin/litellm-local-notice.ts +189 -0
  20. package/telegram-plugin/quota-watch.ts +16 -4
  21. package/telegram-plugin/runtime-metrics.ts +16 -0
  22. package/telegram-plugin/send-gate-degraded.test.ts +9 -7
  23. package/telegram-plugin/send-gate.ts +34 -4
  24. package/telegram-plugin/stream-controller.ts +143 -20
  25. package/telegram-plugin/stream-reply-handler.ts +12 -2
  26. package/telegram-plugin/tests/bot-api.harness.ts +7 -2
  27. package/telegram-plugin/tests/draft-stream.test.ts +110 -1
  28. package/telegram-plugin/tests/effort-command.test.ts +4 -4
  29. package/telegram-plugin/tests/flood-windows-persistence.test.ts +2 -2
  30. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +33 -19
  31. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +47 -127
  32. package/telegram-plugin/tests/litellm-local-notice.test.ts +417 -0
  33. package/telegram-plugin/tests/model-command.test.ts +84 -1
  34. package/telegram-plugin/tests/quota-watch.test.ts +21 -0
  35. package/telegram-plugin/tests/reaction-gate-routing.test.ts +2 -2
  36. package/telegram-plugin/tests/session-model-file.test.ts +7 -155
  37. package/telegram-plugin/tests/stream-controller-send-gate.test.ts +521 -0
  38. package/telegram-plugin/tests/stream-reply-handler.test.ts +44 -0
  39. package/telegram-plugin/tests/worker-activity-feed.test.ts +207 -0
  40. package/telegram-plugin/worker-activity-feed.ts +83 -8
@@ -84,7 +84,11 @@ export function createMockBot(startMessageId = 500): MockBot {
84
84
  const api: MockBotApi = {
85
85
  sendMessage: vi.fn(async () => ({ message_id: state.nextMessageId++ })),
86
86
  sendRichMessage: vi.fn(async () => ({ message_id: state.nextMessageId++ })),
87
- editMessageText: vi.fn(async () => undefined),
87
+ // Faithful to grammy: editMessageText resolves `Message | true`, NEVER
88
+ // undefined. An `undefined` from the production retry stack means the
89
+ // send gate shed/skipped the call (#3110) — stream-controller treats it
90
+ // as not-landed — so the mock default must not be undefined.
91
+ editMessageText: vi.fn(async () => true as const),
88
92
  deleteMessage: vi.fn(async () => true as const),
89
93
  setMessageReaction: vi.fn(async () => true as const),
90
94
  editMessageReplyMarkup: vi.fn(async () => undefined),
@@ -122,7 +126,8 @@ export function installBotResetHook(bot: MockBot): void {
122
126
  bot.api.sendRichMessage.mockImplementation(async () => ({
123
127
  message_id: bot.nextMessageId++,
124
128
  }))
125
- bot.api.editMessageText.mockImplementation(async () => undefined)
129
+ // Faithful to grammy: `Message | true`, never undefined (see above).
130
+ bot.api.editMessageText.mockImplementation(async () => true as const)
126
131
  bot.api.deleteMessage.mockImplementation(async () => true as const)
127
132
  bot.api.setMessageReaction.mockImplementation(async () => true as const)
128
133
  bot.api.editMessageReplyMarkup.mockImplementation(async () => undefined)
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
- import { createDraftStream } from '../draft-stream.js'
2
+ import { createDraftStream, makeDraftEditShedError } from '../draft-stream.js'
3
3
 
4
4
  interface MockTelegram {
5
5
  send: (text: string) => Promise<number>
@@ -137,6 +137,115 @@ describe('createDraftStream', () => {
137
137
  expect(stream.isFinal()).toBe(true)
138
138
  })
139
139
 
140
+ it('finalize(finalText) flushes the supplied snapshot with the stream already final (#3110)', async () => {
141
+ const m = makeMock()
142
+ // Capture what isFinal() reads AT EDIT TIME — the transport layer
143
+ // (stream-controller) classifies the send-gate priority from exactly
144
+ // this signal, so the final snapshot MUST flush with final=true.
145
+ const finalAtEdit: boolean[] = []
146
+ const stream = createDraftStream(
147
+ m.send,
148
+ async (id, text) => {
149
+ finalAtEdit.push(stream.isFinal())
150
+ await m.edit(id, text)
151
+ },
152
+ { throttleMs: 1000 },
153
+ )
154
+
155
+ void stream.update('initial')
156
+ await microtaskFlush()
157
+ expect(m.sendCalls.length).toBe(1)
158
+
159
+ // A stale draft is pending; finalize(text) supersedes it (last-write-wins).
160
+ void stream.update('stale draft')
161
+ await microtaskFlush()
162
+ await stream.finalize('the completed answer')
163
+
164
+ expect(m.editCalls.length).toBe(1)
165
+ expect(m.editCalls[0].text).toBe('the completed answer')
166
+ expect(finalAtEdit).toEqual([true])
167
+ expect(stream.isFinal()).toBe(true)
168
+ })
169
+
170
+ it('a shed flush preserves the snapshot; argument-less finalize() re-delivers it (#3110 F2)', async () => {
171
+ const m = makeMock()
172
+ let shedNext = true
173
+ const stream = createDraftStream(
174
+ m.send,
175
+ async (id, text) => {
176
+ if (shedNext) {
177
+ shedNext = false
178
+ throw makeDraftEditShedError(id)
179
+ }
180
+ await m.edit(id, text)
181
+ },
182
+ { throttleMs: 1000 },
183
+ )
184
+
185
+ void stream.update('v1')
186
+ await microtaskFlush()
187
+ expect(m.sendCalls.length).toBe(1)
188
+
189
+ void stream.update('v2 — shed by the gate')
190
+ vi.advanceTimersByTime(1000)
191
+ await microtaskFlush()
192
+ // The edit was shed: nothing landed, and the snapshot must NOT be
193
+ // recorded as sent.
194
+ expect(m.editCalls.length).toBe(0)
195
+
196
+ // The gateway's cleanup paths finalize with NO argument — the shed
197
+ // snapshot must be re-flushed as the stream's final state, not lost.
198
+ await stream.finalize()
199
+ expect(m.editCalls.length).toBe(1)
200
+ expect(m.editCalls[0].text).toBe('v2 — shed by the gate')
201
+ })
202
+
203
+ it('a newer landed flush supersedes an earlier shed snapshot (no stale resurrect)', async () => {
204
+ const m = makeMock()
205
+ let shedNext = true
206
+ const stream = createDraftStream(
207
+ m.send,
208
+ async (id, text) => {
209
+ if (shedNext) {
210
+ shedNext = false
211
+ throw makeDraftEditShedError(id)
212
+ }
213
+ await m.edit(id, text)
214
+ },
215
+ { throttleMs: 1000 },
216
+ )
217
+
218
+ void stream.update('v1')
219
+ await microtaskFlush()
220
+ void stream.update('v2 — shed')
221
+ vi.advanceTimersByTime(1000)
222
+ await microtaskFlush()
223
+ expect(m.editCalls.length).toBe(0)
224
+
225
+ // A NEWER snapshot lands normally — the shed one is now stale.
226
+ void stream.update('v3 — landed')
227
+ vi.advanceTimersByTime(1000)
228
+ await microtaskFlush()
229
+ expect(m.editCalls.map((c) => c.text)).toEqual(['v3 — landed'])
230
+
231
+ // finalize() must NOT resurrect the superseded shed snapshot.
232
+ await stream.finalize()
233
+ expect(m.editCalls.map((c) => c.text)).toEqual(['v3 — landed'])
234
+ })
235
+
236
+ it('finalize(finalText) still dedupes against text that actually landed', async () => {
237
+ const m = makeMock()
238
+ const stream = createDraftStream(m.send, m.edit, { throttleMs: 1000 })
239
+
240
+ void stream.update('the answer')
241
+ await microtaskFlush()
242
+ expect(m.sendCalls.length).toBe(1)
243
+
244
+ // Same text again as the final snapshot → already on screen, no edit.
245
+ await stream.finalize('the answer')
246
+ expect(m.editCalls.length).toBe(0)
247
+ })
248
+
140
249
  it('updates after finalize are silently dropped', async () => {
141
250
  const m = makeMock()
142
251
  const stream = createDraftStream(m.send, m.edit, { throttleMs: 1000 })
@@ -107,7 +107,7 @@ describe("effort-command: handler", () => {
107
107
  const { deps } = makeDeps({ getConfiguredEffort: () => "medium" });
108
108
  const r = await handleEffortCommand({ kind: "show" }, deps);
109
109
  expect(r.text).toContain("medium");
110
- expect(r.text).toMatch(/persists across restarts and deploys/);
110
+ expect(r.text).toMatch(/lasts until the agent’s next restart/);
111
111
  });
112
112
 
113
113
  it("show falls back to low when effort is unreadable", async () => {
@@ -121,7 +121,7 @@ describe("effort-command: handler", () => {
121
121
  const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
122
122
  expect(calls).toEqual([{ agent: "carrie", level: "high" }]);
123
123
  expect(r.text).toContain("Set effort level to high");
124
- expect(r.text).toMatch(/persists across restarts and deploys/);
124
+ expect(r.text).toMatch(/lasts until the agent’s next restart/);
125
125
  });
126
126
 
127
127
  it("set notes the re-read cost when a confirmation was needed", async () => {
@@ -249,9 +249,9 @@ describe("effort-command: /effort default (#3039)", () => {
249
249
  expect(marked?.text).toBe("✅ max");
250
250
  });
251
251
 
252
- it("help text advertises /effort default and the sticky contract", async () => {
252
+ it("help text advertises /effort default and the session-only contract", async () => {
253
253
  const r = await handleEffortCommand({ kind: "help" }, makeDeps().deps);
254
254
  expect(r.text).toContain("/effort default");
255
- expect(r.text).toContain("persists across restarts and deploys");
255
+ expect(r.text).toContain("lasts until the agent’s next restart");
256
256
  });
257
257
  });
@@ -14,7 +14,7 @@ import {
14
14
  FLOOD_STATE_MODE,
15
15
  FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS,
16
16
  } from '../flood-circuit-breaker.js'
17
- import { createSendGate, type Clock } from '../send-gate.js'
17
+ import { createSendGate, SEND_GATE_SHED, type Clock } from '../send-gate.js'
18
18
 
19
19
  /**
20
20
  * #3084 PR 2 — restart-proof SCOPED flood windows (part3-design §7). Verifies
@@ -119,7 +119,7 @@ describe('#3084 scoped flood-window persistence', () => {
119
119
  chat_id: '7',
120
120
  priorityClass: 'cosmetic',
121
121
  })
122
- expect(res).toBeUndefined()
122
+ expect(res).toBe(SEND_GATE_SHED) // shed sentinel (#3110 F1)
123
123
  expect(gate2.stats().global.shed).toBe(1)
124
124
 
125
125
  // And a critical into the still-long window fails fast — restart did not
@@ -89,36 +89,50 @@ describe('gateway: unconfirmed queued model tokens are gated before durable pers
89
89
  })
90
90
  })
91
91
 
92
- describe('gateway: /restart keeps the session-model override (#3039)', () => {
93
- it('the /restart chat command stamps keep, never revert', () => {
94
- expect(GATEWAY_SRC).toContain("writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')")
95
- expect(GATEWAY_SRC).not.toContain("writeRelaunchModelIntent(smDir, 'revert'")
92
+ describe('gateway: /restart reverts the session-model override (rev 4, session-scoped)', () => {
93
+ it('no restart verb stamps a relaunch-model intent (the subsystem is retired)', () => {
94
+ expect(GATEWAY_SRC).not.toContain('writeRelaunchModelIntent')
96
95
  })
97
96
  })
98
97
 
99
- describe('gateway: /effort persistence choke point (#3039)', () => {
100
- it('buildEffortDeps persists a confirmed apply to .session-effort and wires clearSessionEffort', () => {
98
+ describe('gateway: /effort is session-scoped (#3186)', () => {
99
+ it('buildEffortDeps records a confirmed live apply IN MEMORY only — no durable carrier write', () => {
101
100
  const fnIdx = GATEWAY_SRC.indexOf('function buildEffortDeps(')
102
101
  expect(fnIdx).toBeGreaterThan(0)
103
102
  const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 2500)
104
- expect(win).toContain('writeSessionEffortFile(')
103
+ expect(win).not.toContain('writeSessionEffortFile(')
104
+ expect(win).toContain('sessionEffortOverride = level')
105
+ // /effort default clears the in-memory level AND any leftover carrier.
106
+ expect(win).toContain('sessionEffortOverride = null')
105
107
  expect(win).toContain('clearSessionEffortFile(')
106
- expect(win).toContain('readSessionEffortFile(')
107
108
  })
108
- })
109
109
 
110
- describe('gateway: keep-intent stamp narrowing (#3018 finding 4)', () => {
111
- it('the shutdown keep-intent stamp carries the gateway-shutdown reason prefix', () => {
112
- expect(GATEWAY_SRC).toContain(
113
- "writeRelaunchModelIntent(smDir, 'keep', `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful ${signal} shutdown",
114
- )
110
+ it('the queued-command shutdown persist is the ONLY .session-effort writer', () => {
111
+ const writes = [...GATEWAY_SRC.matchAll(/writeSessionEffortFile\(/g)]
112
+ expect(writes.length).toBe(1)
113
+ const fnIdx = GATEWAY_SRC.indexOf('function persistQueuedCommandForRestart(')
114
+ expect(fnIdx).toBeGreaterThan(0)
115
+ expect(writes[0].index).toBeGreaterThan(fnIdx)
116
+ expect(writes[0].index).toBeLessThan(fnIdx + 3000)
115
117
  })
116
118
 
117
- it('boot clears a stale gateway-shutdown-stamped intent (gateway-only bounce never runs start.sh)', () => {
118
- const idx = GATEWAY_SRC.indexOf('clearStaleGatewayShutdownIntent(bootSmDir)')
119
+ it('boot re-hydrates the in-memory effort override from .active-session-effort', () => {
120
+ const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-effort')")
121
+ expect(idx).toBeGreaterThan(0)
122
+ const win = GATEWAY_SRC.slice(idx, idx + 800)
123
+ expect(win).toContain('getConfiguredEffortForPersist()')
124
+ expect(win).toContain('sessionEffortOverride =')
125
+ })
126
+ })
127
+
128
+ describe('gateway: graceful shutdown no longer preserves a session model (rev 4)', () => {
129
+ it('the shutdown handler stamps no keep-intent — a deploy reverts to config', () => {
130
+ const idx = GATEWAY_SRC.indexOf('async function shutdown(signal: string)')
119
131
  expect(idx).toBeGreaterThan(0)
120
- // The cleanup runs at module top-level (gateway boot), BEFORE the shutdown
121
- // handler could stamp a fresh one for THIS process's own exit.
122
- expect(idx).toBeLessThan(GATEWAY_SRC.indexOf('async function shutdown(signal: string)'))
132
+ const win = GATEWAY_SRC.slice(idx, idx + 6000)
133
+ expect(win).not.toContain('writeRelaunchModelIntent')
134
+ expect(win).not.toContain('GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX')
135
+ // The queued-command persist at shutdown still writes a consume-once carrier.
136
+ expect(win).toContain('persistQueuedCommandForRestart')
123
137
  })
124
138
  })
@@ -1,15 +1,14 @@
1
1
  /**
2
- * Structural pins for the session-model stickiness wiring in gateway.ts
3
- * (reference/rfcs/session-model-stickiness.md).
2
+ * Structural pins for the session-scoped /model wiring in gateway.ts
3
+ * (reference/rfcs/session-model-stickiness.md §0.1, rev 4 — consume-once).
4
4
  *
5
5
  * The behaviour lives in un-exported inline closures (buildModelDeps's
6
- * scheduleModelRelaunch/scheduleRestart, triggerSelfRestart, the /restart and
7
- * /new handlers, the model-menu callback branches, and the boot re-hydration
8
- * block inside the startup IIFE), so — mirroring the other gateway-*.test.ts
9
- * source-pins — we assert on the source structure. The end-to-end behaviour
10
- * of the boot resolver is exercised in tests/scaffold.session-model.test.ts
11
- * (rendered start.sh), the file helpers in session-model-file.test.ts, and
12
- * the handler contract in model-command.test.ts.
6
+ * scheduleModelRelaunch/scheduleRestart, the typed/menu recorders, and the
7
+ * boot re-hydration block inside the startup IIFE), so — mirroring the other
8
+ * gateway-*.test.ts source-pins — we assert on the source structure. The
9
+ * end-to-end boot behaviour is exercised in tests/scaffold.session-model.test.ts
10
+ * (rendered start.sh), the file helpers in session-model-file.test.ts, and the
11
+ * handler contract in model-command.test.ts.
13
12
  */
14
13
 
15
14
  import { describe, it, expect } from 'vitest'
@@ -20,35 +19,27 @@ import { dirname, resolve } from 'node:path'
20
19
  const __dirname = dirname(fileURLToPath(import.meta.url))
21
20
  const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
22
21
 
23
- describe('gateway: triggerSelfRestart stamps relaunch-model intent BEFORE the kill', () => {
24
- it('docker branch: writeRelaunchModelIntent(intentForRestartReason(reason)) precedes the SIGTERM scheduling', () => {
25
- const fnIdx = GATEWAY_SRC.indexOf('function triggerSelfRestart(')
26
- expect(fnIdx).toBeGreaterThan(0)
27
- const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 3000)
28
- const writeIdx = win.indexOf('writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)')
29
- const killIdx = win.indexOf("process.kill(1, 'SIGTERM')")
30
- // Write-before-kill invariant: boot default is REVERT, so the intent must
31
- // be on disk synchronously before the SIGTERM is even scheduled.
32
- expect(writeIdx).toBeGreaterThan(0)
33
- expect(killIdx).toBeGreaterThan(writeIdx)
34
- // And before the setTimeout that schedules it.
35
- const timeoutIdx = win.indexOf('setTimeout(')
36
- expect(timeoutIdx).toBeGreaterThan(writeIdx)
22
+ describe('gateway: the .relaunch-model-intent subsystem is retired (rev 4)', () => {
23
+ it('gateway.ts no longer writes/clears/imports any relaunch-model intent', () => {
24
+ expect(GATEWAY_SRC).not.toContain('writeRelaunchModelIntent')
25
+ expect(GATEWAY_SRC).not.toContain('clearRelaunchModelIntent')
26
+ expect(GATEWAY_SRC).not.toContain('intentForRestartReason')
27
+ expect(GATEWAY_SRC).not.toContain('clearStaleGatewayShutdownIntent')
28
+ expect(GATEWAY_SRC).not.toContain('GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX')
29
+ expect(GATEWAY_SRC).not.toContain('.relaunch-model-intent')
37
30
  })
38
31
 
39
- it('legacy systemd branch stamps intent too (self-target only)', () => {
32
+ it('triggerSelfRestart just signals — no intent stamp before the kill', () => {
40
33
  const fnIdx = GATEWAY_SRC.indexOf('function triggerSelfRestart(')
41
- const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 4200)
42
- const legacyIdx = win.indexOf('// Legacy systemd path.')
43
- expect(legacyIdx).toBeGreaterThan(0)
44
- const legacyWin = win.slice(legacyIdx)
45
- expect(legacyWin).toContain('writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)')
46
- expect(legacyWin.indexOf('writeRelaunchModelIntent')).toBeLessThan(legacyWin.indexOf('spawn('))
34
+ expect(fnIdx).toBeGreaterThan(0)
35
+ const win = GATEWAY_SRC.slice(fnIdx, fnIdx + 3000)
36
+ expect(win).toContain("process.kill(1, 'SIGTERM')")
37
+ expect(win).not.toContain('writeRelaunchModelIntent')
47
38
  })
48
39
  })
49
40
 
50
- describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
51
- it('writes the durable file via writeSessionModelFile before dispatching the restart', () => {
41
+ describe('gateway: scheduleModelRelaunch dep (consume-once .session-model carrier)', () => {
42
+ it('writes the carrier via writeSessionModelFile before dispatching the restart', () => {
52
43
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
53
44
  expect(idx).toBeGreaterThan(0)
54
45
  const win = GATEWAY_SRC.slice(idx, idx + 1800)
@@ -67,7 +58,7 @@ describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
67
58
  expect(restartIdx).toBeGreaterThan(setIdx)
68
59
  })
69
60
 
70
- it('rolls back the prior file content (not just deletion) on a non-in-flight dispatch failure', () => {
61
+ it('rolls back the prior carrier content (not just deletion) on a non-in-flight dispatch failure', () => {
71
62
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
72
63
  const win = GATEWAY_SRC.slice(idx, idx + 1800)
73
64
  expect(win).toContain('const prevFileRaw = readSessionModelFileRaw(agentDir)')
@@ -75,15 +66,6 @@ describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
75
66
  expect(win).toContain("!== 'restart_in_flight'")
76
67
  })
77
68
 
78
- it('the non-in-flight rollback ALSO clears the keep-intent (a live intent with no restart coming would wrongly KEEP across a crash)', () => {
79
- const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
80
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
81
- const inFlightIdx = win.indexOf("!== 'restart_in_flight'")
82
- const clearIdx = win.indexOf('clearRelaunchModelIntent(agentDir)')
83
- expect(inFlightIdx).toBeGreaterThan(0)
84
- expect(clearIdx).toBeGreaterThan(inFlightIdx)
85
- })
86
-
87
69
  it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
88
70
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
89
71
  const win = GATEWAY_SRC.slice(idx, idx + 1800)
@@ -91,63 +73,20 @@ describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
91
73
  })
92
74
  })
93
75
 
94
- describe('gateway: intent writers on the restart verbs', () => {
95
- it('model-switch scheduleRestart stamps keep-intent before the hostd dispatch', () => {
96
- const idx = GATEWAY_SRC.indexOf('scheduleRestart: async (reason: string)')
97
- expect(idx).toBeGreaterThan(0)
98
- const win = GATEWAY_SRC.slice(idx, idx + 3200)
99
- const keepIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'keep', reason)")
100
- const dispatchIdx = win.indexOf("op: 'agent_restart'")
101
- expect(keepIdx).toBeGreaterThan(0)
102
- expect(dispatchIdx).toBeGreaterThan(keepIdx)
103
- })
104
-
105
- it('scheduleRestart clears the keep-intent when hostd refuses (failed dispatch → no live intent on disk)', () => {
106
- const idx = GATEWAY_SRC.indexOf('scheduleRestart: async (reason: string)')
107
- const win = GATEWAY_SRC.slice(idx, idx + 3200)
108
- const failIdx = win.indexOf('hostd restart failed')
109
- const clearIdx = win.indexOf('clearRelaunchModelIntent(smDir)')
110
- expect(clearIdx).toBeGreaterThan(0)
111
- expect(failIdx).toBeGreaterThan(clearIdx) // cleared before the throw's message
112
- // And it sits in the same error branch as clearRestartMarker.
113
- const markerIdx = win.indexOf('clearRestartMarker()')
114
- expect(clearIdx).toBeGreaterThan(markerIdx)
115
- })
116
-
117
- it('/restart stamps a KEEP intent before dispatch (#3039: a restart is not "clear my model")', () => {
118
- const idx = GATEWAY_SRC.indexOf("stampUserRestartReason('user: /restart from chat')")
119
- expect(idx).toBeGreaterThan(0)
120
- const win = GATEWAY_SRC.slice(idx, idx + 900)
121
- const keepIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'keep', 'user: /restart from chat')")
122
- const dispatchIdx = win.indexOf("hostdRequestId('gw-restart')")
123
- expect(keepIdx).toBeGreaterThan(0)
124
- expect(dispatchIdx).toBeGreaterThan(keepIdx)
125
- })
126
-
127
- it('/new and /reset stamp keep-intent (fresh conversation, same model — contract row 7)', () => {
128
- const idx = GATEWAY_SRC.indexOf('stampUserRestartReason(`user: /${kind} from chat`)')
129
- expect(idx).toBeGreaterThan(0)
130
- const win = GATEWAY_SRC.slice(idx, idx + 700)
131
- const keepIdx = win.indexOf("writeRelaunchModelIntent(agentDir, 'keep', `user: /${kind} from chat`)")
132
- const dispatchIdx = win.indexOf('tryHostdDispatch')
133
- expect(keepIdx).toBeGreaterThan(0)
134
- expect(dispatchIdx).toBeGreaterThan(keepIdx)
135
- })
136
- })
137
-
138
- describe('gateway: model-menu callback persists the sticky override', () => {
139
- it('a confirmed selection persists the canonical token (selectedModelToken), never the display label', () => {
140
- // Recording extracted into recordModelMenuSideEffects (#3017) — shared by the
141
- // live dispatcher and the deferred (queued mid-turn) apply so both record
142
- // identically.
76
+ describe('gateway: menu callback carrier handling (session-scoped)', () => {
77
+ it('a live Claude selection records the in-memory override but writes NO carrier', () => {
143
78
  const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
144
79
  expect(idx).toBeGreaterThan(0)
145
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
146
- expect(win).toContain('outcome.selectedModelToken')
147
- expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*outcome\.selectedModelToken/)
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(')
148
87
  })
149
88
 
150
- it('a confirmed "Default" selection CLEARS the sticky file', () => {
89
+ it('a confirmed "Default" selection CLEARS the carrier', () => {
151
90
  const idx = GATEWAY_SRC.indexOf('function recordModelMenuSideEffects')
152
91
  const win = GATEWAY_SRC.slice(idx, idx + 2400)
153
92
  expect(win).toContain('outcome.clearedDefault')
@@ -155,9 +94,6 @@ describe('gateway: model-menu callback persists the sticky override', () => {
155
94
  })
156
95
 
157
96
  it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
158
- // Anchor on the sr-* TARGET dispatcher branch specifically (a mid-turn
159
- // busy-gate #3017 also matches `if (data.startsWith(MODEL_CALLBACK_SR))`, so
160
- // anchor on the relaunch call that is unique to the idle apply branch).
161
97
  const idx = GATEWAY_SRC.indexOf('const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))')
162
98
  expect(idx).toBeGreaterThan(0)
163
99
  const win = GATEWAY_SRC.slice(idx, idx + 1400)
@@ -165,53 +101,37 @@ describe('gateway: model-menu callback persists the sticky override', () => {
165
101
  expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
166
102
  })
167
103
 
168
- it('the sr-to-claude transition writes the durable file (and clears it on a Default tap)', () => {
104
+ it('the sr-to-claude transition (a relaunch) writes the carrier, and clears it on a Default tap', () => {
169
105
  const idx = GATEWAY_SRC.indexOf('isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)')
170
106
  expect(idx).toBeGreaterThan(0)
171
107
  const win = GATEWAY_SRC.slice(idx, idx + 3600)
172
108
  expect(win).toMatch(/writeSessionModelFile\(\s*agentDir,\s*token/)
173
109
  expect(win).toContain('clearSessionModelFile(agentDir)')
174
- // Restart rides triggerSelfRestart with a keep-classified reason.
175
110
  expect(win).toContain("triggerSelfRestart(agentName, 'sr-to-claude-model-switch'")
176
111
  })
177
112
  })
178
113
 
179
- describe('gateway: typed /model persists the REQUESTED canonical token', () => {
180
- it('persists expandSrAlias(parsed.model), and `/model default` clears file + in-memory override', () => {
181
- // Recording extracted into recordTypedModelSwitch (#3017) — shared by the
182
- // live `bot.command('model')` handler and the deferred (queued mid-turn) apply.
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', () => {
183
116
  const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
184
117
  expect(idx).toBeGreaterThan(0)
185
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
186
- expect(win).toContain("requested?.toLowerCase() === 'default'")
187
- expect(win).toContain('sessionModelSource.setOverride(null)')
188
- expect(win).toContain('clearSessionModelFile(smDir)')
189
- // Non-default: the requested token (shape-gated, non-sr) is what persists.
190
- expect(win).toContain('isValidModelArg(requested) && !isSrModel(requested)')
191
- expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*requested/)
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(')
192
122
  })
193
123
 
194
- it('`/model default` file-clear is NOT gated on a positive confirmation (silent-switch path must not resurrect)', () => {
124
+ it('`/model default` clears the carrier + in-memory override (silent-switch path must not resurrect)', () => {
195
125
  const idx = GATEWAY_SRC.indexOf('function recordTypedModelSwitch')
196
- const win = GATEWAY_SRC.slice(idx, idx + 2400)
197
- // The default branch clears the file unconditionally, and only the
198
- // in-memory override change is confirmation-gated inside it.
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.
199
131
  const clearIdx = win.indexOf('if (smDir) clearSessionModelFile(smDir)')
200
132
  const gatedOverrideIdx = win.indexOf('if (reply.selectedModel) sessionModelSource.setOverride(null)')
201
133
  expect(clearIdx).toBeGreaterThan(0)
202
134
  expect(gatedOverrideIdx).toBeGreaterThan(clearIdx)
203
- // And the whole default branch is not nested in an `if (reply.selectedModel)` block:
204
- const between = win.slice(0, clearIdx)
205
- expect(between).not.toContain('if (reply.selectedModel) {')
206
- })
207
-
208
- it('a persist failure is surfaced ON THE REPLY, not just stderr (typed + menu paths)', () => {
209
- expect(GATEWAY_SRC.match(/won’t survive a relaunch/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
210
- const typedIdx = GATEWAY_SRC.indexOf('persistWarning =')
211
- expect(typedIdx).toBeGreaterThan(0)
212
- expect(GATEWAY_SRC).toContain('reply.text + persistWarning')
213
- // Menu path appends onto the outgoing card text.
214
- expect(GATEWAY_SRC).toContain('outcome.reply.text +=')
215
135
  })
216
136
  })
217
137