From f69b2c8961222303c7e69a2b49f9497266244a1a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 04:44:21 +0000 Subject: [PATCH] refactor(plugin-chatbot): one typed adapter at the ChatMessage seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderer.tsx` handed `@object-ui/types` (authoring) messages to components typed with the plugin's own (runtime) `ChatMessage` through three `messages as any` casts. Both contracts are deliberate and deliberately different, but the cast erased ALL of the drift rather than the intentional parts: a new authored role or a newly required runtime key would have kept compiling and surfaced as rendering behaviour instead of a type error. The three casts are now one conversion — `toRuntimeMessages` — with every narrowing decision named, documented and tested: - `role: 'tool'` IS an assistant message (unchanged rendering; the implicit fallthrough through `formatMessageProps` becomes the recorded decision). - `timestamp: Date` becomes its ISO string. The absorption is expressed once, in `toRuntimeTimestamp`, and consumed by both the seam and the hook's `normalizeMessages` — no third dialect. - `toolInvocations[].state` accepts three legacy spellings the runtime dropped (the fourth drift, unlisted in the issue); they map to their AI SDK v6 equivalents exactly as the authoring type's own doc comment declares. - Everything else passes through untouched, deliberately: in API mode the hook hands the seam RUNTIME messages wearing the authoring type, so a field-by-field rebuild would drop the HITL approval card, the draft-review affordance, the build panel and the charts. Fixes #4399 --- .changeset/chatmessage-seam-adapter.md | 20 ++ packages/plugin-chatbot/README.md | 30 +++ .../__tests__/chat-message-contract.test.ts | 129 +++++++++++ .../src/__tests__/chatMessageAdapter.test.ts | 173 +++++++++++++++ .../src/__tests__/renderer.seam.test.tsx | 103 +++++++++ .../plugin-chatbot/src/chatMessageAdapter.ts | 201 ++++++++++++++++++ packages/plugin-chatbot/src/index.tsx | 16 ++ packages/plugin-chatbot/src/renderer.tsx | 25 ++- packages/plugin-chatbot/src/useObjectChat.ts | 16 +- 9 files changed, 706 insertions(+), 7 deletions(-) create mode 100644 .changeset/chatmessage-seam-adapter.md create mode 100644 packages/plugin-chatbot/src/__tests__/chatMessageAdapter.test.ts create mode 100644 packages/plugin-chatbot/src/__tests__/renderer.seam.test.tsx create mode 100644 packages/plugin-chatbot/src/chatMessageAdapter.ts diff --git a/.changeset/chatmessage-seam-adapter.md b/.changeset/chatmessage-seam-adapter.md new file mode 100644 index 0000000000..9a6c9a8ae7 --- /dev/null +++ b/.changeset/chatmessage-seam-adapter.md @@ -0,0 +1,20 @@ +--- +'@object-ui/plugin-chatbot': patch +--- + +Replace the three `messages as any` casts at the `@object-ui/types` ↔ +`@object-ui/plugin-chatbot` `ChatMessage` boundary with one explicit typed +adapter (`toRuntimeMessages` / `authoredToRuntimeMessage`, now exported). + +The authoring contract (`ChatbotSchema['messages']`) and the runtime contract +`` renders are both deliberate and deliberately different; the +casts erased ALL of that drift rather than the intentional parts, so a future +vocabulary move would have surfaced as rendering behaviour instead of a type +error. Each narrowing is now named, documented and tested: an authored +`role: 'tool'` message is an assistant message (unchanged rendering — the +implicit fallthrough is now the recorded decision), a `Date` timestamp becomes +its ISO string (one expression, consumed by both the seam and the hook's +`normalizeMessages`), and the legacy tool-invocation states +`'partial-call'`/`'call'`/`'result'` map to their AI SDK v6 equivalents as the +authoring type's own documentation declares — previously they reached the tool +chip unrecognised and rendered a status badge with no label. diff --git a/packages/plugin-chatbot/README.md b/packages/plugin-chatbot/README.md index 1f96997957..5c3c076ed4 100644 --- a/packages/plugin-chatbot/README.md +++ b/packages/plugin-chatbot/README.md @@ -282,6 +282,36 @@ Note that `@object-ui/types` also exports a `ChatMessage`. That one is the runtime one. Import the schema type from `@object-ui/types` and the runtime type from this package. +### Authoring → runtime: `toRuntimeMessages` + +The two contracts are both deliberate, so they drift — and the conversion +between them is a real decision, not a formality. If you hold **authored** +messages (`@object-ui/types`) and want to render them with the components in +this package, convert them; do not cast: + +```tsx +import type { ChatMessage as AuthoredChatMessage } from '@object-ui/types'; +import { ChatbotEnhanced, toRuntimeMessages } from '@object-ui/plugin-chatbot'; + +function MyAuthoredChat({ messages }: { messages: AuthoredChatMessage[] }) { + return ; +} +``` + +| key | authoring (`@object-ui/types`) | runtime (this package) | what the adapter decides | +|---|---|---|---| +| `role` | `'user' \| 'assistant' \| 'system' \| 'tool'` | `'user' \| 'assistant' \| 'system'` | a `'tool'` message **is an assistant message**: it renders as an assistant bubble with its content shown. `'system'` keeps its own role (`` renders it as a centred pill). | +| `timestamp` | `string \| Date` | `string` | a `Date` becomes its ISO 8601 string. The runtime renders the timestamp straight into a React child, where an object throws. | +| `toolInvocations[].state` | AI SDK v6 states **+ legacy** `'partial-call' \| 'call' \| 'result'` | v6 states only | the legacy spellings map to `'input-streaming'` / `'input-available'` / `'output-available'` — the mapping the authoring type's own docs declare. | +| everything else | — | — | passed through untouched, including keys the runtime contract does not declare. | + +The three registered SDUI renderers (`chatbot`, `chatbot-enhanced`, +`chatbot-floating`) use this adapter; they used to use `messages as any`, which +compiled away the intentional drift and any accidental drift with it +(objectui#4399). `authoredToRuntimeMessage` is the single-message variant, and +`toRuntimeRole` / `toRuntimeTimestamp` / `toRuntimeToolState` are the individual +decisions if you need one on its own. + ### Message mapping helpers If you wire `@ai-sdk/react`'s `useChat()` directly and want to render its diff --git a/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts b/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts index cfb2d1a3f6..d7d5b3eb6a 100644 --- a/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts +++ b/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts @@ -40,6 +40,9 @@ import { fileURLToPath } from 'node:url'; import type { ChatMessage as BarrelChatMessage, ChatbotEnhancedMessage } from '../index'; /** The shape `` renders and the mappers produce. */ import type { ChatMessage as EnhancedChatMessage } from '../ChatbotEnhanced'; +/** The OTHER side of the seam: the JSON/SDUI authoring contract. */ +import type { ChatMessage as AuthoredChatMessage } from '@object-ui/types'; +import type { authoredToRuntimeMessage, toRuntimeMessages } from '../chatMessageAdapter'; type Assert = T; type IsAny = 0 extends 1 & T ? true : false; @@ -131,6 +134,132 @@ describe("the barrel's ChatMessage IS the enhanced shape", () => { }); }); +describe('the authoring ↔ runtime seam is an adapter, not a cast', () => { + it('is pinned at compile time', () => { + // objectui#4399. `renderer.tsx` used to hand `@object-ui/types` messages to + // components typed with the shape above via three `messages as any`. These + // pins are what makes the replacement load-bearing: without the first one + // the adapter could quietly start returning something else, and without the + // second the adapter could become dead code without anyone noticing. + + // Probe hygiene first — same reason as the block above. + type _AuthoredNotAny = Assert, false>>; + type _AuthoredNotUnknown = Assert, false>>; + + // 1. The adapter's output IS the runtime contract — not a lookalike, not a + // widened cousin. A narrowing anywhere in `chatMessageAdapter.ts` (say a + // return type of `Omit`) turns this line red. + type _OutputIsRuntime = Assert< + Equal, EnhancedChatMessage> + >; + type _ArrayOutputIsRuntime = Assert< + Equal, EnhancedChatMessage[]> + >; + + // 2. …and the conversion is NECESSARY: the authoring shape is not directly + // assignable to the runtime one. If this ever flips to `true` the two + // contracts have converged and the adapter is ceremony — which is a + // review conversation, not something to discover by deleting it. + type _DriftIsReal = Assert< + Equal + >; + + // 3. The specific drifts, named individually, so a future vocabulary move + // says WHICH one moved instead of "types differ". Each of these is a + // decision recorded in `chatMessageAdapter.ts`. + type _AuthoringHasToolRole = Assert< + Equal + >; + type _RuntimeHasNoToolRole = Assert< + Equal, never> + >; + type _AuthoringTimestampAcceptsDate = Assert< + Equal + >; + type _RuntimeTimestampIsString = Assert< + Equal + >; + // The fourth drift, which the issue's table did not list: the authoring + // tool-state vocabulary carries three legacy spellings the runtime dropped. + type _LegacyToolStatesAreAuthorable = Assert< + Equal< + Extract< + NonNullable[number]['state']>, + 'partial-call' | 'call' | 'result' + >, + 'partial-call' | 'call' | 'result' + > + >; + type _RuntimeHasNoLegacyToolStates = Assert< + Equal< + Extract< + NonNullable[number]['state']>, + 'partial-call' | 'call' | 'result' + >, + never + > + >; + + expect(true).toBe(true); + }); +}); + +describe('the three renderer call sites go through the adapter', () => { + // The runtime net over the block above, for the `pnpm test` lane that erases + // type assertions. The failure it catches is a cast coming BACK: `as any` on + // this prop compiles forever and silently re-erases every drift the pins + // above name (objectui#4399). + const RENDERER = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'renderer.tsx'); + const source = readFileSync(RENDERER, 'utf8'); + + it('finds the renderer it is guarding', () => { + expect(source).toContain("ComponentRegistry.register('chatbot'"); + }); + + it('passes no cast to a `messages` prop', () => { + const casts = source.match(/messages=\{[^}]*\bas\s+(any|unknown)\b/g) ?? []; + expect( + casts, + 'packages/plugin-chatbot/src/renderer.tsx casts a `messages` prop again. The ' + + '@object-ui/types ↔ plugin ChatMessage seam is `toRuntimeMessages` in ' + + 'chatMessageAdapter.ts — a cast there erases every narrowing decision it ' + + 'records (objectui#4399).', + ).toEqual([]); + }); + + it('feeds all three registered chat components from the adapter', () => { + expect(source).toMatch(/import \{ toRuntimeMessages \} from '\.\/chatMessageAdapter';/); + // `chatbot`, `chatbot-enhanced`, `chatbot-floating` — one seam each. + expect(source.match(/toRuntimeMessages\(messages\)/g) ?? []).toHaveLength(3); + expect(source.match(/messages=\{runtimeMessages\}/g) ?? []).toHaveLength(3); + }); +}); + +describe('the Date → ISO absorption is expressed once', () => { + // The placement half of the ruling: the adapter must not become a THIRD + // dialect. `normalizeMessages` owned this coercion inline; it now consumes + // the adapter's `toRuntimeTimestamp`. Two copies of the ternary is the state + // this guards against — they drift, and the one a message went through is + // then a function of which mode the chat is in. + const HOOK = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'useObjectChat.ts'); + const source = readFileSync(HOOK, 'utf8'); + + it('finds the hook it is guarding', () => { + expect(source).toContain('function normalizeMessages'); + }); + + it('routes the hook through the adapter instead of restating the coercion', () => { + expect(source).toMatch(/import \{ toRuntimeTimestamp \} from '\.\/chatMessageAdapter';/); + expect(source).toContain('timestamp: toRuntimeTimestamp(msg.timestamp)'); + expect( + source.includes('toISOString()'), + 'useObjectChat.ts converts a timestamp itself again. That conversion is the ' + + 'seam\'s decision and lives in `toRuntimeTimestamp` (objectui#4399); a second ' + + 'copy here is the third dialect the card exists to prevent.', + ).toBe(false); + }); +}); + describe('the barrel no longer declares a message shape of its own', () => { // A runtime net over the pins above, for the `pnpm test` lane that erases // them. It reads the source rather than the type because the failure to diff --git a/packages/plugin-chatbot/src/__tests__/chatMessageAdapter.test.ts b/packages/plugin-chatbot/src/__tests__/chatMessageAdapter.test.ts new file mode 100644 index 0000000000..0e4125d52a --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/chatMessageAdapter.test.ts @@ -0,0 +1,173 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The authoring -> runtime chat-message adapter (objectui#4399). + * + * Each narrowing decision in `chatMessageAdapter.ts` gets a test here, so the + * decisions are recorded as behaviour and not only as prose. The compile-time + * half of the seam (the adapter's output IS the runtime contract; the authoring + * shape is NOT directly assignable to it) lives in `chat-message-contract.test.ts` + * beside PR #4400's pins — vitest erases type assertions, so they belong with + * the other `tsc`-only ones. + */ + +import { describe, it, expect } from 'vitest'; +import type { ChatMessage as AuthoredChatMessage } from '@object-ui/types'; +import { + authoredToRuntimeMessage, + toRuntimeMessages, + toRuntimeRole, + toRuntimeTimestamp, + toRuntimeToolInvocation, + toRuntimeToolState, +} from '../chatMessageAdapter'; + +const base: AuthoredChatMessage = { id: 'm1', role: 'user', content: 'hi' }; + +describe('role: the tool -> assistant decision (objectui#4399)', () => { + it("folds the authoring-only 'tool' role onto the assistant role", () => { + // The NAMED decision. Before the adapter this was an implicit fallthrough + // inside `formatMessageProps`; a 'tool' message is an assistant message as + // far as the runtime contract is concerned, and its content still renders. + expect(toRuntimeRole('tool')).toBe('assistant'); + expect(authoredToRuntimeMessage({ ...base, role: 'tool', content: 'tool said x' })).toMatchObject( + { role: 'assistant', content: 'tool said x' }, + ); + }); + + it('leaves the three shared roles alone', () => { + // 'system' in particular keeps its own role: `` renders it as a + // centred pill, which folding it to 'assistant' here would destroy. + expect(toRuntimeRole('user')).toBe('user'); + expect(toRuntimeRole('assistant')).toBe('assistant'); + expect(toRuntimeRole('system')).toBe('system'); + }); +}); + +describe('timestamp: the Date -> ISO absorption, expressed once', () => { + it('converts a Date to an ISO string', () => { + const at = new Date('2026-08-12T02:08:13.000Z'); + expect(toRuntimeTimestamp(at)).toBe('2026-08-12T02:08:13.000Z'); + expect(authoredToRuntimeMessage({ ...base, timestamp: at }).timestamp).toBe( + '2026-08-12T02:08:13.000Z', + ); + }); + + it('passes a string through and leaves undefined undefined', () => { + expect(toRuntimeTimestamp('10:31:00')).toBe('10:31:00'); + expect(toRuntimeTimestamp(undefined)).toBeUndefined(); + }); + + it('drops a value that is neither string nor Date instead of throwing', () => { + // Authored JSON is not type-checked at runtime. The pre-adapter code had + // this same `instanceof` guard; an unconditional `.toISOString()` would + // turn a bad authored timestamp into a crash instead of a missing one. + const notATimestamp = 1755000000000 as unknown as Date; + expect(toRuntimeTimestamp(notATimestamp)).toBeUndefined(); + }); +}); + +describe('toolInvocations: the legacy state vocabulary (the fourth drift)', () => { + it('maps the three legacy states onto their v6 equivalents', () => { + // The mapping the authoring type's own doc comment declares. + expect(toRuntimeToolState('partial-call')).toBe('input-streaming'); + expect(toRuntimeToolState('call')).toBe('input-available'); + expect(toRuntimeToolState('result')).toBe('output-available'); + }); + + it('leaves the v6 states and an absent state alone', () => { + expect(toRuntimeToolState('approval-requested')).toBe('approval-requested'); + expect(toRuntimeToolState('output-error')).toBe('output-error'); + expect(toRuntimeToolState(undefined)).toBeUndefined(); + }); + + it('narrows state without disturbing the rest of the invocation', () => { + const runtime = toRuntimeToolInvocation({ + toolCallId: 'call_1', + toolName: 'create_object', + args: { name: 'Loan' }, + result: { status: 'ok' }, + state: 'result', + }); + expect(runtime).toEqual({ + toolCallId: 'call_1', + toolName: 'create_object', + args: { name: 'Loan' }, + result: { status: 'ok' }, + state: 'output-available', + }); + }); +}); + +describe('pass-through: the API-mode payload survives the seam', () => { + // In API mode `useObjectChat` hands the renderer RUNTIME messages wearing the + // authoring type (`uiMessagesToChatMessages(...) as OuiChatMessage[]`), so the + // values arriving here carry keys the authoring contract does not declare. + // The `as any` casts preserved them by accident; the adapter must preserve + // them on purpose, or the HITL approval card, the "Review N changes" + // affordance and the build panel all vanish from the SDUI renderers. + const apiModeMessage = { + id: 'm2', + role: 'assistant', + content: 'built it', + metadata: { conversationId: 'c1' }, + buildProgress: { phase: 'done', items: [], done: 3, total: 3 }, + charts: [{ chartType: 'bar', data: [], series: [] }], + toolInvocations: [ + { + toolCallId: 'call_2', + toolName: 'apply_blueprint', + state: 'output-available', + pendingActionId: 'pa_1', + draftReview: { items: [{ type: 'object', name: 'Loan' }] }, + }, + ], + } as unknown as AuthoredChatMessage; + + it('keeps the runtime-only message keys', () => { + const runtime = authoredToRuntimeMessage(apiModeMessage); + expect(runtime.buildProgress).toEqual({ phase: 'done', items: [], done: 3, total: 3 }); + expect(runtime.charts).toHaveLength(1); + }); + + it('keeps the runtime-only tool-invocation extensions', () => { + const [tool] = authoredToRuntimeMessage(apiModeMessage).toolInvocations ?? []; + expect(tool?.pendingActionId).toBe('pa_1'); + expect(tool?.draftReview?.items).toEqual([{ type: 'object', name: 'Loan' }]); + }); + + it('keeps `metadata`, which the runtime contract does not declare', () => { + // Graded decision (objectui#4399): the runtime never reads `metadata` — + // there is no `{...message}` spread and no key walk in the package — so + // carrying it is inert either way. It is passed through rather than + // stripped because `useObjectChat` splices it in deliberately, and + // stripping it would be a change this typing card has no reason to make. + const runtime = authoredToRuntimeMessage(apiModeMessage) as { metadata?: unknown }; + expect(runtime.metadata).toEqual({ conversationId: 'c1' }); + }); +}); + +describe('toRuntimeMessages', () => { + it('maps an array and tolerates an absent one', () => { + expect(toRuntimeMessages(undefined)).toEqual([]); + expect( + toRuntimeMessages([ + { ...base, role: 'tool', timestamp: new Date('2026-08-12T02:08:13.000Z') }, + ]), + ).toEqual([ + { + id: 'm1', + role: 'assistant', + content: 'hi', + timestamp: '2026-08-12T02:08:13.000Z', + toolInvocations: undefined, + }, + ]); + }); +}); diff --git a/packages/plugin-chatbot/src/__tests__/renderer.seam.test.tsx b/packages/plugin-chatbot/src/__tests__/renderer.seam.test.tsx new file mode 100644 index 0000000000..d8ab9441ab --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/renderer.seam.test.tsx @@ -0,0 +1,103 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The authoring -> runtime seam, through the REAL renderer path (objectui#4399). + * + * `chatMessageAdapter.test.ts` pins the conversion in isolation; these pin the + * two decisions that are only observable as pixels, driven the way the SDUI + * engine drives them: the registered `chatbot` renderer, its own + * `useObjectChat`, its own ``. The acceptance bar for the card is + * ZERO rendered-output change, so both assertions describe what `main` + * rendered before the adapter existed — they are regression pins, not + * change pins. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import type { ChatbotSchema } from '@object-ui/types'; +// Side-effect import: this is what registers the three chat components. +import '../renderer'; + +/** The registered renderer, resolved exactly as the SDUI engine resolves it. */ +function chatbotRenderer() { + const impl = ComponentRegistry.get('chatbot', 'plugin-chatbot'); + if (!impl) throw new Error('plugin-chatbot:chatbot is not registered'); + return impl as React.ComponentType<{ schema: unknown }>; +} + +describe("a role:'tool' message renders as an assistant bubble", () => { + it('shows its content and wears the assistant avatar', () => { + const Chatbot = chatbotRenderer(); + // `'tool'` is authorable — `ChatMessageSchema` declares it — and has no + // runtime counterpart. The named decision is that it IS an assistant + // message (see `toRuntimeRole`), which is what the seam rendered before + // this card by falling through `formatMessageProps`. + render( + , + ); + + const content = screen.getByText('search returned 3 rows'); + expect(content).toBeInTheDocument(); + + // Assistant side: `` reverses the row for user messages only, and + // picks the assistant avatar fallback for everything else. + const row = content.closest('.flex.gap-3'); + expect(row).not.toBeNull(); + expect(row).toHaveClass('flex-row'); + expect(row).not.toHaveClass('flex-row-reverse'); + expect(row?.textContent).toContain('AI'); + expect(row?.textContent).not.toContain('You'); + + // …and NOT the centred system pill, which is the other non-user branch. + expect(content.closest('.justify-center')).toBeNull(); + }); +}); + +describe('a Date timestamp reaches the DOM as a string', () => { + it('renders the ISO form rather than throwing on an object child', () => { + const Chatbot = chatbotRenderer(); + // `timestamp: z.union([z.string(), z.date()])` is authorable. `` + // renders `{message.timestamp}` straight into a React child, so an + // unabsorbed `Date` is the "Objects are not valid as a React child" throw. + render( + , + ); + + expect(screen.getByText('stamped')).toBeInTheDocument(); + expect(screen.getByText('2026-08-12T02:08:13.000Z')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-chatbot/src/chatMessageAdapter.ts b/packages/plugin-chatbot/src/chatMessageAdapter.ts new file mode 100644 index 0000000000..5ff6311bc0 --- /dev/null +++ b/packages/plugin-chatbot/src/chatMessageAdapter.ts @@ -0,0 +1,201 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The `@object-ui/types` <-> `@object-ui/plugin-chatbot` chat-message seam + * (objectui#4399). + * + * Two `ChatMessage` types meet in `renderer.tsx`, both on purpose: + * + * - `@object-ui/types`' `ChatMessage` is the **authoring** contract — the + * JSON an SDUI schema declares (`ChatMessageSchema` in + * `packages/types/src/zod/complex.zod.ts`), so it is deliberately wider: + * a `'tool'` role and a `Date` timestamp are authorable. + * - `./ChatbotEnhanced`'s `ChatMessage` is the **runtime** contract — what + * the React components actually render, so it is deliberately narrower + * (three roles, string timestamps) and carries render-only keys the + * authoring surface has no business declaring (`buildProgress`, `charts`). + * + * Until this module they met as three `messages as any` casts, which erased + * ALL of the drift rather than the parts that are intentional: a new authored + * role or a newly required runtime key would have kept compiling and surfaced + * as rendering behaviour instead of a type error. This module is that seam, + * written down — one conversion, each narrowing decision named and tested. + * + * ## Narrowing decisions + * + * | key | authoring | runtime | decision | + * |------------------------|------------------------------------------|-----------------------------|----------| + * | `role` | `'user'\|'assistant'\|'system'\|'tool'` | `'user'\|'assistant'\|'system'` | `'tool'` renders as an **assistant** bubble — see {@link toRuntimeRole} | + * | `timestamp` | `string \| Date` | `string` | `Date` -> ISO 8601 — see {@link toRuntimeTimestamp} | + * | `toolInvocations[].state` | v6 states **+ legacy** `'partial-call'\|'call'\|'result'` | v6 states only | legacy -> v6, per the authoring type's own doc comment — see {@link toRuntimeToolState} | + * | `metadata` | `any` | *not declared* | passed through untouched (see "Pass-through" below) | + * | everything else | same shape on both sides | — | passed through untouched | + * + * ## Pass-through — why this is a conversion and not a reconstruction + * + * The adapter narrows the drifting fields **by name** and spreads the rest. It + * deliberately does NOT rebuild the message field-by-field from the authoring + * type, because in API mode the values arriving here are already RUNTIME + * messages wearing the authoring type: `useObjectChat` builds them with + * `uiMessagesToChatMessages(...) as OuiChatMessage[]` (see + * `useObjectChat.ts`), so at runtime they carry `buildProgress`, + * `blueprintProgress`, `charts` and tool invocations bearing the HITL / + * draft-review extensions — every one of which the authoring type erases. A + * field-by-field rebuild would silently drop the approval cards, the "Review N + * changes" affordance and the build panel. The cast used to preserve them by + * accident; the spread preserves them on purpose. + * + * The compile-time value of the seam is unaffected by that: a new authored + * `role` makes {@link toRuntimeRole} unassignable, and a newly REQUIRED runtime + * key makes {@link authoredToRuntimeMessage}'s return type red. Both are the + * type error the cast used to swallow. + */ + +import type { + ChatMessage as AuthoredChatMessage, + ChatToolInvocation as AuthoredToolInvocation, +} from '@object-ui/types'; +import type { + ChatMessage as RuntimeChatMessage, + ChatToolInvocation as RuntimeToolInvocation, +} from './ChatbotEnhanced'; + +/** + * `timestamp: string | Date` -> `string | undefined`. + * + * The authoring schema declares `z.union([z.string(), z.date()])`, and the + * runtime renders `{message.timestamp}` straight into a React child — a `Date` + * there is the classic "Objects are not valid as a React child" throw. + * + * This is the SINGLE expression of that absorption in the package: it was + * inlined in `useObjectChat`'s `normalizeMessages`, which now calls this + * function instead (objectui#4399). `normalizeMessages` keeps applying it + * because the hook's own `messages` output — and the `onSend(content, + * messages)` callback it feeds — has always handed hosts an ISO string, not a + * `Date`; the absorption is expressed once here and consumed at both points. + * + * The `instanceof` check (rather than an unconditional `.toISOString()`) is + * deliberate: authored JSON is not type-checked at runtime, so a value that is + * neither `string` nor `Date` must be dropped rather than thrown on. That is + * the behaviour the inlined version had. + */ +export function toRuntimeTimestamp( + timestamp: AuthoredChatMessage['timestamp'], +): string | undefined { + if (typeof timestamp === 'string') return timestamp; + return timestamp instanceof Date ? timestamp.toISOString() : undefined; +} + +/** + * `role: 'user' | 'assistant' | 'system' | 'tool'` -> the runtime's three roles. + * + * **The named decision (objectui#4399): an authored `'tool'` message renders as + * an assistant bubble, with its content shown.** That was already the outcome + * before this module existed — the cast let `'tool'` reach ``, + * whose `formatMessageProps` maps everything that is not `'user'` to the + * assistant bubble — but it was an implicit fallthrough that nothing recorded + * and nothing tested. It is now a decision this seam makes, by name. + * + * Note this is NOT the same decision as `formatMessageProps`: that one maps a + * runtime role to one of the vendored `` element's two BUBBLE styles + * (and folds `'system'` in as well). This one answers a different question — + * which runtime role an authored `'tool'` message IS. `'system'` keeps its own + * runtime role here and still renders as the centred system pill in + * ``. + * + * Changing the rendering of tool messages (a transcript-style tool block, say) + * is a UX card, not this one; it would start here. + */ +export function toRuntimeRole( + role: AuthoredChatMessage['role'], +): RuntimeChatMessage['role'] { + return role === 'tool' ? 'assistant' : role; +} + +/** + * Tool-invocation `state` -> the runtime's AI SDK v6 lifecycle states. + * + * The authoring type accepts three extra legacy values and its own doc comment + * already declares the mapping: "the legacy `partial-call`/`call`/`result` + * values are kept for back-compat; the AI SDK v6 lifecycle states map cleanly + * to `input-streaming`/`input-available`/`output-available`". This implements + * exactly that sentence — the fourth drift the `as any` was hiding, which the + * issue's table did not list. + * + * Rendered-output note: an authored legacy state used to reach `getToolState` + * unrecognised, which fell through to `'running'` and rendered a status badge + * with no label. Only schema-authored `toolInvocations` can carry the legacy + * spelling (API-mode messages come from `mapMessages`, which emits v6 states + * already), so this is the one place where the seam's honesty changes a + * rendered result — from a blank badge to the state the author declared. + */ +export function toRuntimeToolState( + state: AuthoredToolInvocation['state'], +): RuntimeToolInvocation['state'] { + switch (state) { + case 'partial-call': + return 'input-streaming'; + case 'call': + return 'input-available'; + case 'result': + return 'output-available'; + default: + // Every remaining member of the authoring union IS a runtime state, so + // the compiler proves the narrowing here rather than a cast asserting it. + return state; + } +} + +/** + * One authored tool invocation -> one runtime tool invocation. + * + * Only `state` drifts; everything else is spread through, which is what keeps + * the runtime-only extensions (`pendingActionId`, `draftReview`, + * `proposedPlan`, `proposedChanges`, `builderHandoff`) alive on the API-mode + * path — see the module doc. + */ +export function toRuntimeToolInvocation( + tool: AuthoredToolInvocation, +): RuntimeToolInvocation { + const { state, ...passthrough } = tool; + return { ...passthrough, state: toRuntimeToolState(state) }; +} + +/** + * One authored chat message -> the message shape the chat components render. + * + * This is the seam. The three `messages as any` casts in `renderer.tsx` are + * this function now. + */ +export function authoredToRuntimeMessage( + message: AuthoredChatMessage, +): RuntimeChatMessage { + const { role, timestamp, toolInvocations, ...passthrough } = message; + return { + ...passthrough, + role: toRuntimeRole(role), + timestamp: toRuntimeTimestamp(timestamp), + toolInvocations: toolInvocations?.map(toRuntimeToolInvocation), + }; +} + +/** + * Array form of {@link authoredToRuntimeMessage} — what the three registered + * renderers call. + * + * Call sites memoize on the input array (`useMemo(..., [messages])`) so the + * runtime array's identity stays exactly as stable as the hook's own output: + * local mode holds its messages in state, and the chat components key effects + * and memos off the `messages` prop. + */ +export function toRuntimeMessages( + messages: readonly AuthoredChatMessage[] | undefined, +): RuntimeChatMessage[] { + return (messages ?? []).map(authoredToRuntimeMessage); +} diff --git a/packages/plugin-chatbot/src/index.tsx b/packages/plugin-chatbot/src/index.tsx index 276afa424b..83ef3a5a81 100644 --- a/packages/plugin-chatbot/src/index.tsx +++ b/packages/plugin-chatbot/src/index.tsx @@ -355,6 +355,22 @@ export { } from './mapMessages'; export type { DraftReview, ProposedPlan, BuilderHandoff, ProposedChanges } from './mapMessages'; +// `@object-ui/types` ChatMessage (the JSON/SDUI AUTHORING contract) → the +// runtime `ChatMessage` above. Exported for the same reason as the mappers on +// the line before: a host that holds authored messages and renders these +// components hits the drift (`role: 'tool'`, `timestamp: Date`, legacy tool +// states) and would otherwise reach for `as any` — which is exactly the defect +// objectui#4399 removed from this package's own three renderers. Every +// narrowing decision it makes is documented in `chatMessageAdapter.ts`. +export { + authoredToRuntimeMessage, + toRuntimeMessages, + toRuntimeRole, + toRuntimeTimestamp, + toRuntimeToolInvocation, + toRuntimeToolState, +} from './chatMessageAdapter'; + // Display helpers used internally by ChatbotEnhanced. Exported so app // authors composing their own chat surface get the same pretty tool-call // rendering and friendly error summaries for free. diff --git a/packages/plugin-chatbot/src/renderer.tsx b/packages/plugin-chatbot/src/renderer.tsx index 1d95ae1061..236e94e629 100644 --- a/packages/plugin-chatbot/src/renderer.tsx +++ b/packages/plugin-chatbot/src/renderer.tsx @@ -6,12 +6,14 @@ * LICENSE file in the root directory of this source tree. */ +import { useMemo } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { ChatbotSchema, ChatMessage } from '@object-ui/types'; import { Chatbot } from './index'; import { ChatbotEnhanced } from './ChatbotEnhanced'; import { FloatingChatbot } from './FloatingChatbot'; import { useObjectChat } from './useObjectChat'; +import { toRuntimeMessages } from './chatMessageAdapter'; /** * Chatbot component for Object UI @@ -72,9 +74,18 @@ ComponentRegistry.register('chatbot', sendMessage(content); }; + // The authoring -> runtime message seam (objectui#4399). `useObjectChat` + // speaks the `@object-ui/types` (authoring) contract; `` renders + // the plugin's own. `toRuntimeMessages` names every narrowing decision + // between them — see `chatMessageAdapter.ts`. This used to be `as any`, + // which erased the intentional drift and the accidental drift alike. + // Memoized so the runtime array's identity is exactly as stable as the + // hook's own output (local mode holds `messages` in state). + const runtimeMessages = useMemo(() => toRuntimeMessages(messages), [messages]); + return ( - toRuntimeMessages(messages), [messages]); + return ( toRuntimeMessages(messages), [messages]); + return ( ISO absorption is NOT restated here: it is the authoring -> + * runtime seam's decision and lives in `toRuntimeTimestamp` + * (`chatMessageAdapter.ts`, objectui#4399), which this function consumes. It + * still applies at this point because the hook's own `messages` output — and + * the `onSend(content, messages)` callback fed from it — has always handed + * hosts an ISO string rather than a `Date`; one expression, two consumers. + * + * Roles are deliberately NOT narrowed here: an authored `'tool'` message keeps + * its authored role for the whole of the hook's (authoring-typed) surface and + * is folded to `'assistant'` only at the render seam. */ function normalizeMessages(msgs?: OuiChatMessage[]): OuiChatMessage[] { return (msgs ?? []).map((msg, idx) => ({ id: msg.id || `msg-${idx}`, role: msg.role || 'user', content: msg.content || '', - timestamp: typeof msg.timestamp === 'string' - ? msg.timestamp - : (msg.timestamp instanceof Date ? msg.timestamp.toISOString() : undefined), + timestamp: toRuntimeTimestamp(msg.timestamp), metadata: msg.metadata, streaming: msg.streaming, toolInvocations: msg.toolInvocations,