claudeup 6.7.1 → 6.8.0
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/package.json +4 -4
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/model-visuals.test.tsx +1698 -25
- package/src/__tests__/models-adapter.test.ts +21 -6
- package/src/__tests__/models-cli.test.ts +100 -0
- package/src/__tests__/models-core.test.ts +273 -111
- package/src/__tests__/models-manager.test.ts +15 -12
- package/src/__tests__/models-presets-marketplace.test.ts +29 -2
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/data/models-presets.ts +62 -2
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/models-core.ts +371 -30
- package/src/ui/adapters/modelsAdapter.ts +58 -15
- package/src/ui/components/layout/ScreenLayout.tsx +6 -1
- package/src/ui/renderers/modelRenderers.tsx +413 -108
- package/src/ui/renderers/modelVisuals.tsx +694 -145
- package/src/ui/screens/ModelsScreen.tsx +74 -10
- package/src/ui/state/reducer.ts +20 -0
- package/src/ui/state/types.ts +31 -0
- package/src/ui/theme-mode.ts +128 -14
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "../data/models-presets.js";
|
|
7
7
|
import type { ModelsConfig, ModelsStatus } from "../services/models-core.js";
|
|
8
8
|
import {
|
|
9
|
+
CUSTOM_LABEL,
|
|
9
10
|
type ModelsBrowserItem,
|
|
10
11
|
buildModelsItems,
|
|
11
12
|
firstSelectableIndex,
|
|
@@ -161,9 +162,9 @@ describe("marking the active preset", () => {
|
|
|
161
162
|
]);
|
|
162
163
|
});
|
|
163
164
|
|
|
164
|
-
test("a
|
|
165
|
-
//
|
|
166
|
-
//
|
|
165
|
+
test("a diverged config gets ONE row, called `Custom`, carrying its settings", () => {
|
|
166
|
+
// Divergence is a thing you HAVE, not a fault. The row is the option that holds those
|
|
167
|
+
// settings, so they can be selected and inspected like any preset.
|
|
167
168
|
const config = customConfig();
|
|
168
169
|
const items = buildModelsItems({
|
|
169
170
|
status: status({ state: "on", preset: "custom" }),
|
|
@@ -174,16 +175,30 @@ describe("marking the active preset", () => {
|
|
|
174
175
|
const rows = presetRows(items);
|
|
175
176
|
expect(rows).toHaveLength(BUILT_IN_PRESETS.length + 1);
|
|
176
177
|
const mine = rows.at(-1);
|
|
177
|
-
expect(mine?.
|
|
178
|
+
expect(mine?.label).toBe(CUSTOM_LABEL);
|
|
178
179
|
expect(mine?.custom).toBe(true);
|
|
179
180
|
expect(mine?.active).toBe(true);
|
|
180
181
|
expect(mine?.isDefault).toBe(false);
|
|
181
|
-
// Its own config, so the detail pane
|
|
182
|
+
// Its own config, so the detail pane and the chart show what it actually routes.
|
|
182
183
|
expect(mine?.config).toBe(config);
|
|
183
|
-
// And it is the ONLY active row.
|
|
184
|
+
// And it is the ONLY active row: a built-in of the same name is not this routing.
|
|
184
185
|
expect(rows.filter((item) => item.active)).toHaveLength(1);
|
|
185
186
|
});
|
|
186
187
|
|
|
188
|
+
test("the `Custom` row is never labelled with the file's `preset` string", () => {
|
|
189
|
+
// `models use opus-lead` writes `opus-lead`, and hand-editing afterwards leaves it. A
|
|
190
|
+
// row labelled from that string wears a built-in's name while not being that built-in.
|
|
191
|
+
const items = buildModelsItems({
|
|
192
|
+
status: status({ state: "on", preset: "opus-lead" }),
|
|
193
|
+
config: { ...customConfig(), preset: "opus-lead" },
|
|
194
|
+
query: "",
|
|
195
|
+
});
|
|
196
|
+
const mine = presetRows(items).at(-1);
|
|
197
|
+
expect(mine?.custom).toBe(true);
|
|
198
|
+
expect(mine?.label).toBe(CUSTOM_LABEL);
|
|
199
|
+
expect(mine?.label).not.toBe("Opus main");
|
|
200
|
+
});
|
|
201
|
+
|
|
187
202
|
test("a config naming a built-in adds no extra row", () => {
|
|
188
203
|
const items = buildModelsItems({
|
|
189
204
|
status: status({ state: "on", preset: "sonnet-economy" }),
|
|
@@ -108,6 +108,83 @@ describe("models list", () => {
|
|
|
108
108
|
expect(out).toContain("(default)");
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* A hand-edited config that routes agents through claudish.
|
|
113
|
+
*
|
|
114
|
+
* `models list` prints TIERS, never agents, so before this a mate assigned per-agent —
|
|
115
|
+
* the ordinary way to use one — appeared nowhere in the command's output at all, and the
|
|
116
|
+
* project's own config was printed as a bare name while the four presets it replaced were
|
|
117
|
+
* printed in full.
|
|
118
|
+
*/
|
|
119
|
+
test("an UNBOUND slot says so, rather than printing its own name as a model", async () => {
|
|
120
|
+
// A slot with no entry in `mates` names no model at all. Printing `kangaroo` in the
|
|
121
|
+
// model column would read as a model id nobody recognises — the slot is a role, and a
|
|
122
|
+
// role is not a model. Saying `unbound` names the thing the reader has to go fix.
|
|
123
|
+
await fs.outputJson(join(project, ".claude", "models.json"), {
|
|
124
|
+
version: 1,
|
|
125
|
+
preset: "outback",
|
|
126
|
+
main: { model: "opus", effort: "xhigh" },
|
|
127
|
+
grades: {
|
|
128
|
+
smart: { model: "kangaroo" },
|
|
129
|
+
normal: { model: "opus", effort: "xhigh" },
|
|
130
|
+
cheap: { model: "sonnet", effort: "high" },
|
|
131
|
+
},
|
|
132
|
+
agents: { "dev:researcher": { model: "mate1" } },
|
|
133
|
+
fallback: "normal",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const code = await runModelsCommand(["list"], project);
|
|
137
|
+
|
|
138
|
+
expect(code).toBe(0);
|
|
139
|
+
const out = logs.join("\n");
|
|
140
|
+
expect(out).toContain("outback");
|
|
141
|
+
expect(out).toContain("unbound");
|
|
142
|
+
expect(out).toContain("via claudish");
|
|
143
|
+
// And the per-agent routing, which no tier row could have shown.
|
|
144
|
+
expect(out).toContain("dev:researcher");
|
|
145
|
+
expect(out).toContain("mate1");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a BOUND slot prints the external model, not the slot name", async () => {
|
|
149
|
+
// The other half, and the one that makes the pair worth having: without it, a bug that
|
|
150
|
+
// printed `unbound` unconditionally would pass the test above and look like proof.
|
|
151
|
+
//
|
|
152
|
+
// `grok-4.6` is written as a bare catalog id on purpose — no `@`, no `/`. Those forms
|
|
153
|
+
// pin a provider and bypass subscription-aware routing, and the validator rejects them.
|
|
154
|
+
await fs.outputJson(join(project, ".claude", "models.json"), {
|
|
155
|
+
version: 1,
|
|
156
|
+
preset: "outback",
|
|
157
|
+
main: { model: "opus", effort: "xhigh" },
|
|
158
|
+
grades: {
|
|
159
|
+
smart: { model: "kangaroo" },
|
|
160
|
+
normal: { model: "opus", effort: "xhigh" },
|
|
161
|
+
cheap: { model: "sonnet", effort: "high" },
|
|
162
|
+
},
|
|
163
|
+
agents: { "dev:researcher": { model: "mate1" } },
|
|
164
|
+
mates: {
|
|
165
|
+
kangaroo: { model: "grok-4.6" },
|
|
166
|
+
mate1: { model: "kimi-k3", effort: "high" },
|
|
167
|
+
},
|
|
168
|
+
fallback: "normal",
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const code = await runModelsCommand(["list"], project);
|
|
172
|
+
|
|
173
|
+
expect(code).toBe(0);
|
|
174
|
+
const out = logs.join("\n");
|
|
175
|
+
expect(out).toContain("grok-4.6");
|
|
176
|
+
expect(out).toContain("kimi-k3");
|
|
177
|
+
// The bound slot must no longer be advertising itself as unbound.
|
|
178
|
+
expect(out).not.toContain("kangaroo → unbound");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("says nothing about claudish when no agent is routed there", async () => {
|
|
182
|
+
// The negative control for the block above: it is a report, not a standing headline.
|
|
183
|
+
const code = await runModelsCommand(["list"], project);
|
|
184
|
+
expect(code).toBe(0);
|
|
185
|
+
expect(logs.join("\n")).not.toContain("claudish");
|
|
186
|
+
});
|
|
187
|
+
|
|
111
188
|
test("marks the active preset", async () => {
|
|
112
189
|
await seedProfile();
|
|
113
190
|
await runModelsCommand(["use", "opus-lead"], project);
|
|
@@ -130,6 +207,29 @@ describe("models status", () => {
|
|
|
130
207
|
expect(logs.join("\n")).toContain("Model tiers:");
|
|
131
208
|
});
|
|
132
209
|
|
|
210
|
+
test("a config that routes agents through claudish is healthy, not invalid", async () => {
|
|
211
|
+
// Validity is a property of the FILE, not of this machine: the multimodel plugin is
|
|
212
|
+
// certainly not installed in a temp directory, and the config still has to read as
|
|
213
|
+
// routing that works. `off` is the honest state here — nothing has been applied.
|
|
214
|
+
await fs.outputJson(join(project, ".claude", "models.json"), {
|
|
215
|
+
version: 1,
|
|
216
|
+
preset: "outback",
|
|
217
|
+
main: { model: "opus" },
|
|
218
|
+
grades: {
|
|
219
|
+
smart: { model: "mate1" },
|
|
220
|
+
normal: { model: "opus" },
|
|
221
|
+
cheap: { model: "kangaroo" },
|
|
222
|
+
},
|
|
223
|
+
agents: { "dev:docs": { model: "mate2" } },
|
|
224
|
+
fallback: "normal",
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const code = await runModelsCommand(["status"], project);
|
|
228
|
+
|
|
229
|
+
expect(code).toBe(0);
|
|
230
|
+
expect(logs.join("\n")).not.toContain("invalid");
|
|
231
|
+
});
|
|
232
|
+
|
|
133
233
|
// An invalid config routes nothing, so it is a failure, not a report.
|
|
134
234
|
test("exits 1 on an invalid config and prints each error", async () => {
|
|
135
235
|
await fs.outputJson(join(project, ".claude", "models.json"), {
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* bench — never a reason to relax the assertion.
|
|
8
8
|
*/
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
10
12
|
import {
|
|
11
13
|
BUILT_IN_PRESETS,
|
|
12
14
|
DEFAULT_AGENT_GRADES,
|
|
@@ -17,11 +19,13 @@ import {
|
|
|
17
19
|
EFFORTS,
|
|
18
20
|
type Effort,
|
|
19
21
|
GRADES,
|
|
22
|
+
MATES,
|
|
20
23
|
type ModelsConfig,
|
|
21
24
|
baseAlias,
|
|
22
25
|
buildSettingsPatch,
|
|
23
26
|
computeModelsStatus,
|
|
24
27
|
evaluateAgentHook,
|
|
28
|
+
isMate,
|
|
25
29
|
isRoutableModel,
|
|
26
30
|
resolveAgentRouting,
|
|
27
31
|
validateModelsConfig,
|
|
@@ -250,6 +254,48 @@ describe("evaluateAgentHook", () => {
|
|
|
250
254
|
const d = evaluateAgentHook(agentCall({ model: "" }), base());
|
|
251
255
|
expect(d.kind).toBe("update");
|
|
252
256
|
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A mate passes through, and says so in its own words.
|
|
260
|
+
*
|
|
261
|
+
* The reason is not cosmetic. `unroutable-model` is the hook reporting a BROKEN config —
|
|
262
|
+
* a value that would refuse the spawn if it were injected — and a mate is the config
|
|
263
|
+
* working exactly as written. Reporting one as the other would make every deliberate
|
|
264
|
+
* external routing look like a typo in the hook log, which is the only place a user can
|
|
265
|
+
* see what the hook decided.
|
|
266
|
+
*/
|
|
267
|
+
test("a mate passes through as `mate`, never as `unroutable-model`", () => {
|
|
268
|
+
for (const mate of MATES) {
|
|
269
|
+
const c = base();
|
|
270
|
+
(c.grades.smart as { model: string; effort?: Effort }).model = mate;
|
|
271
|
+
// biome-ignore lint/performance/noDelete: a mate carries no effort
|
|
272
|
+
delete (c.grades.smart as { effort?: Effort }).effort;
|
|
273
|
+
const decision = evaluateAgentHook(agentCall(), c);
|
|
274
|
+
expect(decision).toEqual({ kind: "passthrough", reason: "mate" });
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a mate pinned to one agent passes through, and the rest still route", () => {
|
|
279
|
+
const c = base();
|
|
280
|
+
c.agents["dev:docs"] = { model: "kangaroo" } as never;
|
|
281
|
+
expect(
|
|
282
|
+
evaluateAgentHook(agentCall({ subagent_type: "dev:docs" }), c),
|
|
283
|
+
).toEqual({ kind: "passthrough", reason: "mate" });
|
|
284
|
+
// The negative control: the mate changed nothing about the agents around it.
|
|
285
|
+
expect(evaluateAgentHook(agentCall(), c).kind).toBe("update");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("a mate is never written into updatedInput — there is no update to write it into", () => {
|
|
289
|
+
const c = base();
|
|
290
|
+
c.agents["dev:docs"] = { model: "mate1" } as never;
|
|
291
|
+
const decision = evaluateAgentHook(
|
|
292
|
+
agentCall({ subagent_type: "dev:docs" }),
|
|
293
|
+
c,
|
|
294
|
+
);
|
|
295
|
+
// Stated as the absence of an `update`, because `updatedInput.model` is the exact
|
|
296
|
+
// field AMR-1 measured as spawn-refusing for a non-alias.
|
|
297
|
+
expect(decision.kind).not.toBe("update");
|
|
298
|
+
});
|
|
253
299
|
});
|
|
254
300
|
|
|
255
301
|
describe("buildSettingsPatch", () => {
|
|
@@ -295,6 +341,55 @@ describe("buildSettingsPatch", () => {
|
|
|
295
341
|
expect(set.modelSettings).toBeUndefined();
|
|
296
342
|
});
|
|
297
343
|
|
|
344
|
+
/**
|
|
345
|
+
* A mate may never reach `modelSettings`, and the guard that stops it is load-bearing.
|
|
346
|
+
*
|
|
347
|
+
* `add()` returns early on anything that is not an alias, which is currently the ONLY
|
|
348
|
+
* thing keeping `mate1` out of settings — and it reads as an accident of ordering rather
|
|
349
|
+
* than a rule, so it is pinned here. Two ways it could go wrong, both tested:
|
|
350
|
+
*
|
|
351
|
+
* - a `mate1` key, which Claude Code can key nothing by;
|
|
352
|
+
* - an `undefined` key, which is what a `resolveFullId` returning null would write if
|
|
353
|
+
* the guard moved and the full-id branch ran on a name it cannot resolve.
|
|
354
|
+
*
|
|
355
|
+
* The config is built by hand past the validator on purpose: the validator refuses effort
|
|
356
|
+
* on a mate, and this function is also reached from `doctor` and the TUI with whatever a
|
|
357
|
+
* hand-edited file holds.
|
|
358
|
+
*/
|
|
359
|
+
test("a mate produces no modelSettings key of its own, and no `undefined` key", () => {
|
|
360
|
+
const c = base();
|
|
361
|
+
(c.grades.smart as { model: string; effort?: Effort }).model = "mate1";
|
|
362
|
+
c.agents.pinned = { model: "kangaroo" } as never;
|
|
363
|
+
|
|
364
|
+
const withResolver = buildSettingsPatch(
|
|
365
|
+
c,
|
|
366
|
+
((alias: string) => `claude-${alias}-5`) as never,
|
|
367
|
+
);
|
|
368
|
+
const keys = Object.keys(
|
|
369
|
+
withResolver.set.modelSettings as Record<string, unknown>,
|
|
370
|
+
);
|
|
371
|
+
for (const mate of ["mate1", "mate2", "kangaroo"]) {
|
|
372
|
+
expect(keys).not.toContain(mate);
|
|
373
|
+
expect(keys).not.toContain(`claude-${mate}-5`);
|
|
374
|
+
}
|
|
375
|
+
expect(keys).not.toContain("undefined");
|
|
376
|
+
// And the aliases beside it are untouched — the mate cost nothing else its entry.
|
|
377
|
+
expect(keys).toContain("opus");
|
|
378
|
+
|
|
379
|
+
// The same, with an effort the validator would have refused. This is the branch that
|
|
380
|
+
// actually exercises the `isModelAlias` gate rather than the `effort === undefined`
|
|
381
|
+
// one above it.
|
|
382
|
+
const forced = base();
|
|
383
|
+
forced.grades.smart = { model: "mate1", effort: "xhigh" } as never;
|
|
384
|
+
const patch = buildSettingsPatch(forced, noFullId);
|
|
385
|
+
const forcedKeys = Object.keys(
|
|
386
|
+
patch.set.modelSettings as Record<string, unknown>,
|
|
387
|
+
);
|
|
388
|
+
expect(forcedKeys).not.toContain("mate1");
|
|
389
|
+
expect(forcedKeys).not.toContain("undefined");
|
|
390
|
+
expect(forcedKeys.sort()).toEqual(["opus", "sonnet"]);
|
|
391
|
+
});
|
|
392
|
+
|
|
298
393
|
test("a [1m] main model keys its effort by the base alias", () => {
|
|
299
394
|
const c = base();
|
|
300
395
|
(c.main as { model: string }).model = "opus[1m]";
|
|
@@ -356,7 +451,10 @@ describe("computeModelsStatus", () => {
|
|
|
356
451
|
expect(s.drift).toEqual([]);
|
|
357
452
|
});
|
|
358
453
|
|
|
359
|
-
test("
|
|
454
|
+
test("settings differing from the config is CUSTOM, not a fault", () => {
|
|
455
|
+
// There is no state for it and no drift list. A project may run models and efforts no
|
|
456
|
+
// shipped preset names; the list says so with a `Custom` row carrying those settings,
|
|
457
|
+
// rather than the header reporting a difference as something to fix.
|
|
360
458
|
const s = computeModelsStatus({
|
|
361
459
|
config: base(),
|
|
362
460
|
errors: [],
|
|
@@ -364,108 +462,13 @@ describe("computeModelsStatus", () => {
|
|
|
364
462
|
settings: { ...applied(), effortLevel: "high" },
|
|
365
463
|
resolveFullId: noFullId,
|
|
366
464
|
});
|
|
367
|
-
expect(s.state).toBe("
|
|
368
|
-
expect(s.drift
|
|
369
|
-
});
|
|
370
|
-
/**
|
|
371
|
-
* Drift text is READ BY A PERSON, and these three assertions each pin a defect that
|
|
372
|
-
* shipped because the only existing test checked `.toContain("effortLevel")` — which the
|
|
373
|
-
* broken copy satisfied too.
|
|
374
|
-
*/
|
|
375
|
-
test("an absent key is said in words, never as the string `undefined`", () => {
|
|
376
|
-
// `JSON.stringify(undefined)` returns the STRING "undefined", so the old line read
|
|
377
|
-
// `model: settings has undefined` — a JavaScript value leaking into user-facing text.
|
|
378
|
-
const s = computeModelsStatus({
|
|
379
|
-
config: base(),
|
|
380
|
-
errors: [],
|
|
381
|
-
hookRegistered: true,
|
|
382
|
-
settings: { model: "sonnet" },
|
|
383
|
-
resolveFullId: noFullId,
|
|
384
|
-
});
|
|
385
|
-
expect(s.state).toBe("stale");
|
|
386
|
-
expect(s.drift.join(" ")).not.toContain("undefined");
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* A drift line may never say that X differs from X.
|
|
391
|
-
*
|
|
392
|
-
* This is the defect a code review caught by EXECUTING the function rather than reading
|
|
393
|
-
* it: `modelSettings` was summarised on each side with the full-id keys filtered out to
|
|
394
|
-
* keep the line short, so when only a full id differed — the overwhelmingly common case,
|
|
395
|
-
* since the patch writes every model twice — both sides rendered identical text while
|
|
396
|
-
* `JSON.stringify` correctly said they differed.
|
|
397
|
-
*
|
|
398
|
-
* It matters beyond legibility. Per AMR-1, a full id BEATS an alias when the two disagree,
|
|
399
|
-
* so the suppressed key was the one actually serving subagents. And it arrives on its own
|
|
400
|
-
* schedule: apply a preset, wait for the next model release, and the stored full id stops
|
|
401
|
-
* matching the resolved one for every project.
|
|
402
|
-
*/
|
|
403
|
-
test("a modelSettings difference in a full-id key is named, not collapsed", () => {
|
|
404
|
-
const settings = {
|
|
405
|
-
...applied(),
|
|
406
|
-
modelSettings: {
|
|
407
|
-
opus: { effortLevel: "medium" },
|
|
408
|
-
"claude-opus-4-1-stale": { effortLevel: "low" },
|
|
409
|
-
},
|
|
410
|
-
};
|
|
411
|
-
const s = computeModelsStatus({
|
|
412
|
-
config: base(),
|
|
413
|
-
errors: [],
|
|
414
|
-
hookRegistered: true,
|
|
415
|
-
settings,
|
|
416
|
-
resolveFullId: noFullId,
|
|
417
|
-
});
|
|
418
|
-
const line = s.drift.find((d) => d.startsWith("modelSettings"));
|
|
419
|
-
expect(line).toBeDefined();
|
|
420
|
-
// The stale user-scope key is the one that wins at runtime, so it has to be visible.
|
|
421
|
-
expect(line).toContain("claude-opus-4-1-stale");
|
|
422
|
-
// And the line must state a CHANGE, never two identical halves.
|
|
423
|
-
expect(line).not.toMatch(/settings has (.+), config wants \1/);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
test("only the models that actually differ are listed", () => {
|
|
427
|
-
// The control for the test above: a diff that named every key would satisfy it while
|
|
428
|
-
// burying the one that changed.
|
|
429
|
-
const s = computeModelsStatus({
|
|
430
|
-
config: base(),
|
|
431
|
-
errors: [],
|
|
432
|
-
hookRegistered: true,
|
|
433
|
-
settings: {
|
|
434
|
-
...applied(),
|
|
435
|
-
modelSettings: {
|
|
436
|
-
...(applied().modelSettings as Record<string, unknown>),
|
|
437
|
-
opus: { effortLevel: "low" },
|
|
438
|
-
},
|
|
439
|
-
},
|
|
440
|
-
resolveFullId: noFullId,
|
|
441
|
-
});
|
|
442
|
-
const line = s.drift.find((d) => d.startsWith("modelSettings"));
|
|
443
|
-
expect(line).toContain("opus low");
|
|
444
|
-
// `fable` agrees on both sides, so it has no business in a line about what differs.
|
|
445
|
-
expect(line).not.toContain("fable");
|
|
446
|
-
});
|
|
447
|
-
test("modelSettings drift is summarised, not dumped as JSON", () => {
|
|
448
|
-
// It is a map of model → effort and prints as one. Raw JSON was a 200-character wall
|
|
449
|
-
// that wrapped over four lines and told the reader nothing they could act on.
|
|
450
|
-
const s = computeModelsStatus({
|
|
451
|
-
config: base(),
|
|
452
|
-
errors: [],
|
|
453
|
-
hookRegistered: true,
|
|
454
|
-
settings: {
|
|
455
|
-
...applied(),
|
|
456
|
-
modelSettings: { opus: { effortLevel: "low" } },
|
|
457
|
-
},
|
|
458
|
-
resolveFullId: noFullId,
|
|
459
|
-
});
|
|
460
|
-
const line = s.drift.find((d) => d.startsWith("modelSettings"));
|
|
461
|
-
expect(line).toBeDefined();
|
|
462
|
-
expect(line).not.toContain("{");
|
|
463
|
-
expect(line).toContain("opus low");
|
|
465
|
+
expect(s.state).toBe("on");
|
|
466
|
+
expect(s.drift).toEqual([]);
|
|
464
467
|
});
|
|
465
468
|
|
|
466
|
-
test("
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
+
test("settings written by nobody at all is still not a fault", () => {
|
|
470
|
+
// The strongest form of the same claim, and the one the old `1 drift` badge described
|
|
471
|
+
// most misleadingly: every key absent counted as ONE message.
|
|
469
472
|
const s = computeModelsStatus({
|
|
470
473
|
config: base(),
|
|
471
474
|
errors: [],
|
|
@@ -473,23 +476,23 @@ describe("computeModelsStatus", () => {
|
|
|
473
476
|
settings: {},
|
|
474
477
|
resolveFullId: noFullId,
|
|
475
478
|
});
|
|
476
|
-
expect(s.state).toBe("
|
|
477
|
-
expect(s.drift).
|
|
478
|
-
expect(s.drift[0]).toContain("carry none of the config");
|
|
479
|
+
expect(s.state).toBe("on");
|
|
480
|
+
expect(s.drift).toEqual([]);
|
|
479
481
|
});
|
|
480
482
|
|
|
481
|
-
test("
|
|
482
|
-
// The negative control for the
|
|
483
|
-
//
|
|
483
|
+
test("drift survives for the states that ARE faults", () => {
|
|
484
|
+
// The negative control for the three tests above: `drift` was not emptied everywhere,
|
|
485
|
+
// only where it described a difference. An unhooked config routes NOTHING, and that
|
|
486
|
+
// line is the reason why.
|
|
484
487
|
const s = computeModelsStatus({
|
|
485
488
|
config: base(),
|
|
486
489
|
errors: [],
|
|
487
|
-
hookRegistered:
|
|
488
|
-
settings:
|
|
490
|
+
hookRegistered: false,
|
|
491
|
+
settings: applied(),
|
|
489
492
|
resolveFullId: noFullId,
|
|
490
493
|
});
|
|
491
|
-
expect(s.
|
|
492
|
-
expect(s.drift.
|
|
494
|
+
expect(s.state).toBe("unhooked");
|
|
495
|
+
expect(s.drift.length).toBeGreaterThan(0);
|
|
493
496
|
});
|
|
494
497
|
|
|
495
498
|
test("warns that CLAUDE_CODE_SUBAGENT_MODEL is now inert", () => {
|
|
@@ -637,4 +640,163 @@ describe("isRoutableModel", () => {
|
|
|
637
640
|
])
|
|
638
641
|
expect(isRoutableModel(bad)).toBe(false);
|
|
639
642
|
});
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* THE constraint the whole mate feature is built around.
|
|
646
|
+
*
|
|
647
|
+
* AMR-1 `bogus-model` measured 5/5 that a non-alias `model` refuses the spawn outright —
|
|
648
|
+
* the session gets a tool error and no subagent at all. A mate is a non-alias by
|
|
649
|
+
* construction, so a change that made this pass would not route agents to claudish, it
|
|
650
|
+
* would stop the agents spawning. There is no version of this that is a nice improvement.
|
|
651
|
+
*/
|
|
652
|
+
test("a mate is NEVER routable — injecting one would refuse the spawn", () => {
|
|
653
|
+
for (const mate of MATES) expect(isRoutableModel(mate)).toBe(false);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
describe("mates", () => {
|
|
658
|
+
test("isMate is exact — no prefix rule, no near match", () => {
|
|
659
|
+
for (const mate of MATES) expect(isMate(mate)).toBe(true);
|
|
660
|
+
// `mate10` is a typo, not a tenth slot; `Mate1` is a different string. Resolving
|
|
661
|
+
// either into a real slot would route an agent somewhere nobody named.
|
|
662
|
+
for (const bad of [
|
|
663
|
+
"mate",
|
|
664
|
+
"mate10",
|
|
665
|
+
"Mate1",
|
|
666
|
+
"mate3",
|
|
667
|
+
"kangaroo ",
|
|
668
|
+
"",
|
|
669
|
+
null,
|
|
670
|
+
3,
|
|
671
|
+
])
|
|
672
|
+
expect(isMate(bad)).toBe(false);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
test("`kangaroo` is mate3 under another name, so all three are one vocabulary", () => {
|
|
676
|
+
expect([...MATES]).toEqual(["mate1", "mate2", "kangaroo"]);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("a grade may name a mate, with no effort", () => {
|
|
680
|
+
const c = base();
|
|
681
|
+
(c.grades.smart as { model: string; effort?: Effort }).model = "mate1";
|
|
682
|
+
// biome-ignore lint/performance/noDelete: absence is the point — a mate carries none
|
|
683
|
+
delete (c.grades.smart as { effort?: Effort }).effort;
|
|
684
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("an agent may be pinned to a mate", () => {
|
|
688
|
+
const c = base();
|
|
689
|
+
c.agents["dev:researcher"] = { model: "kangaroo" } as never;
|
|
690
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
691
|
+
expect(resolveAgentRouting(c, "dev:researcher")).toEqual({
|
|
692
|
+
model: "kangaroo",
|
|
693
|
+
source: "agent-override",
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("main REJECTS a mate, and the message says why the main thread cannot take one", () => {
|
|
698
|
+
const c = base();
|
|
699
|
+
(c.main as { model: string }).model = "mate1";
|
|
700
|
+
const errors = validateModelsConfig(c);
|
|
701
|
+
expect(errors.map((e) => e.path)).toEqual(["main.model"]);
|
|
702
|
+
// Not a bare "must be one of …": the reason is that claudeup writes this value into
|
|
703
|
+
// settings.model, which has to name a model Claude Code itself runs.
|
|
704
|
+
expect(errors[0]?.message).toContain("settings.model");
|
|
705
|
+
expect(errors[0]?.message).toContain("claudish");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("effort on a mate is ALLOWED — the rule reversed", () => {
|
|
709
|
+
// It used to be an error, on the reasoning that effort is keyed by model and a mate
|
|
710
|
+
// owns no `modelSettings` key. The key part is still true; the conclusion was wrong. A
|
|
711
|
+
// mate's effort is a per-call value handed to claudish, not a settings key, so there
|
|
712
|
+
// is nothing for it to collide with and no reason to refuse it.
|
|
713
|
+
//
|
|
714
|
+
// What must remain true is that it never reaches `modelSettings` — pinned separately,
|
|
715
|
+
// on `buildSettingsPatch`, because that is where the damage would be.
|
|
716
|
+
const onGrade = base();
|
|
717
|
+
onGrade.grades.cheap = { model: "mate2", effort: "high" } as never;
|
|
718
|
+
expect(validateModelsConfig(onGrade)).toEqual([]);
|
|
719
|
+
|
|
720
|
+
const onAgent = base();
|
|
721
|
+
onAgent.agents["dev:docs"] = { model: "mate1", effort: "low" } as never;
|
|
722
|
+
expect(validateModelsConfig(onAgent)).toEqual([]);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test("effort on `inherit` is STILL an error — the two questions are not the same one", () => {
|
|
726
|
+
// The half of the old rule that survives. `inherit` names no model at all, so there is
|
|
727
|
+
// no key AND no call to attach a value to; a mate names one claudish resolves.
|
|
728
|
+
const onGrade = base();
|
|
729
|
+
onGrade.grades.cheap = { model: "inherit", effort: "high" } as never;
|
|
730
|
+
const gradeErrors = validateModelsConfig(onGrade);
|
|
731
|
+
expect(gradeErrors.map((e) => e.path)).toEqual(["grades.cheap.effort"]);
|
|
732
|
+
expect(gradeErrors[0]?.message).toContain("inherit names no model");
|
|
733
|
+
|
|
734
|
+
const onAgent = base();
|
|
735
|
+
onAgent.agents["dev:docs"] = { model: "inherit", effort: "low" } as never;
|
|
736
|
+
expect(validateModelsConfig(onAgent).map((e) => e.path)).toEqual([
|
|
737
|
+
"agents.dev:docs.effort",
|
|
738
|
+
]);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("two mates on the same model may declare different efforts", () => {
|
|
742
|
+
// Not a conflict, for the same reason the one-model-one-effort pass skips mates
|
|
743
|
+
// entirely: neither owns a `modelSettings` key, so neither can lose one to the other.
|
|
744
|
+
const c = base();
|
|
745
|
+
c.agents.a = { model: "mate1", effort: "low" } as never;
|
|
746
|
+
c.agents.b = { model: "mate2", effort: "xhigh" } as never;
|
|
747
|
+
c.mates = {
|
|
748
|
+
mate1: { model: "grok-4.6" },
|
|
749
|
+
mate2: { model: "grok-4.6" },
|
|
750
|
+
} as never;
|
|
751
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Two seats on one mate is not the `modelSettings` conflict two seats on one alias is.
|
|
756
|
+
*
|
|
757
|
+
* The one-model-one-effort pass exists because `modelSettings` is keyed by model and one
|
|
758
|
+
* key cannot hold two efforts. A mate owns no key at all, so there is nothing to collide
|
|
759
|
+
* — and sending several agents to the same external model is the ordinary way to use a
|
|
760
|
+
* slot, not a mistake to report.
|
|
761
|
+
*/
|
|
762
|
+
test("several agents may share one mate", () => {
|
|
763
|
+
const c = base();
|
|
764
|
+
c.agents.a = { model: "mate1" } as never;
|
|
765
|
+
c.agents.b = { model: "mate1" } as never;
|
|
766
|
+
(c.grades.cheap as { model: string; effort?: Effort }).model = "mate1";
|
|
767
|
+
// biome-ignore lint/performance/noDelete: absence is the point — a mate carries none
|
|
768
|
+
delete (c.grades.cheap as { effort?: Effort }).effort;
|
|
769
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Validity may not depend on the machine reading the file.
|
|
774
|
+
*
|
|
775
|
+
* `models.json` is committed and shared through `profiles.json`, so if `mate1` were only
|
|
776
|
+
* valid where `multimodel@magus` happens to be installed, the same file would be valid on
|
|
777
|
+
* one teammate's laptop and invalid on the next one's — and the error would land on
|
|
778
|
+
* whoever had not installed the plugin, over a routing decision they did not make.
|
|
779
|
+
*
|
|
780
|
+
* Pinned at the strongest available level: the module imports NOTHING, so there is no
|
|
781
|
+
* seam through which a machine fact could reach the validator. Whether the slots are
|
|
782
|
+
* DRAWN is a separate question, answered in the renderers from a boolean passed in.
|
|
783
|
+
*/
|
|
784
|
+
test("the core imports nothing, so validity cannot depend on the machine", () => {
|
|
785
|
+
// Comments are stripped first, the way hook-import-policy.test.ts does it: this file
|
|
786
|
+
// explains itself in English, and a sentence containing `from "…"` is not an import.
|
|
787
|
+
const specifiersOf = (file: string): string[] => {
|
|
788
|
+
const source = readFileSync(
|
|
789
|
+
path.join(import.meta.dir, "..", "services", file),
|
|
790
|
+
"utf-8",
|
|
791
|
+
)
|
|
792
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
793
|
+
.replace(/\/\/[^\n]*/g, "");
|
|
794
|
+
return [...source.matchAll(/\bfrom\s+["']([^"']+)["']/g)].map(
|
|
795
|
+
(match) => match[1] as string,
|
|
796
|
+
);
|
|
797
|
+
};
|
|
798
|
+
expect(specifiersOf("models-core.ts")).toEqual([]);
|
|
799
|
+
// Negative control: a regex that matched nothing would pass that forever.
|
|
800
|
+
expect(specifiersOf("models-manager.ts").length).toBeGreaterThan(0);
|
|
801
|
+
});
|
|
640
802
|
});
|
|
@@ -308,21 +308,24 @@ describe("readModelsStatus", () => {
|
|
|
308
308
|
expect(status.preset).toBe(PRESET);
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
-
test("
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
311
|
+
test("settings differing from the config stays `on` — that is a custom config", () => {
|
|
312
|
+
// Nothing to report. A project running a model no preset names has made a choice, and
|
|
313
|
+
// the list shows it as a `Custom` row rather than the status calling it a problem.
|
|
314
|
+
return (async () => {
|
|
315
|
+
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
316
|
+
const settingsPath = join(project, ".claude", "settings.json");
|
|
317
|
+
await fs.writeJson(settingsPath, {
|
|
318
|
+
...(await fs.readJson(settingsPath)),
|
|
319
|
+
model: "haiku",
|
|
320
|
+
});
|
|
318
321
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
+
const status = await readModelsStatus(project);
|
|
323
|
+
expect(status.state).toBe("on");
|
|
324
|
+
expect(status.drift).toEqual([]);
|
|
325
|
+
})();
|
|
322
326
|
});
|
|
323
327
|
|
|
324
|
-
//
|
|
325
|
-
// leading with a drifted effort value would bury the reason.
|
|
328
|
+
// A config with no hook routes NOTHING, which is a fault and keeps its drift line.
|
|
326
329
|
test("reports unhooked when the hook is not registered", async () => {
|
|
327
330
|
await applyModelPreset({ projectPath: project, preset: PRESET });
|
|
328
331
|
await fs.writeJson(join(configDir, "settings.json"), {});
|