From cfedc0efd06177c0d731ccbc0f1a1d8387cc9da6 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 21:02:33 +0000 Subject: [PATCH 1/2] fix(cursor): synthesize toolCall blocks for exec-channel native tools Cursor's provider only pushed toolCall content blocks for MCP and todo in processInteractionUpdate.toolCallStarted. Native tools (bash, read, write, grep, ls, delete, lsp) execute via the exec channel and produced no toolCall blocks, so persisted assistant messages contained only text. On replay, renderSessionContext could not pair the subsequent toolResult messages with any toolCall block and fell through to addMessageToChat (a no-op for toolResult), causing header-less \`\\u23ce\` output beneath the last assistant text. - packages/ai/src/providers/cursor.ts: add synthesizeCursorExecToolCall and inject it at the top of each native exec case in handleExecServerMessage, using the coding-agent bridge's mapped tool name and args so live event and rebuild render identically. Normalize args.toolCallId before invoking the handler so provider block id and bridge result id always match. - packages/agent/src/agent.ts: drop the text-length split in #emitCursorSplitAssistantMessage. With toolCall blocks now at their correct positions in content, emit the assistant message as-is followed by buffered toolResults; the split's preambleText-per-text copy also silently duplicated text on multi-block turns. - packages/ai/test/cursor-streaming-args.test.ts + new packages/coding-agent/test/issue-4348-repro.test.ts: guard block ordering, event sequence, and rebuild pairing behavior. Fixes #4348 --- packages/agent/CHANGELOG.md | 4 + packages/agent/src/agent.ts | 124 ++-------- packages/ai/CHANGELOG.md | 4 + packages/ai/src/providers/cursor.ts | 94 +++++++- .../ai/test/cursor-streaming-args.test.ts | 79 +++++++ .../test/issue-4348-repro.test.ts | 211 ++++++++++++++++++ 6 files changed, 411 insertions(+), 105 deletions(-) create mode 100644 packages/coding-agent/test/issue-4348-repro.test.ts diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index f90170f35..8421665cf 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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 ### Added diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index ee1ed0ad7..79742fe34 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; @@ -1343,115 +1341,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 fd83d6aad..d8a2209e9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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 ### Changed 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..fbcf3b9f0 --- /dev/null +++ b/packages/coding-agent/test/issue-4348-repro.test.ts @@ -0,0 +1,211 @@ +/** + * 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"); + }); +}); From f26006737e7d3b7671a1bb60d0f2b9e758dbf773 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 2 Jul 2026 21:02:46 +0000 Subject: [PATCH 2/2] style: bun run fix --- packages/coding-agent/test/issue-4348-repro.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/test/issue-4348-repro.test.ts b/packages/coding-agent/test/issue-4348-repro.test.ts index fbcf3b9f0..f99554c0a 100644 --- a/packages/coding-agent/test/issue-4348-repro.test.ts +++ b/packages/coding-agent/test/issue-4348-repro.test.ts @@ -93,8 +93,10 @@ function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContex }, addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => helpers.addMessageToChat(message, options), - renderSessionContext: (context: SessionContext, options?: { updateFooter?: boolean; populateHistory?: boolean }) => - helpers.renderSessionContext(context, options), + renderSessionContext: ( + context: SessionContext, + options?: { updateFooter?: boolean; populateHistory?: boolean }, + ) => helpers.renderSessionContext(context, options), showStatus: vi.fn(), } as unknown as InteractiveModeContext; helpers = new UiHelpers(ctx); @@ -112,7 +114,12 @@ function cursorTurn(): AgentMessage[] { 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 } }, + { + type: "toolCall", + id: "tc-bash", + name: "bash", + arguments: { command: "ls -1", cwd: undefined, timeout: undefined }, + }, ], api: "cursor-agent", provider: "cursor",