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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user