@sean.holung/minicode 0.3.6 → 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 (50) hide show
  1. package/README.md +2 -1
  2. package/dist/scripts/run-benchmarks.js +1 -0
  3. package/dist/src/agent/config.js +27 -0
  4. package/dist/src/agent/editable-config.js +6 -0
  5. package/dist/src/model-utils.js +18 -1
  6. package/dist/src/serve/agent-bridge.js +85 -14
  7. package/dist/src/serve/mcp-server.js +19 -13
  8. package/dist/src/serve/server.js +166 -3
  9. package/dist/src/session/session-store.js +18 -0
  10. package/dist/src/shared/symbol-search.js +156 -0
  11. package/dist/src/tools/search-code-map.js +27 -35
  12. package/dist/src/web/app.js +662 -113
  13. package/dist/src/web/index.html +128 -8
  14. package/dist/src/web/style.css +189 -7
  15. package/dist/tests/agent.test.js +16 -0
  16. package/dist/tests/config-api.test.js +5 -0
  17. package/dist/tests/config-integration.test.js +91 -1
  18. package/dist/tests/config.test.js +9 -0
  19. package/dist/tests/file-tools.test.js +12 -0
  20. package/dist/tests/graph-onboarding.test.js +20 -0
  21. package/dist/tests/mcp-and-plugin.test.js +3 -0
  22. package/dist/tests/model-client-openai.test.js +41 -0
  23. package/dist/tests/model-dropdown-ui.test.js +23 -0
  24. package/dist/tests/model-utils.test.js +26 -1
  25. package/dist/tests/search-code-map.test.js +9 -0
  26. package/dist/tests/serve.integration.test.js +189 -0
  27. package/dist/tests/session-store.test.js +15 -1
  28. package/dist/tests/settings-ui.test.js +11 -0
  29. package/dist/tests/setup-overlay-state.test.js +49 -0
  30. package/dist/tests/system-prompt.test.js +1 -0
  31. package/dist/tests/test-utils.js +1 -0
  32. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
  33. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +10 -1
  34. package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
  35. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +1 -0
  36. package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
  37. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +8 -1
  38. package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
  39. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +164 -27
  40. package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
  41. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.d.ts.map +1 -1
  42. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js +60 -6
  43. package/node_modules/@minicode/agent-sdk/dist/src/tools/run-command.js.map +1 -1
  44. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js +87 -0
  45. package/node_modules/@minicode/agent-sdk/dist/tests/model-client-openai.test.js.map +1 -1
  46. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.d.ts.map +1 -1
  47. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js +1 -0
  48. package/node_modules/@minicode/agent-sdk/dist/tests/test-utils.js.map +1 -1
  49. package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
  50. package/package.json +1 -1
@@ -42,6 +42,7 @@ test("MCP POST with malformed JSON returns 400 with JSON-RPC error", async () =>
42
42
  model: "test",
43
43
  maxSteps: 10,
44
44
  maxTokens: 1024,
45
+ modelTimeoutSeconds: 60,
45
46
  maxContextTokens: 4000,
46
47
  workspaceRoot: "/tmp/mcp-test",
47
48
  commandTimeoutMs: 5000,
@@ -88,6 +89,7 @@ test("MCP POST without session ID and non-init request returns 400", async () =>
88
89
  model: "test",
89
90
  maxSteps: 10,
90
91
  maxTokens: 1024,
92
+ modelTimeoutSeconds: 60,
91
93
  maxContextTokens: 4000,
92
94
  workspaceRoot: "/tmp/mcp-test",
93
95
  commandTimeoutMs: 5000,
@@ -132,6 +134,7 @@ test("MCP PATCH returns 405", async () => {
132
134
  model: "test",
133
135
  maxSteps: 10,
134
136
  maxTokens: 1024,
137
+ modelTimeoutSeconds: 60,
135
138
  maxContextTokens: 4000,
136
139
  workspaceRoot: "/tmp/mcp-test",
137
140
  commandTimeoutMs: 5000,
@@ -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
+ });
@@ -20,6 +20,15 @@ test("search_code_map returns empty when no match", async () => {
20
20
  const result = await tool.execute({ pattern: "XyZNoSymbol123" });
21
21
  assert.ok(result.includes("No symbols matching"));
22
22
  });
23
+ test("search_code_map returns similar matches when exact substring lookup fails", async () => {
24
+ const root = path.resolve(import.meta.dirname, "..");
25
+ const projectIndex = await buildProjectIndex(root);
26
+ const tool = createSearchCodeMapTool(projectIndex);
27
+ const result = await tool.execute({ pattern: "ModelRespnse" });
28
+ assert.ok(result.includes('No exact substring matches for "ModelRespnse"'));
29
+ assert.ok(result.includes("Showing similar symbols instead"));
30
+ assert.ok(result.includes("ModelResponse"));
31
+ });
23
32
  test("search_code_map appears in tool registry when projectIndex provided", async () => {
24
33
  const root = path.resolve(import.meta.dirname, "..");
25
34
  const projectIndex = await buildProjectIndex(root);
@@ -7,6 +7,7 @@ import path from "node:path";
7
7
  import { createRequestHandler, shutdownServe } from "../src/serve/server.js";
8
8
  import { AgentBridge } from "../src/serve/agent-bridge.js";
9
9
  import { Session } from "@minicode/agent-sdk";
10
+ import { DuplicateSessionLabelError } from "../src/session/session-store.js";
10
11
  import { createTestAgentConfig } from "./test-utils.js";
11
12
  /**
12
13
  * Lightweight AgentBridge subclass for testing.
@@ -20,6 +21,9 @@ class MockBridge extends AgentBridge {
20
21
  turnHistory = [];
21
22
  openRouterKey;
22
23
  openRouterSessionActive = false;
24
+ openAiCompatibleApiKey;
25
+ openAiCompatibleSessionActive = false;
26
+ refreshIndexCount = 0;
23
27
  constructor() {
24
28
  super(() => { }, false);
25
29
  }
@@ -91,10 +95,26 @@ class MockBridge extends AgentBridge {
91
95
  connectOpenRouter(apiKey) {
92
96
  this.openRouterKey = apiKey;
93
97
  this.openRouterSessionActive = true;
98
+ this.openAiCompatibleApiKey = undefined;
99
+ this.openAiCompatibleSessionActive = false;
94
100
  this._config.modelProvider = "openai-compatible";
95
101
  this._config.openAiBaseUrl = "https://openrouter.ai/api/v1";
96
102
  this._config.openAiApiKey = apiKey;
97
103
  }
104
+ connectOpenAiCompatible(baseUrl, apiKey) {
105
+ this.openAiCompatibleSessionActive = true;
106
+ this.openRouterKey = undefined;
107
+ this.openRouterSessionActive = false;
108
+ this.openAiCompatibleApiKey = apiKey?.trim() || undefined;
109
+ this._config.modelProvider = "openai-compatible";
110
+ this._config.openAiBaseUrl = baseUrl.trim().replace(/\/+$/, "");
111
+ if (this.openAiCompatibleApiKey) {
112
+ this._config.openAiApiKey = this.openAiCompatibleApiKey;
113
+ }
114
+ else {
115
+ delete this._config.openAiApiKey;
116
+ }
117
+ }
98
118
  disconnectOpenRouter() {
99
119
  if (!this.openRouterSessionActive) {
100
120
  return false;
@@ -110,6 +130,21 @@ class MockBridge extends AgentBridge {
110
130
  isOpenRouterSessionConnected() {
111
131
  return this.openRouterSessionActive;
112
132
  }
133
+ disconnectOpenAiCompatible() {
134
+ if (!this.openAiCompatibleSessionActive) {
135
+ return false;
136
+ }
137
+ this.openAiCompatibleSessionActive = false;
138
+ this.openAiCompatibleApiKey = undefined;
139
+ this._config.modelProvider = this._baseConfig.modelProvider;
140
+ this._config.model = this._baseConfig.model;
141
+ this._config.openAiBaseUrl = this._baseConfig.openAiBaseUrl;
142
+ delete this._config.openAiApiKey;
143
+ return true;
144
+ }
145
+ isOpenAiCompatibleSessionConnected() {
146
+ return this.openAiCompatibleSessionActive;
147
+ }
113
148
  switchModel(modelId) {
114
149
  this._config.model = modelId;
115
150
  }
@@ -119,6 +154,10 @@ class MockBridge extends AgentBridge {
119
154
  hasIndex() {
120
155
  return true;
121
156
  }
157
+ async refreshIndex() {
158
+ this.refreshIndexCount += 1;
159
+ return true;
160
+ }
122
161
  getSymbols() {
123
162
  return [
124
163
  { name: "foo", qualifiedName: "foo", kind: "function", filePath: "src/foo.ts", startLine: 1, endLine: 5, signature: "function foo(): void", exported: true },
@@ -351,6 +390,11 @@ class EmptySessionsBridge extends MockBridge {
351
390
  };
352
391
  }
353
392
  }
393
+ class DuplicateSessionLabelBridge extends MockBridge {
394
+ async saveSess() {
395
+ throw new DuplicateSessionLabelError("test-session", "sess-1");
396
+ }
397
+ }
354
398
  // ── Test harness ──
355
399
  let activeServer;
356
400
  function startTestServer(bridge, options = {}) {
@@ -436,6 +480,18 @@ test("POST /api/sessions/save saves a session", async () => {
436
480
  const body = (await res.json());
437
481
  assert.equal(body.label, "my-save");
438
482
  });
483
+ test("POST /api/sessions/save rejects duplicate session labels", async () => {
484
+ const bridge = new DuplicateSessionLabelBridge();
485
+ const base = await startTestServer(bridge);
486
+ const res = await fetch(`${base}/api/sessions/save`, {
487
+ method: "POST",
488
+ headers: { "Content-Type": "application/json" },
489
+ body: JSON.stringify({ label: "test-session" }),
490
+ });
491
+ assert.equal(res.status, 409);
492
+ const body = (await res.json());
493
+ assert.match(body.error, /already exists/);
494
+ });
439
495
  test("session APIs support the first save when no sessions exist yet", async () => {
440
496
  const bridge = new EmptySessionsBridge();
441
497
  const base = await startTestServer(bridge);
@@ -653,6 +709,90 @@ test("GET /api/status exposes OpenRouter session state and base URL", async () =
653
709
  assert.equal(body.baseUrl, "https://openrouter.ai/api/v1");
654
710
  assert.equal(body.sessionOpenRouterConnected, true);
655
711
  });
712
+ test("POST /api/openai-compatible/connect stores a session-only endpoint", async () => {
713
+ const bridge = new MockBridge();
714
+ const base = await startTestServer(bridge);
715
+ const res = await fetch(`${base}/api/openai-compatible/connect`, {
716
+ method: "POST",
717
+ headers: { "Content-Type": "application/json" },
718
+ body: JSON.stringify({ baseUrl: "http://localhost:1234/v1/", apiKey: "local-key" }),
719
+ });
720
+ assert.equal(res.status, 200);
721
+ const body = await res.json();
722
+ assert.equal(body.ok, true);
723
+ assert.equal(body.sessionOnly, true);
724
+ assert.equal(body.persistedToEnv, false);
725
+ assert.equal(body.persistedEnvPath, null);
726
+ assert.equal(body.persistWarning, null);
727
+ assert.equal(body.baseUrl, "http://localhost:1234/v1");
728
+ assert.equal(body.needsSetup, false);
729
+ assert.deepEqual(body.missing, []);
730
+ assert.equal(body.message, "The OpenAI-compatible provider connected for this serve session.");
731
+ assert.equal(bridge.getConfig().modelProvider, "openai-compatible");
732
+ assert.equal(bridge.getConfig().openAiBaseUrl, "http://localhost:1234/v1");
733
+ assert.equal(bridge.getConfig().openAiApiKey, "local-key");
734
+ assert.equal(bridge.isOpenAiCompatibleSessionConnected(), true);
735
+ });
736
+ test("POST /api/openai-compatible/connect can persist the endpoint to ~/.minicode/.env", async () => {
737
+ const bridge = new MockBridge();
738
+ const minicodeHome = mkdtempSync(path.join(os.tmpdir(), "minicode-openai-compatible-home-"));
739
+ const base = await startTestServer(bridge, { minicodeHome });
740
+ try {
741
+ const res = await fetch(`${base}/api/openai-compatible/connect`, {
742
+ method: "POST",
743
+ headers: { "Content-Type": "application/json" },
744
+ body: JSON.stringify({
745
+ baseUrl: "http://127.0.0.1:1234/v1",
746
+ apiKey: "",
747
+ persistToEnv: true,
748
+ }),
749
+ });
750
+ assert.equal(res.status, 200);
751
+ const body = await res.json();
752
+ assert.equal(body.persistedToEnv, true);
753
+ assert.equal(body.persistWarning, null);
754
+ assert.equal(body.persistedEnvPath, path.join(minicodeHome, ".env"));
755
+ assert.match(body.message, /saved to ~\/\.minicode\/\.env/);
756
+ const envContents = readFileSync(path.join(minicodeHome, ".env"), "utf8");
757
+ assert.match(envContents, /^MODEL_PROVIDER=openai-compatible$/m);
758
+ assert.match(envContents, /^OPENAI_BASE_URL=http:\/\/127\.0\.0\.1:1234\/v1$/m);
759
+ assert.doesNotMatch(envContents, /^OPENAI_API_KEY=/m);
760
+ }
761
+ finally {
762
+ rmSync(minicodeHome, { recursive: true, force: true });
763
+ }
764
+ });
765
+ test("POST /api/openai-compatible/disconnect removes the session-only endpoint", async () => {
766
+ const bridge = new MockBridge();
767
+ bridge.connectOpenAiCompatible("http://localhost:1234/v1", "local-key");
768
+ const base = await startTestServer(bridge);
769
+ const res = await fetch(`${base}/api/openai-compatible/disconnect`, {
770
+ method: "POST",
771
+ headers: { "Content-Type": "application/json" },
772
+ });
773
+ assert.equal(res.status, 200);
774
+ const body = await res.json();
775
+ assert.equal(body.ok, true);
776
+ assert.equal(body.disconnected, true);
777
+ assert.equal(body.sessionOnly, true);
778
+ assert.equal(body.provider, "anthropic");
779
+ assert.equal(body.baseUrl, "http://localhost:1234/v1");
780
+ assert.equal(body.message, "Removed the session-only OpenAI-compatible connection and restored your original provider settings.");
781
+ assert.equal(bridge.isOpenAiCompatibleSessionConnected(), false);
782
+ assert.equal(bridge.getConfig().modelProvider, "anthropic");
783
+ assert.equal(bridge.getConfig().openAiApiKey, undefined);
784
+ });
785
+ test("GET /api/status exposes OpenAI-compatible session state and base URL", async () => {
786
+ const bridge = new MockBridge();
787
+ bridge.connectOpenAiCompatible("http://localhost:1234/v1");
788
+ const base = await startTestServer(bridge);
789
+ const res = await fetch(`${base}/api/status`);
790
+ assert.equal(res.status, 200);
791
+ const body = await res.json();
792
+ assert.equal(body.provider, "openai-compatible");
793
+ assert.equal(body.baseUrl, "http://localhost:1234/v1");
794
+ assert.equal(body.sessionOpenAiCompatibleConnected, true);
795
+ });
656
796
  test("POST /api/openrouter/connect returns 400 when code is missing", async () => {
657
797
  const bridge = new MockBridge();
658
798
  const base = await startTestServer(bridge);
@@ -688,6 +828,18 @@ test("POST /api/openrouter/connect surfaces exchange failures", async () => {
688
828
  globalThis.fetch = originalFetch;
689
829
  }
690
830
  });
831
+ test("POST /api/openai-compatible/connect rejects an invalid endpoint", async () => {
832
+ const bridge = new MockBridge();
833
+ const base = await startTestServer(bridge);
834
+ const res = await fetch(`${base}/api/openai-compatible/connect`, {
835
+ method: "POST",
836
+ headers: { "Content-Type": "application/json" },
837
+ body: JSON.stringify({ baseUrl: "localhost:1234/v1" }),
838
+ });
839
+ assert.equal(res.status, 400);
840
+ const body = await res.json();
841
+ assert.match(body.error, /valid absolute http\(s\) URL/);
842
+ });
691
843
  // ── OpenAI-compatible API tests ──
692
844
  test("GET /v1/models returns minicode-agent model", async () => {
693
845
  const bridge = new MockBridge();
@@ -928,6 +1080,17 @@ test("GET /api/graph returns nodes and edges", async () => {
928
1080
  assert.equal(body.edges[0].to, "Bar");
929
1081
  assert.equal(body.edges[0].kind, "calls");
930
1082
  });
1083
+ test("POST /api/index/refresh rebuilds the project index", async () => {
1084
+ const bridge = new MockBridge();
1085
+ const base = await startTestServer(bridge);
1086
+ const res = await fetch(`${base}/api/index/refresh`, { method: "POST" });
1087
+ assert.equal(res.status, 200);
1088
+ const body = (await res.json());
1089
+ assert.equal(body.ok, true);
1090
+ assert.equal(body.symbolCount, 2);
1091
+ assert.equal(body.edgeCount, 1);
1092
+ assert.equal(bridge.refreshIndexCount, 1);
1093
+ });
931
1094
  test("GET /api/analysis returns deterministic structural findings", async () => {
932
1095
  const bridge = new MockBridge();
933
1096
  const base = await startTestServer(bridge);
@@ -1079,6 +1242,32 @@ test("GET /api/symbols/:name/source returns 500 when file is missing", async ()
1079
1242
  const body = (await res.json());
1080
1243
  assert.ok(body.error.includes("src/foo.ts"));
1081
1244
  });
1245
+ test("GET /api/file-source returns full file contents for a workspace file", async () => {
1246
+ const bridge = new MockBridge();
1247
+ const base = await startTestServer(bridge);
1248
+ const wsRoot = "/tmp/test-workspace";
1249
+ mkdirSync(`${wsRoot}/src`, { recursive: true });
1250
+ writeFileSync(`${wsRoot}/src/foo.ts`, "line1\nfunction foo(): void {\n return;\n}\nline5\n");
1251
+ try {
1252
+ const res = await fetch(`${base}/api/file-source?path=${encodeURIComponent("src/foo.ts")}`);
1253
+ assert.equal(res.status, 200);
1254
+ const body = (await res.json());
1255
+ assert.equal(body.filePath, "src/foo.ts");
1256
+ assert.ok(body.source.includes("function foo(): void"));
1257
+ assert.ok(body.source.includes("line5"));
1258
+ }
1259
+ finally {
1260
+ rmSync(wsRoot, { recursive: true, force: true });
1261
+ }
1262
+ });
1263
+ test("GET /api/file-source rejects path traversal", async () => {
1264
+ const bridge = new MockBridge();
1265
+ const base = await startTestServer(bridge);
1266
+ const res = await fetch(`${base}/api/file-source?path=${encodeURIComponent("../secret.txt")}`);
1267
+ assert.equal(res.status, 403);
1268
+ const body = (await res.json());
1269
+ assert.ok(body.error.includes("Invalid workspace file path"));
1270
+ });
1082
1271
  // ── Annotations API tests ──
1083
1272
  test("GET /api/annotations returns empty annotations initially", async () => {
1084
1273
  const bridge = new MockBridge();
@@ -4,7 +4,7 @@ import { mkdtemp, readdir, rm } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
6
6
  import { Session } from "@minicode/agent-sdk";
7
- import { listSessions, loadSession, loadSessionByLabel, saveSession, setSessionsDir, } from "../src/session/session-store.js";
7
+ import { DuplicateSessionLabelError, listSessions, loadSession, loadSessionByLabel, saveSession, setSessionsDir, } from "../src/session/session-store.js";
8
8
  async function withTmpDir(fn) {
9
9
  const dir = await mkdtemp(path.join(os.tmpdir(), "minicode-test-"));
10
10
  setSessionsDir(dir);
@@ -98,6 +98,20 @@ test("loadSessionByLabel returns undefined for no match", async () => {
98
98
  assert.equal(result, undefined);
99
99
  });
100
100
  });
101
+ test("saveSession rejects duplicate labels for different sessions", async () => {
102
+ await withTmpDir(async (dir) => {
103
+ const firstSession = new Session("first-id");
104
+ firstSession.addMessage({ role: "user", content: "first" });
105
+ await saveSession(firstSession, "My Label");
106
+ const secondSession = new Session("second-id");
107
+ secondSession.addMessage({ role: "user", content: "second" });
108
+ await assert.rejects(() => saveSession(secondSession, " my label "), (error) => error instanceof DuplicateSessionLabelError &&
109
+ error.label === "my label" &&
110
+ error.existingSessionId === "first-id");
111
+ const files = await readdir(dir);
112
+ assert.deepEqual(files, ["first-id.json"]);
113
+ });
114
+ });
101
115
  test("saving same session twice overwrites the file", async () => {
102
116
  await withTmpDir(async (dir) => {
103
117
  const session = new Session("test-id");
@@ -8,11 +8,15 @@ test("built HTML contains settings entry point and modal shell", () => {
8
8
  assert.ok(html.includes('id="settings-btn"'), "HTML should contain the settings button");
9
9
  assert.ok(html.includes('id="settings-modal"'), "HTML should contain the settings modal");
10
10
  assert.ok(html.includes('id="connect-openrouter-btn"'), "HTML should contain the OpenRouter connect button");
11
+ assert.ok(html.includes('id="connect-openai-compatible-btn"'), "HTML should contain the OpenAI-compatible connect button");
11
12
  assert.ok(html.includes('id="config-overlay-intro"'), "HTML should contain the setup overlay intro copy");
12
13
  assert.ok(html.includes("Try minicode for free with OpenRouter"), "HTML should promote the free OpenRouter quick start");
13
14
  assert.ok(html.includes('id="openrouter-connect-modal"'), "HTML should contain the OpenRouter consent modal");
15
+ assert.ok(html.includes('id="openai-compatible-connect-modal"'), "HTML should contain the OpenAI-compatible connect modal");
16
+ assert.ok(html.includes('id="openai-compatible-preset"'), "HTML should contain the OpenAI-compatible preset selector");
14
17
  assert.ok(html.includes('id="openrouter-persist-checkbox"'), "HTML should contain the OpenRouter persistence checkbox");
15
18
  assert.ok(html.includes('id="disconnect-openrouter-btn"'), "HTML should contain the OpenRouter disconnect button");
19
+ assert.ok(html.includes('id="disconnect-openai-compatible-btn"'), "HTML should contain the OpenAI-compatible disconnect button");
16
20
  assert.ok(!html.includes('id="settings-scope"'), "HTML should no longer contain the settings scope selector");
17
21
  assert.ok(html.includes('id="settings-save"'), "HTML should contain the settings save action");
18
22
  });
@@ -24,9 +28,12 @@ test("built CSS contains modal and settings layout styles", () => {
24
28
  assert.ok(css.includes(".settings-help-warning"), "CSS should contain warning styling for env overrides");
25
29
  assert.ok(css.includes("align-items: flex-start;"), "CSS should top-align scrollable setup overlay content");
26
30
  assert.ok(css.includes(".config-overlay-spotlight"), "CSS should style the OpenRouter quick-start spotlight");
31
+ assert.ok(css.includes(".config-overlay-shortcuts"), "CSS should lay out the setup quick-connect cards");
27
32
  assert.ok(css.includes(".openrouter-connect-body"), "CSS should style the OpenRouter consent modal body");
33
+ assert.ok(css.includes(".provider-input"), "CSS should style provider connection form inputs");
28
34
  assert.ok(css.includes(".config-connect-status.success"), "CSS should style OpenRouter connect success state");
29
35
  assert.ok(css.includes(".settings-session-banner"), "CSS should style the OpenRouter session banner");
36
+ assert.ok(css.includes(".settings-provider-shortcuts"), "CSS should style provider shortcuts inside settings");
30
37
  assert.ok(css.includes("body.modal-open"), "CSS should lock scroll while the settings modal is open");
31
38
  });
32
39
  test("built JS contains config loading and saving logic for settings", () => {
@@ -34,11 +41,15 @@ test("built JS contains config loading and saving logic for settings", () => {
34
41
  assert.ok(js.includes("/api/config"), "JS should fetch the config API");
35
42
  assert.ok(js.includes("/api/openrouter/connect"), "JS should call the OpenRouter connect API");
36
43
  assert.ok(js.includes("/api/openrouter/disconnect"), "JS should call the OpenRouter disconnect API");
44
+ assert.ok(js.includes("/api/openai-compatible/connect"), "JS should call the OpenAI-compatible connect API");
45
+ assert.ok(js.includes("/api/openai-compatible/disconnect"), "JS should call the OpenAI-compatible disconnect API");
37
46
  assert.ok(js.includes("persistToHomeEnv"), "JS should support persisting the selected model after OpenRouter setup");
38
47
  assert.ok(js.includes("code_challenge_method"), "JS should generate an OpenRouter PKCE auth request");
39
48
  assert.ok(js.includes("sessionStorage"), "JS should persist the PKCE verifier for the OAuth callback");
40
49
  assert.ok(js.includes("minicode:openrouter:persist-to-env"), "JS should persist the optional OpenRouter env-write choice across OAuth");
41
50
  assert.ok(js.includes("sessionOpenRouterConnected"), "JS should track session-only OpenRouter state");
51
+ assert.ok(js.includes("sessionOpenAiCompatibleConnected"), "JS should track session-only OpenAI-compatible state");
52
+ assert.ok(js.includes("OPENAI_COMPATIBLE_PRESETS"), "JS should include OpenAI-compatible endpoint presets");
42
53
  assert.ok(js.includes("Save settings"), "JS should contain the settings save action text");
43
54
  assert.ok(js.includes("settingsPayload"), "JS should track settings payload state");
44
55
  assert.ok(js.includes("persistedValue"), "JS should wire persisted settings behavior");
@@ -0,0 +1,49 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { DEFAULT_SETUP_INTRO, deriveSetupOverlayState } from "../src/web/setup-overlay-state.js";
4
+ test("fresh install hides MODEL missing copy until a provider is configured", () => {
5
+ const state = deriveSetupOverlayState({
6
+ configuredProvider: null,
7
+ missing: ["MODEL is not set"],
8
+ });
9
+ assert.equal(state.introText, DEFAULT_SETUP_INTRO);
10
+ assert.equal(state.hideQuickConnects, false);
11
+ assert.equal(state.hideOpenRouterSpotlight, false);
12
+ assert.deepEqual(state.missingItems, []);
13
+ assert.equal(state.showModelSelectionHint, false);
14
+ assert.equal(state.modelSelectionNote, null);
15
+ });
16
+ test("configured OpenAI-compatible provider keeps model guidance visible", () => {
17
+ const state = deriveSetupOverlayState({
18
+ configuredProvider: "openai-compatible",
19
+ missing: ["MODEL is not set"],
20
+ });
21
+ assert.equal(state.introText, "An OpenAI-compatible provider is already configured. Select a model to continue:");
22
+ assert.equal(state.hideQuickConnects, true);
23
+ assert.equal(state.hideOpenRouterSpotlight, false);
24
+ assert.deepEqual(state.missingItems, ["MODEL is not set"]);
25
+ assert.equal(state.showModelSelectionHint, true);
26
+ assert.equal(state.modelSelectionNote, null);
27
+ });
28
+ test("configured OpenRouter provider keeps model guidance and hides spotlight", () => {
29
+ const state = deriveSetupOverlayState({
30
+ configuredProvider: "openrouter",
31
+ missing: ["MODEL is not set"],
32
+ });
33
+ assert.equal(state.introText, "OpenRouter is already configured. Select a model to continue:");
34
+ assert.equal(state.hideQuickConnects, true);
35
+ assert.equal(state.hideOpenRouterSpotlight, true);
36
+ assert.deepEqual(state.missingItems, ["MODEL is not set"]);
37
+ assert.equal(state.showModelSelectionHint, true);
38
+ assert.equal(state.modelSelectionNote, 'If you are on the OpenRouter free tier, search "free" in the model dropdown to find supported free models.');
39
+ });
40
+ test("non-model missing items still surface before provider selection", () => {
41
+ const state = deriveSetupOverlayState({
42
+ configuredProvider: null,
43
+ missing: ["SOME_OTHER_SETTING is not set"],
44
+ });
45
+ assert.equal(state.introText, DEFAULT_SETUP_INTRO);
46
+ assert.deepEqual(state.missingItems, ["SOME_OTHER_SETTING is not set"]);
47
+ assert.equal(state.showModelSelectionHint, false);
48
+ assert.equal(state.modelSelectionNote, null);
49
+ });
@@ -8,6 +8,7 @@ function createMinimalConfig(workspaceRoot) {
8
8
  model: "test",
9
9
  maxSteps: 10,
10
10
  maxTokens: 1024,
11
+ modelTimeoutSeconds: 60,
11
12
  maxContextTokens: 16_000,
12
13
  workspaceRoot,
13
14
  commandTimeoutMs: 5000,
@@ -4,6 +4,7 @@ export function createTestAgentConfig(workspaceRoot) {
4
4
  model: "test-model",
5
5
  maxSteps: 10,
6
6
  maxTokens: 1024,
7
+ modelTimeoutSeconds: 60,
7
8
  maxContextTokens: 16_000,
8
9
  workspaceRoot,
9
10
  commandTimeoutMs: 2_000,
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../../src/agent/agent.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../../../src/agent/agent.ts"],"names":[],"mappings":"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"}
@@ -343,7 +343,8 @@ export class CodingAgent {
343
343
  content: thinkingContent,
344
344
  toolCalls: response.toolCalls,
345
345
  });
346
- for (const toolCall of response.toolCalls) {
346
+ for (let toolCallIndex = 0; toolCallIndex < response.toolCalls.length; toolCallIndex += 1) {
347
+ const toolCall = response.toolCalls[toolCallIndex];
347
348
  const fingerprint = signatureForToolCall(toolCall);
348
349
  recentToolCallFingerprints.push(fingerprint);
349
350
  if (recentToolCallFingerprints.length >
@@ -353,6 +354,14 @@ export class CodingAgent {
353
354
  const repeatedCalls = recentToolCallFingerprints.filter((value) => value === fingerprint).length;
354
355
  if (repeatedCalls >= 3) {
355
356
  const loopMessage = "Stopped due to repeated identical tool calls. Please refine the prompt or provide additional constraints.";
357
+ for (const skippedToolCall of response.toolCalls.slice(toolCallIndex)) {
358
+ this.session.addMessage({
359
+ role: "tool",
360
+ toolCallId: skippedToolCall.id,
361
+ toolName: skippedToolCall.name,
362
+ content: `Tool skipped: ${loopMessage}`,
363
+ });
364
+ }
356
365
  this.session.addMessage({
357
366
  role: "assistant",
358
367
  content: loopMessage,