From ef1422c23ece2c36e255530b145c571dfd42fe81 Mon Sep 17 00:00:00 2001 From: seb-veto Date: Fri, 26 Jun 2026 13:20:24 -0700 Subject: [PATCH] fix(cursor): coalesce streamed assistant text into prose blocks (#8544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Cursor --- .../cursor-local/src/ui/parse-stdout.ts | 9 ++- ui/src/adapters/cursor-coalescing.test.ts | 78 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 ui/src/adapters/cursor-coalescing.test.ts diff --git a/packages/adapters/cursor-local/src/ui/parse-stdout.ts b/packages/adapters/cursor-local/src/ui/parse-stdout.ts index 43e56d55e6..0ba02307cd 100644 --- a/packages/adapters/cursor-local/src/ui/parse-stdout.ts +++ b/packages/adapters/cursor-local/src/ui/parse-stdout.ts @@ -334,10 +334,15 @@ export function parseCursorStdoutLine(line: string, ts: string): TranscriptEntry } 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).trim(); + const text = asString(part?.text); if (!text) return []; - return [{ kind: "assistant", ts, text }]; + return [{ kind: "assistant", ts, text, delta: true }]; } if (type === "tool_use") { diff --git a/ui/src/adapters/cursor-coalescing.test.ts b/ui/src/adapters/cursor-coalescing.test.ts new file mode 100644 index 0000000000..1181f67ac1 --- /dev/null +++ b/ui/src/adapters/cursor-coalescing.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { parseCursorStdoutLine } from "@paperclipai/adapter-cursor-local/ui"; +import { parseCursorCloudStdoutLine } from "@paperclipai/adapter-cursor-cloud/ui"; +import { buildTranscript, type RunLogChunk } from "./transcript"; + +const ts = "2026-06-23T12:00:00.000Z"; + +function lines(...jsonLines: unknown[]): RunLogChunk[] { + return [{ ts, stream: "stdout", chunk: jsonLines.map((l) => JSON.stringify(l)).join("\n") + "\n" }]; +} + +// The canonical "nice view" shape both adapters must produce for the run: +// assistant text -> tool_call -> tool_result -> assistant text (+ a separate result footer) +// i.e. exactly two prose blocks with the tool between them, and zero duplication +// of the streamed text into separate per-token bubbles or a re-rendered final. +function assertCanonicalShape(entries: ReturnType) { + const assistantTexts = entries.filter((e) => e.kind === "assistant").map((e) => (e as { text: string }).text); + expect(assistantTexts).toEqual(["Hello world", "Done"]); + + expect(entries.filter((e) => e.kind === "tool_call")).toHaveLength(1); + expect(entries.filter((e) => e.kind === "tool_result")).toHaveLength(1); + + const runShape = entries + .filter((e) => e.kind === "assistant" || e.kind === "tool_call" || e.kind === "tool_result") + .map((e) => e.kind); + expect(runShape).toEqual(["assistant", "tool_call", "tool_result", "assistant"]); +} + +describe("cursor transcript coalescing (render-time projection)", () => { + it("cursor local: coalesces streamed text deltas into prose blocks, preserves tool boundary", () => { + // Six streamed `text` events (token/word granularity) around one tool call. + const chunks = lines( + { type: "text", part: { text: "Hello" } }, + { type: "text", part: { text: " world" } }, + { type: "tool_call", subtype: "started", call_id: "c1", tool_call: { shellToolCall: { args: { command: "ls" } } } }, + { + type: "tool_call", + subtype: "completed", + call_id: "c1", + tool_call: { shellToolCall: { result: { success: { exitCode: 0, stdout: "file.txt" } } } }, + }, + { type: "text", part: { text: "Done" } }, + { type: "result", subtype: "success", result: "Hello world\nDone", usage: { input_tokens: 1, output_tokens: 2 } }, + ); + + const entries = buildTranscript(chunks, parseCursorStdoutLine); + assertCanonicalShape(entries); + + // The consolidated final must not be re-rendered as a third prose block. + expect(entries.some((e) => e.kind === "result")).toBe(true); + expect(entries.filter((e) => e.kind === "assistant")).toHaveLength(2); + }); + + it("cursor local: preserves inter-token whitespace when coalescing deltas", () => { + const chunks = lines( + { type: "text", part: { text: "foo" } }, + { type: "text", part: { text: " bar" } }, + { type: "text", part: { text: " baz" } }, + ); + const entries = buildTranscript(chunks, parseCursorStdoutLine); + const assistantTexts = entries.filter((e) => e.kind === "assistant").map((e) => (e as { text: string }).text); + expect(assistantTexts).toEqual(["foo bar baz"]); + }); + + it("cursor cloud: SDK messages render as prose blocks with tool boundary, no duplication", () => { + const chunks = lines( + { type: "cursor_cloud.message", message: { type: "assistant", message: { content: [{ type: "text", text: "Hello world" }] } } }, + { type: "cursor_cloud.message", message: { type: "tool_call", id: "c1", name: "bash", status: "running", args: { command: "ls" } } }, + { type: "cursor_cloud.message", message: { type: "tool_call", id: "c1", name: "bash", status: "completed", result: { stdout: "file.txt" } } }, + { type: "cursor_cloud.message", message: { type: "assistant", message: { content: [{ type: "text", text: "Done" }] } } }, + { type: "cursor_cloud.result", status: "finished", result: "Hello world\nDone" }, + ); + + const entries = buildTranscript(chunks, parseCursorCloudStdoutLine); + assertCanonicalShape(entries); + expect(entries.some((e) => e.kind === "result")).toBe(true); + }); +});