fix(ai): treat whitespace-only tool output as empty

Whitespace-only tool results (e.g. a bare \r\n from bash) were sent verbatim
to OpenAI Completions, OpenAI Responses, and Google providers. Some providers
reject that with a 400, which then repeats on every request in the session.
They now use the same placeholder as empty output.

closes #8720
This commit is contained in:
David Brailovsky 2026-10-16 14:06:06 +02:00
parent 6fcb48670f
commit 4dd2b149d1
7 changed files with 95 additions and 66 deletions

View file

@ -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

View file

@ -291,7 +291,7 @@ export function convertMessages<T extends GoogleApiType>(model: Model<T>, 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

View file

@ -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 = {

View file

@ -88,7 +88,7 @@ function convertToolResultOutput<TApi extends Api>(
.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)");

View file

@ -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" });
});
});

View file

@ -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");
},
);
});

View file

@ -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");
},
);
});