mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-01 09:33:36 +00:00
fix: use no-output placeholder for empty OpenAI tool results (#97423)
Co-authored-by: scribe-dandelion-cult <scribe-dandelion-cult@hotmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -5075,6 +5075,122 @@ describe("openai transport stream", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("replays update_plan-style empty non-image Responses tool results as no output", () => {
|
||||
const params = buildOpenAIResponsesParams(
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-responses">,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
content: [{ type: "toolCall", id: "call_plan", name: "update_plan", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_plan",
|
||||
toolName: "update_plan",
|
||||
content: [],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
} as never,
|
||||
{ sessionId: "session-123" },
|
||||
) as {
|
||||
input?: Array<{ type?: string; call_id?: string; output?: unknown }>;
|
||||
};
|
||||
|
||||
expect(params.input?.find((item) => item.type === "function_call_output")).toMatchObject({
|
||||
type: "function_call_output",
|
||||
call_id: "call_plan",
|
||||
output: "(no output)",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves image-bearing Responses tool results as image input parts", () => {
|
||||
const params = buildOpenAIResponsesParams(
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-responses">,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
content: [{ type: "toolCall", id: "call_shot", name: "screenshot", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_shot",
|
||||
toolName: "screenshot",
|
||||
content: [{ type: "image", mimeType: "image/png", data: "aW1n" }],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
tools: [],
|
||||
} as never,
|
||||
{ sessionId: "session-123" },
|
||||
) as {
|
||||
input?: Array<{ type?: string; output?: unknown }>;
|
||||
};
|
||||
|
||||
expect(params.input?.find((item) => item.type === "function_call_output")?.output).toEqual([
|
||||
{
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: "data:image/png;base64,aW1n",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits distinct overlong Copilot Responses replay item ids when store is disabled", () => {
|
||||
const sharedToolItemPrefix = "iVec" + "A".repeat(160);
|
||||
const firstToolCallId = `call_first|${sharedToolItemPrefix}Aa`;
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
failTransportStream,
|
||||
finalizeTransportStream,
|
||||
mergeTransportMetadata,
|
||||
sanitizeNonEmptyTransportPayloadText,
|
||||
sanitizeTransportPayloadText,
|
||||
} from "./transport-stream-shared.js";
|
||||
|
||||
@@ -1277,6 +1278,8 @@ function convertResponsesMessages(
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n");
|
||||
const sanitizedTextResult = sanitizeTransportPayloadText(textResult);
|
||||
const hasText = sanitizedTextResult.trim().length > 0;
|
||||
const hasImages = msg.content.some((item) => item.type === "image");
|
||||
const [callId] = msg.toolCallId.split("|");
|
||||
messages.push({
|
||||
@@ -1285,9 +1288,7 @@ function convertResponsesMessages(
|
||||
output:
|
||||
hasImages && model.input.includes("image")
|
||||
? ([
|
||||
...(textResult
|
||||
? [{ type: "input_text", text: sanitizeTransportPayloadText(textResult) }]
|
||||
: []),
|
||||
...(hasText ? [{ type: "input_text", text: sanitizedTextResult }] : []),
|
||||
...msg.content
|
||||
.filter((item) => item.type === "image")
|
||||
.map((item) => ({
|
||||
@@ -1296,7 +1297,10 @@ function convertResponsesMessages(
|
||||
image_url: `data:${item.mimeType};base64,${item.data}`,
|
||||
})),
|
||||
] as ResponseFunctionCallOutputItemList)
|
||||
: sanitizeTransportPayloadText(textResult || "(see attached image)"),
|
||||
: sanitizeNonEmptyTransportPayloadText(
|
||||
textResult,
|
||||
hasImages ? "(see attached image)" : "(no output)",
|
||||
),
|
||||
});
|
||||
}
|
||||
msgIndex += 1;
|
||||
|
||||
@@ -248,6 +248,121 @@ describe("OpenAI-compatible completions params", () => {
|
||||
expect(capturedPayload?.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("replays update_plan-style empty non-image tool results as no output", async () => {
|
||||
let capturedMessages:
|
||||
| Array<{ role?: string; content?: unknown; tool_call_id?: string }>
|
||||
| undefined;
|
||||
const stream = streamOpenAICompletions(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
content: [{ type: "toolCall", id: "call_plan", name: "update_plan", arguments: {} }],
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_plan",
|
||||
toolName: "update_plan",
|
||||
content: [],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
onPayload(payload) {
|
||||
capturedMessages = (payload as { messages?: typeof capturedMessages }).messages;
|
||||
throw new Error("stop before network");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(capturedMessages?.find((message) => message.role === "tool")).toMatchObject({
|
||||
role: "tool",
|
||||
content: "(no output)",
|
||||
tool_call_id: "call_plan",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves image-bearing tool results with image placeholders and attachments", async () => {
|
||||
let capturedMessages:
|
||||
| Array<{ role?: string; content?: unknown; tool_call_id?: string }>
|
||||
| undefined;
|
||||
const stream = streamOpenAICompletions(
|
||||
{ ...model, input: ["text", "image"] },
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
content: [{ type: "toolCall", id: "call_shot", name: "screenshot", arguments: {} }],
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_shot",
|
||||
toolName: "screenshot",
|
||||
content: [{ type: "image", mimeType: "image/png", data: "aW1n" }],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
{
|
||||
apiKey: "sk-test",
|
||||
onPayload(payload) {
|
||||
capturedMessages = (payload as { messages?: typeof capturedMessages }).messages;
|
||||
throw new Error("stop before network");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await stream.result();
|
||||
|
||||
expect(result.stopReason).toBe("error");
|
||||
expect(capturedMessages?.find((message) => message.role === "tool")).toMatchObject({
|
||||
role: "tool",
|
||||
content: "(see attached image)",
|
||||
tool_call_id: "call_shot",
|
||||
});
|
||||
expect(capturedMessages?.find((message) => Array.isArray(message.content))).toMatchObject({
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Attached image(s) from tool result:" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,aW1n" } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reread an unreadable tool inventory length", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const tools = new Proxy([], {
|
||||
|
||||
@@ -90,6 +90,14 @@ function isImageContentBlock(block: { type: string }): block is ImageContent {
|
||||
return block.type === "image";
|
||||
}
|
||||
|
||||
const EMPTY_TOOL_RESULT_TEXT = "(no output)";
|
||||
const IMAGE_TOOL_RESULT_TEXT = "(see attached image)";
|
||||
|
||||
function sanitizeToolResultText(text: string, fallback: string): string {
|
||||
const sanitized = sanitizeSurrogates(text);
|
||||
return sanitized.trim().length > 0 ? sanitized : fallback;
|
||||
}
|
||||
|
||||
export interface OpenAICompletionsOptions extends StreamOptions {
|
||||
toolChoice?: OpenAICompletionsToolChoice;
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
@@ -1135,11 +1143,14 @@ 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 content = sanitizeToolResultText(
|
||||
textResult,
|
||||
hasImages ? IMAGE_TOOL_RESULT_TEXT : EMPTY_TOOL_RESULT_TEXT,
|
||||
);
|
||||
// Some providers require the 'name' field in tool results
|
||||
const toolResultMsg: ChatCompletionToolMessageParam = {
|
||||
role: "tool",
|
||||
content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
|
||||
content,
|
||||
tool_call_id: toolMsg.toolCallId,
|
||||
};
|
||||
if (compat.requiresToolResultName && toolMsg.toolName) {
|
||||
|
||||
@@ -481,6 +481,100 @@ describe("convertResponsesMessages", () => {
|
||||
expect(functionCall).not.toHaveProperty("id");
|
||||
});
|
||||
|
||||
it("replays update_plan-style empty non-image tool results as no output", () => {
|
||||
const input = convertResponsesMessages(
|
||||
nativeOpenAIModel,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: nativeOpenAIModel.api,
|
||||
provider: nativeOpenAIModel.provider,
|
||||
model: nativeOpenAIModel.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
content: [{ type: "toolCall", id: "call_plan", name: "update_plan", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_plan",
|
||||
toolName: "update_plan",
|
||||
content: [],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
} satisfies Context,
|
||||
allowedToolCallProviders,
|
||||
{ includeSystemPrompt: false },
|
||||
) as unknown as Array<Record<string, unknown>>;
|
||||
|
||||
const functionOutput = input.find((item) => item.type === "function_call_output");
|
||||
expect(functionOutput).toMatchObject({
|
||||
type: "function_call_output",
|
||||
call_id: "call_plan",
|
||||
output: "(no output)",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves image-bearing tool results instead of using no-output text", () => {
|
||||
const input = convertResponsesMessages(
|
||||
{ ...nativeOpenAIModel, input: ["text", "image"] },
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
api: nativeOpenAIModel.api,
|
||||
provider: nativeOpenAIModel.provider,
|
||||
model: nativeOpenAIModel.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1,
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_screenshot", name: "screenshot", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_screenshot",
|
||||
toolName: "screenshot",
|
||||
content: [{ type: "image", mimeType: "image/png", data: "aW1n" }],
|
||||
isError: false,
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
} satisfies Context,
|
||||
allowedToolCallProviders,
|
||||
{ includeSystemPrompt: false },
|
||||
) as unknown as Array<{ type?: string; output?: unknown }>;
|
||||
|
||||
const functionOutput = input.find((item) => item.type === "function_call_output");
|
||||
expect(functionOutput?.output).toEqual([
|
||||
{
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: "data:image/png;base64,aW1n",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps encrypted reasoning replay item ids when requested", () => {
|
||||
const input = convertResponsesMessages(
|
||||
nativeOpenAIModel,
|
||||
|
||||
@@ -51,6 +51,14 @@ import { transformMessages } from "./transform-messages.js";
|
||||
// Utilities
|
||||
// =============================================================================
|
||||
|
||||
const EMPTY_TOOL_RESULT_TEXT = "(no output)";
|
||||
const IMAGE_TOOL_RESULT_TEXT = "(see attached image)";
|
||||
|
||||
function sanitizeToolResultText(text: string, fallback: string): string {
|
||||
const sanitized = sanitizeSurrogates(text);
|
||||
return sanitized.trim().length > 0 ? sanitized : fallback;
|
||||
}
|
||||
|
||||
type ReplayableResponseOutputMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
|
||||
type ReplayableResponseReasoningItem = Omit<ResponseReasoningItem, "id"> & { id?: string };
|
||||
type ResponsesTextContentPart =
|
||||
@@ -376,8 +384,9 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
.filter((c): c is TextContent => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
const sanitizedTextResult = sanitizeSurrogates(textResult);
|
||||
const hasImages = msg.content.some((c): c is ImageContent => c.type === "image");
|
||||
const hasText = textResult.length > 0;
|
||||
const hasText = sanitizedTextResult.trim().length > 0;
|
||||
const [callId] = msg.toolCallId.split("|");
|
||||
|
||||
let output: string | ResponseFunctionCallOutputItemList;
|
||||
@@ -387,7 +396,7 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
if (hasText) {
|
||||
contentParts.push({
|
||||
type: "input_text",
|
||||
text: sanitizeSurrogates(textResult),
|
||||
text: sanitizedTextResult,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -403,7 +412,10 @@ export function convertResponsesMessages<TApi extends Api>(
|
||||
|
||||
output = contentParts;
|
||||
} else {
|
||||
output = sanitizeSurrogates(hasText ? textResult : "(see attached image)");
|
||||
output = sanitizeToolResultText(
|
||||
textResult,
|
||||
hasImages ? IMAGE_TOOL_RESULT_TEXT : EMPTY_TOOL_RESULT_TEXT,
|
||||
);
|
||||
}
|
||||
|
||||
messages.push({
|
||||
|
||||
Reference in New Issue
Block a user