@sean.holung/minicode 0.3.2 → 0.3.3

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 (107) hide show
  1. package/README.md +48 -43
  2. package/dist/scripts/run-benchmarks.js +147 -0
  3. package/dist/src/agent/config.js +149 -40
  4. package/dist/src/agent/editable-config.js +314 -0
  5. package/dist/src/analysis/structural-analysis.js +379 -0
  6. package/dist/src/benchmark/evaluator.js +79 -0
  7. package/dist/src/benchmark/index.js +4 -0
  8. package/dist/src/benchmark/reporter.js +177 -0
  9. package/dist/src/benchmark/runner.js +100 -0
  10. package/dist/src/benchmark/task-loader.js +78 -0
  11. package/dist/src/benchmark/types.js +5 -0
  12. package/dist/src/cli/args.js +10 -0
  13. package/dist/src/cli/config-slash-command.js +135 -0
  14. package/dist/src/cli/plugin-install.js +69 -0
  15. package/dist/src/index.js +76 -6
  16. package/dist/src/indexer/cache.js +6 -4
  17. package/dist/src/indexer/code-map.js +41 -13
  18. package/dist/src/indexer/plugins/typescript.js +70 -23
  19. package/dist/src/indexer/project-index.js +175 -36
  20. package/dist/src/indexer/symbol-names.js +92 -0
  21. package/dist/src/model-utils.js +18 -0
  22. package/dist/src/serve/agent-bridge.js +203 -24
  23. package/dist/src/serve/mcp-server.js +405 -0
  24. package/dist/src/serve/server.js +165 -10
  25. package/dist/src/serve/websocket.js +8 -0
  26. package/dist/src/shared/graph-styles.js +119 -0
  27. package/dist/src/tools/find-path.js +75 -0
  28. package/dist/src/tools/find-references.js +7 -2
  29. package/dist/src/tools/get-dependencies.js +3 -2
  30. package/dist/src/tools/read-symbol.js +12 -5
  31. package/dist/src/tools/registry.js +3 -1
  32. package/dist/src/tools/search-code-map.js +4 -2
  33. package/dist/src/ui/app.js +1 -1
  34. package/dist/src/ui/cli-ink.js +79 -4
  35. package/dist/src/ui/components/header-bar.js +6 -2
  36. package/dist/src/ui/state/ui-store.js +5 -0
  37. package/dist/src/web/app.js +1124 -176
  38. package/dist/src/web/index.html +113 -3
  39. package/dist/src/web/style.css +973 -55
  40. package/dist/tests/agent.test.js +31 -0
  41. package/dist/tests/analysis-helpers.test.js +89 -0
  42. package/dist/tests/analysis-ui.test.js +29 -0
  43. package/dist/tests/benchmark-harness.test.js +527 -0
  44. package/dist/tests/config-api.test.js +143 -0
  45. package/dist/tests/config-integration.test.js +751 -0
  46. package/dist/tests/config-slash-command.test.js +106 -0
  47. package/dist/tests/config.test.js +42 -1
  48. package/dist/tests/context-indicator.test.js +220 -0
  49. package/dist/tests/editable-config.test.js +109 -0
  50. package/dist/tests/find-path.test.js +183 -0
  51. package/dist/tests/focus-tracker.test.js +62 -0
  52. package/dist/tests/graph-onboarding.test.js +55 -0
  53. package/dist/tests/graph-styles.test.js +65 -0
  54. package/dist/tests/indexer.test.js +137 -0
  55. package/dist/tests/mcp-and-plugin.test.js +186 -0
  56. package/dist/tests/model-client-openai.test.js +29 -0
  57. package/dist/tests/model-selection.test.js +136 -0
  58. package/dist/tests/model-utils.test.js +22 -0
  59. package/dist/tests/reasoning-effort.test.js +264 -0
  60. package/dist/tests/run-benchmarks.test.js +161 -0
  61. package/dist/tests/search-code-map.test.js +18 -0
  62. package/dist/tests/serve.integration.test.js +218 -2
  63. package/dist/tests/session-ui.test.js +21 -0
  64. package/dist/tests/session.test.js +50 -0
  65. package/dist/tests/settings-ui.test.js +30 -0
  66. package/dist/tests/structural-analysis.test.js +218 -0
  67. package/node_modules/@minicode/agent-sdk/README.md +80 -51
  68. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +16 -5
  69. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  70. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +51 -33
  71. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  72. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +14 -0
  73. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  74. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +3 -2
  75. package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
  76. package/node_modules/@minicode/agent-sdk/dist/src/index.js +2 -0
  77. package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
  78. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts +35 -0
  79. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts.map +1 -0
  80. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js +64 -0
  81. package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js.map +1 -0
  82. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +7 -0
  83. package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
  84. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +5 -1
  85. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  86. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +83 -11
  87. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  88. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts +1 -0
  89. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts.map +1 -1
  90. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js +8 -1
  91. package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js.map +1 -1
  92. package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
  93. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +4 -1
  94. package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
  95. package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js +3 -1
  96. package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js.map +1 -1
  97. package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js +8 -2
  98. package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js.map +1 -1
  99. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +9 -5
  101. package/plugin/.claude-plugin/plugin.json +12 -0
  102. package/plugin/.mcp.json +8 -0
  103. package/plugin/CLAUDE.md +26 -0
  104. package/plugin/skills/analyze/SKILL.md +12 -0
  105. package/plugin/skills/focus/SKILL.md +20 -0
  106. package/plugin/skills/graph/SKILL.md +13 -0
  107. package/plugin/skills/symbols/SKILL.md +13 -0
@@ -0,0 +1,751 @@
1
+ /**
2
+ * Integration tests for the config system.
3
+ *
4
+ * Tests the full config resolution chain: agent.config.json → ~/.minicode/.env → shell env vars,
5
+ * getConfigSetupMessage / getConfigMissing, ensureMinicodeHome, the /api/status needsSetup flag,
6
+ * and AgentBridge graceful degradation when model client cannot initialize.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { createServer } from "node:http";
10
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { afterEach, describe, test } from "node:test";
14
+ import { loadAgentConfig, resolveConfigEnv, getConfigSetupMessage, getConfigMissing, loadConfigFile, } from "../src/agent/config.js";
15
+ import { buildStructuredConfigPayload } from "../src/agent/editable-config.js";
16
+ import { createRequestHandler } from "../src/serve/server.js";
17
+ import { AgentBridge } from "../src/serve/agent-bridge.js";
18
+ import { createTestAgentConfig } from "./test-utils.js";
19
+ // ── Helpers ──
20
+ const tempDirs = [];
21
+ const activeServers = new Set();
22
+ afterEach(async () => {
23
+ await Promise.all([...activeServers].map((server) => new Promise((resolve) => server.close(() => resolve()))));
24
+ activeServers.clear();
25
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
26
+ });
27
+ /** Create an isolated minicode home directory with optional config and .env files. */
28
+ async function createTestHome(options = {}) {
29
+ const home = await mkdtemp(path.join(os.tmpdir(), "minicode-integ-"));
30
+ tempDirs.push(home);
31
+ if (options.config) {
32
+ await writeFile(path.join(home, "agent.config.json"), JSON.stringify(options.config, null, 2) + "\n", "utf8");
33
+ }
34
+ if (options.dotenv !== undefined) {
35
+ await writeFile(path.join(home, ".env"), options.dotenv, "utf8");
36
+ }
37
+ return home;
38
+ }
39
+ /**
40
+ * Temporarily set process.env vars, run a callback, then restore originals.
41
+ * Keys mapped to `undefined` are deleted.
42
+ */
43
+ async function withEnv(overrides, callback) {
44
+ const saved = new Map();
45
+ for (const key of Object.keys(overrides)) {
46
+ saved.set(key, process.env[key]);
47
+ if (overrides[key] === undefined) {
48
+ delete process.env[key];
49
+ }
50
+ else {
51
+ process.env[key] = overrides[key];
52
+ }
53
+ }
54
+ try {
55
+ await callback();
56
+ }
57
+ finally {
58
+ for (const [key, value] of saved) {
59
+ if (value === undefined) {
60
+ delete process.env[key];
61
+ }
62
+ else {
63
+ process.env[key] = value;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ /** A bridge whose getConfig returns a custom config object for testing needsSetup. */
69
+ class ConfigurableBridge extends AgentBridge {
70
+ _config;
71
+ _ready;
72
+ constructor(config, ready = true) {
73
+ super(() => { }, false);
74
+ this._config = config;
75
+ this._ready = ready;
76
+ }
77
+ isReady() {
78
+ return this._ready;
79
+ }
80
+ isBusy() {
81
+ return false;
82
+ }
83
+ getConfig() {
84
+ return this._config;
85
+ }
86
+ async runTurn(message) {
87
+ return { text: `Echo: ${message}`, usage: { inputTokens: 1, outputTokens: 1 } };
88
+ }
89
+ async listSess() { return []; }
90
+ hasIndex() { return false; }
91
+ }
92
+ function startServer(bridge, options = {}) {
93
+ const server = createServer(createRequestHandler(bridge, undefined, options));
94
+ activeServers.add(server);
95
+ return new Promise((resolve) => {
96
+ server.listen(0, "127.0.0.1", () => {
97
+ const addr = server.address();
98
+ if (typeof addr === "object" && addr) {
99
+ resolve(`http://127.0.0.1:${addr.port}`);
100
+ }
101
+ });
102
+ });
103
+ }
104
+ // ═══════════════════════════════════════════════════════════════════
105
+ // Config file loading
106
+ // ═══════════════════════════════════════════════════════════════════
107
+ describe("loadConfigFile", () => {
108
+ test("returns empty object when file does not exist", async () => {
109
+ const result = await loadConfigFile("/tmp/nonexistent-minicode-test/config.json");
110
+ assert.deepEqual(result, {});
111
+ });
112
+ test("parses valid JSON config file", async () => {
113
+ const home = await createTestHome({
114
+ config: { model: "test-model", maxSteps: 25 },
115
+ });
116
+ const result = await loadConfigFile(path.join(home, "agent.config.json"));
117
+ assert.equal(result.model, "test-model");
118
+ assert.equal(result.maxSteps, 25);
119
+ });
120
+ });
121
+ // ═══════════════════════════════════════════════════════════════════
122
+ // ensureMinicodeHome
123
+ // ═══════════════════════════════════════════════════════════════════
124
+ describe("ensureMinicodeHome (via loadAgentConfig)", () => {
125
+ test("creates directory and starter config when home does not exist", async () => {
126
+ const base = await mkdtemp(path.join(os.tmpdir(), "minicode-integ-"));
127
+ tempDirs.push(base);
128
+ const minicodeHome = path.join(base, "fresh-home");
129
+ await withEnv({ MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
130
+ await loadAgentConfig("/tmp", { minicodeHome });
131
+ });
132
+ // Directory should exist
133
+ await access(minicodeHome);
134
+ // Starter config should have been written
135
+ const content = JSON.parse(await readFile(path.join(minicodeHome, "agent.config.json"), "utf8"));
136
+ assert.equal(content.model, "");
137
+ assert.equal(content.modelProvider, "openai-compatible");
138
+ assert.equal(content.openAiBaseUrl, "http://localhost:1234/v1");
139
+ });
140
+ test("does not overwrite existing config", async () => {
141
+ const home = await createTestHome({
142
+ config: { model: "my-model", maxSteps: 99 },
143
+ });
144
+ await withEnv({ MODEL: undefined }, async () => {
145
+ await loadAgentConfig("/tmp", { minicodeHome: home });
146
+ });
147
+ const content = JSON.parse(await readFile(path.join(home, "agent.config.json"), "utf8"));
148
+ assert.equal(content.model, "my-model");
149
+ assert.equal(content.maxSteps, 99);
150
+ });
151
+ });
152
+ // ═══════════════════════════════════════════════════════════════════
153
+ // resolveConfigEnv — precedence tests
154
+ // ═══════════════════════════════════════════════════════════════════
155
+ describe("resolveConfigEnv precedence", () => {
156
+ test("shell env vars override home .env values", async () => {
157
+ const home = await createTestHome({
158
+ dotenv: "MODEL=dotenv-model\nMAX_STEPS=20\n",
159
+ });
160
+ await withEnv({ MODEL: "shell-model", MAX_STEPS: undefined }, async () => {
161
+ const env = await resolveConfigEnv({ minicodeHome: home });
162
+ // MODEL: shell env wins
163
+ assert.equal(env.values.MODEL, "shell-model");
164
+ assert.equal(env.sources.MODEL, "process");
165
+ // MAX_STEPS: only in .env, so .env value
166
+ assert.equal(env.values.MAX_STEPS, "20");
167
+ assert.equal(env.sources.MAX_STEPS, "home-dotenv");
168
+ });
169
+ });
170
+ test("home .env provides values not in shell env", async () => {
171
+ const home = await createTestHome({
172
+ dotenv: "OPENAI_API_KEY=dotenv-key-123\n",
173
+ });
174
+ await withEnv({ OPENAI_API_KEY: undefined }, async () => {
175
+ const env = await resolveConfigEnv({ minicodeHome: home });
176
+ assert.equal(env.values.OPENAI_API_KEY, "dotenv-key-123");
177
+ assert.equal(env.sources.OPENAI_API_KEY, "home-dotenv");
178
+ });
179
+ });
180
+ test("shell env var OPENAI_API_KEY overrides home .env", async () => {
181
+ const home = await createTestHome({
182
+ dotenv: "OPENAI_API_KEY=dotenv-key\n",
183
+ });
184
+ await withEnv({ OPENAI_API_KEY: "shell-key-456" }, async () => {
185
+ const env = await resolveConfigEnv({ minicodeHome: home });
186
+ assert.equal(env.values.OPENAI_API_KEY, "shell-key-456");
187
+ assert.equal(env.sources.OPENAI_API_KEY, "process");
188
+ });
189
+ });
190
+ test("empty home .env produces no extra values", async () => {
191
+ const home = await createTestHome({ dotenv: "" });
192
+ await withEnv({ MODEL: "from-shell" }, async () => {
193
+ const env = await resolveConfigEnv({ minicodeHome: home });
194
+ assert.equal(env.values.MODEL, "from-shell");
195
+ assert.equal(env.sources.MODEL, "process");
196
+ });
197
+ });
198
+ test("no .env file at all still works", async () => {
199
+ const home = await createTestHome();
200
+ await withEnv({ MODEL: "shell-only" }, async () => {
201
+ const env = await resolveConfigEnv({ minicodeHome: home });
202
+ assert.equal(env.values.MODEL, "shell-only");
203
+ });
204
+ });
205
+ });
206
+ // ═══════════════════════════════════════════════════════════════════
207
+ // loadAgentConfig — full precedence chain
208
+ // ═══════════════════════════════════════════════════════════════════
209
+ describe("loadAgentConfig precedence", () => {
210
+ test("config file value is used when no env override exists", async () => {
211
+ const home = await createTestHome({
212
+ config: { model: "config-model", maxSteps: 42 },
213
+ });
214
+ await withEnv({ MODEL: undefined, MAX_STEPS: undefined }, async () => {
215
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
216
+ assert.equal(config.model, "config-model");
217
+ assert.equal(config.maxSteps, 42);
218
+ });
219
+ });
220
+ test("home .env overrides config file", async () => {
221
+ const home = await createTestHome({
222
+ config: { model: "config-model", maxSteps: 42 },
223
+ dotenv: "MODEL=dotenv-model\n",
224
+ });
225
+ await withEnv({ MODEL: undefined }, async () => {
226
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
227
+ assert.equal(config.model, "dotenv-model");
228
+ // maxSteps not in .env, so config file value is used
229
+ assert.equal(config.maxSteps, 42);
230
+ });
231
+ });
232
+ test("shell env overrides both config file and home .env", async () => {
233
+ const home = await createTestHome({
234
+ config: { model: "config-model", maxSteps: 42 },
235
+ dotenv: "MODEL=dotenv-model\nMAX_STEPS=77\n",
236
+ });
237
+ await withEnv({ MODEL: "shell-model", MAX_STEPS: "99" }, async () => {
238
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
239
+ assert.equal(config.model, "shell-model");
240
+ assert.equal(config.maxSteps, 99);
241
+ });
242
+ });
243
+ test("OPENAI_API_KEY from shell env is resolved into config", async () => {
244
+ const home = await createTestHome({
245
+ config: {
246
+ modelProvider: "openai-compatible",
247
+ model: "test-model",
248
+ openAiBaseUrl: "http://localhost:1234/v1",
249
+ },
250
+ });
251
+ await withEnv({
252
+ MODEL: undefined,
253
+ OPENAI_API_KEY: "sk-test-key-from-shell",
254
+ MODEL_PROVIDER: undefined,
255
+ }, async () => {
256
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
257
+ assert.equal(config.openAiApiKey, "sk-test-key-from-shell");
258
+ });
259
+ });
260
+ test("OPENAI_API_KEY from home .env is resolved when no shell env", async () => {
261
+ const home = await createTestHome({
262
+ config: {
263
+ modelProvider: "openai-compatible",
264
+ model: "test-model",
265
+ openAiBaseUrl: "http://localhost:1234/v1",
266
+ },
267
+ dotenv: "OPENAI_API_KEY=sk-dotenv-key\n",
268
+ });
269
+ await withEnv({ OPENAI_API_KEY: undefined, MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
270
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
271
+ assert.equal(config.openAiApiKey, "sk-dotenv-key");
272
+ });
273
+ });
274
+ test("OPENAI_API_KEY from shell overrides home .env", async () => {
275
+ const home = await createTestHome({
276
+ config: {
277
+ modelProvider: "openai-compatible",
278
+ model: "test-model",
279
+ openAiBaseUrl: "http://localhost:1234/v1",
280
+ },
281
+ dotenv: "OPENAI_API_KEY=sk-dotenv-key\n",
282
+ });
283
+ await withEnv({ OPENAI_API_KEY: "sk-shell-key", MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
284
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
285
+ assert.equal(config.openAiApiKey, "sk-shell-key");
286
+ });
287
+ });
288
+ test("OPENAI_API_KEY from config file is used as last resort", async () => {
289
+ const home = await createTestHome({
290
+ config: {
291
+ modelProvider: "openai-compatible",
292
+ model: "test-model",
293
+ openAiBaseUrl: "http://localhost:1234/v1",
294
+ openAiApiKey: "sk-file-key",
295
+ },
296
+ });
297
+ await withEnv({ OPENAI_API_KEY: undefined, MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
298
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
299
+ assert.equal(config.openAiApiKey, "sk-file-key");
300
+ });
301
+ });
302
+ test("OpenRouter base URL resolves OPENROUTER_API_KEY with fallback to OPENAI_API_KEY", async () => {
303
+ const home = await createTestHome({
304
+ config: {
305
+ modelProvider: "openai-compatible",
306
+ model: "google/gemini-2.5-pro",
307
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
308
+ },
309
+ });
310
+ // When OPENROUTER_API_KEY is set, it takes priority
311
+ await withEnv({
312
+ OPENROUTER_API_KEY: "sk-or-router-key",
313
+ OPENAI_API_KEY: "sk-or-openai-key",
314
+ MODEL: undefined,
315
+ MODEL_PROVIDER: undefined,
316
+ OPENAI_BASE_URL: undefined,
317
+ }, async () => {
318
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
319
+ assert.equal(config.openAiApiKey, "sk-or-router-key");
320
+ });
321
+ // When only OPENAI_API_KEY is set, it falls back
322
+ await withEnv({
323
+ OPENROUTER_API_KEY: undefined,
324
+ OPENAI_API_KEY: "sk-or-fallback-key",
325
+ MODEL: undefined,
326
+ MODEL_PROVIDER: undefined,
327
+ OPENAI_BASE_URL: undefined,
328
+ }, async () => {
329
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
330
+ assert.equal(config.openAiApiKey, "sk-or-fallback-key");
331
+ });
332
+ });
333
+ test("model defaults to empty string when not set anywhere", async () => {
334
+ const home = await createTestHome({ config: {} });
335
+ await withEnv({ MODEL: undefined }, async () => {
336
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
337
+ assert.equal(config.model, "");
338
+ });
339
+ });
340
+ test("modelProvider defaults to openai-compatible", async () => {
341
+ const home = await createTestHome({ config: {} });
342
+ await withEnv({ MODEL_PROVIDER: undefined }, async () => {
343
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
344
+ assert.equal(config.modelProvider, "openai-compatible");
345
+ });
346
+ });
347
+ test("provider aliases normalize correctly", async () => {
348
+ const home = await createTestHome({ config: {} });
349
+ for (const alias of ["lmstudio", "lm-studio", "openai"]) {
350
+ await withEnv({ MODEL_PROVIDER: alias }, async () => {
351
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
352
+ assert.equal(config.modelProvider, "openai-compatible", `alias "${alias}" should normalize`);
353
+ });
354
+ }
355
+ await withEnv({ MODEL_PROVIDER: "anthropic" }, async () => {
356
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
357
+ assert.equal(config.modelProvider, "anthropic");
358
+ });
359
+ });
360
+ });
361
+ // ═══════════════════════════════════════════════════════════════════
362
+ // getConfigMissing / getConfigSetupMessage
363
+ // ═══════════════════════════════════════════════════════════════════
364
+ describe("getConfigMissing and getConfigSetupMessage", () => {
365
+ test("returns empty when model is set and provider is openai-compatible", () => {
366
+ const config = {
367
+ ...createTestAgentConfig("/tmp"),
368
+ modelProvider: "openai-compatible",
369
+ model: "some-model",
370
+ };
371
+ assert.deepEqual(getConfigMissing(config), []);
372
+ assert.equal(getConfigSetupMessage(config), null);
373
+ });
374
+ test("returns MODEL missing when model is empty string", () => {
375
+ const config = {
376
+ ...createTestAgentConfig("/tmp"),
377
+ model: "",
378
+ };
379
+ const missing = getConfigMissing(config);
380
+ assert.ok(missing.some((m) => m.includes("MODEL")));
381
+ assert.notEqual(getConfigSetupMessage(config), null);
382
+ });
383
+ test("returns ANTHROPIC_API_KEY missing for anthropic provider without key", async () => {
384
+ await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
385
+ const config = {
386
+ ...createTestAgentConfig("/tmp"),
387
+ modelProvider: "anthropic",
388
+ model: "claude-sonnet-4-20250514",
389
+ };
390
+ const missing = getConfigMissing(config);
391
+ assert.ok(missing.some((m) => m.includes("ANTHROPIC_API_KEY")));
392
+ });
393
+ });
394
+ test("returns no ANTHROPIC_API_KEY missing when key is set", async () => {
395
+ await withEnv({ ANTHROPIC_API_KEY: "sk-ant-test" }, async () => {
396
+ const config = {
397
+ ...createTestAgentConfig("/tmp"),
398
+ modelProvider: "anthropic",
399
+ model: "claude-sonnet-4-20250514",
400
+ };
401
+ const missing = getConfigMissing(config);
402
+ assert.deepEqual(missing, []);
403
+ });
404
+ });
405
+ test("does not require ANTHROPIC_API_KEY for openai-compatible provider", async () => {
406
+ await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
407
+ const config = {
408
+ ...createTestAgentConfig("/tmp"),
409
+ modelProvider: "openai-compatible",
410
+ model: "some-model",
411
+ };
412
+ assert.deepEqual(getConfigMissing(config), []);
413
+ });
414
+ });
415
+ test("can return multiple missing items simultaneously", async () => {
416
+ await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
417
+ const config = {
418
+ ...createTestAgentConfig("/tmp"),
419
+ modelProvider: "anthropic",
420
+ model: "",
421
+ };
422
+ const missing = getConfigMissing(config);
423
+ assert.equal(missing.length, 2);
424
+ assert.ok(missing.some((m) => m.includes("MODEL")));
425
+ assert.ok(missing.some((m) => m.includes("ANTHROPIC_API_KEY")));
426
+ });
427
+ });
428
+ test("getConfigSetupMessage includes setup instructions when model is missing", () => {
429
+ const config = { ...createTestAgentConfig("/tmp"), model: "" };
430
+ const message = getConfigSetupMessage(config);
431
+ assert.ok(message.includes("MODEL is not set"));
432
+ assert.ok(message.includes("~/.minicode/.env"));
433
+ assert.ok(message.includes("agent.config.json"));
434
+ });
435
+ });
436
+ // ═══════════════════════════════════════════════════════════════════
437
+ // End-to-end: loadAgentConfig → getConfigMissing
438
+ // ═══════════════════════════════════════════════════════════════════
439
+ describe("loadAgentConfig → getConfigMissing integration", () => {
440
+ test("fully configured openai-compatible setup reports no missing items", async () => {
441
+ const home = await createTestHome({
442
+ config: {
443
+ modelProvider: "openai-compatible",
444
+ model: "test-model",
445
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
446
+ },
447
+ });
448
+ await withEnv({
449
+ MODEL: undefined,
450
+ MODEL_PROVIDER: undefined,
451
+ OPENAI_API_KEY: "sk-test-key",
452
+ }, async () => {
453
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
454
+ assert.deepEqual(getConfigMissing(config), []);
455
+ });
456
+ });
457
+ test("config with model in .env and provider in file reports no missing", async () => {
458
+ const home = await createTestHome({
459
+ config: { modelProvider: "openai-compatible", openAiBaseUrl: "http://localhost:1234/v1" },
460
+ dotenv: "MODEL=env-model\n",
461
+ });
462
+ await withEnv({ MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
463
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
464
+ assert.equal(config.model, "env-model");
465
+ assert.deepEqual(getConfigMissing(config), []);
466
+ });
467
+ });
468
+ test("config with missing model triggers setup message", async () => {
469
+ const home = await createTestHome({
470
+ config: { modelProvider: "openai-compatible", openAiBaseUrl: "https://openrouter.ai/api/v1" },
471
+ });
472
+ await withEnv({ MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
473
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
474
+ assert.equal(config.model, "");
475
+ const missing = getConfigMissing(config);
476
+ assert.ok(missing.length > 0);
477
+ assert.ok(missing[0].includes("MODEL"));
478
+ });
479
+ });
480
+ test("anthropic provider with API key from shell env reports no missing", async () => {
481
+ const home = await createTestHome({
482
+ config: { modelProvider: "anthropic", model: "claude-sonnet-4-20250514" },
483
+ });
484
+ await withEnv({
485
+ ANTHROPIC_API_KEY: "sk-ant-test-123",
486
+ MODEL: undefined,
487
+ MODEL_PROVIDER: undefined,
488
+ }, async () => {
489
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
490
+ assert.deepEqual(getConfigMissing(config), []);
491
+ });
492
+ });
493
+ test("anthropic provider without API key reports missing", async () => {
494
+ const home = await createTestHome({
495
+ config: { modelProvider: "anthropic", model: "claude-sonnet-4-20250514" },
496
+ });
497
+ await withEnv({
498
+ ANTHROPIC_API_KEY: undefined,
499
+ MODEL: undefined,
500
+ MODEL_PROVIDER: undefined,
501
+ }, async () => {
502
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
503
+ const missing = getConfigMissing(config);
504
+ assert.ok(missing.some((m) => m.includes("ANTHROPIC_API_KEY")));
505
+ });
506
+ });
507
+ });
508
+ // ═══════════════════════════════════════════════════════════════════
509
+ // /api/status endpoint — needsSetup and missing fields
510
+ // ═══════════════════════════════════════════════════════════════════
511
+ describe("/api/status needsSetup", () => {
512
+ test("returns needsSetup: false when model is configured", async () => {
513
+ const config = {
514
+ ...createTestAgentConfig("/tmp"),
515
+ modelProvider: "openai-compatible",
516
+ model: "test-model",
517
+ };
518
+ const bridge = new ConfigurableBridge(config);
519
+ const base = await startServer(bridge);
520
+ const res = await fetch(`${base}/api/status`);
521
+ const body = await res.json();
522
+ assert.equal(body.needsSetup, false);
523
+ assert.deepEqual(body.missing, []);
524
+ });
525
+ test("returns needsSetup: true with missing items when model is empty", async () => {
526
+ const config = {
527
+ ...createTestAgentConfig("/tmp"),
528
+ modelProvider: "openai-compatible",
529
+ model: "",
530
+ };
531
+ const bridge = new ConfigurableBridge(config);
532
+ const base = await startServer(bridge);
533
+ const res = await fetch(`${base}/api/status`);
534
+ const body = await res.json();
535
+ assert.equal(body.needsSetup, true);
536
+ assert.ok(body.missing.length > 0);
537
+ assert.ok(body.missing.some((m) => m.includes("MODEL")));
538
+ });
539
+ test("returns needsSetup: true for anthropic without API key", async () => {
540
+ await withEnv({ ANTHROPIC_API_KEY: undefined }, async () => {
541
+ const config = {
542
+ ...createTestAgentConfig("/tmp"),
543
+ modelProvider: "anthropic",
544
+ model: "claude-sonnet-4-20250514",
545
+ };
546
+ const bridge = new ConfigurableBridge(config);
547
+ const base = await startServer(bridge);
548
+ const res = await fetch(`${base}/api/status`);
549
+ const body = await res.json();
550
+ assert.equal(body.needsSetup, true);
551
+ assert.ok(body.missing.some((m) => m.includes("ANTHROPIC_API_KEY")));
552
+ });
553
+ });
554
+ test("status endpoint includes model and provider info", async () => {
555
+ const config = {
556
+ ...createTestAgentConfig("/tmp"),
557
+ modelProvider: "openai-compatible",
558
+ model: "my-test-model",
559
+ };
560
+ const bridge = new ConfigurableBridge(config);
561
+ const base = await startServer(bridge);
562
+ const res = await fetch(`${base}/api/status`);
563
+ const body = await res.json();
564
+ assert.equal(body.model, "my-test-model");
565
+ assert.equal(body.provider, "openai-compatible");
566
+ });
567
+ });
568
+ // ═══════════════════════════════════════════════════════════════════
569
+ // /api/context — graceful degradation when agent not ready
570
+ // ═══════════════════════════════════════════════════════════════════
571
+ describe("/api/context graceful degradation", () => {
572
+ test("returns zeros when bridge is not ready", async () => {
573
+ const config = {
574
+ ...createTestAgentConfig("/tmp"),
575
+ model: "",
576
+ };
577
+ const bridge = new ConfigurableBridge(config, false);
578
+ const base = await startServer(bridge);
579
+ const res = await fetch(`${base}/api/context`);
580
+ assert.equal(res.status, 200);
581
+ const body = await res.json();
582
+ assert.equal(body.contextTokens, 0);
583
+ assert.equal(body.maxContextTokens, 0);
584
+ });
585
+ });
586
+ // ═══════════════════════════════════════════════════════════════════
587
+ // buildStructuredConfigPayload — env source tracking
588
+ // ═══════════════════════════════════════════════════════════════════
589
+ describe("buildStructuredConfigPayload env source tracking", () => {
590
+ test("reports env override from process when shell env var is set", async () => {
591
+ const home = await createTestHome({
592
+ config: { model: "file-model" },
593
+ });
594
+ await withEnv({ MODEL: "shell-model", MAX_STEPS: undefined }, async () => {
595
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
596
+ const payload = await buildStructuredConfigPayload(config, home);
597
+ const modelEntry = payload.entries.find((e) => e.key === "model");
598
+ assert.equal(modelEntry.overriddenByEnv, true);
599
+ assert.equal(modelEntry.envValue, "shell-model");
600
+ assert.equal(modelEntry.envSource, "process");
601
+ });
602
+ });
603
+ test("reports env override from home-dotenv", async () => {
604
+ const home = await createTestHome({
605
+ config: { model: "file-model" },
606
+ dotenv: "MAX_STEPS=88\n",
607
+ });
608
+ await withEnv({ MAX_STEPS: undefined }, async () => {
609
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
610
+ const payload = await buildStructuredConfigPayload(config, home);
611
+ const maxStepsEntry = payload.entries.find((e) => e.key === "maxSteps");
612
+ assert.equal(maxStepsEntry.overriddenByEnv, true);
613
+ assert.equal(maxStepsEntry.envValue, "88");
614
+ assert.equal(maxStepsEntry.envSource, "home-dotenv");
615
+ assert.equal(maxStepsEntry.envSourcePath, path.join(home, ".env"));
616
+ });
617
+ });
618
+ test("reports no env override when value only in config file", async () => {
619
+ const home = await createTestHome({
620
+ config: { maxSteps: 42 },
621
+ });
622
+ await withEnv({ MAX_STEPS: undefined }, async () => {
623
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
624
+ const payload = await buildStructuredConfigPayload(config, home);
625
+ const maxStepsEntry = payload.entries.find((e) => e.key === "maxSteps");
626
+ assert.equal(maxStepsEntry.overriddenByEnv, false);
627
+ assert.equal(maxStepsEntry.persistedValue, 42);
628
+ });
629
+ });
630
+ test("OPENAI_API_KEY from shell env appears in structured payload", async () => {
631
+ const home = await createTestHome({
632
+ config: { model: "test-model" },
633
+ });
634
+ await withEnv({ OPENAI_API_KEY: "sk-test-in-payload", MODEL: undefined }, async () => {
635
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
636
+ const payload = await buildStructuredConfigPayload(config, home);
637
+ const baseUrlEntry = payload.entries.find((e) => e.envVar === "OPENAI_BASE_URL");
638
+ assert.ok(baseUrlEntry, "should have OPENAI_BASE_URL entry");
639
+ });
640
+ });
641
+ });
642
+ // ═══════════════════════════════════════════════════════════════════
643
+ // Realistic user scenario tests
644
+ // ═══════════════════════════════════════════════════════════════════
645
+ describe("realistic user scenarios", () => {
646
+ test("OpenRouter setup: config file + shell OPENAI_API_KEY → no setup needed", async () => {
647
+ // User has modelProvider and openAiBaseUrl in config, OPENAI_API_KEY in shell
648
+ const home = await createTestHome({
649
+ config: {
650
+ modelProvider: "openai-compatible",
651
+ model: "google/gemini-2.5-pro",
652
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
653
+ maxSteps: 50,
654
+ maxTokens: 4096,
655
+ maxContextTokens: 32000,
656
+ },
657
+ });
658
+ await withEnv({
659
+ OPENAI_API_KEY: "sk-or-test-key",
660
+ MODEL: undefined,
661
+ MODEL_PROVIDER: undefined,
662
+ OPENAI_BASE_URL: undefined,
663
+ }, async () => {
664
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
665
+ // Verify all values are correct
666
+ assert.equal(config.modelProvider, "openai-compatible");
667
+ assert.equal(config.model, "google/gemini-2.5-pro");
668
+ assert.equal(config.openAiBaseUrl, "https://openrouter.ai/api/v1");
669
+ assert.equal(config.openAiApiKey, "sk-or-test-key");
670
+ // Should NOT need setup
671
+ assert.deepEqual(getConfigMissing(config), []);
672
+ assert.equal(getConfigSetupMessage(config), null);
673
+ });
674
+ });
675
+ test("OpenRouter setup missing model → setup required with specific error", async () => {
676
+ // User has provider and URL but forgot the model name
677
+ const home = await createTestHome({
678
+ config: {
679
+ modelProvider: "openai-compatible",
680
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
681
+ maxSteps: 50,
682
+ maxTokens: 4096,
683
+ maxContextTokens: 32000,
684
+ },
685
+ });
686
+ await withEnv({
687
+ OPENAI_API_KEY: "sk-or-test-key",
688
+ MODEL: undefined,
689
+ MODEL_PROVIDER: undefined,
690
+ }, async () => {
691
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
692
+ assert.equal(config.model, "");
693
+ const missing = getConfigMissing(config);
694
+ assert.equal(missing.length, 1);
695
+ assert.ok(missing[0].includes("MODEL"));
696
+ });
697
+ });
698
+ test("local LM Studio setup: only config file, no env vars needed", async () => {
699
+ const home = await createTestHome({
700
+ config: {
701
+ modelProvider: "openai-compatible",
702
+ model: "qwen2.5-coder-7b",
703
+ openAiBaseUrl: "http://localhost:1234/v1",
704
+ },
705
+ });
706
+ await withEnv({
707
+ MODEL: undefined,
708
+ MODEL_PROVIDER: undefined,
709
+ OPENAI_API_KEY: undefined,
710
+ OPENAI_BASE_URL: undefined,
711
+ }, async () => {
712
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
713
+ assert.equal(config.model, "qwen2.5-coder-7b");
714
+ assert.equal(config.openAiBaseUrl, "http://localhost:1234/v1");
715
+ assert.deepEqual(getConfigMissing(config), []);
716
+ });
717
+ });
718
+ test("anthropic setup: model in config, API key in shell", async () => {
719
+ const home = await createTestHome({
720
+ config: {
721
+ modelProvider: "anthropic",
722
+ model: "claude-sonnet-4-20250514",
723
+ },
724
+ });
725
+ await withEnv({
726
+ ANTHROPIC_API_KEY: "sk-ant-test-key",
727
+ MODEL: undefined,
728
+ MODEL_PROVIDER: undefined,
729
+ }, async () => {
730
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
731
+ assert.equal(config.modelProvider, "anthropic");
732
+ assert.equal(config.model, "claude-sonnet-4-20250514");
733
+ assert.deepEqual(getConfigMissing(config), []);
734
+ });
735
+ });
736
+ test("fresh install: auto-created config triggers setup overlay", async () => {
737
+ const base = await mkdtemp(path.join(os.tmpdir(), "minicode-integ-"));
738
+ tempDirs.push(base);
739
+ const minicodeHome = path.join(base, "new-home");
740
+ await withEnv({ MODEL: undefined, MODEL_PROVIDER: undefined }, async () => {
741
+ const config = await loadAgentConfig("/tmp", { minicodeHome });
742
+ // Should have auto-created the directory and starter config
743
+ const fileContent = await readFile(path.join(minicodeHome, "agent.config.json"), "utf8");
744
+ assert.ok(fileContent.includes('"model": ""'));
745
+ // Empty model → needs setup
746
+ assert.equal(config.model, "");
747
+ const missing = getConfigMissing(config);
748
+ assert.ok(missing.length > 0);
749
+ });
750
+ });
751
+ });