Skip to content

Commit 85f7f3a

Browse files
849261680steipete
authored andcommitted
fix: preserve responses tool args on empty done
1 parent bb27cbd commit 85f7f3a

2 files changed

Lines changed: 119 additions & 6 deletions

File tree

src/llm/providers/openai-responses-shared.test.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// OpenAI Responses shared tests cover tool conversion and response item mapping.
22
import type { Tool as OpenAIResponsesTool } from "openai/resources/responses/responses.js";
33
import { describe, expect, it } from "vitest";
4-
import type { Context, Model, Tool } from "../types.js";
5-
import { convertResponsesMessages } from "./openai-responses-shared.js";
4+
import type { AssistantMessage, Context, Model, Tool } from "../types.js";
5+
import { AssistantMessageEventStream } from "../utils/event-stream.js";
6+
import { convertResponsesMessages, processResponsesStream } from "./openai-responses-shared.js";
67
import { convertResponsesTools } from "./openai-responses-tools.js";
78

89
type ResponsesFunctionTool = Extract<OpenAIResponsesTool, { type: "function" }>;
@@ -32,6 +33,32 @@ const proxyOpenAIModel = {
3233
baseUrl: "https://proxy.example.com/v1",
3334
} satisfies Model<"openai-responses">;
3435

36+
function createAssistantOutput(): AssistantMessage {
37+
return {
38+
role: "assistant",
39+
api: nativeOpenAIModel.api,
40+
provider: nativeOpenAIModel.provider,
41+
model: nativeOpenAIModel.id,
42+
usage: {
43+
input: 0,
44+
output: 0,
45+
cacheRead: 0,
46+
cacheWrite: 0,
47+
totalTokens: 0,
48+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
49+
},
50+
stopReason: "stop",
51+
timestamp: 0,
52+
content: [],
53+
};
54+
}
55+
56+
async function* responseEvents(events: Array<Record<string, unknown>>) {
57+
for (const event of events) {
58+
yield event as never;
59+
}
60+
}
61+
3562
describe("convertResponsesTools", () => {
3663
it("enables native strict OpenAI Responses tools and normalizes schemas", () => {
3764
const tools = [
@@ -328,3 +355,82 @@ describe("convertResponsesMessages", () => {
328355
expect(functionCall).not.toHaveProperty("id");
329356
});
330357
});
358+
359+
describe("processResponsesStream", () => {
360+
it.each([
361+
["omits arguments", undefined],
362+
["sends empty arguments", ""],
363+
])("preserves streamed tool-call arguments when done %s", async (_label, doneArguments) => {
364+
const output = createAssistantOutput();
365+
const stream = new AssistantMessageEventStream();
366+
const events: Array<Record<string, unknown>> = [];
367+
const collect = (async () => {
368+
for await (const event of stream) {
369+
events.push(event as unknown as Record<string, unknown>);
370+
}
371+
})();
372+
373+
await processResponsesStream(
374+
responseEvents([
375+
{
376+
type: "response.output_item.added",
377+
item: {
378+
type: "function_call",
379+
id: "fc_read",
380+
call_id: "call_read",
381+
name: "read",
382+
arguments: "",
383+
},
384+
},
385+
{
386+
type: "response.function_call_arguments.delta",
387+
delta: '{"path":"docs/gateway/local-models.md"}',
388+
},
389+
{
390+
type: "response.function_call_arguments.done",
391+
...(doneArguments === undefined ? {} : { arguments: doneArguments }),
392+
item_id: "fc_read",
393+
name: "read",
394+
output_index: 0,
395+
sequence_number: 3,
396+
},
397+
{
398+
type: "response.output_item.done",
399+
item: {
400+
type: "function_call",
401+
id: "fc_read",
402+
call_id: "call_read",
403+
name: "read",
404+
},
405+
},
406+
{
407+
type: "response.completed",
408+
response: {
409+
id: "resp_1",
410+
status: "completed",
411+
},
412+
},
413+
]),
414+
output,
415+
stream,
416+
nativeOpenAIModel,
417+
);
418+
stream.end();
419+
await collect;
420+
421+
expect(output.stopReason).toBe("toolUse");
422+
expect(output.content).toEqual([
423+
{
424+
type: "toolCall",
425+
id: "call_read|fc_read",
426+
name: "read",
427+
arguments: { path: "docs/gateway/local-models.md" },
428+
},
429+
]);
430+
expect(events.map((event) => event.type)).toEqual([
431+
"toolcall_start",
432+
"toolcall_delta",
433+
"toolcall_end",
434+
]);
435+
});
436+
});

src/llm/providers/openai-responses-shared.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -667,11 +667,18 @@ export async function processResponsesStream<TApi extends Api>(
667667
} else if (event.type === "response.function_call_arguments.done") {
668668
if (currentItem?.type === "function_call" && currentBlock?.type === "toolCall") {
669669
const previousPartialJson = currentBlock.partialJson;
670-
currentBlock.partialJson = event.arguments;
671-
currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
670+
const doneArguments = typeof event.arguments === "string" ? event.arguments : undefined;
671+
672+
if (
673+
doneArguments !== undefined &&
674+
(doneArguments.length > 0 || previousPartialJson === "")
675+
) {
676+
currentBlock.partialJson = doneArguments;
677+
currentBlock.arguments = parseStreamingJson(currentBlock.partialJson);
678+
}
672679

673-
if (event.arguments.startsWith(previousPartialJson)) {
674-
const delta = event.arguments.slice(previousPartialJson.length);
680+
if (doneArguments?.startsWith(previousPartialJson)) {
681+
const delta = doneArguments.slice(previousPartialJson.length);
675682
if (delta.length > 0) {
676683
stream.push({
677684
type: "toolcall_delta",

0 commit comments

Comments
 (0)