From d2997d2025b96da2c14b40e171f4b95e069be593 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 24 Jul 2026 10:12:31 +0000 Subject: [PATCH] fix(cursor): synthesized missing exec mcp tool calls Synthesized resolved MCP tool-call blocks directly from exec frames when Cursor omits the matching interaction block, preserving tool-before-answer transcript order without duplicate execution. Added coverage for final text ordering and late streamed-block deduplication. Fixes #6501 --- packages/ai/CHANGELOG.md | 4 +++ packages/ai/src/providers/cursor.ts | 36 ++++++++++++++----- packages/ai/test/cursor-exec-handlers.test.ts | 20 ++++++++++- .../ai/test/cursor-streaming-args.test.ts | 20 +++++++++++ 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 1f722e5fc..a474b4088 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Cursor exec-channel MCP calls such as `web_search` omitting `toolCall` blocks when no interaction block arrives, which rendered their tool cards below the final assistant answer or dropped them on transcript replay. ([#6501](https://github.com/can1357/oh-my-pi/issues/6501)) + ## [17.1.1] - 2026-07-24 ### Added diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index b1070c609..cc3c25794 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -133,6 +133,7 @@ import type { } from "../types"; import { normalizeSystemPrompts } from "../utils"; import { + type CursorExecResolvedCarrier, clearStreamingPartialJson, kCursorExecResolved, kStreamingBlockIndex, @@ -679,7 +680,7 @@ export interface BlockState { currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null; currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null; currentToolCall: ToolCallState | null; - /** MCP call IDs executed through Cursor's exec channel before their stream block arrives. */ + /** MCP call IDs synthesized from exec frames before their redundant streamed block arrives. */ resolvedMcpToolCallIds: Set; firstTokenTime: number | undefined; setTextBlock: (b: (TextContent & { [kStreamingBlockIndex]: number }) | null) => void; @@ -688,6 +689,10 @@ export interface BlockState { setFirstTokenTime: () => void; } +function markCursorExecResolved(block: CursorExecResolvedCarrier): void { + block[kCursorExecResolved] = true; +} + export interface UsageState { sawTokenDelta: boolean; } @@ -1334,9 +1339,20 @@ async function handleExecServerMessage( const args = execMsg.message.value; const mcpCall = decodeMcpCall(args); if (execHandlers?.mcp) { - if (state.currentToolCall?.id === mcpCall.toolCallId) { - state.currentToolCall[kCursorExecResolved] = true; + const existingBlock = output.content.find( + block => block.type === "toolCall" && block.id === mcpCall.toolCallId, + ); + if (existingBlock) { + markCursorExecResolved(existingBlock); } else { + synthesizeCursorExecToolCall( + output, + stream, + state, + mcpCall.toolCallId, + mcpCall.toolName || mcpCall.name, + mcpCall.args, + ); state.resolvedMcpToolCallIds.add(mcpCall.toolCallId); } } @@ -2169,14 +2185,14 @@ function endCurrentThinkingBlock( /** * Synthesize a completed `toolCall` content block for a Cursor exec-channel - * native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`). + * native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`) + * or for an MCP exec frame whose corresponding interaction block is absent. * * 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). + * `renderSessionContext`, so they render beneath the final answer or disappear. * * The block is stamped with {@link kCursorExecResolved} so the shared * `agent-loop.ts` execution pass skips it — Cursor's server-driven exec @@ -2266,6 +2282,10 @@ export function processInteractionUpdate( if (mcpCall) { const args = mcpCall.args || {}; const id = args.toolCallId || crypto.randomUUID(); + const resolvedByExec = state.resolvedMcpToolCallIds.delete(id); + if (resolvedByExec && output.content.some(block => block.type === "toolCall" && block.id === id)) { + return; + } const block: ToolCallState = { type: "toolCall", id, @@ -2275,8 +2295,8 @@ export function processInteractionUpdate( [kStreamingPartialJson]: "", [kStreamingBlockKind]: "mcp", }; - if (state.resolvedMcpToolCallIds.delete(id)) { - block[kCursorExecResolved] = true; + if (resolvedByExec) { + markCursorExecResolved(block); } output.content.push(block); state.setToolCall(block); diff --git a/packages/ai/test/cursor-exec-handlers.test.ts b/packages/ai/test/cursor-exec-handlers.test.ts index 354f6be05..89cab711a 100644 --- a/packages/ai/test/cursor-exec-handlers.test.ts +++ b/packages/ai/test/cursor-exec-handlers.test.ts @@ -6,6 +6,7 @@ import { buildCursorSystemPromptJsons, emptyGrepPatternRejection, handleServerMessage, + processInteractionUpdate, resolveExecHandler, streamCursor, type ToolCallState, @@ -516,7 +517,7 @@ describe("Cursor exec local-work tracking (issue #4593)", () => { expect(written.length).toBe(1); }); - it("marks an MCP call as resolved before its streamed block arrives", async () => { + it("synthesizes an MCP call when the exec frame precedes its streamed block", async () => { const output = cursorAssistantMessage(); const stream = new AssistantMessageEventStream(); const state = newBlockState(); @@ -534,6 +535,7 @@ describe("Cursor exec local-work tracking (issue #4593)", () => { toolName: "mcp__fixture_report", toolCallId: "call-mcp-1", providerIdentifier: "pi-agent", + args: { query: new TextEncoder().encode(JSON.stringify("latest chess news")) }, }), }, }), @@ -567,6 +569,22 @@ describe("Cursor exec local-work tracking (issue #4593)", () => { [], ); + processInteractionUpdate( + { message: { case: "textDelta", value: { text: "Final synthesized answer" } } }, + output, + stream, + state, + { sawTokenDelta: false }, + ); + + expect(output.content).toHaveLength(2); + expect(output.content[0]).toMatchObject({ + type: "toolCall", + id: "call-mcp-1", + name: "mcp__fixture_report", + arguments: { query: "latest chess news" }, + }); + expect(output.content[1]).toMatchObject({ type: "text", text: "Final synthesized answer" }); expect(state.resolvedMcpToolCallIds.has("call-mcp-1")).toBe(true); }); diff --git a/packages/ai/test/cursor-streaming-args.test.ts b/packages/ai/test/cursor-streaming-args.test.ts index 8447914e1..9f74c743a 100644 --- a/packages/ai/test/cursor-streaming-args.test.ts +++ b/packages/ai/test/cursor-streaming-args.test.ts @@ -184,6 +184,26 @@ describe("Cursor MCP exec resolution", () => { expect(block[kCursorExecResolved]).toBe(true); expect(h.state.resolvedMcpToolCallIds.size).toBe(0); }); + + it("does not duplicate an MCP call synthesized from an earlier exec frame", () => { + const h = newHarness(); + synthesizeCursorExecToolCall(h.output, h.stream, h.state, "call-resolved", "web_search", { + query: "latest chess news", + }); + h.state.resolvedMcpToolCallIds.add("call-resolved"); + + startMcpToolCall(h, "web_search", "call-resolved"); + + expect(h.output.content).toHaveLength(1); + expect(h.output.content[0]).toMatchObject({ + type: "toolCall", + id: "call-resolved", + name: "web_search", + arguments: { query: "latest chess news" }, + }); + expect(h.captured.map(event => event.type)).toEqual(["toolcall_start", "toolcall_end"]); + expect(h.state.resolvedMcpToolCallIds.size).toBe(0); + }); }); describe("processInteractionUpdate content block ordering", () => {