switchroom 0.18.7 → 0.18.9

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 (85) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/switchroom.js +905 -758
  3. package/dist/host-control/main.js +1 -1
  4. package/package.json +1 -1
  5. package/profiles/_base/start.sh.hbs +111 -34
  6. package/skills/switchroom-runtime/SKILL.md +2 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
  8. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  9. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  10. package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
  11. package/telegram-plugin/gateway/boot-card.ts +27 -0
  12. package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
  13. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  14. package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
  15. package/telegram-plugin/gateway/gateway.ts +1169 -3043
  16. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
  17. package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
  18. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  19. package/telegram-plugin/gateway/model-command.ts +23 -11
  20. package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
  21. package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
  22. package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
  23. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  24. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  25. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  26. package/telegram-plugin/hooks/hooks.json +10 -10
  27. package/telegram-plugin/hooks/run-hook.sh +84 -0
  28. package/telegram-plugin/model-unavailable.ts +26 -0
  29. package/telegram-plugin/pty-partial-handler.ts +39 -0
  30. package/telegram-plugin/render/rich-render.ts +79 -1
  31. package/telegram-plugin/retry-api-call.ts +62 -0
  32. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  33. package/telegram-plugin/silence-poke.ts +14 -0
  34. package/telegram-plugin/stream-controller.ts +156 -38
  35. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  36. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  37. package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
  38. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  39. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  40. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  41. package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
  42. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
  43. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
  44. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  45. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
  46. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  47. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
  48. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
  49. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
  50. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  51. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
  52. package/telegram-plugin/tests/model-command.test.ts +2 -2
  53. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  54. package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
  55. package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
  56. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
  57. package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
  58. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  59. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  60. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  61. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  62. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  63. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  64. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  65. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  67. package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
  68. package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
  69. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
  70. package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
  71. package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
  72. package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
  73. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
  74. package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
  75. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
  76. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  77. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  78. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  79. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  80. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  81. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  82. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  83. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  84. package/telegram-plugin/voice-ondemand.ts +25 -1
  85. package/telegram-plugin/voice-send.ts +154 -0
@@ -131,10 +131,17 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
131
131
  return after.split('\nasync function ')[0]?.split('\nfunction ')[0] ?? after
132
132
  }
133
133
 
134
- it('executeReply finalizes (clearActivitySummary) before the chunk loop, gated on substantive', () => {
134
+ // #2996 step 1: the chunk send loop was relocated verbatim from executeReply
135
+ // into outbound-send-path.ts's `sendReplyChunks`, invoked here as
136
+ // `await sendReplyChunks(chunkSendDeps, …)`. This guard's INTENT — the
137
+ // lever-2 card finalize runs BEFORE the reply send — is unchanged; the send
138
+ // marker is now the delegation call rather than the inline `for` loop.
139
+ const SEND_MARKER = 'sendReplyChunks('
140
+
141
+ it('executeReply finalizes (clearActivitySummary) before the reply send, gated on substantive', () => {
135
142
  const src = executeReplySrc()
136
143
  const clearIdx = src.indexOf('clearActivitySummary(')
137
- const loopIdx = src.indexOf('for (let i = 0; i < chunks.length')
144
+ const loopIdx = src.indexOf(SEND_MARKER)
138
145
  expect(clearIdx).toBeGreaterThan(-1)
139
146
  expect(loopIdx).toBeGreaterThan(-1)
140
147
  expect(clearIdx).toBeLessThan(loopIdx)
@@ -148,8 +155,8 @@ describe('lever 2 — finalize the card BEFORE a substantive reply send', () =>
148
155
  // An ack (non-substantive) falls through and never finalizes early, so the
149
156
  // reopen path keeps owning the card (the #2141 ack-then-work feed).
150
157
  const replySrc = executeReplySrc()
151
- // The pre-loop clearActivitySummary must be the substantive-gated one.
152
- const preLoop = replySrc.split('for (let i = 0; i < chunks.length')[0] ?? ''
158
+ // The pre-send clearActivitySummary must be the substantive-gated one.
159
+ const preLoop = replySrc.split(SEND_MARKER)[0] ?? ''
153
160
  const clears = [...preLoop.matchAll(/clearActivitySummary\(/g)]
154
161
  expect(clears).toHaveLength(1)
155
162
  })
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Subprocess probe for the SWITCHROOM_DELIVERY_MACHINE_CUTOVER kill switch.
3
+ *
4
+ * The cutover env var is read at MODULE LOAD in both
5
+ * `inbound-delivery-machine-dispatch.ts` and
6
+ * `inbound-delivery-machine-shadow.ts`, so an in-process test can't flip it
7
+ * after import (and bun's `vi` shim has no resetModules/stubEnv). The
8
+ * cutover-flip test spawns this script under `bun` with the env it wants
9
+ * and asserts the JSON verdict printed on stdout.
10
+ *
11
+ * Output: one JSON line —
12
+ * {
13
+ * dispatchEnabled, cutoverEnabled, shadowEnabled,
14
+ * sendCalls, buffered, setTurnStartedCalls, deliverResults
15
+ * }
16
+ * after driving a fresh-turn inbound effect set through dispatchEffects
17
+ * against a recording fake ctx.
18
+ */
19
+
20
+ import { dispatchEffects, isDispatchEnabled } from '../../gateway/inbound-delivery-machine-dispatch'
21
+ import {
22
+ isDeliveryCutoverEnabled,
23
+ __shadowEnabledForTests,
24
+ } from '../../gateway/inbound-delivery-machine-shadow'
25
+ import { createPendingInboundBuffer } from '../../gateway/pending-inbound-buffer'
26
+ import { createPendingPermissionBuffer } from '../../gateway/pending-permission-decisions'
27
+ import { initialState, transition, type ChatKey } from '../../gateway/inbound-delivery-machine'
28
+
29
+ const KEY = '111:_' as ChatKey
30
+ const msg = { type: 'inbound', chatId: '111', messageId: 42, text: 'hello' }
31
+
32
+ const alive = transition(initialState(), { kind: 'bridgeUp', at: 1000 }).state
33
+ const { effects } = transition(alive, {
34
+ kind: 'inbound',
35
+ key: KEY,
36
+ msg: { msgId: 42, isSteering: false, payload: msg },
37
+ at: 2000,
38
+ })
39
+
40
+ let sendCalls = 0
41
+ const buffer = createPendingInboundBuffer()
42
+ let setTurnStartedCalls = 0
43
+ const deliverResults: boolean[] = []
44
+
45
+ dispatchEffects(effects, {
46
+ selfAgent: 'probe-agent',
47
+ ipcServer: {
48
+ sendToAgent: () => {
49
+ sendCalls++
50
+ return true
51
+ },
52
+ } as never,
53
+ pendingInboundBuffer: buffer,
54
+ inboundSpool: null,
55
+ pendingPermissionBuffer: createPendingPermissionBuffer(),
56
+ log: () => {},
57
+ onSetTurnStarted: () => {
58
+ setTurnStartedCalls++
59
+ },
60
+ onDeliverResult: (_k, ok) => {
61
+ deliverResults.push(ok)
62
+ },
63
+ })
64
+
65
+ process.stdout.write(
66
+ JSON.stringify({
67
+ dispatchEnabled: isDispatchEnabled(),
68
+ cutoverEnabled: isDeliveryCutoverEnabled(),
69
+ shadowEnabled: __shadowEnabledForTests(),
70
+ sendCalls,
71
+ buffered: buffer.drain('probe-agent').length,
72
+ setTurnStartedCalls,
73
+ deliverResults,
74
+ }) + '\n',
75
+ )
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ computeFloodWait,
7
+ floodWaitRemainingMs,
8
+ isFloodWaitActive,
9
+ readFloodState,
10
+ writeFloodState,
11
+ makeFloodWaitRecorder,
12
+ suppressNonEssentialSendMs,
13
+ floodStatePath,
14
+ type FloodWaitState,
15
+ } from '../flood-circuit-breaker.js'
16
+
17
+ describe('#2923 flood circuit-breaker', () => {
18
+ let dir: string
19
+ beforeEach(() => {
20
+ dir = mkdtempSync(join(tmpdir(), 'flood-cb-'))
21
+ })
22
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
23
+
24
+ it('records a flood-wait window from retry_after', () => {
25
+ const now = 1_000_000
26
+ const s = computeFloodWait(null, 4116, now)
27
+ expect(s.untilTs).toBe(now + 4116_000)
28
+ expect(s.retryAfterSec).toBe(4116)
29
+ expect(isFloodWaitActive(s, now)).toBe(true)
30
+ expect(isFloodWaitActive(s, s.untilTs + 1)).toBe(false)
31
+ })
32
+
33
+ it('EXTENDS (never shrinks) an existing window on a fresh, shorter 429', () => {
34
+ const now = 1_000_000
35
+ const long = computeFloodWait(null, 4000, now)
36
+ // A later 429 reporting a shorter ban must not pull the expiry earlier.
37
+ const shorter = computeFloodWait(long, 10, now + 1000)
38
+ expect(shorter.untilTs).toBe(long.untilTs)
39
+ })
40
+
41
+ it('floodWaitRemainingMs is 0 for no state / expired state', () => {
42
+ expect(floodWaitRemainingMs(null, 5)).toBe(0)
43
+ const expired: FloodWaitState = { untilTs: 100, retryAfterSec: 1, recordedTs: 0 }
44
+ expect(floodWaitRemainingMs(expired, 200)).toBe(0)
45
+ })
46
+
47
+ it('persists + reads back the window; corrupt marker → null', () => {
48
+ const p = floodStatePath(dir)
49
+ expect(readFloodState(p)).toBeNull()
50
+ const s = computeFloodWait(null, 60, 1000)
51
+ writeFloodState(p, s)
52
+ expect(readFloodState(p)).toEqual(s)
53
+ writeFileSync(p, '{bad')
54
+ expect(readFloodState(p)).toBeNull()
55
+ })
56
+
57
+ it('makeFloodWaitRecorder persists the window (the onFloodWait hook)', () => {
58
+ const p = floodStatePath(dir)
59
+ const record = makeFloodWaitRecorder(p, () => 500_000)
60
+ record(4116)
61
+ const s = readFloodState(p)
62
+ expect(s).not.toBeNull()
63
+ expect(s!.untilTs).toBe(500_000 + 4116_000)
64
+ })
65
+
66
+ it('suppressNonEssentialSendMs > 0 while a ban is open, 0 after it lifts', () => {
67
+ const p = floodStatePath(dir)
68
+ writeFloodState(p, computeFloodWait(null, 68 * 60, 1_000_000))
69
+ // A restart mid-ban: the boot card must be suppressed.
70
+ expect(suppressNonEssentialSendMs(p, 1_000_000)).toBeGreaterThan(0)
71
+ // After the window: send proceeds.
72
+ expect(suppressNonEssentialSendMs(p, 1_000_000 + 68 * 60_000 + 1)).toBe(0)
73
+ })
74
+ })
@@ -35,8 +35,12 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
35
35
  })
36
36
 
37
37
  it('reply: scrubs at entry, before the stderr preview log', () => {
38
+ // #2996: the entry pipeline (normalize → redact → punctuation/bold →
39
+ // voice-scrub) is extracted into outbound-send-path.ts. The reply path now
40
+ // delegates via `normalizeOutboundBody(rawText, 'reply', redactOutboundText)`
41
+ // — the injected redactor still runs at entry, before the stderr preview.
38
42
  const start = src.indexOf('async function executeReply(')
39
- const redactIdx = src.indexOf(`redactOutboundText(text, 'reply')`, start)
43
+ const redactIdx = src.indexOf(`normalizeOutboundBody(rawText, 'reply', redactOutboundText)`, start)
40
44
  const previewIdx = src.indexOf('reply: invoked chatId=', start)
41
45
  expect(start).toBeGreaterThan(0)
42
46
  expect(redactIdx).toBeGreaterThan(start)
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Structural pins for the session-only model relaunch wiring in gateway.ts.
2
+ * Structural pins for the session-model stickiness wiring in gateway.ts
3
+ * (reference/rfcs/session-model-stickiness.md).
3
4
  *
4
5
  * The behaviour lives in un-exported inline closures (buildModelDeps's
5
- * scheduleModelRelaunch, the model-menu callback sr-* branch, and the boot
6
- * re-hydration / alert-sentinel block inside the startup IIFE), so mirroring
7
- * the other gateway-*.test.ts source-pinswe assert on the source structure.
8
- * The end-to-end behaviour of the carrier itself is exercised in
9
- * tests/scaffold.session-model-override.test.ts (rendered start.sh) and the
10
- * handler contract in telegram-plugin/tests/model-command.test.ts.
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), somirroring 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.
11
13
  */
12
14
 
13
15
  import { describe, it, expect } from 'vitest'
@@ -18,54 +20,199 @@ import { dirname, resolve } from 'node:path'
18
20
  const __dirname = dirname(fileURLToPath(import.meta.url))
19
21
  const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
20
22
 
21
- describe('gateway: scheduleModelRelaunch dep', () => {
22
- it('writes the carrier with exact "<model>\\n" bytes', () => {
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)
37
+ })
38
+
39
+ it('legacy systemd branch stamps intent too (self-target only)', () => {
40
+ 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('))
47
+ })
48
+ })
49
+
50
+ describe('gateway: scheduleModelRelaunch dep (durable .session-model)', () => {
51
+ it('writes the durable file via writeSessionModelFile before dispatching the restart', () => {
23
52
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
24
53
  expect(idx).toBeGreaterThan(0)
25
- const win = GATEWAY_SRC.slice(idx, idx + 900)
26
- expect(win).toMatch(/writeFileSync\(\s*join\(agentDir, '\.session-model-override'\),\s*`\$\{model\}\\n`/)
54
+ const win = GATEWAY_SRC.slice(idx, idx + 1800)
55
+ const writeIdx = win.indexOf('writeSessionModelFile(')
56
+ const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
57
+ expect(writeIdx).toBeGreaterThan(0)
58
+ expect(restartIdx).toBeGreaterThan(writeIdx)
27
59
  })
28
60
 
29
61
  it('sets the in-memory session-model override before dispatching the restart', () => {
30
62
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
31
- const win = GATEWAY_SRC.slice(idx, idx + 900)
32
- // The override now lives on the freshness-aware sessionModelSource
33
- // (session-model-source.ts) rather than a bare module-level variable.
63
+ const win = GATEWAY_SRC.slice(idx, idx + 1800)
34
64
  const setIdx = win.indexOf('sessionModelSource.setOverride(model)')
35
65
  const restartIdx = win.indexOf('deps.scheduleRestart(reason)')
36
66
  expect(setIdx).toBeGreaterThan(0)
37
67
  expect(restartIdx).toBeGreaterThan(setIdx)
38
68
  })
39
69
 
70
+ it('rolls back the prior file content (not just deletion) on a non-in-flight dispatch failure', () => {
71
+ const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
72
+ const win = GATEWAY_SRC.slice(idx, idx + 1800)
73
+ expect(win).toContain('const prevFileRaw = readSessionModelFileRaw(agentDir)')
74
+ expect(win).toContain('restoreSessionModelFileRaw(agentDir, prevFileRaw)')
75
+ expect(win).toContain("!== 'restart_in_flight'")
76
+ })
77
+
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
+
40
87
  it('reuses the same scheduleRestart dispatch (not a bespoke restart path)', () => {
41
88
  const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async')
42
- const win = GATEWAY_SRC.slice(idx, idx + 900)
89
+ const win = GATEWAY_SRC.slice(idx, idx + 1800)
43
90
  expect(win).toContain('await deps.scheduleRestart(reason)')
44
91
  })
45
92
  })
46
93
 
47
- describe('gateway: model-menu sr-* target relaunches via the carrier', () => {
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 an explicit revert intent (reason honesty) before dispatch', () => {
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 revertIdx = win.indexOf("writeRelaunchModelIntent(smDir, 'revert', 'user: /restart from chat')")
122
+ const dispatchIdx = win.indexOf("hostdRequestId('gw-restart')")
123
+ expect(revertIdx).toBeGreaterThan(0)
124
+ expect(dispatchIdx).toBeGreaterThan(revertIdx)
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
+ const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
141
+ expect(idx).toBeGreaterThan(0)
142
+ const win = GATEWAY_SRC.slice(idx, idx + 1600)
143
+ expect(win).toContain('outcome.selectedModelToken')
144
+ expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*outcome\.selectedModelToken/)
145
+ })
146
+
147
+ it('a confirmed "Default" selection CLEARS the sticky file', () => {
148
+ const idx = GATEWAY_SRC.indexOf('const outcome = await handleModelMenuCallback(data, modelDeps)')
149
+ const win = GATEWAY_SRC.slice(idx, idx + 1800)
150
+ expect(win).toContain('outcome.clearedDefault')
151
+ expect(win).toContain('clearSessionModelFile(smDir)')
152
+ })
153
+
48
154
  it('the sr-* callback branch calls scheduleModelRelaunch, not inject', () => {
49
155
  const idx = GATEWAY_SRC.indexOf('if (data.startsWith(MODEL_CALLBACK_SR))')
50
156
  expect(idx).toBeGreaterThan(0)
51
157
  const win = GATEWAY_SRC.slice(idx, idx + 1400)
52
158
  expect(win).toContain('modelDeps.scheduleModelRelaunch(srName')
53
- // It must return before falling through to handleModelMenuCallback (which
54
- // would inject an sr-* id claude's picker rejects).
55
159
  expect(win).toMatch(/scheduleModelRelaunch[\s\S]*?\n\s*return\n/)
56
160
  })
161
+
162
+ it('the sr-to-claude transition writes the durable file (and clears it on a Default tap)', () => {
163
+ const idx = GATEWAY_SRC.indexOf('isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)')
164
+ expect(idx).toBeGreaterThan(0)
165
+ const win = GATEWAY_SRC.slice(idx, idx + 3600)
166
+ expect(win).toMatch(/writeSessionModelFile\(\s*agentDir,\s*token/)
167
+ expect(win).toContain('clearSessionModelFile(agentDir)')
168
+ // Restart rides triggerSelfRestart with a keep-classified reason.
169
+ expect(win).toContain("triggerSelfRestart(agentName, 'sr-to-claude-model-switch'")
170
+ })
57
171
  })
58
172
 
59
- describe('gateway boot: session-model re-hydration + LiteLLM-down alert', () => {
60
- it('re-hydrates activeSessionModelOverride from .active-session-model', () => {
173
+ describe('gateway: typed /model persists the REQUESTED canonical token', () => {
174
+ it('persists expandSrAlias(parsed.model), and `/model default` clears file + in-memory override', () => {
175
+ const idx = GATEWAY_SRC.indexOf("const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null")
176
+ expect(idx).toBeGreaterThan(0)
177
+ const win = GATEWAY_SRC.slice(idx, idx + 2400)
178
+ expect(win).toContain("requested?.toLowerCase() === 'default'")
179
+ expect(win).toContain('sessionModelSource.setOverride(null)')
180
+ expect(win).toContain('clearSessionModelFile(smDir)')
181
+ // Non-default: the requested token (shape-gated, non-sr) is what persists.
182
+ expect(win).toContain('isValidModelArg(requested) && !isSrModel(requested)')
183
+ expect(win).toMatch(/writeSessionModelFile\(\s*smDir,\s*requested/)
184
+ })
185
+
186
+ it('`/model default` file-clear is NOT gated on a positive confirmation (silent-switch path must not resurrect)', () => {
187
+ const idx = GATEWAY_SRC.indexOf("const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null")
188
+ const win = GATEWAY_SRC.slice(idx, idx + 2400)
189
+ // The default branch clears the file unconditionally, and only the
190
+ // in-memory override change is confirmation-gated inside it.
191
+ const clearIdx = win.indexOf('if (smDir) clearSessionModelFile(smDir)')
192
+ const gatedOverrideIdx = win.indexOf('if (reply.selectedModel) sessionModelSource.setOverride(null)')
193
+ expect(clearIdx).toBeGreaterThan(0)
194
+ expect(gatedOverrideIdx).toBeGreaterThan(clearIdx)
195
+ // And the whole default branch is not nested in an `if (reply.selectedModel)` block:
196
+ const between = win.slice(0, clearIdx)
197
+ expect(between).not.toContain('if (reply.selectedModel) {')
198
+ })
199
+
200
+ it('a persist failure is surfaced ON THE REPLY, not just stderr (typed + menu paths)', () => {
201
+ expect(GATEWAY_SRC.match(/won’t survive a relaunch/g)?.length ?? 0).toBeGreaterThanOrEqual(2)
202
+ const typedIdx = GATEWAY_SRC.indexOf('persistWarning =')
203
+ expect(typedIdx).toBeGreaterThan(0)
204
+ expect(GATEWAY_SRC).toContain('reply.text + persistWarning')
205
+ // Menu path appends onto the outgoing card text.
206
+ expect(GATEWAY_SRC).toContain('outcome.reply.text +=')
207
+ })
208
+ })
209
+
210
+ describe('gateway boot: session-model re-hydration + alert relay', () => {
211
+ it('re-hydrates the override from .active-session-model', () => {
61
212
  const idx = GATEWAY_SRC.indexOf("join(smAgentDir, '.active-session-model')")
62
213
  expect(idx).toBeGreaterThan(0)
63
214
  const win = GATEWAY_SRC.slice(idx - 200, idx + 1400)
64
- // Only an override when the launched model differs from the configured one.
65
215
  expect(win).toMatch(/launched\.length > 0 && launched !== configured \? launched : null/)
66
- // The configured value must be resolved through resolveMainModel (the SAME
67
- // resolver start.sh's scaffold uses) so an unset/`default` model config does
68
- // not get flagged as a phantom session override on an ordinary restart.
69
216
  expect(win).toContain('resolveMainModel(raw ?? undefined)')
70
217
  })
71
218
 
@@ -74,10 +221,15 @@ describe('gateway boot: session-model re-hydration + LiteLLM-down alert', () =>
74
221
  expect(idx).toBeGreaterThan(0)
75
222
  const win = GATEWAY_SRC.slice(idx, idx + 1100)
76
223
  expect(win).toContain('unlinkSync(alertPath)')
77
- // Broadcasts to every operator in allowFrom, not just allowFrom[0].
78
224
  expect(win).toContain('const operators = loadAccess().allowFrom')
79
225
  expect(win).toContain('for (const operator of operators)')
80
226
  expect(win).toContain('lockedBot.api')
81
227
  expect(win).toContain('.sendMessage(operator')
82
228
  })
83
229
  })
230
+
231
+ describe('gateway: the legacy one-shot carrier is no longer written', () => {
232
+ it('no gateway code writes .session-model-override anymore (start.sh migration shim only reads it)', () => {
233
+ expect(GATEWAY_SRC).not.toMatch(/writeFileSync\([^)]*\.session-model-override/)
234
+ })
235
+ })