switchroom 0.19.2 → 0.19.4
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 +2 -0
- package/dist/auth-broker/index.js +109 -7
- package/dist/cli/autoaccept-poll.js +2 -0
- package/dist/cli/drive-write-pretool.mjs +2 -0
- package/dist/cli/ms-365-write-pretool.mjs +2 -0
- package/dist/cli/switchroom.js +404 -245
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +8 -0
- package/skills/mental-model-curator/SKILL.md +68 -2
- package/telegram-plugin/auth-snapshot-format.ts +104 -12
- package/telegram-plugin/dist/bridge/bridge.js +8 -2
- package/telegram-plugin/dist/gateway/gateway.js +1194 -794
- package/telegram-plugin/dist/server.js +8 -2
- 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 +68 -101
- package/telegram-plugin/gateway/inbound-interceptors.ts +13 -3
- package/telegram-plugin/gateway/model-command.ts +203 -1
- package/telegram-plugin/gateway/outbound-send-path.ts +68 -15
- package/telegram-plugin/gateway/session-model-source.ts +90 -10
- package/telegram-plugin/gateway/stream-render.ts +22 -5
- package/telegram-plugin/quota-bar-format.ts +60 -12
- package/telegram-plugin/reply-owner-resolve.ts +76 -11
- package/telegram-plugin/session-tail.ts +27 -3
- 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/flushed-turn-supersede.test.ts +117 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +185 -29
- package/telegram-plugin/tests/model-command.test.ts +220 -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/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
|
@@ -17,6 +17,11 @@ import { readFileSync } from 'node:fs'
|
|
|
17
17
|
import { fileURLToPath } from 'node:url'
|
|
18
18
|
import { dirname, resolve } from 'node:path'
|
|
19
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'
|
|
20
25
|
|
|
21
26
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
22
27
|
const MODEL_COMMAND_SRC = readFileSync(
|
|
@@ -148,7 +153,7 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
|
|
|
148
153
|
const win = GATEWAY_SRC.slice(idx - 200, idx + 3200)
|
|
149
154
|
// F1: `launched !== configured` is the deterministic apply-boot signal.
|
|
150
155
|
expect(win).toContain('const isApplyBoot = launched.length > 0 && launched !== configured')
|
|
151
|
-
expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null)')
|
|
156
|
+
expect(win).toContain('sessionModelSource.setOverride(isApplyBoot ? launched : null, { verify: true })')
|
|
152
157
|
expect(win).toContain('resolveMainModel(raw ?? undefined)')
|
|
153
158
|
})
|
|
154
159
|
|
|
@@ -158,42 +163,193 @@ describe('gateway boot: session-model re-hydration + confirmation + alert relay'
|
|
|
158
163
|
expect(MODEL_COMMAND_SRC).toContain('gw /model relaunch applied agent=')
|
|
159
164
|
})
|
|
160
165
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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')
|
|
170
189
|
})
|
|
171
190
|
|
|
172
|
-
it('
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
expect(
|
|
179
|
-
|
|
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')
|
|
180
214
|
})
|
|
181
215
|
|
|
182
|
-
it('
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}
|
|
189
245
|
})
|
|
190
246
|
|
|
191
|
-
it('
|
|
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)', () => {
|
|
192
298
|
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
193
|
-
|
|
299
|
+
expect(idx).toBeGreaterThan(0)
|
|
300
|
+
const win = GATEWAY_SRC.slice(idx, idx + 4200)
|
|
301
|
+
expect(win).toContain('classifyModelSwitchConfirmation({')
|
|
302
|
+
expect(win).toContain('formatModelRelaunchDiagLog')
|
|
303
|
+
expect(win).toContain('if (confirmation != null)')
|
|
304
|
+
expect(win).toContain('deliverModelSwitchBootNotice({')
|
|
194
305
|
expect(win).toContain("existsSync(join(smAgentDir, '.session-model-alert'))")
|
|
195
|
-
|
|
196
|
-
|
|
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)')
|
|
313
|
+
})
|
|
314
|
+
|
|
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.
|
|
320
|
+
const idx = GATEWAY_SRC.indexOf('const isApplyBoot = launched.length > 0')
|
|
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')
|
|
197
353
|
})
|
|
198
354
|
|
|
199
355
|
it('N4/reason: the /model switch reason is captured from the clean-shutdown marker', () => {
|
|
@@ -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
|
+
});
|