refactor(plugin-chatbot): one typed adapter at the @object-ui/types ChatMessage seam - #4416
Merged
Merged
Conversation
`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
…tmessage-seam-adapter
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
Collaborator
Author
|
ACCEPT — PM 复核 (session
Flipping ready + arming auto-merge. Generated by Claude Code |
yinlianghui
marked this pull request as ready for review
August 12, 2026 05:30
This was referenced Aug 12, 2026
refactor(plugin-chatbot): useObjectChat declares the message shape it actually returns (#4424)
#4436
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4399
renderer.tsxhanded@object-ui/types(authoring) messages to components typed with the plugin's own (runtime)ChatMessagethrough threemessages as anycasts. Both contracts are deliberate and deliberately different — one is the JSON/SDUI authoring surface, the other is what React actually renders — 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, in the newpackages/plugin-chatbot/src/chatMessageAdapter.ts, with every narrowing decision named, documented and tested.Narrowing decisions
@object-ui/types)plugin-chatbot)role'user' | 'assistant' | 'system' | 'tool''user' | 'assistant' | 'system''tool'IS an assistant message — renders as an assistant bubble, content shown. Today's absorbed outcome, now the recorded decision (toRuntimeRole).'system'keeps its own role: the basic Chatbot renders it as a centred pill, which folding it here would destroy.timestampstring | DatestringDatebecomes its ISO 8601 string (toRuntimeTimestamp). The runtime renders the timestamp straight into a React child, where an object is the "Objects are not valid as a React child" throw.toolInvocations[].state'partial-call' | 'call' | 'result''input-streaming'/'input-available'/'output-available'— the mapping the authoring type's own doc comment already declares. This is a fourth drift the issue's table did not list; it is what forced the decision, since the cast was hiding it too.metadataanymetadata, gradedMeasured, not assumed: nothing in the package reads
metadata. There is no{...message}spread, no key walk, and no reference to it anywhere undersrc/elements/or in either chat component — the only mentions are inuseObjectChat(which produces it) andmapMessages(an input type). So carrying it or stripping it is inert to rendering either way. It is passed through rather than stripped becauseuseObjectChatsplices it in deliberately (metadata: (aiMessages[idx])?.metadata), and stripping a key a sibling module goes out of its way to preserve is a change this typing card has no reason to make.Why the adapter is a conversion, not a reconstruction
The one measurement that shaped the implementation. In API mode
useObjectChatbuilds its return value asuiMessagesToChatMessages(...) as OuiChatMessage[]— so the values arriving at the seam are already runtime messages wearing the authoring type, carryingbuildProgress,blueprintProgress,charts, and tool invocations bearingpendingActionId/draftReview/proposedPlan/proposedChanges/builderHandoff. Every one of those keys is erased by the authoring type.A field-by-field rebuild from the authoring shape would therefore have silently dropped the HITL approval card, the "Review N changes" affordance, the proposed-plan card and the build panel — from all three SDUI renderers. The cast preserved them by accident; the adapter narrows the drifting fields by name and spreads the rest, preserving them on purpose. The compile-time value is unaffected: a new authored role still makes
toRuntimeRoleunassignable, and a newly required runtime key still makes the adapter's return type red. Both are pinned.Placement: the absorption stays singly-expressed
The ruling's design constraint was that the adapter must not become a third dialect.
normalizeMessages(useObjectChat.ts) owned theDateto ISO ternary inline. Measured outcome:normalizeMessagesfeedslocalMessages, which the hook returns as its own authoring-typedmessagesand hands toonSend(content, messages). Hosts have always received an ISO string there, never aDate. Dropping the coercion from the hook and doing it only at the render seam would change that observable output.toRuntimeTimestampin the adapter — the seam owns the decision — andnormalizeMessagescalls it. One expression, two consumers, no restatement. A test pins it:useObjectChat.tsmust importtoRuntimeTimestampand must contain notoISOString()of its own.Roles are deliberately not narrowed in the hook: an authored
'tool'message keeps its authored role across the whole of the hook's authoring-typed surface and is folded only at the render seam.Zero rendered-output change
pnpm exec vitest run packages/plugin-chatbot/was18 files / 286 testsonorigin/mainand is20 files / 306 testshere — same 286, plus 20 new pins. No existing assertion was edited.renderer.seam.test.tsx): they resolve the registeredplugin-chatbot:chatbotcomponent out ofComponentRegistryand render it, so an authoredrole: 'tool'message is asserted to produce the assistant bubble (assistant avatar fallback, not row-reversed, not the system pill) and an authoredDatetimestamp is asserted to reach the DOM as2026-08-12T02:08:13.000Z. Both describe whatmainalready rendered — they are regression pins, not change pins.One honest exception, deliberately reported rather than smoothed over. The legacy tool-state narrowing is the single place where the seam's honesty changes a rendered result. An authored
state: 'result'previously reachedgetToolStateunrecognised, fell through to'running', and rendered a status badge whose label lookup wasundefined; it now renders as the state the author declared. Only schema-authoredtoolInvocationscan carry the legacy spelling (API-mode messages come frommapMessages, which emits v6 states already), no test pinned the old behaviour, and preserving it would have meant either keeping the cast or mapping'result'to a v6 state that contradicts it. Flagging it so the acceptance bar is judged on the facts.Pins added
Compile-time, beside PR #4400's contract pins in
chat-message-contract.test.ts(erased by vitest, checked only bytsc -p tsconfig.test.json):ChatMessage(and the array form is its array);'tool'at runtime, the authoring timestamp acceptingDate, the runtime one not, and the legacy tool states being authorable but not renderable.Runtime nets for the vitest lane:
renderer.tsxpasses no cast to amessagesprop, all three registered components are fed from the adapter, anduseObjectChat.tsroutes throughtoRuntimeTimestampinstead of restating the coercion.Reverse verification
Each probe predicted first, then run, then reverted (commit-then-restore; no
git stash).messagespass (no cast)tscredTS2322 ... Type '"tool"' is not assignable to type '"system" | "user" | "assistant"'messages as anyat one call sitetscgreen, source net redtscexit 0, two net assertions failed. This is exactly why the net sits next to the type pin'tool'folds to'system')DateabsorptionObjects are not valid as a React child (found: [object Date]), through the real renderer path. That it goes red at all is the proof thatnormalizeMessagesconsumes the shared expression: a surviving second copy would have absorbed theDatein local mode and left this greenChatMessagechatMessageAdapter.ts(180,3): error TS2741: Property 'mustExist' is missing— the type error the cast used to swallow, now surfacing at the seamAlso
toRuntimeMessagesand friends are exported from the barrel, for the same reasonuiMessagesToChatMessagesis: a host holding authored messages hits the same drift, and an unexported adapter guarantees the next one writesas any. Additive only — no existing export changed, andturbo run type-checkis green repo-wide (78/78).ChatMessagetypes both exist.patchfor@object-ui/plugin-chatbot.Generated by Claude Code