claudeup 6.3.2 → 6.4.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__/cli-live.test.ts +9 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/gitignore-prerun.test.ts +6 -13
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/manifest.test.ts +134 -0
- package/src/__tests__/model-visuals.test.tsx +789 -0
- package/src/__tests__/models-adapter.test.ts +317 -0
- package/src/__tests__/models-cli.test.ts +173 -0
- package/src/__tests__/models-core.test.ts +640 -0
- package/src/__tests__/models-manager.test.ts +497 -0
- package/src/__tests__/models-screen-state.test.ts +259 -0
- package/src/__tests__/profile-materializer.test.ts +46 -0
- package/src/__tests__/resolver.test.ts +36 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/symlink-manager.test.ts +65 -1
- package/src/__tests__/tabbar-layout.test.ts +40 -2
- package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
- package/src/cli/doctor.ts +90 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/models.ts +214 -0
- package/src/cli/router.ts +12 -0
- package/src/data/gitignore-defaults.ts +4 -0
- package/src/data/models-presets.ts +281 -0
- package/src/data/predefined-profiles.ts +9 -0
- package/src/data/settings-catalog.ts +11 -4
- package/src/main.tsx +51 -82
- package/src/services/hook-registration.ts +218 -0
- package/src/services/manifest.ts +84 -0
- package/src/services/models-core.ts +628 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +11 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/styles-manager.ts +23 -45
- package/src/services/symlink-manager.ts +57 -11
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +21 -0
- package/src/types/index.ts +14 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/modelsAdapter.ts +170 -0
- package/src/ui/components/TabBar.tsx +9 -4
- package/src/ui/components/layout/FooterHints.tsx +20 -3
- package/src/ui/components/layout/ScreenLayout.tsx +87 -7
- package/src/ui/components/primitives/MetaText.tsx +27 -1
- package/src/ui/renderers/modelRenderers.tsx +1004 -0
- package/src/ui/renderers/modelVisuals.tsx +853 -0
- package/src/ui/renderers/skillRenderers.tsx +13 -3
- package/src/ui/renderers/styleRenderers.tsx +7 -3
- package/src/ui/screens/ModelsScreen.tsx +478 -0
- package/src/ui/screens/StylesScreen.tsx +8 -13
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +94 -0
- package/src/ui/state/types.ts +65 -2
- package/src/ui/theme-mode.ts +116 -0
- package/src/ui/theme.ts +26 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for the pure routing core.
|
|
3
|
+
*
|
|
4
|
+
* The behaviour asserted here is not invented: each rule cites the run in
|
|
5
|
+
* `benches/agent-model-routing/` (AMR-1) that measured it on real Claude Code, interactive,
|
|
6
|
+
* n=5. A test that drifts from those numbers is a bug in the code or a signal to re-run the
|
|
7
|
+
* bench — never a reason to relax the assertion.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
BUILT_IN_PRESETS,
|
|
12
|
+
DEFAULT_AGENT_GRADES,
|
|
13
|
+
findPreset,
|
|
14
|
+
presetNames,
|
|
15
|
+
} from "../data/models-presets.js";
|
|
16
|
+
import {
|
|
17
|
+
EFFORTS,
|
|
18
|
+
type Effort,
|
|
19
|
+
GRADES,
|
|
20
|
+
type ModelsConfig,
|
|
21
|
+
baseAlias,
|
|
22
|
+
buildSettingsPatch,
|
|
23
|
+
computeModelsStatus,
|
|
24
|
+
evaluateAgentHook,
|
|
25
|
+
isRoutableModel,
|
|
26
|
+
resolveAgentRouting,
|
|
27
|
+
validateModelsConfig,
|
|
28
|
+
} from "../services/models-core.js";
|
|
29
|
+
|
|
30
|
+
const base = (): ModelsConfig => ({
|
|
31
|
+
version: 1,
|
|
32
|
+
preset: "test",
|
|
33
|
+
main: { model: "opus", effort: "medium" },
|
|
34
|
+
grades: {
|
|
35
|
+
smart: { model: "fable", effort: "xhigh" },
|
|
36
|
+
normal: { model: "opus", effort: "medium" },
|
|
37
|
+
cheap: { model: "sonnet", effort: "low" },
|
|
38
|
+
},
|
|
39
|
+
agents: {
|
|
40
|
+
"dev:architect": "smart",
|
|
41
|
+
Explore: "cheap",
|
|
42
|
+
"seo:editor": { model: "inherit" },
|
|
43
|
+
},
|
|
44
|
+
fallback: "normal",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const noFullId = () => null;
|
|
48
|
+
|
|
49
|
+
describe("validateModelsConfig", () => {
|
|
50
|
+
test("accepts a well-formed config", () => {
|
|
51
|
+
expect(validateModelsConfig(base())).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("every built-in preset validates", () => {
|
|
55
|
+
for (const p of BUILT_IN_PRESETS) {
|
|
56
|
+
expect({ preset: p.preset, errors: validateModelsConfig(p) }).toEqual({
|
|
57
|
+
preset: p.preset,
|
|
58
|
+
errors: [],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects a full model id, and says why", () => {
|
|
64
|
+
const c = base();
|
|
65
|
+
(c.main as { model: string }).model = "claude-opus-5";
|
|
66
|
+
const errors = validateModelsConfig(c);
|
|
67
|
+
expect(errors).toHaveLength(1);
|
|
68
|
+
expect(errors[0]?.path).toBe("main.model");
|
|
69
|
+
expect(errors[0]?.message).toContain("go stale");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("rejects a non-alias on a grade — AMR-1 bogus-model: it refuses the spawn outright", () => {
|
|
73
|
+
const c = base();
|
|
74
|
+
(c.grades.cheap as { model: string }).model = "kimi-k2-thinking";
|
|
75
|
+
const errors = validateModelsConfig(c);
|
|
76
|
+
expect(errors.map((e) => e.path)).toEqual(["grades.cheap.model"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("allows [1m] on main but not on a grade", () => {
|
|
80
|
+
const ok = base();
|
|
81
|
+
(ok.main as { model: string }).model = "opus[1m]";
|
|
82
|
+
expect(validateModelsConfig(ok)).toEqual([]);
|
|
83
|
+
|
|
84
|
+
const bad = base();
|
|
85
|
+
(bad.grades.smart as { model: string }).model = "opus[1m]";
|
|
86
|
+
expect(validateModelsConfig(bad).map((e) => e.path)).toEqual([
|
|
87
|
+
"grades.smart.model",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("rejects `inherit` on main — the main thread needs a real model", () => {
|
|
92
|
+
const c = base();
|
|
93
|
+
(c.main as { model: string }).model = "inherit";
|
|
94
|
+
expect(validateModelsConfig(c)[0]?.path).toBe("main.model");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("rejects two efforts for one model, naming both sites", () => {
|
|
98
|
+
// modelSettings is keyed by MODEL, so one of the two would silently lose.
|
|
99
|
+
const c = base();
|
|
100
|
+
c.grades.normal = { model: "opus", effort: "xhigh" }; // main already claims opus/medium
|
|
101
|
+
const errors = validateModelsConfig(c);
|
|
102
|
+
expect(errors).toHaveLength(1);
|
|
103
|
+
expect(errors[0]?.path).toBe("grades.normal");
|
|
104
|
+
expect(errors[0]?.message).toContain("main");
|
|
105
|
+
expect(errors[0]?.message).toContain("medium");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the same model with the SAME effort twice is fine", () => {
|
|
109
|
+
const c = base();
|
|
110
|
+
c.grades.normal = { model: "opus", effort: "medium" };
|
|
111
|
+
expect(validateModelsConfig(c)).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("rejects effort on `inherit`, which names no model to key it by", () => {
|
|
115
|
+
const c = base();
|
|
116
|
+
c.agents["seo:editor"] = { model: "inherit", effort: "low" } as never;
|
|
117
|
+
expect(validateModelsConfig(c).map((e) => e.path)).toEqual([
|
|
118
|
+
"agents.seo:editor.effort",
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("rejects an unknown grade name in agents", () => {
|
|
123
|
+
const c = base();
|
|
124
|
+
c.agents.whatever = "turbo" as never;
|
|
125
|
+
expect(validateModelsConfig(c).map((e) => e.path)).toEqual([
|
|
126
|
+
"agents.whatever",
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("a wrong version stops immediately rather than emitting noise", () => {
|
|
131
|
+
const errors = validateModelsConfig({ version: 2, main: {}, grades: {} });
|
|
132
|
+
expect(errors).toHaveLength(1);
|
|
133
|
+
expect(errors[0]?.path).toBe("version");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a non-object is one clear error", () => {
|
|
137
|
+
expect(validateModelsConfig("nope")).toEqual([
|
|
138
|
+
{ path: "", message: "must be a JSON object" },
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("resolveAgentRouting", () => {
|
|
144
|
+
test("an explicit agent grade wins", () => {
|
|
145
|
+
expect(resolveAgentRouting(base(), "dev:architect")).toEqual({
|
|
146
|
+
model: "fable",
|
|
147
|
+
effort: "xhigh",
|
|
148
|
+
source: "agent-grade",
|
|
149
|
+
grade: "smart",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a per-agent override beats the grade table", () => {
|
|
154
|
+
expect(resolveAgentRouting(base(), "seo:editor")).toEqual({
|
|
155
|
+
model: "inherit",
|
|
156
|
+
source: "agent-override",
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("an unknown agent falls to the fallback grade, so a new plugin routes on day one", () => {
|
|
161
|
+
expect(resolveAgentRouting(base(), "brand-new:agent")).toEqual({
|
|
162
|
+
model: "opus",
|
|
163
|
+
effort: "medium",
|
|
164
|
+
source: "default-grade",
|
|
165
|
+
grade: "normal",
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a missing subagent_type still resolves", () => {
|
|
170
|
+
expect(resolveAgentRouting(base(), undefined).source).toBe("default-grade");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("evaluateAgentHook", () => {
|
|
175
|
+
const agentCall = (input: Record<string, unknown> = {}) => ({
|
|
176
|
+
tool_name: "Agent",
|
|
177
|
+
tool_input: { subagent_type: "dev:architect", prompt: "go", ...input },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("rewrites the model and carries the whole input through", () => {
|
|
181
|
+
const d = evaluateAgentHook(agentCall(), base());
|
|
182
|
+
expect(d.kind).toBe("update");
|
|
183
|
+
if (d.kind !== "update") return;
|
|
184
|
+
expect(d.output.hookSpecificOutput.updatedInput).toEqual({
|
|
185
|
+
subagent_type: "dev:architect",
|
|
186
|
+
prompt: "go",
|
|
187
|
+
model: "fable",
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("ALWAYS emits hookEventName — AMR-1 hook-minimal fired 5/5 and was ignored without it", () => {
|
|
192
|
+
const d = evaluateAgentHook(agentCall(), base());
|
|
193
|
+
if (d.kind !== "update") throw new Error("expected an update");
|
|
194
|
+
expect(d.output.hookSpecificOutput.hookEventName).toBe("PreToolUse");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("never emits permissionDecision — measured neither required nor harmful, so it only widens permission", () => {
|
|
198
|
+
const d = evaluateAgentHook(agentCall(), base());
|
|
199
|
+
if (d.kind !== "update") throw new Error("expected an update");
|
|
200
|
+
expect(Object.keys(d.output.hookSpecificOutput).sort()).toEqual([
|
|
201
|
+
"hookEventName",
|
|
202
|
+
"updatedInput",
|
|
203
|
+
]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("passes through a tool that is not Agent", () => {
|
|
207
|
+
expect(
|
|
208
|
+
evaluateAgentHook({ tool_name: "Bash", tool_input: {} }, base()),
|
|
209
|
+
).toEqual({
|
|
210
|
+
kind: "passthrough",
|
|
211
|
+
reason: "not-agent",
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("passes through with no config — fail open", () => {
|
|
216
|
+
expect(evaluateAgentHook(agentCall(), null)).toEqual({
|
|
217
|
+
kind: "passthrough",
|
|
218
|
+
reason: "no-config",
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("respects a model the caller already chose", () => {
|
|
223
|
+
expect(evaluateAgentHook(agentCall({ model: "haiku" }), base())).toEqual({
|
|
224
|
+
kind: "passthrough",
|
|
225
|
+
reason: "explicit-model",
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("`inherit` is the opt-out and touches nothing", () => {
|
|
230
|
+
expect(
|
|
231
|
+
evaluateAgentHook(agentCall({ subagent_type: "seo:editor" }), base()),
|
|
232
|
+
).toEqual({
|
|
233
|
+
kind: "passthrough",
|
|
234
|
+
reason: "inherit",
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("refuses to inject an unroutable value even if a hand-edited config carries one", () => {
|
|
239
|
+
// AMR-1 bogus-model: injecting a non-alias refuses the spawn entirely, so passing
|
|
240
|
+
// through costs the routing while injecting costs the user every subagent.
|
|
241
|
+
const c = base();
|
|
242
|
+
(c.grades.smart as { model: string }).model = "kimi-k2-thinking";
|
|
243
|
+
expect(evaluateAgentHook(agentCall(), c)).toEqual({
|
|
244
|
+
kind: "passthrough",
|
|
245
|
+
reason: "unroutable-model",
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("an empty model string is not treated as a caller's choice", () => {
|
|
250
|
+
const d = evaluateAgentHook(agentCall({ model: "" }), base());
|
|
251
|
+
expect(d.kind).toBe("update");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("buildSettingsPatch", () => {
|
|
256
|
+
test("writes main model and effort", () => {
|
|
257
|
+
const { set } = buildSettingsPatch(base(), noFullId);
|
|
258
|
+
expect(set.model).toBe("opus");
|
|
259
|
+
expect(set.effortLevel).toBe("medium");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("writes BOTH key forms with the same value — AMR-1 effort-conflict: the full id wins", () => {
|
|
263
|
+
const resolve = (a: string) =>
|
|
264
|
+
a === "fable" ? "claude-fable-5-1" : `claude-${a}-5`;
|
|
265
|
+
const { set } = buildSettingsPatch(base(), resolve as never);
|
|
266
|
+
expect(set.modelSettings).toEqual({
|
|
267
|
+
opus: { effortLevel: "medium" },
|
|
268
|
+
"claude-opus-5": { effortLevel: "medium" },
|
|
269
|
+
fable: { effortLevel: "xhigh" },
|
|
270
|
+
"claude-fable-5-1": { effortLevel: "xhigh" },
|
|
271
|
+
sonnet: { effortLevel: "low" },
|
|
272
|
+
"claude-sonnet-5": { effortLevel: "low" },
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("a resolver that cannot answer leaves the alias key alone rather than inventing an id", () => {
|
|
277
|
+
const { set } = buildSettingsPatch(base(), noFullId);
|
|
278
|
+
expect(set.modelSettings).toEqual({
|
|
279
|
+
opus: { effortLevel: "medium" },
|
|
280
|
+
fable: { effortLevel: "xhigh" },
|
|
281
|
+
sonnet: { effortLevel: "low" },
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("omits modelSettings entirely when no effort is declared", () => {
|
|
286
|
+
const c = base();
|
|
287
|
+
c.main = { model: "opus" };
|
|
288
|
+
c.grades = {
|
|
289
|
+
smart: { model: "fable" },
|
|
290
|
+
normal: { model: "opus" },
|
|
291
|
+
cheap: { model: "sonnet" },
|
|
292
|
+
};
|
|
293
|
+
c.agents = {};
|
|
294
|
+
const { set } = buildSettingsPatch(c, noFullId);
|
|
295
|
+
expect(set.modelSettings).toBeUndefined();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a [1m] main model keys its effort by the base alias", () => {
|
|
299
|
+
const c = base();
|
|
300
|
+
(c.main as { model: string }).model = "opus[1m]";
|
|
301
|
+
const { set } = buildSettingsPatch(c, noFullId);
|
|
302
|
+
expect(set.model).toBe("opus[1m]");
|
|
303
|
+
expect((set.modelSettings as Record<string, unknown>).opus).toEqual({
|
|
304
|
+
effortLevel: "medium",
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("computeModelsStatus", () => {
|
|
310
|
+
const applied = () => buildSettingsPatch(base(), noFullId).set;
|
|
311
|
+
|
|
312
|
+
test("off when there is no config", () => {
|
|
313
|
+
expect(
|
|
314
|
+
computeModelsStatus({
|
|
315
|
+
config: null,
|
|
316
|
+
errors: [],
|
|
317
|
+
hookRegistered: true,
|
|
318
|
+
settings: {},
|
|
319
|
+
resolveFullId: noFullId,
|
|
320
|
+
}).state,
|
|
321
|
+
).toBe("off");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("invalid reports the validation errors as drift", () => {
|
|
325
|
+
const s = computeModelsStatus({
|
|
326
|
+
config: null,
|
|
327
|
+
errors: [{ path: "main.model", message: "bad" }],
|
|
328
|
+
hookRegistered: true,
|
|
329
|
+
settings: {},
|
|
330
|
+
resolveFullId: noFullId,
|
|
331
|
+
});
|
|
332
|
+
expect(s.state).toBe("invalid");
|
|
333
|
+
expect(s.drift[0]).toContain("main.model");
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("unhooked outranks stale — nothing routes, so that is the headline", () => {
|
|
337
|
+
const s = computeModelsStatus({
|
|
338
|
+
config: base(),
|
|
339
|
+
errors: [],
|
|
340
|
+
hookRegistered: false,
|
|
341
|
+
settings: {},
|
|
342
|
+
resolveFullId: noFullId,
|
|
343
|
+
});
|
|
344
|
+
expect(s.state).toBe("unhooked");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("applied when settings already match", () => {
|
|
348
|
+
const s = computeModelsStatus({
|
|
349
|
+
config: base(),
|
|
350
|
+
errors: [],
|
|
351
|
+
hookRegistered: true,
|
|
352
|
+
settings: applied(),
|
|
353
|
+
resolveFullId: noFullId,
|
|
354
|
+
});
|
|
355
|
+
expect(s.state).toBe("on");
|
|
356
|
+
expect(s.drift).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("stale names the key that differs", () => {
|
|
360
|
+
const s = computeModelsStatus({
|
|
361
|
+
config: base(),
|
|
362
|
+
errors: [],
|
|
363
|
+
hookRegistered: true,
|
|
364
|
+
settings: { ...applied(), effortLevel: "high" },
|
|
365
|
+
resolveFullId: noFullId,
|
|
366
|
+
});
|
|
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");
|
|
464
|
+
});
|
|
465
|
+
|
|
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
|
+
const s = computeModelsStatus({
|
|
470
|
+
config: base(),
|
|
471
|
+
errors: [],
|
|
472
|
+
hookRegistered: true,
|
|
473
|
+
settings: {},
|
|
474
|
+
resolveFullId: noFullId,
|
|
475
|
+
});
|
|
476
|
+
expect(s.state).toBe("stale");
|
|
477
|
+
expect(s.drift).toHaveLength(1);
|
|
478
|
+
expect(s.drift[0]).toContain("carry none of the config");
|
|
479
|
+
});
|
|
480
|
+
|
|
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.
|
|
484
|
+
const s = computeModelsStatus({
|
|
485
|
+
config: base(),
|
|
486
|
+
errors: [],
|
|
487
|
+
hookRegistered: true,
|
|
488
|
+
settings: { model: "opus" },
|
|
489
|
+
resolveFullId: noFullId,
|
|
490
|
+
});
|
|
491
|
+
expect(s.drift.length).toBeGreaterThan(1);
|
|
492
|
+
expect(s.drift.join(" ")).toContain("effortLevel");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("warns that CLAUDE_CODE_SUBAGENT_MODEL is now inert", () => {
|
|
496
|
+
const s = computeModelsStatus({
|
|
497
|
+
config: base(),
|
|
498
|
+
errors: [],
|
|
499
|
+
hookRegistered: true,
|
|
500
|
+
settings: applied(),
|
|
501
|
+
resolveFullId: noFullId,
|
|
502
|
+
subagentModelEnv: "claude-haiku-4-5",
|
|
503
|
+
});
|
|
504
|
+
expect(s.warnings.join(" ")).toContain("CLAUDE_CODE_SUBAGENT_MODEL");
|
|
505
|
+
expect(s.state).toBe("on");
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("presets", () => {
|
|
510
|
+
/**
|
|
511
|
+
* The first request shape still ships verbatim: fable on the orchestrator at `medium`.
|
|
512
|
+
*
|
|
513
|
+
* The second — "fable main, opus for other tasks, sonnet for quick tasks" — does NOT,
|
|
514
|
+
* because the revised preset spec took opus out of `fable-lead` entirely and made it
|
|
515
|
+
* fable in every seat but `cheap`. That is a deliberate change to what the preset means,
|
|
516
|
+
* not a regression, and it is asserted here so the removal stays visible rather than
|
|
517
|
+
* being discovered later as a surprise.
|
|
518
|
+
*/
|
|
519
|
+
test("fable-lead puts fable on the orchestrator at medium", () => {
|
|
520
|
+
const fable = findPreset("fable-lead");
|
|
521
|
+
expect(fable?.main).toEqual({ model: "fable", effort: "medium" });
|
|
522
|
+
// opus is gone from this preset; the only non-fable seat is `cheap`.
|
|
523
|
+
expect(
|
|
524
|
+
GRADES.map((grade) => baseAlias(fable?.grades[grade].model ?? "")),
|
|
525
|
+
).toEqual(["fable", "fable", "sonnet"]);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The orchestrator never gets the most spend.
|
|
530
|
+
*
|
|
531
|
+
* This replaced a test that ranked the models against each other — `haiku < sonnet <
|
|
532
|
+
* opus < fable` — and asserted `smart` held the top-ranked one. That is a CAPABILITY
|
|
533
|
+
* claim, it was written from memory, and it went stale the moment the presets started
|
|
534
|
+
* treating fable as the fast model rather than the strongest. Effort is the axis this
|
|
535
|
+
* config actually controls, so the principle is stated in effort: the thread that
|
|
536
|
+
* dispatches the work is never strictly the biggest spend in the preset.
|
|
537
|
+
*
|
|
538
|
+
* A tie is allowed. `sonnet-economy` runs `main` and `smart` both at `xhigh`, because
|
|
539
|
+
* its saving is the MODEL rather than a throttled effort on top of one.
|
|
540
|
+
*/
|
|
541
|
+
test("no preset spends more on the orchestrator than on the work it dispatches", () => {
|
|
542
|
+
const level = (effort: string | undefined) =>
|
|
543
|
+
effort ? EFFORTS.indexOf(effort as Effort) : -1;
|
|
544
|
+
for (const p of BUILT_IN_PRESETS) {
|
|
545
|
+
const highestTier = Math.max(
|
|
546
|
+
...Object.values(p.grades).map((g) => level(g.effort)),
|
|
547
|
+
);
|
|
548
|
+
expect({
|
|
549
|
+
preset: p.preset,
|
|
550
|
+
mainIsNotTheBiggestSpend: level(p.main.effort) <= highestTier,
|
|
551
|
+
}).toEqual({ preset: p.preset, mainIsNotTheBiggestSpend: true });
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* One model, one effort — the only cross-seat rule there is.
|
|
557
|
+
*
|
|
558
|
+
* `modelSettings` is keyed by model, so a preset naming one model in two seats at two
|
|
559
|
+
* efforts cannot be written to settings at all: one value silently wins. This replaced a
|
|
560
|
+
* test that fixed each model's effort across EVERY preset (`fable` always `medium`, and
|
|
561
|
+
* so on). That was a convention invented to satisfy this constraint, not the constraint
|
|
562
|
+
* itself, and it blocked legitimate presets — `fable-lead` and `fable-top` differ by
|
|
563
|
+
* nothing except fable's effort, which is the whole point of having both.
|
|
564
|
+
*
|
|
565
|
+
* Presets never collide with each other; only one is applied to a project at a time.
|
|
566
|
+
*/
|
|
567
|
+
test("within one preset, a model appears at exactly one effort", () => {
|
|
568
|
+
for (const p of BUILT_IN_PRESETS) {
|
|
569
|
+
const efforts = new Map<string, Set<string>>();
|
|
570
|
+
const seats = [
|
|
571
|
+
{ seat: "main", model: p.main.model, effort: p.main.effort },
|
|
572
|
+
...GRADES.map((grade) => ({
|
|
573
|
+
seat: grade,
|
|
574
|
+
model: p.grades[grade].model,
|
|
575
|
+
effort: p.grades[grade].effort,
|
|
576
|
+
})),
|
|
577
|
+
];
|
|
578
|
+
for (const { model, effort } of seats) {
|
|
579
|
+
const alias = baseAlias(model);
|
|
580
|
+
if (alias === "inherit" || effort === undefined) continue;
|
|
581
|
+
const seen = efforts.get(alias) ?? new Set<string>();
|
|
582
|
+
seen.add(effort);
|
|
583
|
+
efforts.set(alias, seen);
|
|
584
|
+
}
|
|
585
|
+
for (const [alias, seen] of efforts) {
|
|
586
|
+
expect({ preset: p.preset, model: alias, efforts: [...seen] }).toEqual({
|
|
587
|
+
preset: p.preset,
|
|
588
|
+
model: alias,
|
|
589
|
+
efforts: [...seen].slice(0, 1),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("no preset uses haiku", () => {
|
|
596
|
+
// Excluded deliberately, not by oversight — asserted so re-adding it is a decision.
|
|
597
|
+
for (const p of BUILT_IN_PRESETS) {
|
|
598
|
+
const models = [
|
|
599
|
+
p.main.model,
|
|
600
|
+
...GRADES.map((grade) => p.grades[grade].model),
|
|
601
|
+
].map(baseAlias);
|
|
602
|
+
expect({ preset: p.preset, usesHaiku: models.includes("haiku") }).toEqual(
|
|
603
|
+
{
|
|
604
|
+
preset: p.preset,
|
|
605
|
+
usesHaiku: false,
|
|
606
|
+
},
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("preset names are unique", () => {
|
|
612
|
+
expect(new Set(presetNames()).size).toBe(presetNames().length);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("the shared agent table only names real grades", () => {
|
|
616
|
+
for (const [agent, grade] of Object.entries(DEFAULT_AGENT_GRADES)) {
|
|
617
|
+
expect({
|
|
618
|
+
agent,
|
|
619
|
+
ok: ["smart", "normal", "cheap"].includes(grade),
|
|
620
|
+
}).toEqual({ agent, ok: true });
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe("isRoutableModel", () => {
|
|
626
|
+
test("accepts the four aliases and nothing else", () => {
|
|
627
|
+
for (const a of ["opus", "sonnet", "haiku", "fable"])
|
|
628
|
+
expect(isRoutableModel(a)).toBe(true);
|
|
629
|
+
for (const bad of [
|
|
630
|
+
"inherit",
|
|
631
|
+
"claude-opus-5",
|
|
632
|
+
"opus[1m]",
|
|
633
|
+
"",
|
|
634
|
+
"kimi-k2-thinking",
|
|
635
|
+
null,
|
|
636
|
+
5,
|
|
637
|
+
])
|
|
638
|
+
expect(isRoutableModel(bad)).toBe(false);
|
|
639
|
+
});
|
|
640
|
+
});
|