From bb18a049e91b4dfd07a1d5c7d6baf715828a8a22 Mon Sep 17 00:00:00 2001 From: Dmitry Kuznetsov <139351986+dmitry@users.noreply.local> Date: Mon, 25 May 2026 21:05:40 +0300 Subject: [PATCH] fix(stream): emit structured textual tool calls --- open-sse/utils/stream.ts | 33 ++++++++++++++++++++++++++------- tests/unit/stream-utils.test.ts | 6 ++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index afe8e26c61..b9eda7e997 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -249,11 +249,11 @@ function containsMalformedTextualToolCall(text: unknown): boolean { function collectPassthroughTextualToolCall( text: string, toolCalls: Map -): boolean { +): ToolCall | null { const parsed = parseTextualToolCallFromContent(text); - if (!parsed) return false; + if (!parsed) return null; const key = `textual:${toolCalls.size}`; - toolCalls.set(key, { + const toolCall: ToolCall = { id: `call_${Date.now()}_${toolCalls.size}`, index: toolCalls.size, type: "function", @@ -261,8 +261,21 @@ function collectPassthroughTextualToolCall( name: parsed.name, arguments: JSON.stringify(parsed.args || {}), }, - }); - return true; + }; + toolCalls.set(key, toolCall); + return toolCall; +} + +function toStreamingToolCallDelta(toolCall: ToolCall) { + return { + index: toolCall.index, + id: toolCall.id, + type: toolCall.type, + function: { + name: toolCall.function.name, + arguments: toolCall.function.arguments, + }, + }; } function toStreamFailureStatus(value: unknown): number | null { @@ -1384,11 +1397,17 @@ export function createSSEStream(options: StreamOptions = {}) { ) { const parsedCandidate = parseTextualToolCallCandidate(bufferedCandidate); if (parsedCandidate?.kind === "complete") { - collectPassthroughTextualToolCall(bufferedCandidate, passthroughToolCalls); + const collectedToolCall = collectPassthroughTextualToolCall( + bufferedCandidate, + passthroughToolCalls + ); + if (collectedToolCall) { + delta.tool_calls = [toStreamingToolCallDelta(collectedToolCall)]; + } passthroughHasToolCalls = true; textualToolCallConverted = true; passthroughBufferedTextualToolCallContent = ""; - delta.content = ""; + delete delta.content; } else if (parsedCandidate?.kind === "partial") { passthroughBufferedTextualToolCallContent = appendBoundedText( passthroughBufferedTextualToolCallContent, diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index c6d93d6774..1a35f19975 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -163,6 +163,9 @@ test("createSSEStream passthrough converts textual tool-call content into struct ); assert.equal(onCompletePayload.status, 200); + assert.match(text, /"tool_calls":\[/); + assert.match(text, /"name":"terminal"/); + assert.doesNotMatch(text, /"content":"\[Tool call: terminal/); const choice = onCompletePayload.responseBody.choices[0]; assert.equal(choice.finish_reason, "tool_calls"); assert.equal(choice.message.content, null); @@ -216,6 +219,9 @@ test("createSSEStream passthrough converts split textual tool-call content at co } ); + assert.match(text, /"tool_calls":\[/); + assert.match(text, /"name":"terminal"/); + assert.doesNotMatch(text, /"content":"\[Tool call: terminal/); const choice = onCompletePayload.responseBody.choices[0]; assert.equal(choice.finish_reason, "tool_calls"); assert.equal(choice.message.content, null);