Description
Summary
When a model calls a tool that doesn't exist in the tools object (e.g. a hallucinated tool name), processUIMessageStream creates two message parts for the same toolCallId — one static (tool-{toolName}) and one dynamic (dynamic-tool). This causes downstream failures when the conversation is continued, since the model API rejects requests containing two tool results for the same tool call ID.
Steps to Reproduce
- Configure streamText with a set of tools.
- Have the model call a tool name that is not in the tools object.
- The stream emits tool-input-start → tool-input-delta → tool-input-error → tool-output-error for the same toolCallId.
- Observe that the resulting assistant message contains two parts with the same toolCallId.
Root Cause
In process-ui-message-stream.ts:
- tool-input-start arrives without dynamic set (the tool isn't in the tools object, so
tool?.type === 'dynamic' is undefined). Since chunk.dynamic is falsy, updateToolPart is called, which creates a static part (e.g. type: "tool-some-nonexistent-tool-name").
- tool-input-error arrives with dynamic: true (set by
parseToolCall's catch block for NoSuchToolError). The handler branches on chunk.dynamic and calls updateDynamicToolPart, which only searches for parts with type === 'dynamic-tool'. It doesn't find the existing static part, so it creates a second part with type: "dynamic-tool".
Expected Behavior
Only one message part should exist per toolCallId. When tool-input-error arrives for a toolCallId that already has a part (regardless of whether it's static or dynamic), it should update that existing part rather than creating a new one.
Actual Behavior
Two parts are created for the same toolCallId:
{ type: "tool-{toolName}", toolCallId: "...", state: "input-streaming", ... }
{ type: "dynamic-tool", toolCallId: "...", state: "output-error", ... }
Impact
When these messages are persisted and sent in a subsequent request, convertToModelMessages produces two tool results for the same toolCallId, which the Anthropic API (and likely others) rejects as invalid.
AI SDK Version
Code of Conduct
Description
Summary
When a model calls a tool that doesn't exist in the tools object (e.g. a hallucinated tool name), processUIMessageStream creates two message parts for the same toolCallId — one static (tool-{toolName}) and one dynamic (dynamic-tool). This causes downstream failures when the conversation is continued, since the model API rejects requests containing two tool results for the same tool call ID.
Steps to Reproduce
Root Cause
In process-ui-message-stream.ts:
tool?.type === 'dynamic'isundefined). Sincechunk.dynamicis falsy,updateToolPartis called, which creates a static part (e.g.type: "tool-some-nonexistent-tool-name").parseToolCall's catch block forNoSuchToolError). The handler branches onchunk.dynamicand callsupdateDynamicToolPart, which only searches for parts withtype === 'dynamic-tool'. It doesn't find the existing static part, so it creates a second part withtype: "dynamic-tool".Expected Behavior
Only one message part should exist per
toolCallId. Whentool-input-errorarrives for atoolCallIdthat already has a part (regardless of whether it's static or dynamic), it should update that existing part rather than creating a new one.Actual Behavior
Two parts are created for the same toolCallId:
{ type: "tool-{toolName}", toolCallId: "...", state: "input-streaming", ... }{ type: "dynamic-tool", toolCallId: "...", state: "output-error", ... }Impact
When these messages are persisted and sent in a subsequent request,
convertToModelMessagesproduces two tool results for the same toolCallId, which the Anthropic API (and likely others) rejects as invalid.AI SDK Version
Code of Conduct