ada-agent 0.2.0 → 0.3.0

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 (39) hide show
  1. package/README.md +262 -263
  2. package/bench/README.md +88 -88
  3. package/bench/swebench.mjs +242 -242
  4. package/docs/architecture.md +163 -163
  5. package/docs/architecture.svg +73 -73
  6. package/docs/cloudflare.md +81 -81
  7. package/docs/connectors.md +49 -49
  8. package/docs/integrations.md +62 -62
  9. package/package.json +66 -65
  10. package/skills/aesthetic-direction/SKILL.md +24 -24
  11. package/skills/color-palette/SKILL.md +24 -24
  12. package/skills/component-library/SKILL.md +23 -23
  13. package/skills/dark-mode/SKILL.md +24 -24
  14. package/skills/dashboard-ui/SKILL.md +23 -23
  15. package/skills/design-system/SKILL.md +24 -24
  16. package/skills/design-tokens/SKILL.md +24 -24
  17. package/skills/empty-states/SKILL.md +23 -23
  18. package/skills/hero-section/SKILL.md +23 -23
  19. package/skills/micro-interactions/SKILL.md +23 -23
  20. package/skills/motion-design/SKILL.md +23 -23
  21. package/skills/page-transitions/SKILL.md +23 -23
  22. package/skills/pricing-page/SKILL.md +23 -23
  23. package/skills/scroll-animation/SKILL.md +23 -23
  24. package/skills/skeleton-loader/SKILL.md +23 -23
  25. package/skills/tailwind-theme/SKILL.md +24 -24
  26. package/skills/typography/SKILL.md +24 -24
  27. package/skills/ui-polish/SKILL.md +24 -24
  28. package/skills/ui-review/SKILL.md +24 -24
  29. package/skills/web-fonts/SKILL.md +24 -24
  30. package/src/client/autostart.ts +93 -0
  31. package/src/client/catalog.json +1 -1
  32. package/src/client/cli.ts +1275 -1262
  33. package/src/client/models-dev.ts +106 -106
  34. package/src/selfcheck.ts +404 -390
  35. package/src/server/config.ts +65 -65
  36. package/src/server/providers/openai-compat.ts +78 -78
  37. package/src/server/providers/registry.ts +32 -32
  38. package/src/server/router.ts +33 -33
  39. package/src/shared/types.ts +21 -21
package/src/selfcheck.ts CHANGED
@@ -1,390 +1,404 @@
1
- // Offline self-check: tools, session persistence, and routing. No network, no API key.
2
- // Run with: npm run selfcheck
3
-
4
- import assert from "node:assert/strict";
5
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { join } from "node:path";
8
- import { estimateTokens, isContextOverflowError, planCut } from "./client/compaction.ts";
9
- import { loadImage } from "./client/image.ts";
10
- import { expandPrompt } from "./client/prompts.ts";
11
- import { MarkdownStreamer, highlight, renderEditDiff } from "./client/render.ts";
12
- import { Session, list } from "./client/session.ts";
13
- import { loadSkills, registerSkillTool, routeConfident } from "./client/skills.ts";
14
- import { describeCall, parseTextToolCalls, permPhrase, readIntegrationDocs, soleIntegration, writeProjectSkills } from "./client/agent.ts";
15
- import { userBar } from "./client/tui.ts";
16
- import { configuredServers, listConnectors, loadMcpServers } from "./client/mcp.ts";
17
- import { confidentSkill, rankSkills } from "./client/skill-router.ts";
18
- import { getDiagnostics } from "./client/lsp.ts";
19
- import { snapshot } from "./client/snapshot.ts";
20
- import { renderJobs, startJob } from "./client/background.ts";
21
- import { formatFile, htmlToText, isDestructive, registerTool, setAsker, toolByName } from "./client/tools.ts";
22
- import * as checkpoint from "./client/checkpoint.ts";
23
- import { renderTodos, setTodos } from "./client/todos.ts";
24
- import { deleteCredential, getCredential, setCredential } from "./server/credentials.ts";
25
- import { isAllowed } from "./server/identity.ts";
26
- import { route } from "./server/router.ts";
27
-
28
- function tool(name: string) {
29
- const t = toolByName.get(name);
30
- if (!t) throw new Error(`missing tool: ${name}`);
31
- return t;
32
- }
33
-
34
- async function main(): Promise<void> {
35
- // --- tools: write -> edit -> read round-trip ---
36
- const dir = join(tmpdir(), `ada-selfcheck-${Date.now()}`);
37
- const file = join(dir, "a.txt");
38
-
39
- let r = await tool("write_file").run({ path: file, content: "hello world" });
40
- assert.ok(!r.isError, r.output);
41
- r = await tool("edit_file").run({ path: file, old_text: "world", new_text: "ada" });
42
- assert.ok(!r.isError, r.output);
43
- r = await tool("read_file").run({ path: file });
44
- assert.equal(r.output, "hello ada");
45
-
46
- // ambiguous edit must error
47
- await tool("write_file").run({ path: file, content: "x x" });
48
- r = await tool("edit_file").run({ path: file, old_text: "x", new_text: "y" });
49
- assert.ok(r.isError, "ambiguous edit should error");
50
-
51
- // missing read must error
52
- r = await tool("read_file").run({ path: join(dir, "nope.txt") });
53
- assert.ok(r.isError, "missing read should error");
54
-
55
- // bash
56
- r = await tool("bash").run({ command: "echo hi" });
57
- assert.ok(r.output.includes("hi"), r.output);
58
-
59
- // grep / ls / glob
60
- await tool("write_file").run({ path: join(dir, "hello.txt"), content: "alpha\nNEEDLE here\nbeta" });
61
- const g = await tool("grep").run({ path: dir, pattern: "NEEDLE" });
62
- assert.ok(g.output.includes("NEEDLE"), g.output);
63
- const l = await tool("ls").run({ path: dir });
64
- assert.ok(l.output.includes("hello.txt"), l.output);
65
- const gl = await tool("glob").run({ pattern: "src/selfcheck.ts" });
66
- assert.ok(gl.output.includes("selfcheck.ts"), gl.output);
67
-
68
- // read offset/limit
69
- await tool("write_file").run({ path: join(dir, "lines.txt"), content: "L1\nL2\nL3\nL4" });
70
- const ol = await tool("read_file").run({ path: join(dir, "lines.txt"), offset: 2, limit: 2 });
71
- assert.equal(ol.output, "L2\nL3");
72
-
73
- // multi-edit
74
- await tool("write_file").run({ path: join(dir, "m.txt"), content: "aaa bbb ccc" });
75
- r = await tool("edit_file").run({
76
- path: join(dir, "m.txt"),
77
- edits: [
78
- { old_text: "aaa", new_text: "AAA" },
79
- { old_text: "ccc", new_text: "CCC" },
80
- ],
81
- });
82
- assert.ok(!r.isError, r.output);
83
- r = await tool("read_file").run({ path: join(dir, "m.txt") });
84
- assert.equal(r.output, "AAA bbb CCC");
85
-
86
- // CRLF preservation: file uses \r\n, edit's old_text uses \n
87
- const crlf = join(dir, "crlf.txt");
88
- await tool("write_file").run({ path: crlf, content: "one\r\ntwo\r\nthree" });
89
- r = await tool("edit_file").run({ path: crlf, old_text: "two", new_text: "TWO" });
90
- assert.ok(!r.isError, r.output);
91
- r = await tool("read_file").run({ path: crlf });
92
- assert.ok(r.output.includes("\r\n") && r.output.includes("TWO"), JSON.stringify(r.output));
93
-
94
- rmSync(dir, { recursive: true, force: true });
95
-
96
- // --- session append -> load round-trip ---
97
- const s = Session.create();
98
- s.append({ role: "user", content: "hello" });
99
- s.append({ role: "assistant", content: "hi there" });
100
- const loaded = s.load();
101
- assert.equal(loaded.length, 2);
102
- assert.equal(loaded[0]!.content, "hello");
103
- rmSync(s.file, { force: true });
104
-
105
- // --- branching: fork seeds messages, records parent, load skips __meta ---
106
- const parent = Session.create();
107
- parent.append({ role: "user", content: "p1" });
108
- const branch = Session.fork(parent.file, [
109
- { role: "user", content: "p1" },
110
- { role: "assistant", content: "a1" },
111
- ]);
112
- const bl = branch.load();
113
- assert.equal(bl.length, 2, "fork load skips the __meta line");
114
- assert.equal(bl[0]!.content, "p1");
115
- const bm = list().find((m) => m.file === branch.file);
116
- assert.ok(bm?.parent === parent.file, "branch records its parent");
117
- rmSync(parent.file, { force: true });
118
- rmSync(branch.file, { force: true });
119
-
120
- // --- router prefix mapping ---
121
- assert.equal(route("gpt-4o"), "openai");
122
- assert.equal(route("o3-mini"), "openai");
123
- assert.equal(route("claude-opus-4-8"), "anthropic");
124
- assert.equal(route("gemini-2.5-pro"), "google");
125
- assert.equal(route("mistral-large-latest"), "mistral");
126
- assert.equal(route("grok-2"), "xai");
127
- assert.equal(route("deepseek-chat"), "deepseek");
128
- assert.equal(route("qwen-max"), "dashscope");
129
- assert.equal(route("qwq-32b"), "dashscope");
130
- assert.equal(route("qwen/qwen-2.5-72b-instruct"), "openrouter"); // namespaced id stays on OpenRouter
131
- assert.equal(route("gemma4:latest"), "ollama"); // local Ollama "model:tag"
132
- assert.equal(route("mistralai/mistral-7b:free"), "openrouter"); // slash wins over colon
133
- assert.equal(route("meta-llama/llama-3.1-70b"), "openrouter");
134
- assert.equal(route("anything", "mistral"), "mistral");
135
-
136
- // --- compaction ---
137
- assert.ok(estimateTokens([{ role: "user", content: "hello" }] as never) > 0);
138
- assert.ok(isContextOverflowError(new Error("maximum context length exceeded")));
139
- assert.ok(!isContextOverflowError(new Error("invalid api key")));
140
- const convo = [
141
- { role: "system", content: "sys" },
142
- { role: "user", content: "u1" },
143
- { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "bash", arguments: "{}" } }] },
144
- { role: "tool", tool_call_id: "c1", content: "out" },
145
- { role: "assistant", content: "a1" },
146
- { role: "user", content: "u2" },
147
- { role: "assistant", content: "a2" },
148
- { role: "user", content: "u3" },
149
- { role: "assistant", content: "a3" },
150
- ];
151
- const plan = planCut(convo as never, 2);
152
- assert.ok(plan, "should plan a cut");
153
- assert.equal(plan!.system!.role, "system");
154
- assert.equal(plan!.tail[0]!.role, "user"); // tail starts on a user boundary — tool pairs never split
155
-
156
- // --- rendering ---
157
- const diff = renderEditDiff("f.ts", "old line", "new line");
158
- assert.ok(diff.includes("old line") && diff.includes("new line"), diff);
159
- const ms = new MarkdownStreamer();
160
- const rendered = ms.push("# Title\n- item\n") + ms.end();
161
- assert.ok(rendered.includes("Title") && rendered.includes("item"), rendered);
162
- const hl = highlight('const x = "hi" // c');
163
- assert.ok(hl.includes("\x1b[") && hl.includes("const"), hl); // keywords/strings/comments colored
164
-
165
- // --- prompt templates ---
166
- const pm = new Map([["fix", "Fix $1 carefully. All: $ARGUMENTS"]]);
167
- assert.equal(expandPrompt(pm, "/fix foo.ts it crashes"), "Fix foo.ts carefully. All: foo.ts it crashes");
168
- assert.equal(expandPrompt(pm, "/unknown x"), null);
169
- assert.equal(expandPrompt(pm, "hello"), null);
170
-
171
- // --- extensibility: dynamic tool registration + skills ---
172
- registerTool({
173
- name: "__demo",
174
- description: "demo",
175
- parameters: { type: "object", properties: {} },
176
- needsApproval: false,
177
- async run() {
178
- return { output: "ok" };
179
- },
180
- });
181
- assert.ok(toolByName.get("__demo"), "registerTool adds a dynamic tool");
182
- registerSkillTool([{ name: "demo", description: "d", path: "nope" }]);
183
- assert.ok(toolByName.get("use_skill"), "registerSkillTool exposes use_skill");
184
-
185
- // --- credential store round-trip ---
186
- await setCredential("__selfcheck", { type: "api_key", key: "sk-test" });
187
- assert.equal(getCredential("__selfcheck")?.key, "sk-test");
188
- await deleteCredential("__selfcheck");
189
- assert.equal(getCredential("__selfcheck"), undefined);
190
-
191
- // --- multimodal: image file → data url ---
192
- const imgPath = join(tmpdir(), `ada-img-${Date.now()}.png`);
193
- writeFileSync(imgPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
194
- const img = loadImage(imgPath);
195
- assert.ok(img && img.dataUrl.startsWith("data:image/png;base64,"), "loadImage → png data url");
196
- rmSync(imgPath, { force: true });
197
-
198
- // --- checkpoint undo round-trip ---
199
- const cpFile = join(tmpdir(), `ada-cp-${Date.now()}.txt`);
200
- writeFileSync(cpFile, "v1");
201
- checkpoint.record(cpFile);
202
- writeFileSync(cpFile, "v2");
203
- checkpoint.undoAll();
204
- assert.equal(readFileSync(cpFile, "utf8"), "v1", "undo restores the original content");
205
- rmSync(cpFile, { force: true });
206
-
207
- // --- todos + destructive detection ---
208
- setTodos([{ text: "alpha", status: "done" }, { text: "beta", status: "todo" }]);
209
- assert.ok(renderTodos().includes("alpha") && renderTodos().includes("beta"), "todos render");
210
- assert.ok(isDestructive("rm -rf /tmp/x"), "rm -rf is destructive");
211
- assert.ok(!isDestructive("ls -la"), "ls is not destructive");
212
-
213
- // --- web_fetch HTML→text + tools registered ---
214
- const ht = htmlToText("<h1>Hi</h1><p>a &amp; b</p><script>x()</script><ul><li>one</li></ul>");
215
- assert.ok(/Hi/.test(ht) && /a & b/.test(ht) && /- one/.test(ht) && !/x\(\)/.test(ht), "htmlToText strips tags/scripts, decodes entities");
216
- assert.ok(toolByName.has("web_fetch") && toolByName.has("web_search"), "web tools registered");
217
- assert.equal(formatFile(join(tmpdir(), "x.go")), false, "formatFile is a safe no-op when untrusted/no formatter (never throws)");
218
- assert.ok(toolByName.has("lsp_diagnostics"), "lsp_diagnostics tool registered");
219
- assert.deepEqual(await getDiagnostics(join(tmpdir(), "x.ts")), [], "getDiagnostics no-ops when untrusted/no server (never throws)");
220
- const bashRun = await toolByName.get("bash")!.run({ command: "echo pty-probe-123" });
221
- assert.ok(/pty-probe-123/.test(bashRun.output) && /exit 0/.test(bashRun.output), `bash runs a command (PTY): ${bashRun.output.slice(0, 60)}`);
222
-
223
- // --- apply_patch: create → update → delete across files ---
224
- const ap = toolByName.get("apply_patch")!;
225
- const apDir = join(tmpdir(), `ada-ap-${process.pid}`);
226
- mkdirSync(apDir, { recursive: true });
227
- const apFile = join(apDir, "a.txt");
228
- assert.ok(!(await ap.run({ files: [{ path: apFile, action: "create", content: "hello\n" }] })).isError && existsSync(apFile), "apply_patch create");
229
- await ap.run({ files: [{ path: apFile, action: "update", edits: [{ old_text: "hello", new_text: "world" }] }] });
230
- assert.ok(/world/.test(readFileSync(apFile, "utf8")), "apply_patch update");
231
- await ap.run({ files: [{ path: apFile, action: "delete" }] });
232
- assert.ok(!existsSync(apFile), "apply_patch delete");
233
- rmSync(apDir, { recursive: true, force: true });
234
-
235
- // --- ask_user via a stub asker ---
236
- const askTool = toolByName.get("ask_user")!;
237
- setAsker(async (_q, opts) => (opts ? opts[0]! : "the-answer"));
238
- assert.ok(/the-answer/.test((await askTool.run({ question: "?" })).output), "ask_user returns the answer");
239
- assert.ok(/picked-A/.test((await askTool.run({ question: "?", options: ["picked-A", "B"] })).output), "ask_user with options");
240
- setAsker(null);
241
- assert.equal((await askTool.run({ question: "?" })).isError, true, "ask_user errors when no asker is installed");
242
-
243
- // --- grep still works (rg fast path falls back to the JS scan when rg is absent) ---
244
- assert.ok(/tools\.ts/.test((await toolByName.get("grep")!.run({ pattern: "export const tools", path: "src/client" })).output), "grep finds matches");
245
-
246
- // --- workspace snapshot returns a git tree SHA (or null outside a repo); never throws ---
247
- const snap = snapshot();
248
- assert.ok(snap === null || /^[0-9a-f]{40}$/.test(snap), "snapshot returns a tree SHA");
249
-
250
- // --- approval context: readable call descriptions + plain-words permission phrases ---
251
- assert.equal(describeCall("bash", { command: 'dir "C:\\x" /b' }).detail, 'dir "C:\\x" /b', "bash → shows the command, not JSON");
252
- assert.equal(describeCall("read_file", { path: "a.ts" }).label, "read", "read_file → 'read'");
253
- assert.equal(describeCall("merchant__list_products", {}).label, "merchant", "MCP tool → connector name as label");
254
- assert.ok(permPhrase("bash", true).startsWith("⚠"), "destructive bash phrase is flagged");
255
- assert.equal(permPhrase("write_file", false), "create or modify files on disk", "write phrase");
256
- assert.ok(permPhrase("merchant__x", false).includes("connector"), "MCP phrase mentions the connector");
257
-
258
- // --- baked offline catalog seeds pricing/limits (no network) ---
259
- {
260
- const { priceOf, contextOf, catalogSize, catalogText } = await import("./client/models-dev.ts");
261
- assert.ok(catalogSize() > 100, `catalog seeded from catalog.json (${catalogSize()} models)`);
262
- const op = priceOf("claude-opus-4-8");
263
- assert.ok(op && op[0] > 0 && op[1] > 0, "priceOf resolves a baked model offline");
264
- assert.ok((contextOf("claude-opus-4-8") ?? 0) >= 200000, "contextOf resolves a baked model offline");
265
- assert.ok(/anthropic/.test(catalogText()) && /openai/.test(catalogText()) && /cloudflare/.test(catalogText()), "catalogText lists the popular providers");
266
- assert.ok(/claude-opus-4-8/.test(catalogText("anthropic")), "catalogText <provider> lists its models");
267
- }
268
-
269
- // --- provider routing (incl. the new cloudflare + groq/together disambiguation) ---
270
- {
271
- const { route } = await import("./server/router.ts");
272
- const { PROVIDERS } = await import("./server/config.ts");
273
- assert.ok("cloudflare" in PROVIDERS, "cloudflare provider is registered");
274
- assert.equal(route("@cf/moonshotai/kimi-k2.7-code"), "cloudflare", "@cf/ → cloudflare");
275
- assert.equal(route("groq/llama-3.3-70b"), "groq", "groq/ → groq");
276
- assert.equal(route("together/x"), "together", "together/ → together");
277
- assert.equal(route("claude-opus-4-8"), "anthropic", "claude → anthropic");
278
- assert.equal(route("gpt-5"), "openai", "gpt → openai");
279
- assert.equal(route("gemini-3-pro"), "google", "gemini → google");
280
- assert.equal(route("qwen3-coder"), "dashscope", "qwen → dashscope");
281
- assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
282
- }
283
-
284
- // --- background job runs and reports ---
285
- const jid = startJob("selfcheck job", async () => "job-done-ok");
286
- await new Promise((r) => setTimeout(r, 30));
287
- assert.ok(renderJobs().includes(jid) && /job-done-ok/.test(renderJobs()), "background job runs and reports its result");
288
- assert.equal((await toolByName.get("web_fetch")!.run({ url: "http://127.0.0.1/x" })).isError, true, "web_fetch blocks loopback (SSRF guard)");
289
-
290
- // --- destructive classifier: real dangers flagged; everyday redirects are not (2>/dev/null bug) ---
291
- // The /dev/ sink allow-list is boundary-anchored, so device writes whose name starts with a sink
292
- // token (ttyS0, tty1) are still caught they were a confirmed bypass before the fix.
293
- for (const c of ["rm -rf /", "dd if=/dev/zero of=/dev/sda", "git push --force origin main", "git reset --hard", "> /dev/sda", "> /dev/ttyS0", "echo x > /dev/tty1"]) {
294
- assert.ok(isDestructive(c), `should be destructive: ${c}`);
295
- }
296
- for (const c of ['ls "/some/dir" 2>/dev/null', "cat x >/dev/null", "echo hi > /dev/stdout", "grep foo bar 2> /dev/null", "node app.js &>/dev/null", "x >/dev/null 2>&1", "cat >/dev/tty"]) {
297
- assert.ok(!isDestructive(c), `should NOT be destructive: ${c}`);
298
- }
299
-
300
- // --- leaked tool-call recovery (Ollama-over-stream emits the call as text) ---
301
- const leaked = parseTextToolCalls('{"name": "update_todos", "arguments": {"todos": []}}');
302
- assert.equal(leaked?.[0]?.name, "update_todos", "plain JSON tool call recovered");
303
- const tagged = parseTextToolCalls('<tool_call>{"name":"ls","arguments":{"path":"."}}</tool_call>');
304
- assert.equal(tagged?.[0]?.name, "ls", "<tool_call> wrapped call recovered");
305
- assert.equal(parseTextToolCalls('{"name":"spend_time","arguments":{}}'), null, "unknown tool not treated as a call");
306
- assert.equal(parseTextToolCalls("just some prose"), null, "prose is not a tool call");
307
-
308
- // --- TUI user bar fills the full width (no void, single styled echo) ---
309
- const bar = userBar("hi", 40);
310
- assert.ok(bar.includes("hi") && bar.includes(""), "user bar shows the text + marker");
311
- assert.ok(bar.includes("\x1b[48;5;238m"), "user bar has a full-width background");
312
- assert.ok(userBar("x".repeat(200), 40).length > 40, "over-long input does not crash padding");
313
-
314
- // --- bundled skills load + scalable discovery (list_skills / slim use_skill) ---
315
- const allSkills = loadSkills(true);
316
- const skillNames = allSkills.map((s) => s.name);
317
- assert.ok(skillNames.length >= 200, `>=200 skills load (got ${skillNames.length})`);
318
- for (const want of ["commit", "ponytail", "dockerize", "migration", "react-hooks", "terraform-module", "pixel-diff", "canvas-debug", "connect-github", "design-system"]) {
319
- assert.ok(skillNames.includes(want), `bundled skill present: ${want}`);
320
- }
321
- registerSkillTool(allSkills);
322
- const useSkill = toolByName.get("use_skill")!;
323
- assert.ok(useSkill.description.length < 400, `use_skill description is slim (got ${useSkill.description.length})`);
324
- const listSkills = toolByName.get("list_skills")!;
325
- const filtered = (await listSkills.run({ filter: "docker" })).output;
326
- assert.ok(/dockerize/.test(filtered) && !/migration/.test(filtered), "list_skills filter narrows results");
327
- assert.ok(/categories/.test((await listSkills.run({})).output), "list_skills overview lists categories");
328
-
329
- // --- skill routing (lexical relevance ranker behind find_skill + auto-suggest) ---
330
- assert.ok(rankSkills("write a database migration", allSkills, 5).some((r) => r.name === "migration"), "routing surfaces migration");
331
- assert.ok(rankSkills("set up a dark mode theme", allSkills, 5).some((r) => r.name === "dark-mode"), "routing surfaces dark-mode");
332
- const dockerTop = rankSkills("build a docker image for the app", allSkills, 5).map((r) => r.name);
333
- assert.ok(dockerTop.includes("dockerize") || dockerTop.includes("docker-compose"), `routing surfaces a docker skill (got ${dockerTop.join(",")})`);
334
- assert.equal(rankSkills("", allSkills).length, 0, "empty query → no matches");
335
-
336
- // --- confident skill orchestration: auto-apply only on a dominant, name-exact match ---
337
- assert.equal(confidentSkill("describe the project", allSkills), "project-overview", "confident: describe the project → project-overview");
338
- assert.equal(confidentSkill("draw an architecture diagram of this project", allSkills), "architecture-diagram", "confident: → architecture-diagram");
339
- assert.equal(confidentSkill("make a powerpoint about Q3 results", allSkills), null, "precision guard: 'powerpoint' must NOT auto-apply 'low-power'");
340
- assert.equal(confidentSkill("what is 2 + 2", allSkills), null, "ambiguous query no auto-apply");
341
- // LOADED was set by registerSkillTool(allSkills) above, so routeConfident/skillBody resolve a body.
342
- const applied = routeConfident("describe the project");
343
- assert.ok(applied?.name === "project-overview" && /purpose/i.test(applied.body), "routeConfident returns the skill body to inject");
344
- assert.equal(routeConfident("make a powerpoint about Q3 results"), null, "routeConfident respects the precision guard");
345
-
346
- // --- connector catalog (read-only; does not touch .ada/mcp.json) ---
347
- const catalog = listConnectors();
348
- assert.ok(catalog.length >= 8 && catalog.some((c) => c.name === "github"), "connector catalog populated");
349
- assert.ok(catalog.find((c) => c.name === "github")?.needsEnv.includes("GITHUB_PERSONAL_ACCESS_TOKEN"), "github connector declares its env var");
350
-
351
- // --- toolsmith path end-to-end via a real stub MCP server (skips if a real .ada/mcp.json exists) ---
352
- const adaDir = join(process.cwd(), ".ada");
353
- const mcpCfg = join(adaDir, "mcp.json");
354
- if (!existsSync(mcpCfg) && existsSync(join(process.cwd(), "test", "stub-mcp.mjs"))) {
355
- mkdirSync(adaDir, { recursive: true });
356
- writeFileSync(mcpCfg, JSON.stringify({ servers: { stub: { command: "node", args: ["test/stub-mcp.mjs"] } } }));
357
- try {
358
- const loaded = await loadMcpServers(true);
359
- assert.ok(loaded.some((l) => l.startsWith("stub")), "stub MCP server connected + tools registered");
360
- assert.deepEqual(configuredServers(), ["stub"], "configuredServers sees the stub");
361
- assert.equal(soleIntegration(), "stub", "soleIntegration → stub");
362
- const docs = readIntegrationDocs("stub");
363
- assert.ok(/stub__echo/.test(docs) && /stub__add/.test(docs), "readDocs lists the stub's tools");
364
- const n = writeProjectSkills([
365
- { name: "stub-echo", content: "---\nname: stub-echo\ndescription: echo via the stub\ncategory: integration-stub\n---\n# Echo\n1. call stub__echo\n## Rules\n- keep it short" },
366
- { name: "stub-junk", content: "not a skill file" },
367
- ]);
368
- assert.equal(n, 1, "writeProjectSkills writes valid skills and skips junk");
369
- assert.ok(existsSync(join(adaDir, "skills", "stub-echo", "SKILL.md")), "stub-echo SKILL.md written");
370
- } finally {
371
- rmSync(mcpCfg, { force: true });
372
- rmSync(join(adaDir, "skills", "stub-echo"), { recursive: true, force: true });
373
- }
374
- }
375
-
376
- // --- login allowlist ---
377
- assert.ok(isAllowed("anyone"), "no allowlist allow any authenticated user");
378
- process.env.ADA_ALLOWED_USERS = "alice, bob";
379
- assert.ok(isAllowed("alice"));
380
- assert.ok(!isAllowed("carol"), "off-allowlist user rejected");
381
- delete process.env.ADA_ALLOWED_USERS;
382
-
383
- console.log("selfcheck OK");
384
- process.exit(0); // a spawned stub MCP subprocess can hold stdin open — exit cleanly
385
- }
386
-
387
- main().catch((e) => {
388
- console.error("selfcheck FAILED:", e instanceof Error ? e.message : e);
389
- process.exit(1);
390
- });
1
+ // Offline self-check: tools, session persistence, and routing. No network, no API key.
2
+ // Run with: npm run selfcheck
3
+
4
+ import assert from "node:assert/strict";
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { estimateTokens, isContextOverflowError, planCut } from "./client/compaction.ts";
9
+ import { loadImage } from "./client/image.ts";
10
+ import { expandPrompt } from "./client/prompts.ts";
11
+ import { MarkdownStreamer, highlight, renderEditDiff } from "./client/render.ts";
12
+ import { Session, list } from "./client/session.ts";
13
+ import { loadSkills, registerSkillTool, routeConfident } from "./client/skills.ts";
14
+ import { describeCall, parseTextToolCalls, permPhrase, readIntegrationDocs, soleIntegration, writeProjectSkills } from "./client/agent.ts";
15
+ import { userBar } from "./client/tui.ts";
16
+ import { configuredServers, listConnectors, loadMcpServers } from "./client/mcp.ts";
17
+ import { confidentSkill, rankSkills } from "./client/skill-router.ts";
18
+ import { getDiagnostics } from "./client/lsp.ts";
19
+ import { snapshot } from "./client/snapshot.ts";
20
+ import { renderJobs, startJob } from "./client/background.ts";
21
+ import { formatFile, htmlToText, isDestructive, registerTool, setAsker, toolByName } from "./client/tools.ts";
22
+ import * as checkpoint from "./client/checkpoint.ts";
23
+ import { renderTodos, setTodos } from "./client/todos.ts";
24
+ import { deleteCredential, getCredential, setCredential } from "./server/credentials.ts";
25
+ import { isAllowed } from "./server/identity.ts";
26
+ import { route } from "./server/router.ts";
27
+
28
+ function tool(name: string) {
29
+ const t = toolByName.get(name);
30
+ if (!t) throw new Error(`missing tool: ${name}`);
31
+ return t;
32
+ }
33
+
34
+ async function main(): Promise<void> {
35
+ // --- tools: write -> edit -> read round-trip ---
36
+ const dir = join(tmpdir(), `ada-selfcheck-${Date.now()}`);
37
+ const file = join(dir, "a.txt");
38
+
39
+ let r = await tool("write_file").run({ path: file, content: "hello world" });
40
+ assert.ok(!r.isError, r.output);
41
+ r = await tool("edit_file").run({ path: file, old_text: "world", new_text: "ada" });
42
+ assert.ok(!r.isError, r.output);
43
+ r = await tool("read_file").run({ path: file });
44
+ assert.equal(r.output, "hello ada");
45
+
46
+ // ambiguous edit must error
47
+ await tool("write_file").run({ path: file, content: "x x" });
48
+ r = await tool("edit_file").run({ path: file, old_text: "x", new_text: "y" });
49
+ assert.ok(r.isError, "ambiguous edit should error");
50
+
51
+ // missing read must error
52
+ r = await tool("read_file").run({ path: join(dir, "nope.txt") });
53
+ assert.ok(r.isError, "missing read should error");
54
+
55
+ // bash
56
+ r = await tool("bash").run({ command: "echo hi" });
57
+ assert.ok(r.output.includes("hi"), r.output);
58
+
59
+ // grep / ls / glob
60
+ await tool("write_file").run({ path: join(dir, "hello.txt"), content: "alpha\nNEEDLE here\nbeta" });
61
+ const g = await tool("grep").run({ path: dir, pattern: "NEEDLE" });
62
+ assert.ok(g.output.includes("NEEDLE"), g.output);
63
+ const l = await tool("ls").run({ path: dir });
64
+ assert.ok(l.output.includes("hello.txt"), l.output);
65
+ const gl = await tool("glob").run({ pattern: "src/selfcheck.ts" });
66
+ assert.ok(gl.output.includes("selfcheck.ts"), gl.output);
67
+
68
+ // read offset/limit
69
+ await tool("write_file").run({ path: join(dir, "lines.txt"), content: "L1\nL2\nL3\nL4" });
70
+ const ol = await tool("read_file").run({ path: join(dir, "lines.txt"), offset: 2, limit: 2 });
71
+ assert.equal(ol.output, "L2\nL3");
72
+
73
+ // multi-edit
74
+ await tool("write_file").run({ path: join(dir, "m.txt"), content: "aaa bbb ccc" });
75
+ r = await tool("edit_file").run({
76
+ path: join(dir, "m.txt"),
77
+ edits: [
78
+ { old_text: "aaa", new_text: "AAA" },
79
+ { old_text: "ccc", new_text: "CCC" },
80
+ ],
81
+ });
82
+ assert.ok(!r.isError, r.output);
83
+ r = await tool("read_file").run({ path: join(dir, "m.txt") });
84
+ assert.equal(r.output, "AAA bbb CCC");
85
+
86
+ // CRLF preservation: file uses \r\n, edit's old_text uses \n
87
+ const crlf = join(dir, "crlf.txt");
88
+ await tool("write_file").run({ path: crlf, content: "one\r\ntwo\r\nthree" });
89
+ r = await tool("edit_file").run({ path: crlf, old_text: "two", new_text: "TWO" });
90
+ assert.ok(!r.isError, r.output);
91
+ r = await tool("read_file").run({ path: crlf });
92
+ assert.ok(r.output.includes("\r\n") && r.output.includes("TWO"), JSON.stringify(r.output));
93
+
94
+ rmSync(dir, { recursive: true, force: true });
95
+
96
+ // --- session append -> load round-trip ---
97
+ const s = Session.create();
98
+ s.append({ role: "user", content: "hello" });
99
+ s.append({ role: "assistant", content: "hi there" });
100
+ const loaded = s.load();
101
+ assert.equal(loaded.length, 2);
102
+ assert.equal(loaded[0]!.content, "hello");
103
+ rmSync(s.file, { force: true });
104
+
105
+ // --- branching: fork seeds messages, records parent, load skips __meta ---
106
+ const parent = Session.create();
107
+ parent.append({ role: "user", content: "p1" });
108
+ const branch = Session.fork(parent.file, [
109
+ { role: "user", content: "p1" },
110
+ { role: "assistant", content: "a1" },
111
+ ]);
112
+ const bl = branch.load();
113
+ assert.equal(bl.length, 2, "fork load skips the __meta line");
114
+ assert.equal(bl[0]!.content, "p1");
115
+ const bm = list().find((m) => m.file === branch.file);
116
+ assert.ok(bm?.parent === parent.file, "branch records its parent");
117
+ rmSync(parent.file, { force: true });
118
+ rmSync(branch.file, { force: true });
119
+
120
+ // --- router prefix mapping ---
121
+ assert.equal(route("gpt-4o"), "openai");
122
+ assert.equal(route("o3-mini"), "openai");
123
+ assert.equal(route("claude-opus-4-8"), "anthropic");
124
+ assert.equal(route("gemini-2.5-pro"), "google");
125
+ assert.equal(route("mistral-large-latest"), "mistral");
126
+ assert.equal(route("grok-2"), "xai");
127
+ assert.equal(route("deepseek-chat"), "deepseek");
128
+ assert.equal(route("qwen-max"), "dashscope");
129
+ assert.equal(route("qwq-32b"), "dashscope");
130
+ assert.equal(route("qwen/qwen-2.5-72b-instruct"), "openrouter"); // namespaced id stays on OpenRouter
131
+ assert.equal(route("gemma4:latest"), "ollama"); // local Ollama "model:tag"
132
+ assert.equal(route("mistralai/mistral-7b:free"), "openrouter"); // slash wins over colon
133
+ assert.equal(route("meta-llama/llama-3.1-70b"), "openrouter");
134
+ assert.equal(route("anything", "mistral"), "mistral");
135
+
136
+ // --- compaction ---
137
+ assert.ok(estimateTokens([{ role: "user", content: "hello" }] as never) > 0);
138
+ assert.ok(isContextOverflowError(new Error("maximum context length exceeded")));
139
+ assert.ok(!isContextOverflowError(new Error("invalid api key")));
140
+ const convo = [
141
+ { role: "system", content: "sys" },
142
+ { role: "user", content: "u1" },
143
+ { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "bash", arguments: "{}" } }] },
144
+ { role: "tool", tool_call_id: "c1", content: "out" },
145
+ { role: "assistant", content: "a1" },
146
+ { role: "user", content: "u2" },
147
+ { role: "assistant", content: "a2" },
148
+ { role: "user", content: "u3" },
149
+ { role: "assistant", content: "a3" },
150
+ ];
151
+ const plan = planCut(convo as never, 2);
152
+ assert.ok(plan, "should plan a cut");
153
+ assert.equal(plan!.system!.role, "system");
154
+ assert.equal(plan!.tail[0]!.role, "user"); // tail starts on a user boundary — tool pairs never split
155
+
156
+ // --- rendering ---
157
+ const diff = renderEditDiff("f.ts", "old line", "new line");
158
+ assert.ok(diff.includes("old line") && diff.includes("new line"), diff);
159
+ const ms = new MarkdownStreamer();
160
+ const rendered = ms.push("# Title\n- item\n") + ms.end();
161
+ assert.ok(rendered.includes("Title") && rendered.includes("item"), rendered);
162
+ const hl = highlight('const x = "hi" // c');
163
+ assert.ok(hl.includes("\x1b[") && hl.includes("const"), hl); // keywords/strings/comments colored
164
+
165
+ // --- prompt templates ---
166
+ const pm = new Map([["fix", "Fix $1 carefully. All: $ARGUMENTS"]]);
167
+ assert.equal(expandPrompt(pm, "/fix foo.ts it crashes"), "Fix foo.ts carefully. All: foo.ts it crashes");
168
+ assert.equal(expandPrompt(pm, "/unknown x"), null);
169
+ assert.equal(expandPrompt(pm, "hello"), null);
170
+
171
+ // --- extensibility: dynamic tool registration + skills ---
172
+ registerTool({
173
+ name: "__demo",
174
+ description: "demo",
175
+ parameters: { type: "object", properties: {} },
176
+ needsApproval: false,
177
+ async run() {
178
+ return { output: "ok" };
179
+ },
180
+ });
181
+ assert.ok(toolByName.get("__demo"), "registerTool adds a dynamic tool");
182
+ registerSkillTool([{ name: "demo", description: "d", path: "nope" }]);
183
+ assert.ok(toolByName.get("use_skill"), "registerSkillTool exposes use_skill");
184
+
185
+ // --- credential store round-trip ---
186
+ await setCredential("__selfcheck", { type: "api_key", key: "sk-test" });
187
+ assert.equal(getCredential("__selfcheck")?.key, "sk-test");
188
+ await deleteCredential("__selfcheck");
189
+ assert.equal(getCredential("__selfcheck"), undefined);
190
+
191
+ // --- multimodal: image file → data url ---
192
+ const imgPath = join(tmpdir(), `ada-img-${Date.now()}.png`);
193
+ writeFileSync(imgPath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
194
+ const img = loadImage(imgPath);
195
+ assert.ok(img && img.dataUrl.startsWith("data:image/png;base64,"), "loadImage → png data url");
196
+ rmSync(imgPath, { force: true });
197
+
198
+ // --- checkpoint undo round-trip ---
199
+ const cpFile = join(tmpdir(), `ada-cp-${Date.now()}.txt`);
200
+ writeFileSync(cpFile, "v1");
201
+ checkpoint.record(cpFile);
202
+ writeFileSync(cpFile, "v2");
203
+ checkpoint.undoAll();
204
+ assert.equal(readFileSync(cpFile, "utf8"), "v1", "undo restores the original content");
205
+ rmSync(cpFile, { force: true });
206
+
207
+ // --- todos + destructive detection ---
208
+ setTodos([{ text: "alpha", status: "done" }, { text: "beta", status: "todo" }]);
209
+ assert.ok(renderTodos().includes("alpha") && renderTodos().includes("beta"), "todos render");
210
+ assert.ok(isDestructive("rm -rf /tmp/x"), "rm -rf is destructive");
211
+ assert.ok(!isDestructive("ls -la"), "ls is not destructive");
212
+
213
+ // --- web_fetch HTML→text + tools registered ---
214
+ const ht = htmlToText("<h1>Hi</h1><p>a &amp; b</p><script>x()</script><ul><li>one</li></ul>");
215
+ assert.ok(/Hi/.test(ht) && /a & b/.test(ht) && /- one/.test(ht) && !/x\(\)/.test(ht), "htmlToText strips tags/scripts, decodes entities");
216
+ assert.ok(toolByName.has("web_fetch") && toolByName.has("web_search"), "web tools registered");
217
+ assert.equal(formatFile(join(tmpdir(), "x.go")), false, "formatFile is a safe no-op when untrusted/no formatter (never throws)");
218
+ assert.ok(toolByName.has("lsp_diagnostics"), "lsp_diagnostics tool registered");
219
+ assert.deepEqual(await getDiagnostics(join(tmpdir(), "x.ts")), [], "getDiagnostics no-ops when untrusted/no server (never throws)");
220
+ const bashRun = await toolByName.get("bash")!.run({ command: "echo pty-probe-123" });
221
+ assert.ok(/pty-probe-123/.test(bashRun.output) && /exit 0/.test(bashRun.output), `bash runs a command (PTY): ${bashRun.output.slice(0, 60)}`);
222
+
223
+ // --- apply_patch: create → update → delete across files ---
224
+ const ap = toolByName.get("apply_patch")!;
225
+ const apDir = join(tmpdir(), `ada-ap-${process.pid}`);
226
+ mkdirSync(apDir, { recursive: true });
227
+ const apFile = join(apDir, "a.txt");
228
+ assert.ok(!(await ap.run({ files: [{ path: apFile, action: "create", content: "hello\n" }] })).isError && existsSync(apFile), "apply_patch create");
229
+ await ap.run({ files: [{ path: apFile, action: "update", edits: [{ old_text: "hello", new_text: "world" }] }] });
230
+ assert.ok(/world/.test(readFileSync(apFile, "utf8")), "apply_patch update");
231
+ await ap.run({ files: [{ path: apFile, action: "delete" }] });
232
+ assert.ok(!existsSync(apFile), "apply_patch delete");
233
+ rmSync(apDir, { recursive: true, force: true });
234
+
235
+ // --- ask_user via a stub asker ---
236
+ const askTool = toolByName.get("ask_user")!;
237
+ setAsker(async (_q, opts) => (opts ? opts[0]! : "the-answer"));
238
+ assert.ok(/the-answer/.test((await askTool.run({ question: "?" })).output), "ask_user returns the answer");
239
+ assert.ok(/picked-A/.test((await askTool.run({ question: "?", options: ["picked-A", "B"] })).output), "ask_user with options");
240
+ setAsker(null);
241
+ assert.equal((await askTool.run({ question: "?" })).isError, true, "ask_user errors when no asker is installed");
242
+
243
+ // --- grep still works (rg fast path falls back to the JS scan when rg is absent) ---
244
+ assert.ok(/tools\.ts/.test((await toolByName.get("grep")!.run({ pattern: "export const tools", path: "src/client" })).output), "grep finds matches");
245
+
246
+ // --- workspace snapshot returns a git tree SHA (or null outside a repo); never throws ---
247
+ const snap = snapshot();
248
+ assert.ok(snap === null || /^[0-9a-f]{40}$/.test(snap), "snapshot returns a tree SHA");
249
+
250
+ // --- approval context: readable call descriptions + plain-words permission phrases ---
251
+ assert.equal(describeCall("bash", { command: 'dir "C:\\x" /b' }).detail, 'dir "C:\\x" /b', "bash → shows the command, not JSON");
252
+ assert.equal(describeCall("read_file", { path: "a.ts" }).label, "read", "read_file → 'read'");
253
+ assert.equal(describeCall("merchant__list_products", {}).label, "merchant", "MCP tool → connector name as label");
254
+ assert.ok(permPhrase("bash", true).startsWith("⚠"), "destructive bash phrase is flagged");
255
+ assert.equal(permPhrase("write_file", false), "create or modify files on disk", "write phrase");
256
+ assert.ok(permPhrase("merchant__x", false).includes("connector"), "MCP phrase mentions the connector");
257
+
258
+ // --- baked offline catalog seeds pricing/limits (no network) ---
259
+ {
260
+ const { priceOf, contextOf, catalogSize, catalogText } = await import("./client/models-dev.ts");
261
+ assert.ok(catalogSize() > 100, `catalog seeded from catalog.json (${catalogSize()} models)`);
262
+ const op = priceOf("claude-opus-4-8");
263
+ assert.ok(op && op[0] > 0 && op[1] > 0, "priceOf resolves a baked model offline");
264
+ assert.ok((contextOf("claude-opus-4-8") ?? 0) >= 200000, "contextOf resolves a baked model offline");
265
+ assert.ok(/anthropic/.test(catalogText()) && /openai/.test(catalogText()) && /cloudflare/.test(catalogText()), "catalogText lists the popular providers");
266
+ assert.ok(/claude-opus-4-8/.test(catalogText("anthropic")), "catalogText <provider> lists its models");
267
+ }
268
+
269
+ // --- provider routing (incl. the new cloudflare + groq/together disambiguation) ---
270
+ {
271
+ const { route } = await import("./server/router.ts");
272
+ const { PROVIDERS } = await import("./server/config.ts");
273
+ assert.ok("cloudflare" in PROVIDERS, "cloudflare provider is registered");
274
+ assert.equal(route("@cf/moonshotai/kimi-k2.7-code"), "cloudflare", "@cf/ → cloudflare");
275
+ assert.equal(route("groq/llama-3.3-70b"), "groq", "groq/ → groq");
276
+ assert.equal(route("together/x"), "together", "together/ → together");
277
+ assert.equal(route("claude-opus-4-8"), "anthropic", "claude → anthropic");
278
+ assert.equal(route("gpt-5"), "openai", "gpt → openai");
279
+ assert.equal(route("gemini-3-pro"), "google", "gemini → google");
280
+ assert.equal(route("qwen3-coder"), "dashscope", "qwen → dashscope");
281
+ assert.equal(route("anything-else"), "openrouter", "unmatched → openrouter");
282
+ }
283
+
284
+ // --- autostart helpers: URL classification + /health derivation ---
285
+ {
286
+ const { isLocalBackend, healthUrl } = await import("./client/autostart.ts");
287
+ assert.ok(isLocalBackend("http://localhost:8787/v1"), "localhost is local");
288
+ assert.ok(isLocalBackend("http://127.0.0.1:8787/v1"), "127.0.0.1 is local");
289
+ assert.ok(!isLocalBackend("https://ada.example.com/v1"), "remote URL is not local");
290
+ assert.equal(healthUrl("http://localhost:8787/v1"), "http://localhost:8787/health", "/v1 base /health");
291
+ assert.equal(healthUrl("http://localhost:8787"), "http://localhost:8787/health", "bare base /health");
292
+ // Remote URL ensureBackend short-circuits to "remote" without spawning anything.
293
+ const { ensureBackend } = await import("./client/autostart.ts");
294
+ const v = await ensureBackend("https://ada.example.com/v1", { quiet: true, waitMs: 200 });
295
+ assert.equal(v, "remote", "remote URL returns 'remote' without spawning");
296
+ }
297
+
298
+ // --- background job runs and reports ---
299
+ const jid = startJob("selfcheck job", async () => "job-done-ok");
300
+ await new Promise((r) => setTimeout(r, 30));
301
+ assert.ok(renderJobs().includes(jid) && /job-done-ok/.test(renderJobs()), "background job runs and reports its result");
302
+ assert.equal((await toolByName.get("web_fetch")!.run({ url: "http://127.0.0.1/x" })).isError, true, "web_fetch blocks loopback (SSRF guard)");
303
+
304
+ // --- destructive classifier: real dangers flagged; everyday redirects are not (2>/dev/null bug) ---
305
+ // The /dev/ sink allow-list is boundary-anchored, so device writes whose name starts with a sink
306
+ // token (ttyS0, tty1) are still caught — they were a confirmed bypass before the fix.
307
+ for (const c of ["rm -rf /", "dd if=/dev/zero of=/dev/sda", "git push --force origin main", "git reset --hard", "> /dev/sda", "> /dev/ttyS0", "echo x > /dev/tty1"]) {
308
+ assert.ok(isDestructive(c), `should be destructive: ${c}`);
309
+ }
310
+ for (const c of ['ls "/some/dir" 2>/dev/null', "cat x >/dev/null", "echo hi > /dev/stdout", "grep foo bar 2> /dev/null", "node app.js &>/dev/null", "x >/dev/null 2>&1", "cat >/dev/tty"]) {
311
+ assert.ok(!isDestructive(c), `should NOT be destructive: ${c}`);
312
+ }
313
+
314
+ // --- leaked tool-call recovery (Ollama-over-stream emits the call as text) ---
315
+ const leaked = parseTextToolCalls('{"name": "update_todos", "arguments": {"todos": []}}');
316
+ assert.equal(leaked?.[0]?.name, "update_todos", "plain JSON tool call recovered");
317
+ const tagged = parseTextToolCalls('<tool_call>{"name":"ls","arguments":{"path":"."}}</tool_call>');
318
+ assert.equal(tagged?.[0]?.name, "ls", "<tool_call> wrapped call recovered");
319
+ assert.equal(parseTextToolCalls('{"name":"spend_time","arguments":{}}'), null, "unknown tool not treated as a call");
320
+ assert.equal(parseTextToolCalls("just some prose"), null, "prose is not a tool call");
321
+
322
+ // --- TUI user bar fills the full width (no void, single styled echo) ---
323
+ const bar = userBar("hi", 40);
324
+ assert.ok(bar.includes("hi") && bar.includes(""), "user bar shows the text + marker");
325
+ assert.ok(bar.includes("\x1b[48;5;238m"), "user bar has a full-width background");
326
+ assert.ok(userBar("x".repeat(200), 40).length > 40, "over-long input does not crash padding");
327
+
328
+ // --- bundled skills load + scalable discovery (list_skills / slim use_skill) ---
329
+ const allSkills = loadSkills(true);
330
+ const skillNames = allSkills.map((s) => s.name);
331
+ assert.ok(skillNames.length >= 200, `>=200 skills load (got ${skillNames.length})`);
332
+ for (const want of ["commit", "ponytail", "dockerize", "migration", "react-hooks", "terraform-module", "pixel-diff", "canvas-debug", "connect-github", "design-system"]) {
333
+ assert.ok(skillNames.includes(want), `bundled skill present: ${want}`);
334
+ }
335
+ registerSkillTool(allSkills);
336
+ const useSkill = toolByName.get("use_skill")!;
337
+ assert.ok(useSkill.description.length < 400, `use_skill description is slim (got ${useSkill.description.length})`);
338
+ const listSkills = toolByName.get("list_skills")!;
339
+ const filtered = (await listSkills.run({ filter: "docker" })).output;
340
+ assert.ok(/dockerize/.test(filtered) && !/migration/.test(filtered), "list_skills filter narrows results");
341
+ assert.ok(/categories/.test((await listSkills.run({})).output), "list_skills overview lists categories");
342
+
343
+ // --- skill routing (lexical relevance ranker behind find_skill + auto-suggest) ---
344
+ assert.ok(rankSkills("write a database migration", allSkills, 5).some((r) => r.name === "migration"), "routing surfaces migration");
345
+ assert.ok(rankSkills("set up a dark mode theme", allSkills, 5).some((r) => r.name === "dark-mode"), "routing surfaces dark-mode");
346
+ const dockerTop = rankSkills("build a docker image for the app", allSkills, 5).map((r) => r.name);
347
+ assert.ok(dockerTop.includes("dockerize") || dockerTop.includes("docker-compose"), `routing surfaces a docker skill (got ${dockerTop.join(",")})`);
348
+ assert.equal(rankSkills("", allSkills).length, 0, "empty query → no matches");
349
+
350
+ // --- confident skill orchestration: auto-apply only on a dominant, name-exact match ---
351
+ assert.equal(confidentSkill("describe the project", allSkills), "project-overview", "confident: describe the project project-overview");
352
+ assert.equal(confidentSkill("draw an architecture diagram of this project", allSkills), "architecture-diagram", "confident: → architecture-diagram");
353
+ assert.equal(confidentSkill("make a powerpoint about Q3 results", allSkills), null, "precision guard: 'powerpoint' must NOT auto-apply 'low-power'");
354
+ assert.equal(confidentSkill("what is 2 + 2", allSkills), null, "ambiguous query → no auto-apply");
355
+ // LOADED was set by registerSkillTool(allSkills) above, so routeConfident/skillBody resolve a body.
356
+ const applied = routeConfident("describe the project");
357
+ assert.ok(applied?.name === "project-overview" && /purpose/i.test(applied.body), "routeConfident returns the skill body to inject");
358
+ assert.equal(routeConfident("make a powerpoint about Q3 results"), null, "routeConfident respects the precision guard");
359
+
360
+ // --- connector catalog (read-only; does not touch .ada/mcp.json) ---
361
+ const catalog = listConnectors();
362
+ assert.ok(catalog.length >= 8 && catalog.some((c) => c.name === "github"), "connector catalog populated");
363
+ assert.ok(catalog.find((c) => c.name === "github")?.needsEnv.includes("GITHUB_PERSONAL_ACCESS_TOKEN"), "github connector declares its env var");
364
+
365
+ // --- toolsmith path end-to-end via a real stub MCP server (skips if a real .ada/mcp.json exists) ---
366
+ const adaDir = join(process.cwd(), ".ada");
367
+ const mcpCfg = join(adaDir, "mcp.json");
368
+ if (!existsSync(mcpCfg) && existsSync(join(process.cwd(), "test", "stub-mcp.mjs"))) {
369
+ mkdirSync(adaDir, { recursive: true });
370
+ writeFileSync(mcpCfg, JSON.stringify({ servers: { stub: { command: "node", args: ["test/stub-mcp.mjs"] } } }));
371
+ try {
372
+ const loaded = await loadMcpServers(true);
373
+ assert.ok(loaded.some((l) => l.startsWith("stub")), "stub MCP server connected + tools registered");
374
+ assert.deepEqual(configuredServers(), ["stub"], "configuredServers sees the stub");
375
+ assert.equal(soleIntegration(), "stub", "soleIntegration → stub");
376
+ const docs = readIntegrationDocs("stub");
377
+ assert.ok(/stub__echo/.test(docs) && /stub__add/.test(docs), "readDocs lists the stub's tools");
378
+ const n = writeProjectSkills([
379
+ { name: "stub-echo", content: "---\nname: stub-echo\ndescription: echo via the stub\ncategory: integration-stub\n---\n# Echo\n1. call stub__echo\n## Rules\n- keep it short" },
380
+ { name: "stub-junk", content: "not a skill file" },
381
+ ]);
382
+ assert.equal(n, 1, "writeProjectSkills writes valid skills and skips junk");
383
+ assert.ok(existsSync(join(adaDir, "skills", "stub-echo", "SKILL.md")), "stub-echo SKILL.md written");
384
+ } finally {
385
+ rmSync(mcpCfg, { force: true });
386
+ rmSync(join(adaDir, "skills", "stub-echo"), { recursive: true, force: true });
387
+ }
388
+ }
389
+
390
+ // --- login allowlist ---
391
+ assert.ok(isAllowed("anyone"), "no allowlist → allow any authenticated user");
392
+ process.env.ADA_ALLOWED_USERS = "alice, bob";
393
+ assert.ok(isAllowed("alice"));
394
+ assert.ok(!isAllowed("carol"), "off-allowlist user rejected");
395
+ delete process.env.ADA_ALLOWED_USERS;
396
+
397
+ console.log("selfcheck OK");
398
+ process.exit(0); // a spawned stub MCP subprocess can hold stdin open — exit cleanly
399
+ }
400
+
401
+ main().catch((e) => {
402
+ console.error("selfcheck FAILED:", e instanceof Error ? e.message : e);
403
+ process.exit(1);
404
+ });