diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 7ad17140a..9bf241470 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -5,6 +5,9 @@ ### Changed - Support dynamic model resolution to enable seamless mid-run model switching +### Fixed + +- Fixed cursor-agent assistant messages containing native tool calls being split on text length and duplicating text blocks on replay, by emitting the assistant message as-is followed by buffered tool results and pairing them by `toolCallId` in the transcript rebuild ([#4348](https://github.com/can1357/oh-my-pi/issues/4348)). ## [16.3.0] - 2026-07-02 diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ffd2f0d94..2e2b6e872 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -320,10 +320,9 @@ export interface AgentPromptOptions { toolChoice?: ToolChoice; } -/** Buffered Cursor tool result with text position at time of call */ +/** Buffered Cursor exec-channel tool result waiting to be emitted after the assistant message. */ interface CursorToolResultEntry { toolResult: ToolResultMessage; - textLengthAtCall: number; } export class Agent { @@ -1097,12 +1096,11 @@ export class Agent { } } catch {} } - // Buffer tool result with current text length for correct ordering later. - // Cursor executes tools server-side during streaming, so the assistant message - // already incorporates results. We buffer here and emit in correct order - // when the assistant message ends. - const textLength = this.#getAssistantTextLength(this.#state.streamMessage); - this.#cursorToolResultBuffer.push({ toolResult: finalMessage, textLengthAtCall: textLength }); + // Cursor executes tools server-side during streaming. We buffer + // each toolResult and emit them right after the assistant message + // closes (see `#emitCursorSplitAssistantMessage`), so replay + // receives (assistant with interleaved toolCall blocks) → results. + this.#cursorToolResultBuffer.push({ toolResult: finalMessage }); return finalMessage; } : undefined; @@ -1344,115 +1342,33 @@ export class Agent { } } - /** Calculate total text length from an assistant message's content blocks */ - #getAssistantTextLength(message: AgentMessage | null): number { - if (message?.role !== "assistant" || !Array.isArray(message.content)) { - return 0; - } - let length = 0; - for (const block of message.content) { - if (block.type === "text") { - length += (block as TextContent).text.length; - } - } - return length; - } - /** - * Emit a Cursor assistant message split around tool results. - * This fixes the ordering issue where tool results appear after the full explanation. + * Emit a Cursor assistant message with buffered exec-channel toolResults. * - * Output order: Assistant(preamble) -> ToolResults -> Assistant(continuation) + * Since the Cursor provider now synthesizes `toolCall` content blocks at the + * point each exec tool starts (issue #4348), the assistant message content + * already interleaves text/thinking with toolCall blocks in execution order. + * We emit the message as-is and let the buffered toolResults follow — the + * transcript rebuild in `renderSessionContext` pairs them by `toolCallId`. + * + * Historical note: this used to split the assistant message at + * `textLengthAtCall` to interpose toolResults between preamble and + * continuation. That workaround existed because native cursor tools had no + * toolCall blocks; it also copied `preambleText` into every text block on + * multi-text turns, producing duplicated text on replay. */ #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void { const buffer = this.#cursorToolResultBuffer; this.#cursorToolResultBuffer = []; - if (buffer.length === 0) { - // No tool results, emit normally - this.#state.streamMessage = null; - this.appendMessage(assistantMessage); - this.#emit({ type: "message_end", message: assistantMessage }); - return; - } - - // Find the split point: minimum text length at first tool call - const splitPoint = Math.min(...buffer.map(r => r.textLengthAtCall)); - - // Extract text content from assistant message - const content = assistantMessage.content; - let fullText = ""; - for (const block of content) { - if (block.type === "text") { - fullText += block.text; - } - } - - // If no text or split point is 0 or at/past end, don't split - if (fullText.length === 0 || splitPoint <= 0 || splitPoint >= fullText.length) { - // Emit assistant message first, then tool results (original behavior but with buffered results) - this.#state.streamMessage = null; - this.appendMessage(assistantMessage); - this.#emit({ type: "message_end", message: assistantMessage }); - - // Emit buffered tool results - for (const { toolResult } of buffer) { - this.#emit({ type: "message_start", message: toolResult }); - this.appendMessage(toolResult); - this.#emit({ type: "message_end", message: toolResult }); - } - return; - } - - // Split the text - const preambleText = fullText.slice(0, splitPoint); - const continuationText = fullText.slice(splitPoint); - - // Create preamble message (text before tools) - const preambleContent = content.map(block => { - if (block.type === "text") { - return { ...block, text: preambleText }; - } - return block; - }); - const preambleMessage: AssistantMessage = { - ...assistantMessage, - content: preambleContent, - }; - - // Emit preamble this.#state.streamMessage = null; - this.appendMessage(preambleMessage); - this.#emit({ type: "message_end", message: preambleMessage }); + this.appendMessage(assistantMessage); + this.#emit({ type: "message_end", message: assistantMessage }); - // Emit buffered tool results for (const { toolResult } of buffer) { this.#emit({ type: "message_start", message: toolResult }); this.appendMessage(toolResult); this.#emit({ type: "message_end", message: toolResult }); } - - // Emit continuation message (text after tools) if non-empty - const trimmedContinuation = continuationText.trim(); - if (trimmedContinuation.length > 0) { - // Create continuation message with only text content (no thinking/toolCalls) - const continuationContent: TextContent[] = [{ type: "text", text: continuationText }]; - const continuationMessage: AssistantMessage = { - ...assistantMessage, - content: continuationContent, - // Zero out usage for continuation since it's part of same response - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }; - this.#emit({ type: "message_start", message: continuationMessage }); - this.appendMessage(continuationMessage); - this.#emit({ type: "message_end", message: continuationMessage }); - } } } diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 64d6a3d9a..df2e47460 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -14,6 +14,9 @@ - Fixed Anthropic OAuth usage reporting to stop retrying on 429 rate-limit errors - Fixed usage cache to correctly persist null values during cold-start failure backoff windows +### Fixed + +- Fixed cursor-agent persisted transcripts losing tool-call structure by synthesizing `toolCall` content blocks for exec-channel native tools (`bash`/`read`/`write`/`grep`/`ls`/`delete`/`lsp`), so replay pairs each tool result with its call instead of rendering header-less tool output beneath the last assistant text ([#4348](https://github.com/can1357/oh-my-pi/issues/4348)). ## [16.3.1] - 2026-07-02 diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index fe39434fd..88e9ca1cf 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -628,7 +628,7 @@ export type ToolCallState = ToolCall & { [kStreamingBlockIndex]: number; [kStreamingPartialJson]?: string; [kStreamingLastParseLen]?: number; - [kStreamingBlockKind]: "mcp" | "todo"; + [kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec"; }; export interface BlockState { @@ -674,6 +674,9 @@ async function handleServerMessage( execHandlers, onToolResult, requestContextTools, + output, + stream, + state, ); } else if (msgCase === "conversationCheckpointUpdate") { handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint); @@ -1030,6 +1033,9 @@ async function handleExecServerMessage( execHandlers: CursorExecHandlers | undefined, onToolResult: CursorToolResultHandler | undefined, requestContextTools: McpToolDefinition[], + output: AssistantMessage, + stream: AssistantMessageEventStream, + state: BlockState, ): Promise { const execCase = execMsg.message.case; log("exec", "dispatch", { execCase, execId: execMsg.execId, hasHandlers: !!execHandlers }); @@ -1064,6 +1070,8 @@ async function handleExecServerMessage( switch (execCase) { case "readArgs": { const args = execMsg.message.value; + if (!args.toolCallId) args.toolCallId = crypto.randomUUID(); + synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path }); const { execResult } = await resolveExecHandler( args, execHandlers?.read?.bind(execHandlers), @@ -1077,6 +1085,11 @@ async function handleExecServerMessage( } case "lsArgs": { const args = execMsg.message.value; + if (!args.toolCallId) args.toolCallId = crypto.randomUUID(); + // Bridge maps `ls` onto the coding-agent `read` tool (see + // `CursorExecHandlers.ls` in `pi-coding-agent/src/cursor.ts`); mirror + // that here so the synthesized block matches the toolResult's `toolName`. + synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path }); const { execResult } = await resolveExecHandler( args, execHandlers?.ls?.bind(execHandlers), @@ -1090,6 +1103,16 @@ async function handleExecServerMessage( } case "grepArgs": { const args = execMsg.message.value; + if (!args.toolCallId) args.toolCallId = crypto.randomUUID(); + // Mirror the coding-agent bridge's arg mapping so live UI (from + // `tool_execution_start`) and rebuilt transcript (from this block) + // display identical args. + const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || "."; + synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "grep", { + pattern: args.pattern, + path: searchPath, + case: args.caseInsensitive === true ? false : undefined, + }); const { execResult } = await resolveExecHandler( args, execHandlers?.grep?.bind(execHandlers), @@ -1103,6 +1126,13 @@ async function handleExecServerMessage( } case "writeArgs": { const args = execMsg.message.value; + if (!args.toolCallId) args.toolCallId = crypto.randomUUID(); + // Match the bridge: prefer `fileText`, fall back to decoded `fileBytes`. + const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array()); + synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "write", { + path: args.path, + content, + }); const { execResult } = await resolveExecHandler( args, execHandlers?.write?.bind(execHandlers), @@ -1125,6 +1155,8 @@ async function handleExecServerMessage( } case "deleteArgs": { const args = execMsg.message.value; + if (!args.toolCallId) args.toolCallId = crypto.randomUUID(); + synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "delete", { path: args.path }); const { execResult } = await resolveExecHandler( args, execHandlers?.delete?.bind(execHandlers), @@ -1138,7 +1170,16 @@ async function handleExecServerMessage( } case "shellArgs": { const args = execMsg.message.value; + if (!args.toolCallId) args.toolCallId = crypto.randomUUID(); const normalizedArgs: ShellArgs = { ...args, workingDirectory: args.workingDirectory || process.cwd() }; + // Match the bridge (`CursorExecHandlers.shell`): map `workingDirectory` + // → `cwd`, drop non-positive timeouts. + const shellTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined; + synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", { + command: args.command, + cwd: args.workingDirectory || undefined, + timeout: shellTimeout, + }); const { execResult } = await resolveExecHandler( args, execHandlers?.shell?.bind(execHandlers), @@ -1153,6 +1194,13 @@ async function handleExecServerMessage( } case "shellStreamArgs": { const args = execMsg.message.value; + if (!args.toolCallId) args.toolCallId = crypto.randomUUID(); + const shellStreamTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined; + synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", { + command: args.command, + cwd: args.workingDirectory || undefined, + timeout: shellStreamTimeout, + }); await handleShellStreamArgs(args, execMsg, h2Request, execHandlers, onToolResult); return; } @@ -1200,6 +1248,13 @@ async function handleExecServerMessage( } case "diagnosticsArgs": { const args = execMsg.message.value; + if (!args.toolCallId) args.toolCallId = crypto.randomUUID(); + // Bridge maps `diagnostics` onto the coding-agent `lsp` tool with + // `action: "diagnostics"` and `file: path`. + synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "lsp", { + action: "diagnostics", + file: args.path, + }); const { execResult } = await resolveExecHandler( args, execHandlers?.diagnostics?.bind(execHandlers), @@ -2016,6 +2071,43 @@ function endCurrentThinkingBlock( state.setThinkingBlock(null); } +/** + * Synthesize a completed `toolCall` content block for a Cursor exec-channel + * native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`). + * + * Args arrive complete on the exec message, so the block opens and closes in + * one step — no partial-JSON streaming path. Without this the persisted + * assistant message carries only text/thinking blocks, and on replay the + * following `toolResult` messages have no matching `toolCall.id` in + * `renderSessionContext`, so they render as header-less `⎿` lines beneath the + * last text block instead of proper tool components (issue #4348). + * + * Exported for tests to exercise ordering with adjacent text/thinking blocks. + */ +export function synthesizeCursorExecToolCall( + output: AssistantMessage, + stream: AssistantMessageEventStream, + state: BlockState, + toolCallId: string, + toolName: string, + args: Record, +): void { + endCurrentTextBlock(output, stream, state); + endCurrentThinkingBlock(output, stream, state); + const block: ToolCallState = { + type: "toolCall", + id: toolCallId, + name: toolName, + arguments: args, + [kStreamingBlockIndex]: output.content.length, + [kStreamingBlockKind]: "cursor-exec", + }; + output.content.push(block); + const idx = output.content.length - 1; + stream.push({ type: "toolcall_start", contentIndex: idx, partial: output }); + stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: output }); +} + /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */ export function processInteractionUpdate( update: any, diff --git a/packages/ai/test/cursor-streaming-args.test.ts b/packages/ai/test/cursor-streaming-args.test.ts index 619e58e7f..df81beadc 100644 --- a/packages/ai/test/cursor-streaming-args.test.ts +++ b/packages/ai/test/cursor-streaming-args.test.ts @@ -3,6 +3,7 @@ import { type BlockState, mergeCursorMcpToolCallArgs, processInteractionUpdate, + synthesizeCursorExecToolCall, type ToolCallState, type UsageState, } from "@oh-my-pi/pi-ai/providers/cursor"; @@ -324,3 +325,81 @@ describe("processInteractionUpdate args_text_delta handling", () => { }); }); }); + +describe("synthesizeCursorExecToolCall (issue #4348)", () => { + it("closes preceding text/thinking blocks before opening the synthesized toolCall", () => { + const h = newHarness(); + + pushTextDelta(h, "reading "); + synthesizeCursorExecToolCall(h.output, h.stream, h.state, "call-read", "read", { path: "src/foo.ts" }); + + expect(h.output.content.map(b => b.type)).toEqual(["text", "toolCall"]); + expect(h.output.content[0]).toMatchObject({ type: "text", text: "reading " }); + expect(h.output.content[1]).toMatchObject({ + type: "toolCall", + id: "call-read", + name: "read", + arguments: { path: "src/foo.ts" }, + }); + // text_end fires before toolcall_start so the preceding text block finalizes; + // toolcall_end fires immediately after — exec-channel args arrive complete, + // so no partial-JSON streaming is needed for the synthesized block. + expect(h.captured.map(e => e.type)).toEqual([ + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_end", + ]); + expect(h.state.currentTextBlock).toBeNull(); + expect(h.state.currentToolCall).toBeNull(); + }); + + it("preserves interleaving order across text ↔ tool ↔ text", () => { + const h = newHarness(); + + pushTextDelta(h, "planning "); + synthesizeCursorExecToolCall(h.output, h.stream, h.state, "t1", "read", { path: "a.txt" }); + pushTextDelta(h, "then "); + synthesizeCursorExecToolCall(h.output, h.stream, h.state, "t2", "bash", { + command: "echo hi", + cwd: undefined, + timeout: undefined, + }); + pushTextDelta(h, "done"); + + expect(h.output.content.map(b => b.type)).toEqual(["text", "toolCall", "text", "toolCall", "text"]); + const [t1, tc1, t2, tc2, t3] = h.output.content; + expect(t1).toMatchObject({ type: "text", text: "planning " }); + expect(tc1).toMatchObject({ type: "toolCall", id: "t1", name: "read" }); + expect(t2).toMatchObject({ type: "text", text: "then " }); + expect(tc2).toMatchObject({ + type: "toolCall", + id: "t2", + name: "bash", + arguments: { command: "echo hi", cwd: undefined, timeout: undefined }, + }); + expect(t3).toMatchObject({ type: "text", text: "done" }); + }); + + it("emits toolcall events at the exact index the block occupies in content", () => { + const h = newHarness(); + + pushTextDelta(h, "pre"); + synthesizeCursorExecToolCall(h.output, h.stream, h.state, "call-1", "grep", { + pattern: "foo", + path: ".", + case: undefined, + }); + + const toolStart = h.captured.find(e => e.type === "toolcall_start"); + const toolEnd = h.captured.find(e => e.type === "toolcall_end"); + // Text block sits at index 0; synthesized toolCall at index 1. + expect(toolStart).toMatchObject({ type: "toolcall_start", contentIndex: 1 }); + expect(toolEnd).toMatchObject({ + type: "toolcall_end", + contentIndex: 1, + toolCall: { id: "call-1", name: "grep" }, + }); + }); +}); diff --git a/packages/coding-agent/test/issue-4348-repro.test.ts b/packages/coding-agent/test/issue-4348-repro.test.ts new file mode 100644 index 000000000..f99554c0a --- /dev/null +++ b/packages/coding-agent/test/issue-4348-repro.test.ts @@ -0,0 +1,218 @@ +/** + * Regression for issue #4348: cursor-agent persisted transcripts lose tool-call + * structure, so replay renders header-less tool results. + * + * Before the fix, the Cursor provider only synthesized `toolCall` content + * blocks for `mcpToolCall` and `updateTodosToolCall`. Native exec-channel tools + * (`bash`/`read`/`write`/`grep`/`ls`/`delete`/`lsp`) executed via the bridge + * and never appeared in `AssistantMessage.content`. `renderSessionContext` + * then had no matching toolCall block for each `toolResult` message, and the + * results fell through to `addMessageToChat`, rendering as bare `⎿` lines + * beneath the last assistant text. + * + * The fix (in `packages/ai/src/providers/cursor.ts` `handleExecServerMessage`) + * synthesizes a `toolCall` block on the exec channel using the same tool name + * and args the bridge emits via `tool_execution_start`. This test asserts the + * post-fix persisted shape rebuilds into proper `ToolExecutionComponent`s + * that own their tool results, not into orphan `⎿` toolResult lines. + */ + +import { beforeAll, describe, expect, it, vi } from "bun:test"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; +import type { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; +import { Container } from "@oh-my-pi/pi-tui"; + +beforeAll(() => { + initTheme(); +}); + +const emptyUsage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +function transcriptWith(messages: AgentMessage[]): SessionContext { + return { + messages, + thinkingLevel: "off", + serviceTier: undefined, + models: {}, + injectedTtsrRules: [], + selectedMCPToolNames: [], + hasPersistedMCPToolSelection: false, + mode: "none", + }; +} + +function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContext; chatContainer: Container } { + const chatContainer = new Container(); + let helpers: UiHelpers; + const ctx = { + chatContainer, + pendingMessagesContainer: new Container(), + pendingBashComponents: [], + pendingPythonComponents: [], + pendingTools: new Map(), + statusLine: { invalidate: vi.fn() }, + updateEditorBorderColor: vi.fn(), + updateEditorTopBorder: vi.fn(), + ui: { requestRender: vi.fn(), imageBudget: undefined }, + resetTranscript: () => chatContainer.clear(), + settings: { get: () => false }, + toolOutputExpanded: false, + hideThinkingBlock: false, + focusedAgentId: undefined, + editor: { addToHistory: vi.fn() }, + viewSession: { + buildTranscriptSessionContext: () => transcript, + getToolByName: () => undefined, + extensionRunner: undefined, + sessionManager: { + getEntries: vi.fn(() => []), + getCwd: vi.fn(() => "/tmp"), + }, + }, + sessionManager: { + getEntries: vi.fn(() => []), + getCwd: vi.fn(() => "/tmp"), + putBlobSync: vi.fn(() => ({ + hash: "hash", + path: "/tmp/hash", + displayPath: "/tmp/hash.png", + ref: "blob:sha256:hash", + })), + }, + addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => + helpers.addMessageToChat(message, options), + renderSessionContext: ( + context: SessionContext, + options?: { updateFooter?: boolean; populateHistory?: boolean }, + ) => helpers.renderSessionContext(context, options), + showStatus: vi.fn(), + } as unknown as InteractiveModeContext; + helpers = new UiHelpers(ctx); + return { ctx, chatContainer }; +} + +/** Build the cursor-shaped assistant + toolResults message set for one turn. */ +function cursorTurn(): AgentMessage[] { + // After the fix, the Cursor provider synthesizes `toolCall` blocks with the + // bridge's mapped tool names ("bash"/"read"). The stopReason for cursor + // turns with tool results is "toolUse" mid-turn — this matches how the + // agent-loop finalizes cursor exec turns. + const assistant: AssistantMessage = { + role: "assistant", + content: [ + { type: "text", text: "Reading and listing:" }, + { type: "toolCall", id: "tc-read", name: "read", arguments: { path: "src/foo.ts" } }, + { + type: "toolCall", + id: "tc-bash", + name: "bash", + arguments: { command: "ls -1", cwd: undefined, timeout: undefined }, + }, + ], + api: "cursor-agent", + provider: "cursor", + model: "cursor-composer-2.5", + usage: emptyUsage, + stopReason: "toolUse", + timestamp: 1, + }; + return [ + assistant, + { + role: "toolResult", + toolCallId: "tc-read", + toolName: "read", + content: [{ type: "text", text: "READ_RESULT_MARKER content of foo.ts" }], + isError: false, + timestamp: 2, + }, + { + role: "toolResult", + toolCallId: "tc-bash", + toolName: "bash", + content: [{ type: "text", text: "BASH_RESULT_MARKER file1\nfile2" }], + isError: false, + timestamp: 3, + }, + ]; +} + +describe("issue #4348: cursor exec-channel tool results pair with synthesized toolCall blocks on rebuild", () => { + it("renders bash toolResult inside a ToolExecutionComponent, not as an orphan `⎿` line", async () => { + await Settings.init({ inMemory: true }); + const transcript = transcriptWith(cursorTurn()); + const { ctx, chatContainer } = makeRenderCtx(transcript); + + new UiHelpers(ctx).renderInitialMessages(); + + // Component structure: an assistant message, then a bash + // ToolExecutionComponent for the synthesized bash block, then a + // ReadToolGroupComponent for the synthesized read block. Absent the + // synthesis (pre-fix), neither would exist — both results would fall + // through `addMessageToChat` (a no-op for `toolResult`) and vanish. + const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); + expect(rendered).toContain("Reading and listing:"); + // Bash result is fully surfaced inside the ToolExecutionComponent — + // header carries the command, body carries the output. + expect(rendered).toContain("ls -1"); + expect(rendered).toContain("BASH_RESULT_MARKER"); + // Read result flows into the ReadToolGroupComponent. Its file-content + // preview is gated by the `read.toolResultPreview` setting (off in + // this harness), so we assert on the pairing signal: the read call + // appears with its path, only reachable when the toolResult attaches. + expect(rendered).toContain("Read src/foo.ts"); + }); + + it("does not orphan the bash toolResult under the assistant when the toolCall block is missing", async () => { + // Simulates the PRE-fix persisted shape: assistant with only text (no + // toolCall blocks) + a toolResult message. The renderer has nothing to + // pair the result with. This test guards the failure mode so a future + // regression that reverts the synthesis is caught: the rendered output + // notably omits the bash command preview. + await Settings.init({ inMemory: true }); + const preFixAssistant: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "Running command:" }], + api: "cursor-agent", + provider: "cursor", + model: "cursor-composer-2.5", + usage: emptyUsage, + stopReason: "toolUse", + timestamp: 1, + }; + const transcript = transcriptWith([ + preFixAssistant, + { + role: "toolResult", + toolCallId: "tc-orphan", + toolName: "bash", + content: [{ type: "text", text: "ORPHAN_RESULT some output" }], + isError: false, + timestamp: 2, + }, + ]); + const { ctx, chatContainer } = makeRenderCtx(transcript); + + new UiHelpers(ctx).renderInitialMessages(); + + const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); + expect(rendered).toContain("Running command:"); + // Fallback path (`addMessageToChat` case "toolResult") is a no-op, so + // the result content never lands in the transcript at all. That silent + // drop is exactly what the reporter saw in the wild — every native + // cursor tool's output disappeared from replay. + expect(rendered).not.toContain("ORPHAN_RESULT"); + }); +});