From 426c1044b65881b2112da4792df81d03d3ac1503 Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Mar 2026 08:07:20 -0600 Subject: [PATCH] fix cursor stream-json multiplexed output handling --- .../cursor-local/src/cli/format-event.ts | 3 +- .../cursor-local/src/server/execute.ts | 34 ++++++++++++++++++- .../adapters/cursor-local/src/server/parse.ts | 3 +- .../cursor-local/src/shared/stream.ts | 16 +++++++++ .../cursor-local/src/ui/parse-stdout.ts | 12 ++++--- .../__tests__/cursor-local-adapter.test.ts | 28 +++++++++++++++ 6 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 packages/adapters/cursor-local/src/shared/stream.ts diff --git a/packages/adapters/cursor-local/src/cli/format-event.ts b/packages/adapters/cursor-local/src/cli/format-event.ts index ff97f8ea..556a3ebb 100644 --- a/packages/adapters/cursor-local/src/cli/format-event.ts +++ b/packages/adapters/cursor-local/src/cli/format-event.ts @@ -1,4 +1,5 @@ import pc from "picocolors"; +import { normalizeCursorStreamLine } from "../shared/stream.js"; function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; @@ -121,7 +122,7 @@ function printLegacyToolEvent(part: Record): void { } export function printCursorStreamEvent(raw: string, _debug: boolean): void { - const line = raw.trim(); + const line = normalizeCursorStreamLine(raw).line; if (!line) return; let parsed: Record | null = null; diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 0170747d..5a5af21e 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -16,6 +16,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js"; import { parseCursorJsonl, isCursorUnknownSessionError } from "./parse.js"; +import { normalizeCursorStreamLine } from "../shared/stream.js"; function firstNonEmptyLine(text: string): string { return ( @@ -251,13 +252,44 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const normalized = normalizeCursorStreamLine(rawLine); + if (!normalized.line) return; + await onLog(normalized.stream ?? "stdout", `${normalized.line}\n`); + }; + const flushStdoutChunk = async (chunk: string, finalize = false) => { + const combined = `${stdoutLineBuffer}${chunk}`; + const lines = combined.split(/\r?\n/); + stdoutLineBuffer = lines.pop() ?? ""; + + for (const line of lines) { + await emitNormalizedStdoutLine(line); + } + + if (finalize) { + const trailing = stdoutLineBuffer.trim(); + stdoutLineBuffer = ""; + if (trailing) { + await emitNormalizedStdoutLine(trailing); + } + } + }; + const proc = await runChildProcess(runId, command, args, { cwd, env, timeoutSec, graceSec, - onLog, + onLog: async (stream, chunk) => { + if (stream !== "stdout") { + await onLog(stream, chunk); + return; + } + await flushStdoutChunk(chunk); + }, }); + await flushStdoutChunk("", true); return { proc, diff --git a/packages/adapters/cursor-local/src/server/parse.ts b/packages/adapters/cursor-local/src/server/parse.ts index 5851fb7f..8644e106 100644 --- a/packages/adapters/cursor-local/src/server/parse.ts +++ b/packages/adapters/cursor-local/src/server/parse.ts @@ -1,4 +1,5 @@ import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils"; +import { normalizeCursorStreamLine } from "../shared/stream.js"; function asErrorText(value: unknown): string { if (typeof value === "string") return value; @@ -60,7 +61,7 @@ export function parseCursorJsonl(stdout: string) { }; for (const rawLine of stdout.split(/\r?\n/)) { - const line = rawLine.trim(); + const line = normalizeCursorStreamLine(rawLine).line; if (!line) continue; const event = parseJson(line); diff --git a/packages/adapters/cursor-local/src/shared/stream.ts b/packages/adapters/cursor-local/src/shared/stream.ts new file mode 100644 index 00000000..cb3e0914 --- /dev/null +++ b/packages/adapters/cursor-local/src/shared/stream.ts @@ -0,0 +1,16 @@ +export function normalizeCursorStreamLine(rawLine: string): { + stream: "stdout" | "stderr" | null; + line: string; +} { + const trimmed = rawLine.trim(); + if (!trimmed) return { stream: null, line: "" }; + + const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*([\[{].*)$/i); + if (!prefixed) { + return { stream: null, line: trimmed }; + } + + const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout"; + const line = (prefixed[2] ?? "").trim(); + return { stream, line }; +} diff --git a/packages/adapters/cursor-local/src/ui/parse-stdout.ts b/packages/adapters/cursor-local/src/ui/parse-stdout.ts index e513459f..1028a765 100644 --- a/packages/adapters/cursor-local/src/ui/parse-stdout.ts +++ b/packages/adapters/cursor-local/src/ui/parse-stdout.ts @@ -1,4 +1,5 @@ import type { TranscriptEntry } from "@paperclipai/adapter-utils"; +import { normalizeCursorStreamLine } from "../shared/stream.js"; function safeJsonParse(text: string): unknown { try { @@ -101,9 +102,12 @@ function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry } export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry[] { - const parsed = asRecord(safeJsonParse(line)); + const normalized = normalizeCursorStreamLine(line); + if (!normalized.line) return []; + + const parsed = asRecord(safeJsonParse(normalized.line)); if (!parsed) { - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: normalized.line }]; } const type = asString(parsed.type); @@ -156,7 +160,7 @@ export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry } if (type === "error") { - const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || line; + const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || normalized.line; return [{ kind: "stderr", ts, text: message }]; } @@ -236,5 +240,5 @@ export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: normalized.line }]; } diff --git a/server/src/__tests__/cursor-local-adapter.test.ts b/server/src/__tests__/cursor-local-adapter.test.ts index ee797ba1..462177f1 100644 --- a/server/src/__tests__/cursor-local-adapter.test.ts +++ b/server/src/__tests__/cursor-local-adapter.test.ts @@ -39,6 +39,24 @@ describe("cursor parser", () => { 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", () => { @@ -108,6 +126,16 @@ describe("cursor ui stdout parser", () => { }, ]); }); + + 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" }]); + }); }); function stripAnsi(value: string): string {