diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 402b30263..f80fecce8 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed whitespace-only tool output (for example a bare `\r\n` from bash) being sent as-is to OpenAI Completions, OpenAI Responses, and Google providers; it now uses the same placeholder as empty output, since some providers reject whitespace-only tool content with a 400 ([#8720](https://github.com/earendil-works/pi/issues/8720)) + ## [1.3.0] - 2026-10-15 ### Breaking Changes diff --git a/packages/ai/src/api/google-shared.ts b/packages/ai/src/api/google-shared.ts index aef32f316..129812344 100644 --- a/packages/ai/src/api/google-shared.ts +++ b/packages/ai/src/api/google-shared.ts @@ -291,7 +291,7 @@ export function convertMessages(model: Model, contex ? msg.content.filter((c): c is ImageContent => c.type === "image") : []; - const hasText = textResult.length > 0; + const hasText = textResult.trim().length > 0; const hasImages = imageContent.length > 0; // Gemini 3+ models support multimodal function responses with images nested inside diff --git a/packages/ai/src/api/openai-completions.ts b/packages/ai/src/api/openai-completions.ts index 32cf42b45..b8310c496 100644 --- a/packages/ai/src/api/openai-completions.ts +++ b/packages/ai/src/api/openai-completions.ts @@ -1420,7 +1420,7 @@ export function convertMessages( const hasImages = toolMsg.content.some((c) => c.type === "image"); // Always send tool result with text (or placeholder if only images) - const hasText = textResult.length > 0; + const hasText = textResult.trim().length > 0; const toolResultText = hasText ? textResult : hasImages ? "(see attached image)" : "(no tool output)"; // Some providers require the 'name' field in tool results const toolResultMsg: ChatCompletionToolMessageParam = { diff --git a/packages/ai/src/api/openai-responses-shared.ts b/packages/ai/src/api/openai-responses-shared.ts index 001864d58..14cb28a77 100644 --- a/packages/ai/src/api/openai-responses-shared.ts +++ b/packages/ai/src/api/openai-responses-shared.ts @@ -88,7 +88,7 @@ function convertToolResultOutput( .map((c) => c.text) .join("\n"); const images = content.filter((c): c is ImageContent => c.type === "image"); - const hasText = textResult.length > 0; + const hasText = textResult.trim().length > 0; if (images.length === 0 || !model.input.includes("image")) { return sanitizeSurrogates(hasText ? textResult : images.length > 0 ? "(see attached image)" : "(no tool output)"); diff --git a/packages/ai/test/google-shared-image-tool-result-routing.test.ts b/packages/ai/test/google-shared-image-tool-result-routing.test.ts index 0e55eb543..adbd1bb3f 100644 --- a/packages/ai/test/google-shared-image-tool-result-routing.test.ts +++ b/packages/ai/test/google-shared-image-tool-result-routing.test.ts @@ -100,4 +100,21 @@ describe("google-shared image tool result routing", () => { expect(imageResponse?.parts).toHaveLength(1); expect(imageResponse?.parts?.[0]?.inlineData).toBeTruthy(); }); + + // https://github.com/earendil-works/pi/issues/8720 + it("treats whitespace-only tool result text as no text", () => { + const model = makeModel("google-generative-ai", "google", "gemini-3-pro-preview"); + const context = makeContext(model); + for (const message of context.messages) { + if (message.role !== "toolResult") continue; + if (message.toolCallId === "call_a") message.content = [{ type: "text", text: "\r\n" }]; + if (message.toolCallId === "call_img") message.content.unshift({ type: "text", text: " \n" }); + } + const contents = convertMessages(model, normalizeContext(context)); + + const responses = contents[2].parts?.map((part) => part.functionResponse?.response); + expect(responses?.[0]).toEqual({ output: "" }); + expect(responses?.[1]).toEqual({ output: "(see attached image)" }); + expect(responses?.[2]).toEqual({ output: "beta text" }); + }); }); diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 4d303865e..7acc7221c 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -57,12 +57,12 @@ function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessa }; } -function buildEmptyToolResult(toolCallId: string, timestamp: number): ToolResultMessage { +function buildEmptyToolResult(toolCallId: string, timestamp: number, text = ""): ToolResultMessage { return { role: "toolResult", toolCallId, toolName: "bash", - content: [{ type: "text", text: "" }], + content: [{ type: "text", text }], isError: false, timestamp, }; @@ -144,38 +144,42 @@ describe("openai-completions convertMessages", () => { expect(imageParts.length).toBe(2); }); - it("uses '(no tool output)' placeholder for empty tool results without images", () => { - const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini"); - const model: Model<"openai-completions"> = { - ...baseModel, - api: "openai-completions", - input: ["text", "image"], - }; + // Whitespace-only cases: https://github.com/earendil-works/pi/issues/8720 + it.each(["", "\r\n", " \n\t"])( + "uses '(no tool output)' placeholder for tool result text %j without images", + (text) => { + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini"); + const model: Model<"openai-completions"> = { + ...baseModel, + api: "openai-completions", + input: ["text", "image"], + }; - const now = Date.now(); - const assistantMessage: AssistantMessage = { - role: "assistant", - content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }], - api: model.api, - provider: model.provider, - model: model.id, - usage: emptyUsage, - stopReason: "toolUse", - timestamp: now, - }; + const now = Date.now(); + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }], + api: model.api, + provider: model.provider, + model: model.id, + usage: emptyUsage, + stopReason: "toolUse", + timestamp: now, + }; - const context = normalizeContext({ - messages: [ - { role: "user", content: "Run the command", timestamp: now - 1 }, - assistantMessage, - buildEmptyToolResult("tool-1", now + 1), - ], - }); + const context = normalizeContext({ + messages: [ + { role: "user", content: "Run the command", timestamp: now - 1 }, + assistantMessage, + buildEmptyToolResult("tool-1", now + 1, text), + ], + }); - const messages = convertMessages(model, context, compat); - const toolMessage = messages.find((m) => m.role === "tool") as { role: "tool"; content: string } | undefined; - expect(toolMessage).toBeTruthy(); - expect(toolMessage?.content).toBe("(no tool output)"); - expect(toolMessage?.content).not.toContain("see attached image"); - }); + const messages = convertMessages(model, context, compat); + const toolMessage = messages.find((m) => m.role === "tool") as { role: "tool"; content: string } | undefined; + expect(toolMessage).toBeTruthy(); + expect(toolMessage?.content).toBe("(no tool output)"); + expect(toolMessage?.content).not.toContain("see attached image"); + }, + ); }); diff --git a/packages/ai/test/openai-responses-empty-tool-result.test.ts b/packages/ai/test/openai-responses-empty-tool-result.test.ts index 89375111e..e1bee472e 100644 --- a/packages/ai/test/openai-responses-empty-tool-result.test.ts +++ b/packages/ai/test/openai-responses-empty-tool-result.test.ts @@ -12,47 +12,51 @@ const usage: Usage = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; -function buildEmptyToolResult(toolCallId: string, timestamp: number): ToolResultMessage { +function buildEmptyToolResult(toolCallId: string, timestamp: number, text = ""): ToolResultMessage { return { role: "toolResult", toolCallId, toolName: "bash", - content: [{ type: "text", text: "" }], + content: [{ type: "text", text }], isError: false, timestamp, }; } describe("OpenAI Responses convertResponsesMessages empty tool result", () => { - it("uses '(no tool output)' placeholder for empty tool results without images", () => { - const model = getModel("openai", "gpt-4o-mini"); - const now = Date.now(); - const assistant: AssistantMessage = { - role: "assistant", - content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }], - api: model.api, - provider: model.provider, - model: model.id, - usage, - stopReason: "toolUse", - timestamp: now, - }; + // Whitespace-only cases: https://github.com/earendil-works/pi/issues/8720 + it.each(["", "\r\n", " \n\t"])( + "uses '(no tool output)' placeholder for tool result text %j without images", + (text) => { + const model = getModel("openai", "gpt-4o-mini"); + const now = Date.now(); + const assistant: AssistantMessage = { + role: "assistant", + content: [{ type: "toolCall", id: "tool-1", name: "bash", arguments: { command: "true" } }], + api: model.api, + provider: model.provider, + model: model.id, + usage, + stopReason: "toolUse", + timestamp: now, + }; - const context = normalizeContext({ - messages: [ - { role: "user", content: "Run the command", timestamp: now - 1 }, - assistant, - buildEmptyToolResult("tool-1", now + 1), - ], - }); + const context = normalizeContext({ + messages: [ + { role: "user", content: "Run the command", timestamp: now - 1 }, + assistant, + buildEmptyToolResult("tool-1", now + 1, text), + ], + }); - const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"])); - const functionCallOutput = input.find((item) => item.type === "function_call_output") as - | { type: "function_call_output"; output: string } - | undefined; + const input = convertResponsesMessages(model, context, new Set(["openai", "openai-codex", "opencode"])); + const functionCallOutput = input.find((item) => item.type === "function_call_output") as + | { type: "function_call_output"; output: string } + | undefined; - expect(functionCallOutput).toBeTruthy(); - expect(functionCallOutput?.output).toBe("(no tool output)"); - expect(functionCallOutput?.output).not.toContain("see attached image"); - }); + expect(functionCallOutput).toBeTruthy(); + expect(functionCallOutput?.output).toBe("(no tool output)"); + expect(functionCallOutput?.output).not.toContain("see attached image"); + }, + ); });