fix cursor stream-json multiplexed output handling

This commit is contained in:
Dotta
2026-03-05 08:07:20 -06:00
parent 875924a7f3
commit 426c1044b6
6 changed files with 89 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
import pc from "picocolors";
import { normalizeCursorStreamLine } from "../shared/stream.js";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
@@ -121,7 +122,7 @@ function printLegacyToolEvent(part: Record<string, unknown>): void {
}
export function printCursorStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
const line = normalizeCursorStreamLine(raw).line;
if (!line) return;
let parsed: Record<string, unknown> | null = null;

View File

@@ -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<AdapterExec
});
}
let stdoutLineBuffer = "";
const emitNormalizedStdoutLine = async (rawLine: string) => {
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,

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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 }];
}

View File

@@ -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 {