Skip to content

Commit 0b8b5df

Browse files
committed
fix(cursor): normalize text-part tool results
1 parent c42d1eb commit 0b8b5df

2 files changed

Lines changed: 87 additions & 15 deletions

File tree

‎src/adapters/cursor/protobuf-request.ts‎

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,8 @@ type DecodedResultPart =
396396
| { kind: "image"; bytes: Uint8Array; mimeType: string }
397397
| { kind: "undecodable" };
398398

399+
type NormalizedToolResult = { text: string; isError: boolean };
400+
399401
/**
400402
* Decode a tool result's parts ONCE. `toolCallStep` may re-serialize a step several times while
401403
* shrinking it to fit blob admission, and decoding base64 on every attempt made that loop
@@ -427,17 +429,24 @@ function toolResultContentItems(
427429
message: OcxToolResultMessage,
428430
decoded?: DecodedResultPart[],
429431
maxImages = Number.POSITIVE_INFINITY,
432+
normalizedText?: NormalizedToolResult,
430433
) {
431434
const parts = decoded ?? decodeResultParts(message);
435+
const textItem = (text: string) => [create(McpToolResultContentItemSchema, {
436+
content: { case: "text" as const, value: create(McpTextContentSchema, { text }) },
437+
})];
432438
if (!parts) {
433-
const raw = typeof message.content === "string" ? message.content : "";
439+
const normalized = normalizedText
440+
?? normalizedToolResult(message, typeof message.content === "string" ? message.content : "");
441+
return textItem(normalized.text);
442+
}
443+
const normalized = normalizedText ?? normalizedDecodedTextResult(message, parts);
444+
if (normalized) {
434445
// #1920/#1866: empty or failure-state Computer Use / node_repl results are
435-
// normalized before they reach the native wire (isError is applied in
436-
// toolResultPart via normalizedToolResult below).
437-
const { text } = normalizedToolResult(message, raw);
438-
return [create(McpToolResultContentItemSchema, {
439-
content: { case: "text" as const, value: create(McpTextContentSchema, { text }) },
440-
})];
446+
// normalized before they reach the native wire. Pure-text part arrays use
447+
// the same newline-joined representation this serializer already emitted;
448+
// image-bearing and undecodable results stay on the lossless part path.
449+
return textItem(normalized.text);
441450
}
442451
// Images are dropped OLDEST first when the step must shrink: the most recent screenshot is the
443452
// one the model is reasoning about, so it is the last to go.
@@ -505,7 +514,7 @@ function toolResultToText(message: OcxToolResultMessage): string {
505514
* Shared #1920 normalization entry: pure-text results only. Image-bearing or
506515
* encrypted results pass through untouched (their content is not plain text).
507516
*/
508-
function normalizedToolResult(message: OcxToolResultMessage, text: string): { text: string; isError: boolean } {
517+
function normalizedToolResult(message: OcxToolResultMessage, text: string): NormalizedToolResult {
509518
if (message.containsEncryptedContent) return { text, isError: message.isError };
510519
return normalizeCursorToolResultText(text, {
511520
toolName: message.toolName,
@@ -514,6 +523,19 @@ function normalizedToolResult(message: OcxToolResultMessage, text: string): { te
514523
});
515524
}
516525

526+
/**
527+
* A content-part result is plain text only when every decoded part is text (the empty array is the
528+
* empty text result). Join it exactly as toolResultContentItems already did, then share the string
529+
* normalization contract. Any image or undecodable part keeps the existing part-preserving path.
530+
*/
531+
function normalizedDecodedTextResult(
532+
message: OcxToolResultMessage,
533+
parts: DecodedResultPart[],
534+
): NormalizedToolResult | undefined {
535+
if (parts.some(part => part.kind !== "text")) return undefined;
536+
return normalizedToolResult(message, parts.map(part => part.kind === "text" ? part.text : "").join("\n"));
537+
}
538+
517539
function argBytes(value: unknown): Uint8Array {
518540
try {
519541
return toBinary(ValueSchema, fromJson(ValueSchema, value as JsonValue));
@@ -568,15 +590,15 @@ function toolCallStep(
568590

569591
function toolResultPart(message: OcxToolResultMessage, decoded?: DecodedResultPart[], maxImages?: number) {
570592
const parts = decoded ?? decodeResultParts(message);
571-
const normalizedIsError = parts
572-
? message.isError
573-
: normalizedToolResult(message, typeof message.content === "string" ? message.content : "").isError;
593+
const normalized = parts
594+
? normalizedDecodedTextResult(message, parts)
595+
: normalizedToolResult(message, typeof message.content === "string" ? message.content : "");
574596
return create(McpToolResultSchema, {
575597
result: {
576598
case: "success",
577599
value: create(McpSuccessSchema, {
578-
isError: normalizedIsError,
579-
content: toolResultContentItems(message, decoded, maxImages),
600+
isError: normalized?.isError ?? message.isError,
601+
content: toolResultContentItems(message, parts, maxImages, normalized),
580602
}),
581603
},
582604
});

‎tests/cursor-toolresult-normalize.test.ts‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
GetBlobArgsSchema,
1111
KvServerMessageSchema,
1212
} from "../src/adapters/cursor/gen/agent_pb";
13-
import type { OcxMessage } from "../src/types";
13+
import type { OcxMessage, OcxToolResultMessage } from "../src/types";
1414

1515
function blobData(blobId: Uint8Array): Uint8Array {
1616
const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, {
@@ -44,7 +44,15 @@ function decodedToolResult(bytes: Uint8Array) {
4444
return undefined;
4545
}
4646

47-
function requestWith(resultContent: string, toolOverrides: Partial<{ toolName: string; toolNamespace?: string; isError: boolean }> = {}) {
47+
function requestWith(
48+
resultContent: OcxToolResultMessage["content"],
49+
toolOverrides: Partial<{
50+
toolName: string;
51+
toolNamespace?: string;
52+
isError: boolean;
53+
containsEncryptedContent: boolean;
54+
}> = {},
55+
) {
4856
const rawMessages: OcxMessage[] = [
4957
{ role: "user", content: "run it", timestamp: 1 },
5058
{
@@ -60,6 +68,7 @@ function requestWith(resultContent: string, toolOverrides: Partial<{ toolName: s
6068
toolNamespace: "toolNamespace" in toolOverrides ? toolOverrides.toolNamespace : "mcp__node_repl",
6169
content: resultContent,
6270
isError: toolOverrides.isError ?? false,
71+
containsEncryptedContent: toolOverrides.containsEncryptedContent,
6372
timestamp: 3,
6473
},
6574
];
@@ -135,6 +144,47 @@ describe("native wire decode (#1920 disposition: formatted text at toolResultPar
135144
expect(first.content.case === "text" ? first.content.value.text : "").toContain("recovery");
136145
});
137146

147+
test("an empty text-part result receives the same normalization as an empty string", () => {
148+
const result = decodedToolResult(requestWith([{ type: "text", text: "" }]));
149+
expect(result).toBeDefined();
150+
expect(result!.isError).toBe(true);
151+
const first = result!.content[0];
152+
expect(first.content.case === "text" ? first.content.value.text : "").toContain("[empty output");
153+
});
154+
155+
test("a failure-state text-part result receives recovery guidance and isError=true", () => {
156+
const result = decodedToolResult(requestWith([{ type: "text", text: "ReferenceError: sky is not defined" }]));
157+
expect(result).toBeDefined();
158+
expect(result!.isError).toBe(true);
159+
const first = result!.content[0];
160+
expect(first.content.case === "text" ? first.content.value.text : "").toContain("recovery");
161+
});
162+
163+
test("image-bearing results keep their text and image parts without failure normalization", () => {
164+
const failureText = "ReferenceError: sky is not defined";
165+
const result = decodedToolResult(requestWith([
166+
{ type: "text", text: failureText },
167+
{ type: "image", imageUrl: "data:image/png;base64,iVBORw0KGgo=" },
168+
]));
169+
expect(result).toBeDefined();
170+
expect(result!.isError).toBe(false);
171+
expect(result!.content).toHaveLength(2);
172+
expect(result!.content[0]?.content.case === "text" ? result!.content[0].content.value.text : "").toBe(failureText);
173+
expect(result!.content[1]?.content.case).toBe("image");
174+
});
175+
176+
test("encrypted text-part results remain unmodified", () => {
177+
const failureText = "ReferenceError: sky is not defined";
178+
const result = decodedToolResult(requestWith(
179+
[{ type: "text", text: failureText }],
180+
{ containsEncryptedContent: true },
181+
));
182+
expect(result).toBeDefined();
183+
expect(result!.isError).toBe(false);
184+
const first = result!.content[0];
185+
expect(first.content.case === "text" ? first.content.value.text : "").toBe(failureText);
186+
});
187+
138188
test("a normal tool result decodes byte-identical (no normalization side effects)", () => {
139189
const result = decodedToolResult(requestWith("plain output", { toolName: "read_file", toolNamespace: undefined }));
140190
expect(result).toBeDefined();

0 commit comments

Comments
 (0)