@cortexkit/opencode-magic-context 0.22.0 → 0.22.2

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 (69) hide show
  1. package/README.md +10 -0
  2. package/dist/config/agent-disable.d.ts +0 -9
  3. package/dist/config/agent-disable.d.ts.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/schema/agent-overrides.d.ts +0 -3
  6. package/dist/config/schema/agent-overrides.d.ts.map +1 -1
  7. package/dist/config/schema/magic-context.d.ts +15 -0
  8. package/dist/config/schema/magic-context.d.ts.map +1 -1
  9. package/dist/features/builtin-commands/types.d.ts +0 -2
  10. package/dist/features/builtin-commands/types.d.ts.map +1 -1
  11. package/dist/features/magic-context/dreamer/scheduler.d.ts +0 -4
  12. package/dist/features/magic-context/dreamer/scheduler.d.ts.map +1 -1
  13. package/dist/features/magic-context/git-commits/index.d.ts +1 -0
  14. package/dist/features/magic-context/git-commits/index.d.ts.map +1 -1
  15. package/dist/features/magic-context/git-commits/storage-git-commits.d.ts.map +1 -1
  16. package/dist/features/magic-context/git-commits/sweep-coordinator.d.ts +48 -0
  17. package/dist/features/magic-context/git-commits/sweep-coordinator.d.ts.map +1 -0
  18. package/dist/features/magic-context/key-files/storage-key-files.d.ts +0 -5
  19. package/dist/features/magic-context/key-files/storage-key-files.d.ts.map +1 -1
  20. package/dist/features/magic-context/literal-probes.d.ts +24 -0
  21. package/dist/features/magic-context/literal-probes.d.ts.map +1 -0
  22. package/dist/features/magic-context/memory/embedding-identity.d.ts.map +1 -1
  23. package/dist/features/magic-context/memory/embedding-openai.d.ts +6 -0
  24. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  25. package/dist/features/magic-context/memory/embedding-probe.d.ts +5 -0
  26. package/dist/features/magic-context/memory/embedding-probe.d.ts.map +1 -1
  27. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  28. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  29. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  30. package/dist/features/magic-context/search.d.ts +7 -0
  31. package/dist/features/magic-context/search.d.ts.map +1 -1
  32. package/dist/features/magic-context/storage-db.d.ts +1 -1
  33. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  34. package/dist/features/magic-context/storage-notes.d.ts +8 -0
  35. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  36. package/dist/hooks/auto-update-checker/cache.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/compartment-runner-types.d.ts +14 -1
  38. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/derive-budgets.d.ts +3 -3
  40. package/dist/hooks/magic-context/event-resolvers.d.ts +1 -0
  41. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +7 -2
  43. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +667 -266
  46. package/dist/plugin/dream-timer.d.ts.map +1 -1
  47. package/dist/plugin/event.d.ts +10 -0
  48. package/dist/plugin/event.d.ts.map +1 -1
  49. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  50. package/dist/shared/announcement.d.ts +16 -0
  51. package/dist/shared/announcement.d.ts.map +1 -1
  52. package/dist/shared/models-dev-cache.d.ts +54 -27
  53. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  54. package/dist/shared/rpc-types.d.ts +3 -1
  55. package/dist/shared/rpc-types.d.ts.map +1 -1
  56. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  57. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  58. package/package.json +4 -4
  59. package/src/shared/announcement.test.ts +23 -7
  60. package/src/shared/announcement.ts +24 -1
  61. package/src/shared/conflict-detector.test.ts +15 -2
  62. package/src/shared/conflict-fixer.test.ts +5 -1
  63. package/src/shared/models-dev-cache.test.ts +200 -300
  64. package/src/shared/models-dev-cache.ts +184 -176
  65. package/src/shared/opencode-compaction-detector.test.ts +10 -2
  66. package/src/shared/rpc-client.test.ts +5 -1
  67. package/src/shared/rpc-types.ts +3 -1
  68. package/src/tui/index.tsx +17 -8
  69. package/src/tui/slots/sidebar-content.tsx +20 -10
@@ -1,108 +1,88 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
6
6
  clearModelsDevCache,
7
7
  getModelsDevCacheState,
8
- getModelsDevContextLimit,
8
+ getSdkContextLimit,
9
9
  refreshModelLimitsFromApi,
10
10
  } from "./models-dev-cache";
11
11
 
12
- describe("models-dev-cache", () => {
12
+ /**
13
+ * Model context limits resolve from OpenCode's SDK only (`config.providers()`),
14
+ * bounded to a sane [20k, 3M] range, with a persisted last-known-good cache for
15
+ * cold start. We no longer read OpenCode's `models.json` file ourselves (a torn
16
+ * read produced impossible limits and a stale copy out-voted the live cap).
17
+ */
18
+ describe("models-dev-cache (SDK-only)", () => {
13
19
  let tempDir: string;
14
- let originalEnv: Record<string, string | undefined>;
20
+ let originalXdgData: string | undefined;
21
+
22
+ function makeClient(providers: Array<unknown>) {
23
+ return { config: { providers: async () => ({ data: { providers } }) } };
24
+ }
15
25
 
16
26
  beforeEach(() => {
17
27
  tempDir = mkdtempSync(join(tmpdir(), "mc-models-dev-"));
18
- originalEnv = {
19
- OPENCODE_MODELS_PATH: process.env.OPENCODE_MODELS_PATH,
20
- OPENCODE_MODELS_URL: process.env.OPENCODE_MODELS_URL,
21
- XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
22
- OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
23
- };
24
- // Isolate from user environment — including user's ~/.config/opencode/opencode.jsonc
25
- // which may have custom provider limits that would override models.json entries.
26
- delete process.env.OPENCODE_MODELS_PATH;
27
- delete process.env.OPENCODE_MODELS_URL;
28
- process.env.XDG_CACHE_HOME = tempDir;
29
- // Point at an empty directory so no opencode.json{c} is read unless the test writes one.
30
- const emptyConfigDir = join(tempDir, "config", "opencode");
31
- mkdirSync(emptyConfigDir, { recursive: true });
32
- process.env.OPENCODE_CONFIG_DIR = emptyConfigDir;
28
+ // Isolate the persisted-cache file under a temp data dir so tests never
29
+ // touch the real ~/.local/share/cortexkit/magic-context cache.
30
+ originalXdgData = process.env.XDG_DATA_HOME;
31
+ process.env.XDG_DATA_HOME = tempDir;
33
32
  clearModelsDevCache();
34
33
  });
35
34
 
36
35
  afterEach(() => {
37
- // Restore env.
38
- for (const [k, v] of Object.entries(originalEnv)) {
39
- if (v === undefined) delete process.env[k];
40
- else process.env[k] = v;
36
+ if (originalXdgData === undefined) delete process.env.XDG_DATA_HOME;
37
+ else process.env.XDG_DATA_HOME = originalXdgData;
38
+ try {
39
+ rmSync(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
40
+ } catch {
41
+ /* Ignore EBUSY on Windows */
41
42
  }
42
- rmSync(tempDir, { recursive: true, force: true });
43
43
  clearModelsDevCache();
44
44
  });
45
45
 
46
- test("reads context limits from models.json under XDG_CACHE_HOME", () => {
47
- const opencodeDir = join(tempDir, "opencode");
48
- mkdirSync(opencodeDir, { recursive: true });
49
- writeFileSync(
50
- join(opencodeDir, "models.json"),
51
- JSON.stringify({
52
- anthropic: {
53
- models: {
54
- "claude-sonnet-4-6": { limit: { context: 200000 } },
55
- },
56
- },
57
- "github-copilot": {
46
+ test("resolves from the SDK and prefers limit.input over limit.context", async () => {
47
+ // github-copilot / codex shape: input is the max prompt, context the total
48
+ // window. Pressure math must use the input cap (OpenCode's own overflow.ts
49
+ // does the same), so a 400k-context / 272k-input model resolves to 272k.
50
+ await refreshModelLimitsFromApi(
51
+ makeClient([
52
+ {
53
+ id: "github-copilot",
58
54
  models: {
59
- "gpt-5.3-codex": { limit: { context: 400000 } },
55
+ "gpt-5.3-codex": { limit: { context: 400000, input: 272000 } },
56
+ "legacy-only-context": { limit: { context: 100000 } },
60
57
  },
61
58
  },
62
- }),
59
+ ]),
63
60
  );
64
61
 
65
- expect(getModelsDevContextLimit("anthropic", "claude-sonnet-4-6")).toBe(200000);
66
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(400000);
67
- expect(getModelsDevContextLimit("unknown", "unknown")).toBeUndefined();
62
+ expect(getSdkContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
63
+ expect(getSdkContextLimit("github-copilot", "legacy-only-context")).toBe(100000);
64
+ expect(getSdkContextLimit("unknown", "unknown")).toBeUndefined();
68
65
  });
69
66
 
70
- test("prefers limit.input over limit.context when both are present", () => {
71
- //#given GitHub Copilot shape: input is max prompt, context is total window.
72
- // Matches real-world github-copilot/gpt-5.3-codex which has
73
- // limit.context = 400000 (total), limit.input = 272000 (max prompt).
74
- // Our pressure math must use the input cap; sending a 400K prompt gets rejected.
75
- // OpenCode's own session/overflow.ts follows the same rule.
76
- const opencodeDir = join(tempDir, "opencode");
77
- mkdirSync(opencodeDir, { recursive: true });
78
- writeFileSync(
79
- join(opencodeDir, "models.json"),
80
- JSON.stringify({
81
- "github-copilot": {
82
- models: {
83
- "gpt-5.3-codex": { limit: { context: 400000, input: 272000 } },
84
- "claude-opus-4.6": { limit: { context: 144000, input: 128000 } },
85
- // Context-only model (no input) falls back to context.
86
- "legacy-only-context": { limit: { context: 100000 } },
87
- },
67
+ test("Codex-OAuth cap is honored: a 400k/272k gpt-5.5 resolves to 272k (not the stale 922k)", async () => {
68
+ // The bug we're fixing: the SDK reports the auth-resolved cap; nothing may
69
+ // out-vote it with a larger stale value.
70
+ await refreshModelLimitsFromApi(
71
+ makeClient([
72
+ {
73
+ id: "openai",
74
+ models: { "gpt-5.5": { limit: { context: 400000, input: 272000 } } },
88
75
  },
89
- }),
76
+ ]),
90
77
  );
91
-
92
- //#then
93
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
94
- expect(getModelsDevContextLimit("github-copilot", "claude-opus-4.6")).toBe(128000);
95
- expect(getModelsDevContextLimit("github-copilot", "legacy-only-context")).toBe(100000);
78
+ expect(getSdkContextLimit("openai", "gpt-5.5")).toBe(272000);
96
79
  });
97
80
 
98
- test("derived experimental.modes inherit the effective (input) limit", () => {
99
- //#given — parent has input < context; derived modes should inherit input, not context
100
- const opencodeDir = join(tempDir, "opencode");
101
- mkdirSync(opencodeDir, { recursive: true });
102
- writeFileSync(
103
- join(opencodeDir, "models.json"),
104
- JSON.stringify({
105
- openai: {
81
+ test("derived experimental.modes inherit the effective (input) limit", async () => {
82
+ await refreshModelLimitsFromApi(
83
+ makeClient([
84
+ {
85
+ id: "openai",
106
86
  models: {
107
87
  "gpt-5.4": {
108
88
  limit: { context: 1050000, input: 922000 },
@@ -110,185 +90,169 @@ describe("models-dev-cache", () => {
110
90
  },
111
91
  },
112
92
  },
113
- }),
93
+ ]),
114
94
  );
115
-
116
- //#then
117
- expect(getModelsDevContextLimit("openai", "gpt-5.4")).toBe(922000);
118
- expect(getModelsDevContextLimit("openai", "gpt-5.4-fast")).toBe(922000);
119
- expect(getModelsDevContextLimit("openai", "gpt-5.4-mini")).toBe(922000);
95
+ expect(getSdkContextLimit("openai", "gpt-5.4")).toBe(922000);
96
+ expect(getSdkContextLimit("openai", "gpt-5.4-fast")).toBe(922000);
97
+ expect(getSdkContextLimit("openai", "gpt-5.4-mini")).toBe(922000);
120
98
  });
121
99
 
122
- test("custom opencode.json provider overlay uses limit.input preferentially", () => {
123
- //#given — user defines a proxy provider in opencode.json with input < context
124
- const opencodeDir = join(tempDir, "opencode");
125
- mkdirSync(opencodeDir, { recursive: true });
126
- const configDir = join(tempDir, "config", "opencode");
127
- mkdirSync(configDir, { recursive: true });
128
- writeFileSync(
129
- join(configDir, "opencode.json"),
130
- JSON.stringify({
131
- provider: {
132
- "my-proxy": {
133
- models: {
134
- "split-model": { limit: { context: 400000, input: 200000 } },
135
- },
100
+ test("matches a tagged ollama model against its tag-less SDK entry", async () => {
101
+ await refreshModelLimitsFromApi(
102
+ makeClient([
103
+ {
104
+ id: "ollama-cloud",
105
+ models: {
106
+ "deepseek-v4-pro": { limit: { context: 1048576 } },
107
+ "gemma3:27b": { limit: { context: 131072 } },
136
108
  },
137
109
  },
138
- }),
110
+ ]),
139
111
  );
140
- process.env.OPENCODE_CONFIG_DIR = configDir;
141
- clearModelsDevCache();
142
-
143
- //#then
144
- expect(getModelsDevContextLimit("my-proxy", "split-model")).toBe(200000);
145
-
146
- // Cleanup: restore env (afterEach also handles this, but we added a new var)
147
- delete process.env.OPENCODE_CONFIG_DIR;
112
+ // Tagged invocation falls back to the tag-less entry.
113
+ expect(getSdkContextLimit("ollama-cloud", "deepseek-v4-pro:cloud")).toBe(1048576);
114
+ // Exact tagged match still wins (no wrongful collapse).
115
+ expect(getSdkContextLimit("ollama-cloud", "gemma3:27b")).toBe(131072);
116
+ // Unknown tagged model with no tag-less base stays undefined.
117
+ expect(getSdkContextLimit("ollama-cloud", "nonexistent:cloud")).toBeUndefined();
148
118
  });
149
119
 
150
- test("API cache uses limit.input preferentially", async () => {
151
- //#given API response shape mirrors file layer
152
- const mockClient = {
153
- config: {
154
- providers: async () => ({
155
- data: {
156
- providers: [
157
- {
158
- id: "github-copilot",
159
- models: {
160
- "gpt-5.3-codex": {
161
- limit: { context: 400000, input: 272000 },
162
- },
163
- },
164
- },
165
- ],
120
+ describe("sanity bounds [20k, 3M]", () => {
121
+ test("rejects an implausibly small limit (torn-read garbage like 6748)", async () => {
122
+ await refreshModelLimitsFromApi(
123
+ makeClient([
124
+ {
125
+ id: "ollama-cloud",
126
+ // 6748 is smaller than a single system prompt — impossible
127
+ // as a real limit; must be rejected, not trusted.
128
+ models: { "deepseek-v4-pro": { limit: { context: 6748 } } },
166
129
  },
167
- }),
168
- },
169
- };
170
- await refreshModelLimitsFromApi(mockClient);
130
+ ]),
131
+ );
132
+ expect(getSdkContextLimit("ollama-cloud", "deepseek-v4-pro")).toBeUndefined();
133
+ });
171
134
 
172
- //#then
173
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.3-codex")).toBe(272000);
174
- });
135
+ test("rejects a below-floor 8192 num_ctx default", async () => {
136
+ await refreshModelLimitsFromApi(
137
+ makeClient([{ id: "p", models: { m: { limit: { context: 8192 } } } }]),
138
+ );
139
+ expect(getSdkContextLimit("p", "m")).toBeUndefined();
140
+ });
175
141
 
176
- test("expands experimental.modes into derived model IDs with parent context", () => {
177
- const opencodeDir = join(tempDir, "opencode");
178
- mkdirSync(opencodeDir, { recursive: true });
179
- writeFileSync(
180
- join(opencodeDir, "models.json"),
181
- JSON.stringify({
182
- "github-copilot": {
183
- models: {
184
- "gpt-5.4": {
185
- limit: { context: 400000 },
186
- experimental: { modes: { fast: {}, high: {} } },
142
+ test("rejects an impossibly large limit (> 3M)", async () => {
143
+ await refreshModelLimitsFromApi(
144
+ makeClient([{ id: "p", models: { m: { limit: { context: 5_000_000 } } } }]),
145
+ );
146
+ expect(getSdkContextLimit("p", "m")).toBeUndefined();
147
+ });
148
+
149
+ test("accepts values exactly on the bounds", async () => {
150
+ await refreshModelLimitsFromApi(
151
+ makeClient([
152
+ {
153
+ id: "p",
154
+ models: {
155
+ lo: { limit: { context: 20000 } },
156
+ hi: { limit: { context: 3000000 } },
187
157
  },
188
158
  },
189
- },
190
- }),
191
- );
192
-
193
- // Parent ID works.
194
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.4")).toBe(400000);
195
- // Derived mode IDs inherit parent context.
196
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.4-fast")).toBe(400000);
197
- expect(getModelsDevContextLimit("github-copilot", "gpt-5.4-high")).toBe(400000);
198
- });
199
-
200
- test("OPENCODE_MODELS_PATH env overrides default path", () => {
201
- // Write real file somewhere unexpected.
202
- const customPath = join(tempDir, "elsewhere", "my-models.json");
203
- mkdirSync(join(tempDir, "elsewhere"), { recursive: true });
204
- writeFileSync(
205
- customPath,
206
- JSON.stringify({
207
- anthropic: { models: { "claude-4": { limit: { context: 1000000 } } } },
208
- }),
209
- );
210
- process.env.OPENCODE_MODELS_PATH = customPath;
211
- clearModelsDevCache();
212
-
213
- expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
159
+ ]),
160
+ );
161
+ expect(getSdkContextLimit("p", "lo")).toBe(20000);
162
+ expect(getSdkContextLimit("p", "hi")).toBe(3000000);
163
+ });
214
164
  });
215
165
 
216
- test("OPENCODE_MODELS_URL (non-default) selects hashed filename", () => {
217
- // We can't easily verify the exact hash without duplicating the hash logic,
218
- // but we can confirm that setting OPENCODE_MODELS_URL prevents reading
219
- // the default models.json when that file exists with different data.
220
- const opencodeDir = join(tempDir, "opencode");
221
- mkdirSync(opencodeDir, { recursive: true });
222
- writeFileSync(
223
- join(opencodeDir, "models.json"),
224
- JSON.stringify({
225
- anthropic: { models: { "claude-4": { limit: { context: 500000 } } } },
226
- }),
227
- );
228
-
229
- process.env.OPENCODE_MODELS_URL = "https://custom.example.com/models";
230
- clearModelsDevCache();
166
+ describe("persisted cache (cold start)", () => {
167
+ test("seeds from the persisted file after a clear (restart simulation)", async () => {
168
+ // First run: warm + persist.
169
+ await refreshModelLimitsFromApi(
170
+ makeClient([{ id: "openai", models: { "gpt-5.5": { limit: { input: 272000 } } } }]),
171
+ );
172
+ expect(getSdkContextLimit("openai", "gpt-5.5")).toBe(272000);
173
+
174
+ // Simulate a restart: in-memory cache gone, but the persisted file
175
+ // remains under XDG_DATA_HOME. The next lookup seeds from disk.
176
+ clearModelsDevCache();
177
+ expect(getModelsDevCacheState().apiLoaded).toBe(false);
178
+ expect(getSdkContextLimit("openai", "gpt-5.5")).toBe(272000);
179
+ // Seeding populated the in-memory cache.
180
+ expect(getModelsDevCacheState().apiLoaded).toBe(true);
181
+ });
231
182
 
232
- // Should NOT find claude-4 because we're looking at a hashed filename now,
233
- // not models.json.
234
- expect(getModelsDevContextLimit("anthropic", "claude-4")).toBeUndefined();
183
+ test("does not persist or seed insane values", async () => {
184
+ await refreshModelLimitsFromApi(
185
+ makeClient([
186
+ {
187
+ id: "p",
188
+ models: {
189
+ good: { limit: { context: 200000 } },
190
+ bad: { limit: { context: 6748 } },
191
+ },
192
+ },
193
+ ]),
194
+ );
195
+ clearModelsDevCache();
196
+ expect(getSdkContextLimit("p", "good")).toBe(200000);
197
+ expect(getSdkContextLimit("p", "bad")).toBeUndefined();
198
+ });
235
199
  });
236
200
 
237
- test("API cache takes priority over file cache", async () => {
238
- // Seed file layer with one value.
239
- const opencodeDir = join(tempDir, "opencode");
240
- mkdirSync(opencodeDir, { recursive: true });
241
- writeFileSync(
242
- join(opencodeDir, "models.json"),
243
- JSON.stringify({
244
- anthropic: { models: { "claude-4": { limit: { context: 100000 } } } },
245
- }),
246
- );
247
-
248
- // Sanity: file layer returns 100000 before API refresh.
249
- expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(100000);
250
-
251
- // Mock client providing DIFFERENT value via API.
252
- const mockClient = {
253
- config: {
254
- providers: async () => ({
255
- data: {
256
- providers: [
257
- {
258
- id: "anthropic",
259
- models: {
260
- "claude-4": { limit: { context: 1000000 } },
261
- },
201
+ describe("startup retry", () => {
202
+ test("retries when the provider payload is empty, then succeeds", async () => {
203
+ let calls = 0;
204
+ const client = {
205
+ config: {
206
+ providers: async () => {
207
+ calls++;
208
+ if (calls === 1) return { data: { providers: [] } };
209
+ return {
210
+ data: {
211
+ providers: [
212
+ { id: "p", models: { m: { limit: { context: 200000 } } } },
213
+ ],
262
214
  },
263
- ],
215
+ };
264
216
  },
265
- }),
266
- },
267
- };
268
- await refreshModelLimitsFromApi(mockClient);
269
-
270
- // API value wins.
271
- expect(getModelsDevContextLimit("anthropic", "claude-4")).toBe(1000000);
217
+ },
218
+ };
219
+ await refreshModelLimitsFromApi(client, { retries: 2, retryDelayMs: 1 });
220
+ expect(calls).toBe(2);
221
+ expect(getSdkContextLimit("p", "m")).toBe(200000);
222
+ });
272
223
 
273
- const state = getModelsDevCacheState();
274
- expect(state.apiLoaded).toBe(true);
275
- expect(state.apiCount).toBe(1);
224
+ test("stops early on first successful load (no wasted retries)", async () => {
225
+ let calls = 0;
226
+ const client = {
227
+ config: {
228
+ providers: async () => {
229
+ calls++;
230
+ return {
231
+ data: {
232
+ providers: [
233
+ { id: "p", models: { m: { limit: { context: 200000 } } } },
234
+ ],
235
+ },
236
+ };
237
+ },
238
+ },
239
+ };
240
+ await refreshModelLimitsFromApi(client, { retries: 3, retryDelayMs: 1 });
241
+ expect(calls).toBe(1);
242
+ });
276
243
  });
277
244
 
278
- test("refreshModelLimitsFromApi tolerates empty/malformed responses", async () => {
279
- // Undefined data.
245
+ test("tolerates empty / malformed / thrown responses without populating", async () => {
280
246
  await refreshModelLimitsFromApi({
281
247
  config: { providers: async () => ({ data: undefined }) },
282
248
  });
283
249
  expect(getModelsDevCacheState().apiLoaded).toBe(false);
284
250
 
285
- // Non-array providers.
286
251
  await refreshModelLimitsFromApi({
287
252
  config: { providers: async () => ({ data: { providers: "not an array" } }) },
288
253
  });
289
254
  expect(getModelsDevCacheState().apiLoaded).toBe(false);
290
255
 
291
- // Thrown error.
292
256
  await refreshModelLimitsFromApi({
293
257
  config: {
294
258
  providers: async () => {
@@ -299,96 +263,32 @@ describe("models-dev-cache", () => {
299
263
  expect(getModelsDevCacheState().apiLoaded).toBe(false);
300
264
  });
301
265
 
302
- test("repeated manual API refreshes replace cache state without corruption", async () => {
303
- // Simulates the issue #77 recovery path manually retrying provider metadata
304
- // after a bad cache value. Normal startup no longer schedules periodic
305
- // refreshes, but explicit refresh calls should still replace cache state
306
- // cleanly even when provider counts alternate.
307
- const sizeA = {
308
- data: {
309
- providers: [
310
- {
311
- id: "p",
312
- models: {
313
- m1: { limit: { context: 100 } },
314
- m2: { limit: { context: 100 } },
315
- m3: { limit: { context: 100 } },
316
- },
317
- },
318
- ],
266
+ test("repeated refreshes replace cache state without corruption", async () => {
267
+ const clientA = makeClient([
268
+ {
269
+ id: "p",
270
+ models: {
271
+ m1: { limit: { context: 200000 } },
272
+ m2: { limit: { context: 200000 } },
273
+ m3: { limit: { context: 200000 } },
274
+ },
319
275
  },
320
- };
321
- const sizeB = {
322
- data: {
323
- providers: [
324
- {
325
- id: "p",
326
- models: {
327
- m1: { limit: { context: 100 } },
328
- m2: { limit: { context: 100 } },
329
- },
330
- },
331
- ],
276
+ ]);
277
+ const clientB = makeClient([
278
+ {
279
+ id: "p",
280
+ models: { m1: { limit: { context: 200000 } }, m2: { limit: { context: 200000 } } },
332
281
  },
333
- };
334
-
335
- const clientA = { config: { providers: async () => sizeA } };
336
- const clientB = { config: { providers: async () => sizeB } };
282
+ ]);
337
283
 
338
284
  await refreshModelLimitsFromApi(clientA);
339
285
  expect(getModelsDevCacheState().apiCount).toBe(3);
340
-
341
286
  await refreshModelLimitsFromApi(clientB);
342
287
  expect(getModelsDevCacheState().apiCount).toBe(2);
343
-
344
288
  await refreshModelLimitsFromApi(clientA);
345
289
  expect(getModelsDevCacheState().apiCount).toBe(3);
346
290
 
347
- await refreshModelLimitsFromApi(clientB);
348
- expect(getModelsDevCacheState().apiCount).toBe(2);
349
-
350
- await refreshModelLimitsFromApi(clientA);
351
- expect(getModelsDevCacheState().apiCount).toBe(3);
352
-
353
- // The cache itself still updates on every call (model contents are correct
354
- // for whichever provider response just arrived). The suppression is purely
355
- // a logging concern. Last call was clientA → all three models present.
356
- expect(getModelsDevContextLimit("p", "m1")).toBe(100);
357
- expect(getModelsDevContextLimit("p", "m2")).toBe(100);
358
- expect(getModelsDevContextLimit("p", "m3")).toBe(100);
359
- });
360
-
361
- test("falls back to file layer when API provider/model key is missing", async () => {
362
- const opencodeDir = join(tempDir, "opencode");
363
- mkdirSync(opencodeDir, { recursive: true });
364
- writeFileSync(
365
- join(opencodeDir, "models.json"),
366
- JSON.stringify({
367
- anthropic: { models: { "claude-only-in-file": { limit: { context: 777777 } } } },
368
- }),
369
- );
370
-
371
- const mockClient = {
372
- config: {
373
- providers: async () => ({
374
- data: {
375
- providers: [
376
- {
377
- id: "anthropic",
378
- models: {
379
- "claude-only-in-api": { limit: { context: 888888 } },
380
- },
381
- },
382
- ],
383
- },
384
- }),
385
- },
386
- };
387
- await refreshModelLimitsFromApi(mockClient);
388
-
389
- // API-only key comes from API.
390
- expect(getModelsDevContextLimit("anthropic", "claude-only-in-api")).toBe(888888);
391
- // File-only key falls through to file layer.
392
- expect(getModelsDevContextLimit("anthropic", "claude-only-in-file")).toBe(777777);
291
+ expect(getSdkContextLimit("p", "m1")).toBe(200000);
292
+ expect(getSdkContextLimit("p", "m3")).toBe(200000);
393
293
  });
394
294
  });