@sean.holung/minicode 0.3.5 → 0.3.6
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.
- package/README.md +21 -45
- package/dist/src/agent/config.js +51 -66
- package/dist/src/agent/editable-config.js +50 -58
- package/dist/src/agent/home-env.js +74 -0
- package/dist/src/cli/config-slash-command.js +15 -13
- package/dist/src/serve/agent-bridge.js +87 -28
- package/dist/src/serve/server.js +161 -4
- package/dist/src/session/session-preview.js +14 -0
- package/dist/src/shared/graph-search.js +80 -0
- package/dist/src/shared/graph-selection.js +40 -0
- package/dist/src/web/app.js +494 -56
- package/dist/src/web/index.html +68 -6
- package/dist/src/web/style.css +208 -1
- package/dist/tests/config-api.test.js +5 -5
- package/dist/tests/config-integration.test.js +130 -56
- package/dist/tests/config-slash-command.test.js +12 -11
- package/dist/tests/config.test.js +12 -4
- package/dist/tests/editable-config.test.js +15 -12
- package/dist/tests/graph-onboarding.test.js +10 -1
- package/dist/tests/graph-search.test.js +66 -0
- package/dist/tests/graph-selection.test.js +58 -0
- package/dist/tests/home-env.test.js +56 -0
- package/dist/tests/serve.integration.test.js +229 -6
- package/dist/tests/session-preview.test.js +56 -0
- package/dist/tests/session-ui.test.js +2 -0
- package/dist/tests/settings-ui.test.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { buildFileFocusedSelection, buildGraphEdgeId, buildGraphEdgeIndex, } from "../src/shared/graph-selection.js";
|
|
4
|
+
test("buildGraphEdgeIndex indexes edges by both source and target", () => {
|
|
5
|
+
const edges = [
|
|
6
|
+
{ source: "Review#class", target: "Session.trim", kind: "references" },
|
|
7
|
+
{ source: "renderReview", target: "Review#type", kind: "references" },
|
|
8
|
+
];
|
|
9
|
+
const edgeIndex = buildGraphEdgeIndex(edges);
|
|
10
|
+
assert.deepEqual(edgeIndex.get("Review#class"), [edges[0]]);
|
|
11
|
+
assert.deepEqual(edgeIndex.get("Session.trim"), [edges[0]]);
|
|
12
|
+
assert.deepEqual(edgeIndex.get("renderReview"), [edges[1]]);
|
|
13
|
+
assert.deepEqual(edgeIndex.get("Review#type"), [edges[1]]);
|
|
14
|
+
});
|
|
15
|
+
test("buildFileFocusedSelection includes file symbols and touching neighbors", () => {
|
|
16
|
+
const fileIndex = new Map([
|
|
17
|
+
["src/review.ts", ["Review#class", "Review#type"]],
|
|
18
|
+
]);
|
|
19
|
+
const edges = [
|
|
20
|
+
{ source: "Review#class", target: "Session.trim", kind: "references" },
|
|
21
|
+
{ source: "renderReview", target: "Review#type", kind: "references" },
|
|
22
|
+
{ source: "Review#class", target: "Review#type", kind: "references" },
|
|
23
|
+
];
|
|
24
|
+
const selection = buildFileFocusedSelection({
|
|
25
|
+
filePath: "src/review.ts",
|
|
26
|
+
fileIndex,
|
|
27
|
+
edgeIndex: buildGraphEdgeIndex(edges),
|
|
28
|
+
});
|
|
29
|
+
assert.deepEqual(new Set(selection.nodeIds), new Set(["Review#class", "Review#type", "Session.trim", "renderReview"]));
|
|
30
|
+
assert.deepEqual(new Set(selection.edges.map((edge) => buildGraphEdgeId(edge))), new Set(edges.map((edge) => buildGraphEdgeId(edge))));
|
|
31
|
+
});
|
|
32
|
+
test("buildFileFocusedSelection excludes edges between external neighbors", () => {
|
|
33
|
+
const fileIndex = new Map([
|
|
34
|
+
["src/review.ts", ["Review#class"]],
|
|
35
|
+
]);
|
|
36
|
+
const reviewToSession = { source: "Review#class", target: "Session.trim", kind: "references" };
|
|
37
|
+
const reviewToRender = { source: "Review#class", target: "renderReview", kind: "references" };
|
|
38
|
+
const sessionToRender = { source: "Session.trim", target: "renderReview", kind: "references" };
|
|
39
|
+
const edges = [reviewToSession, reviewToRender, sessionToRender];
|
|
40
|
+
const selection = buildFileFocusedSelection({
|
|
41
|
+
filePath: "src/review.ts",
|
|
42
|
+
fileIndex,
|
|
43
|
+
edgeIndex: buildGraphEdgeIndex(edges),
|
|
44
|
+
});
|
|
45
|
+
assert.deepEqual(new Set(selection.nodeIds), new Set(["Review#class", "Session.trim", "renderReview"]));
|
|
46
|
+
assert.deepEqual(new Set(selection.edges.map((edge) => buildGraphEdgeId(edge))), new Set([
|
|
47
|
+
buildGraphEdgeId(reviewToSession),
|
|
48
|
+
buildGraphEdgeId(reviewToRender),
|
|
49
|
+
]));
|
|
50
|
+
});
|
|
51
|
+
test("buildFileFocusedSelection returns an empty selection for unknown files", () => {
|
|
52
|
+
const selection = buildFileFocusedSelection({
|
|
53
|
+
filePath: "src/missing.ts",
|
|
54
|
+
fileIndex: new Map(),
|
|
55
|
+
edgeIndex: buildGraphEdgeIndex([]),
|
|
56
|
+
});
|
|
57
|
+
assert.deepEqual(selection, { nodeIds: [], edges: [] });
|
|
58
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { afterEach, test } from "node:test";
|
|
6
|
+
import { getHomeEnvPath, upsertHomeEnvValues } from "../src/agent/home-env.js";
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
10
|
+
});
|
|
11
|
+
test("upsertHomeEnvValues creates ~/.minicode/.env when missing", async () => {
|
|
12
|
+
const minicodeHome = await mkdtemp(path.join(os.tmpdir(), "minicode-home-env-"));
|
|
13
|
+
tempDirs.push(minicodeHome);
|
|
14
|
+
const result = await upsertHomeEnvValues({
|
|
15
|
+
minicodeHome,
|
|
16
|
+
values: {
|
|
17
|
+
MODEL_PROVIDER: "openai-compatible",
|
|
18
|
+
OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
|
|
19
|
+
OPENROUTER_API_KEY: "sk-or-v1-test",
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
assert.equal(result.path, getHomeEnvPath(minicodeHome));
|
|
23
|
+
const contents = await readFile(result.path, "utf8");
|
|
24
|
+
assert.match(contents, /^MODEL_PROVIDER=openai-compatible/m);
|
|
25
|
+
assert.match(contents, /^OPENAI_BASE_URL=https:\/\/openrouter\.ai\/api\/v1/m);
|
|
26
|
+
assert.match(contents, /^OPENROUTER_API_KEY=sk-or-v1-test/m);
|
|
27
|
+
});
|
|
28
|
+
test("upsertHomeEnvValues replaces existing keys and preserves unrelated lines", async () => {
|
|
29
|
+
const minicodeHome = await mkdtemp(path.join(os.tmpdir(), "minicode-home-env-"));
|
|
30
|
+
tempDirs.push(minicodeHome);
|
|
31
|
+
const envPath = getHomeEnvPath(minicodeHome);
|
|
32
|
+
await writeFile(envPath, [
|
|
33
|
+
"# Existing config",
|
|
34
|
+
"MODEL_PROVIDER=anthropic",
|
|
35
|
+
"OPENAI_BASE_URL=https://example.invalid/v1",
|
|
36
|
+
"OPENROUTER_API_KEY=old-key",
|
|
37
|
+
"OPENROUTER_API_KEY=older-key",
|
|
38
|
+
"MODEL=existing-model",
|
|
39
|
+
"",
|
|
40
|
+
].join("\n"), "utf8");
|
|
41
|
+
await upsertHomeEnvValues({
|
|
42
|
+
minicodeHome,
|
|
43
|
+
values: {
|
|
44
|
+
MODEL_PROVIDER: "openai-compatible",
|
|
45
|
+
OPENAI_BASE_URL: "https://openrouter.ai/api/v1",
|
|
46
|
+
OPENROUTER_API_KEY: "sk-or-v1-new",
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const contents = await readFile(envPath, "utf8");
|
|
50
|
+
assert.match(contents, /^# Existing config/m);
|
|
51
|
+
assert.match(contents, /^MODEL_PROVIDER=openai-compatible$/m);
|
|
52
|
+
assert.match(contents, /^OPENAI_BASE_URL=https:\/\/openrouter\.ai\/api\/v1$/m);
|
|
53
|
+
assert.match(contents, /^OPENROUTER_API_KEY=sk-or-v1-new$/m);
|
|
54
|
+
assert.match(contents, /^MODEL=existing-model$/m);
|
|
55
|
+
assert.equal(contents.match(/^OPENROUTER_API_KEY=/gm)?.length, 1);
|
|
56
|
+
});
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test, afterEach } from "node:test";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
-
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
5
7
|
import { createRequestHandler, shutdownServe } from "../src/serve/server.js";
|
|
6
8
|
import { AgentBridge } from "../src/serve/agent-bridge.js";
|
|
9
|
+
import { Session } from "@minicode/agent-sdk";
|
|
7
10
|
import { createTestAgentConfig } from "./test-utils.js";
|
|
8
11
|
/**
|
|
9
12
|
* Lightweight AgentBridge subclass for testing.
|
|
@@ -12,7 +15,11 @@ import { createTestAgentConfig } from "./test-utils.js";
|
|
|
12
15
|
class MockBridge extends AgentBridge {
|
|
13
16
|
_busy = false;
|
|
14
17
|
_currentSessionId = "sess-1";
|
|
18
|
+
_baseConfig = createTestAgentConfig("/tmp/test-workspace");
|
|
19
|
+
_config = createTestAgentConfig("/tmp/test-workspace");
|
|
15
20
|
turnHistory = [];
|
|
21
|
+
openRouterKey;
|
|
22
|
+
openRouterSessionActive = false;
|
|
16
23
|
constructor() {
|
|
17
24
|
super(() => { }, false);
|
|
18
25
|
}
|
|
@@ -20,7 +27,7 @@ class MockBridge extends AgentBridge {
|
|
|
20
27
|
return this._busy;
|
|
21
28
|
}
|
|
22
29
|
getConfig() {
|
|
23
|
-
return
|
|
30
|
+
return this._config;
|
|
24
31
|
}
|
|
25
32
|
async runTurn(message) {
|
|
26
33
|
if (this._busy)
|
|
@@ -65,11 +72,47 @@ class MockBridge extends AgentBridge {
|
|
|
65
72
|
async loadSess(label) {
|
|
66
73
|
if (label === "nonexistent")
|
|
67
74
|
return null;
|
|
68
|
-
|
|
75
|
+
const session = new Session("sess-1");
|
|
76
|
+
session.addMessage({
|
|
77
|
+
role: "user",
|
|
78
|
+
content: "[Conversation Summary — earlier messages were compacted to save context]\nOlder summary",
|
|
79
|
+
});
|
|
80
|
+
for (let i = 1; i <= 11; i += 1) {
|
|
81
|
+
session.addMessage({
|
|
82
|
+
role: i % 2 === 0 ? "assistant" : "user",
|
|
83
|
+
content: `message-${i}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { session, label };
|
|
69
87
|
}
|
|
70
88
|
getCurrentSessionId() {
|
|
71
89
|
return this._currentSessionId;
|
|
72
90
|
}
|
|
91
|
+
connectOpenRouter(apiKey) {
|
|
92
|
+
this.openRouterKey = apiKey;
|
|
93
|
+
this.openRouterSessionActive = true;
|
|
94
|
+
this._config.modelProvider = "openai-compatible";
|
|
95
|
+
this._config.openAiBaseUrl = "https://openrouter.ai/api/v1";
|
|
96
|
+
this._config.openAiApiKey = apiKey;
|
|
97
|
+
}
|
|
98
|
+
disconnectOpenRouter() {
|
|
99
|
+
if (!this.openRouterSessionActive) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
this.openRouterSessionActive = false;
|
|
103
|
+
this.openRouterKey = undefined;
|
|
104
|
+
this._config.modelProvider = this._baseConfig.modelProvider;
|
|
105
|
+
this._config.model = this._baseConfig.model;
|
|
106
|
+
this._config.openAiBaseUrl = this._baseConfig.openAiBaseUrl;
|
|
107
|
+
delete this._config.openAiApiKey;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
isOpenRouterSessionConnected() {
|
|
111
|
+
return this.openRouterSessionActive;
|
|
112
|
+
}
|
|
113
|
+
switchModel(modelId) {
|
|
114
|
+
this._config.model = modelId;
|
|
115
|
+
}
|
|
73
116
|
setBusy(busy) {
|
|
74
117
|
this._busy = busy;
|
|
75
118
|
}
|
|
@@ -310,8 +353,8 @@ class EmptySessionsBridge extends MockBridge {
|
|
|
310
353
|
}
|
|
311
354
|
// ── Test harness ──
|
|
312
355
|
let activeServer;
|
|
313
|
-
function startTestServer(bridge) {
|
|
314
|
-
const handler = createRequestHandler(bridge);
|
|
356
|
+
function startTestServer(bridge, options = {}) {
|
|
357
|
+
const handler = createRequestHandler(bridge, undefined, options);
|
|
315
358
|
const server = createServer(handler);
|
|
316
359
|
activeServer = server;
|
|
317
360
|
return new Promise((resolve) => {
|
|
@@ -367,7 +410,7 @@ test("GET /api/config returns formatted config plus structured settings", async
|
|
|
367
410
|
assert.ok(body.config.includes("test-model"));
|
|
368
411
|
assert.equal(body.restartRequired, true);
|
|
369
412
|
assert.equal(body.secretsUiSupported, false);
|
|
370
|
-
assert.ok(body.settings.configPath.endsWith("/.minicode
|
|
413
|
+
assert.ok(body.settings.configPath.endsWith("/.minicode/.env"));
|
|
371
414
|
assert.ok(body.settings.entries.some((entry) => entry.key === "maxSteps"));
|
|
372
415
|
});
|
|
373
416
|
test("GET /api/sessions returns session list", async () => {
|
|
@@ -432,6 +475,10 @@ test("POST /api/sessions/load returns success for known session", async () => {
|
|
|
432
475
|
assert.equal(res.status, 200);
|
|
433
476
|
const body = (await res.json());
|
|
434
477
|
assert.equal(body.label, "test-session");
|
|
478
|
+
assert.equal(body.messages.length, 10);
|
|
479
|
+
assert.equal(body.messages[0]?.content, "message-2");
|
|
480
|
+
assert.equal(body.messages[9]?.content, "message-11");
|
|
481
|
+
assert.ok(body.messages.every((message) => !message.content.startsWith("[Conversation Summary")));
|
|
435
482
|
});
|
|
436
483
|
test("POST /api/chat returns agent response", async () => {
|
|
437
484
|
const bridge = new MockBridge();
|
|
@@ -468,6 +515,179 @@ test("POST /api/chat returns 429 when agent is busy", async () => {
|
|
|
468
515
|
});
|
|
469
516
|
assert.equal(res.status, 429);
|
|
470
517
|
});
|
|
518
|
+
test("POST /api/openrouter/connect exchanges code and stores a session-only key", async () => {
|
|
519
|
+
const bridge = new MockBridge();
|
|
520
|
+
const base = await startTestServer(bridge);
|
|
521
|
+
const originalFetch = globalThis.fetch;
|
|
522
|
+
globalThis.fetch = async (input, init) => {
|
|
523
|
+
if (String(input) !== "https://openrouter.ai/api/v1/auth/keys") {
|
|
524
|
+
return originalFetch(input, init);
|
|
525
|
+
}
|
|
526
|
+
assert.equal(init?.method, "POST");
|
|
527
|
+
assert.equal((init?.headers)["Content-Type"], "application/json");
|
|
528
|
+
const body = JSON.parse(String(init?.body));
|
|
529
|
+
assert.equal(body.code, "oauth-code");
|
|
530
|
+
assert.equal(body.code_verifier, "pkce-verifier");
|
|
531
|
+
assert.equal(body.code_challenge_method, "S256");
|
|
532
|
+
return new Response(JSON.stringify({ key: "sk-or-v1-session-key" }), { status: 200, headers: { "content-type": "application/json" } });
|
|
533
|
+
};
|
|
534
|
+
try {
|
|
535
|
+
const res = await originalFetch(`${base}/api/openrouter/connect`, {
|
|
536
|
+
method: "POST",
|
|
537
|
+
headers: { "Content-Type": "application/json" },
|
|
538
|
+
body: JSON.stringify({ code: "oauth-code", codeVerifier: "pkce-verifier" }),
|
|
539
|
+
});
|
|
540
|
+
assert.equal(res.status, 200);
|
|
541
|
+
const body = await res.json();
|
|
542
|
+
assert.equal(body.ok, true);
|
|
543
|
+
assert.equal(body.sessionOnly, true);
|
|
544
|
+
assert.equal(body.persistedToEnv, false);
|
|
545
|
+
assert.equal(body.persistedEnvPath, null);
|
|
546
|
+
assert.equal(body.persistWarning, null);
|
|
547
|
+
assert.equal(body.baseUrl, "https://openrouter.ai/api/v1");
|
|
548
|
+
assert.equal(body.needsSetup, false);
|
|
549
|
+
assert.deepEqual(body.missing, []);
|
|
550
|
+
assert.equal(body.message, "OpenRouter connected for this serve session.");
|
|
551
|
+
assert.equal(bridge.openRouterKey, "sk-or-v1-session-key");
|
|
552
|
+
assert.equal(bridge.getConfig().modelProvider, "openai-compatible");
|
|
553
|
+
assert.equal(bridge.getConfig().openAiBaseUrl, "https://openrouter.ai/api/v1");
|
|
554
|
+
assert.equal(bridge.getConfig().openAiApiKey, "sk-or-v1-session-key");
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
globalThis.fetch = originalFetch;
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
test("POST /api/openrouter/connect can persist OpenRouter setup to ~/.minicode/.env", async () => {
|
|
561
|
+
const bridge = new MockBridge();
|
|
562
|
+
const minicodeHome = mkdtempSync(path.join(os.tmpdir(), "minicode-openrouter-home-"));
|
|
563
|
+
const base = await startTestServer(bridge, { minicodeHome });
|
|
564
|
+
const originalFetch = globalThis.fetch;
|
|
565
|
+
globalThis.fetch = async (input, init) => {
|
|
566
|
+
if (String(input) !== "https://openrouter.ai/api/v1/auth/keys") {
|
|
567
|
+
return originalFetch(input, init);
|
|
568
|
+
}
|
|
569
|
+
return new Response(JSON.stringify({ key: "sk-or-v1-session-key" }), { status: 200, headers: { "content-type": "application/json" } });
|
|
570
|
+
};
|
|
571
|
+
try {
|
|
572
|
+
const res = await originalFetch(`${base}/api/openrouter/connect`, {
|
|
573
|
+
method: "POST",
|
|
574
|
+
headers: { "Content-Type": "application/json" },
|
|
575
|
+
body: JSON.stringify({ code: "oauth-code", codeVerifier: "pkce-verifier", persistToEnv: true }),
|
|
576
|
+
});
|
|
577
|
+
assert.equal(res.status, 200);
|
|
578
|
+
const body = await res.json();
|
|
579
|
+
assert.equal(body.persistedToEnv, true);
|
|
580
|
+
assert.equal(body.persistWarning, null);
|
|
581
|
+
assert.equal(body.persistedEnvPath, path.join(minicodeHome, ".env"));
|
|
582
|
+
assert.match(body.message, /saved to ~\/\.minicode\/\.env/);
|
|
583
|
+
const envContents = readFileSync(path.join(minicodeHome, ".env"), "utf8");
|
|
584
|
+
assert.match(envContents, /^MODEL_PROVIDER=openai-compatible$/m);
|
|
585
|
+
assert.match(envContents, /^OPENAI_BASE_URL=https:\/\/openrouter\.ai\/api\/v1$/m);
|
|
586
|
+
assert.match(envContents, /^OPENROUTER_API_KEY=sk-or-v1-session-key$/m);
|
|
587
|
+
}
|
|
588
|
+
finally {
|
|
589
|
+
globalThis.fetch = originalFetch;
|
|
590
|
+
rmSync(minicodeHome, { recursive: true, force: true });
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
test("POST /api/model persists the selected model to ~/.minicode/.env", async () => {
|
|
594
|
+
const bridge = new MockBridge();
|
|
595
|
+
const minicodeHome = mkdtempSync(path.join(os.tmpdir(), "minicode-model-home-"));
|
|
596
|
+
const envPath = path.join(minicodeHome, ".env");
|
|
597
|
+
writeFileSync(envPath, [
|
|
598
|
+
"MODEL_PROVIDER=openai-compatible",
|
|
599
|
+
"OPENAI_BASE_URL=https://openrouter.ai/api/v1",
|
|
600
|
+
"OPENROUTER_API_KEY=sk-or-v1-session-key",
|
|
601
|
+
"",
|
|
602
|
+
].join("\n"), "utf8");
|
|
603
|
+
const base = await startTestServer(bridge, { minicodeHome });
|
|
604
|
+
try {
|
|
605
|
+
const res = await fetch(`${base}/api/model`, {
|
|
606
|
+
method: "POST",
|
|
607
|
+
headers: { "Content-Type": "application/json" },
|
|
608
|
+
body: JSON.stringify({ model: "openrouter/test-model" }),
|
|
609
|
+
});
|
|
610
|
+
assert.equal(res.status, 200);
|
|
611
|
+
const body = await res.json();
|
|
612
|
+
assert.equal(body.model, "openrouter/test-model");
|
|
613
|
+
assert.equal(body.persistedToEnv, true);
|
|
614
|
+
assert.equal(body.persistedEnvPath, envPath);
|
|
615
|
+
const envContents = readFileSync(envPath, "utf8");
|
|
616
|
+
assert.match(envContents, /^MODEL=openrouter\/test-model$/m);
|
|
617
|
+
assert.match(envContents, /^OPENROUTER_API_KEY=sk-or-v1-session-key$/m);
|
|
618
|
+
assert.equal(bridge.getConfig().model, "openrouter/test-model");
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
rmSync(minicodeHome, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
test("POST /api/openrouter/disconnect removes the session-only OpenRouter connection", async () => {
|
|
625
|
+
const bridge = new MockBridge();
|
|
626
|
+
bridge.connectOpenRouter("sk-or-v1-session-key");
|
|
627
|
+
const base = await startTestServer(bridge);
|
|
628
|
+
const res = await fetch(`${base}/api/openrouter/disconnect`, {
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: { "Content-Type": "application/json" },
|
|
631
|
+
});
|
|
632
|
+
assert.equal(res.status, 200);
|
|
633
|
+
const body = await res.json();
|
|
634
|
+
assert.equal(body.ok, true);
|
|
635
|
+
assert.equal(body.disconnected, true);
|
|
636
|
+
assert.equal(body.sessionOnly, true);
|
|
637
|
+
assert.equal(body.provider, "anthropic");
|
|
638
|
+
assert.equal(body.baseUrl, "http://localhost:1234/v1");
|
|
639
|
+
assert.equal(body.message, "Removed the session-only OpenRouter connection and restored your original provider settings.");
|
|
640
|
+
assert.equal(bridge.openRouterSessionActive, false);
|
|
641
|
+
assert.equal(bridge.getConfig().modelProvider, "anthropic");
|
|
642
|
+
assert.equal(bridge.getConfig().openAiBaseUrl, "http://localhost:1234/v1");
|
|
643
|
+
assert.equal(bridge.getConfig().openAiApiKey, undefined);
|
|
644
|
+
});
|
|
645
|
+
test("GET /api/status exposes OpenRouter session state and base URL", async () => {
|
|
646
|
+
const bridge = new MockBridge();
|
|
647
|
+
bridge.connectOpenRouter("sk-or-v1-session-key");
|
|
648
|
+
const base = await startTestServer(bridge);
|
|
649
|
+
const res = await fetch(`${base}/api/status`);
|
|
650
|
+
assert.equal(res.status, 200);
|
|
651
|
+
const body = await res.json();
|
|
652
|
+
assert.equal(body.provider, "openai-compatible");
|
|
653
|
+
assert.equal(body.baseUrl, "https://openrouter.ai/api/v1");
|
|
654
|
+
assert.equal(body.sessionOpenRouterConnected, true);
|
|
655
|
+
});
|
|
656
|
+
test("POST /api/openrouter/connect returns 400 when code is missing", async () => {
|
|
657
|
+
const bridge = new MockBridge();
|
|
658
|
+
const base = await startTestServer(bridge);
|
|
659
|
+
const res = await fetch(`${base}/api/openrouter/connect`, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
headers: { "Content-Type": "application/json" },
|
|
662
|
+
body: JSON.stringify({ codeVerifier: "pkce-verifier" }),
|
|
663
|
+
});
|
|
664
|
+
assert.equal(res.status, 400);
|
|
665
|
+
});
|
|
666
|
+
test("POST /api/openrouter/connect surfaces exchange failures", async () => {
|
|
667
|
+
const bridge = new MockBridge();
|
|
668
|
+
const base = await startTestServer(bridge);
|
|
669
|
+
const originalFetch = globalThis.fetch;
|
|
670
|
+
globalThis.fetch = async (input, init) => {
|
|
671
|
+
if (String(input) !== "https://openrouter.ai/api/v1/auth/keys") {
|
|
672
|
+
return originalFetch(input, init);
|
|
673
|
+
}
|
|
674
|
+
return new Response("Invalid code", { status: 403 });
|
|
675
|
+
};
|
|
676
|
+
try {
|
|
677
|
+
const res = await originalFetch(`${base}/api/openrouter/connect`, {
|
|
678
|
+
method: "POST",
|
|
679
|
+
headers: { "Content-Type": "application/json" },
|
|
680
|
+
body: JSON.stringify({ code: "bad-code", codeVerifier: "pkce-verifier" }),
|
|
681
|
+
});
|
|
682
|
+
assert.equal(res.status, 403);
|
|
683
|
+
const body = await res.json();
|
|
684
|
+
assert.ok(body.error.includes("OpenRouter OAuth exchange failed"));
|
|
685
|
+
assert.ok(body.error.includes("Invalid code"));
|
|
686
|
+
}
|
|
687
|
+
finally {
|
|
688
|
+
globalThis.fetch = originalFetch;
|
|
689
|
+
}
|
|
690
|
+
});
|
|
471
691
|
// ── OpenAI-compatible API tests ──
|
|
472
692
|
test("GET /v1/models returns minicode-agent model", async () => {
|
|
473
693
|
const bridge = new MockBridge();
|
|
@@ -576,6 +796,7 @@ test("GET / serves index.html", async () => {
|
|
|
576
796
|
const res = await fetch(`${base}/`);
|
|
577
797
|
assert.equal(res.status, 200);
|
|
578
798
|
assert.equal(res.headers.get("content-type"), "text/html");
|
|
799
|
+
assert.equal(res.headers.get("cache-control"), "no-store");
|
|
579
800
|
const html = await res.text();
|
|
580
801
|
assert.ok(html.includes("minicode"));
|
|
581
802
|
});
|
|
@@ -585,6 +806,7 @@ test("GET /style.css serves CSS file", async () => {
|
|
|
585
806
|
const res = await fetch(`${base}/style.css`);
|
|
586
807
|
assert.equal(res.status, 200);
|
|
587
808
|
assert.equal(res.headers.get("content-type"), "text/css");
|
|
809
|
+
assert.equal(res.headers.get("cache-control"), "no-store");
|
|
588
810
|
});
|
|
589
811
|
test("GET /app.js serves JS file", async () => {
|
|
590
812
|
const bridge = new MockBridge();
|
|
@@ -592,6 +814,7 @@ test("GET /app.js serves JS file", async () => {
|
|
|
592
814
|
const res = await fetch(`${base}/app.js`);
|
|
593
815
|
assert.equal(res.status, 200);
|
|
594
816
|
assert.equal(res.headers.get("content-type"), "application/javascript");
|
|
817
|
+
assert.equal(res.headers.get("cache-control"), "no-store");
|
|
595
818
|
});
|
|
596
819
|
test("GET /nonexistent returns 404", async () => {
|
|
597
820
|
const bridge = new MockBridge();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { buildSessionPreview, isCompactionSummaryMessage, } from "../src/session/session-preview.js";
|
|
4
|
+
test("isCompactionSummaryMessage detects compacted summary stubs", () => {
|
|
5
|
+
assert.equal(isCompactionSummaryMessage({
|
|
6
|
+
role: "user",
|
|
7
|
+
content: "[Conversation Summary — earlier messages were compacted to save context]\nSummary text",
|
|
8
|
+
}), true);
|
|
9
|
+
assert.equal(isCompactionSummaryMessage({
|
|
10
|
+
role: "assistant",
|
|
11
|
+
content: "[Conversation Summary — earlier messages were compacted to save context]\nSummary text",
|
|
12
|
+
}), false);
|
|
13
|
+
});
|
|
14
|
+
test("buildSessionPreview filters compaction summaries and keeps the last ten messages", () => {
|
|
15
|
+
const preview = buildSessionPreview([
|
|
16
|
+
{
|
|
17
|
+
role: "user",
|
|
18
|
+
content: "[Conversation Summary — earlier messages were compacted using LLM summarization]\nSummary text",
|
|
19
|
+
},
|
|
20
|
+
{ role: "user", content: "message-1" },
|
|
21
|
+
{ role: "assistant", content: "message-2" },
|
|
22
|
+
{ role: "user", content: "message-3" },
|
|
23
|
+
{ role: "assistant", content: "message-4" },
|
|
24
|
+
{ role: "user", content: "message-5" },
|
|
25
|
+
{ role: "assistant", content: "message-6" },
|
|
26
|
+
{ role: "user", content: "message-7" },
|
|
27
|
+
{ role: "assistant", content: "message-8" },
|
|
28
|
+
{ role: "user", content: "message-9" },
|
|
29
|
+
{ role: "assistant", content: "message-10" },
|
|
30
|
+
{ role: "user", content: "message-11" },
|
|
31
|
+
]);
|
|
32
|
+
assert.equal(preview.length, 10);
|
|
33
|
+
assert.equal(preview[0]?.content, "message-2");
|
|
34
|
+
assert.equal(preview[9]?.content, "message-11");
|
|
35
|
+
assert.ok(preview.every((message) => !message.content.startsWith("[Conversation Summary")));
|
|
36
|
+
});
|
|
37
|
+
test("buildSessionPreview preserves tool messages in order", () => {
|
|
38
|
+
const preview = buildSessionPreview([
|
|
39
|
+
{
|
|
40
|
+
role: "assistant",
|
|
41
|
+
content: "Let me check that",
|
|
42
|
+
toolCalls: [{ id: "tool-1", name: "search", input: { query: "foo" } }],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
role: "tool",
|
|
46
|
+
toolCallId: "tool-1",
|
|
47
|
+
toolName: "search",
|
|
48
|
+
content: "search output",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
role: "assistant",
|
|
52
|
+
content: "Found it",
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
assert.deepEqual(preview.map((message) => message.role), ["assistant", "tool", "assistant"]);
|
|
56
|
+
});
|
|
@@ -20,4 +20,6 @@ test("built JS contains active saved session update logic", () => {
|
|
|
20
20
|
assert.ok(js.includes("Session updated:"), "JS should emit the update confirmation message");
|
|
21
21
|
assert.ok(js.includes("sessionRefreshTracker"), "JS should guard session list refreshes against stale responses");
|
|
22
22
|
assert.ok(js.includes('saveBtn.setAttribute("disabled", "true")'), "JS should disable saving while the first save is in flight");
|
|
23
|
+
assert.ok(js.includes("renderLoadedSessionMessages"), "JS should render session previews after load");
|
|
24
|
+
assert.ok(js.includes("body.messages"), "JS should read preview messages from the load session response");
|
|
23
25
|
});
|
|
@@ -7,6 +7,12 @@ test("built HTML contains settings entry point and modal shell", () => {
|
|
|
7
7
|
const html = readFileSync(join(distWeb, "index.html"), "utf8");
|
|
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
|
+
assert.ok(html.includes('id="connect-openrouter-btn"'), "HTML should contain the OpenRouter connect button");
|
|
11
|
+
assert.ok(html.includes('id="config-overlay-intro"'), "HTML should contain the setup overlay intro copy");
|
|
12
|
+
assert.ok(html.includes("Try minicode for free with OpenRouter"), "HTML should promote the free OpenRouter quick start");
|
|
13
|
+
assert.ok(html.includes('id="openrouter-connect-modal"'), "HTML should contain the OpenRouter consent modal");
|
|
14
|
+
assert.ok(html.includes('id="openrouter-persist-checkbox"'), "HTML should contain the OpenRouter persistence checkbox");
|
|
15
|
+
assert.ok(html.includes('id="disconnect-openrouter-btn"'), "HTML should contain the OpenRouter disconnect button");
|
|
10
16
|
assert.ok(!html.includes('id="settings-scope"'), "HTML should no longer contain the settings scope selector");
|
|
11
17
|
assert.ok(html.includes('id="settings-save"'), "HTML should contain the settings save action");
|
|
12
18
|
});
|
|
@@ -16,11 +22,23 @@ test("built CSS contains modal and settings layout styles", () => {
|
|
|
16
22
|
assert.ok(css.includes(".settings-list"), "CSS should contain settings list styles");
|
|
17
23
|
assert.ok(css.includes(".settings-item-meta"), "CSS should contain settings metadata grid styles");
|
|
18
24
|
assert.ok(css.includes(".settings-help-warning"), "CSS should contain warning styling for env overrides");
|
|
25
|
+
assert.ok(css.includes("align-items: flex-start;"), "CSS should top-align scrollable setup overlay content");
|
|
26
|
+
assert.ok(css.includes(".config-overlay-spotlight"), "CSS should style the OpenRouter quick-start spotlight");
|
|
27
|
+
assert.ok(css.includes(".openrouter-connect-body"), "CSS should style the OpenRouter consent modal body");
|
|
28
|
+
assert.ok(css.includes(".config-connect-status.success"), "CSS should style OpenRouter connect success state");
|
|
29
|
+
assert.ok(css.includes(".settings-session-banner"), "CSS should style the OpenRouter session banner");
|
|
19
30
|
assert.ok(css.includes("body.modal-open"), "CSS should lock scroll while the settings modal is open");
|
|
20
31
|
});
|
|
21
32
|
test("built JS contains config loading and saving logic for settings", () => {
|
|
22
33
|
const js = readFileSync(join(distWeb, "app.js"), "utf8");
|
|
23
34
|
assert.ok(js.includes("/api/config"), "JS should fetch the config API");
|
|
35
|
+
assert.ok(js.includes("/api/openrouter/connect"), "JS should call the OpenRouter connect API");
|
|
36
|
+
assert.ok(js.includes("/api/openrouter/disconnect"), "JS should call the OpenRouter disconnect API");
|
|
37
|
+
assert.ok(js.includes("persistToHomeEnv"), "JS should support persisting the selected model after OpenRouter setup");
|
|
38
|
+
assert.ok(js.includes("code_challenge_method"), "JS should generate an OpenRouter PKCE auth request");
|
|
39
|
+
assert.ok(js.includes("sessionStorage"), "JS should persist the PKCE verifier for the OAuth callback");
|
|
40
|
+
assert.ok(js.includes("minicode:openrouter:persist-to-env"), "JS should persist the optional OpenRouter env-write choice across OAuth");
|
|
41
|
+
assert.ok(js.includes("sessionOpenRouterConnected"), "JS should track session-only OpenRouter state");
|
|
24
42
|
assert.ok(js.includes("Save settings"), "JS should contain the settings save action text");
|
|
25
43
|
assert.ok(js.includes("settingsPayload"), "JS should track settings payload state");
|
|
26
44
|
assert.ok(js.includes("persistedValue"), "JS should wire persisted settings behavior");
|