From 0b8b5dfd142ec2aee457b11c6051768aeeaaa63e Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 18 Aug 2026 14:09:00 +0000 Subject: [PATCH] fix(cursor): normalize text-part tool results --- src/adapters/cursor/protobuf-request.ts | 48 ++++++++++++++------ tests/cursor-toolresult-normalize.test.ts | 54 ++++++++++++++++++++++- 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 2a55cdb179..e03dc4e71f 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -396,6 +396,8 @@ type DecodedResultPart = | { kind: "image"; bytes: Uint8Array; mimeType: string } | { kind: "undecodable" }; +type NormalizedToolResult = { text: string; isError: boolean }; + /** * Decode a tool result's parts ONCE. `toolCallStep` may re-serialize a step several times while * shrinking it to fit blob admission, and decoding base64 on every attempt made that loop @@ -427,17 +429,24 @@ function toolResultContentItems( message: OcxToolResultMessage, decoded?: DecodedResultPart[], maxImages = Number.POSITIVE_INFINITY, + normalizedText?: NormalizedToolResult, ) { const parts = decoded ?? decodeResultParts(message); + const textItem = (text: string) => [create(McpToolResultContentItemSchema, { + content: { case: "text" as const, value: create(McpTextContentSchema, { text }) }, + })]; if (!parts) { - const raw = typeof message.content === "string" ? message.content : ""; + const normalized = normalizedText + ?? normalizedToolResult(message, typeof message.content === "string" ? message.content : ""); + return textItem(normalized.text); + } + const normalized = normalizedText ?? normalizedDecodedTextResult(message, parts); + if (normalized) { // #1920/#1866: empty or failure-state Computer Use / node_repl results are - // normalized before they reach the native wire (isError is applied in - // toolResultPart via normalizedToolResult below). - const { text } = normalizedToolResult(message, raw); - return [create(McpToolResultContentItemSchema, { - content: { case: "text" as const, value: create(McpTextContentSchema, { text }) }, - })]; + // normalized before they reach the native wire. Pure-text part arrays use + // the same newline-joined representation this serializer already emitted; + // image-bearing and undecodable results stay on the lossless part path. + return textItem(normalized.text); } // Images are dropped OLDEST first when the step must shrink: the most recent screenshot is the // one the model is reasoning about, so it is the last to go. @@ -505,7 +514,7 @@ function toolResultToText(message: OcxToolResultMessage): string { * Shared #1920 normalization entry: pure-text results only. Image-bearing or * encrypted results pass through untouched (their content is not plain text). */ -function normalizedToolResult(message: OcxToolResultMessage, text: string): { text: string; isError: boolean } { +function normalizedToolResult(message: OcxToolResultMessage, text: string): NormalizedToolResult { if (message.containsEncryptedContent) return { text, isError: message.isError }; return normalizeCursorToolResultText(text, { toolName: message.toolName, @@ -514,6 +523,19 @@ function normalizedToolResult(message: OcxToolResultMessage, text: string): { te }); } +/** + * A content-part result is plain text only when every decoded part is text (the empty array is the + * empty text result). Join it exactly as toolResultContentItems already did, then share the string + * normalization contract. Any image or undecodable part keeps the existing part-preserving path. + */ +function normalizedDecodedTextResult( + message: OcxToolResultMessage, + parts: DecodedResultPart[], +): NormalizedToolResult | undefined { + if (parts.some(part => part.kind !== "text")) return undefined; + return normalizedToolResult(message, parts.map(part => part.kind === "text" ? part.text : "").join("\n")); +} + function argBytes(value: unknown): Uint8Array { try { return toBinary(ValueSchema, fromJson(ValueSchema, value as JsonValue)); @@ -568,15 +590,15 @@ function toolCallStep( function toolResultPart(message: OcxToolResultMessage, decoded?: DecodedResultPart[], maxImages?: number) { const parts = decoded ?? decodeResultParts(message); - const normalizedIsError = parts - ? message.isError - : normalizedToolResult(message, typeof message.content === "string" ? message.content : "").isError; + const normalized = parts + ? normalizedDecodedTextResult(message, parts) + : normalizedToolResult(message, typeof message.content === "string" ? message.content : ""); return create(McpToolResultSchema, { result: { case: "success", value: create(McpSuccessSchema, { - isError: normalizedIsError, - content: toolResultContentItems(message, decoded, maxImages), + isError: normalized?.isError ?? message.isError, + content: toolResultContentItems(message, parts, maxImages, normalized), }), }, }); diff --git a/tests/cursor-toolresult-normalize.test.ts b/tests/cursor-toolresult-normalize.test.ts index 7b94e0ad3b..e4d5283544 100644 --- a/tests/cursor-toolresult-normalize.test.ts +++ b/tests/cursor-toolresult-normalize.test.ts @@ -10,7 +10,7 @@ import { GetBlobArgsSchema, KvServerMessageSchema, } from "../src/adapters/cursor/gen/agent_pb"; -import type { OcxMessage } from "../src/types"; +import type { OcxMessage, OcxToolResultMessage } from "../src/types"; function blobData(blobId: Uint8Array): Uint8Array { const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { @@ -44,7 +44,15 @@ function decodedToolResult(bytes: Uint8Array) { return undefined; } -function requestWith(resultContent: string, toolOverrides: Partial<{ toolName: string; toolNamespace?: string; isError: boolean }> = {}) { +function requestWith( + resultContent: OcxToolResultMessage["content"], + toolOverrides: Partial<{ + toolName: string; + toolNamespace?: string; + isError: boolean; + containsEncryptedContent: boolean; + }> = {}, +) { const rawMessages: OcxMessage[] = [ { role: "user", content: "run it", timestamp: 1 }, { @@ -60,6 +68,7 @@ function requestWith(resultContent: string, toolOverrides: Partial<{ toolName: s toolNamespace: "toolNamespace" in toolOverrides ? toolOverrides.toolNamespace : "mcp__node_repl", content: resultContent, isError: toolOverrides.isError ?? false, + containsEncryptedContent: toolOverrides.containsEncryptedContent, timestamp: 3, }, ]; @@ -135,6 +144,47 @@ describe("native wire decode (#1920 disposition: formatted text at toolResultPar expect(first.content.case === "text" ? first.content.value.text : "").toContain("recovery"); }); + test("an empty text-part result receives the same normalization as an empty string", () => { + const result = decodedToolResult(requestWith([{ type: "text", text: "" }])); + expect(result).toBeDefined(); + expect(result!.isError).toBe(true); + const first = result!.content[0]; + expect(first.content.case === "text" ? first.content.value.text : "").toContain("[empty output"); + }); + + test("a failure-state text-part result receives recovery guidance and isError=true", () => { + const result = decodedToolResult(requestWith([{ type: "text", text: "ReferenceError: sky is not defined" }])); + expect(result).toBeDefined(); + expect(result!.isError).toBe(true); + const first = result!.content[0]; + expect(first.content.case === "text" ? first.content.value.text : "").toContain("recovery"); + }); + + test("image-bearing results keep their text and image parts without failure normalization", () => { + const failureText = "ReferenceError: sky is not defined"; + const result = decodedToolResult(requestWith([ + { type: "text", text: failureText }, + { type: "image", imageUrl: "data:image/png;base64,iVBORw0KGgo=" }, + ])); + expect(result).toBeDefined(); + expect(result!.isError).toBe(false); + expect(result!.content).toHaveLength(2); + expect(result!.content[0]?.content.case === "text" ? result!.content[0].content.value.text : "").toBe(failureText); + expect(result!.content[1]?.content.case).toBe("image"); + }); + + test("encrypted text-part results remain unmodified", () => { + const failureText = "ReferenceError: sky is not defined"; + const result = decodedToolResult(requestWith( + [{ type: "text", text: failureText }], + { containsEncryptedContent: true }, + )); + expect(result).toBeDefined(); + expect(result!.isError).toBe(false); + const first = result!.content[0]; + expect(first.content.case === "text" ? first.content.value.text : "").toBe(failureText); + }); + test("a normal tool result decodes byte-identical (no normalization side effects)", () => { const result = decodedToolResult(requestWith("plain output", { toolName: "read_file", toolNamespace: undefined })); expect(result).toBeDefined();