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
11 changes: 11 additions & 0 deletions .changeset/chatmessage-one-contract-4383.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/plugin-chatbot': minor
---

`@object-ui/plugin-chatbot`'s `ChatMessage` is now one type instead of two

The barrel exported two different `ChatMessage` types: a minimal one it declared itself (`id` / `role` / `content` / `timestamp` / `avatar` / `avatarFallback`) and the shape `<ChatbotEnhanced>` actually renders, re-exported under the alias `ChatbotEnhancedMessage`. The natural name resolved to the narrow one, so an importer reaching for `ChatMessage` silently got the wrong contract — and the compiler could not object, because both shapes existed on purpose and every construction site spreads the extra keys conditionally, which defeats excess-property checking. That is how app-shell's `AiChatPage` ended up unable to read `toolInvocations` off its own function's return value (objectui#4040; re-pointed in PR #4379, but the collision itself was left standing). objectui#4383.

**Breaking semantics** (declared `minor` per AGENTS.md §版本号策略 — objectui never declares `major` outside an `@objectstack` major sync): `ChatMessage` exported from `@object-ui/plugin-chatbot` now denotes the enhanced shape. In practice this is a widening rather than a removal — every field of the retired shape survives with the same type, and the enhanced shape adds only optional keys (`streaming`, `toolInvocations`, `reasoning`, `sources`, `traceId`, `buildProgress`, `blueprintProgress`, `charts`), so anything that was a valid `ChatMessage` still is, and `<Chatbot messages={…} />` keeps accepting the same values. Code that relied on the name meaning *exactly* the six-key shape (exhaustive `keyof` maps, `Equal`-style assertions) is the case that changes.

`ChatbotEnhancedMessage` is kept as a `@deprecated` alias of the same type, so importers that spelled the disambiguating name keep compiling; new code should import `ChatMessage`. Pinned at compile time by `packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts`.
26 changes: 26 additions & 0 deletions packages/plugin-chatbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,32 @@ import { AIElements } from '@object-ui/plugin-chatbot';
</AIElements.Conversation>;
```

### The `ChatMessage` type

`ChatMessage` is this package's one message contract — the shape
`<ChatbotEnhanced>` renders and the mappers below produce. On top of the core
`id` / `role` / `content` / `timestamp` / `avatar` / `avatarFallback` fields it
carries the streaming and agent-process keys (`streaming`, `toolInvocations`,
`reasoning`, `sources`, `traceId`, `buildProgress`, `blueprintProgress`,
`charts`), all optional.

```tsx
import type { ChatMessage } from '@object-ui/plugin-chatbot';
```

> This barrel used to export **two** different `ChatMessage` types: a minimal
> one declared here, plus the enhanced shape aliased as
> `ChatbotEnhancedMessage`. Reaching for the natural name got you the narrow
> contract with no compiler complaint (objectui#4383). The minimal shape is
> retired; `ChatbotEnhancedMessage` is now a **deprecated alias of the same
> type**, kept only so existing importers keep compiling.

Note that `@object-ui/types` also exports a `ChatMessage`. That one is the
**JSON/SDUI schema** type (`ChatbotSchema['messages']`, `role` includes
`'tool'`, `timestamp` may be a `Date`) — the authoring contract, not the React
runtime one. Import the schema type from `@object-ui/types` and the runtime
type from this package.

### Message mapping helpers

If you wire `@ai-sdk/react`'s `useChat()` directly and want to render its
Expand Down
163 changes: 163 additions & 0 deletions packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* 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.
*/

/**
* `@object-ui/plugin-chatbot` — one name, one chat message contract
* (objectui#4383).
*
* The barrel used to export TWO different `ChatMessage` types: a minimal one
* it declared itself (`id`/`role`/`content`/`timestamp`/`avatar`/
* `avatarFallback`) and the enhanced one `<ChatbotEnhanced>` actually renders,
* re-exported under the alias `ChatbotEnhancedMessage`. The natural name
* resolved to the narrow shape, so the next importer who reached for
* `ChatMessage` got the wrong contract and the compiler could not object —
* both shapes existed on purpose, and every construction site spreads the
* extra keys conditionally (`...(x ? { toolInvocations } : {})`), which
* defeats excess-property checking. That is how app-shell's `AiChatPage` ended
* up unable to read `toolInvocations` off its own function's return value
* (objectui#4040, re-pointed at the enhanced type in PR #4379 without touching
* the collision itself).
*
* The pins below are what makes the convergence hold. They are COMPILE-TIME
* assertions: a violation is a `tsc` error under this package's
* `tsconfig.test.json`, not a runtime failure — vitest erases them entirely,
* so `pnpm test` proves nothing about them and only `pnpm --filter
* @object-ui/plugin-chatbot type-check` can. The runtime block at the bottom is
* a cheap net over the same fact for the vitest run.
*/

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

/** The name the barrel publishes — what any importer gets by default. */
import type { ChatMessage as BarrelChatMessage, ChatbotEnhancedMessage } from '../index';
/** The shape `<ChatbotEnhanced>` renders and the mappers produce. */
import type { ChatMessage as EnhancedChatMessage } from '../ChatbotEnhanced';

type Assert<T extends true> = T;
type IsAny<T> = 0 extends 1 & T ? true : false;
/** The `unknown` erasure the `any` probe reports `false` for (objectui#3155). */
type IsUnknown<T> = [unknown] extends [T] ? ([T] extends [unknown] ? true : false) : false;
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false;
/** objectstack#4075: an index signature absorbs every excess key. */
type HasIndexSignature<T> = string extends keyof T ? true : false;
type Has<T, K extends string> = K extends keyof T ? true : false;

/**
* The retired shape, transcribed verbatim from the declaration this change
* deleted from `src/index.tsx`. It exists here ONLY so the negative pin below
* can name what must never come back — nothing imports it.
*/
interface RetiredMinimalChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: string;
avatar?: string;
avatarFallback?: string;
}

describe("the barrel's ChatMessage IS the enhanced shape", () => {
it('is pinned at compile time', () => {
// Probe hygiene: an `any`/`unknown` on either side would make every
// `Equal` below answer whatever it is asked, so check the probes first.
type _BarrelNotAny = Assert<Equal<IsAny<BarrelChatMessage>, false>>;
type _BarrelNotUnknown = Assert<Equal<IsUnknown<BarrelChatMessage>, false>>;
type _EnhancedNotAny = Assert<Equal<IsAny<EnhancedChatMessage>, false>>;

// The whole point of the card: the natural name resolves to the enhanced
// contract. Before the fix this was `false` — the barrel declared its own
// minimal interface — and this line alone turned `tsc` red.
type _BarrelIsEnhanced = Assert<Equal<BarrelChatMessage, EnhancedChatMessage>>;

// ...and the disambiguating alias kept for compat denotes the SAME type,
// so the two spellings are one contract rather than two shapes again.
type _AliasIsEnhanced = Assert<Equal<ChatbotEnhancedMessage, EnhancedChatMessage>>;
type _AliasIsBarrel = Assert<Equal<ChatbotEnhancedMessage, BarrelChatMessage>>;

// The negative half. `Equal` is structural, so re-declaring the retired
// members anywhere in the barrel's `ChatMessage` — not just restoring the
// literal interface — trips this.
type _NotTheRetiredShape = Assert<
Equal<Equal<BarrelChatMessage, RetiredMinimalChatMessage>, false>
>;

// The mechanism from objectstack#4075: with `[k: string]: unknown` on the
// message type, EVERY structural comparison above answers "identical",
// however far the barrel drifts. Pin its absence or the pins are theatre.
type _NoIndexSignature = Assert<Equal<HasIndexSignature<BarrelChatMessage>, false>>;

// The keys the retired shape did NOT have — i.e. exactly what an importer
// reaching for `ChatMessage` used to lose. Named individually so a future
// narrowing says WHICH capability it dropped instead of "types differ".
type _HasStreaming = Assert<Has<BarrelChatMessage, 'streaming'>>;
type _HasToolInvocations = Assert<Has<BarrelChatMessage, 'toolInvocations'>>;
type _HasReasoning = Assert<Has<BarrelChatMessage, 'reasoning'>>;
type _HasSources = Assert<Has<BarrelChatMessage, 'sources'>>;
type _HasTraceId = Assert<Has<BarrelChatMessage, 'traceId'>>;
type _HasBuildProgress = Assert<Has<BarrelChatMessage, 'buildProgress'>>;
type _HasBlueprintProgress = Assert<Has<BarrelChatMessage, 'blueprintProgress'>>;
type _HasCharts = Assert<Has<BarrelChatMessage, 'charts'>>;

// The retirement is a WIDENING, which is what makes it safe for existing
// callers: every field of the retired shape survives with the same type,
// and everything added is optional — so anything that used to be a valid
// `ChatMessage` still is. If a future edit makes one of the added keys
// required, this is the line that fails.
type _RetiredStillAssignable = Assert<
RetiredMinimalChatMessage extends BarrelChatMessage ? true : false
>;
type _IdSurvives = Assert<Equal<BarrelChatMessage['id'], string>>;
type _RoleSurvives = Assert<
Equal<BarrelChatMessage['role'], 'user' | 'assistant' | 'system'>
>;
type _ContentSurvives = Assert<Equal<BarrelChatMessage['content'], string>>;
type _TimestampSurvives = Assert<Equal<BarrelChatMessage['timestamp'], string | undefined>>;
type _AvatarSurvives = Assert<Equal<BarrelChatMessage['avatar'], string | undefined>>;
type _AvatarFallbackSurvives = Assert<
Equal<BarrelChatMessage['avatarFallback'], string | undefined>
>;

expect(true).toBe(true);
});
});

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
// catch is a DECLARATION reappearing in this module: that is what split the
// name in two, and it is the one edit `tsc` reports only in the type-check
// job.
const BARREL = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'index.tsx');
const source = readFileSync(BARREL, 'utf8');

it('finds the barrel it is guarding', () => {
// If the file moves or is renamed, fail here rather than pass vacuously.
expect(source).toContain('export type { ChatMessage }');
});

it('declares no local ChatMessage type', () => {
expect(
/^\s*(export\s+)?(interface|type)\s+ChatMessage\b/m.test(source),
'packages/plugin-chatbot/src/index.tsx declares a `ChatMessage` of its own again. ' +
'The barrel must RE-EXPORT the one from `./ChatbotEnhanced` — a second declaration ' +
'is how the name came to mean two different shapes (objectui#4383).',
).toBe(false);
});

it('re-exports the message contract from ChatbotEnhanced', () => {
expect(source).toMatch(/export type \{ ChatMessage \} from '\.\/ChatbotEnhanced';/);
expect(source).toMatch(
/export type \{ ChatMessage as ChatbotEnhancedMessage \} from '\.\/ChatbotEnhanced';/,
);
});
});
51 changes: 40 additions & 11 deletions packages/plugin-chatbot/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,11 @@ import * as React from "react"
import { cn } from "@object-ui/components"
import { Button, Input, ScrollArea, Avatar, AvatarFallback, AvatarImage } from "@object-ui/components"
import { Send } from "lucide-react"

// Message type definition
export interface ChatMessage {
id: string
role: "user" | "assistant" | "system"
content: string
timestamp?: string
avatar?: string
avatarFallback?: string
}
// The package's single chat message contract. A SECOND, minimal `ChatMessage`
// used to be declared right here and exported from this barrel under the
// natural name; it is retired — see the documented re-export at the bottom of
// this file (objectui#4383).
import type { ChatMessage } from "./ChatbotEnhanced"

// Chatbot container props
export interface ChatbotProps extends React.HTMLAttributes<HTMLDivElement> {
Expand Down Expand Up @@ -298,7 +293,6 @@ export * from './renderer';
export { ChatbotEnhanced, publishHealthFromResponse } from './ChatbotEnhanced';
export type {
ChatbotEnhancedProps,
ChatMessage as ChatbotEnhancedMessage,
ChatToolInvocation as ChatbotEnhancedToolInvocation,
ChatSource as ChatbotEnhancedSource,
ChatbotLabels,
Expand All @@ -307,6 +301,41 @@ export type {
PublishOutcome,
} from './ChatbotEnhanced';

/**
* The chat message contract of `@object-ui/plugin-chatbot` — the shape
* `<ChatbotEnhanced>` renders and `uiMessagesToChatMessages()` produces.
*
* This barrel used to DECLARE a second, minimal `ChatMessage` of its own
* (`id`/`role`/`content`/`timestamp`/`avatar`/`avatarFallback` only) while the
* enhanced shape was re-exported from the same module under the alias
* `ChatbotEnhancedMessage`. One natural name, two contracts: an importer who
* reached for `ChatMessage` silently got the narrow one, and the mismatch
* compiled because every construction site spreads the extra keys
* conditionally (`...(x ? { toolInvocations } : {})`), which defeats
* excess-property checking — so the declared type was narrower than every
* value flowing through it (objectui#4040; corrected for app-shell's
* `AiChatPage` in PR #4379, but the barrel itself was left as-is).
*
* The minimal declaration is retired rather than renamed: nothing produced it,
* no importer asked for it, and every field it had is present with the same
* type on the enhanced shape, which adds only OPTIONAL keys on top. So
* `ChatMessage` now denotes one contract everywhere in this package
* (objectui#4383). Pinned at compile time in
* `__tests__/chat-message-contract.test.ts`.
*
* `<Chatbot>` renders only the core fields; the enhanced surface (tool calls,
* reasoning, sources, build progress, charts) is `<ChatbotEnhanced>`.
*/
export type { ChatMessage } from './ChatbotEnhanced';

/**
* @deprecated Use `ChatMessage`. This alias now denotes the SAME type — it is
* kept only so importers that spelled the disambiguating name while the barrel
* still carried two shapes (e.g. app-shell's `AiChatPage`, PR #4379) keep
* compiling. New code should import `ChatMessage` (objectui#4383).
*/
export type { ChatMessage as ChatbotEnhancedMessage } from './ChatbotEnhanced';

// Re-export the vendored Vercel AI Elements (MIT, src/elements/) so app
// authors who want to compose their own chat surface don't have to reach
// into deep paths. Wrappers (e.g. ConsoleFloatingChatbot) should consume
Expand Down
Loading