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.
- package/README.md +2 -2
- package/dist/cli/switchroom.js +905 -758
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +111 -34
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/gateway/gateway.js +46273 -44324
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- package/telegram-plugin/gateway/approval-card-stores.ts +99 -0
- package/telegram-plugin/gateway/boot-card.ts +27 -0
- package/telegram-plugin/gateway/bot-commands-ops-info.ts +194 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +2660 -0
- package/telegram-plugin/gateway/gateway.ts +1169 -3043
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +181 -23
- package/telegram-plugin/gateway/inbound-delivery-machine.ts +8 -0
- package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +23 -11
- package/telegram-plugin/gateway/outbound-send-path.ts +375 -0
- package/telegram-plugin/gateway/pending-state-stores.ts +106 -0
- package/telegram-plugin/gateway/register-bot-commands.ts +30 -0
- package/telegram-plugin/gateway/session-model-file.ts +198 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-card-stores.test.ts +124 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +701 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +11 -4
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +75 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +5 -1
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +418 -0
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +348 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +141 -52
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -1
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
- package/telegram-plugin/tests/outbound-send-chunks.test.ts +304 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +222 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +34 -15
- package/telegram-plugin/tests/pending-state-stores.test.ts +235 -0
- package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -0
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +18 -4
- package/telegram-plugin/tests/vault-approval-posture.test.ts +15 -7
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-union.test.ts +8 -4
- package/telegram-plugin/tests/vault-grant-wizard.test.ts +8 -1
- package/telegram-plugin/tests/vault-grants-revoke.test.ts +8 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +8 -4
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +8 -4
- package/telegram-plugin/tests/voice-send.test.ts +308 -0
- package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- 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
|
-
|
|
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(
|
|
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-
|
|
152
|
-
const preLoop = replySrc.split(
|
|
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(`
|
|
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-
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
* tests/scaffold.session-model
|
|
10
|
-
*
|
|
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.
|
|
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:
|
|
22
|
-
it('
|
|
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 +
|
|
26
|
-
|
|
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 +
|
|
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 +
|
|
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:
|
|
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
|
|
60
|
-
it('
|
|
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
|
+
})
|