switchroom 0.18.7 → 0.18.8

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 (54) hide show
  1. package/dist/cli/switchroom.js +905 -758
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_base/start.sh.hbs +111 -34
  5. package/skills/switchroom-runtime/SKILL.md +2 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1403 -657
  7. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  8. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  9. package/telegram-plugin/gateway/boot-card.ts +27 -0
  10. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  11. package/telegram-plugin/gateway/gateway.ts +564 -85
  12. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  13. package/telegram-plugin/gateway/model-command.ts +23 -11
  14. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  15. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  16. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  17. package/telegram-plugin/hooks/hooks.json +10 -10
  18. package/telegram-plugin/hooks/run-hook.sh +84 -0
  19. package/telegram-plugin/model-unavailable.ts +26 -0
  20. package/telegram-plugin/pty-partial-handler.ts +39 -0
  21. package/telegram-plugin/render/rich-render.ts +79 -1
  22. package/telegram-plugin/retry-api-call.ts +62 -0
  23. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  24. package/telegram-plugin/silence-poke.ts +14 -0
  25. package/telegram-plugin/stream-controller.ts +156 -38
  26. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  27. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  28. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  29. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  30. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  31. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  32. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  33. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  34. package/telegram-plugin/tests/model-command.test.ts +2 -2
  35. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  36. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  38. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  39. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  40. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  41. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  42. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  43. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  44. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  45. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  46. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  48. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  50. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  51. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  52. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  53. package/telegram-plugin/voice-ondemand.ts +25 -1
  54. package/telegram-plugin/voice-send.ts +154 -0
@@ -0,0 +1,118 @@
1
+ /**
2
+ * #2995 — mid-flight busy ack: gateway wiring guards.
3
+ *
4
+ * The gateway IIFE is too entangled to instantiate in-process, so these
5
+ * are source-level assertions (the established pattern —
6
+ * multitopic-routing-wiring.test.ts, buffer-gate-broadened.test.ts). They
7
+ * pin the load-bearing wiring: the buffered-inbound and steer call sites,
8
+ * the silent send, the shared queuedStatusMsgIds lifecycle (promote/reap
9
+ * cleanup for free), the per-turn dedupe reset, and the kill switch. The
10
+ * POLICY and the rendered text are behaviourally tested in
11
+ * busy-ack.test.ts; the live end-to-end shape in
12
+ * uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import { readFileSync } from 'node:fs'
17
+ import { resolve } from 'node:path'
18
+
19
+ const gatewaySrc = readFileSync(
20
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
21
+ 'utf-8',
22
+ )
23
+
24
+ describe('#2995 mid-flight busy ack — gateway wiring', () => {
25
+ it('the buffer-until-idle branch calls maybePostBusyAck for the inbound own chat/topic', () => {
26
+ expect(gatewaySrc).toMatch(
27
+ /maybePostBusyAck\('buffer-until-idle', chat_id, messageThreadId \?\? undefined\)/,
28
+ )
29
+ // …AFTER the pendingInboundBuffer.push (the ack narrates a real queue).
30
+ const branch = gatewaySrc.split("deliveryGate.decision === 'buffer-until-idle'")[1] ?? ''
31
+ const pushIdx = branch.indexOf('pendingInboundBuffer.push(selfAgent, inboundMsg)')
32
+ const ackIdx = branch.indexOf("maybePostBusyAck('buffer-until-idle'")
33
+ expect(pushIdx).toBeGreaterThanOrEqual(0)
34
+ expect(ackIdx).toBeGreaterThan(pushIdx)
35
+ })
36
+
37
+ it('cross-topic queued status and the busy ack are mutually exclusive (no double-card race)', () => {
38
+ // Both helpers record into queuedStatusMsgIds only AFTER their
39
+ // sendMessage awaits resolve, so calling both in the same handler pass
40
+ // would race past each other's has(key) check and double-card the
41
+ // topic. The buffer branch must pick exactly one.
42
+ expect(gatewaySrc).toMatch(
43
+ /if \(crossTopicQueuedCard\) \{\s*postQueuedStatus\(chat_id, messageThreadId, inFlightThread\)\s*\} else \{/,
44
+ )
45
+ })
46
+
47
+ it('a mid-turn steer gets the steer-worded variant only AFTER successful bridge dispatch', () => {
48
+ // A "Steer noted" card for a send that missed (bridge offline) would
49
+ // be untrue — the ack lives inside the `if (delivered)` branch.
50
+ expect(gatewaySrc).toMatch(
51
+ /if \(delivered\) \{[\s\S]{0,700}if \(isSteering\) \{\s*maybePostBusyAck\('steer', chat_id, messageThreadId \?\? undefined\)/,
52
+ )
53
+ })
54
+
55
+ it('an under-threshold miss arms ONE deferred re-check pinned to the running turn', () => {
56
+ const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
57
+ // Armed only for the young-step miss, for the remaining age gap.
58
+ expect(fn).toMatch(/stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS &&\s*!busyAckRecheckTimers\.has\(key\)/)
59
+ expect(fn).toMatch(/BUSY_ACK_STEP_AGE_THRESHOLD_MS - stepAgeMs \+ 250/)
60
+ // The re-fire is guarded on the SAME turn still running.
61
+ expect(fn).toMatch(/currentTurn\?\.turnId !== turnIdAtSchedule\) return/)
62
+ // A posted card cancels the pending re-check.
63
+ expect(fn).toMatch(/clearTimeout\(pendingRecheck\)/)
64
+ })
65
+
66
+ it('the decision is fed from live tool-flight + step-age readings (pure module owns policy)', () => {
67
+ const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
68
+ expect(fn).toMatch(/shouldPostBusyAck\(\{ gateDecision, midToolCall, stepAgeMs, alreadyAcked \}\)/)
69
+ expect(fn).toMatch(/const midToolCall = toolFlightTracker\.isMidToolCall\(\)/)
70
+ expect(fn).toMatch(/silencePoke\.longestInFlightTool\(/)
71
+ expect(fn).toMatch(/const stepAgeMs = step\?\.durationMs \?\? null/)
72
+ })
73
+
74
+ it('dedupe: alreadyAcked couples the live card map AND the per-turn key set', () => {
75
+ const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
76
+ expect(fn).toMatch(
77
+ /const alreadyAcked = queuedStatusMsgIds\.has\(key\) \|\| busyAckPostedKeys\.has\(key\)/,
78
+ )
79
+ expect(fn).toMatch(/busyAckPostedKeys\.add\(key\)/)
80
+ })
81
+
82
+ it('the card is sent SILENT (disable_notification: true) through the swallowing wrapper', () => {
83
+ const fn = gatewaySrc.split('function postBusyAck')[1]?.split('\nfunction ')[0] ?? ''
84
+ expect(fn).toMatch(/swallowingApiCall\(/)
85
+ expect(fn).toMatch(/disable_notification: true/)
86
+ // Thread-optional: a DM send must NOT pass message_thread_id.
87
+ expect(fn).toMatch(/\.\.\.\(threadId != null \? \{ message_thread_id: threadId \} : \{\}\)/)
88
+ })
89
+
90
+ it('the card shares queuedStatusMsgIds — promote/reap lifecycle cleans it up', () => {
91
+ const fn = gatewaySrc.split('function postBusyAck')[1]?.split('\nfunction ')[0] ?? ''
92
+ // Idempotent against the shared key (never stacks on the cross-topic card).
93
+ expect(fn).toMatch(/if \(queuedStatusMsgIds\.has\(key\)\) return/)
94
+ expect(fn).toMatch(/queuedStatusMsgIds\.set\(key, \{ chatId, threadId: threadId \?\? null, messageId \}\)/)
95
+ // Post-race orphan cleanup, same pattern as postQueuedStatus.
96
+ expect(fn).toMatch(/busy-ack\.post-race-cleanup/)
97
+ })
98
+
99
+ it('turn-end cleanup is PER-KEY: dedupe entry deleted and re-check timer cancelled for the ending turn only', () => {
100
+ const purge = gatewaySrc.split('function purgeReactionTracking')[1]?.split('\nfunction ')[0] ?? ''
101
+ // Per-key delete — a purge for topic A must not reset topic B's dedupe.
102
+ expect(purge).toMatch(/busyAckPostedKeys\.delete\(key\)/)
103
+ expect(purge).not.toMatch(/busyAckPostedKeys\.clear\(\)/)
104
+ expect(purge).toMatch(/busyAckRecheckTimers\.delete\(key\)/)
105
+ expect(purge).toMatch(/clearTimeout\(busyAckRecheck\)/)
106
+ })
107
+
108
+ it('DM cards are promoted too (promoteQueuedStatus no longer early-returns on a null thread)', () => {
109
+ const fn = gatewaySrc.split('function promoteQueuedStatus')[1]?.split('\nfunction ')[0] ?? ''
110
+ expect(fn).not.toMatch(/if \(thread == null\) return/)
111
+ })
112
+
113
+ it('kill switch defaults ON, independently disableable', () => {
114
+ expect(gatewaySrc).toMatch(/SWITCHROOM_MIDFLIGHT_BUSY_ACK !== '0'/)
115
+ const fn = gatewaySrc.split('function maybePostBusyAck')[1]?.split('\nfunction ')[0] ?? ''
116
+ expect(fn).toMatch(/if \(!MIDFLIGHT_BUSY_ACK_ENABLED\) return/)
117
+ })
118
+ })
@@ -0,0 +1,121 @@
1
+ /**
2
+ * #2995 — mid-flight busy ack: pure decision module.
3
+ *
4
+ * Pins the deterministic policy (`shouldPostBusyAck`) and the rendered
5
+ * card text (`formatBusyAckText`) — the model-free ack a buffered/steered
6
+ * mid-turn inbound gets while the running turn sits inside one long tool
7
+ * step. Behavioral assertions on the RENDERED text (wording contract:
8
+ * "Queued" for the buffered path per the steer-or-queue classification-
9
+ * visibility invariant; never "Queued" for a steer).
10
+ */
11
+
12
+ import { describe, it, expect } from 'vitest'
13
+ import {
14
+ BUSY_ACK_STEP_AGE_THRESHOLD_MS,
15
+ shouldPostBusyAck,
16
+ formatBusyAckText,
17
+ } from '../gateway/busy-ack.js'
18
+
19
+ const base = {
20
+ gateDecision: 'buffer-until-idle' as const,
21
+ midToolCall: true,
22
+ stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS + 1,
23
+ alreadyAcked: false,
24
+ }
25
+
26
+ describe('shouldPostBusyAck', () => {
27
+ it('fires for a buffered inbound behind an old tool step', () => {
28
+ expect(shouldPostBusyAck(base)).toBe(true)
29
+ })
30
+
31
+ it('fires for a steer behind an old tool step', () => {
32
+ expect(shouldPostBusyAck({ ...base, gateDecision: 'steer' })).toBe(true)
33
+ })
34
+
35
+ it('never fires for a plain fresh-turn deliver', () => {
36
+ expect(shouldPostBusyAck({ ...base, gateDecision: 'deliver' })).toBe(false)
37
+ })
38
+
39
+ it('never fires when not mid-tool-call (turn thinking between tools)', () => {
40
+ expect(shouldPostBusyAck({ ...base, midToolCall: false })).toBe(false)
41
+ })
42
+
43
+ it('never fires below the step-age threshold (agent seconds from answering)', () => {
44
+ expect(
45
+ shouldPostBusyAck({ ...base, stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS - 1 }),
46
+ ).toBe(false)
47
+ // exactly at the threshold → fires (>= semantics)
48
+ expect(
49
+ shouldPostBusyAck({ ...base, stepAgeMs: BUSY_ACK_STEP_AGE_THRESHOLD_MS }),
50
+ ).toBe(true)
51
+ })
52
+
53
+ it('never fires with no tracked step age', () => {
54
+ expect(shouldPostBusyAck({ ...base, stepAgeMs: null })).toBe(false)
55
+ })
56
+
57
+ it('fires at most once per key — a second ping gets no second card', () => {
58
+ // The gateway feeds `alreadyAcked` from its per-turn key set + the live
59
+ // card map; the policy must refuse when either says "already acked".
60
+ expect(shouldPostBusyAck({ ...base, alreadyAcked: true })).toBe(false)
61
+ })
62
+
63
+ it('threshold constant sits between the fast-tool envelope and the ignored horizon', () => {
64
+ expect(BUSY_ACK_STEP_AGE_THRESHOLD_MS).toBeGreaterThanOrEqual(5_000)
65
+ expect(BUSY_ACK_STEP_AGE_THRESHOLD_MS).toBeLessThanOrEqual(30_000)
66
+ })
67
+ })
68
+
69
+ describe('formatBusyAckText — rendered card text', () => {
70
+ it('buffered path says "Queued", names the blocking activity, and promises the answer', () => {
71
+ const text = formatBusyAckText({
72
+ gateDecision: 'buffer-until-idle',
73
+ toolName: 'Bash',
74
+ toolLabel: 'gh pr checks --watch',
75
+ })
76
+ expect(text).toContain('Queued')
77
+ expect(text).toContain('`Bash: gh pr checks --watch`')
78
+ expect(text).toMatch(/I'll answer when this step finishes/)
79
+ })
80
+
81
+ it('carries no point-in-time elapsed figure (a static card must not decay)', () => {
82
+ // The card is posted once and never re-rendered while the blocking
83
+ // step runs — any "(Nm elapsed)" would silently go stale on screen.
84
+ const text = formatBusyAckText({
85
+ gateDecision: 'buffer-until-idle',
86
+ toolName: 'Bash',
87
+ toolLabel: 'sleep 90',
88
+ })
89
+ expect(text).not.toMatch(/elapsed|\b\d+[sm]\b/)
90
+ })
91
+
92
+ it('steer path never says "Queued" (classification-visibility invariant)', () => {
93
+ const text = formatBusyAckText({
94
+ gateDecision: 'steer',
95
+ toolName: 'Bash',
96
+ toolLabel: 'sleep 90',
97
+ })
98
+ expect(text).not.toContain('Queued')
99
+ expect(text).toContain('Steer noted')
100
+ expect(text).toContain('`Bash: sleep 90`')
101
+ })
102
+
103
+ it('degrades honestly when the tool label is unknown', () => {
104
+ const text = formatBusyAckText({
105
+ gateDecision: 'buffer-until-idle',
106
+ toolName: null,
107
+ toolLabel: null,
108
+ })
109
+ expect(text).toContain('Queued')
110
+ expect(text).toContain('a long-running step')
111
+ })
112
+
113
+ it('uses the bare tool name when no label was derived', () => {
114
+ const text = formatBusyAckText({
115
+ gateDecision: 'buffer-until-idle',
116
+ toolName: 'Bash',
117
+ toolLabel: null,
118
+ })
119
+ expect(text).toContain('`Bash`')
120
+ })
121
+ })
@@ -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
+ })
@@ -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
+ })