Skip to content

Commit c556ded

Browse files
scribe-dandelion-cultCopilot
andcommitted
fix: use no-output placeholder for empty OpenAI tool results
Co-authored-by: Copilot <[email protected]>
1 parent c6ade83 commit c556ded

6 files changed

Lines changed: 361 additions & 9 deletions

src/agents/openai-transport-stream.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5075,6 +5075,122 @@ describe("openai transport stream", () => {
50755075
}
50765076
});
50775077

5078+
it("replays update_plan-style empty non-image Responses tool results as no output", () => {
5079+
const params = buildOpenAIResponsesParams(
5080+
{
5081+
id: "gpt-5.5",
5082+
name: "GPT-5.5",
5083+
api: "openai-responses",
5084+
provider: "openai",
5085+
baseUrl: "https://api.openai.com/v1",
5086+
reasoning: true,
5087+
input: ["text"],
5088+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
5089+
contextWindow: 200000,
5090+
maxTokens: 8192,
5091+
} satisfies Model<"openai-responses">,
5092+
{
5093+
systemPrompt: "system",
5094+
messages: [
5095+
{
5096+
role: "assistant",
5097+
api: "openai-responses",
5098+
provider: "openai",
5099+
model: "gpt-5.5",
5100+
usage: {
5101+
input: 0,
5102+
output: 0,
5103+
cacheRead: 0,
5104+
cacheWrite: 0,
5105+
totalTokens: 0,
5106+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
5107+
},
5108+
stopReason: "toolUse",
5109+
timestamp: 1,
5110+
content: [{ type: "toolCall", id: "call_plan", name: "update_plan", arguments: {} }],
5111+
},
5112+
{
5113+
role: "toolResult",
5114+
toolCallId: "call_plan",
5115+
toolName: "update_plan",
5116+
content: [],
5117+
isError: false,
5118+
timestamp: 2,
5119+
},
5120+
],
5121+
tools: [],
5122+
} as never,
5123+
{ sessionId: "session-123" },
5124+
) as {
5125+
input?: Array<{ type?: string; call_id?: string; output?: unknown }>;
5126+
};
5127+
5128+
expect(params.input?.find((item) => item.type === "function_call_output")).toMatchObject({
5129+
type: "function_call_output",
5130+
call_id: "call_plan",
5131+
output: "(no output)",
5132+
});
5133+
});
5134+
5135+
it("preserves image-bearing Responses tool results as image input parts", () => {
5136+
const params = buildOpenAIResponsesParams(
5137+
{
5138+
id: "gpt-5.5",
5139+
name: "GPT-5.5",
5140+
api: "openai-responses",
5141+
provider: "openai",
5142+
baseUrl: "https://api.openai.com/v1",
5143+
reasoning: true,
5144+
input: ["text", "image"],
5145+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
5146+
contextWindow: 200000,
5147+
maxTokens: 8192,
5148+
} satisfies Model<"openai-responses">,
5149+
{
5150+
systemPrompt: "system",
5151+
messages: [
5152+
{
5153+
role: "assistant",
5154+
api: "openai-responses",
5155+
provider: "openai",
5156+
model: "gpt-5.5",
5157+
usage: {
5158+
input: 0,
5159+
output: 0,
5160+
cacheRead: 0,
5161+
cacheWrite: 0,
5162+
totalTokens: 0,
5163+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
5164+
},
5165+
stopReason: "toolUse",
5166+
timestamp: 1,
5167+
content: [{ type: "toolCall", id: "call_shot", name: "screenshot", arguments: {} }],
5168+
},
5169+
{
5170+
role: "toolResult",
5171+
toolCallId: "call_shot",
5172+
toolName: "screenshot",
5173+
content: [{ type: "image", mimeType: "image/png", data: "aW1n" }],
5174+
isError: false,
5175+
timestamp: 2,
5176+
},
5177+
],
5178+
tools: [],
5179+
} as never,
5180+
{ sessionId: "session-123" },
5181+
) as {
5182+
input?: Array<{ type?: string; output?: unknown }>;
5183+
};
5184+
5185+
expect(params.input?.find((item) => item.type === "function_call_output")?.output).toEqual([
5186+
{
5187+
type: "input_image",
5188+
detail: "auto",
5189+
image_url: "data:image/png;base64,aW1n",
5190+
},
5191+
]);
5192+
});
5193+
50785194
it("omits distinct overlong Copilot Responses replay item ids when store is disabled", () => {
50795195
const sharedToolItemPrefix = "iVec" + "A".repeat(160);
50805196
const firstToolCallId = `call_first|${sharedToolItemPrefix}Aa`;

src/agents/openai-transport-stream.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import {
101101
failTransportStream,
102102
finalizeTransportStream,
103103
mergeTransportMetadata,
104+
sanitizeNonEmptyTransportPayloadText,
104105
sanitizeTransportPayloadText,
105106
} from "./transport-stream-shared.js";
106107

@@ -1277,6 +1278,8 @@ function convertResponsesMessages(
12771278
.filter((item) => item.type === "text")
12781279
.map((item) => item.text)
12791280
.join("\n");
1281+
const sanitizedTextResult = sanitizeTransportPayloadText(textResult);
1282+
const hasText = sanitizedTextResult.trim().length > 0;
12801283
const hasImages = msg.content.some((item) => item.type === "image");
12811284
const [callId] = msg.toolCallId.split("|");
12821285
messages.push({
@@ -1285,9 +1288,7 @@ function convertResponsesMessages(
12851288
output:
12861289
hasImages && model.input.includes("image")
12871290
? ([
1288-
...(textResult
1289-
? [{ type: "input_text", text: sanitizeTransportPayloadText(textResult) }]
1290-
: []),
1291+
...(hasText ? [{ type: "input_text", text: sanitizedTextResult }] : []),
12911292
...msg.content
12921293
.filter((item) => item.type === "image")
12931294
.map((item) => ({
@@ -1296,7 +1297,10 @@ function convertResponsesMessages(
12961297
image_url: `data:${item.mimeType};base64,${item.data}`,
12971298
})),
12981299
] as ResponseFunctionCallOutputItemList)
1299-
: sanitizeTransportPayloadText(textResult || "(see attached image)"),
1300+
: sanitizeNonEmptyTransportPayloadText(
1301+
textResult,
1302+
hasImages ? "(see attached image)" : "(no output)",
1303+
),
13001304
});
13011305
}
13021306
msgIndex += 1;

src/llm/providers/openai-completions.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,121 @@ describe("OpenAI-compatible completions params", () => {
248248
expect(capturedPayload?.tools).toEqual([]);
249249
});
250250

251+
it("replays update_plan-style empty non-image tool results as no output", async () => {
252+
let capturedMessages:
253+
| Array<{ role?: string; content?: unknown; tool_call_id?: string }>
254+
| undefined;
255+
const stream = streamOpenAICompletions(
256+
model,
257+
{
258+
messages: [
259+
{
260+
role: "assistant",
261+
api: model.api,
262+
provider: model.provider,
263+
model: model.id,
264+
usage: {
265+
input: 0,
266+
output: 0,
267+
cacheRead: 0,
268+
cacheWrite: 0,
269+
totalTokens: 0,
270+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
271+
},
272+
stopReason: "toolUse",
273+
content: [{ type: "toolCall", id: "call_plan", name: "update_plan", arguments: {} }],
274+
timestamp: 1,
275+
},
276+
{
277+
role: "toolResult",
278+
toolCallId: "call_plan",
279+
toolName: "update_plan",
280+
content: [],
281+
isError: false,
282+
timestamp: 2,
283+
},
284+
],
285+
} as never,
286+
{
287+
apiKey: "sk-test",
288+
onPayload(payload) {
289+
capturedMessages = (payload as { messages?: typeof capturedMessages }).messages;
290+
throw new Error("stop before network");
291+
},
292+
},
293+
);
294+
295+
const result = await stream.result();
296+
297+
expect(result.stopReason).toBe("error");
298+
expect(capturedMessages?.find((message) => message.role === "tool")).toMatchObject({
299+
role: "tool",
300+
content: "(no output)",
301+
tool_call_id: "call_plan",
302+
});
303+
});
304+
305+
it("preserves image-bearing tool results with image placeholders and attachments", async () => {
306+
let capturedMessages:
307+
| Array<{ role?: string; content?: unknown; tool_call_id?: string }>
308+
| undefined;
309+
const stream = streamOpenAICompletions(
310+
{ ...model, input: ["text", "image"] },
311+
{
312+
messages: [
313+
{
314+
role: "assistant",
315+
api: model.api,
316+
provider: model.provider,
317+
model: model.id,
318+
usage: {
319+
input: 0,
320+
output: 0,
321+
cacheRead: 0,
322+
cacheWrite: 0,
323+
totalTokens: 0,
324+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
325+
},
326+
stopReason: "toolUse",
327+
content: [{ type: "toolCall", id: "call_shot", name: "screenshot", arguments: {} }],
328+
timestamp: 1,
329+
},
330+
{
331+
role: "toolResult",
332+
toolCallId: "call_shot",
333+
toolName: "screenshot",
334+
content: [{ type: "image", mimeType: "image/png", data: "aW1n" }],
335+
isError: false,
336+
timestamp: 2,
337+
},
338+
],
339+
} as never,
340+
{
341+
apiKey: "sk-test",
342+
onPayload(payload) {
343+
capturedMessages = (payload as { messages?: typeof capturedMessages }).messages;
344+
throw new Error("stop before network");
345+
},
346+
},
347+
);
348+
349+
const result = await stream.result();
350+
351+
expect(result.stopReason).toBe("error");
352+
expect(capturedMessages?.find((message) => message.role === "tool")).toMatchObject({
353+
role: "tool",
354+
content: "(see attached image)",
355+
tool_call_id: "call_shot",
356+
});
357+
expect(capturedMessages?.find((message) => Array.isArray(message.content))).toMatchObject({
358+
role: "user",
359+
content: [
360+
{ type: "text", text: "Attached image(s) from tool result:" },
361+
{ type: "image_url", image_url: { url: "data:image/png;base64,aW1n" } },
362+
],
363+
});
364+
});
365+
251366
it("does not reread an unreadable tool inventory length", async () => {
252367
let capturedPayload: Record<string, unknown> | undefined;
253368
const tools = new Proxy([], {

src/llm/providers/openai-completions.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ function isImageContentBlock(block: { type: string }): block is ImageContent {
9090
return block.type === "image";
9191
}
9292

93+
const EMPTY_TOOL_RESULT_TEXT = "(no output)";
94+
const IMAGE_TOOL_RESULT_TEXT = "(see attached image)";
95+
96+
function sanitizeToolResultText(text: string, fallback: string): string {
97+
const sanitized = sanitizeSurrogates(text);
98+
return sanitized.trim().length > 0 ? sanitized : fallback;
99+
}
100+
93101
export interface OpenAICompletionsOptions extends StreamOptions {
94102
toolChoice?: OpenAICompletionsToolChoice;
95103
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -1135,11 +1143,14 @@ export function convertMessages(
11351143
const hasImages = toolMsg.content.some((c) => c.type === "image");
11361144

11371145
// Always send tool result with text (or placeholder if only images)
1138-
const hasText = textResult.length > 0;
1146+
const content = sanitizeToolResultText(
1147+
textResult,
1148+
hasImages ? IMAGE_TOOL_RESULT_TEXT : EMPTY_TOOL_RESULT_TEXT,
1149+
);
11391150
// Some providers require the 'name' field in tool results
11401151
const toolResultMsg: ChatCompletionToolMessageParam = {
11411152
role: "tool",
1142-
content: sanitizeSurrogates(hasText ? textResult : "(see attached image)"),
1153+
content,
11431154
tool_call_id: toolMsg.toolCallId,
11441155
};
11451156
if (compat.requiresToolResultName && toolMsg.toolName) {

0 commit comments

Comments
 (0)