406 lines
11 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|