From 14613a8632c6bc155e9340144487ecea418a6f32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 01:43:54 +0000 Subject: [PATCH] refactor(plugin-chatbot): one name, one ChatMessage contract (#4383) The barrel exported two different `ChatMessage` types: a minimal one it declared itself (id/role/content/timestamp/avatar/avatarFallback) and the shape `` 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 (objectui#4040 / PR #4379). The minimal declaration is retired; `ChatMessage` now IS the enhanced shape, and `ChatbotEnhancedMessage` survives as a deprecated alias OF THE SAME TYPE so existing importers keep compiling. Pinned at compile time in `__tests__/chat-message-contract.test.ts`, with a runtime net that fails if the barrel ever declares a message shape of its own again. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/chatmessage-one-contract-4383.md | 11 ++ packages/plugin-chatbot/README.md | 26 +++ .../__tests__/chat-message-contract.test.ts | 163 ++++++++++++++++++ packages/plugin-chatbot/src/index.tsx | 51 ++++-- 4 files changed, 240 insertions(+), 11 deletions(-) create mode 100644 .changeset/chatmessage-one-contract-4383.md create mode 100644 packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts diff --git a/.changeset/chatmessage-one-contract-4383.md b/.changeset/chatmessage-one-contract-4383.md new file mode 100644 index 0000000000..a30268db6a --- /dev/null +++ b/.changeset/chatmessage-one-contract-4383.md @@ -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 `` 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 `` 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`. diff --git a/packages/plugin-chatbot/README.md b/packages/plugin-chatbot/README.md index ec59545a9f..1f96997957 100644 --- a/packages/plugin-chatbot/README.md +++ b/packages/plugin-chatbot/README.md @@ -256,6 +256,32 @@ import { AIElements } from '@object-ui/plugin-chatbot'; ; ``` +### The `ChatMessage` type + +`ChatMessage` is this package's one message contract — the shape +`` 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 diff --git a/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts b/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts new file mode 100644 index 0000000000..cfb2d1a3f6 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts @@ -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 `` 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 `` renders and the mappers produce. */ +import type { ChatMessage as EnhancedChatMessage } from '../ChatbotEnhanced'; + +type Assert = T; +type IsAny = 0 extends 1 & T ? true : false; +/** The `unknown` erasure the `any` probe reports `false` for (objectui#3155). */ +type IsUnknown = [unknown] extends [T] ? ([T] extends [unknown] ? true : false) : false; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +/** objectstack#4075: an index signature absorbs every excess key. */ +type HasIndexSignature = string extends keyof T ? true : false; +type Has = 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, false>>; + type _BarrelNotUnknown = Assert, false>>; + type _EnhancedNotAny = Assert, 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>; + + // ...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>; + type _AliasIsBarrel = Assert>; + + // 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, 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, 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>; + type _HasToolInvocations = Assert>; + type _HasReasoning = Assert>; + type _HasSources = Assert>; + type _HasTraceId = Assert>; + type _HasBuildProgress = Assert>; + type _HasBlueprintProgress = Assert>; + type _HasCharts = Assert>; + + // 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>; + type _RoleSurvives = Assert< + Equal + >; + type _ContentSurvives = Assert>; + type _TimestampSurvives = Assert>; + type _AvatarSurvives = Assert>; + type _AvatarFallbackSurvives = Assert< + Equal + >; + + 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';/, + ); + }); +}); diff --git a/packages/plugin-chatbot/src/index.tsx b/packages/plugin-chatbot/src/index.tsx index 9e37b529ee..276afa424b 100644 --- a/packages/plugin-chatbot/src/index.tsx +++ b/packages/plugin-chatbot/src/index.tsx @@ -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 { @@ -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, @@ -307,6 +301,41 @@ export type { PublishOutcome, } from './ChatbotEnhanced'; +/** + * The chat message contract of `@object-ui/plugin-chatbot` — the shape + * `` 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`. + * + * `` renders only the core fields; the enhanced surface (tool calls, + * reasoning, sources, build progress, charts) is ``. + */ +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