Files
NetMesh/electron/bridges/aiBridge/sdk/claudeDriver.test.cjs

257 lines
10 KiB
JavaScript
Raw Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const { translateClaudeMessage, buildClaudeQueryOptions, buildClaudePromptInput, classifyClaudeSpawnError, listClaudeModels, mapClaudeModels, parseClaudeSettings, splitClaudeModelSelection } = require("./claudeDriver.cjs");
function collector() {
const events = [];
const emitter = {
text: (t) => events.push({ k: "text", t }),
reasoning: (d) => events.push({ k: "reasoning", d }),
reasoningEnd: () => events.push({ k: "reasoningEnd" }),
toolCall: (name, args, id) => events.push({ k: "toolCall", name, args, id }),
toolResult: (id, out, name) => events.push({ k: "toolResult", id, out, name }),
status: (m) => events.push({ k: "status", m }),
sessionId: (s) => events.push({ k: "sessionId", s }),
};
return { events, emitter };
}
test("init system message -> sessionId event", () => {
const { events, emitter } = collector();
translateClaudeMessage({ type: "system", subtype: "init", session_id: "sess-1" }, emitter);
assert.deepEqual(events, [{ k: "sessionId", s: "sess-1" }]);
});
test("stream_event text_delta -> text event", () => {
const { events, emitter } = collector();
translateClaudeMessage(
{ type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "hello" } } },
emitter,
);
assert.deepEqual(events, [{ k: "text", t: "hello" }]);
});
test("assistant tool_use block -> toolCall event", () => {
const { events, emitter } = collector();
translateClaudeMessage(
{
type: "assistant",
message: { content: [{ type: "tool_use", id: "tu-1", name: "mcp__netcatty-remote-hosts__terminal_execute", input: { command: "ls" } }] },
},
emitter,
);
assert.deepEqual(events, [
{ k: "toolCall", name: "mcp__netcatty-remote-hosts__terminal_execute", args: { command: "ls" }, id: "tu-1" },
]);
});
test("assistant text block (non-partial) is NOT double-emitted when partials enabled", () => {
// With includePartialMessages, text arrives via stream_event; the assistant
// message text block is the consolidated copy and must be skipped to avoid dupes.
const { events, emitter } = collector();
translateClaudeMessage(
{ type: "assistant", message: { content: [{ type: "text", text: "consolidated" }] } },
emitter,
);
assert.deepEqual(events, []);
});
test("user tool_result block -> toolResult event", () => {
const { events, emitter } = collector();
translateClaudeMessage(
{ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "tu-1", content: "output text" }] } },
emitter,
);
assert.deepEqual(events, [{ k: "toolResult", id: "tu-1", out: "output text", name: undefined }]);
});
test("buildClaudeQueryOptions sets bypassPermissions, built-in tools, mcp stdio, abort", () => {
const ac = new AbortController();
const opts = buildClaudeQueryOptions({
cwd: "/tmp",
model: "claude-opus-4-6",
env: { PATH: "/usr/bin" },
pathToClaudeCodeExecutable: "/abs/claude",
abortController: ac,
injectedMcpServers: [{
name: "netcatty-remote-hosts", type: "stdio",
command: "/abs/electron", args: ["/abs/server.cjs"],
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
}],
});
assert.equal(opts.permissionMode, "bypassPermissions");
// required companion to bypassPermissions (SDK rejects the bypass without it)
assert.equal(opts.allowDangerouslySkipPermissions, true);
assert.equal(opts.includePartialMessages, true);
assert.equal(opts.pathToClaudeCodeExecutable, "/abs/claude");
assert.equal(opts.abortController, ac);
// MCP mode disables Claude Code built-ins entirely; injected MCP tools remain wired below.
assert.deepEqual(opts.tools, []);
for (const t of ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"]) {
assert.ok(opts.disallowedTools.includes(t), `expected ${t} disallowed`);
}
// netcatty MCP wired as keyed stdio with env object (not pair array)
assert.equal(opts.mcpServers["netcatty-remote-hosts"].type, "stdio");
assert.deepEqual(opts.mcpServers["netcatty-remote-hosts"].env, { NETCATTY_MCP_PORT: "1" });
});
test("built-in tools are mode-aware: Skills+CLI allows only Bash/Skill, MCP blocks all built-ins", () => {
const skills = buildClaudeQueryOptions({ env: {}, toolIntegrationMode: "skills" });
// Bash + Skill are the only Claude Code built-ins exposed so the agent can
// drive the netcatty CLI skill without direct file/search/web/local tools.
assert.deepEqual(skills.tools, ["Bash", "Skill"]);
for (const t of ["Read", "Edit", "Write", "MultiEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Task", "Agent", "REPL", "Workflow"]) {
assert.ok(!skills.tools.includes(t), `expected ${t} absent from skills mode tool whitelist`);
}
// UI-coupled tools still blocked in BOTH modes as defense-in-depth.
for (const t of ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"]) {
assert.ok(skills.disallowedTools.includes(t), `expected ${t} blocked in skills mode`);
}
// MCP mode (and the undefined default) disables all Claude Code built-ins.
assert.deepEqual(buildClaudeQueryOptions({ env: {}, toolIntegrationMode: "mcp" }).tools, []);
assert.deepEqual(buildClaudeQueryOptions({ env: {} }).tools, []);
});
test("classifyClaudeSpawnError detects ENOENT 'native binary not found'", () => {
const r = classifyClaudeSpawnError(new Error("Claude Code native binary not found at /abs/claude"));
assert.equal(r.isSpawnEnoent, true);
});
test("classifyClaudeSpawnError detects code:ENOENT", () => {
const e = new Error("spawn failed"); e.code = "ENOENT"; e.syscall = "spawn";
assert.equal(classifyClaudeSpawnError(e).isSpawnEnoent, true);
});
test("mapClaudeModels maps {value,displayName,description} -> {id,name,description} and drops value-less", () => {
const out = mapClaudeModels([
{ value: "claude-opus-4-6", displayName: "Opus 4.6", description: "Recommended" },
{ value: "claude-sonnet-4-6", displayName: "Sonnet 4.6" },
{ displayName: "no value -> dropped" },
]);
assert.deepEqual(out, [
{
id: "claude-opus-4-6",
name: "Opus 4.6",
description: "Recommended",
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
},
{
id: "claude-sonnet-4-6",
name: "Sonnet 4.6",
description: undefined,
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
},
]);
assert.deepEqual(mapClaudeModels(null), []);
});
test("splitClaudeModelSelection only treats known trailing effort as thinking", () => {
assert.deepEqual(splitClaudeModelSelection("sonnet/high"), { model: "sonnet", effort: "high" });
assert.deepEqual(splitClaudeModelSelection("claude-opus-4-6"), {
model: "claude-opus-4-6",
effort: undefined,
});
assert.deepEqual(splitClaudeModelSelection("org/custom-model"), {
model: "org/custom-model",
effort: undefined,
});
});
test("buildClaudeQueryOptions splits model/effort into model + settings.effort", () => {
const opts = buildClaudeQueryOptions({
cwd: "/tmp",
model: "sonnet/high",
env: {},
settings: { model: "sonnet" },
});
assert.equal(opts.model, "sonnet");
assert.equal(opts.effort, "high");
assert.deepEqual(opts.settings, { model: "sonnet", effort: "high" });
});
test("parseClaudeSettings: path string, inline JSON object, empty, and bad JSON", () => {
assert.equal(parseClaudeSettings("/path/to/settings.json"), "/path/to/settings.json");
assert.deepEqual(parseClaudeSettings('{"model":"sonnet"}'), { model: "sonnet" });
assert.deepEqual(parseClaudeSettings({ model: "opus" }), { model: "opus" });
assert.equal(parseClaudeSettings(""), undefined);
assert.equal(parseClaudeSettings(null), undefined);
assert.equal(parseClaudeSettings("{bad json"), "{bad json"); // invalid JSON -> treated as a path
});
test("buildClaudeQueryOptions wires settings (additive to CLAUDE_CONFIG_DIR) and omits when absent", () => {
const withS = buildClaudeQueryOptions({ env: {}, settings: "/abs/settings.json" });
assert.equal(withS.settings, "/abs/settings.json");
const without = buildClaudeQueryOptions({ env: {} });
assert.equal("settings" in without, false);
});
test("buildClaudeQueryOptions wires resume so context carries across turns; omits when absent", () => {
// Without options.resume the SDK starts a fresh session every turn (amnesia).
assert.equal(buildClaudeQueryOptions({ env: {}, resume: "sess-1" }).resume, "sess-1");
assert.equal("resume" in buildClaudeQueryOptions({ env: {} }), false);
});
test("buildClaudePromptInput sends supported images as native image blocks", async () => {
const input = buildClaudePromptInput("describe this", [
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
{ filename: "bad.svg", mediaType: "image/svg+xml", filePath: "/tmp/bad.svg", base64Data: "def" },
]);
const messages = [];
for await (const message of input) messages.push(message);
assert.deepEqual(messages, [{
type: "user",
message: {
role: "user",
content: [
{ type: "text", text: "describe this" },
{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
],
},
parent_tool_use_id: null,
}]);
});
test("buildClaudePromptInput keeps plain text when there are no supported images", () => {
assert.equal(
buildClaudePromptInput("hello", [{ filename: "note.txt", mediaType: "text/plain", base64Data: "abc" }]),
"hello",
);
});
test("listClaudeModels aborts a hung SDK query and returns it for cleanup", async () => {
const abortController = new AbortController();
let queryAbortSignal;
let returnCount = 0;
let releaseModels;
const pendingModels = new Promise((resolve) => { releaseModels = resolve; });
const queryFn = ({ options }) => {
queryAbortSignal = options.abortController.signal;
return {
supportedModels: () => pendingModels,
async return() {
returnCount += 1;
},
};
};
const modelsPromise = listClaudeModels({
pathToClaudeCodeExecutable: "/bin/claude",
env: {},
queryFn,
abortController,
});
abortController.abort();
const outcome = await Promise.race([
modelsPromise.then(() => "settled"),
new Promise((resolve) => setTimeout(() => resolve("hung"), 20)),
]);
if (outcome === "hung") releaseModels([]);
assert.equal(outcome, "settled");
assert.deepEqual(await modelsPromise, []);
assert.equal(queryAbortSignal.aborted, true);
assert.equal(returnCount, 1);
});