From ae85d1b993b7ad5ec3e6e321a58b13f03364126d Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 22 Dec 2025 15:28:30 -0500 Subject: [PATCH 1/2] fix: emit tool_call_end events in OpenAI handler when streaming ends The OpenAiHandler was not emitting tool_call_end events when the API stream ended with finish_reason === 'tool_calls'. This could cause the extension to appear stuck waiting for more stream data. Changes: - Added tracking of active tool call IDs in createMessage() and handleStreamResponse() - Emit tool_call_end events when finish_reason === 'tool_calls' - Added test coverage for both regular and O3 family models Closes: #10275 Linear: ROO-269 --- src/api/providers/__tests__/openai.spec.ts | 8 ++++++ src/api/providers/openai.ts | 29 ++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 31fdaa23899..4469efd4d17 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -295,6 +295,10 @@ describe("OpenAiHandler", () => { name: undefined, arguments: '"value"}', }) + + // Verify tool_call_end event is emitted when finish_reason is "tool_calls" + const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + expect(toolCallEndChunks).toHaveLength(1) }) it("should yield tool calls even when finish_reason is not set (fallback behavior)", async () => { @@ -855,6 +859,10 @@ describe("OpenAiHandler", () => { name: undefined, arguments: "{}", }) + + // Verify tool_call_end event is emitted when finish_reason is "tool_calls" + const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") + expect(toolCallEndChunks).toHaveLength(1) }) it("should yield tool calls for O3 model even when finish_reason is not set (fallback behavior)", async () => { diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index b198fe11d37..d75c90c696d 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -194,9 +194,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ) let lastUsage + const activeToolCallIds = new Set() for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} + const finishReason = chunk.choices?.[0]?.finish_reason if (delta.content) { for (const chunk of matcher.update(delta.content)) { @@ -213,6 +215,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + activeToolCallIds.add(toolCall.id) + } yield { type: "tool_call_partial", index: toolCall.index, @@ -223,6 +228,15 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + // Emit tool_call_end events when finish_reason is "tool_calls" + // This ensures tool calls are finalized even if the stream doesn't properly close + if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { + for (const id of activeToolCallIds) { + yield { type: "tool_call_end", id } + } + activeToolCallIds.clear() + } + if (chunk.usage) { lastUsage = chunk.usage } @@ -443,8 +457,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } private async *handleStreamResponse(stream: AsyncIterable): ApiStream { + const activeToolCallIds = new Set() + for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta + const finishReason = chunk.choices?.[0]?.finish_reason if (delta) { if (delta.content) { @@ -457,6 +474,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl // Emit raw tool call chunks - NativeToolCallParser handles state management if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + activeToolCallIds.add(toolCall.id) + } yield { type: "tool_call_partial", index: toolCall.index, @@ -468,6 +488,15 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + // Emit tool_call_end events when finish_reason is "tool_calls" + // This ensures tool calls are finalized even if the stream doesn't properly close + if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { + for (const id of activeToolCallIds) { + yield { type: "tool_call_end", id } + } + activeToolCallIds.clear() + } + if (chunk.usage) { yield { type: "usage", From 4f9bc4922b4b2787f628047244cf944d55ab5a52 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 22 Dec 2025 15:56:07 -0500 Subject: [PATCH 2/2] refactor: extract processToolCalls helper to reduce duplication Consolidates the duplicated tool call processing logic from createMessage() and handleStreamResponse() into a single private helper method. This improves maintainability and ensures consistent behavior across both code paths. --- src/api/providers/openai.ts | 89 +++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index d75c90c696d..d6f50d02691 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -213,29 +213,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - if (delta.tool_calls) { - for (const toolCall of delta.tool_calls) { - if (toolCall.id) { - activeToolCallIds.add(toolCall.id) - } - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - - // Emit tool_call_end events when finish_reason is "tool_calls" - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { - for (const id of activeToolCallIds) { - yield { type: "tool_call_end", id } - } - activeToolCallIds.clear() - } + yield* this.processToolCalls(delta, finishReason, activeToolCallIds) if (chunk.usage) { lastUsage = chunk.usage @@ -471,30 +449,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta.tool_calls) { - for (const toolCall of delta.tool_calls) { - if (toolCall.id) { - activeToolCallIds.add(toolCall.id) - } - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - } - - // Emit tool_call_end events when finish_reason is "tool_calls" - // This ensures tool calls are finalized even if the stream doesn't properly close - if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { - for (const id of activeToolCallIds) { - yield { type: "tool_call_end", id } - } - activeToolCallIds.clear() + yield* this.processToolCalls(delta, finishReason, activeToolCallIds) } if (chunk.usage) { @@ -507,6 +462,46 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } + /** + * Helper generator to process tool calls from a stream chunk. + * Tracks active tool call IDs and yields tool_call_partial and tool_call_end events. + * @param delta - The delta object from the stream chunk + * @param finishReason - The finish_reason from the stream chunk + * @param activeToolCallIds - Set to track active tool call IDs (mutated in place) + */ + private *processToolCalls( + delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta | undefined, + finishReason: string | null | undefined, + activeToolCallIds: Set, + ): Generator< + | { type: "tool_call_partial"; index: number; id?: string; name?: string; arguments?: string } + | { type: "tool_call_end"; id: string } + > { + if (delta?.tool_calls) { + for (const toolCall of delta.tool_calls) { + if (toolCall.id) { + activeToolCallIds.add(toolCall.id) + } + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + + // Emit tool_call_end events when finish_reason is "tool_calls" + // This ensures tool calls are finalized even if the stream doesn't properly close + if (finishReason === "tool_calls" && activeToolCallIds.size > 0) { + for (const id of activeToolCallIds) { + yield { type: "tool_call_end", id } + } + activeToolCallIds.clear() + } + } + protected _getUrlHost(baseUrl?: string): string { try { return new URL(baseUrl ?? "").host