mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
## 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>
413 lines
13 KiB
TypeScript
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 }];
|
|
}
|