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>
This commit is contained in:
seb-veto 2026-06-26 13:20:24 -07:00 committed by GitHub
parent a329199e99
commit ef1422c23e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 85 additions and 2 deletions

View File

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

View File

@ -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<typeof buildTranscript>) {
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);
});
});