Found while implementing #4399 (the typed adapter at the @object-ui/types to plugin-chatbot ChatMessage seam). Out of that card's scope — #4399 owns the three renderer.tsx cast sites, and this is one layer up, in the hook that feeds them — so filing rather than fixing. Unassigned, finding-labelled, no pm:queue: nothing a user hits today, evidence below.
Fact
useObjectChat declares messages: OuiChatMessage[] on UseObjectChatReturn — the @object-ui/types authoring contract. In local mode that is true. In API mode it is not: the value is built from the runtime mapper and cast.
packages/plugin-chatbot/src/useObjectChat.ts:
const apiMessages: OuiChatMessage[] = uiMessagesToChatMessages(aiMessages, {
isStreaming: isLoading,
}).map((m, idx) => ({
...m,
metadata: (aiMessages[idx] as any)?.metadata,
})) as OuiChatMessage[];
uiMessagesToChatMessages returns the runtime ChatMessage (./ChatbotEnhanced). Those objects carry keys the authoring type does not declare, all of them load-bearing for rendering:
- on the message:
buildProgress, blueprintProgress, charts
- on each tool invocation:
pendingActionId, draftReview, proposedPlan, proposedChanges, builderHandoff
The as OuiChatMessage[] erases every one of them. So the hook's declared output type is narrower than the values flowing through it, in the direction that hides capability.
Why finding and not a defect
Nothing is broken today. The keys survive at runtime because every consumer between the hook and the components passes the objects through by reference rather than rebuilding them. That was true of the three messages as any casts before #4399, and it is true of the adapter that replaced them — PR #4416 spreads unlisted keys through specifically because of this cast, and says so in chatMessageAdapter.ts. Its pass-through: the API-mode payload survives the seam tests are the current pin on the behaviour.
Why it is still worth a card
The pass-through is a workaround for a mis-declared type, and it is load-bearing without being enforced. Anyone who writes the obvious thing — rebuild a message field-by-field from its declared type, anywhere on this path — silently deletes the HITL approval card, the "Review N changes" affordance, the proposed-plan card, the build panel and inline charts. The compiler will agree with them, because the declared type genuinely does not have those keys. That is a trap with no guard rail on it other than a comment.
Directions worth weighing (not a recommendation — needs the same measurement #4399 got):
- give
UseObjectChatReturn.messages a type that is honest about both modes (a union, or the runtime type, given the runtime shape is a superset of what authoring declares for the shared keys);
- keep the authoring type and have API mode genuinely produce it, moving the runtime-only lift to the render seam;
- leave it and pin the pass-through harder.
Option 1 changes a public hook contract and touches onSend(content, messages), so it is a survey card, not a rider.
Secondary observation, same family
packages/app-shell/src/console/ai/AiChatPage.tsx:80 still carries a comment stating that @object-ui/plugin-chatbot "ALSO exports a minimal legacy ChatMessage from its own barrel module (id/role/content/timestamp/avatar only)". That has been false since #4383 / PR #4400: the barrel exports one contract, and ChatbotEnhancedMessage is now a deprecated alias of the same type. One stale paragraph, but it describes a hazard that no longer exists and would send a reader looking for a collision that was already retired.
Anchor
Measured on claude/issue-4399-chatmessage-seam-adapter at 5b7926107 (merge-base with main 0f2134831, 2026-08-12). Reproduce: git grep -n "as OuiChatMessage\[\]" -- packages/plugin-chatbot/src/useObjectChat.ts.
Related: #4399 (the seam below this one, fixed in PR #4416), #4383 (the in-package collision, fixed), #4040.
Generated by Claude Code
Found while implementing #4399 (the typed adapter at the
@object-ui/typestoplugin-chatbotChatMessage seam). Out of that card's scope — #4399 owns the threerenderer.tsxcast sites, and this is one layer up, in the hook that feeds them — so filing rather than fixing. Unassigned,finding-labelled, nopm:queue: nothing a user hits today, evidence below.Fact
useObjectChatdeclaresmessages: OuiChatMessage[]onUseObjectChatReturn— the@object-ui/typesauthoring contract. In local mode that is true. In API mode it is not: the value is built from the runtime mapper and cast.packages/plugin-chatbot/src/useObjectChat.ts:uiMessagesToChatMessagesreturns the runtimeChatMessage(./ChatbotEnhanced). Those objects carry keys the authoring type does not declare, all of them load-bearing for rendering:buildProgress,blueprintProgress,chartspendingActionId,draftReview,proposedPlan,proposedChanges,builderHandoffThe
as OuiChatMessage[]erases every one of them. So the hook's declared output type is narrower than the values flowing through it, in the direction that hides capability.Why
findingand not a defectNothing is broken today. The keys survive at runtime because every consumer between the hook and the components passes the objects through by reference rather than rebuilding them. That was true of the three
messages as anycasts before #4399, and it is true of the adapter that replaced them — PR #4416 spreads unlisted keys through specifically because of this cast, and says so inchatMessageAdapter.ts. Itspass-through: the API-mode payload survives the seamtests are the current pin on the behaviour.Why it is still worth a card
The pass-through is a workaround for a mis-declared type, and it is load-bearing without being enforced. Anyone who writes the obvious thing — rebuild a message field-by-field from its declared type, anywhere on this path — silently deletes the HITL approval card, the "Review N changes" affordance, the proposed-plan card, the build panel and inline charts. The compiler will agree with them, because the declared type genuinely does not have those keys. That is a trap with no guard rail on it other than a comment.
Directions worth weighing (not a recommendation — needs the same measurement #4399 got):
UseObjectChatReturn.messagesa type that is honest about both modes (a union, or the runtime type, given the runtime shape is a superset of what authoring declares for the shared keys);Option 1 changes a public hook contract and touches
onSend(content, messages), so it is a survey card, not a rider.Secondary observation, same family
packages/app-shell/src/console/ai/AiChatPage.tsx:80still carries a comment stating that@object-ui/plugin-chatbot"ALSO exports a minimal legacyChatMessagefrom its own barrel module (id/role/content/timestamp/avatar only)". That has been false since #4383 / PR #4400: the barrel exports one contract, andChatbotEnhancedMessageis now a deprecated alias of the same type. One stale paragraph, but it describes a hazard that no longer exists and would send a reader looking for a collision that was already retired.Anchor
Measured on
claude/issue-4399-chatmessage-seam-adapterat5b7926107(merge-base with main0f2134831, 2026-08-12). Reproduce:git grep -n "as OuiChatMessage\[\]" -- packages/plugin-chatbot/src/useObjectChat.ts.Related: #4399 (the seam below this one, fixed in PR #4416), #4383 (the in-package collision, fixed), #4040.
Generated by Claude Code