Files
PaperClipAI/packages/adapters/cursor-local/src/ui/parse-stdout.ts
T
ef1422c23e fix(cursor): coalesce streamed assistant text into prose blocks (#8544)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent runs stream their transcripts through per-adapter stdout
parsers into the chat/run transcript UI
(`ui/src/adapters/transcript.ts`)
> - The Cursor CLI (local) streams assistant text as many small `text`
events (often a token or word each), and the parser emitted one
assistant entry per event and trimmed each
> - As a result the chat rendered one bubble per token ("every line a
new token") and dropped inter-token whitespace, making Cursor runs hard
to read
> - The render layer already coalesces consecutive `delta` entries
(`appendTranscriptEntry`), but the Cursor parser never tagged streamed
text as a delta
> - This pull request tags streamed `text` as a delta (without trimming)
so the existing render-time coalescer merges them into one assistant
block, while a `tool_call`/`tool_result` between deltas still breaks the
run
> - The benefit is readable Cursor transcripts with correct spacing and
preserved tool boundaries, with no change to the canonical event stream
(raw view unaffected)

## Linked Issues or Issue Description

No existing public issue — describing the bug inline (per
`.github/ISSUE_TEMPLATE/bug_report.yml`):

**What happened**

In the chat/run transcript, Cursor (local) assistant messages render as
one bubble per token/word, and inter-token spaces are dropped, making
the transcript unreadable. Root cause:
`packages/adapters/cursor-local/src/ui/parse-stdout.ts` (`type: "text"`
branch) emitted `{ kind: "assistant" }` per streamed `text` event
without `delta: true` and trimmed each, so the render-time coalescer
(`ui/src/adapters/transcript.ts`) never merged them and whitespace was
lost.

**Expected behavior**

Streamed assistant text should render as a single contiguous prose
block, with tool calls preserved as boundaries between blocks.

**Steps to reproduce**

1. Run a Cursor (local) agent that streams a multi-word assistant
message.
2. Open the run transcript in the chat UI.
3. Observe each streamed token/word rendered as its own bubble, with
inter-token spaces missing.

**Paperclip version**

Reproduced on current `master` (cutover base `e68188c43`).

**Deployment mode**

Self-hosted, `cursor_local` adapter.

## What Changed

- `packages/adapters/cursor-local/src/ui/parse-stdout.ts`: tag streamed
`text` events as `{ kind: "assistant", delta: true }` and stop trimming,
so the existing `appendTranscriptEntry` coalescer merges consecutive
deltas into one block.
- `ui/src/adapters/cursor-coalescing.test.ts` (new): dual-shape golden
fixtures (Cursor local + cloud) exercising the full render-time
projection via `buildTranscript`.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/adapters/cursor-coalescing.test.ts src/adapters/transcript.test.ts`
→ **10/10 pass**.
- `pnpm --filter @paperclipai/ui --filter
@paperclipai/adapter-cursor-local typecheck` → **green**.
- The golden fixtures assert the run `text → tool_call → tool_result →
text → consolidated final` renders as exactly **two prose blocks with
the tool between them**, **no duplication** of the consolidated final,
and **inter-token whitespace preserved** across coalesced deltas.

## Risks

- **Low risk.** Pure classification at parse time; the canonical event
stream and the raw view are unchanged — only the "nice" render-time
projection changes. The coalescing logic (`appendTranscriptEntry`) is
pre-existing and already covered by tests. No schema, migration, or
behavioral change outside transcript rendering.

## Model Used

- **Claude Opus 4.8** (Anthropic), extended/high reasoning mode, driven
via the Cursor agent with tool use + code execution. Diagnosis and
fixtures grounded in the repo's actual parser/render code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (none found)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub references)
- [x] My branch name describes the change
(`fix/cursor-transcript-coalescing`) and contains no internal Paperclip
ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes (N/A —
no documented behavior changes)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending CI run)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending review)
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Sebastian Heyneman <sebastian@joinnova.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 15:20:24 -05:00

413 lines
13 KiB
TypeScript

import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { normalizeCursorStreamLine } from "../shared/stream.js";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function stringifyUnknown(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/** Max chars of stdout/stderr to show in run log for shell tool results. */
const SHELL_OUTPUT_TRUNCATE = 2000;
/**
* Format shell tool result for run log: exit code + stdout/stderr (truncated).
* If the result is not a shell-shaped object, returns full stringify.
*/
function formatShellToolResultForLog(result: unknown): string {
const obj = asRecord(result);
if (!obj) return stringifyUnknown(result);
const success = asRecord(obj.success);
if (!success) return stringifyUnknown(result);
const exitCode = asNumber(success.exitCode, NaN);
const stdout = asString(success.stdout).trim();
const stderr = asString(success.stderr).trim();
const hasShellShape = Number.isFinite(exitCode) || stdout.length > 0 || stderr.length > 0;
if (!hasShellShape) return stringifyUnknown(result);
const lines: string[] = [];
if (Number.isFinite(exitCode)) lines.push(`exit ${exitCode}`);
if (stdout) {
const out = stdout.length > SHELL_OUTPUT_TRUNCATE ? stdout.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stdout;
lines.push("<stdout>");
lines.push(out);
}
if (stderr) {
const err = stderr.length > SHELL_OUTPUT_TRUNCATE ? stderr.slice(0, SHELL_OUTPUT_TRUNCATE) + "\n... (truncated)" : stderr;
lines.push("<stderr>");
lines.push(err);
}
return lines.join("\n");
}
/** Return compact input for run log when tool is shell/shellToolCall (command only). */
function compactShellToolInput(rawInput: unknown, payload?: Record<string, unknown>): unknown {
const cmd = asString(payload?.command ?? asRecord(rawInput)?.command);
if (cmd) return { command: cmd };
return rawInput;
}
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();
return text ? [{ kind: "assistant", ts, text }] : [];
}
const message = asRecord(messageRaw);
if (!message) return [];
const entries: TranscriptEntry[] = [];
const directText = asString(message.text).trim();
if (directText) {
entries.push({ kind: "assistant", 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") {
const text = asString(part.text).trim();
if (text) entries.push({ kind: "assistant", ts, text });
continue;
}
if (type === "thinking") {
const text = asString(part.text).trim();
if (text) entries.push({ kind: "thinking", ts, text });
continue;
}
if (type === "tool_call") {
const name = asString(part.name, asString(part.tool, "tool"));
const rawInput = part.input ?? part.arguments ?? part.args ?? {};
const input =
name === "shellToolCall" || name === "shell"
? compactShellToolInput(rawInput, asRecord(rawInput) ?? undefined)
: rawInput;
entries.push({
kind: "tool_call",
ts,
name,
toolUseId:
asString(part.tool_use_id) ||
asString(part.toolUseId) ||
asString(part.call_id) ||
asString(part.id) ||
undefined,
input,
});
continue;
}
if (type === "tool_result") {
const toolUseId =
asString(part.tool_use_id) ||
asString(part.toolUseId) ||
asString(part.call_id) ||
asString(part.id) ||
"tool_result";
const rawOutput = part.output ?? part.result ?? part.text;
const contentText =
typeof rawOutput === "object" && rawOutput !== null
? formatShellToolResultForLog(rawOutput)
: asString(rawOutput) || stringifyUnknown(rawOutput);
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
entries.push({
kind: "tool_result",
ts,
toolUseId,
content: contentText,
isError,
});
}
}
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 rawInput = payload.args ?? asRecord(payload.function)?.arguments ?? payload;
const isShellTool = toolName === "shellToolCall" || toolName === "shell";
const input = isShellTool ? compactShellToolInput(rawInput, payload) : rawInput;
if (subtype === "started" || subtype === "start") {
return [{
kind: "tool_call",
ts,
name: toolName,
toolUseId: callId,
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;
const content =
result !== undefined
? isShellTool
? formatShellToolResultForLog(result)
: stringifyUnknown(result)
: `${toolName} completed`;
return [{
kind: "tool_result",
ts,
toolUseId: callId,
content,
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 [];
const parsed = asRecord(safeJsonParse(normalized.line));
if (!parsed) {
return [{ kind: "stdout", ts, text: normalized.line }];
}
const type = asString(parsed.type);
if (type === "system") {
const subtype = asString(parsed.subtype);
if (subtype === "init") {
const sessionId =
asString(parsed.session_id) ||
asString(parsed.sessionId) ||
asString(parsed.sessionID);
return [{ kind: "init", ts, model: asString(parsed.model, "cursor"), sessionId }];
}
return [{ kind: "system", ts, text: subtype ? `system: ${subtype}` : "system" }];
}
if (type === "assistant") {
const entries = parseAssistantMessage(parsed.message, ts);
return entries.length > 0 ? entries : [{ kind: "assistant", ts, text: asString(parsed.result) }];
}
if (type === "user") {
return parseUserMessage(parsed.message, ts);
}
if (type === "thinking") {
const textFromTopLevel = asString(parsed.text);
const textFromDelta = asString(asRecord(parsed.delta)?.text);
const text = textFromTopLevel.length > 0 ? textFromTopLevel : textFromDelta;
const subtype = asString(parsed.subtype).trim().toLowerCase();
const isDelta = subtype === "delta" || asRecord(parsed.delta) !== null;
if (!text.trim()) return [];
return [{ kind: "thinking", ts, text: isDelta ? text : text.trim(), ...(isDelta ? { delta: true } : {}) }];
}
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));
const outputTokens = asNumber(usage?.output_tokens, asNumber(usage?.outputTokens));
const cachedTokens = asNumber(
usage?.cached_input_tokens,
asNumber(usage?.cachedInputTokens, asNumber(usage?.cache_read_input_tokens)),
);
const subtype = asString(parsed.subtype, "result");
const errors = Array.isArray(parsed.errors)
? parsed.errors.map((value) => stringifyUnknown(value)).filter(Boolean)
: [];
const errorText = asString(parsed.error).trim();
if (errorText) errors.push(errorText);
const isError = parsed.is_error === true || subtype === "error" || subtype === "failed";
return [{
kind: "result",
ts,
text: asString(parsed.result),
inputTokens,
outputTokens,
cachedTokens,
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
subtype,
isError,
errors,
}];
}
if (type === "error") {
const message = asString(parsed.message) || stringifyUnknown(parsed.error ?? parsed.detail) || normalized.line;
return [{ kind: "stderr", ts, text: message }];
}
// Compatibility with older stream-json event shapes.
if (type === "step_start") {
const sessionId = asString(parsed.sessionID);
return [{ kind: "system", ts, text: `step started${sessionId ? ` (${sessionId})` : ""}` }];
}
if (type === "text") {
// Streamed assistant text arrives as many small `text` events (often a token
// or word each). Tag them as deltas — without trimming, so inter-token
// whitespace is preserved — and let the render-time coalescer
// (appendTranscriptEntry) merge consecutive deltas into one assistant block.
// A tool_call/tool_result between deltas correctly breaks the run.
const part = asRecord(parsed.part);
const text = asString(part?.text);
if (!text) return [];
return [{ kind: "assistant", ts, text, delta: true }];
}
if (type === "tool_use") {
const part = asRecord(parsed.part);
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
const toolName = asString(part?.tool, "tool");
const state = asRecord(part?.state);
const input = state?.input ?? {};
const output = asString(state?.output).trim();
const status = asString(state?.status).trim();
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
const isError =
status === "failed" ||
status === "error" ||
status === "cancelled" ||
(Number.isFinite(exitCode) && exitCode !== 0);
const entries: TranscriptEntry[] = [
{
kind: "tool_call",
ts,
name: toolName,
input,
},
];
if (status || output) {
const lines: string[] = [];
if (status) lines.push(`status: ${status}`);
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
if (output) {
if (lines.length > 0) lines.push("");
lines.push(output);
}
entries.push({
kind: "tool_result",
ts,
toolUseId,
content: lines.join("\n").trim() || "tool completed",
isError,
});
}
return entries;
}
if (type === "step_finish") {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const reason = asString(part?.reason);
return [{
kind: "result",
ts,
text: reason,
inputTokens: asNumber(tokens?.input),
outputTokens: asNumber(tokens?.output),
cachedTokens: asNumber(cache?.read),
costUsd: asNumber(part?.cost),
subtype: reason || "step_finish",
isError: reason === "error" || reason === "failed",
errors: [],
}];
}
return [{ kind: "stdout", ts, text: normalized.line }];
}