expand cursor stream-json event parsing coverage
This commit is contained in:
@@ -24,6 +24,30 @@ function stringifyUnknown(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printUserMessage(messageRaw: unknown): void {
|
||||||
|
if (typeof messageRaw === "string") {
|
||||||
|
const text = messageRaw.trim();
|
||||||
|
if (text) console.log(pc.gray(`user: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = asRecord(messageRaw);
|
||||||
|
if (!message) return;
|
||||||
|
|
||||||
|
const directText = asString(message.text).trim();
|
||||||
|
if (directText) console.log(pc.gray(`user: ${directText}`));
|
||||||
|
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = asRecord(partRaw);
|
||||||
|
if (!part) continue;
|
||||||
|
const type = asString(part.type).trim();
|
||||||
|
if (type !== "output_text" && type !== "text") continue;
|
||||||
|
const text = asString(part.text).trim();
|
||||||
|
if (text) console.log(pc.gray(`user: ${text}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function printAssistantMessage(messageRaw: unknown): void {
|
function printAssistantMessage(messageRaw: unknown): void {
|
||||||
if (typeof messageRaw === "string") {
|
if (typeof messageRaw === "string") {
|
||||||
const text = messageRaw.trim();
|
const text = messageRaw.trim();
|
||||||
@@ -82,6 +106,56 @@ function printAssistantMessage(messageRaw: unknown): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printToolCallEventTopLevel(parsed: Record<string, unknown>): void {
|
||||||
|
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
||||||
|
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "")));
|
||||||
|
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
||||||
|
if (!toolCall) {
|
||||||
|
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [toolName] = Object.keys(toolCall);
|
||||||
|
if (!toolName) {
|
||||||
|
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||||
|
const args = payload.args ?? asRecord(payload.function)?.arguments;
|
||||||
|
const result =
|
||||||
|
payload.result ??
|
||||||
|
payload.output ??
|
||||||
|
payload.error ??
|
||||||
|
asRecord(payload.function)?.result ??
|
||||||
|
asRecord(payload.function)?.output;
|
||||||
|
const isError =
|
||||||
|
parsed.is_error === true ||
|
||||||
|
payload.is_error === true ||
|
||||||
|
subtype === "failed" ||
|
||||||
|
subtype === "error" ||
|
||||||
|
subtype === "cancelled" ||
|
||||||
|
payload.error !== undefined;
|
||||||
|
|
||||||
|
if (subtype === "started" || subtype === "start") {
|
||||||
|
console.log(pc.yellow(`tool_call: ${toolName}${callId ? ` (${callId})` : ""}`));
|
||||||
|
if (args !== undefined) {
|
||||||
|
console.log(pc.gray(stringifyUnknown(args)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||||
|
const header = `tool_result${isError ? " (error)" : ""}${callId ? ` (${callId})` : ""}`;
|
||||||
|
console.log((isError ? pc.red : pc.cyan)(header));
|
||||||
|
if (result !== undefined) {
|
||||||
|
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(result)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
||||||
|
}
|
||||||
|
|
||||||
function printLegacyToolEvent(part: Record<string, unknown>): void {
|
function printLegacyToolEvent(part: Record<string, unknown>): void {
|
||||||
const tool = asString(part.tool, "tool");
|
const tool = asString(part.tool, "tool");
|
||||||
const callId = asString(part.callID, asString(part.id, ""));
|
const callId = asString(part.callID, asString(part.id, ""));
|
||||||
@@ -158,6 +232,22 @@ export function printCursorStreamEvent(raw: string, _debug: boolean): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "user") {
|
||||||
|
printUserMessage(parsed.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "thinking") {
|
||||||
|
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||||
|
if (text) console.log(pc.gray(`thinking: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_call") {
|
||||||
|
printToolCallEventTopLevel(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
const usage = asRecord(parsed.usage);
|
const usage = asRecord(parsed.usage);
|
||||||
const input = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
const input = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||||
|
|||||||
@@ -32,6 +32,32 @@ function stringifyUnknown(value: unknown): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseUserMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||||
|
if (typeof messageRaw === "string") {
|
||||||
|
const text = messageRaw.trim();
|
||||||
|
return text ? [{ kind: "user", ts, text }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = asRecord(messageRaw);
|
||||||
|
if (!message) return [];
|
||||||
|
|
||||||
|
const entries: TranscriptEntry[] = [];
|
||||||
|
const directText = asString(message.text).trim();
|
||||||
|
if (directText) entries.push({ kind: "user", ts, text: directText });
|
||||||
|
|
||||||
|
const content = Array.isArray(message.content) ? message.content : [];
|
||||||
|
for (const partRaw of content) {
|
||||||
|
const part = asRecord(partRaw);
|
||||||
|
if (!part) continue;
|
||||||
|
const type = asString(part.type).trim();
|
||||||
|
if (type !== "output_text" && type !== "text") continue;
|
||||||
|
const text = asString(part.text).trim();
|
||||||
|
if (text) entries.push({ kind: "user", ts, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
||||||
if (typeof messageRaw === "string") {
|
if (typeof messageRaw === "string") {
|
||||||
const text = messageRaw.trim();
|
const text = messageRaw.trim();
|
||||||
@@ -101,6 +127,64 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseCursorToolCallEvent(event: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
||||||
|
const subtype = asString(event.subtype).trim().toLowerCase();
|
||||||
|
const callId =
|
||||||
|
asString(event.call_id) ||
|
||||||
|
asString(event.callId) ||
|
||||||
|
asString(event.id) ||
|
||||||
|
"tool_call";
|
||||||
|
const toolCall = asRecord(event.tool_call ?? event.toolCall);
|
||||||
|
if (!toolCall) {
|
||||||
|
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [toolName] = Object.keys(toolCall);
|
||||||
|
if (!toolName) {
|
||||||
|
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
||||||
|
}
|
||||||
|
const payload = asRecord(toolCall[toolName]) ?? {};
|
||||||
|
const input = payload.args ?? asRecord(payload.function)?.arguments ?? {};
|
||||||
|
|
||||||
|
if (subtype === "started" || subtype === "start") {
|
||||||
|
return [{
|
||||||
|
kind: "tool_call",
|
||||||
|
ts,
|
||||||
|
name: toolName,
|
||||||
|
input,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
||||||
|
const result =
|
||||||
|
payload.result ??
|
||||||
|
payload.output ??
|
||||||
|
payload.error ??
|
||||||
|
asRecord(payload.function)?.result ??
|
||||||
|
asRecord(payload.function)?.output;
|
||||||
|
const isError =
|
||||||
|
event.is_error === true ||
|
||||||
|
payload.is_error === true ||
|
||||||
|
asString(payload.status).toLowerCase() === "error" ||
|
||||||
|
asString(payload.status).toLowerCase() === "failed" ||
|
||||||
|
asString(payload.status).toLowerCase() === "cancelled" ||
|
||||||
|
payload.error !== undefined;
|
||||||
|
return [{
|
||||||
|
kind: "tool_result",
|
||||||
|
ts,
|
||||||
|
toolUseId: callId,
|
||||||
|
content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`,
|
||||||
|
isError,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
kind: "system",
|
||||||
|
ts,
|
||||||
|
text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}`,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
const normalized = normalizeCursorStreamLine(line);
|
const normalized = normalizeCursorStreamLine(line);
|
||||||
if (!normalized.line) return [];
|
if (!normalized.line) return [];
|
||||||
@@ -129,6 +213,20 @@ export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry
|
|||||||
return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }];
|
return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "user") {
|
||||||
|
return parseUserMessage(parsed.message, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "thinking") {
|
||||||
|
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
||||||
|
if (!text) return [];
|
||||||
|
return [{ kind: "thinking", ts, text }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "tool_call") {
|
||||||
|
return parseCursorToolCallEvent(parsed, ts);
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
const usage = asRecord(parsed.usage);
|
const usage = asRecord(parsed.usage);
|
||||||
const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||||
|
|||||||
@@ -136,6 +136,74 @@ describe("cursor ui stdout parser", () => {
|
|||||||
),
|
),
|
||||||
).toEqual([{ kind: "thinking", ts, text: "streamed" }]);
|
).toEqual([{ kind: "thinking", ts, text: "streamed" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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" }]);
|
||||||
|
|
||||||
|
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", 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 {
|
function stripAnsi(value: string): string {
|
||||||
@@ -143,7 +211,7 @@ function stripAnsi(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("cursor cli formatter", () => {
|
describe("cursor cli formatter", () => {
|
||||||
it("prints init, assistant, tool, and result events", () => {
|
it("prints init, user, assistant, tool, and result events", () => {
|
||||||
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -151,6 +219,15 @@ describe("cursor cli formatter", () => {
|
|||||||
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_abc", model: "gpt-5" }),
|
JSON.stringify({ type: "system", subtype: "init", session_id: "chat_abc", model: "gpt-5" }),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
printCursorStreamEvent(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
content: [{ type: "text", text: "run tests" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
printCursorStreamEvent(
|
printCursorStreamEvent(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
@@ -160,6 +237,14 @@ describe("cursor cli formatter", () => {
|
|||||||
}),
|
}),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
printCursorStreamEvent(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "thinking",
|
||||||
|
subtype: "delta",
|
||||||
|
text: "looking at package.json",
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
printCursorStreamEvent(
|
printCursorStreamEvent(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
@@ -178,6 +263,32 @@ describe("cursor cli formatter", () => {
|
|||||||
}),
|
}),
|
||||||
false,
|
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(
|
printCursorStreamEvent(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "result",
|
type: "result",
|
||||||
@@ -196,8 +307,13 @@ describe("cursor cli formatter", () => {
|
|||||||
expect(lines).toEqual(
|
expect(lines).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
"Cursor init (session: chat_abc, model: gpt-5)",
|
"Cursor init (session: chat_abc, model: gpt-5)",
|
||||||
|
"user: run tests",
|
||||||
"assistant: hello",
|
"assistant: hello",
|
||||||
|
"thinking: looking at package.json",
|
||||||
"tool_call: bash",
|
"tool_call: bash",
|
||||||
|
"tool_call: readToolCall (call_1)",
|
||||||
|
"tool_result (call_1)",
|
||||||
|
'{\n "success": {\n "content": "README contents"\n }\n}',
|
||||||
"tool_result",
|
"tool_result",
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
"result: subtype=success",
|
"result: subtype=success",
|
||||||
|
|||||||
Reference in New Issue
Block a user