@sean.holung/minicode 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -42
- package/dist/scripts/run-benchmarks.js +147 -0
- package/dist/src/agent/config.js +149 -40
- package/dist/src/agent/editable-config.js +314 -0
- package/dist/src/analysis/structural-analysis.js +379 -0
- package/dist/src/benchmark/evaluator.js +79 -0
- package/dist/src/benchmark/index.js +4 -0
- package/dist/src/benchmark/reporter.js +177 -0
- package/dist/src/benchmark/runner.js +100 -0
- package/dist/src/benchmark/task-loader.js +78 -0
- package/dist/src/benchmark/types.js +5 -0
- package/dist/src/cli/args.js +10 -0
- package/dist/src/cli/config-slash-command.js +135 -0
- package/dist/src/cli/plugin-install.js +69 -0
- package/dist/src/index.js +76 -6
- package/dist/src/indexer/cache.js +6 -4
- package/dist/src/indexer/code-map.js +41 -13
- package/dist/src/indexer/plugins/typescript.js +70 -23
- package/dist/src/indexer/project-index.js +175 -36
- package/dist/src/indexer/symbol-names.js +92 -0
- package/dist/src/model-utils.js +18 -0
- package/dist/src/serve/agent-bridge.js +203 -24
- package/dist/src/serve/mcp-server.js +405 -0
- package/dist/src/serve/server.js +165 -10
- package/dist/src/serve/websocket.js +8 -0
- package/dist/src/shared/graph-styles.js +119 -0
- package/dist/src/tools/find-path.js +75 -0
- package/dist/src/tools/find-references.js +7 -2
- package/dist/src/tools/get-dependencies.js +3 -2
- package/dist/src/tools/read-symbol.js +12 -5
- package/dist/src/tools/registry.js +3 -1
- package/dist/src/tools/search-code-map.js +4 -2
- package/dist/src/ui/app.js +1 -1
- package/dist/src/ui/cli-ink.js +79 -4
- package/dist/src/ui/components/header-bar.js +6 -2
- package/dist/src/ui/state/ui-store.js +5 -0
- package/dist/src/web/app.js +1124 -176
- package/dist/src/web/index.html +113 -3
- package/dist/src/web/style.css +973 -55
- package/dist/tests/agent.test.js +31 -0
- package/dist/tests/analysis-helpers.test.js +89 -0
- package/dist/tests/analysis-ui.test.js +29 -0
- package/dist/tests/benchmark-harness.test.js +527 -0
- package/dist/tests/config-api.test.js +143 -0
- package/dist/tests/config-integration.test.js +751 -0
- package/dist/tests/config-slash-command.test.js +106 -0
- package/dist/tests/config.test.js +42 -1
- package/dist/tests/context-indicator.test.js +220 -0
- package/dist/tests/editable-config.test.js +109 -0
- package/dist/tests/find-path.test.js +183 -0
- package/dist/tests/focus-tracker.test.js +62 -0
- package/dist/tests/graph-onboarding.test.js +55 -0
- package/dist/tests/graph-styles.test.js +65 -0
- package/dist/tests/indexer.test.js +137 -0
- package/dist/tests/mcp-and-plugin.test.js +186 -0
- package/dist/tests/model-client-openai.test.js +29 -0
- package/dist/tests/model-selection.test.js +136 -0
- package/dist/tests/model-utils.test.js +22 -0
- package/dist/tests/reasoning-effort.test.js +264 -0
- package/dist/tests/run-benchmarks.test.js +161 -0
- package/dist/tests/search-code-map.test.js +18 -0
- package/dist/tests/serve.integration.test.js +218 -2
- package/dist/tests/session-ui.test.js +21 -0
- package/dist/tests/session.test.js +50 -0
- package/dist/tests/settings-ui.test.js +30 -0
- package/dist/tests/structural-analysis.test.js +218 -0
- package/node_modules/@minicode/agent-sdk/README.md +80 -51
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts +16 -5
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js +51 -33
- package/node_modules/@minicode/agent-sdk/dist/src/agent/agent.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts +14 -0
- package/node_modules/@minicode/agent-sdk/dist/src/agent/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts +3 -2
- package/node_modules/@minicode/agent-sdk/dist/src/index.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/index.js +2 -0
- package/node_modules/@minicode/agent-sdk/dist/src/index.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts +35 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.d.ts.map +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js +64 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/focus-tracker.js.map +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts +7 -0
- package/node_modules/@minicode/agent-sdk/dist/src/indexer/types.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts +5 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +83 -11
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts +1 -0
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js +8 -1
- package/node_modules/@minicode/agent-sdk/dist/src/safety/guardrails.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.js +4 -1
- package/node_modules/@minicode/agent-sdk/dist/src/session/session.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js +3 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/agent.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js +8 -2
- package/node_modules/@minicode/agent-sdk/dist/tests/guardrails.test.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -5
- package/plugin/.claude-plugin/plugin.json +12 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/CLAUDE.md +26 -0
- package/plugin/skills/analyze/SKILL.md +12 -0
- package/plugin/skills/focus/SKILL.md +20 -0
- package/plugin/skills/graph/SKILL.md +13 -0
- package/plugin/skills/symbols/SKILL.md +13 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { access, mkdtemp, readFile, rm } 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 { getGlobalConfigPath, setPersistedConfigValue, unsetPersistedConfigValue, } from "../src/agent/editable-config.js";
|
|
7
|
+
import { handleConfigSlashCommand } from "../src/cli/config-slash-command.js";
|
|
8
|
+
import { createTestAgentConfig } from "./test-utils.js";
|
|
9
|
+
const tempDirs = [];
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
12
|
+
});
|
|
13
|
+
test("setPersistedConfigValue writes mapped keys and unset removes empty config files", async () => {
|
|
14
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "minicode-config-"));
|
|
15
|
+
tempDirs.push(home);
|
|
16
|
+
const setResult = await setPersistedConfigValue({
|
|
17
|
+
minicodeHome: home,
|
|
18
|
+
key: "commandTimeoutMs",
|
|
19
|
+
rawValue: "45000",
|
|
20
|
+
});
|
|
21
|
+
assert.equal(setResult.path, path.join(home, "agent.config.json"));
|
|
22
|
+
const file = JSON.parse(await readFile(setResult.path, "utf8"));
|
|
23
|
+
assert.equal(file.commandTimeout, 45000);
|
|
24
|
+
await unsetPersistedConfigValue({
|
|
25
|
+
minicodeHome: home,
|
|
26
|
+
key: "commandTimeoutMs",
|
|
27
|
+
});
|
|
28
|
+
await assert.rejects(access(setResult.path));
|
|
29
|
+
});
|
|
30
|
+
test("handleConfigSlashCommand persists config and reports env overrides", async () => {
|
|
31
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "minicode-config-"));
|
|
32
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "minicode-config-ws-"));
|
|
33
|
+
tempDirs.push(home, workspace);
|
|
34
|
+
const config = createTestAgentConfig(workspace);
|
|
35
|
+
const previous = process.env.MAX_STEPS;
|
|
36
|
+
try {
|
|
37
|
+
process.env.MAX_STEPS = "120";
|
|
38
|
+
const result = await handleConfigSlashCommand("/config set maxSteps 64", {
|
|
39
|
+
config,
|
|
40
|
+
minicodeHome: home,
|
|
41
|
+
});
|
|
42
|
+
assert.equal(result.handled, true);
|
|
43
|
+
assert.match(result.message ?? "", /Saved config: maxSteps = 64/);
|
|
44
|
+
assert.match(result.message ?? "", /MAX_STEPS is currently set/);
|
|
45
|
+
const persisted = JSON.parse(await readFile(path.join(home, "agent.config.json"), "utf8"));
|
|
46
|
+
assert.equal(persisted.maxSteps, 64);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (previous === undefined) {
|
|
50
|
+
delete process.env.MAX_STEPS;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
process.env.MAX_STEPS = previous;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
test("handleConfigSlashCommand reports config layers with /config get", async () => {
|
|
58
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "minicode-config-"));
|
|
59
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "minicode-config-ws-"));
|
|
60
|
+
tempDirs.push(home, workspace);
|
|
61
|
+
const config = {
|
|
62
|
+
...createTestAgentConfig(workspace),
|
|
63
|
+
modelProvider: "openai-compatible",
|
|
64
|
+
};
|
|
65
|
+
const previous = process.env.MODEL_PROVIDER;
|
|
66
|
+
try {
|
|
67
|
+
process.env.MODEL_PROVIDER = "openai-compatible";
|
|
68
|
+
await setPersistedConfigValue({
|
|
69
|
+
minicodeHome: home,
|
|
70
|
+
key: "modelProvider",
|
|
71
|
+
rawValue: "openai-compatible",
|
|
72
|
+
});
|
|
73
|
+
const getResult = await handleConfigSlashCommand("/config get modelProvider", {
|
|
74
|
+
config,
|
|
75
|
+
minicodeHome: home,
|
|
76
|
+
});
|
|
77
|
+
assert.equal(getResult.handled, true);
|
|
78
|
+
assert.match(getResult.message ?? "", /effective: openai-compatible/);
|
|
79
|
+
assert.match(getResult.message ?? "", /config file: openai-compatible/);
|
|
80
|
+
assert.match(getResult.message ?? "", /env override \(MODEL_PROVIDER\): openai-compatible/);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (previous === undefined) {
|
|
84
|
+
delete process.env.MODEL_PROVIDER;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
process.env.MODEL_PROVIDER = previous;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
test("handleConfigSlashCommand rejects non-editable keys and keeps secrets env-only", async () => {
|
|
92
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "minicode-config-"));
|
|
93
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "minicode-config-ws-"));
|
|
94
|
+
tempDirs.push(home, workspace);
|
|
95
|
+
const result = await handleConfigSlashCommand("/config set openAiApiKey secret", {
|
|
96
|
+
config: createTestAgentConfig(workspace),
|
|
97
|
+
minicodeHome: home,
|
|
98
|
+
});
|
|
99
|
+
assert.equal(result.handled, true);
|
|
100
|
+
assert.match(result.message ?? "", /Unknown editable config key "openAiApiKey"/);
|
|
101
|
+
assert.match(result.message ?? "", /Secrets like API keys stay env-only for now/);
|
|
102
|
+
});
|
|
103
|
+
test("getGlobalConfigPath resolves to minicode home", () => {
|
|
104
|
+
const home = "/tmp/example-home";
|
|
105
|
+
assert.equal(getGlobalConfigPath(home), path.join(home, "agent.config.json"));
|
|
106
|
+
});
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtemp, mkdir, 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";
|
|
3
6
|
import { loadAgentConfig } from "../src/agent/config.js";
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
10
|
+
});
|
|
4
11
|
test("loadAgentConfig normalizes openai-compatible provider aliases", async () => {
|
|
5
12
|
const previousProvider = process.env.MODEL_PROVIDER;
|
|
6
13
|
const previousBaseUrl = process.env.OPENAI_BASE_URL;
|
|
@@ -35,3 +42,37 @@ test("loadAgentConfig normalizes openai-compatible provider aliases", async () =
|
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
});
|
|
45
|
+
test("loadAgentConfig uses global config and env vars with correct precedence", async () => {
|
|
46
|
+
const base = await mkdtemp(path.join(os.tmpdir(), "minicode-config-test-"));
|
|
47
|
+
tempDirs.push(base);
|
|
48
|
+
const minicodeHome = path.join(base, "home");
|
|
49
|
+
await mkdir(minicodeHome, { recursive: true });
|
|
50
|
+
await writeFile(path.join(minicodeHome, "agent.config.json"), JSON.stringify({ model: "global-model", maxSteps: 33 }, null, 2) + "\n", "utf8");
|
|
51
|
+
await writeFile(path.join(minicodeHome, ".env"), "MODEL=home-env-model\n", "utf8");
|
|
52
|
+
const previousMaxSteps = process.env.MAX_STEPS;
|
|
53
|
+
const previousModel = process.env.MODEL;
|
|
54
|
+
try {
|
|
55
|
+
// Shell env vars should override everything
|
|
56
|
+
process.env.MAX_STEPS = "120";
|
|
57
|
+
delete process.env.MODEL;
|
|
58
|
+
const config = await loadAgentConfig("/tmp", { minicodeHome });
|
|
59
|
+
// MODEL from ~/.minicode/.env (no shell override)
|
|
60
|
+
assert.equal(config.model, "home-env-model");
|
|
61
|
+
// MAX_STEPS from shell env (overrides config file value of 33)
|
|
62
|
+
assert.equal(config.maxSteps, 120);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (previousMaxSteps === undefined) {
|
|
66
|
+
delete process.env.MAX_STEPS;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
process.env.MAX_STEPS = previousMaxSteps;
|
|
70
|
+
}
|
|
71
|
+
if (previousModel === undefined) {
|
|
72
|
+
delete process.env.MODEL;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
process.env.MODEL = previousModel;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test, afterEach } from "node:test";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { createRequestHandler } from "../src/serve/server.js";
|
|
5
|
+
import { AgentBridge } from "../src/serve/agent-bridge.js";
|
|
6
|
+
import { CodingAgent, ToolRegistry, } from "@minicode/agent-sdk";
|
|
7
|
+
import { UiStore } from "../src/ui/state/ui-store.js";
|
|
8
|
+
import { createTestAgentConfig } from "./test-utils.js";
|
|
9
|
+
// ── Mock model client ──
|
|
10
|
+
class SequenceModelClient {
|
|
11
|
+
responses;
|
|
12
|
+
constructor(responses) {
|
|
13
|
+
this.responses = [...responses];
|
|
14
|
+
}
|
|
15
|
+
async chat(params) {
|
|
16
|
+
void params;
|
|
17
|
+
const next = this.responses.shift();
|
|
18
|
+
if (!next)
|
|
19
|
+
throw new Error("No queued model response.");
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// ── MockBridge for serve tests ──
|
|
24
|
+
class MockBridge extends AgentBridge {
|
|
25
|
+
_busy = false;
|
|
26
|
+
_contextTokens = 1200;
|
|
27
|
+
_maxContextTokens = 16000;
|
|
28
|
+
constructor() {
|
|
29
|
+
super(() => { }, false);
|
|
30
|
+
}
|
|
31
|
+
isReady() {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
isBusy() {
|
|
35
|
+
return this._busy;
|
|
36
|
+
}
|
|
37
|
+
getConfig() {
|
|
38
|
+
return createTestAgentConfig("/tmp/test-workspace");
|
|
39
|
+
}
|
|
40
|
+
getAgent() {
|
|
41
|
+
const ctx = { contextTokens: this._contextTokens, maxContextTokens: this._maxContextTokens };
|
|
42
|
+
return {
|
|
43
|
+
getContextStatus: () => ctx,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async runTurn(message) {
|
|
47
|
+
this._busy = true;
|
|
48
|
+
this.emit({ type: "streaming_chunk", content: `Echo: ${message}` });
|
|
49
|
+
this._busy = false;
|
|
50
|
+
return { text: `Echo: ${message}`, usage: { inputTokens: 10, outputTokens: 5 } };
|
|
51
|
+
}
|
|
52
|
+
async listSess() { return []; }
|
|
53
|
+
async saveSess() { return { id: "s", label: "l", createdAt: "", savedAt: "", messageCount: 0 }; }
|
|
54
|
+
async loadSess() { return null; }
|
|
55
|
+
hasIndex() { return false; }
|
|
56
|
+
emit(msg) {
|
|
57
|
+
for (const fn of this.listeners) {
|
|
58
|
+
fn(msg);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
setContextState(tokens, max) {
|
|
62
|
+
this._contextTokens = tokens;
|
|
63
|
+
this._maxContextTokens = max;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// ── Test harness ──
|
|
67
|
+
let activeServer;
|
|
68
|
+
function startTestServer(bridge) {
|
|
69
|
+
const handler = createRequestHandler(bridge);
|
|
70
|
+
const server = createServer(handler);
|
|
71
|
+
activeServer = server;
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
server.listen(0, "127.0.0.1", () => {
|
|
74
|
+
const addr = server.address();
|
|
75
|
+
if (typeof addr === "object" && addr) {
|
|
76
|
+
resolve(`http://127.0.0.1:${addr.port}`);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function stopTestServer() {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
if (activeServer) {
|
|
84
|
+
activeServer.close(() => resolve());
|
|
85
|
+
activeServer = undefined;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
resolve();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
afterEach(async () => {
|
|
93
|
+
await stopTestServer();
|
|
94
|
+
});
|
|
95
|
+
// ── REST API: /api/context ──
|
|
96
|
+
test("GET /api/context returns context token status", async () => {
|
|
97
|
+
const bridge = new MockBridge();
|
|
98
|
+
const base = await startTestServer(bridge);
|
|
99
|
+
const res = await fetch(`${base}/api/context`);
|
|
100
|
+
assert.equal(res.status, 200);
|
|
101
|
+
const body = (await res.json());
|
|
102
|
+
assert.equal(body.contextTokens, 1200);
|
|
103
|
+
assert.equal(body.maxContextTokens, 16000);
|
|
104
|
+
});
|
|
105
|
+
test("GET /api/context reflects updated context state", async () => {
|
|
106
|
+
const bridge = new MockBridge();
|
|
107
|
+
bridge.setContextState(8000, 16000);
|
|
108
|
+
const base = await startTestServer(bridge);
|
|
109
|
+
const res = await fetch(`${base}/api/context`);
|
|
110
|
+
const body = (await res.json());
|
|
111
|
+
assert.equal(body.contextTokens, 8000);
|
|
112
|
+
assert.equal(body.maxContextTokens, 16000);
|
|
113
|
+
});
|
|
114
|
+
// ── Agent emits context_status UiUpdate ──
|
|
115
|
+
test("agent emits context_status UiUpdate during turn", async () => {
|
|
116
|
+
const config = createTestAgentConfig("/tmp/test-workspace");
|
|
117
|
+
const modelClient = new SequenceModelClient([
|
|
118
|
+
{
|
|
119
|
+
text: "done",
|
|
120
|
+
toolCalls: [],
|
|
121
|
+
stopReason: "end_turn",
|
|
122
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
const toolRegistry = new ToolRegistry([]);
|
|
126
|
+
const events = [];
|
|
127
|
+
const agent = new CodingAgent({
|
|
128
|
+
config,
|
|
129
|
+
modelClient,
|
|
130
|
+
toolRegistry,
|
|
131
|
+
onUiUpdate: (event) => events.push(event),
|
|
132
|
+
});
|
|
133
|
+
await agent.runTurn("hello");
|
|
134
|
+
const contextEvents = events.filter((e) => e.type === "context_status");
|
|
135
|
+
assert.ok(contextEvents.length >= 1, "should emit at least one context_status event");
|
|
136
|
+
const ev = contextEvents[0];
|
|
137
|
+
assert.equal(ev.type, "context_status");
|
|
138
|
+
assert.equal(typeof ev.contextTokens, "number");
|
|
139
|
+
assert.equal(ev.maxContextTokens, config.maxContextTokens);
|
|
140
|
+
});
|
|
141
|
+
test("agent context_status tokens increase as messages are added", async () => {
|
|
142
|
+
const config = createTestAgentConfig("/tmp/test-workspace");
|
|
143
|
+
const modelClient = new SequenceModelClient([
|
|
144
|
+
{
|
|
145
|
+
text: "first",
|
|
146
|
+
toolCalls: [],
|
|
147
|
+
stopReason: "end_turn",
|
|
148
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
text: "second",
|
|
152
|
+
toolCalls: [],
|
|
153
|
+
stopReason: "end_turn",
|
|
154
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
const toolRegistry = new ToolRegistry([]);
|
|
158
|
+
const contextTokensPerTurn = [];
|
|
159
|
+
const agent = new CodingAgent({
|
|
160
|
+
config,
|
|
161
|
+
modelClient,
|
|
162
|
+
toolRegistry,
|
|
163
|
+
onUiUpdate: (event) => {
|
|
164
|
+
if (event.type === "context_status") {
|
|
165
|
+
contextTokensPerTurn.push(event.contextTokens);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
await agent.runTurn("hello");
|
|
170
|
+
await agent.runTurn("world");
|
|
171
|
+
assert.equal(contextTokensPerTurn.length, 2);
|
|
172
|
+
assert.ok(contextTokensPerTurn[1] > contextTokensPerTurn[0], `context should grow: ${contextTokensPerTurn[0]} → ${contextTokensPerTurn[1]}`);
|
|
173
|
+
});
|
|
174
|
+
// ── CodingAgent.getContextStatus() ──
|
|
175
|
+
test("agent getContextStatus returns current token estimate and max", async () => {
|
|
176
|
+
const config = createTestAgentConfig("/tmp/test-workspace");
|
|
177
|
+
const modelClient = new SequenceModelClient([
|
|
178
|
+
{
|
|
179
|
+
text: "done",
|
|
180
|
+
toolCalls: [],
|
|
181
|
+
stopReason: "end_turn",
|
|
182
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
const toolRegistry = new ToolRegistry([]);
|
|
186
|
+
const agent = new CodingAgent({ config, modelClient, toolRegistry });
|
|
187
|
+
// Before any turn, context should be empty
|
|
188
|
+
const before = agent.getContextStatus();
|
|
189
|
+
assert.equal(before.contextTokens, 0);
|
|
190
|
+
assert.equal(before.maxContextTokens, config.maxContextTokens);
|
|
191
|
+
await agent.runTurn("test message");
|
|
192
|
+
// After a turn, context should have some tokens
|
|
193
|
+
const after = agent.getContextStatus();
|
|
194
|
+
assert.ok(after.contextTokens > 0, "context should have tokens after a turn");
|
|
195
|
+
assert.equal(after.maxContextTokens, config.maxContextTokens);
|
|
196
|
+
});
|
|
197
|
+
// ── UiStore context status ──
|
|
198
|
+
test("UiStore tracks context status via setContextStatus", () => {
|
|
199
|
+
const store = new UiStore();
|
|
200
|
+
assert.equal(store.getState().contextTokens, 0);
|
|
201
|
+
assert.equal(store.getState().maxContextTokens, 0);
|
|
202
|
+
store.setContextStatus(5000, 40000);
|
|
203
|
+
assert.equal(store.getState().contextTokens, 5000);
|
|
204
|
+
assert.equal(store.getState().maxContextTokens, 40000);
|
|
205
|
+
});
|
|
206
|
+
test("UiStore context status updates trigger listeners", () => {
|
|
207
|
+
const store = new UiStore();
|
|
208
|
+
let notified = false;
|
|
209
|
+
store.subscribe(() => { notified = true; });
|
|
210
|
+
store.setContextStatus(1000, 16000);
|
|
211
|
+
assert.ok(notified, "listener should have been called");
|
|
212
|
+
assert.equal(store.getState().contextTokens, 1000);
|
|
213
|
+
});
|
|
214
|
+
test("UiStore reset clears context status", () => {
|
|
215
|
+
const store = new UiStore();
|
|
216
|
+
store.setContextStatus(5000, 40000);
|
|
217
|
+
store.reset();
|
|
218
|
+
assert.equal(store.getState().contextTokens, 0);
|
|
219
|
+
assert.equal(store.getState().maxContextTokens, 0);
|
|
220
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { access, mkdir, 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 { applyPersistedConfigUpdates, buildStructuredConfigPayload, getGlobalConfigPath, } from "../src/agent/editable-config.js";
|
|
7
|
+
import { createTestAgentConfig } from "./test-utils.js";
|
|
8
|
+
const tempDirs = [];
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
11
|
+
});
|
|
12
|
+
async function withUnsetEnvVars(names, callback) {
|
|
13
|
+
const previous = new Map();
|
|
14
|
+
for (const name of names) {
|
|
15
|
+
previous.set(name, process.env[name]);
|
|
16
|
+
delete process.env[name];
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await callback();
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
for (const [name, value] of previous) {
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
delete process.env[name];
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
process.env[name] = value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
test("buildStructuredConfigPayload reports effective values and env overrides", async () => {
|
|
33
|
+
await withUnsetEnvVars(["MAX_STEPS", "MODEL", "ENABLE_DYNAMIC_PROMPT"], async () => {
|
|
34
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "minicode-editable-config-"));
|
|
35
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "minicode-editable-ws-"));
|
|
36
|
+
tempDirs.push(home, workspace);
|
|
37
|
+
await applyPersistedConfigUpdates({
|
|
38
|
+
minicodeHome: home,
|
|
39
|
+
updates: {
|
|
40
|
+
maxSteps: 77,
|
|
41
|
+
model: "global-model",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
process.env.MAX_STEPS = "120";
|
|
45
|
+
const payload = await buildStructuredConfigPayload({
|
|
46
|
+
...createTestAgentConfig(workspace),
|
|
47
|
+
maxSteps: 120,
|
|
48
|
+
model: "global-model",
|
|
49
|
+
}, home);
|
|
50
|
+
assert.equal(payload.configPath, path.join(home, "agent.config.json"));
|
|
51
|
+
const maxSteps = payload.entries.find((entry) => entry.key === "maxSteps");
|
|
52
|
+
assert.equal(maxSteps?.effectiveValue, 120);
|
|
53
|
+
assert.equal(maxSteps?.persistedValue, 77);
|
|
54
|
+
assert.equal(maxSteps?.envValue, "120");
|
|
55
|
+
assert.equal(maxSteps?.envSource, "process");
|
|
56
|
+
assert.equal(maxSteps?.envSourcePath, null);
|
|
57
|
+
assert.equal(maxSteps?.overriddenByEnv, true);
|
|
58
|
+
const model = payload.entries.find((entry) => entry.key === "model");
|
|
59
|
+
assert.equal(model?.effectiveValue, "global-model");
|
|
60
|
+
assert.equal(model?.persistedValue, "global-model");
|
|
61
|
+
assert.equal(model?.overriddenByEnv, false);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
test("buildStructuredConfigPayload reports home dotenv env source", async () => {
|
|
65
|
+
await withUnsetEnvVars(["MAX_STEPS"], async () => {
|
|
66
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "minicode-editable-config-"));
|
|
67
|
+
const workspace = await mkdtemp(path.join(os.tmpdir(), "minicode-editable-ws-"));
|
|
68
|
+
tempDirs.push(home, workspace);
|
|
69
|
+
await mkdir(home, { recursive: true });
|
|
70
|
+
await writeFile(path.join(home, ".env"), "MAX_STEPS=88\n", "utf8");
|
|
71
|
+
const payload = await buildStructuredConfigPayload({
|
|
72
|
+
...createTestAgentConfig(workspace),
|
|
73
|
+
maxSteps: 88,
|
|
74
|
+
}, home);
|
|
75
|
+
const maxSteps = payload.entries.find((entry) => entry.key === "maxSteps");
|
|
76
|
+
assert.equal(maxSteps?.envValue, "88");
|
|
77
|
+
assert.equal(maxSteps?.envSource, "home-dotenv");
|
|
78
|
+
assert.equal(maxSteps?.envSourcePath, path.join(home, ".env"));
|
|
79
|
+
assert.equal(maxSteps?.overriddenByEnv, true);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
test("applyPersistedConfigUpdates writes global config and removes files when everything is unset", async () => {
|
|
83
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "minicode-editable-config-"));
|
|
84
|
+
tempDirs.push(home);
|
|
85
|
+
const result = await applyPersistedConfigUpdates({
|
|
86
|
+
minicodeHome: home,
|
|
87
|
+
updates: {
|
|
88
|
+
keepRecentMessages: 18,
|
|
89
|
+
enableFileReadDedup: false,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
assert.equal(result.path, getGlobalConfigPath(home));
|
|
93
|
+
assert.deepEqual(result.saved, [
|
|
94
|
+
{ key: "keepRecentMessages", value: 18 },
|
|
95
|
+
{ key: "enableFileReadDedup", value: false },
|
|
96
|
+
]);
|
|
97
|
+
const configPath = path.join(home, "agent.config.json");
|
|
98
|
+
const persisted = JSON.parse(await readFile(configPath, "utf8"));
|
|
99
|
+
assert.equal(persisted.keepRecentMessages, 18);
|
|
100
|
+
assert.equal(persisted.enableFileReadDedup, false);
|
|
101
|
+
await applyPersistedConfigUpdates({
|
|
102
|
+
minicodeHome: home,
|
|
103
|
+
updates: {
|
|
104
|
+
keepRecentMessages: null,
|
|
105
|
+
enableFileReadDedup: null,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
await assert.rejects(access(configPath));
|
|
109
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { test } from "node:test";
|
|
4
|
+
import { createProjectIndex } from "../src/indexer/project-index.js";
|
|
5
|
+
import { createFindPathTool } from "../src/tools/find-path.js";
|
|
6
|
+
function makeSymbol(name, kind = "function") {
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
qualifiedName: name,
|
|
10
|
+
kind,
|
|
11
|
+
filePath: "test.ts",
|
|
12
|
+
startLine: 1,
|
|
13
|
+
endLine: 5,
|
|
14
|
+
signature: `function ${name}()`,
|
|
15
|
+
exported: true,
|
|
16
|
+
dependencies: [],
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function buildTestIndex(symbols, edges) {
|
|
20
|
+
const symMap = new Map();
|
|
21
|
+
const fileMap = new Map();
|
|
22
|
+
for (const sym of symbols) {
|
|
23
|
+
symMap.set(sym.qualifiedName, sym);
|
|
24
|
+
const existing = fileMap.get(sym.filePath) ?? [];
|
|
25
|
+
existing.push(sym);
|
|
26
|
+
fileMap.set(sym.filePath, existing);
|
|
27
|
+
}
|
|
28
|
+
return createProjectIndex(symMap, fileMap, edges, [], new Map(), "/tmp/test");
|
|
29
|
+
}
|
|
30
|
+
test("findPath returns path between two connected symbols", () => {
|
|
31
|
+
const symbols = [makeSymbol("a"), makeSymbol("b"), makeSymbol("c")];
|
|
32
|
+
const edges = [
|
|
33
|
+
{ from: "a", to: "b", kind: "calls" },
|
|
34
|
+
{ from: "b", to: "c", kind: "calls" },
|
|
35
|
+
];
|
|
36
|
+
const index = buildTestIndex(symbols, edges);
|
|
37
|
+
const result = index.findPath("a", "c");
|
|
38
|
+
assert.equal(result.length, 3);
|
|
39
|
+
assert.equal(result[0].qualifiedName, "a");
|
|
40
|
+
assert.equal(result[1].qualifiedName, "b");
|
|
41
|
+
assert.equal(result[2].qualifiedName, "c");
|
|
42
|
+
});
|
|
43
|
+
test("findPath returns empty array when no path exists", () => {
|
|
44
|
+
const symbols = [makeSymbol("a"), makeSymbol("b")];
|
|
45
|
+
const edges = [];
|
|
46
|
+
const index = buildTestIndex(symbols, edges);
|
|
47
|
+
const result = index.findPath("a", "b");
|
|
48
|
+
assert.equal(result.length, 0);
|
|
49
|
+
});
|
|
50
|
+
test("findPath returns direct path for adjacent symbols", () => {
|
|
51
|
+
const symbols = [makeSymbol("a"), makeSymbol("b")];
|
|
52
|
+
const edges = [{ from: "a", to: "b", kind: "calls" }];
|
|
53
|
+
const index = buildTestIndex(symbols, edges);
|
|
54
|
+
const result = index.findPath("a", "b");
|
|
55
|
+
assert.equal(result.length, 2);
|
|
56
|
+
assert.equal(result[0].qualifiedName, "a");
|
|
57
|
+
assert.equal(result[1].qualifiedName, "b");
|
|
58
|
+
});
|
|
59
|
+
test("findPath finds path via reverse edges", () => {
|
|
60
|
+
const symbols = [makeSymbol("a"), makeSymbol("b"), makeSymbol("c")];
|
|
61
|
+
// Only edge is b->a and b->c, so path from a to c goes a<-b->c
|
|
62
|
+
const edges = [
|
|
63
|
+
{ from: "b", to: "a", kind: "calls" },
|
|
64
|
+
{ from: "b", to: "c", kind: "calls" },
|
|
65
|
+
];
|
|
66
|
+
const index = buildTestIndex(symbols, edges);
|
|
67
|
+
const result = index.findPath("a", "c");
|
|
68
|
+
assert.equal(result.length, 3);
|
|
69
|
+
assert.equal(result[0].qualifiedName, "a");
|
|
70
|
+
assert.equal(result[1].qualifiedName, "b");
|
|
71
|
+
assert.equal(result[2].qualifiedName, "c");
|
|
72
|
+
});
|
|
73
|
+
test("findPath respects maxDepth", () => {
|
|
74
|
+
const symbols = [makeSymbol("a"), makeSymbol("b"), makeSymbol("c"), makeSymbol("d")];
|
|
75
|
+
const edges = [
|
|
76
|
+
{ from: "a", to: "b", kind: "calls" },
|
|
77
|
+
{ from: "b", to: "c", kind: "calls" },
|
|
78
|
+
{ from: "c", to: "d", kind: "calls" },
|
|
79
|
+
];
|
|
80
|
+
const index = buildTestIndex(symbols, edges);
|
|
81
|
+
// maxDepth 1 should not find a path from a to d (3 hops away)
|
|
82
|
+
const result = index.findPath("a", "d", 1);
|
|
83
|
+
assert.equal(result.length, 0);
|
|
84
|
+
// maxDepth 3 should find it
|
|
85
|
+
const result2 = index.findPath("a", "d", 3);
|
|
86
|
+
assert.equal(result2.length, 4);
|
|
87
|
+
});
|
|
88
|
+
test("findPath returns empty for unknown symbols", () => {
|
|
89
|
+
const symbols = [makeSymbol("a")];
|
|
90
|
+
const index = buildTestIndex(symbols, []);
|
|
91
|
+
assert.equal(index.findPath("a", "nonexistent").length, 0);
|
|
92
|
+
assert.equal(index.findPath("nonexistent", "a").length, 0);
|
|
93
|
+
});
|
|
94
|
+
test("findPathToEntryPoint traces back to entry points", () => {
|
|
95
|
+
const symbols = [makeSymbol("entry"), makeSymbol("middle"), makeSymbol("leaf")];
|
|
96
|
+
const edges = [
|
|
97
|
+
{ from: "entry", to: "middle", kind: "calls" },
|
|
98
|
+
{ from: "middle", to: "leaf", kind: "calls" },
|
|
99
|
+
];
|
|
100
|
+
const index = buildTestIndex(symbols, edges);
|
|
101
|
+
const paths = index.findPathToEntryPoint("leaf");
|
|
102
|
+
assert.ok(paths.length > 0);
|
|
103
|
+
// The path should go from entry -> middle -> leaf
|
|
104
|
+
const firstPath = paths[0];
|
|
105
|
+
assert.equal(firstPath[0].qualifiedName, "entry");
|
|
106
|
+
assert.equal(firstPath[firstPath.length - 1].qualifiedName, "leaf");
|
|
107
|
+
});
|
|
108
|
+
test("findPathToEntryPoint returns empty for entry point symbols", () => {
|
|
109
|
+
const symbols = [makeSymbol("entry"), makeSymbol("other")];
|
|
110
|
+
const edges = [
|
|
111
|
+
{ from: "entry", to: "other", kind: "calls" },
|
|
112
|
+
];
|
|
113
|
+
const index = buildTestIndex(symbols, edges);
|
|
114
|
+
// "entry" has no inbound edges, so it IS an entry point
|
|
115
|
+
const paths = index.findPathToEntryPoint("entry");
|
|
116
|
+
// Should return empty or self-referential since it's already an entry point
|
|
117
|
+
assert.equal(paths.length, 0);
|
|
118
|
+
});
|
|
119
|
+
test("findPathToEntryPoint returns empty for unknown symbols", () => {
|
|
120
|
+
const index = buildTestIndex([], []);
|
|
121
|
+
const paths = index.findPathToEntryPoint("nonexistent");
|
|
122
|
+
assert.equal(paths.length, 0);
|
|
123
|
+
});
|
|
124
|
+
test("find_path tool returns path between two symbols", async () => {
|
|
125
|
+
const symbols = [makeSymbol("a"), makeSymbol("b"), makeSymbol("c")];
|
|
126
|
+
const edges = [
|
|
127
|
+
{ from: "a", to: "b", kind: "calls" },
|
|
128
|
+
{ from: "b", to: "c", kind: "calls" },
|
|
129
|
+
];
|
|
130
|
+
const index = buildTestIndex(symbols, edges);
|
|
131
|
+
const tool = createFindPathTool(index);
|
|
132
|
+
const result = await tool.execute({ from: "a", to: "c" });
|
|
133
|
+
assert.ok(result.includes("# Path from a to c"));
|
|
134
|
+
assert.ok(result.includes("3 symbols"));
|
|
135
|
+
assert.ok(result.includes("[function] a"));
|
|
136
|
+
assert.ok(result.includes("[function] b"));
|
|
137
|
+
assert.ok(result.includes("[function] c"));
|
|
138
|
+
});
|
|
139
|
+
test("find_path tool traces to entry points when 'to' is omitted", async () => {
|
|
140
|
+
const symbols = [makeSymbol("entry"), makeSymbol("middle"), makeSymbol("leaf")];
|
|
141
|
+
const edges = [
|
|
142
|
+
{ from: "entry", to: "middle", kind: "calls" },
|
|
143
|
+
{ from: "middle", to: "leaf", kind: "calls" },
|
|
144
|
+
];
|
|
145
|
+
const index = buildTestIndex(symbols, edges);
|
|
146
|
+
const tool = createFindPathTool(index);
|
|
147
|
+
const result = await tool.execute({ from: "leaf" });
|
|
148
|
+
assert.ok(result.includes("Entry point paths for leaf"));
|
|
149
|
+
assert.ok(result.includes("[function] entry"));
|
|
150
|
+
});
|
|
151
|
+
test("find_path tool returns error for unknown symbol", async () => {
|
|
152
|
+
const index = buildTestIndex([], []);
|
|
153
|
+
const tool = createFindPathTool(index);
|
|
154
|
+
const result = await tool.execute({ from: "nonexistent" });
|
|
155
|
+
assert.ok(result.includes("not found"));
|
|
156
|
+
});
|
|
157
|
+
test("find_path tool returns error when target symbol not found", async () => {
|
|
158
|
+
const symbols = [makeSymbol("a")];
|
|
159
|
+
const index = buildTestIndex(symbols, []);
|
|
160
|
+
const tool = createFindPathTool(index);
|
|
161
|
+
const result = await tool.execute({ from: "a", to: "nonexistent" });
|
|
162
|
+
assert.ok(result.includes("not found"));
|
|
163
|
+
});
|
|
164
|
+
test("find_path tool reports no path when symbols are disconnected", async () => {
|
|
165
|
+
const symbols = [makeSymbol("a"), makeSymbol("b")];
|
|
166
|
+
const index = buildTestIndex(symbols, []);
|
|
167
|
+
const tool = createFindPathTool(index);
|
|
168
|
+
const result = await tool.execute({ from: "a", to: "b" });
|
|
169
|
+
assert.ok(result.includes("No path found"));
|
|
170
|
+
});
|
|
171
|
+
test("find_path tool works with real project index", async () => {
|
|
172
|
+
const { buildProjectIndex } = await import("../src/indexer/project-index.js");
|
|
173
|
+
const root = path.resolve(import.meta.dirname, "..");
|
|
174
|
+
const projectIndex = await buildProjectIndex(root);
|
|
175
|
+
const tool = createFindPathTool(projectIndex);
|
|
176
|
+
// Find path between two known symbols
|
|
177
|
+
const result = await tool.execute({
|
|
178
|
+
from: "createModelClient",
|
|
179
|
+
to: "AgentConfig",
|
|
180
|
+
});
|
|
181
|
+
assert.ok(result.includes("# Path from createModelClient to AgentConfig"));
|
|
182
|
+
assert.ok(result.includes("symbols"));
|
|
183
|
+
});
|