refactor(plugin-chatbot): useObjectChat declares the message shape it actually returns (#4424) - #4436
Merged
Merged
Conversation
… actually returns (#4424) The hook typed `messages` — and the `onSend(content, messages)` callback fed from it — as `@object-ui/types`' AUTHORING `ChatMessage`. True in local mode only: API mode built RUNTIME messages and asserted them into place with `as OuiChatMessage[]`, erasing `buildProgress`, `blueprintProgress`, `charts` and the HITL / draft-review / proposed-plan / builder-handoff extensions on every tool invocation. Those keys are the approval card, the "Review N changes" affordance, the plan card, the build panel and the inline charts; they survived only because nothing on the path rebuilt a message. The survey found the honest type to be neither `ChatMessage`: wide where local mode is wide (authored 'tool' role, legacy tool states — folded only at the render seam), narrow where both modes are narrow (`timestamp` is `string`, never `Date`), plus the render-only keys API mode really carries. Published as `ObjectChatMessage`, a SUBTYPE of the authoring contract, so correct consumers are untouched. The cast is deleted, not moved: the mapper's output satisfies the declaration, so the compiler checks the assignment. The #4399 seam is unchanged in behaviour and still necessary, but its pass-through is no longer an act of faith: `SeamChatMessage` names the render-only keys, so the spread preserves them as declared properties and the pass-through fixture is type-checked instead of cast into place. Rider: AiChatPage's comment still described a second, minimal legacy `ChatMessage` on the plugin barrel — retired in #4383 / PR #4400. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
…objectchat-honest-type
|
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 |
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 #4424
useObjectChatdeclaredmessages— and theonSend(content, messages)callback fed from it — as the@object-ui/typesauthoringChatMessage. True in local mode only. In API mode the values came out of the runtime mapper and were asserted into place:The authoring contract declares none of what those values carry:
buildProgress,blueprintProgress,charts, andpendingActionId/draftReview/proposedPlan/proposedChanges/builderHandoffon each tool invocation — i.e. the HITL approval card, the "Review N changes" affordance, the proposed-plan card, the build panel and the inline charts. They survived only because nothing on the path rebuilt a message.Landing A (the ruling's target: direction 1, survey-first). The declaration is now the truth; the cast is deleted rather than moved.
Survey
Ruling required the survey published regardless of landing. Every consumer of the hook's
messages, and everyonSendsite.messages— 8 consumers, 0 need the authoring typeplugin-chatbot/src/renderer.tsx:85, :297, :428(chatbot,chatbot-enhanced,chatbot-floating)toRuntimeMessages(messages)(the #4399 seam) and passes the result to the componentsapp-shell/.../AiChatPage.tsx:1590sanitizeChatMessagesForCache(...)messages as ChatMessage[]AiChatPage.tsx:1614isConversationZh(...)AiChatPage.tsx:1659useHitlInChat({ messages })toolInvocations[*].pendingActionId, a runtime-only keyAiChatPage.tsx:1756deriveBoundPackageId(...)messages as unknown as readonly PackageBearingMessage[]AiChatPage.tsx:2034theChatbotEnhancedelement'smessagespropNo other package consumes the hook:
git grep useObjectChatoutsideplugin-chatbotandapp-shellreturns onlycontent/docs/plugins/plugin-chatbot.mdx.The measurement that matters: the single host consumer pays for the mis-declaration five times over, casting straight back to the runtime type at every site. Nobody wanted the authoring type. That is the survey clearing landing A.
onSend(content, messages)— 3 forwarding sites, 0 host implementorsDeclared once (
UseObjectChatOptions), invoked twice (useObjectChat.ts:600API mode,:640local mode), forwarded three times asonSend: schema.onSendfrom the same renderers. No app, example or host in the repo implements it today.Both invocations feed it the same array they are about to hold (
[...apiMessages, newUserMessage]/[...localMessages, userMessage]), so it carries exactly themessagesvalues — the dispatch's condition for retyping it too is met, and it is retyped. Pinned behaviourally, not just asserted:useObjectChat.honestMessages.test.tsxreadsbuildProgressandpendingActionIdoffonSend's own argument.Is local mode "also effectively runtime-shaped"? — No, measured, in two named ways
This is what decided the shape of the honest type, so it is the load-bearing measurement:
normalizeMessagesrole'tool'toolInvocations[].state'partial-call'/'call'/'result'timestamptoRuntimeTimestamp(...)⇒ `stringmetadataBoth misses are deliberate and recorded in
normalizeMessages' own doc: roles are not narrowed here, the fold to'assistant'is the render seam's decision. So the runtime type would be a lie about local mode, and the authoring type is a lie about API mode.The honest type is therefore neither of the two existing contracts, and not a union either — no consumer discriminates on mode (all 8 above treat the two modes as one shape), so a
RuntimeMessage[] | AuthoredMessage[]union would impose a discrimination nobody performs. It is the widened common supertype: the shape both modes' values inhabit.What landed
ObjectChatMessage, exported from@object-ui/plugin-chatbot:'tool'role and the legacy tool states;timestamp?: string, neverDate(API mode never produces one; local mode absorbs it before emitting). This is what the old declaration got wrong in the other direction: it asked every consumer to handle a value that cannot arrive;UseObjectChatReturn.messages,UseObjectChatOptions.onSendand the three renderers'onSenddeclarations speak it. Theas OuiChatMessage[]assertion is deleted, not relocated — the mapper's output satisfies the declaration, so the compiler checks the assignment instead of being told to stop looking.The pass-through narrows from faith to declaration
Per the dispatch, the pass-through is not deleted — the adapter is exported from the barrel and still serves hosts holding plain authored messages. What changed is that it is no longer invisible. Its input type (
SeamChatMessage/SeamToolInvocation, both exported) names the render-only keys as optional members, so the spread preserves them as declared properties. Concretely, inchatMessageAdapter.test.tsthe API-mode fixture went fromto a plain
const apiModeMessage: SeamChatMessage = { ... }— misspellbuildProgressnow and the file goes red instead of quietly testing a payload the seam would never see.SeamChatMessagekeeps authoring'stimestamp?: string | DatewhileObjectChatMessagenarrows it tostring. That gap is deliberate and documented: the seam is where an authoredDatestill dies, and narrowing it there would make that recorded decision unreachable.The seam is still necessary — this is not the rejected option 2
Pinned explicitly:
ObjectChatMessageis not assignable to the runtime type ('tool'and the legacy states still have to be narrowed bychatMessageAdapter.ts). Nothing was moved into a field-by-field rebuild.Verification
pnpm exec vitest run packages/plugin-chatbot/— 313 passed (21 files): refactor(plugin-chatbot): one typed adapter at the @object-ui/types ChatMessage seam #4416's 306, plus 7 new (2 compile-pin blocks, 1 source net, 4 behavioural).tsccommands (tsc --noEmit && tsc -p tsconfig.test.json) — green.turbo run type-check --concurrency=2(the no-downstream-red proof for a published type change) — 78 successful, 78 total, incl.app-shell,console, every example.pnpm exec vitest run packages/app-shell/src/console/ai/— 108 passed (17 files).linton both touched packages — 0 errors.check-changeset-presence/check-changeset-no-major/check-changeset-fixed/check-control-bytes/check-phantom-dependencies— all green.Reverse verification (direction predicted first, then measured)
1. Restore the cast. Predicted: a type error, not a redundant cast — the authoring type is not assignable to the honest declaration. Measured, exactly:
plus an independent runtime red from the source net (
asserts API-mode messages into the authoring type no more). The cast cannot come back silently by either route. Reverted, tree clean.2. Break one preserved key (drop
buildProgressfrom the seam's input key list). Predicted: tsc-only red — the value still flows at runtime, so only the type job can see it. Measured: 5 compile errors (2Assertpins, the fixture's excess-property check, 2 behavioural reads) whileviteststayed 313 passed / 0 failed on the identical break. That asymmetry is the reason these pins live in the type-check job and is stated in the test file's header. Reverted, tree clean.Changeset grading
minorfor@object-ui/plugin-chatbot(patchfor app-shell's comment). The hook's return type is published API and hosts can observe the change — the #4403 precedent — so it is not a silent internal edit. It is not major (and never could be here:fixedgroup,check-changeset-no-major).The reason it is safe at
minoris pinned as a type assertion rather than asserted in prose:ObjectChatMessageis a subtype of the authoringChatMessageit replaces, so everything that correctly accepted the old declaration still accepts these values — including a hostonSendtyped asChatMessage[], which keeps type-checking by contravariance. One observable narrowing, deliberately: code branching ontimestamp instanceof Datewas handling a value this hook cannot emit, and now says so at compile time.Rider
packages/app-shell/src/console/ai/AiChatPage.tsx— comment-only, per the ruling.Before (false since #4383 / PR #4400):
After: the barrel publishes one contract — its
ChatMessageIS the enhanced type andChatbotEnhancedMessageis a deprecated alias of the same declaration, kept so this import (PR #4379) keeps compiling. The #4040 history is retained as history; the retirement is cited to #4383 / PR #4400 and to the plugin'schat-message-contract.test.tspins, with a pointer that new code here should spellChatMessage.Follow-up this enables (not in scope, not filed as a defect)
AiChatPage's five casts (survey rows 4-8) still compile and are still needed — the honest type keeps the'tool'role, so narrowing to the runtime type remains a real conversion. The right fix is for that host to route them through the exportedtoRuntimeMessagesinstead of casting, which is the same move #4399 made inside this package. Out of this card's surface; noted for triage.Docs
packages/plugin-chatbot/README.mdandcontent/docs/plugins/plugin-chatbot.mdxboth gained a section on what actually comes back out of the hook, and why it is neitherChatMessage(repo rule #2).Generated by Claude Code