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
20 changes: 20 additions & 0 deletions .changeset/chatmessage-seam-adapter.md
Original file line number Diff line number Diff line change
@@ -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
`<ChatbotEnhanced>` 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.
30 changes: 30 additions & 0 deletions packages/plugin-chatbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ChatbotEnhanced messages={toRuntimeMessages(messages)} />;
}
```

| 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 (`<Chatbot>` 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
Expand Down
129 changes: 129 additions & 0 deletions packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ import { fileURLToPath } from 'node:url';
import type { ChatMessage as BarrelChatMessage, ChatbotEnhancedMessage } from '../index';
/** The shape `<ChatbotEnhanced>` 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 extends true> = T;
type IsAny<T> = 0 extends 1 & T ? true : false;
Expand Down Expand Up @@ -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<Equal<IsAny<AuthoredChatMessage>, false>>;
type _AuthoredNotUnknown = Assert<Equal<IsUnknown<AuthoredChatMessage>, 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<ChatMessage, 'charts'>`) turns this line red.
type _OutputIsRuntime = Assert<
Equal<ReturnType<typeof authoredToRuntimeMessage>, EnhancedChatMessage>
>;
type _ArrayOutputIsRuntime = Assert<
Equal<ReturnType<typeof toRuntimeMessages>, 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<AuthoredChatMessage extends EnhancedChatMessage ? true : false, false>
>;

// 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<AuthoredChatMessage['role'], 'user' | 'assistant' | 'system' | 'tool'>
>;
type _RuntimeHasNoToolRole = Assert<
Equal<Extract<EnhancedChatMessage['role'], 'tool'>, never>
>;
type _AuthoringTimestampAcceptsDate = Assert<
Equal<AuthoredChatMessage['timestamp'], string | Date | undefined>
>;
type _RuntimeTimestampIsString = Assert<
Equal<EnhancedChatMessage['timestamp'], string | undefined>
>;
// 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<NonNullable<AuthoredChatMessage['toolInvocations']>[number]['state']>,
'partial-call' | 'call' | 'result'
>,
'partial-call' | 'call' | 'result'
>
>;
type _RuntimeHasNoLegacyToolStates = Assert<
Equal<
Extract<
NonNullable<NonNullable<EnhancedChatMessage['toolInvocations']>[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
Expand Down
173 changes: 173 additions & 0 deletions packages/plugin-chatbot/src/__tests__/chatMessageAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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: `<Chatbot>` 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,
},
]);
});
});
Loading
Loading