Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/ai/src/ui/process-ui-message-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6706,6 +6706,104 @@ describe('processUIMessageStream', () => {
});
});

describe('tool input available with dynamic flag mismatch', () => {
// Regression: when tool-input-start creates a static part (dynamic is
// undefined because the tool isn't in the tools object) and tool-input-available
// arrives with dynamic: true (from parseToolCall's catch for NoSuchToolError),
// the available handler should update the existing static part instead of
// creating a second dynamic-tool part.
beforeEach(async () => {
const stream = createUIMessageStream([
{
type: 'start',
},
{
type: 'start-step',
},
{
toolCallId: 'call-1',
toolName: 'nonExistentTool',
type: 'tool-input-start',
// dynamic is NOT set (undefined) — this is what happens when the
// tool isn't in the tools object and the provider doesn't set it
},
{
inputTextDelta: '{ "foo": "bar" }',
toolCallId: 'call-1',
type: 'tool-input-delta',
},
{
input: { foo: 'bar' },
toolCallId: 'call-1',
toolName: 'nonExistentTool',
type: 'tool-input-available',
// dynamic IS set to true — this is what parseToolCall returns for
// invalid tool calls (NoSuchToolError catch)
dynamic: true,
},
{
type: 'finish-step',
},
{
type: 'finish',
},
]);

state = createStreamingUIMessageState({
messageId: 'msg-123',
lastMessage: undefined,
});

await consumeStream({
stream: processUIMessageStream({
stream,
runUpdateMessageJob,
onError: error => {
throw error;
},
}),
});
});

it('should produce exactly one tool part (no duplicate)', async () => {
const toolParts = state!.message.parts.filter(
(p: any) => p.toolCallId === 'call-1',
);
expect(toolParts).toHaveLength(1);
});

it('should keep the static tool type from tool-input-start', async () => {
const toolPart = state!.message.parts.find(
(p: any) => p.toolCallId === 'call-1',
) as any;
expect(toolPart.type).toBe('tool-nonExistentTool');
});

it('should have the correct final message state', async () => {
expect(state!.message.parts).toMatchInlineSnapshot(`
[
{
"type": "step-start",
},
{
"errorText": undefined,
"input": {
"foo": "bar",
},
"output": undefined,
"preliminary": undefined,
"providerExecuted": undefined,
"rawInput": undefined,
"state": "input-available",
"title": undefined,
"toolCallId": "call-1",
"type": "tool-nonExistentTool",
},
]
`);
});
});

describe('preliminary tool results', () => {
beforeEach(async () => {
const stream = createUIMessageStream([
Expand Down
13 changes: 12 additions & 1 deletion packages/ai/src/ui/process-ui-message-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,18 @@ export function processUIMessageStream<UI_MESSAGE extends UIMessage>({
}

case 'tool-input-available': {
if (chunk.dynamic) {
// When a part already exists for this toolCallId (e.g. from
// tool-input-start), honour its type so we update in place
// instead of creating a duplicate with a mismatched type.
const existingInputAvailablePart = state.message.parts
.filter(isToolUIPart)
.find(p => p.toolCallId === chunk.toolCallId);
const isInputAvailableDynamic =
existingInputAvailablePart != null
? existingInputAvailablePart.type === 'dynamic-tool'
: !!chunk.dynamic;

if (isInputAvailableDynamic) {
updateDynamicToolPart({
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
Expand Down