From 410c42156f5d38381b38b53191e78a1fa23ad795 Mon Sep 17 00:00:00 2001 From: Haohao-end <2227625024@qq.com> Date: Sun, 22 Mar 2026 20:00:21 +0800 Subject: [PATCH] fix(provider): handle delayed tool call names in streamed responses --- .../openai-compatible-chat-language-model.ts | 150 ++++++++---------- .../copilot/copilot-chat-model.test.ts | 57 +++++++ .../opencode/test/session/message-v2.test.ts | 5 +- 3 files changed, 126 insertions(+), 86 deletions(-) diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts index c85d3f3d1780..b9c74fbfab4c 100644 --- a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts @@ -326,9 +326,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { id: string type: "function" function: { - name: string + name?: string arguments: string } + started: boolean hasFinished: boolean }> = [] @@ -363,6 +364,54 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { let isActiveReasoning = false let isActiveText = false let reasoningOpaque: string | undefined + const sync = ( + call: (typeof toolCalls)[number], + ctl: TransformStreamDefaultController, + done = false, + ) => { + if (call.function.name == null) { + return + } + + if (!call.started) { + ctl.enqueue({ + type: "tool-input-start", + id: call.id, + toolName: call.function.name, + }) + call.started = true + + if (call.function.arguments.length > 0) { + ctl.enqueue({ + type: "tool-input-delta", + id: call.id, + delta: call.function.arguments, + }) + } + } + + if (call.hasFinished) { + return + } + + if (!done && !isParsableJson(call.function.arguments)) { + return + } + + ctl.enqueue({ + type: "tool-input-end", + id: call.id, + }) + + ctl.enqueue({ + type: "tool-call", + toolCallId: call.id ?? generateId(), + toolName: call.function.name, + input: call.function.arguments, + providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, + }) + call.hasFinished = true + } return { stream: response.pipeThrough( @@ -524,59 +573,17 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { }) } - if (toolCallDelta.function?.name == null) { - throw new InvalidResponseDataError({ - data: toolCallDelta, - message: `Expected 'function.name' to be a string.`, - }) - } - - controller.enqueue({ - type: "tool-input-start", - id: toolCallDelta.id, - toolName: toolCallDelta.function.name, - }) - toolCalls[index] = { id: toolCallDelta.id, type: "function", function: { - name: toolCallDelta.function.name, + name: toolCallDelta.function?.name || undefined, arguments: toolCallDelta.function.arguments ?? "", }, + started: false, hasFinished: false, } - - const toolCall = toolCalls[index] - - if (toolCall.function?.name != null && toolCall.function?.arguments != null) { - // send delta if the argument text has already started: - if (toolCall.function.arguments.length > 0) { - controller.enqueue({ - type: "tool-input-delta", - id: toolCall.id, - delta: toolCall.function.arguments, - }) - } - - // check if tool call is complete - // (some providers send the full tool call in one chunk): - if (isParsableJson(toolCall.function.arguments)) { - controller.enqueue({ - type: "tool-input-end", - id: toolCall.id, - }) - - controller.enqueue({ - type: "tool-call", - toolCallId: toolCall.id ?? generateId(), - toolName: toolCall.function.name, - input: toolCall.function.arguments, - providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, - }) - toolCall.hasFinished = true - } - } + sync(toolCalls[index], controller) continue } @@ -588,37 +595,22 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { continue } + if (toolCall.function.name == null && toolCallDelta.function?.name) { + toolCall.function.name = toolCallDelta.function.name + } + if (toolCallDelta.function?.arguments != null) { toolCall.function!.arguments += toolCallDelta.function?.arguments ?? "" + if (toolCall.started) { + controller.enqueue({ + type: "tool-input-delta", + id: toolCall.id, + delta: toolCallDelta.function.arguments ?? "", + }) + } } - // send delta - controller.enqueue({ - type: "tool-input-delta", - id: toolCall.id, - delta: toolCallDelta.function.arguments ?? "", - }) - - // check if tool call is complete - if ( - toolCall.function?.name != null && - toolCall.function?.arguments != null && - isParsableJson(toolCall.function.arguments) - ) { - controller.enqueue({ - type: "tool-input-end", - id: toolCall.id, - }) - - controller.enqueue({ - type: "tool-call", - toolCallId: toolCall.id ?? generateId(), - toolName: toolCall.function.name, - input: toolCall.function.arguments, - providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, - }) - toolCall.hasFinished = true - } + sync(toolCall, controller) } } }, @@ -639,17 +631,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { // go through all tool calls and send the ones that are not finished for (const toolCall of toolCalls.filter((toolCall) => !toolCall.hasFinished)) { - controller.enqueue({ - type: "tool-input-end", - id: toolCall.id, - }) - - controller.enqueue({ - type: "tool-call", - toolCallId: toolCall.id ?? generateId(), - toolName: toolCall.function.name, - input: toolCall.function.arguments, - }) + sync(toolCall, controller, true) } const providerMetadata: SharedV2ProviderMetadata = { diff --git a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts index 562da4507d3f..ba99800f989a 100644 --- a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts +++ b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts @@ -71,6 +71,12 @@ const FIXTURES = { `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`, `data: [DONE]`, ], + + delayedToolName: [ + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"path\\":\\""},"id":"call_delayed","index":0,"type":"function"}]}}],"created":1769917421,"id":"delayed-name","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"qwen3-coder"}`, + `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"/README.md\\"}","name":"read_file"},"index":0,"type":"function"}]}}],"created":1769917421,"id":"delayed-name","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"qwen3-coder"}`, + `data: [DONE]`, + ], } function createMockFetch(chunks: string[]) { @@ -482,6 +488,57 @@ describe("doStream", () => { }) }) + test("should buffer tool deltas until function name arrives", async () => { + const mockFetch = createMockFetch(FIXTURES.delayedToolName) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + const errors = parts.filter((part) => part.type === "error") + const tool = parts.filter( + (part) => + part.type === "tool-input-start" || + part.type === "tool-input-delta" || + part.type === "tool-input-end" || + part.type === "tool-call", + ) + + expect(errors).toHaveLength(0) + expect(tool).toStrictEqual([ + { + type: "tool-input-start", + id: "call_delayed", + toolName: "read_file", + }, + { + type: "tool-input-delta", + id: "call_delayed", + delta: '{"path":"/README.md"}', + }, + { + type: "tool-input-end", + id: "call_delayed", + }, + { + type: "tool-call", + toolCallId: "call_delayed", + toolName: "read_file", + input: '{"path":"/README.md"}', + providerMetadata: undefined, + }, + ]) + + const finish = parts.find((part) => part.type === "finish") + expect(finish).toMatchObject({ + type: "finish", + finishReason: "tool-calls", + }) + }) + test("should include response metadata from first chunk", async () => { const mockFetch = createMockFetch(FIXTURES.basicText) const model = createModel(mockFetch) diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 0d5b89730a98..77de82fc7255 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -699,9 +699,10 @@ describe("session.message-v2.toModelMessage", () => { expect(MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) - test("converts pending/running tool calls to error results to prevent dangling tool_use", () => { + test("converts interrupted pending/running tool calls to error results to prevent dangling tool_use", () => { const userID = "m-user" const assistantID = "m-assistant" + const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() as MessageV2.Assistant["error"] const input: MessageV2.WithParts[] = [ { @@ -715,7 +716,7 @@ describe("session.message-v2.toModelMessage", () => { ] as MessageV2.Part[], }, { - info: assistantInfo(assistantID, userID), + info: assistantInfo(assistantID, userID, aborted), parts: [ { ...basePart(assistantID, "a1"),