claudeup 6.6.0 → 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.
Files changed (31) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/conventions-manager.test.ts +45 -45
  3. package/src/__tests__/enabled-not-installed.test.ts +1 -1
  4. package/src/__tests__/mate-availability.test.ts +156 -0
  5. package/src/__tests__/mate-catalog.test.ts +295 -0
  6. package/src/__tests__/model-visuals.test.tsx +1698 -25
  7. package/src/__tests__/models-adapter.test.ts +21 -6
  8. package/src/__tests__/models-cli.test.ts +100 -0
  9. package/src/__tests__/models-core.test.ts +273 -111
  10. package/src/__tests__/models-manager.test.ts +15 -12
  11. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  12. package/src/__tests__/models-screen-state.test.ts +57 -1
  13. package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
  14. package/src/__tests__/resolver.test.ts +3 -3
  15. package/src/cli/doctor.ts +8 -13
  16. package/src/cli/models.ts +97 -13
  17. package/src/data/models-presets.ts +72 -13
  18. package/src/data/predefined-profiles.ts +7 -7
  19. package/src/services/claude-settings.ts +1 -1
  20. package/src/services/community-styles.ts +1 -1
  21. package/src/services/mate-availability.ts +133 -0
  22. package/src/services/mate-catalog.ts +265 -0
  23. package/src/services/models-core.ts +371 -30
  24. package/src/ui/adapters/modelsAdapter.ts +58 -15
  25. package/src/ui/components/layout/ScreenLayout.tsx +6 -1
  26. package/src/ui/renderers/modelRenderers.tsx +413 -108
  27. package/src/ui/renderers/modelVisuals.tsx +694 -145
  28. package/src/ui/screens/ModelsScreen.tsx +74 -10
  29. package/src/ui/state/reducer.ts +20 -0
  30. package/src/ui/state/types.ts +31 -0
  31. 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 hand-edited config gets a row of its own, marked active", () => {
165
- // Without it the list shows four built-ins with none marked, which reads
166
- // as "routing is off" for a project whose routing is emphatically on.
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?.config.preset).toBe("custom");
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 shows what it actually routes.
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("stale names the key that differs", () => {
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("stale");
368
- expect(s.drift.join(" ")).toContain("effortLevel");
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("nothing written at all is ONE line, not one per key", () => {
467
- // Three lines that all say "absent" read as three distinct problems. The wording states
468
- // effect, not history an unparseable settings.json also reads as all-absent here.
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("stale");
477
- expect(s.drift).toHaveLength(1);
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("but a partial write still names each key that differs", () => {
482
- // The negative control for the collapse above: without it, a status that ALWAYS
483
- // returned one line would pass that test and hide every real per-key difference.
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: true,
488
- settings: { model: "opus" },
490
+ hookRegistered: false,
491
+ settings: applied(),
489
492
  resolveFullId: noFullId,
490
493
  });
491
- expect(s.drift.length).toBeGreaterThan(1);
492
- expect(s.drift.join(" ")).toContain("effortLevel");
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("reports stale when settings drift from the config", async () => {
312
- await applyModelPreset({ projectPath: project, preset: PRESET });
313
- const settingsPath = join(project, ".claude", "settings.json");
314
- await fs.writeJson(settingsPath, {
315
- ...(await fs.readJson(settingsPath)),
316
- model: "haiku",
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
- const status = await readModelsStatus(project);
320
- expect(status.state).toBe("stale");
321
- expect(status.drift.join(" ")).toContain("model");
322
+ const status = await readModelsStatus(project);
323
+ expect(status.state).toBe("on");
324
+ expect(status.drift).toEqual([]);
325
+ })();
322
326
  });
323
327
 
324
- // `unhooked` outranks `stale`: a config with no hook routes NOTHING, so
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"), {});