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
This commit is contained in:
roboomp
2026-07-24 10:12:31 +00:00
parent 69307261c3
commit d2997d2025
4 changed files with 71 additions and 9 deletions
+4
View File
@@ -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
+28 -8
View File
@@ -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<string>;
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);
+19 -1
View File
@@ -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);
});
@@ -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", () => {