Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .changeset/8214-chatbot-anypart-state-widen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@object-ui/plugin-chatbot': minor
---

`uiMessagesToChatMessages` / `uiMessageToChatMessage` now accept `@ai-sdk/react`'s
`UIMessage[]` — the exact input the exported mappers are documented for
(objectui#8214).

**Not breaking.** The parameter type only got looser: every argument that compiled
before still compiles. `minor` rather than `patch` because a published signature
accepts input it refused before, which is a capability a consumer can newly rely on.

`mapMessages.ts`'s local `AnyPart` is written to absorb whatever a producer hands the
mapper — every other member is `string` / `unknown` — but `state` was typed against the
OUTPUT contract (`ChatToolInvocation['state']`, the tool-invocation lifecycle). The AI
SDK's own text and reasoning parts carry `state: 'streaming' | 'done'`, which is not in
that union, so the deliberately-permissive input interface was on that one property
STRICTER than the union it exists to absorb and the whole `UIMessage[]` assignment was
refused (TS2345). An app that followed the README and drove `useChat()` itself had to
add an `as never` / `as any` of its own, permanently disabling checking on that seam.

`AnyPart.state` is now `string`, and the one read site narrows through an `isToolState`
guard whose table is a `Record` over `ChatToolInvocation['state']` — so the output stays
exactly as checked as before, and the table cannot drift from the union it guards.

Small behaviour fix that falls out of the guard: a part carrying an unrecognized state
string (an AI SDK v4 snapshot's `'result'`, say) used to pass through verbatim into
`ChatToolInvocation.state`, fall past every branch in `getToolState`, and render a
finished call as "Running" forever. It now normalizes to `undefined`, which is the
documented "infer from `errorText` / `result`" case.
10 changes: 0 additions & 10 deletions packages/plugin-chatbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,16 +350,6 @@ of writing your own — they handle `parts: [{ type: 'text' | 'reasoning'
| 'tool-*' | 'source-*' }]`, the streaming-cursor flag, and the legacy
`msg.toolInvocations` fallback:

> ⚠️ Today this call needs a cast on the reader's side. `uiMessagesToChatMessages`
> declares its parameter as the package's own permissive `AnyUIMessage[]`, whose
> `AnyPart.state` is typed as the tool-invocation state union — so a `TextUIPart`
> carrying `state: 'streaming' | 'done'` is refused, and with it the whole
> `UIMessage[]` that `useChat()` returns. The block below is what you should
> write; it compiles once objectui#8214 widens that member. Nothing about the
> mapper's runtime behaviour is affected.

<!-- doc-snippet: fragment — `uiMessagesToChatMessages` declares its parameter as the package's own `AnyUIMessage[]`, whose `AnyPart.state` is the tool-invocation state union, so `@ai-sdk/react`'s `UIMessage` — whose text parts carry `state: 'streaming' | 'done'` — is refused at the very call this section is about (measured: TS2345 x1). objectui#8214 holds that parameter type; this block is what a reader should write and compiles the day it lands -->

```tsx
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
Expand Down
5 changes: 3 additions & 2 deletions packages/plugin-chatbot/src/__tests__/mapMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* `@ai-sdk/react`'s `useChat()` directly (e.g. Studio's chat panel).
*/
import { describe, it, expect } from 'vitest';
import type { UIMessage } from '@ai-sdk/react';
import {
uiMessageToChatMessage,
uiMessagesToChatMessages,
Expand Down Expand Up @@ -120,7 +121,7 @@ describe('uiMessageToChatMessage', () => {
});

it('uiMessagesToChatMessages: only the streaming tail keeps a tool Running; prior messages terminalize', () => {
const msgs = [
const msgs: UIMessage[] = [
{
id: 'm-prior',
role: 'assistant',
Expand All @@ -132,7 +133,7 @@ describe('uiMessageToChatMessage', () => {
parts: [{ type: 'tool-add_field', toolCallId: 'c2', state: 'input-available', input: {} }],
},
];
const out = uiMessagesToChatMessages(msgs as never, { isStreaming: true });
const out = uiMessagesToChatMessages(msgs, { isStreaming: true });
expect(out[0].toolInvocations?.[0]?.state).toBe('output-available'); // prior turn → terminalized
expect(out[1].toolInvocations?.[0]?.state).toBe('input-available'); // live tail → still Running
});
Expand Down
44 changes: 42 additions & 2 deletions packages/plugin-chatbot/src/mapMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,18 @@ interface AnyPart {
args?: unknown;
result?: unknown;
errorText?: string;
state?: ChatToolInvocation['state'];
/**
* Free-form on purpose. `AnyPart` absorbs whatever a producer hands the
* mapper, and `state` is NOT one namespace: a tool part carries the
* tool-invocation lifecycle, while `@ai-sdk/react`'s text and reasoning
* parts carry `'streaming' | 'done'`. Typing this member against the
* OUTPUT contract made the deliberately-permissive input interface
* stricter than the union it exists to absorb, so the SDK's own
* `UIMessage[]` — the documented input of the exported mappers — was
* refused outright (objectui#8214). `isToolState` below is what keeps the
* output checked; this stays open.
*/
state?: string;
url?: string;
href?: string;
title?: string;
Expand All @@ -44,6 +55,35 @@ interface AnyUIMessage {
metadata?: unknown;
}

/**
* The tool-invocation lifecycle states as a runtime value. Declared as a
* `Record` over the union so the compiler rejects a typo here AND requires a
* row when `ChatToolInvocation['state']` grows: the table cannot drift from
* the type it guards.
*/
const TOOL_STATES: Record<NonNullable<ChatToolInvocation['state']>, true> = {
'input-streaming': true,
'input-available': true,
'approval-requested': true,
'approval-responded': true,
'output-available': true,
'output-error': true,
'output-denied': true,
};

/**
* Narrows a part's free-form `state` to the tool-invocation lifecycle. Any
* other spelling (a text/reasoning part's `'streaming' | 'done'`, an AI SDK v4
* snapshot's `'result'`) is not a tool state and maps to `undefined` — which is
* the documented "infer from `errorText` / `result`" case in
* `ChatbotEnhanced.getToolState`, not a loss: an unrecognized string used to
* pass through verbatim and fall past every branch there, rendering a finished
* call as "Running" forever.
*/
function isToolState(state: string | undefined): state is NonNullable<ChatToolInvocation['state']> {
return state !== undefined && state in TOOL_STATES;
}

function extractText(msg: AnyUIMessage, parts: AnyPart[]): string {
if (typeof msg.content === 'string') return msg.content;
return parts
Expand Down Expand Up @@ -631,7 +671,7 @@ function extractToolInvocations(
// ENDED, so it cannot still be running, output-snapshot or not.
// Only the actively-streaming trailing assistant message (`liveTail`)
// may legitimately keep a tool spinning; everything else is history.
const persistedState = p.state;
const persistedState = isToolState(p.state) ? p.state : undefined;
const isDanglingInput =
persistedState === 'input-available' || persistedState === 'input-streaming';
const baseState: ChatToolInvocation['state'] =
Expand Down
Loading