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:
parent
a329199e99
commit
ef1422c23e
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue