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")