fix(stream): emit structured textual tool calls

This commit is contained in:
Dmitry Kuznetsov
2026-05-25 21:05:40 +03:00
parent d25394b2c5
commit bb18a049e9
2 changed files with 32 additions and 7 deletions

View File

@@ -249,11 +249,11 @@ function containsMalformedTextualToolCall(text: unknown): boolean {
function collectPassthroughTextualToolCall(
text: string,
toolCalls: Map<string, ToolCall>
): 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,

View File

@@ -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);