[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
256
electron/bridges/aiBridge/sdk/claudeDriver.test.cjs
Normal file
256
electron/bridges/aiBridge/sdk/claudeDriver.test.cjs
Normal file
@@ -0,0 +1,256 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user