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
549 lines
15 KiB
JavaScript
549 lines
15 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
const {
|
|
buildCursorAgentOptions,
|
|
buildCursorSendMessage,
|
|
formatCursorErrorForUser,
|
|
isCursorAgentNotFoundError,
|
|
mapCursorModels,
|
|
runCursorTurn,
|
|
toCursorMcpServers,
|
|
translateCursorEvent,
|
|
withTemporaryProcessEnv,
|
|
} = require("./cursorDriver.cjs");
|
|
|
|
function makeEmitter() {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
text: (value) => calls.push(["text", value]),
|
|
reasoning: (value) => calls.push(["reasoning", value]),
|
|
reasoningEnd: () => calls.push(["reasoningEnd"]),
|
|
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
|
|
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
|
|
sessionId: (id) => calls.push(["sessionId", id]),
|
|
emitDone: () => calls.push(["done"]),
|
|
emitError: (message) => calls.push(["error", message]),
|
|
};
|
|
}
|
|
|
|
test("buildCursorAgentOptions uses api key, model, cwd, and injected MCP servers", () => {
|
|
const options = buildCursorAgentOptions({
|
|
apiKey: "cur-key",
|
|
model: "composer-2",
|
|
cwd: "/repo",
|
|
injectedMcpServers: [
|
|
{
|
|
name: "netcatty",
|
|
command: "node",
|
|
args: ["server.cjs"],
|
|
env: [{ name: "TOKEN", value: "abc" }],
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(options, {
|
|
apiKey: "cur-key",
|
|
model: { id: "composer-2" },
|
|
local: { cwd: "/repo", autoReview: false },
|
|
mcpServers: {
|
|
netcatty: {
|
|
type: "stdio",
|
|
command: "node",
|
|
args: ["server.cjs"],
|
|
env: { TOKEN: "abc" },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("buildCursorAgentOptions falls back to CURSOR_API_KEY and composer-2.5", () => {
|
|
const options = buildCursorAgentOptions({
|
|
env: { CURSOR_API_KEY: "env-key" },
|
|
cwd: "/repo",
|
|
});
|
|
|
|
assert.equal(options.apiKey, "env-key");
|
|
assert.deepEqual(options.model, { id: "composer-2.5" });
|
|
});
|
|
|
|
test("toCursorMcpServers drops invalid server configs", () => {
|
|
assert.deepEqual(
|
|
toCursorMcpServers([
|
|
null,
|
|
{ name: "", command: "node" },
|
|
{ name: "ok", command: "node", args: [] },
|
|
]),
|
|
{ ok: { type: "stdio", command: "node", args: [], env: {} } },
|
|
);
|
|
});
|
|
|
|
test("withTemporaryProcessEnv restores env after async work", async () => {
|
|
const original = process.env.NETCATTY_CURSOR_TEST_ENV;
|
|
delete process.env.NETCATTY_CURSOR_TEST_ENV;
|
|
|
|
const value = await withTemporaryProcessEnv(
|
|
{ NETCATTY_CURSOR_TEST_ENV: "present" },
|
|
async () => process.env.NETCATTY_CURSOR_TEST_ENV,
|
|
);
|
|
|
|
assert.equal(value, "present");
|
|
assert.equal(process.env.NETCATTY_CURSOR_TEST_ENV, undefined);
|
|
if (original !== undefined) process.env.NETCATTY_CURSOR_TEST_ENV = original;
|
|
});
|
|
|
|
test("runCursorTurn exposes runtime env while creating and sending", async () => {
|
|
const emitter = makeEmitter();
|
|
const observed = [];
|
|
const sdkModule = {
|
|
Agent: {
|
|
async create() {
|
|
observed.push(["create", process.env.NETCATTY_TOOL_CLI_DISCOVERY_FILE]);
|
|
return {
|
|
agentId: "agent-env",
|
|
async send() {
|
|
observed.push(["send", process.env.NETCATTY_TOOL_CLI_DISCOVERY_FILE]);
|
|
return {
|
|
async *stream() {
|
|
yield { type: "assistant", message: { content: [{ type: "text", text: "ok" }] } };
|
|
},
|
|
};
|
|
},
|
|
close() {},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
await runCursorTurn({
|
|
prompt: "hi",
|
|
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
|
runtimeEnv: { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" },
|
|
emitter,
|
|
sdkModule,
|
|
});
|
|
|
|
assert.deepEqual(observed, [
|
|
["create", "/tmp/discovery.json"],
|
|
["send", "/tmp/discovery.json"],
|
|
]);
|
|
});
|
|
|
|
test("translateCursorEvent maps assistant, thinking, and tool events", () => {
|
|
const emitter = makeEmitter();
|
|
const state = {};
|
|
|
|
translateCursorEvent({ type: "thinking", text: "checking" }, emitter, state);
|
|
translateCursorEvent({
|
|
type: "assistant",
|
|
message: {
|
|
content: [
|
|
{ type: "text", text: "hello" },
|
|
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "README.md" } },
|
|
],
|
|
},
|
|
}, emitter, state);
|
|
translateCursorEvent({
|
|
type: "tool_call",
|
|
call_id: "tool-1",
|
|
name: "read_file",
|
|
status: "completed",
|
|
result: { content: [{ type: "text", text: "contents" }] },
|
|
}, emitter, state);
|
|
|
|
assert.deepEqual(emitter.calls, [
|
|
["reasoning", "checking"],
|
|
["reasoningEnd"],
|
|
["text", "hello"],
|
|
["toolCall", "read_file", { path: "README.md" }, "tool-1"],
|
|
["toolResult", "tool-1", "contents", "read_file"],
|
|
]);
|
|
});
|
|
|
|
test("translateCursorEvent uses nested Cursor MCP toolName for display", () => {
|
|
const emitter = makeEmitter();
|
|
const state = {};
|
|
const args = {
|
|
providerIdentifier: "netcatty-remote-hosts",
|
|
toolName: "terminal_execute",
|
|
args: { command: "uname -a" },
|
|
};
|
|
|
|
translateCursorEvent({
|
|
type: "tool_call",
|
|
call_id: "mcp-1",
|
|
name: "mcp",
|
|
status: "completed",
|
|
args,
|
|
result: { content: [{ type: "text", text: "Linux" }] },
|
|
}, emitter, state);
|
|
|
|
assert.deepEqual(emitter.calls, [
|
|
["toolCall", "terminal_execute", args, "mcp-1"],
|
|
["toolResult", "mcp-1", "Linux", "terminal_execute"],
|
|
]);
|
|
});
|
|
|
|
test("translateCursorEvent marks error status as failed", () => {
|
|
const emitter = makeEmitter();
|
|
const state = {};
|
|
|
|
const failed = translateCursorEvent({ type: "status", status: "ERROR", message: "bad key" }, emitter, state);
|
|
|
|
assert.equal(failed, true);
|
|
assert.equal(state.failed, true);
|
|
assert.deepEqual(emitter.calls, [["error", "bad key"]]);
|
|
});
|
|
|
|
test("translateCursorEvent rewrites Cursor authentication errors", () => {
|
|
const emitter = makeEmitter();
|
|
const state = {};
|
|
|
|
const failed = translateCursorEvent({ type: "status", status: "ERROR", message: "bad API key" }, emitter, state);
|
|
|
|
assert.equal(failed, true);
|
|
assert.equal(state.failed, true);
|
|
assert.deepEqual(emitter.calls, [[
|
|
"error",
|
|
"Cursor authentication failed. Update the Cursor API Key in Settings -> AI.",
|
|
]]);
|
|
});
|
|
|
|
test("formatCursorErrorForUser points users to the settings API key", () => {
|
|
assert.equal(
|
|
formatCursorErrorForUser("unauthorized"),
|
|
"Cursor authentication failed. Update the Cursor API Key in Settings -> AI.",
|
|
);
|
|
});
|
|
|
|
test("isCursorAgentNotFoundError detects stale resume ids", () => {
|
|
assert.equal(isCursorAgentNotFoundError(new Error("Agent 61668441-bfcb-4795-a575-c46d70ad01fe not found")), true);
|
|
assert.equal(isCursorAgentNotFoundError(new Error("unauthorized")), false);
|
|
});
|
|
|
|
test("runCursorTurn falls back to create when resume agent is missing", async () => {
|
|
const emitter = makeEmitter();
|
|
const observed = [];
|
|
const sdkModule = {
|
|
Agent: {
|
|
async resume(id) {
|
|
observed.push(["resume", id]);
|
|
throw new Error(`Agent ${id} not found`);
|
|
},
|
|
async create() {
|
|
observed.push(["create"]);
|
|
return {
|
|
agentId: "agent-fresh",
|
|
async send() {
|
|
return {
|
|
async *stream() {
|
|
yield { type: "assistant", message: { content: [{ type: "text", text: "ok" }] } };
|
|
},
|
|
};
|
|
},
|
|
close() {},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await runCursorTurn({
|
|
prompt: "hi",
|
|
resumeSessionId: "61668441-bfcb-4795-a575-c46d70ad01fe",
|
|
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
|
emitter,
|
|
sdkModule,
|
|
});
|
|
|
|
assert.deepEqual(observed, [
|
|
["resume", "61668441-bfcb-4795-a575-c46d70ad01fe"],
|
|
["create"],
|
|
]);
|
|
assert.equal(result.sessionId, "agent-fresh");
|
|
assert.deepEqual(emitter.calls, [
|
|
["sessionId", "agent-fresh"],
|
|
["text", "ok"],
|
|
["done"],
|
|
]);
|
|
});
|
|
|
|
test("runCursorTurn creates or resumes an agent, streams events, and emits done", async () => {
|
|
const emitter = makeEmitter();
|
|
const captured = {};
|
|
const sdkModule = {
|
|
Agent: {
|
|
async create(options) {
|
|
captured.createOptions = options;
|
|
return {
|
|
agentId: "agent-new",
|
|
async send(message) {
|
|
captured.message = message;
|
|
return {
|
|
id: "run-1",
|
|
agentId: "agent-new",
|
|
async *stream() {
|
|
yield { type: "assistant", message: { content: [{ type: "text", text: "done" }] } };
|
|
},
|
|
};
|
|
},
|
|
async close() {
|
|
captured.closed = true;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await runCursorTurn({
|
|
prompt: "hi",
|
|
attachments: [{ mediaType: "image/png", base64Data: "abc", filename: "a.png" }],
|
|
agentOptions: { apiKey: "key", model: { id: "composer-2" }, local: { cwd: "/repo" } },
|
|
emitter,
|
|
sdkModule,
|
|
});
|
|
|
|
assert.equal(result.sessionId, "agent-new");
|
|
assert.deepEqual(captured.message, {
|
|
text: "hi",
|
|
images: [{ data: "abc", mimeType: "image/png" }],
|
|
});
|
|
assert.deepEqual(emitter.calls, [
|
|
["sessionId", "agent-new"],
|
|
["text", "done"],
|
|
["done"],
|
|
]);
|
|
assert.equal(captured.closed, true);
|
|
});
|
|
|
|
test("runCursorTurn does not emit done after a Cursor error status", async () => {
|
|
const emitter = makeEmitter();
|
|
const sdkModule = {
|
|
Agent: {
|
|
async create() {
|
|
return {
|
|
agentId: "agent-error",
|
|
async send() {
|
|
return {
|
|
async *stream() {
|
|
yield { type: "status", status: "ERROR", message: "bad key" };
|
|
yield { type: "assistant", message: { content: [{ type: "text", text: "late" }] } };
|
|
},
|
|
};
|
|
},
|
|
close() {},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
|
|
const result = await runCursorTurn({
|
|
prompt: "hi",
|
|
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
|
emitter,
|
|
sdkModule,
|
|
});
|
|
|
|
assert.equal(result.sessionId, "agent-error");
|
|
assert.deepEqual(emitter.calls, [
|
|
["sessionId", "agent-error"],
|
|
["error", "bad key"],
|
|
]);
|
|
});
|
|
|
|
test("runCursorTurn returns when aborted while creating an agent", async () => {
|
|
const emitter = makeEmitter();
|
|
let resolveCreate;
|
|
const createPromise = new Promise((resolve) => {
|
|
resolveCreate = resolve;
|
|
});
|
|
const sdkModule = {
|
|
Agent: {
|
|
create() {
|
|
return createPromise;
|
|
},
|
|
},
|
|
};
|
|
const controller = new AbortController();
|
|
const turnPromise = runCursorTurn({
|
|
prompt: "hi",
|
|
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
|
emitter,
|
|
signal: controller.signal,
|
|
sdkModule,
|
|
});
|
|
|
|
controller.abort();
|
|
const result = await turnPromise;
|
|
assert.deepEqual(result, { sessionId: null });
|
|
assert.deepEqual(emitter.calls, []);
|
|
|
|
let closed = false;
|
|
resolveCreate({ agentId: "late", close: () => { closed = true; } });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(closed, true);
|
|
});
|
|
|
|
test("runCursorTurn restores runtime env when aborted while creating an agent", async () => {
|
|
const emitter = makeEmitter();
|
|
const original = process.env.NETCATTY_CURSOR_ABORT_ENV;
|
|
delete process.env.NETCATTY_CURSOR_ABORT_ENV;
|
|
const sdkModule = {
|
|
Agent: {
|
|
create() {
|
|
return new Promise(() => {});
|
|
},
|
|
},
|
|
};
|
|
const controller = new AbortController();
|
|
const turnPromise = runCursorTurn({
|
|
prompt: "hi",
|
|
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
|
runtimeEnv: { NETCATTY_CURSOR_ABORT_ENV: "present" },
|
|
emitter,
|
|
signal: controller.signal,
|
|
sdkModule,
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(process.env.NETCATTY_CURSOR_ABORT_ENV, "present");
|
|
controller.abort();
|
|
await turnPromise;
|
|
assert.equal(process.env.NETCATTY_CURSOR_ABORT_ENV, undefined);
|
|
if (original !== undefined) process.env.NETCATTY_CURSOR_ABORT_ENV = original;
|
|
});
|
|
|
|
test("runCursorTurn cancels a late Cursor run when aborted while sending", async () => {
|
|
const emitter = makeEmitter();
|
|
let resolveSend;
|
|
let cancelled = false;
|
|
const sendPromise = new Promise((resolve) => {
|
|
resolveSend = resolve;
|
|
});
|
|
const sdkModule = {
|
|
Agent: {
|
|
async create() {
|
|
return {
|
|
agentId: "agent-send-abort",
|
|
send() {
|
|
return sendPromise;
|
|
},
|
|
close() {},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
const controller = new AbortController();
|
|
const turnPromise = runCursorTurn({
|
|
prompt: "hi",
|
|
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
|
|
emitter,
|
|
signal: controller.signal,
|
|
sdkModule,
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
controller.abort();
|
|
const result = await turnPromise;
|
|
assert.deepEqual(result, { sessionId: "agent-send-abort" });
|
|
assert.deepEqual(emitter.calls, [["sessionId", "agent-send-abort"]]);
|
|
|
|
resolveSend({ cancel: async () => { cancelled = true; }, stream: async function* stream() {} });
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
assert.equal(cancelled, true);
|
|
});
|
|
|
|
test("mapCursorModels prefers advertised effort parameter values over fallbacks", () => {
|
|
assert.deepEqual(
|
|
mapCursorModels([
|
|
{
|
|
id: "custom-reasoner",
|
|
displayName: "Custom Reasoner",
|
|
parameters: [
|
|
{ id: "effort", values: [{ value: "low" }, { value: "xhigh" }] },
|
|
],
|
|
},
|
|
{
|
|
id: "gpt-5",
|
|
displayName: "GPT-5",
|
|
parameters: [
|
|
{ id: "effort", values: [{ value: "low" }, { value: "high" }] },
|
|
],
|
|
},
|
|
]),
|
|
[
|
|
{
|
|
id: "custom-reasoner",
|
|
name: "Custom Reasoner",
|
|
thinkingLevels: ["low", "xhigh"],
|
|
defaultThinkingLevel: "low",
|
|
},
|
|
{
|
|
id: "gpt-5",
|
|
name: "GPT-5",
|
|
thinkingLevels: ["low", "high"],
|
|
defaultThinkingLevel: "low",
|
|
},
|
|
],
|
|
);
|
|
});
|
|
|
|
test("mapCursorModels maps display names and effort variants into thinkingLevels", () => {
|
|
assert.deepEqual(
|
|
mapCursorModels([
|
|
{ id: "composer-2.5", displayName: "Composer 2.5", description: "Default" },
|
|
{ id: "gpt-5", displayName: "GPT-5", variants: [{ displayName: "Fast", params: [{ id: "effort", value: "low" }] }] },
|
|
]),
|
|
[
|
|
{ id: "composer-2.5", name: "Composer 2.5", description: "Default" },
|
|
{
|
|
id: "gpt-5",
|
|
name: "GPT-5",
|
|
thinkingLevels: ["low", "medium", "high"],
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
],
|
|
);
|
|
});
|
|
|
|
test("mapCursorModels keeps extra-param variants as separate models", () => {
|
|
const mapped = mapCursorModels([
|
|
{
|
|
id: "gpt-5",
|
|
displayName: "GPT-5",
|
|
variants: [
|
|
{ displayName: "Fast", params: [{ id: "effort", value: "low" }] },
|
|
{
|
|
displayName: "Fast custom",
|
|
params: [{ id: "effort", value: "low" }, { id: "mode", value: "fast" }],
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
assert.deepEqual(mapped, [
|
|
{
|
|
id: "gpt-5",
|
|
name: "GPT-5",
|
|
thinkingLevels: ["low", "medium", "high"],
|
|
defaultThinkingLevel: "medium",
|
|
},
|
|
{
|
|
id: "gpt-5?effort=low&mode=fast",
|
|
name: "GPT-5 - Fast custom",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("parseCursorModelSelection accepts query and slash effort encodings", () => {
|
|
const { parseCursorModelSelection, encodeCursorCliModel } = require("./cursorDriver.cjs");
|
|
assert.deepEqual(parseCursorModelSelection("gpt-5/high"), {
|
|
id: "gpt-5",
|
|
params: [{ id: "effort", value: "high" }],
|
|
});
|
|
assert.deepEqual(parseCursorModelSelection("gpt-5?effort=low"), {
|
|
id: "gpt-5",
|
|
params: [{ id: "effort", value: "low" }],
|
|
});
|
|
assert.equal(encodeCursorCliModel("gpt-5/high"), "gpt-5?effort=high");
|
|
});
|