Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
257 lines
10 KiB
JavaScript
257 lines
10 KiB
JavaScript
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);
|
|
});
|