@sean.holung/minicode 0.3.7 → 0.3.8

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 (30) hide show
  1. package/dist/src/agent/config.js +25 -0
  2. package/dist/src/model-utils.js +18 -1
  3. package/dist/src/serve/agent-bridge.js +85 -14
  4. package/dist/src/serve/server.js +137 -3
  5. package/dist/src/session/session-store.js +18 -0
  6. package/dist/src/web/app.js +559 -90
  7. package/dist/src/web/index.html +112 -8
  8. package/dist/src/web/style.css +141 -7
  9. package/dist/tests/agent.test.js +16 -0
  10. package/dist/tests/config-integration.test.js +91 -1
  11. package/dist/tests/file-tools.test.js +12 -0
  12. package/dist/tests/graph-onboarding.test.js +8 -0
  13. package/dist/tests/model-client-openai.test.js +41 -0
  14. package/dist/tests/model-dropdown-ui.test.js +23 -0
  15. package/dist/tests/model-utils.test.js +26 -1
  16. package/dist/tests/serve.integration.test.js +163 -0
  17. package/dist/tests/session-store.test.js +15 -1
  18. package/dist/tests/settings-ui.test.js +11 -0
  19. package/dist/tests/setup-overlay-state.test.js +49 -0
  20. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  21. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +10 -1
  22. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  23. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  24. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +21 -0
  25. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  26. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  27. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +60 -6
  28. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  29. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +1 -1
@@ -27,6 +27,10 @@
27
27
  <div class="model-menu">
28
28
  <button id="model-btn" class="header-btn" title="Switch model"><span id="model-info"></span> ▾</button>
29
29
  <div id="model-dropdown" class="dropdown hidden">
30
+ <div class="dropdown-section dropdown-search-section">
31
+ <input id="model-search" type="text" placeholder="Search models..." autocomplete="off" />
32
+ </div>
33
+ <div class="dropdown-divider"></div>
30
34
  <div id="model-list" class="dropdown-section">
31
35
  <div class="dropdown-empty">Loading models...</div>
32
36
  </div>
@@ -61,15 +65,28 @@
61
65
  <div class="config-overlay-content">
62
66
  <h2>Agent not connected</h2>
63
67
  <p id="config-missing" class="config-missing hidden"></p>
64
- <div id="config-overlay-spotlight" class="config-overlay-spotlight">
65
- <div class="config-overlay-spotlight-copy">
66
- <span class="config-overlay-spotlight-badge">Fastest way to start</span>
67
- <strong>Try minicode for free with OpenRouter</strong>
68
- <p>Create a free OpenRouter account, connect it to this <code>minicode serve</code> session, and use free models without editing config files first.</p>
68
+ <div id="config-overlay-quick-connects" class="config-overlay-shortcuts">
69
+ <div id="config-overlay-spotlight" class="config-overlay-spotlight">
70
+ <div class="config-overlay-spotlight-copy">
71
+ <span class="config-overlay-spotlight-badge">Fastest way to start</span>
72
+ <strong>Try minicode for free with OpenRouter</strong>
73
+ <p>Create a free OpenRouter account, connect it to this <code>minicode serve</code> session, and use free models without editing config files first.</p>
74
+ </div>
75
+ <div class="config-overlay-actions config-overlay-spotlight-actions">
76
+ <button id="connect-openrouter-btn" class="dropdown-action" type="button" data-openrouter-connect="true">Connect OpenRouter</button>
77
+ </div>
69
78
  </div>
70
- <div class="config-overlay-actions config-overlay-spotlight-actions">
71
- <button id="connect-openrouter-btn" class="dropdown-action" type="button" data-openrouter-connect="true">Connect OpenRouter</button>
72
- <span class="config-overlay-action-note">Session-only. Nothing is written to disk.</span>
79
+
80
+ <div id="config-overlay-openai-compatible" class="config-overlay-spotlight config-overlay-spotlight-secondary">
81
+ <div class="config-overlay-spotlight-copy">
82
+ <span class="config-overlay-spotlight-badge">Local quick connect</span>
83
+ <strong>Connect OpenAI-compatible</strong>
84
+ <p>Choose a preset like LM Studio, OpenAI, Ollama, or Custom, then adjust the endpoint if needed before connecting this <code>minicode serve</code> session.</p>
85
+ </div>
86
+ <div class="config-overlay-actions config-overlay-spotlight-actions">
87
+ <button id="connect-openai-compatible-btn" class="dropdown-action" type="button" data-openai-compatible-connect="true">Connect OpenAI-compatible</button>
88
+ <span class="config-overlay-action-note">Starts with common presets, but the endpoint stays editable.</span>
89
+ </div>
73
90
  </div>
74
91
  </div>
75
92
  <p id="config-overlay-intro">minicode needs a model provider to run. Configure one of the following:</p>
@@ -115,6 +132,7 @@ MODEL=your-model-name</pre>
115
132
  <div id="graph-toolbar">
116
133
  <input id="graph-search" type="text" placeholder="Search symbols or files..." autocomplete="off" />
117
134
  <button id="graph-analyze" class="header-btn">Analyze</button>
135
+ <button id="graph-refresh" class="header-btn" title="Refresh the dependency graph and symbol search">Refresh</button>
118
136
  <button id="graph-fit" class="header-btn">Fit</button>
119
137
  <button id="graph-relayout" class="header-btn">Re-layout</button>
120
138
  <button id="graph-clear" class="header-btn">Clear</button>
@@ -176,6 +194,23 @@ MODEL=your-model-name</pre>
176
194
  </div>
177
195
  <button id="disconnect-openrouter-btn" class="header-btn" type="button">Disconnect OpenRouter</button>
178
196
  </div>
197
+ <div id="settings-openai-compatible-session" class="settings-session-banner hidden" role="status" aria-live="polite">
198
+ <div class="settings-session-copy">
199
+ <div class="settings-session-title">OpenAI-compatible provider is connected for this serve session</div>
200
+ <div id="settings-openai-compatible-session-meta" class="settings-session-meta"></div>
201
+ </div>
202
+ <button id="disconnect-openai-compatible-btn" class="header-btn" type="button">Disconnect OpenAI-compatible</button>
203
+ </div>
204
+ <div class="settings-provider-shortcuts">
205
+ <div class="settings-provider-shortcuts-copy">
206
+ <div class="settings-session-title">Quick connect</div>
207
+ <div class="settings-session-meta">Temporarily connect a provider for this serve session, or optionally save it to <code>~/.minicode/.env</code>.</div>
208
+ </div>
209
+ <div class="settings-provider-shortcuts-actions">
210
+ <button class="header-btn" type="button" data-openrouter-connect="true">Connect OpenRouter</button>
211
+ <button class="header-btn" type="button" data-openai-compatible-connect="true">Connect OpenAI-compatible</button>
212
+ </div>
213
+ </div>
179
214
  <div id="settings-list" class="settings-list">
180
215
  <div class="dropdown-empty">Loading settings...</div>
181
216
  </div>
@@ -235,6 +270,75 @@ MODEL=your-model-name</pre>
235
270
  </section>
236
271
  </div>
237
272
 
273
+ <div id="openai-compatible-connect-modal" class="modal hidden" aria-hidden="true">
274
+ <div id="openai-compatible-connect-backdrop" class="modal-backdrop"></div>
275
+ <section class="modal-panel modal-panel-compact" role="dialog" aria-modal="true" aria-labelledby="openai-compatible-connect-title">
276
+ <div class="modal-header">
277
+ <div>
278
+ <h2 id="openai-compatible-connect-title">Connect OpenAI-compatible</h2>
279
+ <p class="modal-subtitle">Configure an OpenAI-compatible endpoint for this <code>minicode serve</code> session and optionally save it for future runs.</p>
280
+ </div>
281
+ <button id="openai-compatible-connect-close" class="header-btn" type="button">Close</button>
282
+ </div>
283
+
284
+ <div class="openrouter-connect-body">
285
+ <p class="openrouter-connect-lead">
286
+ Pick a preset to prefill a common endpoint, then adjust it if needed. minicode will point this serve session at the OpenAI-compatible API endpoint you provide.
287
+ </p>
288
+
289
+ <div class="openrouter-connect-note">
290
+ <strong>Connection details</strong>
291
+ <p>Local runtimes like LM Studio or Ollama usually work without an API key, while hosted providers like OpenAI typically require one. The endpoint stays editable no matter which preset you choose.</p>
292
+ </div>
293
+
294
+ <label class="provider-input-group" for="openai-compatible-preset">
295
+ <span class="provider-input-label">Preset</span>
296
+ <select id="openai-compatible-preset" class="provider-input">
297
+ <option value="lmstudio">LM Studio</option>
298
+ <option value="openai">OpenAI</option>
299
+ <option value="ollama">Ollama</option>
300
+ <option value="custom">Custom</option>
301
+ </select>
302
+ </label>
303
+
304
+ <p id="openai-compatible-preset-help" class="openrouter-connect-help">
305
+ LM Studio pre-fills the default local server endpoint at <code>http://localhost:1234/v1</code>.
306
+ </p>
307
+
308
+ <label class="provider-input-group" for="openai-compatible-base-url">
309
+ <span class="provider-input-label">Endpoint</span>
310
+ <input id="openai-compatible-base-url" class="provider-input" type="text" value="http://localhost:1234/v1" spellcheck="false" autocomplete="off" />
311
+ </label>
312
+
313
+ <label class="provider-input-group" for="openai-compatible-api-key">
314
+ <span class="provider-input-label">API key (optional)</span>
315
+ <input id="openai-compatible-api-key" class="provider-input" type="password" placeholder="Leave blank for local providers that do not require auth" spellcheck="false" autocomplete="off" />
316
+ </label>
317
+
318
+ <label class="openrouter-persist-toggle" for="openai-compatible-persist-checkbox">
319
+ <input id="openai-compatible-persist-checkbox" type="checkbox" />
320
+ <span>Save the OpenAI-compatible provider defaults to <code>~/.minicode/.env</code> after connecting.</span>
321
+ </label>
322
+
323
+ <p class="openrouter-connect-help">
324
+ When enabled, minicode will update <code>MODEL_PROVIDER</code> and <code>OPENAI_BASE_URL</code>, and it will also write <code>OPENAI_API_KEY</code> if you provide one. Nothing is written unless you check the box.
325
+ </p>
326
+
327
+ <p id="openai-compatible-connect-status" class="config-connect-status hidden"></p>
328
+ </div>
329
+
330
+ <div class="modal-footer">
331
+ <p class="settings-footnote">
332
+ This uses the same OpenAI-compatible runtime path minicode already uses for local and hosted providers.
333
+ </p>
334
+ <div class="settings-actions">
335
+ <button id="openai-compatible-connect-cancel" class="header-btn" type="button">Cancel</button>
336
+ <button id="openai-compatible-connect-continue" class="dropdown-action" type="button">Continue</button>
337
+ </div>
338
+ </div>
339
+ </section>
340
+ </div>
341
+
238
342
  <div id="file-preview-modal" class="modal hidden" aria-hidden="true">
239
343
  <div id="file-preview-backdrop" class="modal-backdrop"></div>
240
344
  <section class="modal-panel modal-panel-file-preview" role="dialog" aria-modal="true" aria-labelledby="file-preview-title">
@@ -90,24 +90,51 @@ h1 {
90
90
  }
91
91
 
92
92
  #model-dropdown {
93
- max-height: 320px;
93
+ width: min(420px, calc(100vw - 32px));
94
+ max-height: 360px;
95
+ }
96
+
97
+ #model-search {
98
+ width: 100%;
99
+ background: var(--bg);
100
+ border: 1px solid var(--border);
101
+ border-radius: 4px;
102
+ color: var(--text);
103
+ font-family: var(--font-mono);
104
+ font-size: 12px;
105
+ padding: 6px 8px;
106
+ }
107
+
108
+ #model-search:focus {
109
+ outline: none;
110
+ border-color: var(--accent);
94
111
  }
95
112
 
96
113
  #model-list {
97
- max-height: 280px;
114
+ max-height: 300px;
98
115
  overflow-y: auto;
99
116
  }
100
117
 
118
+ .dropdown-search-section {
119
+ padding-bottom: 10px;
120
+ }
121
+
101
122
  .model-item {
102
- padding: 6px 8px;
123
+ display: flex;
124
+ align-items: center;
125
+ justify-content: space-between;
126
+ gap: 12px;
127
+ width: 100%;
128
+ padding: 8px;
103
129
  border-radius: 4px;
104
130
  cursor: pointer;
131
+ border: none;
132
+ background: transparent;
133
+ text-align: left;
105
134
  font-size: 12px;
106
135
  transition: background 0.15s;
107
136
  color: var(--text);
108
- overflow: hidden;
109
- text-overflow: ellipsis;
110
- white-space: nowrap;
137
+ font-family: var(--font-mono);
111
138
  }
112
139
 
113
140
  .model-item:hover {
@@ -115,8 +142,40 @@ h1 {
115
142
  }
116
143
 
117
144
  .model-item.active {
145
+ background: rgba(122, 162, 247, 0.12);
146
+ outline: 1px solid rgba(122, 162, 247, 0.35);
147
+ }
148
+
149
+ .model-item-body {
150
+ display: flex;
151
+ flex: 1;
152
+ flex-direction: column;
153
+ min-width: 0;
154
+ }
155
+
156
+ .model-item-name {
157
+ color: var(--text);
158
+ font-weight: 500;
159
+ overflow: hidden;
160
+ text-overflow: ellipsis;
161
+ white-space: nowrap;
162
+ }
163
+
164
+ .model-item-subtitle {
165
+ color: var(--text-dim);
166
+ font-size: 11px;
167
+ overflow: hidden;
168
+ text-overflow: ellipsis;
169
+ white-space: nowrap;
170
+ }
171
+
172
+ .model-item-badge {
118
173
  color: var(--accent);
174
+ font-size: 11px;
119
175
  font-weight: 600;
176
+ flex-shrink: 0;
177
+ letter-spacing: 0.04em;
178
+ text-transform: uppercase;
120
179
  }
121
180
 
122
181
  /* Session menu */
@@ -363,11 +422,17 @@ h1 {
363
422
  margin-bottom: 16px;
364
423
  }
365
424
 
366
- .config-overlay-spotlight {
425
+ .config-overlay-shortcuts {
367
426
  display: flex;
368
427
  flex-direction: column;
369
428
  gap: 12px;
370
429
  margin-bottom: 16px;
430
+ }
431
+
432
+ .config-overlay-spotlight {
433
+ display: flex;
434
+ flex-direction: column;
435
+ gap: 12px;
371
436
  padding: 14px 16px;
372
437
  border-radius: 8px;
373
438
  border: 1px solid rgba(122, 162, 247, 0.35);
@@ -377,6 +442,13 @@ h1 {
377
442
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03);
378
443
  }
379
444
 
445
+ .config-overlay-spotlight-secondary {
446
+ border-color: rgba(158, 206, 106, 0.24);
447
+ background:
448
+ linear-gradient(135deg, rgba(158, 206, 106, 0.14), rgba(122, 162, 247, 0.06)),
449
+ var(--bg-surface);
450
+ }
451
+
380
452
  .config-overlay-spotlight-copy {
381
453
  display: flex;
382
454
  flex-direction: column;
@@ -1188,6 +1260,31 @@ footer {
1188
1260
  flex-shrink: 0;
1189
1261
  }
1190
1262
 
1263
+ .settings-provider-shortcuts {
1264
+ display: flex;
1265
+ align-items: flex-start;
1266
+ justify-content: space-between;
1267
+ gap: 16px;
1268
+ margin-bottom: 16px;
1269
+ padding: 14px 16px;
1270
+ border-radius: 10px;
1271
+ border: 1px solid var(--border);
1272
+ background: rgba(26, 27, 38, 0.72);
1273
+ }
1274
+
1275
+ .settings-provider-shortcuts-copy {
1276
+ display: flex;
1277
+ flex-direction: column;
1278
+ gap: 4px;
1279
+ }
1280
+
1281
+ .settings-provider-shortcuts-actions {
1282
+ display: flex;
1283
+ gap: 8px;
1284
+ flex-wrap: wrap;
1285
+ justify-content: flex-end;
1286
+ }
1287
+
1191
1288
  .openrouter-connect-body {
1192
1289
  padding: 20px;
1193
1290
  display: flex;
@@ -1240,11 +1337,48 @@ footer {
1240
1337
  color: var(--text);
1241
1338
  }
1242
1339
 
1340
+ .provider-input-group {
1341
+ display: flex;
1342
+ flex-direction: column;
1343
+ gap: 6px;
1344
+ }
1345
+
1346
+ .provider-input-label {
1347
+ color: var(--text);
1348
+ font-size: 12px;
1349
+ font-weight: 500;
1350
+ }
1351
+
1352
+ .provider-input {
1353
+ width: 100%;
1354
+ background: var(--bg);
1355
+ border: 1px solid var(--border);
1356
+ border-radius: 6px;
1357
+ color: var(--text);
1358
+ font-family: var(--font-mono);
1359
+ font-size: 12px;
1360
+ padding: 9px 10px;
1361
+ }
1362
+
1363
+ .provider-input:focus {
1364
+ outline: none;
1365
+ border-color: var(--accent);
1366
+ }
1367
+
1243
1368
  @media (max-width: 760px) {
1244
1369
  .settings-session-banner {
1245
1370
  flex-direction: column;
1246
1371
  align-items: flex-start;
1247
1372
  }
1373
+
1374
+ .settings-provider-shortcuts {
1375
+ flex-direction: column;
1376
+ }
1377
+
1378
+ .settings-provider-shortcuts-actions {
1379
+ width: 100%;
1380
+ justify-content: flex-start;
1381
+ }
1248
1382
  }
1249
1383
 
1250
1384
  .settings-list::-webkit-scrollbar,
@@ -59,6 +59,21 @@ function createEchoTool() {
59
59
  execute: async (input) => `echo:${String(input.value)}`,
60
60
  };
61
61
  }
62
+ function assertToolCallTranscriptIsComplete(messages) {
63
+ for (let i = 0; i < messages.length; i += 1) {
64
+ const message = messages[i];
65
+ if (message?.role !== "assistant" || !message.toolCalls?.length) {
66
+ continue;
67
+ }
68
+ const toolCalls = message.toolCalls;
69
+ for (let offset = 0; offset < toolCalls.length; offset += 1) {
70
+ const toolCall = toolCalls[offset];
71
+ const toolResult = messages[i + offset + 1];
72
+ assert.equal(toolResult?.role, "tool");
73
+ assert.equal(toolResult?.role === "tool" ? toolResult.toolCallId : undefined, toolCall.id);
74
+ }
75
+ }
76
+ }
62
77
  test("agent executes tool calls and returns final assistant text", async () => {
63
78
  const responses = [
64
79
  {
@@ -96,6 +111,7 @@ test("agent stops on repeated identical tool calls", async () => {
96
111
  });
97
112
  const { text } = await agent.runTurn("Do something");
98
113
  assert.match(text, /repeated identical tool calls/);
114
+ assertToolCallTranscriptIsComplete(agent.getSession().getMessages());
99
115
  });
100
116
  test("agent tells the user how to continue when the turn call limit is reached", async () => {
101
117
  const config = createTestAgentConfig("/tmp");
@@ -11,7 +11,7 @@ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { afterEach, describe, test } from "node:test";
14
- import { loadAgentConfig, resolveConfigEnv, getConfigSetupMessage, getConfigMissing, } from "../src/agent/config.js";
14
+ import { loadAgentConfig, resolveConfigEnv, getConfigSetupMessage, getConfiguredProvider, getConfigMissing, } from "../src/agent/config.js";
15
15
  import { buildStructuredConfigPayload } from "../src/agent/editable-config.js";
16
16
  import { createRequestHandler } from "../src/serve/server.js";
17
17
  import { AgentBridge } from "../src/serve/agent-bridge.js";
@@ -637,6 +637,40 @@ describe("/api/status needsSetup", () => {
637
637
  assert.equal(body.model, "my-test-model");
638
638
  assert.equal(body.provider, "openai-compatible");
639
639
  });
640
+ test("status endpoint reports no configured provider for a fresh install using defaults", async () => {
641
+ const baseDir = await mkdtemp(path.join(os.tmpdir(), "minicode-integ-"));
642
+ tempDirs.push(baseDir);
643
+ const minicodeHome = path.join(baseDir, "new-home");
644
+ const config = {
645
+ ...createTestAgentConfig("/tmp"),
646
+ modelProvider: "openai-compatible",
647
+ model: "",
648
+ openAiBaseUrl: "http://localhost:1234/v1",
649
+ };
650
+ const bridge = new ConfigurableBridge(config);
651
+ const base = await startServer(bridge, { minicodeHome });
652
+ const res = await fetch(`${base}/api/status`);
653
+ const body = await res.json();
654
+ assert.equal(body.configuredProvider, null);
655
+ assert.equal(body.needsSetup, true);
656
+ assert.ok(body.missing.some((m) => m.includes("MODEL")));
657
+ });
658
+ test("status endpoint reports openai-compatible when localhost is explicitly configured", async () => {
659
+ const minicodeHome = await createTestHome({
660
+ config: {
661
+ modelProvider: "openai-compatible",
662
+ openAiBaseUrl: "http://localhost:1234/v1",
663
+ },
664
+ });
665
+ const config = await loadAgentConfig("/tmp", { minicodeHome });
666
+ const bridge = new ConfigurableBridge(config);
667
+ const base = await startServer(bridge, { minicodeHome });
668
+ const res = await fetch(`${base}/api/status`);
669
+ const body = await res.json();
670
+ assert.equal(body.configuredProvider, "openai-compatible");
671
+ assert.equal(body.needsSetup, true);
672
+ assert.ok(body.missing.some((m) => m.includes("MODEL")));
673
+ });
640
674
  });
641
675
  // ═══════════════════════════════════════════════════════════════════
642
676
  // /api/context — graceful degradation when agent not ready
@@ -823,3 +857,59 @@ describe("realistic user scenarios", () => {
823
857
  });
824
858
  });
825
859
  });
860
+ describe("getConfiguredProvider", () => {
861
+ test("returns null for a fresh install using only fallback localhost defaults", async () => {
862
+ const base = await mkdtemp(path.join(os.tmpdir(), "minicode-integ-"));
863
+ tempDirs.push(base);
864
+ const minicodeHome = path.join(base, "new-home");
865
+ await withEnv({
866
+ MODEL: undefined,
867
+ MODEL_PROVIDER: undefined,
868
+ OPENAI_BASE_URL: undefined,
869
+ OPENAI_API_KEY: undefined,
870
+ OPENROUTER_API_KEY: undefined,
871
+ ANTHROPIC_API_KEY: undefined,
872
+ }, async () => {
873
+ const config = await loadAgentConfig("/tmp", { minicodeHome });
874
+ const resolvedEnv = await resolveConfigEnv({ minicodeHome });
875
+ assert.equal(getConfiguredProvider(config, resolvedEnv.values), null);
876
+ });
877
+ });
878
+ test("returns openrouter when OpenRouter is explicitly configured", async () => {
879
+ const home = await createTestHome({
880
+ config: {
881
+ modelProvider: "openai-compatible",
882
+ openAiBaseUrl: "https://openrouter.ai/api/v1",
883
+ },
884
+ dotenv: "OPENROUTER_API_KEY=sk-or-test-key\n",
885
+ });
886
+ await withEnv({
887
+ MODEL_PROVIDER: undefined,
888
+ OPENAI_BASE_URL: undefined,
889
+ OPENAI_API_KEY: undefined,
890
+ OPENROUTER_API_KEY: undefined,
891
+ }, async () => {
892
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
893
+ const resolvedEnv = await resolveConfigEnv({ minicodeHome: home });
894
+ assert.equal(getConfiguredProvider(config, resolvedEnv.values), "openrouter");
895
+ });
896
+ });
897
+ test("returns openai-compatible when a local endpoint is explicitly configured", async () => {
898
+ const home = await createTestHome({
899
+ config: {
900
+ modelProvider: "openai-compatible",
901
+ openAiBaseUrl: "http://localhost:1234/v1",
902
+ },
903
+ });
904
+ await withEnv({
905
+ MODEL_PROVIDER: undefined,
906
+ OPENAI_BASE_URL: undefined,
907
+ OPENAI_API_KEY: undefined,
908
+ OPENROUTER_API_KEY: undefined,
909
+ }, async () => {
910
+ const config = await loadAgentConfig("/tmp", { minicodeHome: home });
911
+ const resolvedEnv = await resolveConfigEnv({ minicodeHome: home });
912
+ assert.equal(getConfiguredProvider(config, resolvedEnv.values), "openai-compatible");
913
+ });
914
+ });
915
+ });
@@ -91,6 +91,18 @@ test("run_command refresh removes deleted files from the index", async () => {
91
91
  await runTool.execute({ command: "rm src/util.ts" });
92
92
  assert.equal(index.getSymbol("add"), undefined, "deleted file should be removed from the index");
93
93
  });
94
+ test("run_command times out commands that ignore SIGTERM", async () => {
95
+ const workspaceRoot = await createTempWorkspace();
96
+ const config = createTestAgentConfig(workspaceRoot);
97
+ config.commandTimeoutMs = 100;
98
+ const runTool = createRunCommandTool(config);
99
+ const start = Date.now();
100
+ const output = await runTool.execute({
101
+ command: `node -e 'process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'`,
102
+ });
103
+ assert.match(output, /timed_out: true/);
104
+ assert.ok(Date.now() - start < 5_000, "run_command should return after the timeout and kill grace period");
105
+ });
94
106
  test("read_file supports negative offset and line limits", async () => {
95
107
  const workspaceRoot = await createTempWorkspace();
96
108
  const filePath = path.join(workspaceRoot, "lines.txt");
@@ -24,6 +24,7 @@ test('built HTML contains #cy graph container', () => {
24
24
  assert.ok(html.includes('id="cy"'), 'HTML should contain the #cy graph container');
25
25
  assert.ok(html.includes('id="graph-pane"'), 'HTML should contain the #graph-pane wrapper');
26
26
  assert.ok(html.includes('Search symbols or files...'), 'HTML should expose mixed symbol/file search');
27
+ assert.ok(html.includes('id="graph-refresh"'), 'HTML should expose a graph refresh button');
27
28
  assert.ok(html.includes('id="file-preview-modal"'), 'HTML should contain the file preview modal shell');
28
29
  assert.ok(html.includes('id="file-preview-code"'), 'HTML should contain the file preview code surface');
29
30
  });
@@ -67,6 +68,13 @@ test('built JS supports file search results and file-centered neighborhood rende
67
68
  assert.ok(js.includes('/api/file-source?path='), 'JS should fetch full file contents for the preview modal');
68
69
  assert.ok(js.includes('detail-file-link'), 'JS should wire the symbol detail filename into the preview modal');
69
70
  });
71
+ test('built JS refreshes graph data manually and after mutating tool calls', () => {
72
+ const js = readFileSync(join(distWeb, 'app.js'), 'utf-8');
73
+ assert.ok(js.includes('/api/index/refresh'), 'manual graph refresh should call the backend index refresh endpoint');
74
+ assert.ok(js.includes('refreshGraphData'), 'JS should define a graph data refresh helper');
75
+ assert.ok(js.includes('scheduleGraphDataRefresh'), 'JS should debounce automatic graph refreshes after tool calls');
76
+ assert.ok(js.includes('"write_file"') && js.includes('"edit_file"') && js.includes('"run_command"'), 'mutating tools should trigger a graph data refresh so new symbols appear in search');
77
+ });
70
78
  test('built CSS contains file preview modal sizing and link styling', () => {
71
79
  const css = readFileSync(join(distWeb, 'style.css'), 'utf-8');
72
80
  assert.ok(css.includes('.modal-panel-file-preview'), 'CSS should contain the file preview modal panel class');
@@ -102,6 +102,47 @@ test("openai-compatible client sends correct app URL in HTTP-Referer header", as
102
102
  assert.equal(capturedHeaders["HTTP-Referer"], "https://minicode.seanholung.com", "HTTP-Referer should point to minicode.seanholung.com");
103
103
  assert.equal(capturedHeaders["X-Title"], "minicode");
104
104
  });
105
+ test("openai-compatible client repairs missing tool results before sending", async () => {
106
+ let capturedBody = "";
107
+ const fetchImpl = async (_input, init) => {
108
+ capturedBody = String(init?.body ?? "");
109
+ return new Response(JSON.stringify({
110
+ choices: [
111
+ {
112
+ finish_reason: "stop",
113
+ message: { content: "ok", tool_calls: [] },
114
+ },
115
+ ],
116
+ usage: { prompt_tokens: 5, completion_tokens: 3 },
117
+ }), { status: 200, headers: { "content-type": "application/json" } });
118
+ };
119
+ const client = new OpenAICompatibleModelClient({
120
+ baseUrl: "http://localhost:1234/v1",
121
+ fetchImpl,
122
+ });
123
+ await client.chat({
124
+ model: "test-model",
125
+ system: "sys",
126
+ messages: [
127
+ { role: "user", content: "start" },
128
+ {
129
+ role: "assistant",
130
+ content: "checking",
131
+ toolCalls: [{ id: "call-missing", name: "read_file", input: { path: "src/index.ts" } }],
132
+ },
133
+ { role: "user", content: "continue" },
134
+ ],
135
+ tools: [],
136
+ maxTokens: 64,
137
+ });
138
+ const parsedBody = JSON.parse(capturedBody);
139
+ const messages = parsedBody.messages;
140
+ assert.equal(messages[2]?.role, "assistant");
141
+ assert.equal(messages[3]?.role, "tool");
142
+ assert.equal(messages[3]?.tool_call_id, "call-missing");
143
+ assert.match(String(messages[3]?.content), /Tool result unavailable/);
144
+ assert.equal(messages[4]?.role, "user");
145
+ });
105
146
  test("createModelClient returns openai-compatible client", () => {
106
147
  const config = {
107
148
  ...createTestAgentConfig("/tmp"),
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { test } from "node:test";
5
+ const distWeb = join(import.meta.dirname, "..", "dist", "src", "web");
6
+ test("built HTML contains model search input", () => {
7
+ const html = readFileSync(join(distWeb, "index.html"), "utf8");
8
+ assert.ok(html.includes('id="model-search"'), "HTML should expose a dedicated model search input");
9
+ assert.ok(html.includes('placeholder="Search models..."'), "HTML should prompt users to search models");
10
+ });
11
+ test("built CSS contains searchable model dropdown styling", () => {
12
+ const css = readFileSync(join(distWeb, "style.css"), "utf8");
13
+ assert.ok(css.includes("#model-search"), "CSS should style the model search input");
14
+ assert.ok(css.includes(".model-item-body"), "CSS should support stacked model result content");
15
+ assert.ok(css.includes(".model-item-badge"), "CSS should style the active model badge");
16
+ });
17
+ test("built JS contains searchable model dropdown behavior", () => {
18
+ const js = readFileSync(join(distWeb, "app.js"), "utf8");
19
+ assert.ok(js.includes("filterModelsByQuery"), "JS should filter models by the dropdown query");
20
+ assert.ok(js.includes("focusModelSearchInput"), "JS should focus the search field when opening the dropdown");
21
+ assert.ok(js.includes("No matching models"), "JS should render an empty state for unmatched queries");
22
+ assert.ok(js.includes('modelSearchInput.addEventListener("input"'), "JS should update results as the user types");
23
+ });
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
- import { sortModelsAlphabetically } from "../src/model-utils.js";
3
+ import { filterModelsByQuery, getModelDisplayName, sortModelsAlphabetically } from "../src/model-utils.js";
4
4
  test("sortModelsAlphabetically sorts by display name without mutating input", () => {
5
5
  const models = [
6
6
  { id: "zeta-2", name: "Zeta 2" },
@@ -20,3 +20,28 @@ test("sortModelsAlphabetically uses id as a stable tiebreaker", () => {
20
20
  const sorted = sortModelsAlphabetically(models);
21
21
  assert.deepEqual(sorted.map((model) => model.id), ["gpt-4.1-a", "gpt-4.1-b"]);
22
22
  });
23
+ test("getModelDisplayName falls back to id and trims whitespace", () => {
24
+ assert.equal(getModelDisplayName({ id: "google/gemini-2.5-flash-preview", name: " Gemini 2.5 Flash " }), "Gemini 2.5 Flash");
25
+ assert.equal(getModelDisplayName({ id: "qwen/qwen3-coder" }), "qwen/qwen3-coder");
26
+ });
27
+ test("filterModelsByQuery matches on display name and id without mutating order", () => {
28
+ const models = [
29
+ { id: "z-ai/glm-4.5-air", name: "GLM 4.5 Air" },
30
+ { id: "google/gemini-2.5-flash-preview", name: "Gemini 2.5 Flash" },
31
+ { id: "openai/gpt-4.1-mini", name: "GPT-4.1 Mini" },
32
+ ];
33
+ const byName = filterModelsByQuery(models, "gemini flash");
34
+ const byId = filterModelsByQuery(models, "glm-4.5");
35
+ const blank = filterModelsByQuery(models, " ");
36
+ assert.deepEqual(byName.map((model) => model.id), ["google/gemini-2.5-flash-preview"]);
37
+ assert.deepEqual(byId.map((model) => model.id), ["z-ai/glm-4.5-air"]);
38
+ assert.deepEqual(blank.map((model) => model.id), models.map((model) => model.id));
39
+ });
40
+ test("filterModelsByQuery returns all tokens match across name and id", () => {
41
+ const models = [
42
+ { id: "google/gemini-2.5-flash-preview", name: "Gemini Flash Preview" },
43
+ { id: "google/gemini-2.5-pro-preview", name: "Gemini Pro Preview" },
44
+ ];
45
+ const filtered = filterModelsByQuery(models, "google flash");
46
+ assert.deepEqual(filtered.map((model) => model.id), ["google/gemini-2.5-flash-preview"]);
47
+ });