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 {
|
||||
if (typeof messageRaw === "string") {
|
||||
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 {
|
||||
const tool = asString(part.tool, "tool");
|
||||
const callId = asString(part.callID, asString(part.id, ""));
|
||||
@@ -158,6 +232,22 @@ export function printCursorStreamEvent(raw: string, _debug: boolean): void {
|
||||
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") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
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[] {
|
||||
if (typeof messageRaw === "string") {
|
||||
const text = messageRaw.trim();
|
||||
@@ -101,6 +127,64 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry
|
||||
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[] {
|
||||
const normalized = normalizeCursorStreamLine(line);
|
||||
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) }];
|
||||
}
|
||||
|
||||
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") {
|
||||
const usage = asRecord(parsed.usage);
|
||||
const inputTokens = asNumber(usage?.input_tokens, asNumber(usage?.inputTokens));
|
||||
|
||||
@@ -136,6 +136,74 @@ describe("cursor ui stdout parser", () => {
|
||||
),
|
||||
).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 {
|
||||
@@ -143,7 +211,7 @@ function stripAnsi(value: string): string {
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
|
||||
try {
|
||||
@@ -151,6 +219,15 @@ describe("cursor cli formatter", () => {
|
||||
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",
|
||||
@@ -160,6 +237,14 @@ describe("cursor cli formatter", () => {
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "thinking",
|
||||
subtype: "delta",
|
||||
text: "looking at package.json",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
printCursorStreamEvent(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
@@ -178,6 +263,32 @@ describe("cursor cli formatter", () => {
|
||||
}),
|
||||
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",
|
||||
@@ -196,8 +307,13 @@ describe("cursor cli formatter", () => {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user