diff --git a/.changeset/8214-chatbot-anypart-state-widen.md b/.changeset/8214-chatbot-anypart-state-widen.md new file mode 100644 index 0000000000..4a64e716d0 --- /dev/null +++ b/.changeset/8214-chatbot-anypart-state-widen.md @@ -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. diff --git a/packages/plugin-chatbot/README.md b/packages/plugin-chatbot/README.md index 0b607af350..f8e6820959 100644 --- a/packages/plugin-chatbot/README.md +++ b/packages/plugin-chatbot/README.md @@ -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. - - - ```tsx import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; diff --git a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts index 11dd1cc456..145ed835fb 100644 --- a/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts +++ b/packages/plugin-chatbot/src/__tests__/mapMessages.test.ts @@ -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, @@ -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', @@ -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 }); diff --git a/packages/plugin-chatbot/src/mapMessages.ts b/packages/plugin-chatbot/src/mapMessages.ts index fdccc8d39c..287af55201 100644 --- a/packages/plugin-chatbot/src/mapMessages.ts +++ b/packages/plugin-chatbot/src/mapMessages.ts @@ -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; @@ -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, 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 { + return state !== undefined && state in TOOL_STATES; +} + function extractText(msg: AnyUIMessage, parts: AnyPart[]): string { if (typeof msg.content === 'string') return msg.content; return parts @@ -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'] =