Files
paperclip/server/src/__tests__/cursor-local-adapter.test.ts
2026-03-11 10:35:41 -05:00

406 lines
11 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { isCursorUnknownSessionError, parseCursorJsonl } from "@paperclipai/adapter-cursor-local/server";
import { parseCursorStdoutLine } from "@paperclipai/adapter-cursor-local/ui";
import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
describe("cursor parser", () => {
it("extracts session, summary, usage, cost, and terminal error message", () => {
const stdout = [
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_123", model: "gpt-5" }),
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "output_text", text: "hello" }],
},
}),
JSON.stringify({
type: "result",
subtype: "success",
session_id: "chat_123",
usage: {
input_tokens: 100,
cached_input_tokens: 25,
output_tokens: 40,
},
total_cost_usd: 0.001,
result: "Task complete",
}),
JSON.stringify({ type: "error", message: "model access denied" }),
].join("\n");
const parsed = parseCursorJsonl(stdout);
expect(parsed.sessionId).toBe("chat_123");
expect(parsed.summary).toBe("hello");
expect(parsed.usage).toEqual({
inputTokens: 100,
cachedInputTokens: 25,
outputTokens: 40,
});
expect(parsed.costUsd).toBeCloseTo(0.001, 6);
expect(parsed.errorMessage).toBe("model access denied");
});
it("parses multiplexed stdout-prefixed json lines", () => {
const stdout = [
'stdout{"type":"system","subtype":"init","session_id":"chat_prefixed","model":"gpt-5"}',
'stdout{"type":"assistant","message":{"content":[{"type":"output_text","text":"prefixed hello"}]}}',
'stdout{"type":"result","subtype":"success","usage":{"input_tokens":3,"output_tokens":2,"cached_input_tokens":1},"total_cost_usd":0.0001}',
].join("\n");
const parsed = parseCursorJsonl(stdout);
expect(parsed.sessionId).toBe("chat_prefixed");
expect(parsed.summary).toBe("prefixed hello");
expect(parsed.usage).toEqual({
inputTokens: 3,
cachedInputTokens: 1,
outputTokens: 2,
});
expect(parsed.costUsd).toBeCloseTo(0.0001, 6);
});
});
describe("cursor stale session detection", () => {
it("treats missing/unknown session messages as an unknown session error", () => {
expect(isCursorUnknownSessionError("", "unknown session id chat_123")).toBe(true);
expect(isCursorUnknownSessionError("", "chat abc not found")).toBe(true);
});
});
describe("cursor ui stdout parser", () => {
it("parses assistant, thinking, and tool lifecycle events", () => {
const ts = "2026-03-05T00:00:00.000Z";
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "assistant",
message: {
content: [
{ type: "output_text", text: "I will run a command." },
{ type: "thinking", text: "Checking repository state" },
{ type: "tool_call", name: "bash", input: { command: "ls -1" } },
{ type: "tool_result", tool_use_id: "tool_1", output: "AGENTS.md\n", status: "ok" },
],
},
}),
ts,
),
).toEqual([
{ kind: "assistant", ts, text: "I will run a command." },
{ kind: "thinking", ts, text: "Checking repository state" },
{ kind: "tool_call", ts, name: "bash", input: { command: "ls -1" } },
{ kind: "tool_result", ts, toolUseId: "tool_1", content: "AGENTS.md\n", isError: false },
]);
});
it("parses result usage and errors", () => {
const ts = "2026-03-05T00:00:00.000Z";
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "result",
subtype: "success",
result: "Done",
usage: {
input_tokens: 10,
output_tokens: 5,
cached_input_tokens: 2,
},
total_cost_usd: 0.00042,
is_error: false,
}),
ts,
),
).toEqual([
{
kind: "result",
ts,
text: "Done",
inputTokens: 10,
outputTokens: 5,
cachedTokens: 2,
costUsd: 0.00042,
subtype: "success",
isError: false,
errors: [],
},
]);
});
it("parses stdout-prefixed json lines", () => {
const ts = "2026-03-05T00:00:00.000Z";
expect(
parseCursorStdoutLine(
'stdout{"type":"assistant","message":{"content":[{"type":"thinking","text":"streamed"}]}}',
ts,
),
).toEqual([{ kind: "thinking", ts, text: "streamed" }]);
});
it("compacts shellToolCall and shell tool result for run log", () => {
const ts = "2026-03-05T00:00:00.000Z";
const longCommand = "curl -s -X POST \"$PAPERCLIP_API_URL/api/issues/abc/checkout\" -H \"Authorization: Bearer $PAPERCLIP_API_KEY\"";
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "tool_call",
subtype: "started",
call_id: "call_shell_1",
tool_call: {
shellToolCall: {
command: longCommand,
workingDirectory: "/tmp",
timeout: 30000,
toolCallId: "tool_xyz",
simpleCommands: ["curl"],
parsingResult: { parsingFailed: false, executableCommands: [] },
},
},
}),
ts,
),
).toEqual([
{
kind: "tool_call",
ts,
name: "shellToolCall",
toolUseId: "call_shell_1",
input: { command: longCommand },
},
]);
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "tool_call",
subtype: "completed",
call_id: "call_shell_1",
tool_call: {
shellToolCall: {
result: {
success: {
command: longCommand,
exitCode: 0,
stdout: '{"id":"abc","status":"in_progress"}',
stderr: "",
executionTime: 100,
},
},
},
},
}),
ts,
),
).toEqual([
{
kind: "tool_result",
ts,
toolUseId: "call_shell_1",
content: "exit 0\n<stdout>\n{\"id\":\"abc\",\"status\":\"in_progress\"}",
isError: false,
},
]);
});
it("parses user, top-level thinking, and top-level tool_call events", () => {
const ts = "2026-03-05T00:00:00.000Z";
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "user",
message: {
role: "user",
content: [{ type: "text", text: "Please inspect README.md" }],
},
}),
ts,
),
).toEqual([{ kind: "user", ts, text: "Please inspect README.md" }]);
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "thinking",
subtype: "delta",
text: "planning next command",
}),
ts,
),
).toEqual([{ kind: "thinking", ts, text: "planning next command", delta: true }]);
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "thinking",
subtype: "delta",
text: " with preserved leading space",
}),
ts,
),
).toEqual([{ kind: "thinking", ts, text: " with preserved leading space", delta: true }]);
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "tool_call",
subtype: "started",
call_id: "call_1",
tool_call: {
readToolCall: {
args: { path: "README.md" },
},
},
}),
ts,
),
).toEqual([{ kind: "tool_call", ts, name: "readToolCall", toolUseId: "call_1", input: { path: "README.md" } }]);
expect(
parseCursorStdoutLine(
JSON.stringify({
type: "tool_call",
subtype: "completed",
call_id: "call_1",
tool_call: {
readToolCall: {
result: { success: { content: "README contents" } },
},
},
}),
ts,
),
).toEqual([
{
kind: "tool_result",
ts,
toolUseId: "call_1",
content: '{\n "success": {\n "content": "README contents"\n }\n}',
isError: false,
},
]);
});
});
function stripAnsi(value: string): string {
return value.replace(/\x1b\[[0-9;]*m/g, "");
}
describe("cursor cli formatter", () => {
it("prints init, user, assistant, tool, and result events", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
printCursorStreamEvent(
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_abc", model: "gpt-5" }),
false,
);
printCursorStreamEvent(
JSON.stringify({
type: "user",
message: {
content: [{ type: "text", text: "run tests" }],
},
}),
false,
);
printCursorStreamEvent(
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "output_text", text: "hello" }],
},
}),
false,
);
printCursorStreamEvent(
JSON.stringify({
type: "thinking",
subtype: "delta",
text: "looking at package.json",
}),
false,
);
printCursorStreamEvent(
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "tool_call", name: "bash", input: { command: "ls -1" } }],
},
}),
false,
);
printCursorStreamEvent(
JSON.stringify({
type: "assistant",
message: {
content: [{ type: "tool_result", output: "AGENTS.md", status: "ok" }],
},
}),
false,
);
printCursorStreamEvent(
JSON.stringify({
type: "tool_call",
subtype: "started",
call_id: "call_1",
tool_call: {
readToolCall: {
args: { path: "README.md" },
},
},
}),
false,
);
printCursorStreamEvent(
JSON.stringify({
type: "tool_call",
subtype: "completed",
call_id: "call_1",
tool_call: {
readToolCall: {
result: { success: { content: "README contents" } },
},
},
}),
false,
);
printCursorStreamEvent(
JSON.stringify({
type: "result",
subtype: "success",
result: "Done",
usage: { input_tokens: 10, output_tokens: 5, cached_input_tokens: 2 },
total_cost_usd: 0.00042,
}),
false,
);
const lines = spy.mock.calls
.map((call) => call.map((v) => String(v)).join(" "))
.map(stripAnsi);
expect(lines).toEqual(
expect.arrayContaining([
"Cursor init (session: chat_abc, model: gpt-5)",
"user: run tests",
"assistant: hello",
"thinking: looking at package.json",
"tool_call: bash",
"tool_call: readToolCall (call_1)",
"tool_result (call_1)",
'{\n "success": {\n "content": "README contents"\n }\n}',
"tool_result",
"AGENTS.md",
"result: subtype=success",
"tokens: in=10 out=5 cached=2 cost=$0.000420",
"assistant: Done",
]),
);
} finally {
spy.mockRestore();
}
});
});