switchroom 0.19.1 → 0.19.3
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 +31 -1
- package/dist/auth-broker/index.js +565 -48
- package/dist/cli/autoaccept-poll.js +31 -1
- package/dist/cli/drive-write-pretool.mjs +32 -2
- package/dist/cli/ms-365-write-pretool.mjs +32 -2
- package/dist/cli/switchroom.js +1148 -274
- package/dist/host-control/main.js +3 -3
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +1 -0
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/skills/switchroom-cli/SKILL.md +25 -0
- package/telegram-plugin/auth-snapshot-format.ts +143 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1427 -689
- package/telegram-plugin/dist/server.js +8 -2
- package/telegram-plugin/external-spend.ts +135 -0
- package/telegram-plugin/flushed-turn-supersede.ts +117 -13
- package/telegram-plugin/gateway/auth-add-flow.ts +215 -6
- package/telegram-plugin/gateway/auth-command.ts +138 -5
- package/telegram-plugin/gateway/gateway.ts +141 -158
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +309 -1
- package/telegram-plugin/gateway/narrative-lane.ts +23 -9
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/status-pin-store.ts +64 -4
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/gateway/usage-mask.ts +29 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
- package/telegram-plugin/quota-bar-format.ts +78 -12
- package/telegram-plugin/quota-check.ts +17 -2
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
- package/telegram-plugin/tests/auth-add-flow.test.ts +367 -5
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +41 -0
- package/telegram-plugin/tests/external-spend.test.ts +168 -0
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +219 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
- package/telegram-plugin/tests/quota-check.test.ts +57 -0
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +257 -13
- package/telegram-plugin/tests/send-reply-golden.test.ts +154 -0
- package/telegram-plugin/tests/session-model-source.test.ts +142 -0
- package/telegram-plugin/tests/session-tail-first-attach.test.ts +115 -2
- package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
- package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
- package/telegram-plugin/tests/usage-mask.test.ts +35 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
- package/vendor/hindsight-memory/CHANGELOG.md +102 -0
- package/vendor/hindsight-memory/README.md +2 -1
- package/vendor/hindsight-memory/hooks/hooks.json +12 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +100 -3
- package/vendor/hindsight-memory/scripts/lib/config.py +150 -1
- package/vendor/hindsight-memory/scripts/lib/content.py +55 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +152 -15
- package/vendor/hindsight-memory/scripts/lib/parallel_recall.py +142 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +31 -0
- package/vendor/hindsight-memory/scripts/recall.py +789 -143
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +22 -1
- package/vendor/hindsight-memory/scripts/retain.py +71 -2
- package/vendor/hindsight-memory/scripts/subagent_retain.py +501 -0
- package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +169 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +177 -0
- package/vendor/hindsight-memory/scripts/tests/test_lesson_tagging.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_context_turns_default.py +200 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +477 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +51 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_parallel_deadline.py +409 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_weights.py +96 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +413 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +49 -0
- package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +439 -0
- package/vendor/hindsight-memory/settings.json +3 -1
|
@@ -21,8 +21,10 @@ import { describe, it, expect } from 'vitest'
|
|
|
21
21
|
import {
|
|
22
22
|
decideSupersede,
|
|
23
23
|
decideSupersedeCorrection,
|
|
24
|
+
flushedAnswerMatchesReply,
|
|
24
25
|
FlushedTurnSupersedeRegistry,
|
|
25
26
|
DEFAULT_SUPERSEDE_TTL_MS,
|
|
27
|
+
SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS,
|
|
26
28
|
type FlushedTurnRecord,
|
|
27
29
|
} from '../flushed-turn-supersede.js'
|
|
28
30
|
|
|
@@ -264,3 +266,118 @@ describe('FlushedTurnSupersedeRegistry — record / peek / take lifecycle', () =
|
|
|
264
266
|
expect(reg.peek('chat1', undefined, { liveTurnId: 'turn-A', now: 5000 }).reason).toBe('no-record')
|
|
265
267
|
})
|
|
266
268
|
})
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* #3429 — async handback edit-in-place supersede of a flush-delivered message
|
|
272
|
+
* can fail to surface client-side.
|
|
273
|
+
*
|
|
274
|
+
* The flush ends its turn synchronously BEFORE recording, so EVERY superseding
|
|
275
|
+
* reply is a late reply — and an async sub-agent handback landing within the
|
|
276
|
+
* 60 s TTL with no live gateway turn resolves the flush-delivered ENDED turn
|
|
277
|
+
* as its owner via the latest-ended tier, the SAME identity as the turn's own
|
|
278
|
+
* canonical late replay. Identity-only supersede then consumed the record and
|
|
279
|
+
* EDITED the flushed message in place with the handback's unrelated content
|
|
280
|
+
* (msgs 10482/10486, 2026-07-20) — Telegram edits never push-notify, so the
|
|
281
|
+
* handback silently failed to surface AND the flushed answer was destroyed.
|
|
282
|
+
*
|
|
283
|
+
* The content gate: `replyText` is compared against the record's flushed text
|
|
284
|
+
* (`flushedAnswerMatchesReply` — whitespace-normalized equality, or containment
|
|
285
|
+
* with a minimum-length guard on the contained side). Same answer → supersede
|
|
286
|
+
* (the wanted correction); different content → 'new-content', send fresh,
|
|
287
|
+
* record NOT consumed.
|
|
288
|
+
*/
|
|
289
|
+
describe('#3429 — flushedAnswerMatchesReply (content discriminator)', () => {
|
|
290
|
+
const ANSWER = 'The deploy is green: all 12 services rolled out and health checks pass.'
|
|
291
|
+
const FLUSH_BLOB = `Let me check the rollout status.\n\n${ANSWER}`
|
|
292
|
+
|
|
293
|
+
it('matches whitespace-normalized equality', () => {
|
|
294
|
+
expect(flushedAnswerMatchesReply(ANSWER, ANSWER)).toBe(true)
|
|
295
|
+
expect(flushedAnswerMatchesReply(`${ANSWER}\n`, ANSWER.replace(': ', ': '))).toBe(true)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('matches the classic containment class: flush = narration+answer ⊇ clean reply', () => {
|
|
299
|
+
expect(flushedAnswerMatchesReply(FLUSH_BLOB, ANSWER)).toBe(true)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('matches reverse containment: reply ⊇ partially-delivered flush text', () => {
|
|
303
|
+
expect(flushedAnswerMatchesReply(ANSWER, FLUSH_BLOB)).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('does NOT match genuinely different content (the handback)', () => {
|
|
307
|
+
const handback =
|
|
308
|
+
'Worker finished: PR #3430 is up with the fix for the vault broker timeout, ' +
|
|
309
|
+
'tests are green, ready for your review.'
|
|
310
|
+
expect(flushedAnswerMatchesReply(FLUSH_BLOB, handback)).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('short containment below the minimum-length guard does NOT match ' +
|
|
314
|
+
'(a coincidental substring must never claim a handback)', () => {
|
|
315
|
+
const shortReply = 'rolled out'
|
|
316
|
+
expect(shortReply.length).toBeLessThan(SUPERSEDE_MATCH_MIN_CONTAINMENT_CHARS)
|
|
317
|
+
expect(FLUSH_BLOB.includes(shortReply)).toBe(true)
|
|
318
|
+
expect(flushedAnswerMatchesReply(FLUSH_BLOB, shortReply)).toBe(false)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('short EQUAL texts still match (equality has no length floor)', () => {
|
|
322
|
+
expect(flushedAnswerMatchesReply('yes, done', 'yes, done')).toBe(true)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('#3429 — decideSupersede new-content gate', () => {
|
|
327
|
+
const FLUSHED = 'Narration first.\n\nHere is the finished summary of the incident you asked about.'
|
|
328
|
+
const HANDBACK =
|
|
329
|
+
'Sub-agent handback: the researcher finished and found three root causes, ' +
|
|
330
|
+
'written up in the report at /tmp/report.md — want the highlights?'
|
|
331
|
+
|
|
332
|
+
it('same-turn same-answer late reply still supersedes (the wanted correction)', () => {
|
|
333
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
334
|
+
liveTurnId: 'turn-A',
|
|
335
|
+
replyText: 'Here is the finished summary of the incident you asked about.',
|
|
336
|
+
now: 1_000_010,
|
|
337
|
+
})
|
|
338
|
+
expect(d.supersede).toBe(true)
|
|
339
|
+
expect(d.reason).toBe('supersede')
|
|
340
|
+
expect(d.recordText).toBe(FLUSHED)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('CORE #3429: same turn identity + DIFFERENT content → new-content, NO supersede', () => {
|
|
344
|
+
const d = decideSupersede(rec({ text: FLUSHED }), {
|
|
345
|
+
liveTurnId: 'turn-A',
|
|
346
|
+
replyText: HANDBACK,
|
|
347
|
+
now: 1_000_010,
|
|
348
|
+
})
|
|
349
|
+
expect(d.supersede).toBe(false)
|
|
350
|
+
expect(d.reason).toBe('new-content')
|
|
351
|
+
expect(d.deleteMessageIds).toEqual([])
|
|
352
|
+
expect(d.recordText).toBe(FLUSHED)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('legacy identity-only callers (no replyText) keep the pre-#3429 behaviour', () => {
|
|
356
|
+
const d = decideSupersede(rec({ text: FLUSHED }), { liveTurnId: 'turn-A', now: 1_000_010 })
|
|
357
|
+
expect(d.supersede).toBe(true)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('take() does NOT consume the record on new-content — the genuine replay ' +
|
|
361
|
+
'can still correct the flushed message afterwards', () => {
|
|
362
|
+
const reg = new FlushedTurnSupersedeRegistry()
|
|
363
|
+
const now = 1_000_000
|
|
364
|
+
reg.record('chat9', undefined, { turnId: 'turn-F', messageIds: [7001], text: FLUSHED }, now)
|
|
365
|
+
|
|
366
|
+
// The handback lands first: new-content, nothing consumed, nothing deleted.
|
|
367
|
+
const d1 = reg.take('chat9', undefined, { liveTurnId: 'turn-F', replyText: HANDBACK, now: now + 10_000 })
|
|
368
|
+
expect(d1.supersede).toBe(false)
|
|
369
|
+
expect(d1.reason).toBe('new-content')
|
|
370
|
+
|
|
371
|
+
// The turn's own canonical replay lands later: record still there, supersede
|
|
372
|
+
// fires and consumes it.
|
|
373
|
+
const d2 = reg.take('chat9', undefined, {
|
|
374
|
+
liveTurnId: 'turn-F',
|
|
375
|
+
replyText: 'Here is the finished summary of the incident you asked about.',
|
|
376
|
+
now: now + 20_000,
|
|
377
|
+
})
|
|
378
|
+
expect(d2.supersede).toBe(true)
|
|
379
|
+
expect(d2.deleteMessageIds).toEqual([7001])
|
|
380
|
+
// Now consumed.
|
|
381
|
+
expect(reg.peek('chat9', undefined, { liveTurnId: 'turn-F', now: now + 21_000 }).reason).toBe('no-record')
|
|
382
|
+
})
|
|
383
|
+
})
|
|
@@ -16,8 +16,18 @@ import { describe, it, expect } from 'vitest'
|
|
|
16
16
|
import { readFileSync } from 'node:fs'
|
|
17
17
|
import { fileURLToPath } from 'node:url'
|
|
18
18
|
import { dirname, resolve } from 'node:path'
|
|
19
|
+
import { SESSION_MODEL_FILE } from '../gateway/session-model-file.js'
|
|
20
|
+
import {
|
|
21
|
+
classifyModelSwitchConfirmation,
|
|
22
|
+
formatModelRelaunchDiagLog,
|
|
23
|
+
resolveModelSwitchBootNotice,
|
|
24
|
+
} from '../gateway/model-command.js'
|
|
19
25
|
|
|
20
26
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
27
|
+
const MODEL_COMMAND_SRC = readFileSync(
|
|
28
|
+
resolve(__dirname, '../gateway/model-command.ts'),
|
|
29
|
+
'utf8',
|
|
30
|
+
)
|
|
21
31
|
const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
|
|
22
32
|
|
|
23
33
|
describe('gateway: the .relaunch-model-intent subsystem is retired (rev 4)', () => {
|
|
@@ -143,49 +153,203 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
|
|
|
143
153
|
const win = GATEWAY_SRC.slice(idx - 200, idx + 3200)
|
|
144
154
|
// F1: `launched !== configured` is the deterministic apply-boot signal.
|
|
145
155
|
expect(win).toContain('const isApplyBoot = launched.length > 0 && launched !== configured')
|
|
146
|
-
expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null)')
|
|
156
|
+
expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })')
|
|
147
157
|
expect(win).toContain('resolveMainModel(raw ?? undefined)')
|
|
148
158
|
})
|
|
149
159
|
|
|
150
160
|
it('logs the applied model for diagnosability (F1)', () => {
|
|
151
|
-
expect(GATEWAY_SRC).toContain('
|
|
161
|
+
expect(GATEWAY_SRC).toContain('formatModelRelaunchDiagLog')
|
|
152
162
|
expect(GATEWAY_SRC).toContain('gw /model relaunch scheduled agent=')
|
|
163
|
+
expect(MODEL_COMMAND_SRC).toContain('gw /model relaunch applied agent=')
|
|
153
164
|
})
|
|
154
165
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
// #3427 item 2: the following are BEHAVIORAL tests — they run the real
|
|
167
|
+
// classifier → formatter/notice pipeline and assert the EMITTED outcome
|
|
168
|
+
// (log line / card body), not that a string merely exists in the source.
|
|
169
|
+
// The thin `wires …` test at the end pins that gateway.ts actually calls
|
|
170
|
+
// this pipeline (the gateway boot IIFE cannot be unit-booted).
|
|
171
|
+
|
|
172
|
+
it('emits the NOT-APPLIED log for a silent revert (classify → formatModelRelaunchDiagLog)', () => {
|
|
173
|
+
const confirmation = classifyModelSwitchConfirmation({
|
|
174
|
+
reason: 'user: /model fable (session-only relaunch, menu)',
|
|
175
|
+
launched: 'claude-opus-4-8',
|
|
176
|
+
configured: 'claude-opus-4-8',
|
|
177
|
+
})
|
|
178
|
+
expect(confirmation.kind).toBe('not-applied')
|
|
179
|
+
const log = formatModelRelaunchDiagLog({
|
|
180
|
+
agent: 'klanker',
|
|
181
|
+
launched: 'claude-opus-4-8',
|
|
182
|
+
configured: 'claude-opus-4-8',
|
|
183
|
+
confirmation,
|
|
184
|
+
isApplyBoot: false,
|
|
185
|
+
})
|
|
186
|
+
expect(log).toContain('gw /model relaunch NOT-APPLIED agent=klanker')
|
|
187
|
+
expect(log).toContain('target=fable')
|
|
188
|
+
expect(log).toContain('revertedTo=claude-opus-4-8')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('emits outcome=applied for a landed switch and outcome=default for a revert', () => {
|
|
192
|
+
const applied = classifyModelSwitchConfirmation({
|
|
193
|
+
reason: 'user: /model claude-haiku-4-5 (session-only relaunch)',
|
|
194
|
+
launched: 'claude-haiku-4-5',
|
|
195
|
+
configured: 'claude-opus-4-8',
|
|
196
|
+
})
|
|
197
|
+
expect(
|
|
198
|
+
formatModelRelaunchDiagLog({
|
|
199
|
+
agent: 'a', launched: 'claude-haiku-4-5', configured: 'claude-opus-4-8',
|
|
200
|
+
confirmation: applied, isApplyBoot: true,
|
|
201
|
+
}),
|
|
202
|
+
).toContain('override=set outcome=applied')
|
|
203
|
+
const dflt = classifyModelSwitchConfirmation({
|
|
204
|
+
reason: 'user: /model default (revert relaunch)',
|
|
205
|
+
launched: 'claude-opus-4-8',
|
|
206
|
+
configured: 'claude-opus-4-8',
|
|
207
|
+
})
|
|
208
|
+
expect(
|
|
209
|
+
formatModelRelaunchDiagLog({
|
|
210
|
+
agent: 'a', launched: 'claude-opus-4-8', configured: 'claude-opus-4-8',
|
|
211
|
+
confirmation: dflt, isApplyBoot: false,
|
|
212
|
+
}),
|
|
213
|
+
).toContain('override=cleared outcome=default')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('sends the green applied card for a landed switch, the default card for an intended revert (F1/N4)', () => {
|
|
217
|
+
const applied = resolveModelSwitchBootNotice({
|
|
218
|
+
agent: 'a',
|
|
219
|
+
confirmation: classifyModelSwitchConfirmation({
|
|
220
|
+
reason: 'user: /model fable (session-only relaunch)',
|
|
221
|
+
launched: 'fable',
|
|
222
|
+
configured: 'claude-opus-4-8',
|
|
223
|
+
}),
|
|
224
|
+
hasSessionModelAlert: false,
|
|
225
|
+
})
|
|
226
|
+
expect(applied.kind).toBe('card')
|
|
227
|
+
if (applied.kind === 'card') {
|
|
228
|
+
expect(applied.body).toContain('✅ Now running `fable`')
|
|
229
|
+
expect(applied.body).toContain('session-only')
|
|
230
|
+
}
|
|
231
|
+
// N4: launched===configured on a /model default reason still confirms.
|
|
232
|
+
const dflt = resolveModelSwitchBootNotice({
|
|
233
|
+
agent: 'a',
|
|
234
|
+
confirmation: classifyModelSwitchConfirmation({
|
|
235
|
+
reason: 'user: /model default (revert relaunch)',
|
|
236
|
+
launched: 'claude-opus-4-8',
|
|
237
|
+
configured: 'claude-opus-4-8',
|
|
238
|
+
}),
|
|
239
|
+
hasSessionModelAlert: false,
|
|
240
|
+
})
|
|
241
|
+
expect(dflt.kind).toBe('card')
|
|
242
|
+
if (dflt.kind === 'card') {
|
|
243
|
+
expect(dflt.body).toContain('✅ Now running `claude-opus-4-8` (the configured default)')
|
|
244
|
+
}
|
|
165
245
|
})
|
|
166
246
|
|
|
167
|
-
it('warns
|
|
247
|
+
it('warns (⚠️, not a green ✅) when a non-default switch silently reverted to the default (silent-revert fix)', () => {
|
|
248
|
+
const notice = resolveModelSwitchBootNotice({
|
|
249
|
+
agent: 'a',
|
|
250
|
+
confirmation: classifyModelSwitchConfirmation({
|
|
251
|
+
reason: 'user: /model fable (session-only relaunch, menu)',
|
|
252
|
+
launched: 'claude-opus-4-8',
|
|
253
|
+
configured: 'claude-opus-4-8',
|
|
254
|
+
}),
|
|
255
|
+
hasSessionModelAlert: false,
|
|
256
|
+
})
|
|
257
|
+
expect(notice.kind).toBe('card')
|
|
258
|
+
if (notice.kind === 'card') {
|
|
259
|
+
expect(notice.body).toContain("⚠️ Your switch to `fable` didn't apply")
|
|
260
|
+
expect(notice.body).toContain('reverted to `claude-opus-4-8`')
|
|
261
|
+
// LOW-3: the re-issue hint keeps the target inside backticks.
|
|
262
|
+
expect(notice.body).toContain('Re-issue `/model fable`')
|
|
263
|
+
expect(notice.body).not.toContain('✅')
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('suppresses ONLY the not-applied card when a tailored .session-model-alert is present (LOW-2)', () => {
|
|
268
|
+
const reverted = classifyModelSwitchConfirmation({
|
|
269
|
+
reason: 'user: /model fable (session-only relaunch)',
|
|
270
|
+
launched: 'claude-opus-4-8',
|
|
271
|
+
configured: 'claude-opus-4-8',
|
|
272
|
+
})
|
|
273
|
+
const suppressed = resolveModelSwitchBootNotice({
|
|
274
|
+
agent: 'klanker',
|
|
275
|
+
confirmation: reverted,
|
|
276
|
+
hasSessionModelAlert: true,
|
|
277
|
+
})
|
|
278
|
+
expect(suppressed.kind).toBe('suppress')
|
|
279
|
+
if (suppressed.kind === 'suppress') {
|
|
280
|
+
expect(suppressed.log).toContain('suppressing not-applied confirmation')
|
|
281
|
+
expect(suppressed.log).toContain('agent=klanker')
|
|
282
|
+
expect(suppressed.log).toContain('target=fable')
|
|
283
|
+
}
|
|
284
|
+
// An APPLIED switch still confirms even when an (unrelated) alert exists.
|
|
285
|
+
const applied = resolveModelSwitchBootNotice({
|
|
286
|
+
agent: 'klanker',
|
|
287
|
+
confirmation: classifyModelSwitchConfirmation({
|
|
288
|
+
reason: 'user: /model fable (session-only relaunch)',
|
|
289
|
+
launched: 'fable',
|
|
290
|
+
configured: 'claude-opus-4-8',
|
|
291
|
+
}),
|
|
292
|
+
hasSessionModelAlert: true,
|
|
293
|
+
})
|
|
294
|
+
expect(applied.kind).toBe('card')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('wires the pipeline into the boot rehydration (gateway calls classify → diag log → notice)', () => {
|
|
168
298
|
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// isApplyBoot ternary — so a reverted non-default switch yields the ⚠️ card.
|
|
299
|
+
expect(idx).toBeGreaterThan(0)
|
|
300
|
+
const win = GATEWAY_SRC.slice(idx, idx + 4200)
|
|
172
301
|
expect(win).toContain('classifyModelSwitchConfirmation({')
|
|
173
|
-
expect(win).toContain(
|
|
174
|
-
expect(win).toContain(
|
|
175
|
-
expect(win).toContain(
|
|
176
|
-
expect(win).toContain("
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
|
|
302
|
+
expect(win).toContain('formatModelRelaunchDiagLog')
|
|
303
|
+
expect(win).toContain('if (confirmation != null)')
|
|
304
|
+
expect(win).toContain('deliverModelSwitchBootNotice({')
|
|
305
|
+
expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
|
|
306
|
+
// Both notice arms (suppress → stderr, card → sendMessage) now live in
|
|
307
|
+
// model-command.ts (deliverModelSwitchBootNotice — behaviorally tested in
|
|
308
|
+
// model-command.test.ts); the gateway injects the shared boot-card deps
|
|
309
|
+
// (stderr log sink + one raw thread-aware send closure).
|
|
310
|
+
expect(win).toContain('...modelBootCardDeps')
|
|
311
|
+
expect(win).toContain('log: (line) => process.stderr.write(line)')
|
|
312
|
+
expect(win).toContain('lockedBot.api.sendMessage(chatId, body, opts)')
|
|
180
313
|
})
|
|
181
314
|
|
|
182
|
-
it('
|
|
315
|
+
it('arms the #3427 requested-vs-served tripwire on an apply-boot (comparator + handler)', () => {
|
|
316
|
+
// The source is constructed with the conservative comparator…
|
|
317
|
+
expect(GATEWAY_SRC).toContain('createSessionModelSource({ servedMatchesRequested: servedModelMatchesRequested })')
|
|
318
|
+
// …and the apply-boot rehydration verify-ARMS the override (H1: the ONLY
|
|
319
|
+
// arming site) and registers the handler that logs + warns.
|
|
183
320
|
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
184
|
-
const win = GATEWAY_SRC.slice(idx, idx +
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
321
|
+
const win = GATEWAY_SRC.slice(idx, idx + 4500)
|
|
322
|
+
expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })')
|
|
323
|
+
// The handler body lives in model-command.ts (buildServedModelDivergenceHandler,
|
|
324
|
+
// behaviorally tested in model-command.test.ts — log line + operator card);
|
|
325
|
+
// the gateway registers it ONLY on an apply-boot, fed by the shared deps.
|
|
326
|
+
expect(win).toContain(
|
|
327
|
+
'sessionModelSource.setDivergenceHandler(buildServedModelDivergenceHandler(modelBootCardDeps))',
|
|
328
|
+
)
|
|
329
|
+
const armIdx = win.indexOf('sessionModelSource.setDivergenceHandler(')
|
|
330
|
+
expect(win.slice(0, armIdx)).toContain('if (isApplyBoot) {')
|
|
331
|
+
// M2: the handler must NOT destroy the override record — a transient
|
|
332
|
+
// fallback substitution self-corrects; freshness already fixes /status.
|
|
333
|
+
// Structurally guaranteed post-extraction: the injected ModelBootCardDeps
|
|
334
|
+
// surface (agent/chat/log/sendCard) carries NO handle to the source, so
|
|
335
|
+
// the handler CANNOT call setOverride. Pin that the deps object stays free
|
|
336
|
+
// of any source handle.
|
|
337
|
+
const depsStart = win.indexOf('const modelBootCardDeps')
|
|
338
|
+
expect(depsStart).toBeGreaterThan(0)
|
|
339
|
+
const depsWin = win.slice(depsStart, win.indexOf('if (isApplyBoot)'))
|
|
340
|
+
expect(depsWin).not.toContain('sessionModelSource')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('H1 (#3437): the command-time relaunch record does NOT verify-arm the tripwire', () => {
|
|
344
|
+
// scheduleModelRelaunch sets the pre-restart status-honesty override with a
|
|
345
|
+
// BARE setOverride — arming it would let an OLD-model assistant line in the
|
|
346
|
+
// pre-restart window false-accuse a valid NEW token. The behavioral guard
|
|
347
|
+
// lives in session-model-source.test.ts; this pins the gateway call sites.
|
|
348
|
+
const idx = GATEWAY_SRC.indexOf('scheduleModelRelaunch: async (model: string, reason: string)')
|
|
349
|
+
expect(idx).toBeGreaterThan(0)
|
|
350
|
+
const win = GATEWAY_SRC.slice(idx, idx + 2600)
|
|
351
|
+
expect(win).toContain('sessionModelSource.setOverride(model)')
|
|
352
|
+
expect(win).not.toContain('verify')
|
|
189
353
|
})
|
|
190
354
|
|
|
191
355
|
it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
|
|
@@ -213,6 +377,32 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
|
|
|
213
377
|
})
|
|
214
378
|
})
|
|
215
379
|
|
|
380
|
+
|
|
381
|
+
describe('gateway SESSION_MODEL_FILE stays pinned to start.sh.hbs rev5 carrier', () => {
|
|
382
|
+
it('hbs applies the same basename gateway writes (no rev4/rev5 name drift)', async () => {
|
|
383
|
+
const { SESSION_MODEL_FILE } = await import('../gateway/session-model-file.js')
|
|
384
|
+
const { readFileSync } = await import('node:fs')
|
|
385
|
+
const { resolve, dirname } = await import('node:path')
|
|
386
|
+
const { fileURLToPath } = await import('node:url')
|
|
387
|
+
expect(SESSION_MODEL_FILE).toBe('.session-model')
|
|
388
|
+
// Vitest may run with import.meta or __dirname depending on config.
|
|
389
|
+
const here = typeof __dirname !== 'undefined'
|
|
390
|
+
? __dirname
|
|
391
|
+
: dirname(fileURLToPath(import.meta.url))
|
|
392
|
+
const hbs = readFileSync(
|
|
393
|
+
resolve(here, '../../profiles/_base/start.sh.hbs'),
|
|
394
|
+
'utf8',
|
|
395
|
+
)
|
|
396
|
+
// Bare file test on the rev5 carrier (not only -override/-alert siblings).
|
|
397
|
+
expect(hbs).toMatch(/\[\s*-f\s+[^\]]*\/\.session-model["\s\]]/)
|
|
398
|
+
expect(hbs).toContain('configuredDefaultAtWrite')
|
|
399
|
+
// Legacy migration shim still converts leftover override → .session-model.
|
|
400
|
+
expect(hbs).toContain('.session-model-override')
|
|
401
|
+
expect(hbs).toMatch(/migrated legacy one-shot carrier/)
|
|
402
|
+
})
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
|
|
216
406
|
describe('gateway: the legacy one-shot carrier is no longer written', () => {
|
|
217
407
|
it('no gateway code writes .session-model-override anymore (start.sh migration shim only reads it)', () => {
|
|
218
408
|
expect(GATEWAY_SRC).not.toMatch(/writeFileSync\([^)]*\.session-model-override/)
|
|
@@ -32,6 +32,13 @@ import {
|
|
|
32
32
|
parseModelSwitchTarget,
|
|
33
33
|
modelFamilyToken,
|
|
34
34
|
MODEL_ALIASES,
|
|
35
|
+
servedModelMatchesRequested,
|
|
36
|
+
formatServedModelDivergenceLog,
|
|
37
|
+
formatServedModelDivergenceCard,
|
|
38
|
+
buildServedModelDivergenceHandler,
|
|
39
|
+
deliverModelSwitchBootNotice,
|
|
40
|
+
unvalidatedIdCaveat,
|
|
41
|
+
type ModelBootCardDeps,
|
|
35
42
|
type ModelCommandDeps,
|
|
36
43
|
} from "../gateway/model-command.js";
|
|
37
44
|
|
|
@@ -691,3 +698,216 @@ describe("modelFamilyToken", () => {
|
|
|
691
698
|
expect(modelFamilyToken("sr-gemini-2.5-flash")).toBe("sr-gemini-2.5-flash");
|
|
692
699
|
});
|
|
693
700
|
});
|
|
701
|
+
|
|
702
|
+
// ── #3427 item 4: requested-vs-served divergence (fallback-model masking) ────
|
|
703
|
+
|
|
704
|
+
describe("servedModelMatchesRequested", () => {
|
|
705
|
+
it("matches a requested alias against its resolved full id (family)", () => {
|
|
706
|
+
expect(servedModelMatchesRequested("sonnet", "claude-sonnet-5")).toBe(true);
|
|
707
|
+
expect(servedModelMatchesRequested("opus", "claude-opus-4-8")).toBe(true);
|
|
708
|
+
expect(servedModelMatchesRequested("fable", "claude-fable-5")).toBe(true);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("flags a requested alias served by a DIFFERENT family (fallback substituted)", () => {
|
|
712
|
+
expect(servedModelMatchesRequested("sonnet", "claude-opus-4-8")).toBe(false);
|
|
713
|
+
expect(servedModelMatchesRequested("haiku", "claude-sonnet-5")).toBe(false);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("matches a full id exactly and as a date-stamped descendant", () => {
|
|
717
|
+
expect(servedModelMatchesRequested("claude-sonnet-5", "claude-sonnet-5")).toBe(true);
|
|
718
|
+
expect(servedModelMatchesRequested("claude-sonnet-5", "claude-sonnet-5-20260203")).toBe(true);
|
|
719
|
+
expect(servedModelMatchesRequested("Claude-Sonnet-5", "claude-sonnet-5")).toBe(true);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("flags an invalid/unknown full id served by the fallback — the masking case", () => {
|
|
723
|
+
// THE #3427 item-4 case: `/model claude-sonnet-9` (nonexistent) launches,
|
|
724
|
+
// --fallback-model silently serves opus. A test that wouldn't fail on the
|
|
725
|
+
// masking is not a test: this must be a MISMATCH.
|
|
726
|
+
expect(servedModelMatchesRequested("claude-sonnet-9", "claude-opus-4-8")).toBe(false);
|
|
727
|
+
// Same-family but wrong version is still a mismatch (sonnet-9 ≠ sonnet-5).
|
|
728
|
+
expect(servedModelMatchesRequested("claude-sonnet-9", "claude-sonnet-5")).toBe(false);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("is conservative: non-claude served ids and non-comparable requests never accuse", () => {
|
|
732
|
+
// LiteLLM/sr-* served names are not deterministically comparable.
|
|
733
|
+
expect(servedModelMatchesRequested("sr-glm-5", "glm-5")).toBe(true);
|
|
734
|
+
expect(servedModelMatchesRequested("claude-sonnet-5", "sr-glm-5")).toBe(true);
|
|
735
|
+
// Legacy friendly-label overrides ("Opus 4.8") are not comparable either.
|
|
736
|
+
expect(servedModelMatchesRequested("Opus 4.8", "claude-opus-4-8")).toBe(true);
|
|
737
|
+
// The default sentinel never diverges.
|
|
738
|
+
expect(servedModelMatchesRequested("default", "claude-opus-4-8")).toBe(true);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("L3 (#3437): legacy id shapes (family after version) never false-accuse an alias request", () => {
|
|
742
|
+
// `claude-3-opus-20240229` → modelFamilyToken yields "3", not "opus"; the
|
|
743
|
+
// segment fallback must still match the alias.
|
|
744
|
+
expect(servedModelMatchesRequested("opus", "claude-3-opus-20240229")).toBe(true);
|
|
745
|
+
expect(servedModelMatchesRequested("haiku", "claude-3-5-haiku-20241022")).toBe(true);
|
|
746
|
+
// …while a genuinely different family still fires.
|
|
747
|
+
expect(servedModelMatchesRequested("sonnet", "claude-3-opus-20240229")).toBe(false);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
describe("served-model divergence formatters (#3427 item 4)", () => {
|
|
752
|
+
it("log line is greppable and names requested + served", () => {
|
|
753
|
+
const log = formatServedModelDivergenceLog({
|
|
754
|
+
agent: "klanker",
|
|
755
|
+
requested: "claude-sonnet-9",
|
|
756
|
+
served: "claude-opus-4-8",
|
|
757
|
+
});
|
|
758
|
+
expect(log).toContain("gw /model served-model DIVERGENCE agent=klanker");
|
|
759
|
+
expect(log).toContain("requested=claude-sonnet-9");
|
|
760
|
+
expect(log).toContain("served=claude-opus-4-8");
|
|
761
|
+
expect(log.endsWith("\n")).toBe(true);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("card warns, names both models in backticks, and says how to recover", () => {
|
|
765
|
+
const card = formatServedModelDivergenceCard({
|
|
766
|
+
requested: "claude-sonnet-9",
|
|
767
|
+
served: "claude-opus-4-8",
|
|
768
|
+
});
|
|
769
|
+
expect(card).toContain("⚠️");
|
|
770
|
+
expect(card).toContain("`claude-opus-4-8`");
|
|
771
|
+
expect(card).toContain("`claude-sonnet-9`");
|
|
772
|
+
expect(card).toContain("/model");
|
|
773
|
+
expect(card).not.toContain("✅");
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("M2 (#3437): the card and log name BOTH causes — never a flat 'invalid' accusation", () => {
|
|
777
|
+
// --fallback-model also substitutes on transient overload/unavailability;
|
|
778
|
+
// the signal cannot distinguish that from a bogus id, so neither surface
|
|
779
|
+
// may assert invalidity as fact.
|
|
780
|
+
const card = formatServedModelDivergenceCard({
|
|
781
|
+
requested: "claude-sonnet-9",
|
|
782
|
+
served: "claude-opus-4-8",
|
|
783
|
+
});
|
|
784
|
+
expect(card).toContain("temporarily unavailable");
|
|
785
|
+
expect(card).toContain("invalid");
|
|
786
|
+
expect(card).toContain("If it persists");
|
|
787
|
+
expect(card).toContain("Either"); // hedged alternatives, not an assertion of invalidity
|
|
788
|
+
const log = formatServedModelDivergenceLog({
|
|
789
|
+
agent: "a", requested: "claude-sonnet-9", served: "claude-opus-4-8",
|
|
790
|
+
});
|
|
791
|
+
expect(log).toContain("invalid/unknown OR model transiently unavailable");
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
describe("boot /model cards — buildServedModelDivergenceHandler / deliverModelSwitchBootNotice (#2996 extraction)", () => {
|
|
796
|
+
function makeCardDeps(overrides: Partial<ModelBootCardDeps> = {}) {
|
|
797
|
+
const logs: string[] = [];
|
|
798
|
+
const sends: Array<{ chatId: string; body: string; opts: Record<string, unknown> }> = [];
|
|
799
|
+
const deps: ModelBootCardDeps = {
|
|
800
|
+
agent: "klanker",
|
|
801
|
+
chat: { chatId: "-100123", threadId: 42 },
|
|
802
|
+
log: (line) => { logs.push(line); },
|
|
803
|
+
sendCard: (chatId, body, opts) => {
|
|
804
|
+
sends.push({ chatId, body, opts });
|
|
805
|
+
return Promise.resolve();
|
|
806
|
+
},
|
|
807
|
+
...overrides,
|
|
808
|
+
};
|
|
809
|
+
return { deps, logs, sends };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
it("divergence handler logs the DIVERGENCE line and sends the card to the marker chat + thread", () => {
|
|
813
|
+
const { deps, logs, sends } = makeCardDeps();
|
|
814
|
+
buildServedModelDivergenceHandler(deps)({ requested: "claude-sonnet-9", served: "claude-opus-4-8" });
|
|
815
|
+
expect(logs.some((l) => l.includes("gw /model served-model DIVERGENCE agent=klanker"))).toBe(true);
|
|
816
|
+
expect(sends).toHaveLength(1);
|
|
817
|
+
expect(sends[0].chatId).toBe("-100123");
|
|
818
|
+
expect(sends[0].body).toContain("`claude-opus-4-8`");
|
|
819
|
+
expect(sends[0].opts).toEqual({ parse_mode: "Markdown", message_thread_id: 42 });
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("divergence handler with NO marker chat still logs but never sends", () => {
|
|
823
|
+
const { deps, logs, sends } = makeCardDeps({ chat: null });
|
|
824
|
+
buildServedModelDivergenceHandler(deps)({ requested: "sonnet", served: "claude-opus-4-8" });
|
|
825
|
+
expect(logs.some((l) => l.includes("DIVERGENCE"))).toBe(true);
|
|
826
|
+
expect(sends).toHaveLength(0);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
it("divergence card send rejection is swallowed and logged — never throws", async () => {
|
|
830
|
+
const { deps, logs } = makeCardDeps({
|
|
831
|
+
sendCard: () => Promise.reject(new Error("THREAD_NOT_FOUND")),
|
|
832
|
+
});
|
|
833
|
+
expect(() =>
|
|
834
|
+
buildServedModelDivergenceHandler(deps)({ requested: "claude-sonnet-9", served: "claude-opus-4-8" }),
|
|
835
|
+
).not.toThrow();
|
|
836
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
837
|
+
expect(logs.some((l) => l.includes("served-model divergence send failed: THREAD_NOT_FOUND"))).toBe(true);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it("boot notice: applied confirmation sends the ✅ card (thread-aware), no suppress log", () => {
|
|
841
|
+
const { deps, logs, sends } = makeCardDeps();
|
|
842
|
+
deliverModelSwitchBootNotice({
|
|
843
|
+
...deps,
|
|
844
|
+
confirmation: { kind: "applied", launched: "claude-opus-4-8" },
|
|
845
|
+
hasSessionModelAlert: false,
|
|
846
|
+
});
|
|
847
|
+
expect(sends).toHaveLength(1);
|
|
848
|
+
expect(sends[0].body).toContain("✅ Now running `claude-opus-4-8`");
|
|
849
|
+
expect(sends[0].opts).toEqual({ parse_mode: "Markdown", message_thread_id: 42 });
|
|
850
|
+
expect(logs).toHaveLength(0);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("boot notice: not-applied + .session-model-alert suppresses the card and writes the suppress log", () => {
|
|
854
|
+
const { deps, logs, sends } = makeCardDeps({ chat: { chatId: "-100123", threadId: null } });
|
|
855
|
+
deliverModelSwitchBootNotice({
|
|
856
|
+
...deps,
|
|
857
|
+
confirmation: { kind: "not-applied", target: "fable", revertedTo: "claude-sonnet-5" },
|
|
858
|
+
hasSessionModelAlert: true,
|
|
859
|
+
});
|
|
860
|
+
expect(sends).toHaveLength(0);
|
|
861
|
+
expect(logs.some((l) => l.includes("suppressing not-applied confirmation") && l.includes("target=fable"))).toBe(true);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it("boot notice: no marker chat is a full no-op (matches pre-extraction inline behavior)", () => {
|
|
865
|
+
const { deps, logs, sends } = makeCardDeps({ chat: null });
|
|
866
|
+
deliverModelSwitchBootNotice({
|
|
867
|
+
...deps,
|
|
868
|
+
confirmation: { kind: "not-applied", target: "fable", revertedTo: "claude-sonnet-5" },
|
|
869
|
+
hasSessionModelAlert: true,
|
|
870
|
+
});
|
|
871
|
+
expect(sends).toHaveLength(0);
|
|
872
|
+
expect(logs).toHaveLength(0);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it("boot notice: threadId null omits message_thread_id from send opts", () => {
|
|
876
|
+
const { deps, sends } = makeCardDeps({ chat: { chatId: "777", threadId: null } });
|
|
877
|
+
deliverModelSwitchBootNotice({
|
|
878
|
+
...deps,
|
|
879
|
+
confirmation: { kind: "default", launched: "claude-sonnet-5" },
|
|
880
|
+
hasSessionModelAlert: false,
|
|
881
|
+
});
|
|
882
|
+
expect(sends).toHaveLength(1);
|
|
883
|
+
expect(sends[0].chatId).toBe("777");
|
|
884
|
+
expect(sends[0].opts).toEqual({ parse_mode: "Markdown" });
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
describe("unvalidatedIdCaveat — immediate fail-fast warn on free-text claude-* ids (#3427 item 4)", () => {
|
|
889
|
+
const esc = { escapeHtml: (s: string) => s };
|
|
890
|
+
|
|
891
|
+
it("warns for a full claude-* id (cannot be pre-validated)", () => {
|
|
892
|
+
const caveat = unvalidatedIdCaveat(esc, "claude-sonnet-9");
|
|
893
|
+
expect(caveat).not.toBeNull();
|
|
894
|
+
expect(caveat).toContain("claude-sonnet-9");
|
|
895
|
+
expect(caveat).toContain("fallback");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("stays silent for aliases, sr-* ids and the default sentinel", () => {
|
|
899
|
+
expect(unvalidatedIdCaveat(esc, "opus")).toBeNull();
|
|
900
|
+
expect(unvalidatedIdCaveat(esc, "sonnet")).toBeNull();
|
|
901
|
+
expect(unvalidatedIdCaveat(esc, "sr-glm-5")).toBeNull();
|
|
902
|
+
expect(unvalidatedIdCaveat(esc, "default")).toBeNull();
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("the /model set ACK carries the caveat for a typed full id — and not for an alias", async () => {
|
|
906
|
+
const { deps } = makeDeps();
|
|
907
|
+
const full = await handleModelCommand({ kind: "set", model: "claude-sonnet-9" }, deps);
|
|
908
|
+
expect(full.text).toContain("can't be validated before launch");
|
|
909
|
+
expect(full.text).toContain("fallback");
|
|
910
|
+
const alias = await handleModelCommand({ kind: "set", model: "opus" }, deps);
|
|
911
|
+
expect(alias.text).not.toContain("can't be validated before launch");
|
|
912
|
+
});
|
|
913
|
+
});
|