Skip to content

Commit 78acfd4

Browse files
feat #20295
Co-authored-by: wasim-builds <108329802+wasim-builds@users.noreply.github.com>
1 parent 3da84fd commit 78acfd4

8 files changed

Lines changed: 207 additions & 5 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ai': patch
3+
---
4+
5+
feat(ai): add a stable UI message type and type guard for tool output errors

content/docs/04-ai-sdk-ui/03-chatbot-tool-usage.mdx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ export default function Chat() {
329329

330330
### Error handling
331331

332-
Sometimes an error may occur during client-side tool execution. Use the `addToolOutput` method with a `state` of `output-error` and `errorText` value instead of `output` record the error.
332+
Sometimes an error may occur during client-side tool execution. Use the `addToolOutput` method with a `state` of `output-error` and `errorText` value instead of `output` to record the error.
333333

334334
```tsx filename='app/page.tsx' highlight="19,36-41"
335335
'use client';
@@ -380,6 +380,21 @@ export default function Chat() {
380380
}
381381
```
382382

383+
When rendering messages, use `isToolOutputErrorUIPart` to identify failed
384+
static and dynamic tool parts without checking the tool state directly:
385+
386+
```tsx
387+
import { isToolOutputErrorUIPart, type UIMessage } from 'ai';
388+
389+
function ToolError({ part }: { part: UIMessage['parts'][number] }) {
390+
if (!isToolOutputErrorUIPart(part)) {
391+
return null;
392+
}
393+
394+
return <div role="alert">{part.errorText}</div>;
395+
}
396+
```
397+
383398
## Tool Execution Approval
384399

385400
Tool execution approval lets you require user confirmation before a server-side tool runs. Unlike [client-side tools](#example) that execute in the browser, tools with approval still execute on the server—but only after the user approves.

content/docs/07-reference/01-ai-sdk-core/31-ui-message.mdx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,34 @@ type ToolUIPart<TOOLS extends UITools = UITools> = ValueOf<{
203203
the tool part transitions from `approval-requested` to `approval-responded` and
204204
in later approval-bearing output states.
205205

206+
### `ToolOutputErrorUIPart`
207+
208+
A static or dynamic tool part whose execution failed. Use the
209+
`isToolOutputErrorUIPart` type guard when rendering messages so your code does
210+
not need to check the tool state discriminator directly.
211+
212+
```tsx
213+
import { isToolOutputErrorUIPart, type UIMessage } from 'ai';
214+
215+
function ToolError({ part }: { part: UIMessage['parts'][number] }) {
216+
if (!isToolOutputErrorUIPart(part)) {
217+
return null;
218+
}
219+
220+
return <div role="alert">{part.errorText}</div>;
221+
}
222+
```
223+
224+
The generic `ToolOutputErrorUIPart<TOOLS>` type preserves the input types of
225+
static tools and also includes dynamic tool errors:
226+
227+
```typescript
228+
type ToolOutputErrorUIPart<TOOLS extends UITools = UITools> = Extract<
229+
ToolUIPart<TOOLS> | DynamicToolUIPart,
230+
{ state: 'output-error' }
231+
>;
232+
```
233+
206234
### `CustomContentUIPart`
207235

208236
A provider-specific custom content part of a message.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { isToolOutputErrorUIPart, type UIDataTypes, type UIMessage } from 'ai';
2+
3+
type WeatherTools = {
4+
weather: {
5+
input: { city: string };
6+
output: { temperature: number };
7+
};
8+
};
9+
10+
const message: UIMessage<unknown, UIDataTypes, WeatherTools> = {
11+
id: 'message-1',
12+
role: 'assistant',
13+
parts: [
14+
{
15+
type: 'tool-weather',
16+
toolCallId: 'call-1',
17+
state: 'output-error',
18+
input: { city: 'Berlin' },
19+
errorText: 'Weather service unavailable',
20+
},
21+
],
22+
};
23+
24+
for (const part of message.parts) {
25+
if (isToolOutputErrorUIPart(part)) {
26+
console.log(`Tool failed: ${part.errorText}`);
27+
}
28+
}

packages/ai/src/ui/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export {
4444
isReasoningUIPart,
4545
isStaticToolUIPart,
4646
isTextUIPart,
47+
isToolOutputErrorUIPart,
4748
isToolUIPart,
4849
type CustomContentUIPart,
4950
type DataUIPart,
@@ -57,6 +58,7 @@ export {
5758
type SourceUrlUIPart,
5859
type StepStartUIPart,
5960
type TextUIPart,
61+
type ToolOutputErrorUIPart,
6062
type ToolUIPart,
6163
type UIDataTypes,
6264
type UIMessage,

packages/ai/src/ui/ui-messages.test-d.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { describe, it } from 'vitest';
2-
import type { DynamicToolUIPart, ToolUIPart } from './ui-messages';
1+
import { describe, expectTypeOf, it } from 'vitest';
2+
import {
3+
isToolOutputErrorUIPart,
4+
type DynamicToolUIPart,
5+
type ToolOutputErrorUIPart,
6+
type ToolUIPart,
7+
type UIMessagePart,
8+
} from './ui-messages';
39

410
type TestTools = {
511
weather: {
@@ -86,3 +92,50 @@ describe('UIMessagePart', () => {
8692
type _Responded = AssertAssignable<ToolUIPart<TestTools>, RespondedPart>;
8793
});
8894
});
95+
96+
describe('ToolOutputErrorUIPart', () => {
97+
it('represents static and dynamic tool output errors', () => {
98+
type StaticPart = {
99+
type: 'tool-weather';
100+
state: 'output-error';
101+
toolCallId: 'call-1';
102+
input: { city: 'Tokyo' };
103+
errorText: 'Weather service unavailable';
104+
};
105+
type _Static = AssertAssignable<
106+
ToolOutputErrorUIPart<TestTools>,
107+
StaticPart
108+
>;
109+
110+
type DynamicPart = {
111+
type: 'dynamic-tool';
112+
toolName: 'weather';
113+
state: 'output-error';
114+
toolCallId: 'call-2';
115+
input: { city: 'Tokyo' };
116+
errorText: 'Weather service unavailable';
117+
};
118+
type _Dynamic = AssertAssignable<
119+
ToolOutputErrorUIPart<TestTools>,
120+
DynamicPart
121+
>;
122+
});
123+
124+
it('narrows tool output errors while preserving static tool input types', () => {
125+
const part = null as unknown as UIMessagePart<
126+
Record<string, never>,
127+
TestTools
128+
>;
129+
130+
if (isToolOutputErrorUIPart(part)) {
131+
expectTypeOf(part).toEqualTypeOf<ToolOutputErrorUIPart<TestTools>>();
132+
expectTypeOf(part.errorText).toEqualTypeOf<string>();
133+
134+
if (part.type === 'tool-weather') {
135+
expectTypeOf(part.input).toEqualTypeOf<
136+
TestTools['weather']['input'] | undefined
137+
>();
138+
}
139+
}
140+
});
141+
});

packages/ai/src/ui/ui-messages.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
getStaticToolName,
44
isCustomContentUIPart,
55
isDataUIPart,
6+
isToolOutputErrorUIPart,
67
} from './ui-messages';
78

89
describe('getStaticToolName', () => {
@@ -82,3 +83,51 @@ describe('isDataUIPart', () => {
8283
).toBe(false);
8384
});
8485
});
86+
87+
describe('isToolOutputErrorUIPart', () => {
88+
it('should return true for a static tool output error part', () => {
89+
expect(
90+
isToolOutputErrorUIPart({
91+
type: 'tool-weather',
92+
toolCallId: 'tool1',
93+
state: 'output-error',
94+
input: { city: 'Berlin' },
95+
errorText: 'Weather service unavailable',
96+
}),
97+
).toBe(true);
98+
});
99+
100+
it('should return true for a dynamic tool output error part', () => {
101+
expect(
102+
isToolOutputErrorUIPart({
103+
type: 'dynamic-tool',
104+
toolName: 'weather',
105+
toolCallId: 'tool1',
106+
state: 'output-error',
107+
input: { city: 'Berlin' },
108+
errorText: 'Weather service unavailable',
109+
}),
110+
).toBe(true);
111+
});
112+
113+
it('should return false for a successful tool output part', () => {
114+
expect(
115+
isToolOutputErrorUIPart({
116+
type: 'tool-weather',
117+
toolCallId: 'tool1',
118+
state: 'output-available',
119+
input: { city: 'Berlin' },
120+
output: { temperature: 18 },
121+
}),
122+
).toBe(false);
123+
});
124+
125+
it('should return false for a non-tool part', () => {
126+
expect(
127+
isToolOutputErrorUIPart({
128+
type: 'text',
129+
text: 'Weather service unavailable',
130+
}),
131+
).toBe(false);
132+
});
133+
});

packages/ai/src/ui/ui-messages.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ export type UIToolInvocation<TOOL extends UITool | Tool> = {
361361
};
362362
}
363363
| {
364-
state: 'output-error'; // TODO AI SDK 6: change to 'error' state
364+
state: 'output-error';
365365
input: asUITool<TOOL>['input'] | undefined;
366366
rawInput?: unknown; // TODO AI SDK 6: remove this field, input should be unknown
367367
output?: never;
@@ -489,7 +489,7 @@ export type DynamicToolUIPart = {
489489
};
490490
}
491491
| {
492-
state: 'output-error'; // TODO AI SDK 6: change to 'error' state
492+
state: 'output-error';
493493
input: unknown;
494494
output?: never;
495495
errorText: string;
@@ -523,6 +523,17 @@ export type DynamicToolUIPart = {
523523
}
524524
);
525525

526+
/**
527+
* A static or dynamic tool UI part whose execution failed.
528+
*
529+
* Use `isToolOutputErrorUIPart` to identify tool output errors without
530+
* depending on the underlying tool state discriminator.
531+
*/
532+
export type ToolOutputErrorUIPart<TOOLS extends UITools = UITools> = Extract<
533+
ToolUIPart<TOOLS> | DynamicToolUIPart,
534+
{ state: 'output-error' }
535+
>;
536+
526537
/**
527538
* Type guard to check if a message part is a text part.
528539
*/
@@ -603,6 +614,17 @@ export function isToolUIPart<TOOLS extends UITools>(
603614
return isStaticToolUIPart(part) || isDynamicToolUIPart(part);
604615
}
605616

617+
/**
618+
* Check if a message part is a tool output error part.
619+
*
620+
* This works for both static and dynamic tools.
621+
*/
622+
export function isToolOutputErrorUIPart<TOOLS extends UITools>(
623+
part: UIMessagePart<UIDataTypes, TOOLS>,
624+
): part is ToolOutputErrorUIPart<TOOLS> {
625+
return isToolUIPart(part) && part.state === 'output-error';
626+
}
627+
606628
/**
607629
* Returns the name of the static tool.
608630
*

0 commit comments

Comments
 (0)