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
1015 lines
34 KiB
JavaScript
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);
|
|
});
|