claudeup 6.3.2 → 6.5.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 (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-notice.test.ts +3 -3
  3. package/src/__tests__/cli-live.test.ts +9 -2
  4. package/src/__tests__/cli-update-view.test.ts +2 -2
  5. package/src/__tests__/footer-hints.test.ts +40 -0
  6. package/src/__tests__/gap-fill-versions.test.ts +24 -24
  7. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  8. package/src/__tests__/hook-import-policy.test.ts +90 -0
  9. package/src/__tests__/hook-process.test.ts +256 -0
  10. package/src/__tests__/hook-registration.test.ts +224 -0
  11. package/src/__tests__/manifest.test.ts +134 -0
  12. package/src/__tests__/marketplace-badge.test.ts +1 -1
  13. package/src/__tests__/marketplaces.test.ts +0 -1
  14. package/src/__tests__/model-visuals.test.tsx +793 -0
  15. package/src/__tests__/models-adapter.test.ts +317 -0
  16. package/src/__tests__/models-cli.test.ts +173 -0
  17. package/src/__tests__/models-core.test.ts +640 -0
  18. package/src/__tests__/models-manager.test.ts +497 -0
  19. package/src/__tests__/models-screen-state.test.ts +259 -0
  20. package/src/__tests__/moved-marketplace.test.ts +7 -8
  21. package/src/__tests__/plugin-contents.test.ts +1 -1
  22. package/src/__tests__/profile-adopt.test.ts +1 -1
  23. package/src/__tests__/profile-materializer.test.ts +48 -2
  24. package/src/__tests__/resolver.test.ts +43 -6
  25. package/src/__tests__/settings-file.test.ts +179 -0
  26. package/src/__tests__/symlink-manager.test.ts +65 -1
  27. package/src/__tests__/tabbar-layout.test.ts +40 -2
  28. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  29. package/src/__tests__/version-snapshot.test.ts +4 -4
  30. package/src/cli/doctor.ts +90 -0
  31. package/src/cli/hook.ts +129 -0
  32. package/src/cli/models.ts +214 -0
  33. package/src/cli/router.ts +12 -0
  34. package/src/data/gitignore-defaults.ts +4 -1
  35. package/src/data/gitignore-reasons.ts +0 -4
  36. package/src/data/marketplaces.ts +1 -15
  37. package/src/data/models-presets.ts +270 -0
  38. package/src/data/predefined-profiles.ts +12 -21
  39. package/src/data/settings-catalog.ts +11 -4
  40. package/src/main.tsx +51 -82
  41. package/src/services/hook-registration.ts +218 -0
  42. package/src/services/manifest.ts +84 -0
  43. package/src/services/models-core.ts +628 -0
  44. package/src/services/models-manager.ts +606 -0
  45. package/src/services/plugin-manager.ts +2 -3
  46. package/src/services/profile-materializer.ts +17 -0
  47. package/src/services/resolver.ts +13 -2
  48. package/src/services/settings-file.ts +69 -0
  49. package/src/services/styles-manager.ts +23 -45
  50. package/src/services/symlink-manager.ts +57 -11
  51. package/src/tui.tsx +112 -0
  52. package/src/types/bun.d.ts +21 -0
  53. package/src/types/index.ts +14 -0
  54. package/src/ui/App.tsx +15 -3
  55. package/src/ui/adapters/modelsAdapter.ts +170 -0
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/TabBar.tsx +9 -4
  58. package/src/ui/components/layout/FooterHints.tsx +20 -3
  59. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  60. package/src/ui/components/primitives/MetaText.tsx +27 -1
  61. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  62. package/src/ui/renderers/modelVisuals.tsx +853 -0
  63. package/src/ui/renderers/skillRenderers.tsx +13 -3
  64. package/src/ui/renderers/styleRenderers.tsx +7 -3
  65. package/src/ui/screens/ModelsScreen.tsx +478 -0
  66. package/src/ui/screens/PluginsScreen.tsx +1 -1
  67. package/src/ui/screens/StylesScreen.tsx +8 -13
  68. package/src/ui/screens/index.ts +1 -0
  69. package/src/ui/state/reducer.ts +94 -0
  70. package/src/ui/state/types.ts +65 -2
  71. package/src/ui/theme-mode.ts +116 -0
  72. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,497 @@
1
+ /**
2
+ * models-manager — the filesystem half of per-subagent model routing.
3
+ *
4
+ * Every test works in a temp project AND a temp Claude config dir. The config
5
+ * dir matters: `applyModelPreset` registers the hook at user scope, so a test
6
+ * that did not isolate `CLAUDE_CONFIG_DIR` would write into the operator's real
7
+ * `~/.claude/settings.json`. `hook-registration.ts` refuses to resolve a path
8
+ * under `bun test` without it, so forgetting is loud rather than damaging.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
+ import { mkdtemp, rm } from "node:fs/promises";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import fs from "fs-extra";
15
+ import { findPreset } from "../data/models-presets.js";
16
+ import { getManifestPath, writeManifest } from "../services/manifest.js";
17
+ import type { ModelsConfig } from "../services/models-core.js";
18
+ import {
19
+ applyModelPreset,
20
+ clearModels,
21
+ configPath,
22
+ readModelsConfig,
23
+ readModelsStatus,
24
+ resolveFullModelId,
25
+ } from "../services/models-manager.js";
26
+ import type { ProfileManifest } from "../types/index.js";
27
+
28
+ let project: string;
29
+ let configDir: string;
30
+ let previousConfigDir: string | undefined;
31
+
32
+ beforeEach(async () => {
33
+ project = await mkdtemp(join(tmpdir(), "models-project-"));
34
+ configDir = await mkdtemp(join(tmpdir(), "models-config-"));
35
+ previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
36
+ process.env.CLAUDE_CONFIG_DIR = configDir;
37
+ });
38
+
39
+ afterEach(async () => {
40
+ // biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
41
+ if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
42
+ else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
43
+ await rm(project, { recursive: true, force: true });
44
+ await rm(configDir, { recursive: true, force: true });
45
+ });
46
+
47
+ const PRESET = "fable-advisor";
48
+
49
+ function manifestWith(profile: string, extra = {}): ProfileManifest {
50
+ return {
51
+ version: 2,
52
+ profiles: { [profile]: { name: profile, ...extra } },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Stage a project that already has a profile ACTIVE, the way `claudeup install`
58
+ * leaves one: a materialized dir and `.claude/settings.json` symlinked into it.
59
+ * Built by hand rather than by running install, which needs the claude CLI.
60
+ */
61
+ async function stageActiveProfile(
62
+ name: string,
63
+ entry: Record<string, unknown> = {},
64
+ settings: Record<string, unknown> = {},
65
+ ): Promise<void> {
66
+ await writeManifest(manifestWith(name, entry), project);
67
+ const dir = join(project, ".claude", "_profiles", name);
68
+ await fs.outputJson(join(dir, "settings.json"), settings, { spaces: 2 });
69
+ await fs.ensureDir(join(project, ".claude"));
70
+ await fs.symlink(
71
+ join("_profiles", name, "settings.json"),
72
+ join(project, ".claude", "settings.json"),
73
+ );
74
+ }
75
+
76
+ async function readSettings(): Promise<Record<string, unknown>> {
77
+ return fs.readJson(join(project, ".claude", "settings.json"));
78
+ }
79
+
80
+ describe("applyModelPreset", () => {
81
+ test("writes the config, the settings, and the manifest entry", async () => {
82
+ await stageActiveProfile("team");
83
+
84
+ const result = await applyModelPreset({
85
+ projectPath: project,
86
+ preset: PRESET,
87
+ });
88
+
89
+ expect(result.profile).toBe("team");
90
+ expect(result.recordedInManifest).toBe(true);
91
+
92
+ // The generated config the hook reads.
93
+ const written = await fs.readJson(configPath(project));
94
+ expect(written.preset).toBe(PRESET);
95
+ expect(written.grades.smart.model).toBe("fable");
96
+
97
+ // The settings the main thread reads.
98
+ const settings = await readSettings();
99
+ expect(settings.model).toBe("opus");
100
+ expect(settings.effortLevel).toBe("xhigh");
101
+ expect((settings.modelSettings as Record<string, unknown>).fable).toEqual({
102
+ effortLevel: "medium",
103
+ });
104
+
105
+ // The committed record — without this, the next `claudeup install`
106
+ // rewrites settings.json from the manifest and reverts all of the above.
107
+ const manifest = await fs.readJson(getManifestPath(project));
108
+ expect(manifest.profiles.team.models.preset).toBe(PRESET);
109
+ expect(manifest.profiles.team.settings.model).toBe("opus");
110
+ });
111
+
112
+ test("registers the agent-model hook at user scope", async () => {
113
+ await stageActiveProfile("team");
114
+ const result = await applyModelPreset({
115
+ projectPath: project,
116
+ preset: PRESET,
117
+ });
118
+ expect(result.hook).toBe("registered");
119
+
120
+ const userSettings = await fs.readJson(join(configDir, "settings.json"));
121
+ expect(userSettings.hooks.PreToolUse[0].matcher).toBe("Agent");
122
+ expect(userSettings.hooks.PreToolUse[0].hooks[0].command).toBe(
123
+ "claudeup hook agent-model",
124
+ );
125
+
126
+ // Second apply finds it already there.
127
+ const again = await applyModelPreset({
128
+ projectPath: project,
129
+ preset: PRESET,
130
+ });
131
+ expect(again.hook).toBe("already");
132
+ });
133
+
134
+ // The symlink is how the active profile is wired. `updateSettingsFile` writes
135
+ // through it on purpose; a remove-then-create would silently detach the
136
+ // project from its profile and leave the next read looking at a fresh file.
137
+ test("keeps the profile symlink and updates profiles.json under it", async () => {
138
+ await stageActiveProfile("team", { plugins: { "dev@magus": "latest" } });
139
+
140
+ await applyModelPreset({ projectPath: project, preset: PRESET });
141
+
142
+ const link = join(project, ".claude", "settings.json");
143
+ expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
144
+ expect(await fs.readlink(link)).toBe(
145
+ join("_profiles", "team", "settings.json"),
146
+ );
147
+
148
+ // The write landed in the profile's copy, and re-materializing did not
149
+ // cost the profile the plugins its manifest entry declares.
150
+ const inProfile = await fs.readJson(
151
+ join(project, ".claude", "_profiles", "team", "settings.json"),
152
+ );
153
+ expect(inProfile.model).toBe("opus");
154
+ expect(inProfile.enabledPlugins["dev@magus"]).toBe(true);
155
+
156
+ // And .claude/models.json is a link into the same profile dir, not a copy.
157
+ const modelsLink = join(project, ".claude", "models.json");
158
+ expect((await fs.lstat(modelsLink)).isSymbolicLink()).toBe(true);
159
+ expect(await fs.readlink(modelsLink)).toBe(
160
+ join("_profiles", "team", "models.json"),
161
+ );
162
+ });
163
+
164
+ test("without a profile it writes a plain models.json and records nothing", async () => {
165
+ const result = await applyModelPreset({
166
+ projectPath: project,
167
+ preset: PRESET,
168
+ });
169
+
170
+ expect(result.profile).toBeNull();
171
+ expect(result.recordedInManifest).toBe(false);
172
+ expect((await fs.lstat(configPath(project))).isSymbolicLink()).toBe(false);
173
+ expect((await fs.readJson(configPath(project))).preset).toBe(PRESET);
174
+ expect((await readSettings()).model).toBe("opus");
175
+ });
176
+
177
+ // updateSettingsFile refuses a file it cannot parse — a human is probably
178
+ // mid-edit, and writing our keys back over `{}` is how a whole config
179
+ // disappears.
180
+ test("refuses a settings.json it cannot parse, and changes nothing", async () => {
181
+ const settingsPath = join(project, ".claude", "settings.json");
182
+ await fs.outputFile(settingsPath, "{ not json");
183
+
184
+ await expect(
185
+ applyModelPreset({ projectPath: project, preset: PRESET }),
186
+ ).rejects.toThrow(/not valid JSON/);
187
+
188
+ expect(await fs.readFile(settingsPath, "utf8")).toBe("{ not json");
189
+ });
190
+
191
+ test("refuses an invalid config and names every error", async () => {
192
+ const bad = {
193
+ ...(findPreset(PRESET) as ModelsConfig),
194
+ grades: {
195
+ smart: { model: "gpt-5" },
196
+ normal: { model: "opus" },
197
+ cheap: { model: "sonnet" },
198
+ },
199
+ } as unknown as ModelsConfig;
200
+
201
+ await expect(
202
+ applyModelPreset({ projectPath: project, preset: bad }),
203
+ ).rejects.toThrow(/grades.smart.model/);
204
+ expect(await fs.pathExists(configPath(project))).toBe(false);
205
+ });
206
+
207
+ test("an unknown preset name lists the valid ones", async () => {
208
+ await expect(
209
+ applyModelPreset({ projectPath: project, preset: "fable-leed" }),
210
+ ).rejects.toThrow(/Available: .*fable-lead/);
211
+ });
212
+ });
213
+
214
+ describe("clearModels", () => {
215
+ test("removes the config, the link, and the manifest entry", async () => {
216
+ await stageActiveProfile("team");
217
+ await applyModelPreset({ projectPath: project, preset: PRESET });
218
+
219
+ await clearModels(project);
220
+
221
+ expect(await fs.pathExists(configPath(project))).toBe(false);
222
+ // lstat, not pathExists: a DANGLING symlink is the failure this guards.
223
+ await expect(fs.lstat(configPath(project))).rejects.toThrow();
224
+ expect(
225
+ await fs.pathExists(
226
+ join(project, ".claude", "_profiles", "team", "models.json"),
227
+ ),
228
+ ).toBe(false);
229
+
230
+ const manifest = await fs.readJson(getManifestPath(project));
231
+ expect(manifest.profiles.team.models).toBeUndefined();
232
+ expect(manifest.profiles.team.settings.model).toBeUndefined();
233
+ });
234
+
235
+ // The user's own `/effort` entries live in the same modelSettings object.
236
+ // Deleting the object would take their settings with ours.
237
+ test("leaves unrelated modelSettings keys and unrelated settings intact", async () => {
238
+ await applyModelPreset({ projectPath: project, preset: PRESET });
239
+
240
+ const settingsPath = join(project, ".claude", "settings.json");
241
+ const before = await fs.readJson(settingsPath);
242
+ await fs.writeJson(
243
+ settingsPath,
244
+ {
245
+ ...before,
246
+ outputStyle: "composed-team",
247
+ modelSettings: {
248
+ ...(before.modelSettings as Record<string, unknown>),
249
+ "claude-something-else": { effortLevel: "low" },
250
+ },
251
+ },
252
+ { spaces: 2 },
253
+ );
254
+
255
+ await clearModels(project);
256
+
257
+ const after = await fs.readJson(settingsPath);
258
+ expect(after.model).toBeUndefined();
259
+ expect(after.effortLevel).toBeUndefined();
260
+ expect(after.outputStyle).toBe("composed-team");
261
+ expect(after.modelSettings).toEqual({
262
+ "claude-something-else": { effortLevel: "low" },
263
+ });
264
+ });
265
+
266
+ test("removes modelSettings entirely once nothing is left in it", async () => {
267
+ await applyModelPreset({ projectPath: project, preset: PRESET });
268
+ await clearModels(project);
269
+ expect(
270
+ (await fs.readJson(join(project, ".claude", "settings.json")))
271
+ .modelSettings,
272
+ ).toBeUndefined();
273
+ });
274
+
275
+ test("is a no-op on a project that never had routing", async () => {
276
+ await clearModels(project);
277
+ expect(await fs.pathExists(configPath(project))).toBe(false);
278
+ });
279
+ });
280
+
281
+ describe("readModelsConfig", () => {
282
+ test("a missing file is off, not an error", async () => {
283
+ const result = await readModelsConfig(project);
284
+ expect(result.config).toBeNull();
285
+ expect(result.errors).toEqual([]);
286
+ });
287
+
288
+ test("an invalid file reports errors and yields no config", async () => {
289
+ await fs.outputJson(configPath(project), { version: 1, preset: "x" });
290
+ const result = await readModelsConfig(project);
291
+ expect(result.config).toBeNull();
292
+ expect(result.errors.map((e) => e.path)).toContain("main");
293
+ });
294
+
295
+ test("unparseable JSON is an error, not an empty config", async () => {
296
+ await fs.outputFile(configPath(project), "{ nope");
297
+ const result = await readModelsConfig(project);
298
+ expect(result.config).toBeNull();
299
+ expect(result.errors[0]?.message).toMatch(/not valid JSON/);
300
+ });
301
+ });
302
+
303
+ describe("readModelsStatus", () => {
304
+ test("reports applied once the preset is written and the hook is on", async () => {
305
+ await applyModelPreset({ projectPath: project, preset: PRESET });
306
+ const status = await readModelsStatus(project);
307
+ expect(status.state).toBe("on");
308
+ expect(status.preset).toBe(PRESET);
309
+ });
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
+ });
318
+
319
+ const status = await readModelsStatus(project);
320
+ expect(status.state).toBe("stale");
321
+ expect(status.drift.join(" ")).toContain("model");
322
+ });
323
+
324
+ // `unhooked` outranks `stale`: a config with no hook routes NOTHING, so
325
+ // leading with a drifted effort value would bury the reason.
326
+ test("reports unhooked when the hook is not registered", async () => {
327
+ await applyModelPreset({ projectPath: project, preset: PRESET });
328
+ await fs.writeJson(join(configDir, "settings.json"), {});
329
+
330
+ const status = await readModelsStatus(project);
331
+ expect(status.state).toBe("unhooked");
332
+ });
333
+
334
+ test("warns that CLAUDE_CODE_SUBAGENT_MODEL is now inert", async () => {
335
+ await applyModelPreset({ projectPath: project, preset: PRESET });
336
+ const previous = process.env.CLAUDE_CODE_SUBAGENT_MODEL;
337
+ process.env.CLAUDE_CODE_SUBAGENT_MODEL = "haiku";
338
+ try {
339
+ const status = await readModelsStatus(project);
340
+ expect(status.warnings.join(" ")).toContain("CLAUDE_CODE_SUBAGENT_MODEL");
341
+ } finally {
342
+ // biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
343
+ if (previous === undefined) delete process.env.CLAUDE_CODE_SUBAGENT_MODEL;
344
+ else process.env.CLAUDE_CODE_SUBAGENT_MODEL = previous;
345
+ }
346
+ });
347
+ });
348
+
349
+ describe("resolveFullModelId", () => {
350
+ /** Write a transcript line the way Claude Code records one. */
351
+ async function transcript(
352
+ home: string,
353
+ name: string,
354
+ models: string[],
355
+ ): Promise<void> {
356
+ await fs.outputFile(
357
+ join(home, ".claude", "projects", "-tmp-x", `${name}.jsonl`),
358
+ `${models
359
+ .map((m) => JSON.stringify({ message: { model: m } }))
360
+ .join("\n")}\n`,
361
+ );
362
+ }
363
+
364
+ test("returns null when the home has no transcripts at all", async () => {
365
+ const home = await mkdtemp(join(tmpdir(), "models-home-"));
366
+ try {
367
+ expect(resolveFullModelId("opus", { home })).toBeNull();
368
+ } finally {
369
+ await rm(home, { recursive: true, force: true });
370
+ }
371
+ });
372
+
373
+ test("returns null when nothing in the transcripts names that alias", async () => {
374
+ const home = await mkdtemp(join(tmpdir(), "models-home-"));
375
+ try {
376
+ await transcript(home, "a", ["claude-sonnet-9-20260101"]);
377
+ expect(resolveFullModelId("opus", { home })).toBeNull();
378
+ } finally {
379
+ await rm(home, { recursive: true, force: true });
380
+ }
381
+ });
382
+
383
+ // The plainest spelling wins: transcripts record `claude-opus-5` while usage
384
+ // envelopes record `claude-opus-5[1m]`, and a context-window suffix is a
385
+ // DIFFERENT modelSettings key — writing it would set effort on a model the
386
+ // session is not running.
387
+ test("finds the id and prefers the spelling with no bracketed suffix", async () => {
388
+ const home = await mkdtemp(join(tmpdir(), "models-home-"));
389
+ try {
390
+ await transcript(home, "a", [
391
+ "claude-opus-7-20260401[1m]",
392
+ "claude-opus-7-20260401[1m]",
393
+ "claude-opus-7-20260401[1m]",
394
+ "claude-opus-7-20260401",
395
+ ]);
396
+ expect(resolveFullModelId("opus", { home })).toBe(
397
+ "claude-opus-7-20260401",
398
+ );
399
+ } finally {
400
+ await rm(home, { recursive: true, force: true });
401
+ }
402
+ });
403
+
404
+ test("takes the most frequent spelling, not the first seen", async () => {
405
+ const home = await mkdtemp(join(tmpdir(), "models-home-"));
406
+ try {
407
+ await transcript(home, "a", [
408
+ "claude-haiku-4-5-20251001",
409
+ "claude-haiku-9-20260701",
410
+ "claude-haiku-9-20260701",
411
+ ]);
412
+ expect(resolveFullModelId("haiku", { home })).toBe(
413
+ "claude-haiku-9-20260701",
414
+ );
415
+ } finally {
416
+ await rm(home, { recursive: true, force: true });
417
+ }
418
+ });
419
+
420
+ /**
421
+ * Write a transcript of roughly `mb` megabytes that names `model` ONCE, at the very
422
+ * start, followed by padding. Shaped to defeat a tail-only reader on purpose.
423
+ */
424
+ async function bigTranscript(
425
+ home: string,
426
+ name: string,
427
+ model: string | null,
428
+ mb: number,
429
+ ): Promise<void> {
430
+ const head = model
431
+ ? `${JSON.stringify({ message: { model } })}\n`
432
+ : `${JSON.stringify({ message: { note: "no model here" } })}\n`;
433
+ // Padding carries no `"model"` key, so a reader that only sees it finds nothing.
434
+ const line = `${JSON.stringify({ message: { text: "x".repeat(900) } })}\n`;
435
+ const pad = line.repeat(Math.ceil((mb * 1024 * 1024) / line.length));
436
+ await fs.outputFile(
437
+ join(home, ".claude", "projects", "-tmp-big", `${name}.jsonl`),
438
+ head + pad,
439
+ );
440
+ }
441
+
442
+ /**
443
+ * MEASURED, and the reason the window is a byte budget rather than a file count: this
444
+ * machine holds 2995 transcripts, and the newest SIXTY spanned fifty minutes, because
445
+ * every live session appends to its own file. `claude-sonnet-5` sat at rank 85 and so
446
+ * could not resolve at all — while `claude-opus-5`, running right then, resolved fine.
447
+ * An alias is not gone because it is an hour old.
448
+ */
449
+ test("resolves an alias whose newest mention is far down the ranking", async () => {
450
+ const home = await mkdtemp(join(tmpdir(), "models-home-"));
451
+ try {
452
+ // 120 newer transcripts that never name sonnet, then the one that does.
453
+ for (let i = 0; i < 120; i += 1) {
454
+ await transcript(home, `newer-${String(i).padStart(3, "0")}`, [
455
+ "claude-opus-5",
456
+ ]);
457
+ }
458
+ await transcript(home, "older", ["claude-sonnet-5"]);
459
+ // Make the sonnet one the OLDEST, so only a wide window reaches it.
460
+ const file = join(home, ".claude", "projects", "-tmp-x", "older.jsonl");
461
+ const old = new Date(Date.now() - 86_400_000);
462
+ await fs.utimes(file, old, old);
463
+
464
+ expect(resolveFullModelId("sonnet", { home })).toBe("claude-sonnet-5");
465
+ } finally {
466
+ await rm(home, { recursive: true, force: true });
467
+ }
468
+ });
469
+
470
+ /**
471
+ * The other half of the same defect. Reading whole files here was a memory bomb: the
472
+ * largest single transcript on this machine is 597 MB, and `readFileSync(file, "utf8")`
473
+ * on the newest sixty took 6.5 s per `models use`. Capping only the TOTAL was not enough
474
+ * either — one huge decoy consumed the whole budget before the scan reached the file
475
+ * that had the answer, so the cap is per file as well.
476
+ */
477
+ test("a huge transcript with no match does not hide a later one", async () => {
478
+ const home = await mkdtemp(join(tmpdir(), "models-home-"));
479
+ try {
480
+ await bigTranscript(home, "decoy", null, 12);
481
+ await transcript(home, "real", ["claude-fable-5-1"]);
482
+ const older = join(home, ".claude", "projects", "-tmp-x", "real.jsonl");
483
+ const old = new Date(Date.now() - 86_400_000);
484
+ await fs.utimes(older, old, old);
485
+
486
+ const started = performance.now();
487
+ const id = resolveFullModelId("fable", { home });
488
+ const elapsed = performance.now() - started;
489
+
490
+ expect(id).toBe("claude-fable-5-1");
491
+ // Generous: the point is that it does not read 12 MB looking for nothing.
492
+ expect(elapsed).toBeLessThan(2000);
493
+ } finally {
494
+ await rm(home, { recursive: true, force: true });
495
+ }
496
+ });
497
+ });