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