From 087b200e5e3cb66ff68801041535d534d9e9e102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=81=BF=E5=89=91?= Date: Mon, 20 Jul 2026 00:01:51 +0800 Subject: [PATCH] fix(ai): tolerate empty-string tool call id/name in streaming deltas Some OpenAI-compatible APIs (e.g. DashScope token-plan / GLM-5.2) send empty strings for id and function.name on subsequent tool call deltas instead of omitting them or sending null. The nullish coalescing operator (??) does not treat empty strings as absent, causing appendOrStart to fail with 'tool call delta is missing id or name'. Treat empty strings as absent so the accumulated identity from the first delta is preserved. --- .../ai/src/protocols/utils/tool-stream.ts | 9 ++++- packages/ai/test/tool-stream.test.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/ai/src/protocols/utils/tool-stream.ts b/packages/ai/src/protocols/utils/tool-stream.ts index 1bb271f8899f..49edcd3d50b7 100644 --- a/packages/ai/src/protocols/utils/tool-stream.ts +++ b/packages/ai/src/protocols/utils/tool-stream.ts @@ -131,6 +131,11 @@ export const start = ( * identity on the first delta instead of a separate start event. OpenAI Chat has * this shape: `tool_calls[].index` is the stream key, and `id` / `name` may only * appear on the first delta for that index. + * + * Some OpenAI-compatible APIs (e.g. DashScope token-plan) send empty strings + * for `id` / `name` on subsequent deltas instead of omitting them. Treat + * empty strings as absent so the accumulated identity from the first delta + * is preserved. */ export const appendOrStart = ( route: string, @@ -140,8 +145,8 @@ export const appendOrStart = ( missingToolMessage: string, ): AppendOutcome | LLMError => { const current = tools[key] - const id = delta.id ?? current?.id - const name = delta.name ?? current?.name + const id = (delta.id || undefined) ?? current?.id + const name = (delta.name || undefined) ?? current?.name if (!id || !name) return eventError(route, missingToolMessage) const tool = { diff --git a/packages/ai/test/tool-stream.test.ts b/packages/ai/test/tool-stream.test.ts index c02b57c4862d..f52578f28e04 100644 --- a/packages/ai/test/tool-stream.test.ts +++ b/packages/ai/test/tool-stream.test.ts @@ -36,6 +36,44 @@ describe("ToolStream", () => { }), ) + it.effect("tolerates empty-string id and name on subsequent deltas", () => + Effect.gen(function* () { + const first = ToolStream.appendOrStart( + ADAPTER, + ToolStream.empty(), + 0, + { id: "call_1", name: "calculator", text: "{" }, + "missing tool", + ) + if (ToolStream.isError(first)) return yield* first + // DashScope token-plan sends id:"" and name:"" on continuation deltas + const second = ToolStream.appendOrStart( + ADAPTER, + first.tools, + 0, + { id: "", name: "", text: '"expression": "2+3"}' }, + "missing tool", + ) + if (ToolStream.isError(second)) return yield* second + const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0) + + expect(first.events).toEqual([ + { type: "tool-input-start", id: "call_1", name: "calculator" }, + { type: "tool-input-delta", id: "call_1", name: "calculator", text: "{" }, + ]) + expect(second.events).toEqual([ + { type: "tool-input-delta", id: "call_1", name: "calculator", text: '"expression": "2+3"}' }, + ]) + expect(finished).toEqual({ + tools: {}, + events: [ + { type: "tool-input-end", id: "call_1", name: "calculator" }, + { type: "tool-call", id: "call_1", name: "calculator", input: { expression: "2+3" } }, + ], + }) + }), + ) + it.effect("fails appendExisting when the provider skipped the tool start", () => Effect.gen(function* () { const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty(), 0, "{}", "missing tool")