diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 39a18bd6eb..18d5957eab 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -188,10 +188,12 @@ function assistantRootText( } // Cursor builds the actual model prompt from rootPromptMessagesJson (turns[] is UI/display metadata), -// so prior history — including assistant tool calls and tool results — must be replayed here or a -// ResumeAction has nothing model-visible to continue from. The active user message is excluded -// because it travels in the action. Tool results are assistant-role text with a [Tool Result] -// or [Tool Error] marker so Cursor does not wrap them as `` (#1992). Each entry is a SHA-256 blob ID. +// so prior history must be replayed here or a ResumeAction has nothing model-visible to continue from. +// The active user message is excluded because it travels in the action. When the continuation cannot +// rely on native MCP turn state, tool results stay assistant-role text with a [Tool Result] / +// [Tool Error] marker so Cursor does not wrap them as `` (#1992). Native resume models +// already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto +// few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID. function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobRequestScopeToken): { ids: Uint8Array[]; byteLength: number; @@ -212,6 +214,7 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR } const externalModel = isCursorExternalWireModel(request.modelId); + const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId); const lastRawIsToolResult = messages.at(-1)?.role === "toolResult"; const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages); @@ -243,6 +246,10 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR } // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here. } else if (message.role === "toolResult") { + // Native resume models already receive the paired MCP result through turns[]. Replaying + // the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto + // to echo that envelope as chat instead of continuing from the structured result. + if (!echoToolResultInRoot) continue; // #1920: the prefix must reflect the NORMALIZED error state (an empty // node_repl result is an error even when the runtime said isError=false). const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]"; diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 1139ea79fb..cf37282c80 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -913,6 +913,47 @@ describe("Cursor blob handshake", () => { const run = msg.message.case === "runRequest" ? msg.message.value : undefined; expect(run?.action?.action.case).toBe("resumeAction"); + const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; + const serialized = JSON.stringify(roots); + expect(serialized).toContain("read a file"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + }); + + test("native Auto Intelligence omits assistant-role [Tool Result] root replay", () => { + const bytes = encodeCursorRunRequest({ + modelId: "auto-intelligence", + conversationId: "c-auto-intel", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: read_file\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto-intelligence", + timestamp: 2, + content: [{ type: "toolCall", id: "call_1", name: "read_file", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: "call_1", toolName: "read_file", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + expect(run?.action?.action.case).toBe("resumeAction"); + const roots = decodeRootMessages(bytes) as Array<{ role?: string; content?: unknown }>; + const serialized = JSON.stringify(roots); + expect(roots.some(root => root.role === "assistant")).toBe(false); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).toContain("read a file"); + const turnIds = run?.conversationState?.turns ?? []; + expect(turnIds).toHaveLength(1); + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnIds[0]!)); + expect(turn.turn.case).toBe("agentConversationTurn"); + const steps = turn.turn.case === "agentConversationTurn" ? turn.turn.value.steps : []; + expect(steps).toHaveLength(1); + const step = fromBinary(ConversationStepSchema, blobData(steps[0]!)); + expect(step.message.case).toBe("toolCall"); }); test("drives composer-2.5 tool-result continuations as userMessageAction", () => { diff --git a/tests/cursor-tool-continuation.test.ts b/tests/cursor-tool-continuation.test.ts index 6880d741cb..2772245035 100644 --- a/tests/cursor-tool-continuation.test.ts +++ b/tests/cursor-tool-continuation.test.ts @@ -39,7 +39,7 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = { role: "toolResult", toolCallId: "call_1", toolName: "read_file", toolNamespace: "mcp__fs", content: "FILE CONTENTS HERE", isError: false, timestamp: 3 }, ]; - test("tool result text is present in rootPromptMessagesJson, not only in turns[]", () => { + test("external-continuation tool result text is present in rootPromptMessagesJson, not only in turns[]", () => { const bytes = encodeCursorRunRequest({ modelId: "composer-2.5", conversationId: "c1", @@ -49,14 +49,29 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = }); const roots = decodeRoots(bytes); const serialized = JSON.stringify(roots); - // The model prompt (rootPromptMessagesJson) MUST carry the tool result, or ResumeAction has - // nothing model-visible to resume from. Reference: danger-pi buildRootPromptMessagesJson. + // composer-2.5 still continues as userMessageAction, so the model prompt must carry the + // tool result. Reference: danger-pi buildRootPromptMessagesJson. expect(serialized).toContain("FILE CONTENTS HERE"); expect(serialized).toContain("call_1"); // The prior user turn must also be replayed (not system-only). expect(serialized).toContain("read a file"); }); + test("native resume models keep tool results on turns[], not as assistant-role root text", () => { + const bytes = encodeCursorRunRequest({ + modelId: "auto-intelligence", + conversationId: "c-auto", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nFILE CONTENTS HERE" }], + rawMessages, + }); + const serialized = JSON.stringify(decodeRoots(bytes)); + expect(serialized).toContain("read a file"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).not.toContain("FILE CONTENTS HERE"); + }); + test("rootPromptMessagesJson still leads with the system prompt blob", () => { const bytes = encodeCursorRunRequest({ modelId: "composer-2.5", @@ -82,11 +97,25 @@ describe("363-B: tool result reaches the model via rootPromptMessagesJson", () = // "[Tool Call]" text. The model few-shot-mimics that marker and emits later parallel/mixed tool // calls as inert text instead of real tool frames (halting multi-tool continuations). expect(serialized).not.toContain("[Tool Call]"); - // ...but the tool's model-visible continuation context (call id + output) must still survive via - // the paired tool RESULT echo, so the model can continue from it. + // composer-2.5 still needs the paired tool RESULT echo in the model-visible prompt. expect(serialized).toContain("FILE CONTENTS HERE"); expect(serialized).toContain("call_1"); }); + + test("native resume models do not few-shot [Tool Result] as assistant chat", () => { + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5-fast", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: mcp__fs__read_file\nis_error: false\noutput:\nFILE CONTENTS HERE" }], + rawMessages, + }); + const serialized = JSON.stringify(decodeRoots(bytes)); + expect(serialized).not.toContain("[Tool Call]"); + expect(serialized).not.toContain("[Tool Result]"); + expect(serialized).not.toContain("[tool_result]"); + expect(serialized).toContain("read a file"); + }); }); import { create as createPb } from "@bufbuild/protobuf";