Files
NetMesh/electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1015 lines
34 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
buildOpenCodeConfig,
buildOpenCodePromptParts,
classifyOpenCodeSpawnError,
createOpenCodeProcessEnv,
listOpenCodeModels,
mapOpenCodeModels,
OPENCODE_LIST_SERVER_IDLE_MS,
parseOpenCodeModel,
resetOpenCodeListServerPool,
resolveUsableOpenCodeBinPath,
runOpenCodeTurn,
translateOpenCodeEvent,
withOpenCodeProcessEnv,
} = require("./opencodeDriver.cjs");
function collector() {
const events = [];
const emitter = {
text: (t) => events.push({ k: "text", t }),
reasoning: (d) => events.push({ k: "reasoning", d }),
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 }),
emitDone: () => events.push({ k: "done" }),
emitError: (m) => events.push({ k: "error", m }),
};
return { events, emitter };
}
test("parseOpenCodeModel splits provider/model ids", () => {
assert.deepEqual(parseOpenCodeModel("openai/gpt-5.1"), { providerID: "openai", modelID: "gpt-5.1" });
assert.deepEqual(parseOpenCodeModel("anthropic/claude-sonnet-4-6"), { providerID: "anthropic", modelID: "claude-sonnet-4-6" });
assert.equal(parseOpenCodeModel("gpt-5.1"), undefined);
assert.equal(parseOpenCodeModel(""), undefined);
});
test("buildOpenCodeConfig isolates local tools and injects Netcatty MCP", () => {
const cfg = buildOpenCodeConfig({
model: "openai/gpt-5.1",
injectedMcpServers: [{
name: "netcatty-remote-hosts",
command: "/abs/electron",
args: ["/abs/server.cjs"],
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
}],
});
assert.equal(cfg.model, "openai/gpt-5.1");
assert.equal(cfg.permission.edit, "deny");
assert.equal(cfg.permission.bash, "deny");
assert.equal(cfg.permission.webfetch, "deny");
assert.equal(cfg.permission.question, "deny");
assert.equal(cfg.permission.skill, "allow");
assert.equal(cfg.permission.external_directory["*"], "deny");
// Native OpenCode skill directories stay readable in MCP mode (issue #1939).
assert.equal(cfg.permission.external_directory["*.opencode/skills/**"], "allow");
assert.equal(cfg.permission.read["*.config/opencode/skills/**"], "allow");
assert.equal(cfg.permission.read["*"], undefined);
assert.deepEqual(cfg.mcp["netcatty-remote-hosts"], {
type: "local",
command: ["/abs/electron", "/abs/server.cjs"],
environment: { NETCATTY_MCP_PORT: "1" },
enabled: true,
});
});
test("buildOpenCodeConfig allowlists Netcatty CLI paths in skills mode", () => {
const cfg = buildOpenCodeConfig({
toolIntegrationMode: "skills",
skillsPathAllowlist: [
"/Applications/Netcatty.app/Contents/MacOS/**",
"/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**",
],
});
assert.equal(cfg.permission.bash, "allow");
assert.equal(cfg.permission.skill, "allow");
assert.equal(cfg.permission.list, "deny");
assert.equal(cfg.permission.external_directory["*"], "deny");
assert.equal(cfg.permission.external_directory["/Applications/Netcatty.app/Contents/MacOS/**"], "allow");
assert.equal(
cfg.permission.external_directory["/Users/me/Library/Application Support/netcatty/netcatty-tool-cli/**"],
"allow",
);
// Native OpenCode skill directories stay readable in skills mode too (issue #1939).
assert.equal(cfg.permission.external_directory["*.opencode/skills/**"], "allow");
assert.equal(cfg.permission.read["*.claude/skills/**"], "allow");
});
test("buildOpenCodePromptParts includes supported images as file parts", () => {
assert.deepEqual(buildOpenCodePromptParts("describe", [
{ 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" },
]), [
{ type: "text", text: "describe" },
{ type: "file", mime: "image/png", filename: "shot.png", url: "file:///tmp/shot.png" },
]);
});
test("createOpenCodeProcessEnv creates an opencode shim for arbitrary custom binary paths", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-opencode-test-"));
const fakeBin = path.join(tempRoot, "my-opencode-wrapper");
fs.writeFileSync(fakeBin, "#!/bin/sh\n");
fs.chmodSync(fakeBin, 0o755);
const { env, cleanup } = createOpenCodeProcessEnv(
{ PATH: "/usr/bin" },
fakeBin,
{
platform: "linux",
getTempFilePath: (name) => path.join(tempRoot, name),
},
);
try {
const shimDir = env.PATH.split(path.delimiter)[0];
const shim = path.join(shimDir, "opencode");
assert.equal(env.OPENCODE_BIN, fakeBin);
assert.equal(fs.existsSync(shim), true);
assert.match(fs.readFileSync(shim, "utf8"), new RegExp(`exec "${fakeBin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
} finally {
cleanup();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("createOpenCodeProcessEnv uses a unique shim directory per launch", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-opencode-test-"));
const fakeBin = path.join(tempRoot, "my-opencode-wrapper");
fs.writeFileSync(fakeBin, "#!/bin/sh\n");
fs.chmodSync(fakeBin, 0o755);
const first = createOpenCodeProcessEnv(
{ PATH: "/usr/bin" },
fakeBin,
{
platform: "linux",
getTempFilePath: (name) => path.join(tempRoot, name),
},
);
const second = createOpenCodeProcessEnv(
{ PATH: "/usr/bin" },
fakeBin,
{
platform: "linux",
getTempFilePath: (name) => path.join(tempRoot, name),
},
);
try {
const firstShimDir = first.env.PATH.split(path.delimiter)[0];
const secondShimDir = second.env.PATH.split(path.delimiter)[0];
const shimParent = path.dirname(firstShimDir);
assert.notEqual(firstShimDir, secondShimDir);
assert.equal(path.dirname(secondShimDir), shimParent);
assert.equal(fs.existsSync(path.join(firstShimDir, "opencode")), true);
assert.equal(fs.existsSync(path.join(secondShimDir, "opencode")), true);
first.cleanup();
assert.equal(fs.existsSync(path.join(firstShimDir, "opencode")), false);
assert.equal(fs.existsSync(path.join(secondShimDir, "opencode")), true);
assert.equal(fs.existsSync(shimParent), true);
second.cleanup();
assert.equal(fs.existsSync(shimParent), false);
} finally {
second.cleanup();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("withOpenCodeProcessEnv launches before another call can overwrite process env", async () => {
const previousOpenCodeBin = process.env.OPENCODE_BIN;
const previousPath = process.env.PATH;
const observed = [];
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-opencode-test-"));
const firstBin = path.join(tempRoot, "opencode-one");
const secondBin = path.join(tempRoot, "opencode-two");
fs.writeFileSync(firstBin, "#!/bin/sh\nexit 0\n");
fs.writeFileSync(secondBin, "#!/bin/sh\nexit 0\n");
fs.chmodSync(firstBin, 0o755);
fs.chmodSync(secondBin, 0o755);
try {
const first = withOpenCodeProcessEnv(
{ OPENCODE_BIN: firstBin, PATH: "/tmp/one" },
null,
() => {
observed.push({ bin: process.env.OPENCODE_BIN, path: process.env.PATH });
return new Promise((resolve) => setImmediate(resolve));
},
);
const second = withOpenCodeProcessEnv(
{ OPENCODE_BIN: secondBin, PATH: "/tmp/two" },
null,
() => {
observed.push({ bin: process.env.OPENCODE_BIN, path: process.env.PATH });
return Promise.resolve();
},
);
await Promise.all([first, second]);
assert.deepEqual(observed, [
{ bin: firstBin, path: "/tmp/one" },
{ bin: secondBin, path: "/tmp/two" },
]);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
if (previousOpenCodeBin === undefined) delete process.env.OPENCODE_BIN;
else process.env.OPENCODE_BIN = previousOpenCodeBin;
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
test("withOpenCodeProcessEnv restores process env before async startup settles", async () => {
const previousOpenCodeBin = process.env.OPENCODE_BIN;
const previousPath = process.env.PATH;
let releaseStartup;
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-opencode-test-"));
const fakeBin = path.join(tempRoot, "opencode-starting");
fs.writeFileSync(fakeBin, "#!/bin/sh\nexit 0\n");
fs.chmodSync(fakeBin, 0o755);
try {
const pending = withOpenCodeProcessEnv(
{ OPENCODE_BIN: fakeBin, PATH: "/tmp/opencode-shim" },
null,
() => {
assert.equal(process.env.OPENCODE_BIN, fakeBin);
return new Promise((resolve) => { releaseStartup = resolve; });
},
);
assert.equal(process.env.OPENCODE_BIN, previousOpenCodeBin);
assert.equal(process.env.PATH, previousPath);
releaseStartup();
await pending;
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
if (previousOpenCodeBin === undefined) delete process.env.OPENCODE_BIN;
else process.env.OPENCODE_BIN = previousOpenCodeBin;
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
}
});
test("translateOpenCodeEvent maps text, reasoning, tools, errors, and idle", () => {
const { events, emitter } = collector();
const state = {};
translateOpenCodeEvent(
{ directory: "/tmp", payload: { type: "message.part.updated", properties: { part: { type: "text", id: "p1", text: "hello" }, delta: "he" } } },
emitter,
state,
);
translateOpenCodeEvent(
{ payload: { type: "message.part.updated", properties: { part: { type: "reasoning", id: "r1", text: "thinking" }, delta: "think" } } },
emitter,
state,
);
translateOpenCodeEvent(
{ payload: { type: "message.part.updated", properties: { part: { type: "tool", callID: "tool-1", tool: "netcatty_run", state: { status: "running", input: { command: "uptime" } } } } } },
emitter,
state,
);
translateOpenCodeEvent(
{ payload: { type: "message.part.updated", properties: { part: { type: "tool", callID: "tool-1", tool: "netcatty_run", state: { status: "completed", input: { command: "uptime" }, output: "ok" } } } } },
emitter,
state,
);
translateOpenCodeEvent(
{ payload: { type: "session.error", properties: { error: { data: { message: "bad key" } } } } },
emitter,
state,
);
translateOpenCodeEvent(
{ payload: { type: "session.idle", properties: { sessionID: "sess-1" } } },
emitter,
state,
);
assert.deepEqual(events, [
{ k: "text", t: "he" },
{ k: "reasoning", d: "think" },
{ k: "toolCall", name: "netcatty_run", args: { command: "uptime" }, id: "tool-1" },
{ k: "toolResult", id: "tool-1", out: "ok", name: "netcatty_run" },
{ k: "error", m: "bad key" },
{ k: "status", m: "OpenCode session idle" },
]);
});
test("translateOpenCodeEvent maps OpenCode part delta text and reasoning events", () => {
const { events, emitter } = collector();
const state = {};
assert.equal(
translateOpenCodeEvent(
{ type: "message.part.delta", properties: { sessionID: "sess-1", messageID: "msg-1", partID: "p1", field: "text", delta: "he" } },
emitter,
state,
).content,
true,
);
translateOpenCodeEvent(
{ type: "message.part.updated", properties: { part: { type: "text", id: "p1", sessionID: "sess-1", text: "hello" } } },
emitter,
state,
);
translateOpenCodeEvent(
{ type: "message.part.updated", properties: { part: { type: "reasoning", id: "r1", sessionID: "sess-1", text: "" } } },
emitter,
state,
);
assert.equal(
translateOpenCodeEvent(
{ type: "message.part.delta", properties: { sessionID: "sess-1", messageID: "msg-1", partID: "r1", field: "text", delta: "think" } },
emitter,
state,
).content,
true,
);
translateOpenCodeEvent(
{ type: "message.part.updated", properties: { part: { type: "reasoning", id: "r1", sessionID: "sess-1", text: "thinking" } } },
emitter,
state,
);
assert.deepEqual(events, [
{ k: "text", t: "he" },
{ k: "text", t: "llo" },
{ k: "reasoning", d: "think" },
{ k: "reasoning", d: "ing" },
]);
});
test("translateOpenCodeEvent ignores user message text and delta parts", () => {
const { events, emitter } = collector();
const state = {};
translateOpenCodeEvent(
{ type: "message.updated", properties: { info: { id: "msg-user", role: "user" } } },
emitter,
state,
);
translateOpenCodeEvent(
{ type: "message.part.updated", properties: { part: { type: "text", id: "p-user", messageID: "msg-user", text: "用户问题" } } },
emitter,
state,
);
translateOpenCodeEvent(
{ type: "message.part.delta", properties: { messageID: "msg-user", partID: "p-user", field: "text", delta: "?" } },
emitter,
state,
);
translateOpenCodeEvent(
{ type: "message.updated", properties: { info: { id: "msg-asst", role: "assistant" } } },
emitter,
state,
);
translateOpenCodeEvent(
{ type: "message.part.updated", properties: { part: { type: "text", id: "p-asst", messageID: "msg-asst", text: "reply" }, delta: "reply" } },
emitter,
state,
);
assert.deepEqual(events, [{ k: "text", t: "reply" }]);
});
test("translateOpenCodeEvent also accepts direct OpenCode SDK events", () => {
const { events, emitter } = collector();
translateOpenCodeEvent(
{ type: "message.part.updated", properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "hello" }, delta: "he" } },
emitter,
);
translateOpenCodeEvent(
{ type: "session.idle", properties: { sessionID: "sess-1" } },
emitter,
);
assert.deepEqual(events, [
{ k: "text", t: "he" },
{ k: "status", m: "OpenCode session idle" },
]);
});
test("translateOpenCodeEvent emits a tool call before a result when only completion arrives", () => {
const { events, emitter } = collector();
translateOpenCodeEvent(
{ type: "message.part.updated", properties: { part: { type: "tool", callID: "tool-1", tool: "netcatty_run", state: { status: "completed", input: { command: "whoami" }, output: "me" } } } },
emitter,
);
assert.deepEqual(events, [
{ k: "toolCall", name: "netcatty_run", args: { command: "whoami" }, id: "tool-1" },
{ k: "toolResult", id: "tool-1", out: "me", name: "netcatty_run" },
]);
});
test("translateOpenCodeEvent maps tool status error to toolResult without failing the turn (#2718)", () => {
const { events, emitter } = collector();
const state = {};
const result = translateOpenCodeEvent(
{
type: "message.part.updated",
properties: {
part: {
type: "tool",
callID: "tool-err",
tool: "netcatty-remote-hosts_terminal_execute",
state: {
status: "error",
input: { command: "du /missing" },
error: "Error: Operation failed",
},
},
},
},
emitter,
state,
);
assert.equal(result.error, false);
assert.equal(result.content, true);
assert.deepEqual(events, [
{
k: "toolCall",
name: "netcatty-remote-hosts_terminal_execute",
args: { command: "du /missing" },
id: "tool-err",
},
{
k: "toolResult",
id: "tool-err",
out: "Error: Operation failed",
name: "netcatty-remote-hosts_terminal_execute",
},
]);
assert.equal(events.some((event) => event.k === "error"), false);
});
test("runOpenCodeTurn continues after a tool error part and reaches session.idle (#2718)", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
const stream = {
async *[Symbol.asyncIterator]() {
yield {
type: "message.part.updated",
properties: {
part: {
type: "tool",
sessionID: "sess-1",
callID: "t1",
tool: "terminal_execute",
state: { status: "error", input: { command: "false" }, error: "Error: Operation failed" },
},
},
};
yield {
type: "message.part.updated",
properties: {
part: {
type: "text",
sessionID: "sess-1",
id: "p1",
text: "retrying",
},
delta: "retrying",
},
};
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
},
};
await runOpenCodeTurn({
prompt: "run false then continue",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() {} } }),
});
assert.ok(events.some((event) => event.k === "toolResult" && event.out === "Error: Operation failed"));
assert.ok(events.some((event) => event.k === "text" && event.t === "retrying"));
assert.ok(events.some((event) => event.k === "done"));
assert.equal(events.some((event) => event.k === "error"), false);
});
test("mapOpenCodeModels flattens providers", () => {
assert.deepEqual(mapOpenCodeModels({
providers: [
{ id: "openai", name: "OpenAI", models: { "gpt-5.1": { name: "GPT-5.1" } } },
{ id: "anthropic", models: { "claude-sonnet": {} } },
],
}), [
{ id: "openai/gpt-5.1", name: "OpenAI GPT-5.1" },
{ id: "anthropic/claude-sonnet", name: "anthropic claude-sonnet" },
]);
assert.deepEqual(mapOpenCodeModels(null), []);
});
test("runOpenCodeTurn ignores events from other OpenCode sessions", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
const stream = {
async *[Symbol.asyncIterator]() {
yield { type: "message.part.updated", properties: { part: { type: "text", sessionID: "other", id: "p0", text: "wrong" }, delta: "wrong" } };
yield { type: "message.part.updated", properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "right" }, delta: "right" } };
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
},
};
await runOpenCodeTurn({
prompt: "hello",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() {} } }),
});
assert.deepEqual(events.filter((event) => event.k === "text"), [
{ k: "text", t: "right" },
]);
});
test("runOpenCodeTurn creates a session, streams event deltas, and returns session id", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
let eventController;
const stream = {
async *[Symbol.asyncIterator]() {
await new Promise((resolve) => { eventController = resolve; });
yield { payload: { type: "message.part.updated", properties: { part: { type: "text", id: "p1", text: "hi" }, delta: "hi" } } };
yield { payload: { type: "session.idle", properties: { sessionID: "sess-1" } } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async (args) => {
assert.equal(args.query.directory, "/repo");
return { data: { id: "sess-1" } };
},
promptAsync: async (args) => {
assert.equal(args.path.id, "sess-1");
assert.deepEqual(args.body.model, { providerID: "openai", modelID: "gpt-5.1" });
eventController();
return { data: true };
},
},
};
const result = await runOpenCodeTurn({
prompt: "hello",
cwd: "/repo",
model: "openai/gpt-5.1",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() {} } }),
});
assert.deepEqual(result, { sessionId: "sess-1" });
assert.deepEqual(events, [
{ k: "sessionId", s: "sess-1" },
{ k: "text", t: "hi" },
{ k: "status", m: "OpenCode session idle" },
{ k: "done" },
]);
});
test("runOpenCodeTurn sends Netcatty context via body.system instead of user parts", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
const stream = {
async *[Symbol.asyncIterator]() {
yield { payload: { type: "message.part.updated", properties: { part: { type: "text", id: "p1", text: "ok" }, delta: "ok" } } };
yield { payload: { type: "session.idle", properties: { sessionID: "sess-1" } } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async (args) => {
assert.equal(args.body.system, "[Context: You are inside Netcatty.]");
assert.deepEqual(args.body.parts, [{ type: "text", text: "看看这个机器的配置" }]);
return { data: true };
},
},
};
await runOpenCodeTurn({
prompt: "看看这个机器的配置",
systemPrompt: "[Context: You are inside Netcatty.]",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() {} } }),
});
assert.deepEqual(events.filter((event) => event.k === "text"), [{ k: "text", t: "ok" }]);
});
test("runOpenCodeTurn treats OpenCode part delta events as streamed content", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
const stream = {
async *[Symbol.asyncIterator]() {
yield { type: "message.part.delta", properties: { sessionID: "sess-1", messageID: "msg-1", partID: "p1", field: "text", delta: "hi" } };
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
},
};
const result = await runOpenCodeTurn({
prompt: "hello",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() {} } }),
});
assert.deepEqual(result, { sessionId: "sess-1" });
assert.deepEqual(events, [
{ k: "sessionId", s: "sess-1" },
{ k: "text", t: "hi" },
{ k: "status", m: "OpenCode session idle" },
{ k: "done" },
]);
});
test("runOpenCodeTurn surfaces promptAsync error results without waiting for events", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
let closeCount = 0;
let abortCount = 0;
let iteratorReturnCount = 0;
const stream = {
[Symbol.asyncIterator]() {
return {
next: async () => new Promise(() => {}),
return: async () => {
iteratorReturnCount += 1;
return { done: true };
},
};
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ error: { data: { message: "bad model" } } }),
abort: async () => { abortCount += 1; },
},
};
const result = await Promise.race([
runOpenCodeTurn({
prompt: "hello",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() { closeCount += 1; } } }),
}),
new Promise((resolve) => setTimeout(() => resolve("timed-out"), 50)),
]);
assert.notEqual(result, "timed-out");
assert.deepEqual(result, { sessionId: "sess-1" });
assert.equal(events.some((event) => event.k === "error" && event.m === "bad model"), true);
assert.equal(events.some((event) => event.k === "done"), false);
assert.equal(abortCount, 1);
assert.equal(closeCount >= 1, true);
assert.equal(iteratorReturnCount, 1);
});
test("runOpenCodeTurn returns promptly when aborted while the event stream is quiet", async () => {
const { events, emitter } = collector();
const abortController = new AbortController();
let closeCount = 0;
let abortCount = 0;
const stream = {
async *[Symbol.asyncIterator]() {
await new Promise(() => {});
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
abort: async () => { abortCount += 1; },
},
};
const running = runOpenCodeTurn({
prompt: "hello",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() { closeCount += 1; } } }),
});
await new Promise((resolve) => setImmediate(resolve));
abortController.abort();
const result = await Promise.race([
running,
new Promise((resolve) => setTimeout(() => resolve("timed-out"), 50)),
]);
assert.notEqual(result, "timed-out");
assert.deepEqual(result, { sessionId: "sess-1" });
assert.equal(abortCount, 1);
assert.equal(closeCount >= 1, true);
assert.equal(events.some((event) => event.k === "done"), false);
});
test("runOpenCodeTurn passes an explicit non-default port to the OpenCode SDK factory", async () => {
const { emitter } = collector();
const abortController = new AbortController();
let capturedPort;
const stream = {
async *[Symbol.asyncIterator]() {
yield { type: "message.part.updated", properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "ok" }, delta: "ok" } };
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => ({ data: true }),
},
};
await runOpenCodeTurn({
prompt: "hello",
emitter,
abortController,
openCodeFactory: async (options) => {
capturedPort = options.port;
return { client, server: { close() {} } };
},
});
assert.equal(typeof capturedPort, "number");
assert.notEqual(capturedPort, 4096);
});
test("runOpenCodeTurn waits for the OpenCode event stream before prompting", async () => {
const { emitter } = collector();
const abortController = new AbortController();
let releaseServerConnected;
let serverConnectedSeen = false;
const stream = {
async *[Symbol.asyncIterator]() {
await new Promise((resolve) => { releaseServerConnected = resolve; });
yield { payload: { type: "server.connected", properties: {} } };
serverConnectedSeen = true;
yield { payload: { type: "message.part.updated", properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "ok" }, delta: "ok" } } };
yield { payload: { type: "session.idle", properties: { sessionID: "sess-1" } } };
},
};
const client = {
global: { event: async () => ({ stream }) },
session: {
create: async () => ({ data: { id: "sess-1" } }),
promptAsync: async () => {
assert.equal(serverConnectedSeen, true);
return { data: true };
},
},
};
const running = runOpenCodeTurn({
prompt: "hello",
model: "openai/gpt-5.1",
emitter,
abortController,
openCodeFactory: async () => ({ client, server: { close() {} } }),
});
await new Promise((resolve) => setImmediate(resolve));
releaseServerConnected();
const result = await running;
assert.deepEqual(result, { sessionId: "sess-1" });
});
test("resolveUsableOpenCodeBinPath ignores missing candidates", () => {
assert.equal(resolveUsableOpenCodeBinPath("/definitely/missing/opencode", { OPENCODE_BIN: "/also/missing" }), undefined);
});
test("createOpenCodeProcessEnv drops stale OPENCODE_BIN when no usable binary exists", () => {
const { env } = createOpenCodeProcessEnv(
{ OPENCODE_BIN: "/stale/opencode", PATH: "/bin" },
"/missing/opencode",
);
assert.equal(env.OPENCODE_BIN, undefined);
assert.equal(env.PATH, "/bin");
});
test("createOpenCodeProcessEnv prefers an existing opencode binary", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-opencode-test-"));
const fakeBin = path.join(tempRoot, "my-opencode-wrapper");
fs.writeFileSync(fakeBin, "#!/bin/sh\nexit 0\n");
fs.chmodSync(fakeBin, 0o755);
const { env, cleanup } = createOpenCodeProcessEnv(
{ OPENCODE_BIN: "/stale/opencode", PATH: "/bin" },
fakeBin,
);
try {
assert.equal(env.OPENCODE_BIN, fakeBin);
assert.match(env.PATH, /opencode-sdk-shim/);
} finally {
cleanup();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
test("listOpenCodeModels passes an explicit non-default port to the OpenCode SDK factory", async () => {
resetOpenCodeListServerPool();
let capturedPort;
const models = await listOpenCodeModels({
binPath: "/tmp/opencode-port-test",
openCodeFactory: async (options) => {
capturedPort = options.port;
return {
client: { config: { providers: async () => ({ providers: [], default: { openai: "gpt-5.1" } }) } },
server: { close() {} },
};
},
});
assert.deepEqual(models, { currentModelId: "openai/gpt-5.1", models: [] });
assert.equal(typeof capturedPort, "number");
assert.notEqual(capturedPort, 4096);
resetOpenCodeListServerPool();
});
test("listOpenCodeModels aborts a hanging providers() call and tears down the pooled server", async () => {
resetOpenCodeListServerPool();
const abortController = new AbortController();
let closeCount = 0;
let sawAbortSignal = false;
let releaseProviders;
const running = listOpenCodeModels({
binPath: "/tmp/opencode-abort-test",
signal: abortController.signal,
openCodeFactory: async (options) => {
sawAbortSignal = Boolean(options.signal);
return {
client: {
config: {
providers: () => new Promise((resolve) => {
releaseProviders = resolve;
}),
},
},
server: {
close() { closeCount += 1; },
},
};
},
});
// Let the factory settle, then abort while providers() is pending.
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
abortController.abort(new Error("list-models timed out"));
const models = await running;
assert.deepEqual(models, { currentModelId: null, models: [] });
assert.equal(sawAbortSignal, true);
// Release schedules a short idle close; force dispose for a deterministic assert.
resetOpenCodeListServerPool();
assert.equal(closeCount, 1);
// Unblock any leftover resolver so the test process can exit cleanly.
releaseProviders?.({ providers: [], default: {} });
});
test("listOpenCodeModels reuses one pooled OpenCode server for concurrent loads", async () => {
resetOpenCodeListServerPool();
let createCount = 0;
let closeCount = 0;
let releaseProviders;
const providersGate = new Promise((resolve) => { releaseProviders = resolve; });
const openCodeFactory = async () => {
createCount += 1;
return {
client: {
config: {
providers: async () => providersGate,
},
},
server: {
close() { closeCount += 1; },
},
};
};
const first = listOpenCodeModels({
binPath: "/tmp/opencode-pool-test",
openCodeFactory,
});
const second = listOpenCodeModels({
binPath: "/tmp/opencode-pool-test",
openCodeFactory,
});
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
assert.equal(createCount, 1);
releaseProviders({
providers: [],
default: { openai: "gpt-5.1" },
});
const [a, b] = await Promise.all([first, second]);
assert.deepEqual(a, { currentModelId: "openai/gpt-5.1", models: [] });
assert.deepEqual(b, { currentModelId: "openai/gpt-5.1", models: [] });
assert.equal(createCount, 1);
resetOpenCodeListServerPool();
assert.equal(closeCount, 1);
assert.ok(OPENCODE_LIST_SERVER_IDLE_MS >= 0);
});
test("listOpenCodeModels returns empty immediately when already aborted", async () => {
resetOpenCodeListServerPool();
let createCount = 0;
const abortController = new AbortController();
abortController.abort(new Error("pre-aborted"));
const models = await listOpenCodeModels({
binPath: "/tmp/opencode-preabort-test",
signal: abortController.signal,
openCodeFactory: async () => {
createCount += 1;
return {
client: { config: { providers: async () => ({ providers: [] }) } },
server: { close() {} },
};
},
});
assert.deepEqual(models, { currentModelId: null, models: [] });
assert.equal(createCount, 0);
resetOpenCodeListServerPool();
});
test("listOpenCodeModels does not share a pooled server across different catalog env", async () => {
resetOpenCodeListServerPool();
let createCount = 0;
const openCodeFactory = async () => {
createCount += 1;
const id = createCount;
return {
client: {
config: {
providers: async () => ({
providers: [],
default: { openai: `profile-${id}` },
}),
},
},
server: { close() {} },
};
};
const first = await listOpenCodeModels({
binPath: "/tmp/opencode-env-pool",
env: { HOME: "/Users/a", OPENCODE_CONFIG_DIR: "/a/config" },
openCodeFactory,
});
const second = await listOpenCodeModels({
binPath: "/tmp/opencode-env-pool",
env: { HOME: "/Users/b", OPENCODE_CONFIG_DIR: "/b/config" },
openCodeFactory,
});
assert.equal(createCount, 2);
assert.deepEqual(first, { currentModelId: "openai/profile-1", models: [] });
assert.deepEqual(second, { currentModelId: "openai/profile-2", models: [] });
resetOpenCodeListServerPool();
});
test("classifyOpenCodeSpawnError recognizes missing opencode CLI", () => {
assert.equal(classifyOpenCodeSpawnError(Object.assign(new Error("spawn opencode ENOENT"), { code: "ENOENT" })).isSpawnEnoent, true);
assert.equal(classifyOpenCodeSpawnError(new Error("other")).isSpawnEnoent, false);
});