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

1653 lines
55 KiB
JavaScript
Raw Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const http = require("node:http");
const { prepareCommandForSpawn } = require("./ai/shellUtils.cjs");
const {
isCodexAuthError: realIsCodexAuthError,
normalizeCodexIntegrationState: realNormalizeCodexIntegrationState,
} = require("./ai/codexHelpers.cjs");
function createIpcMainStub() {
const handlers = new Map();
return {
handlers,
handle(channel, handler) {
handlers.set(channel, handler);
},
};
}
function loadBridgeWithMocks(options = {}) {
const mocks = {
"./mcpServerBridge.cjs": {
init() {},
setMainWindowGetter() {},
setVaultAgentInvoker() {},
getOrCreateHost: async () => 4010,
getScopedSessionIds: () => [],
buildMcpServerConfig: () => ({ name: "netcatty-remote-hosts", type: "http", url: "http://127.0.0.1:4010" }),
getPermissionMode: () =>
typeof options.getPermissionMode === "function"
? options.getPermissionMode()
: "default",
getMaxIterations: () => 20,
setCommandTimeout: (...args) => {
if (typeof options.setCommandTimeout === "function") options.setCommandTimeout(...args);
},
updateAttachmentMetadata: (...args) => {
if (typeof options.updateAttachmentMetadata === "function") options.updateAttachmentMetadata(...args);
},
setChatSessionCancelled() {},
cancelPtyExecsForSession() {},
clearPendingApprovals() {},
cleanupScopedMetadata: async () => {},
cleanup() {},
},
"../cli/discoveryPath.cjs": {
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
TOOL_CLI_DISCOVERY_ENV_VAR: "NETCATTY_TOOL_CLI_DISCOVERY_FILE",
},
"./ai/userSkills.cjs": {
scanUserSkills: async () => ({ readyCount: 0, warningCount: 0, skills: [], warnings: [] }),
buildUserSkillsContext: async () => ({ context: "", selectedSkills: [] }),
toPublicUserSkillsStatus: (value) => value,
},
"./ai/shellUtils.cjs": {
stripAnsi: (value) => value,
normalizeCliPathForPlatform: (...args) =>
typeof options.normalizeCliPathForPlatform === "function"
? options.normalizeCliPathForPlatform(...args)
: args[0],
shouldUseShellForCommand: () => false,
prepareCommandForSpawn: (...args) =>
typeof options.prepareCommandForSpawn === "function"
? options.prepareCommandForSpawn(...args)
: prepareCommandForSpawn(...args),
normalizeClaudeCodeExecutableEnvForSdk: (env) =>
typeof options.normalizeClaudeCodeExecutableEnvForSdk === "function"
? options.normalizeClaudeCodeExecutableEnvForSdk(env)
: env,
isPlausibleCliVersionOutput: (value) =>
typeof options.isPlausibleCliVersionOutput === "function"
? options.isPlausibleCliVersionOutput(value)
: true,
resolveCliFromPath: (...args) =>
typeof options.resolveCliFromPath === "function"
? options.resolveCliFromPath(...args)
: null,
resolveCliFromPathAsync: async (...args) =>
typeof options.resolveCliFromPathAsync === "function"
? options.resolveCliFromPathAsync(...args)
: typeof options.resolveCliFromPath === "function"
? options.resolveCliFromPath(...args)
: null,
resolveSdkBinPath: (...args) =>
typeof options.resolveSdkBinPath === "function"
? options.resolveSdkBinPath(...args)
: null,
resolveSdkBinPathAsync: async (...args) =>
typeof options.resolveSdkBinPathAsync === "function"
? options.resolveSdkBinPathAsync(...args)
: typeof options.resolveSdkBinPath === "function"
? options.resolveSdkBinPath(...args)
: null,
getShellEnv: async () => (
typeof options.shellEnv === "function"
? options.shellEnv()
: options.shellEnv || {}
),
invalidateShellEnvCache: () => {
if (typeof options.invalidateShellEnvCache === "function") options.invalidateShellEnvCache();
},
toUnpackedAsarPath: (value) => value,
},
"./ai/codexHelpers.cjs": {
codexLoginSessions: new Map(),
appendCodexLoginOutput() {},
toCodexLoginSessionResponse: (session) => ({ sessionId: session.id, codexPath: session.codexPath }),
getActiveCodexLoginSession: () =>
typeof options.getActiveCodexLoginSession === "function"
? options.getActiveCodexLoginSession()
: null,
normalizeCodexIntegrationState: (...args) =>
typeof options.normalizeCodexIntegrationState === "function"
? options.normalizeCodexIntegrationState(...args)
: realNormalizeCodexIntegrationState(...args),
appendCodexChatGptValidationFailure: (rawOutput, validationError) =>
`${rawOutput}\n\nChatGPT auth validation failed:\n${validationError}`.trim(),
readCodexCustomProviderConfig: () => null,
getCodexCustomConfigPreflightError: () => null,
extractCodexError: (err) => ({ message: err?.message || String(err) }),
isCodexAuthError: (...args) =>
typeof options.isCodexAuthError === "function"
? options.isCodexAuthError(...args)
: realIsCodexAuthError(...args),
getCodexAuthFingerprint: (...args) =>
typeof options.getCodexAuthFingerprint === "function"
? options.getCodexAuthFingerprint(...args)
: "auth-fingerprint",
getCodexMcpFingerprint: () => "mcp-fingerprint",
invalidateCodexValidationCache() {},
getCodexValidationCache: () => null,
setCodexValidationCache() {},
},
"./aiBridge/agentAuthProbes.cjs": {
probeClaudeAuth: (...args) =>
typeof options.probeClaudeAuth === "function" ? options.probeClaudeAuth(...args) : { authenticated: false, authSource: null },
probeCopilotAuth: (...args) =>
typeof options.probeCopilotAuth === "function" ? options.probeCopilotAuth(...args) : { authenticated: false, authSource: null },
probeCodexAuth: (...args) =>
typeof options.probeCodexAuth === "function" ? options.probeCodexAuth(...args) : { authenticated: false, authSource: null },
probeCodebuddyAuth: (...args) =>
typeof options.probeCodebuddyAuth === "function"
? options.probeCodebuddyAuth(...args)
: { authenticated: false, authSource: null },
probeCursorCliAuth: (...args) =>
typeof options.probeCursorCliAuth === "function"
? options.probeCursorCliAuth(...args)
: { authenticated: false, authSource: null, email: null, binPath: null },
probeGrokAuth: (...args) =>
typeof options.probeGrokAuth === "function"
? options.probeGrokAuth(...args)
: { authenticated: false, authSource: null },
},
"./ai/ptyExec.cjs": {
execViaPty: async () => {
throw new Error("execViaPty should not be called in this test");
},
},
"./ipcUtils.cjs": {
safeSend: (...args) => {
if (typeof options.safeSend === "function") options.safeSend(...args);
},
},
"./windowManager.cjs": {
getMainWindow() {
return {
isDestroyed: () => false,
webContents: { id: 1 },
};
},
getSettingsWindow() {
return null;
},
},
};
if (typeof options.registerSdkStreamHandlers === "function") {
mocks["./aiBridge/sdk/sdkStreamHandlers.cjs"] = {
registerSdkStreamHandlers: options.registerSdkStreamHandlers,
};
}
const bridgePath = require.resolve("./aiBridge.cjs");
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (Object.prototype.hasOwnProperty.call(mocks, request)) {
return mocks[request];
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
try {
const bridge = require("./aiBridge.cjs");
return {
bridge,
restore() {
try {
bridge.cleanup();
} finally {
delete require.cache[bridgePath];
Module._load = originalLoad;
}
},
};
} catch (error) {
delete require.cache[bridgePath];
Module._load = originalLoad;
throw error;
}
}
test("cleanup closes persistent CodeBuddy sessions", () => {
let codebuddyCloseCalls = 0;
let codexCloseCalls = 0;
let sdkAbortCalls = 0;
const { bridge, restore } = loadBridgeWithMocks({
registerSdkStreamHandlers: (context) => {
context.sdkActiveStreams = new Map([
["request-1", { abort: () => { sdkAbortCalls += 1; } }],
]);
context.codexAppServerRuntime = {
close: () => { codexCloseCalls += 1; },
};
context.codebuddySessionManager = {
closeAll: () => { codebuddyCloseCalls += 1; },
};
},
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
bridge.cleanup();
assert.equal(sdkAbortCalls, 1);
assert.equal(codexCloseCalls, 1);
assert.equal(codebuddyCloseCalls, 1);
} finally {
restore();
}
});
test("non-2xx streaming responses are bounded and actively terminated", async () => {
let requestClosed = false;
let resolveRequestClosed;
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
const server = http.createServer((_request, response) => {
response.writeHead(500, { "content-type": "text/plain" });
const interval = setInterval(() => response.write("abcdefgh"), 2);
response.on("close", () => {
clearInterval(interval);
requestClosed = true;
resolveRequestClosed();
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const { bridge, restore } = loadBridgeWithMocks();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
});
try {
await assert.rejects(
() => bridge._streamRequestForTests(
`http://127.0.0.1:${address.port}/streaming-error`,
{
method: "GET",
maxErrorBodyBytes: 32,
totalTimeoutMs: 1_000,
},
{ sender: { id: 1 } },
"streaming-error",
false,
),
/error response exceeded/i,
);
await Promise.race([
requestClosedPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
]);
assert.equal(requestClosed, true);
assert.equal(bridge._getActiveStreamCountForTests(), 0);
} finally {
restore();
await new Promise((resolve) => server.close(resolve));
}
});
test("AI stream default total timeout is long enough for extended reasoning", () => {
const { bridge, restore } = loadBridgeWithMocks();
try {
assert.equal(bridge.DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS, 120_000);
assert.equal(bridge.DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS, 30 * 60 * 1000);
assert.ok(bridge.DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS > bridge.DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS);
} finally {
restore();
}
});
test("AI stream total timeout never undercuts the configured idle timeout", () => {
const { bridge, restore } = loadBridgeWithMocks();
try {
const timeouts = bridge._resolveAIStreamTimeoutsForTests({
idleTimeoutMs: 60 * 60 * 1000,
totalTimeoutMs: 30 * 60 * 1000,
});
assert.equal(timeouts.idleTimeoutMs, 60 * 60 * 1000);
assert.ok(timeouts.totalTimeoutMs > timeouts.idleTimeoutMs);
} finally {
restore();
}
});
test("streaming requests enforce a total deadline even while bytes keep arriving", async () => {
let requestClosed = false;
let resolveRequestClosed;
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
const server = http.createServer((_request, response) => {
response.writeHead(500, { "content-type": "text/plain" });
const interval = setInterval(() => response.write("x"), 2);
response.on("close", () => {
clearInterval(interval);
requestClosed = true;
resolveRequestClosed();
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const { bridge, restore } = loadBridgeWithMocks();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
});
try {
await assert.rejects(
() => bridge._streamRequestForTests(
`http://127.0.0.1:${address.port}/never-ending`,
{
method: "GET",
maxErrorBodyBytes: 1024 * 1024,
totalTimeoutMs: 30,
},
{ sender: { id: 1 } },
"never-ending",
false,
),
/total deadline exceeded/i,
);
await Promise.race([
requestClosedPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
]);
assert.equal(requestClosed, true);
assert.equal(bridge._getActiveStreamCountForTests(), 0);
} finally {
restore();
await new Promise((resolve) => server.close(resolve));
}
});
test("streaming requests do not abort active thinking output at the idle deadline", async () => {
let requestClosed = false;
let resolveRequestClosed;
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
const server = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/event-stream" });
let n = 0;
const interval = setInterval(() => {
n += 1;
response.write(`data:{"choices":[{"delta":{"reasoning_content":"${n}"}}]}\n`);
if (n >= 6) {
clearInterval(interval);
response.end("data:[DONE]\n\n");
}
}, 25);
response.on("close", () => {
clearInterval(interval);
requestClosed = true;
resolveRequestClosed();
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const sentEvents = [];
const { bridge, restore } = loadBridgeWithMocks({
safeSend: (_sender, channel, payload) => {
sentEvents.push({ channel, payload });
},
});
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
});
try {
const result = await bridge._streamRequestForTests(
`http://127.0.0.1:${address.port}/thinking`,
{
method: "GET",
idleTimeoutMs: 80,
totalTimeoutMs: 2_000,
},
{ sender: { id: 1 } },
"thinking",
false,
);
assert.equal(result.statusCode, 200);
await Promise.race([
new Promise((resolve, reject) => {
const started = Date.now();
const poll = () => {
if (sentEvents.some(({ channel }) => channel === "netcatty:ai:stream:end")) {
resolve();
return;
}
const errorEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:error");
if (errorEvent) {
reject(new Error(errorEvent.payload.error));
return;
}
if (Date.now() - started > 1_500) {
reject(new Error("thinking stream never finished"));
return;
}
setTimeout(poll, 10);
};
poll();
}),
]);
await Promise.race([
requestClosedPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
]);
assert.equal(requestClosed, true);
assert.equal(bridge._getActiveStreamCountForTests(), 0);
assert.equal(
sentEvents.some(({ channel }) => channel === "netcatty:ai:stream:error"),
false,
);
} finally {
restore();
await new Promise((resolve) => server.close(resolve));
}
});
test("streaming requests abort after the idle deadline when no more bytes arrive", async () => {
let requestClosed = false;
let resolveRequestClosed;
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
const server = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/event-stream" });
response.write('data:{"choices":[{"delta":{"reasoning_content":"start"}}]}\n');
response.on("close", () => {
requestClosed = true;
resolveRequestClosed();
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const sentEvents = [];
const { bridge, restore } = loadBridgeWithMocks({
safeSend: (_sender, channel, payload) => {
sentEvents.push({ channel, payload });
},
});
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
});
try {
const result = await bridge._streamRequestForTests(
`http://127.0.0.1:${address.port}/stalled`,
{
method: "GET",
idleTimeoutMs: 40,
totalTimeoutMs: 2_000,
},
{ sender: { id: 1 } },
"stalled",
false,
);
assert.equal(result.statusCode, 200);
await Promise.race([
new Promise((resolve, reject) => {
const started = Date.now();
const poll = () => {
const errorEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:error");
if (errorEvent) {
resolve(errorEvent.payload.error);
return;
}
if (Date.now() - started > 1_000) {
reject(new Error("stalled stream was not aborted"));
return;
}
setTimeout(poll, 10);
};
poll();
}).then((error) => {
assert.match(error, /idle deadline exceeded/i);
}),
]);
await Promise.race([
requestClosedPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
]);
assert.equal(requestClosed, true);
assert.equal(bridge._getActiveStreamCountForTests(), 0);
} finally {
restore();
await new Promise((resolve) => server.close(resolve));
}
});
test("streaming chat handler forwards the configured idle deadline", { timeout: 5_000 }, async (t) => {
const sentEvents = [];
const server = http.createServer((request, response) => {
request.resume();
request.on("end", () => {
response.writeHead(200, { "content-type": "text/event-stream" });
response.write('data:{"choices":[{"delta":{"content":"start"}}]}\n\n');
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
const { bridge, restore } = loadBridgeWithMocks({
safeSend: (_sender, channel, payload) => sentEvents.push({ channel, payload }),
});
t.after(restore);
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
});
bridge.registerHandlers(ipcMain);
const address = server.address();
assert.ok(address && typeof address === "object");
const baseURL = `http://127.0.0.1:${address.port}`;
const sender = { id: 1 };
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
{ sender },
{ providers: [{ id: "custom-idle", baseURL }] },
);
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
{ sender },
{
requestId: "custom-idle-request",
url: `${baseURL}/v1/chat/completions`,
headers: { "content-type": "application/json" },
body: '{"stream":true}',
providerId: "custom-idle",
idleTimeoutMs: 40,
},
);
assert.equal(result.ok, true);
const error = await new Promise((resolve, reject) => {
const started = Date.now();
const poll = () => {
const event = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:error");
if (event) {
resolve(event.payload.error);
return;
}
if (Date.now() - started > 1_000) {
reject(new Error("configured stream idle deadline was not forwarded"));
return;
}
setTimeout(poll, 10);
};
poll();
});
assert.match(error, /idle deadline exceeded|request timeout/i);
});
test("mcp attachment update handler forwards current chat attachments", async () => {
const calls = [];
const { bridge, restore } = loadBridgeWithMocks({
updateAttachmentMetadata: (attachments, chatSessionId) => calls.push({ attachments, chatSessionId }),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const updateAttachments = ipcMain.handlers.get("netcatty:ai:mcp:update-attachments");
assert.equal(typeof updateAttachments, "function");
const attachments = [{
filename: "hosts_export_2026-06-25.csv",
mediaType: "text/csv",
base64Data: Buffer.from("label,hostname\nprod,prod.example.com\n").toString("base64"),
filePath: "/tmp/hosts_export_2026-06-25.csv",
}];
const result = await updateAttachments({ sender: { id: 1 } }, {
attachments,
chatSessionId: "chat-1",
});
assert.deepEqual(result, { ok: true });
assert.deepEqual(calls, [{ attachments, chatSessionId: "chat-1" }]);
} finally {
restore();
}
});
test("command timeout handler accepts one-day timeout values", async () => {
const calls = [];
const { bridge, restore } = loadBridgeWithMocks({
setCommandTimeout: (value) => calls.push(value),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const setCommandTimeout = ipcMain.handlers.get("netcatty:ai:mcp:set-command-timeout");
assert.equal(typeof setCommandTimeout, "function");
const result = await setCommandTimeout({ sender: { id: 1 } }, { timeout: 86_400 });
assert.deepEqual(result, { ok: true });
assert.deepEqual(calls, [86_400]);
} finally {
restore();
}
});
test("streaming AI responses preserve UTF-8 characters split across network chunks", { timeout: 5_000 }, async (t) => {
const sentEvents = [];
let handleStreamEvent = () => {};
const { bridge, restore } = loadBridgeWithMocks({
safeSend: (_sender, channel, payload) => {
sentEvents.push({ channel, payload });
handleStreamEvent(channel, payload);
},
});
const ipcMain = createIpcMainStub();
const server = require("node:http").createServer((req, res) => {
req.resume();
req.on("end", () => {
res.writeHead(200, { "content-type": "text/event-stream" });
const message = Buffer.from('data: {"choices":[{"delta":{"content":"环境正常"}}]}\n\n');
const splitAt = message.indexOf(Buffer.from("环")) + 1;
res.write(message.subarray(0, splitAt));
setImmediate(() => res.end(message.subarray(splitAt)));
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const address = server.address();
assert.ok(address && typeof address === "object");
const baseURL = `http://127.0.0.1:${address.port}`;
const sender = { id: 1 };
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
{ sender },
{ providers: [{ id: "split-utf8", baseURL }] },
);
const streamFinished = new Promise((resolve, reject) => {
handleStreamEvent = (channel, payload) => {
if (channel === "netcatty:ai:stream:end") resolve();
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
};
});
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
{ sender },
{
requestId: "split-utf8-request",
url: `${baseURL}/v1/chat/completions`,
headers: { "content-type": "application/json" },
body: '{"stream":true}',
providerId: "split-utf8",
},
);
await streamFinished;
assert.equal(result.ok, true);
const dataEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:data");
assert.equal(dataEvent?.payload.data, '{"choices":[{"delta":{"content":"环境正常"}}]}');
} finally {
restore();
}
});
test("streaming AI responses accept SSE data: lines without a space after the colon", { timeout: 5_000 }, async (t) => {
const sentEvents = [];
let handleStreamEvent = () => {};
const { bridge, restore } = loadBridgeWithMocks({
safeSend: (_sender, channel, payload) => {
sentEvents.push({ channel, payload });
handleStreamEvent(channel, payload);
},
});
const ipcMain = createIpcMainStub();
const server = require("node:http").createServer((req, res) => {
req.resume();
req.on("end", () => {
res.writeHead(200, { "content-type": "text/event-stream" });
// Older AxonHub 0.9 emits `data:{json}` with no space (issue #3020).
res.end('data:{"choices":[{"delta":{"content":"hello"}}]}\ndata:[DONE]\n\n');
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const address = server.address();
assert.ok(address && typeof address === "object");
const baseURL = `http://127.0.0.1:${address.port}`;
const sender = { id: 1 };
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
{ sender },
{ providers: [{ id: "axonhub-legacy", baseURL }] },
);
const streamFinished = new Promise((resolve, reject) => {
handleStreamEvent = (channel, payload) => {
if (channel === "netcatty:ai:stream:end") resolve();
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
};
});
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
{ sender },
{
requestId: "axonhub-legacy-request",
url: `${baseURL}/v1/chat/completions`,
headers: { "content-type": "application/json" },
body: '{"stream":true}',
providerId: "axonhub-legacy",
},
);
await streamFinished;
assert.equal(result.ok, true);
const dataEvents = sentEvents
.filter(({ channel }) => channel === "netcatty:ai:stream:data")
.map(({ payload }) => payload.data);
assert.deepEqual(dataEvents, [
'{"choices":[{"delta":{"content":"hello"}}]}',
"[DONE]",
]);
} finally {
restore();
}
});
test("streaming AI responses flush a trailing data: line that has no space and no newline", { timeout: 5_000 }, async (t) => {
const sentEvents = [];
let handleStreamEvent = () => {};
const { bridge, restore } = loadBridgeWithMocks({
safeSend: (_sender, channel, payload) => {
sentEvents.push({ channel, payload });
handleStreamEvent(channel, payload);
},
});
const ipcMain = createIpcMainStub();
const server = require("node:http").createServer((req, res) => {
req.resume();
req.on("end", () => {
res.writeHead(200, { "content-type": "text/event-stream" });
res.end('data:{"choices":[{"delta":{"content":"tail"}}]}');
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const address = server.address();
assert.ok(address && typeof address === "object");
const baseURL = `http://127.0.0.1:${address.port}`;
const sender = { id: 1 };
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
{ sender },
{ providers: [{ id: "flush-no-space", baseURL }] },
);
const streamFinished = new Promise((resolve, reject) => {
handleStreamEvent = (channel, payload) => {
if (channel === "netcatty:ai:stream:end") resolve();
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
};
});
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
{ sender },
{
requestId: "flush-no-space-request",
url: `${baseURL}/v1/chat/completions`,
headers: { "content-type": "application/json" },
body: '{"stream":true}',
providerId: "flush-no-space",
},
);
await streamFinished;
assert.equal(result.ok, true);
const dataEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:data");
assert.equal(dataEvent?.payload.data, '{"choices":[{"delta":{"content":"tail"}}]}');
} finally {
restore();
}
});
test("discover returns the 3-layer contract for an installed, authenticated agent", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discover-contract-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
const claudePath = path.join(tempDir, process.platform === "win32" ? "claude.cmd" : "claude");
fs.writeFileSync(
claudePath,
process.platform === "win32" ? "@echo off\r\necho 1.2.3 (Claude Code)\r\n" : "#!/bin/sh\necho '1.2.3 (Claude Code)'\n",
{ mode: 0o755 },
);
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: (cmd) => (cmd === "claude" ? claudePath : null),
isPlausibleCliVersionOutput: () => true,
probeClaudeAuth: () => ({ authenticated: true, authSource: "env" }),
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discover, "function");
const agents = await discover({ sender: { id: 1 } });
const claude = agents.find((agent) => agent.command === "claude");
assert.ok(claude);
assert.equal(claude.sdkBackend, "claude");
assert.equal(claude.binPath, claudePath);
assert.equal(claude.path, claudePath);
assert.equal(claude.installed, true);
assert.equal(claude.available, true);
assert.equal(claude.authenticated, true);
assert.equal(claude.authSource, "env");
} finally {
restore();
}
});
test("resolve-cli does not fall back to PATH when a custom path is invalid", async () => {
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: () => null,
resolveCliFromPath: (command) => (command === "codex" ? "/usr/local/bin/codex" : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli({ sender: { id: 1 } }, {
command: "codex",
customPath: "/missing/codex",
refreshShellEnv: true,
});
assert.equal(result.path, null);
assert.equal(result.available, false);
assert.equal(result.installed, false);
} finally {
restore();
}
});
test("codex login does not reuse an active session from a different resolved path", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPathAsync: (command) => (command === "codex" ? "/usr/bin/codex" : null),
getActiveCodexLoginSession: () => ({
id: "codex_login_custom",
state: "running",
process: { killed: false },
codexPath: "/custom/codex",
}),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const startLogin = ipcMain.handlers.get("netcatty:ai:codex:start-login");
const result = await startLogin({ sender: { id: 1 } }, {});
assert.equal(result.ok, false);
assert.match(result.error, /different CLI path/);
} finally {
restore();
}
});
test("codex integration keeps ChatGPT connected when the SDK validation probe fails", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, "codex");
fs.writeFileSync(
codexPath,
`#!${process.execPath}\nconsole.log('Logged in using ChatGPT');\n`,
{ mode: 0o755 },
);
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: (value) => value,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
assert.equal(typeof handler, "function");
const result = await handler({ sender: { id: 1 } }, {
codexPath,
validateChatGptAuth: true,
});
assert.equal(result.state, "connected_chatgpt", JSON.stringify(result));
assert.equal(result.isConnected, true);
assert.match(result.rawOutput, /Logged in using ChatGPT/);
assert.match(result.rawOutput, /ChatGPT auth validation failed:/);
} finally {
restore();
}
});
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, "codex.cmd");
fs.writeFileSync(
codexPath,
"@echo off\r\necho codex-cli 1.2.3\r\n",
"utf8",
);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexPath,
binPath: codexPath,
version: "codex-cli 1.2.3",
available: true,
installed: true,
});
} finally {
restore();
}
});
test("resolve-cli probes Windows Claude cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, "claude.cmd");
fs.writeFileSync(
claudePath,
"@echo off\r\necho 2.1.123 (Claude Code)\r\n",
"utf8",
);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
assert.deepEqual(result, {
path: claudePath,
binPath: claudePath,
version: "2.1.123 (Claude Code)",
available: true,
installed: true,
});
} finally {
restore();
}
});
test("resolve-cli probes Windows Claude exe paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude exe resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, "claude.exe");
fs.copyFileSync(process.execPath, claudePath);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
assert.deepEqual(result, {
path: claudePath,
binPath: claudePath,
version: process.version,
available: true,
installed: true,
});
} finally {
restore();
}
});
test("resolve-cli reports Cursor Agent CLI on PATH as installed without CLI login", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
probeCursorCliAuth: () => ({
authenticated: false,
authSource: null,
email: null,
binPath: "/usr/local/bin/cursor-agent",
}),
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
assert.equal(result.installed, true);
assert.equal(result.sdkInstalled, true);
assert.equal(result.cliBinPath, "/usr/local/bin/cursor-agent");
assert.equal(result.cliLoginOk, false);
assert.equal(result.available, false);
} finally {
restore();
}
});
test("resolve-cli reports Cursor SDK installed but unavailable without an API key", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
assert.deepEqual(result, {
path: "cursor",
binPath: "cursor",
version: "Cursor SDK",
available: false,
installed: false,
authenticated: false,
authSource: null,
cliEmail: null,
cliBinPath: null,
cliLoginOk: false,
apiKeyOk: false,
sdkInstalled: true,
});
} finally {
restore();
}
});
test("resolve-cli separates Cursor SDK installation from API key availability", async () => {
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: () => "/Applications/Cursor.app/Contents/MacOS/Cursor",
resolveCliFromPath: () => null,
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "/Applications/Cursor.app/Contents/MacOS/Cursor" },
);
assert.deepEqual(result, {
path: "cursor",
binPath: "cursor",
version: "Cursor SDK",
available: false,
installed: false,
authenticated: false,
authSource: null,
cliEmail: null,
cliBinPath: null,
cliLoginOk: false,
apiKeyOk: false,
sdkInstalled: true,
});
} finally {
restore();
}
});
test("resolve-cli ignores custom Cursor paths and stores the SDK sentinel path", async () => {
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: () => "/tmp/not-cursor",
resolveCliFromPath: () => null,
shellEnv: { CURSOR_API_KEY: "cur-key" },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "/tmp/not-cursor" },
);
assert.deepEqual(result, {
path: "cursor",
binPath: "cursor",
version: "Cursor SDK",
available: true,
installed: false,
authenticated: true,
authSource: "CURSOR_API_KEY",
cliEmail: null,
cliBinPath: null,
cliLoginOk: false,
apiKeyOk: true,
sdkInstalled: true,
});
} finally {
restore();
}
});
test("resolve-cli exposes Cursor SDK support when installed and authenticated", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
shellEnv: { CURSOR_API_KEY: "cur-key" },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
assert.deepEqual(result, {
path: "cursor",
binPath: "cursor",
version: "Cursor SDK",
available: true,
installed: false,
authenticated: true,
authSource: "CURSOR_API_KEY",
cliEmail: null,
cliBinPath: null,
cliLoginOk: false,
apiKeyOk: true,
sdkInstalled: true,
});
} finally {
restore();
}
});
test("resolve-cli exposes Cursor SDK support when API key is saved in settings", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => "/usr/local/bin/cursor",
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "", apiKeyPresent: true },
);
assert.deepEqual(result, {
path: "/usr/local/bin/cursor",
binPath: "/usr/local/bin/cursor",
version: "Cursor SDK",
available: true,
installed: false,
authenticated: true,
authSource: "settings",
cliEmail: null,
cliBinPath: null,
cliLoginOk: false,
apiKeyOk: true,
sdkInstalled: true,
});
} finally {
restore();
}
});
test("resolve-cli reports settings as Cursor auth source when settings and env keys both exist", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => "/usr/local/bin/cursor",
shellEnv: { CURSOR_API_KEY: "env-key" },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "", apiKeyPresent: true },
);
assert.equal(result.available, true);
assert.equal(result.authenticated, true);
assert.equal(result.authSource, "settings");
} finally {
restore();
}
});
test("resolve-cli can refresh shell env before resolving Cursor", async () => {
let refreshed = false;
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
shellEnv: { CURSOR_API_KEY: "cur-key" },
invalidateShellEnvCache: () => { refreshed = true; },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "", refreshShellEnv: true },
);
assert.equal(refreshed, true);
assert.equal(result.available, true);
} finally {
restore();
}
});
test("discover exposes Cursor when CLI login succeeds without API key", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
probeCursorCliAuth: () => ({
authenticated: true,
authSource: "cli-login",
email: "user@example.com",
binPath: "/Users/me/.local/bin/agent",
}),
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
const agents = await discover({ sender: { id: 1 } }, {});
const cursor = agents.find((agent) => agent.command === "cursor");
assert.equal(cursor?.available, true);
assert.equal(cursor?.installed, true);
assert.equal(cursor?.authenticated, true);
assert.equal(cursor?.authSource, "cli-login");
assert.equal(cursor?.path, "/Users/me/.local/bin/agent");
assert.equal(cursor?.cliEmail, "user@example.com");
} finally {
restore();
}
});
test("discover exposes Cursor SDK support when API key is saved in settings", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
const agents = await discover({ sender: { id: 1 } }, { apiKeyPresent: true });
const cursor = agents.find((agent) => agent.command === "cursor");
assert.equal(cursor?.path, "cursor");
assert.equal(cursor?.available, true);
assert.equal(cursor?.installed, false);
assert.equal(cursor?.sdkInstalled, true);
assert.equal(cursor?.authenticated, true);
assert.equal(cursor?.authSource, "settings");
} finally {
restore();
}
});
test("resolve-cli exposes Cursor CLI login without API key", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
probeCursorCliAuth: () => ({
authenticated: true,
authSource: "cli-login",
email: "user@example.com",
binPath: "/Users/me/.local/bin/agent",
}),
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
assert.equal(result.available, true);
assert.equal(result.installed, true);
assert.equal(result.authenticated, true);
assert.equal(result.authSource, "cli-login");
assert.equal(result.path, "/Users/me/.local/bin/agent");
assert.equal(result.cliEmail, "user@example.com");
} finally {
restore();
}
});
test("discover can refresh shell env before scanning Cursor", async () => {
let refreshed = false;
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
shellEnv: () => (refreshed ? { CURSOR_API_KEY: "cur-key" } : {}),
invalidateShellEnvCache: () => { refreshed = true; },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
const agents = await discover({ sender: { id: 1 } }, { refreshShellEnv: true });
const cursor = agents.find((agent) => agent.command === "cursor");
assert.equal(refreshed, true);
assert.equal(cursor?.path, "cursor");
assert.equal(cursor?.available, true);
} finally {
restore();
}
});
/** Boot the bridge with a local HTTP server and capture the request it receives. */
async function withCapturingServer(t, options, respond) {
const received = {};
let handleStreamEvent = () => {};
const { bridge, restore } = loadBridgeWithMocks({
safeSend: (_sender, channel, payload) => handleStreamEvent(channel, payload),
...options,
});
const ipcMain = createIpcMainStub();
const server = http.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
received.headers = req.headers;
received.body = Buffer.concat(chunks);
respond(res);
});
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
t.after(() => new Promise((resolve) => server.close(resolve)));
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
const address = server.address();
assert.ok(address && typeof address === "object");
const baseURL = `http://127.0.0.1:${address.port}`;
const sender = { id: 1 };
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
{ sender },
{ providers: [{ id: "content-length", baseURL }] },
);
return {
received,
baseURL,
sender,
ipcMain,
restore,
onStreamEvent: (fn) => {
handleStreamEvent = fn;
},
};
}
test("streaming AI requests send Content-Length instead of chunked encoding", { timeout: 5_000 }, async (t) => {
const ctx = await withCapturingServer(t, {}, (res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n');
});
try {
const body = JSON.stringify({
stream: true,
messages: [{ role: "user", content: "hi" }],
});
const streamFinished = new Promise((resolve, reject) => {
ctx.onStreamEvent((channel, payload) => {
if (channel === "netcatty:ai:stream:end") resolve();
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
});
});
const result = await ctx.ipcMain.handlers.get("netcatty:ai:chat:stream")(
{ sender: ctx.sender },
{
requestId: "content-length-stream",
url: `${ctx.baseURL}/v1/messages`,
headers: { "content-type": "application/json" },
body,
providerId: "content-length",
},
);
await streamFinished;
assert.equal(result.ok, true);
assert.equal(ctx.received.headers["content-length"], String(Buffer.byteLength(body)));
assert.equal(ctx.received.headers["transfer-encoding"], undefined);
assert.equal(ctx.received.body.toString("utf8"), body);
} finally {
ctx.restore();
}
});
test("Content-Length for AI request bodies counts bytes, not UTF-16 code units", { timeout: 5_000 }, async (t) => {
const ctx = await withCapturingServer(t, {}, (res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n');
});
try {
// A realistic system prompt: multi-byte characters make the UTF-16 length
// shorter than the UTF-8 byte length, so `body.length` would truncate.
const body = JSON.stringify({
stream: true,
system: "你是 Catty Agent一个内置于 netcatty 的终端自动化助手。请检查磁盘使用率。",
messages: [{ role: "user", content: "检查系统状态" }],
});
assert.notEqual(body.length, Buffer.byteLength(body));
const streamFinished = new Promise((resolve, reject) => {
ctx.onStreamEvent((channel, payload) => {
if (channel === "netcatty:ai:stream:end") resolve();
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
});
});
const result = await ctx.ipcMain.handlers.get("netcatty:ai:chat:stream")(
{ sender: ctx.sender },
{
requestId: "content-length-multibyte",
url: `${ctx.baseURL}/v1/messages`,
headers: { "content-type": "application/json" },
body,
providerId: "content-length",
},
);
await streamFinished;
assert.equal(result.ok, true);
assert.equal(ctx.received.headers["content-length"], String(Buffer.byteLength(body)));
assert.equal(ctx.received.body.toString("utf8"), body);
} finally {
ctx.restore();
}
});
test("non-streaming AI requests send Content-Length instead of chunked encoding", { timeout: 5_000 }, async (t) => {
const ctx = await withCapturingServer(t, {}, (res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end('{"data":[]}');
});
try {
const body = JSON.stringify({ model: "test-model", input: "hi" });
const result = await ctx.ipcMain.handlers.get("netcatty:ai:fetch")(
{ sender: ctx.sender },
{
url: `${ctx.baseURL}/v1/messages`,
method: "POST",
headers: { "content-type": "application/json" },
body,
providerId: "content-length",
},
);
assert.equal(result.ok, true);
assert.equal(ctx.received.headers["content-length"], String(Buffer.byteLength(body)));
assert.equal(ctx.received.headers["transfer-encoding"], undefined);
assert.equal(ctx.received.body.toString("utf8"), body);
} finally {
ctx.restore();
}
});