From 2fcace71471b2b728bc6ad379ce24437be1f50f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 00:18:47 +0000 Subject: [PATCH 1/3] fix(ai): treat whitespace-only tool text as empty Blank tool text, error values, and content items now match an empty string. Anthropic success tool results drop that text and keep non-blank text unchanged. --- .../ai/src/protocols/anthropic-messages.ts | 8 +++- packages/ai/src/tool-history.ts | 7 ++-- .../test/provider/anthropic-messages.test.ts | 41 +++++++++++++++++++ packages/ai/test/tool-history.test.ts | 41 ++++++++++++++++++- 4 files changed, 90 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index dc2a972094ec..b537ab903fa5 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -814,7 +814,10 @@ const lowerMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMReq // Tool results may carry structured text, images, and documents. Keep media as provider-native // content instead of JSON-stringifying base64 into a prompt string. const lowerToolResultContentItem = Effect.fnUntraced(function* (item: Tool.Content, request: LLMRequest) { - if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock + if (item.type === "text") { + if (item.text.trim() === "") return + return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock + } return yield* lowerMedia(ProviderShared.toolFileMedia(item), request) }) @@ -828,7 +831,8 @@ const lowerToolResultContent = Effect.fnUntraced(function* (part: ToolResultPart // An is_error tool_result may contain only text. Image and document blocks in that content are // rejected, so replace them with a note instead of lowering media. if (part.result.type === "error") return lowerToolErrorContent(part, content) - return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request)) + const blocks = yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request)) + return blocks.flatMap((block) => (block === undefined ? [] : [block])) }) const lowerToolErrorContent = (part: ToolResultPart, content: ReadonlyArray) => [ diff --git a/packages/ai/src/tool-history.ts b/packages/ai/src/tool-history.ts index ddf7d1ce95f9..0cc42c270fda 100644 --- a/packages/ai/src/tool-history.ts +++ b/packages/ai/src/tool-history.ts @@ -2,6 +2,7 @@ import { Message, ToolResultPart, type ToolCallPart } from "./schema/messages.js const EMPTY_TOOL_OUTPUT = "(no tool output)" const MISSING_TOOL_RESULT = "Tool result missing" +const blankText = (value: unknown) => typeof value === "string" && value.trim() === "" export function normalizeToolHistory(messages: ReadonlyArray) { const normalized: Message[] = [] @@ -86,12 +87,12 @@ function normalizeToolResult(part: ToolResultPart, call: ToolCallPart | undefine call === undefined || (part.name === call.name && part.namespace === call.namespace) ? part : { ...part, name: call.name, namespace: call.namespace } - if (named.result.type === "text" && named.result.value === "") + if (named.result.type === "text" && blankText(named.result.value)) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } } - if (named.result.type === "error" && named.result.value === "") + if (named.result.type === "error" && blankText(named.result.value)) return { ...named, result: { ...named.result, value: EMPTY_TOOL_OUTPUT } } if (named.result.type !== "content") return named - const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "") + const value = named.result.value.filter((item) => item.type !== "text" || !blankText(item.text)) if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } } if (value.length === named.result.value.length) return named return { ...named, result: { type: "content", value } } diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 853402e00c38..d93ca76cbc52 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -823,6 +823,47 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("drops whitespace-only tool-result text and keeps non-blank text", () => + Effect.gen(function* () { + const spaced = " Keep this spacing. " + const prepared = yield* compileRequest( + LLM.request({ + model, + cache: "none", + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]), + Message.tool({ + id: "call_1", + name: "read", + resultType: "content", + result: [ + { type: "text", text: " \n\t" }, + { type: "text", text: spaced }, + { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }, + ], + }), + ], + }), + ) + const blank = yield* compileRequest( + LLM.request({ + model, + cache: "none", + messages: [ + Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]), + Message.tool({ id: "call_1", name: "read", resultType: "text", result: " \n\t" }), + ], + }), + ) + + expect(expectToolResult(prepared.body).content).toEqual([ + { type: "text", text: spaced }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } }, + ]) + expect(expectToolResult(blank.body).content).toBe("(no tool output)") + }), + ) + it.effect("lowers single-image tool-result content as a structured image block", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/ai/test/tool-history.test.ts b/packages/ai/test/tool-history.test.ts index 80eac7a36943..6cba94dd65b3 100644 --- a/packages/ai/test/tool-history.test.ts +++ b/packages/ai/test/tool-history.test.ts @@ -58,7 +58,7 @@ describe("tool history normalization", () => { expect(normalizeToolHistory([Message.assistant(hosted)])).toEqual([Message.assistant(hosted)]) }) - test("normalizes empty results without changing whitespace or media", () => { + test("normalizes empty and whitespace-only results without changing media", () => { const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" } const normalized = normalizeToolHistory([ Message.assistant([ @@ -80,7 +80,44 @@ describe("tool history normalization", () => { { type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } }, { type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } }, { type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } }, - { type: "tool-result", id: "whitespace", name: "whitespace", result: { type: "text", value: " " } }, + { + type: "tool-result", + id: "whitespace", + name: "whitespace", + result: { type: "text", value: "(no tool output)" }, + }, + ]) + }) + + test("treats whitespace-only text and content as empty and keeps non-blank text", () => { + const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" } + const spaced = " Keep this spacing. " + const normalized = normalizeToolHistory([ + Message.assistant([ + toolCall("text"), + toolCall("error"), + toolCall("content"), + toolCall("spaced"), + toolCall("mixed"), + ]), + toolResult("text", " \n\t", "text", "text"), + toolResult("error", " \n\t", "error", "error"), + toolResult("content", [{ type: "text", text: " \n\t" }], "content", "content"), + toolResult("spaced", spaced, "spaced", "text"), + toolResult("mixed", [{ type: "text", text: " \n\t" }, { type: "text", text: spaced }, media], "mixed", "content"), + ]) + + expect(normalized.slice(1).map((message) => message.content[0])).toEqual([ + { type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } }, + { type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } }, + { type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } }, + { type: "tool-result", id: "spaced", name: "spaced", result: { type: "text", value: spaced } }, + { + type: "tool-result", + id: "mixed", + name: "mixed", + result: { type: "content", value: [{ type: "text", text: spaced }, media] }, + }, ]) }) From 2307be905f32a82ba9a84439dfc0e90bc74ea209 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 00:29:54 +0000 Subject: [PATCH 2/3] fix(ai): keep whitespace separators in mixed tool results --- packages/ai/src/tool-history.ts | 10 ++++--- .../ai/test/provider/mistral-chat.test.ts | 25 +++++++++++++++++ packages/ai/test/tool-history.test.ts | 27 ++++++++++++------- 3 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/ai/src/tool-history.ts b/packages/ai/src/tool-history.ts index 0cc42c270fda..8d5e084348ff 100644 --- a/packages/ai/src/tool-history.ts +++ b/packages/ai/src/tool-history.ts @@ -92,8 +92,10 @@ function normalizeToolResult(part: ToolResultPart, call: ToolCallPart | undefine if (named.result.type === "error" && blankText(named.result.value)) return { ...named, result: { ...named.result, value: EMPTY_TOOL_OUTPUT } } if (named.result.type !== "content") return named - const value = named.result.value.filter((item) => item.type !== "text" || !blankText(item.text)) - if (value.length === 0) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } } - if (value.length === named.result.value.length) return named - return { ...named, result: { type: "content", value } } + // A whitespace text item can separate the others. Mistral joins text with "", so dropping "\n" + // from ["foo", "\n", "bar"] yields "foobar". Replace the array only when every item is blank + // and there is no media. Protocol lowering filters its own blanks. + if (named.result.value.every((item) => item.type === "text" && blankText(item.text))) + return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } } + return named } diff --git a/packages/ai/test/provider/mistral-chat.test.ts b/packages/ai/test/provider/mistral-chat.test.ts index 742ea1e6da43..b72a47048e5c 100644 --- a/packages/ai/test/provider/mistral-chat.test.ts +++ b/packages/ai/test/provider/mistral-chat.test.ts @@ -353,6 +353,31 @@ describe("Mistral Chat", () => { }), ) + it.effect("keeps a whitespace separator when joining text-only tool results", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.tool({ + id: "Ab12Cd34E", + name: "lookup", + resultType: "content", + result: [ + { type: "text", text: "foo" }, + { type: "text", text: "\n" }, + { type: "text", text: "bar" }, + ], + }), + ], + }), + ) + expect(prepared.body.messages).toEqual([ + { role: "tool", tool_call_id: "Ab12Cd34E", name: "lookup", content: "foo\nbar" }, + ]) + }), + ) + it.effect("streams ordered thinking and text and replays native thinking metadata", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/ai/test/tool-history.test.ts b/packages/ai/test/tool-history.test.ts index 6cba94dd65b3..70d460c2237d 100644 --- a/packages/ai/test/tool-history.test.ts +++ b/packages/ai/test/tool-history.test.ts @@ -79,7 +79,12 @@ describe("tool history normalization", () => { { type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } }, { type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } }, { type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } }, - { type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } }, + { + type: "tool-result", + id: "mixed", + name: "mixed", + result: { type: "content", value: [{ type: "text", text: "" }, media] }, + }, { type: "tool-result", id: "whitespace", @@ -89,22 +94,30 @@ describe("tool history normalization", () => { ]) }) - test("treats whitespace-only text and content as empty and keeps non-blank text", () => { + test("treats whitespace-only text and content as empty and leaves mixed content unchanged", () => { const media = { type: "file" as const, uri: "data:image/png;base64,AQID", mime: "image/png" } const spaced = " Keep this spacing. " + const separated = [ + { type: "text" as const, text: "foo" }, + { type: "text" as const, text: "\n" }, + { type: "text" as const, text: "bar" }, + ] + const mixed = [{ type: "text" as const, text: " \n\t" }, { type: "text" as const, text: spaced }, media] const normalized = normalizeToolHistory([ Message.assistant([ toolCall("text"), toolCall("error"), toolCall("content"), toolCall("spaced"), + toolCall("separated"), toolCall("mixed"), ]), toolResult("text", " \n\t", "text", "text"), toolResult("error", " \n\t", "error", "error"), toolResult("content", [{ type: "text", text: " \n\t" }], "content", "content"), toolResult("spaced", spaced, "spaced", "text"), - toolResult("mixed", [{ type: "text", text: " \n\t" }, { type: "text", text: spaced }, media], "mixed", "content"), + toolResult("separated", separated, "separated", "content"), + toolResult("mixed", mixed, "mixed", "content"), ]) expect(normalized.slice(1).map((message) => message.content[0])).toEqual([ @@ -112,12 +125,8 @@ describe("tool history normalization", () => { { type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } }, { type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } }, { type: "tool-result", id: "spaced", name: "spaced", result: { type: "text", value: spaced } }, - { - type: "tool-result", - id: "mixed", - name: "mixed", - result: { type: "content", value: [{ type: "text", text: spaced }, media] }, - }, + { type: "tool-result", id: "separated", name: "separated", result: { type: "content", value: separated } }, + { type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: mixed } }, ]) }) From 60ac11e8c2dd25332a034ac8db8a79b3906f37e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 29 Oct 2026 00:37:17 +0000 Subject: [PATCH 3/3] fix(ai): drop exact empty strings from mixed tool results --- packages/ai/src/protocols/bedrock-converse.ts | 1 + packages/ai/src/tool-history.ts | 12 +++-- .../ai/test/provider/bedrock-converse.test.ts | 47 +++++++++++++++++++ packages/ai/test/tool-history.test.ts | 7 +-- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 1bc7a9a4c008..8c8f2e127289 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -341,6 +341,7 @@ const lowerToolResultContent = Effect.fnUntraced(function* ( const content: Array> = [] for (const item of part.result.value) { if (item.type === "text") { + if (item.text.trim() === "") continue content.push({ text: item.text }) continue } diff --git a/packages/ai/src/tool-history.ts b/packages/ai/src/tool-history.ts index 8d5e084348ff..1001178652cf 100644 --- a/packages/ai/src/tool-history.ts +++ b/packages/ai/src/tool-history.ts @@ -92,10 +92,12 @@ function normalizeToolResult(part: ToolResultPart, call: ToolCallPart | undefine if (named.result.type === "error" && blankText(named.result.value)) return { ...named, result: { ...named.result, value: EMPTY_TOOL_OUTPUT } } if (named.result.type !== "content") return named - // A whitespace text item can separate the others. Mistral joins text with "", so dropping "\n" - // from ["foo", "\n", "bar"] yields "foobar". Replace the array only when every item is blank - // and there is no media. Protocol lowering filters its own blanks. - if (named.result.value.every((item) => item.type === "text" && blankText(item.text))) + // Exact "" is not a separator. Drop it, then replace the array only when every remaining + // item is blank and there is no media. Whitespace such as "\n" stays: Mistral joins text + // with "", so ["foo", "\n", "bar"] must remain "foo\nbar". + const value = named.result.value.filter((item) => item.type !== "text" || item.text !== "") + if (value.every((item) => item.type === "text" && blankText(item.text))) return { ...named, result: { type: "text", value: EMPTY_TOOL_OUTPUT } } - return named + if (value.length === named.result.value.length) return named + return { ...named, result: { type: "content", value } } } diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index a2de0b26b6eb..9d567fd5bdf3 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -648,6 +648,53 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("drops blank tool-result text and keeps the image", () => + Effect.gen(function* () { + const prepared = yield* compileRequest( + LLM.request({ + id: "req_tool_blank_text", + model, + messages: [ + Message.user("Capture the screen."), + Message.assistant([ToolCallPart.make({ id: "tool_1", name: "screenshot", input: {} })]), + Message.tool({ + id: "tool_1", + name: "screenshot", + result: { + type: "content", + value: [ + { type: "text", text: " " }, + { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }, + ], + }, + }), + ], + cache: "none", + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "user", content: [{ text: "Capture the screen." }] }, + { + role: "assistant", + content: [{ toolUse: { toolUseId: "tool_1", name: "screenshot", input: {} } }], + }, + { + role: "user", + content: [ + { + toolResult: { + toolUseId: "tool_1", + content: [{ image: { format: "png", source: { bytes: "AAAA" } } }], + status: "success", + }, + }, + ], + }, + ]) + }), + ) + it.effect("hoists tool-result images beside the result for Bedrock GPT models", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/ai/test/tool-history.test.ts b/packages/ai/test/tool-history.test.ts index 70d460c2237d..feb89583934f 100644 --- a/packages/ai/test/tool-history.test.ts +++ b/packages/ai/test/tool-history.test.ts @@ -79,12 +79,7 @@ describe("tool history normalization", () => { { type: "tool-result", id: "text", name: "text", result: { type: "text", value: "(no tool output)" } }, { type: "tool-result", id: "content", name: "content", result: { type: "text", value: "(no tool output)" } }, { type: "tool-result", id: "error", name: "error", result: { type: "error", value: "(no tool output)" } }, - { - type: "tool-result", - id: "mixed", - name: "mixed", - result: { type: "content", value: [{ type: "text", text: "" }, media] }, - }, + { type: "tool-result", id: "mixed", name: "mixed", result: { type: "content", value: [media] } }, { type: "tool-result", id: "whitespace",