Skip to content

refactor(plugin-chatbot): useObjectChat declares the message shape it actually returns (#4424) - #4436

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4424-useobjectchat-honest-type
Aug 12, 2026
Merged

refactor(plugin-chatbot): useObjectChat declares the message shape it actually returns (#4424)#4436
yinlianghui merged 2 commits into
mainfrom
claude/issue-4424-useobjectchat-honest-type

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4424

useObjectChat declared messages — and the onSend(content, messages) callback fed from it — as the @object-ui/types authoring ChatMessage. True in local mode only. In API mode the values came out of the runtime mapper and were asserted into place:

const apiMessages: OuiChatMessage[] = uiMessagesToChatMessages(aiMessages, {
  isStreaming: isLoading,
}).map(/* splice metadata back in */) as OuiChatMessage[];   // ← the card

The authoring contract declares none of what those values carry: buildProgress, blueprintProgress, charts, and pendingActionId / draftReview / proposedPlan / proposedChanges / builderHandoff on 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 every onSend site.

messages — 8 consumers, 0 need the authoring type

# Site What it needs Evidence
1-3 plugin-chatbot/src/renderer.tsx:85, :297, :428 (chatbot, chatbot-enhanced, chatbot-floating) values that survive to the runtime type each calls toRuntimeMessages(messages) (the #4399 seam) and passes the result to the components
4 app-shell/.../AiChatPage.tsx:1590 sanitizeChatMessagesForCache(...) runtime type casts: messages as ChatMessage[]
5 AiChatPage.tsx:1614 isConversationZh(...) runtime type same cast
6 AiChatPage.tsx:1659 useHitlInChat({ messages }) runtime type — reads toolInvocations[*].pendingActionId, a runtime-only key same cast
7 AiChatPage.tsx:1756 deriveBoundPackageId(...) a local structural shape messages as unknown as readonly PackageBearingMessage[]
8 AiChatPage.tsx:2034 the ChatbotEnhanced element's messages prop runtime type same cast

No other package consumes the hook: git grep useObjectChat outside plugin-chatbot and app-shell returns only content/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 implementors

Declared once (UseObjectChatOptions), invoked twice (useObjectChat.ts:600 API mode, :640 local mode), forwarded three times as onSend: schema.onSend from 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 the messages values — the dispatch's condition for retyping it too is met, and it is retyped. Pinned behaviourally, not just asserted: useObjectChat.honestMessages.test.tsx reads buildProgress and pendingActionId off onSend'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:

local mode after normalizeMessages runtime-compatible?
role authored role passed through, including 'tool' no — runtime has three roles
toolInvocations[].state authored state passed through, including legacy 'partial-call' / 'call' / 'result' no — runtime has v6 states only
timestamp toRuntimeTimestamp(...) ⇒ `string undefined`
metadata passed through not declared by runtime

Both 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:

  • wide where local mode is wide — keeps the 'tool' role and the legacy tool states;
  • narrow where both modes are narrowtimestamp?: string, never Date (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;
  • plus the render-only keys API mode really carries.

UseObjectChatReturn.messages, UseObjectChatOptions.onSend and the three renderers' onSend declarations speak it. The as 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, in chatMessageAdapter.test.ts the API-mode fixture went from

const apiModeMessage = { /* buildProgress, charts, pendingActionId, draftReview */ }
  as unknown as AuthoredChatMessage;      // assert past the compiler

to a plain const apiModeMessage: SeamChatMessage = { ... } — misspell buildProgress now and the file goes red instead of quietly testing a payload the seam would never see.

SeamChatMessage keeps authoring's timestamp?: string | Date while ObjectChatMessage narrows it to string. That gap is deliberate and documented: the seam is where an authored Date still 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: ObjectChatMessage is not assignable to the runtime type ('tool' and the legacy states still have to be narrowed by chatMessageAdapter.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).
  • Both tsc commands (tsc --noEmit && tsc -p tsconfig.test.json) — green.
  • Repo-wide 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).
  • lint on 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:

src/useObjectChat.ts(585,9): error TS2322:
  Type 'ChatMessage[]' is not assignable to type 'ObjectChatMessage[]'.

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 buildProgress from 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 (2 Assert pins, the fixture's excess-property check, 2 behavioural reads) while vitest stayed 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

minor for @object-ui/plugin-chatbot (patch for 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: fixed group, check-changeset-no-major).

The reason it is safe at minor is pinned as a type assertion rather than asserted in prose: ObjectChatMessage is a subtype of the authoring ChatMessage it replaces, so everything that correctly accepted the old declaration still accepts these values — including a host onSend typed as ChatMessage[], which keeps type-checking by contravariance. One observable narrowing, deliberately: code branching on timestamp instanceof Date was 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):

@object-ui/plugin-chatbot ALSO exports a minimal legacy ChatMessage from its own barrel module (id/role/content/timestamp/avatar only), and that is what this import used to resolve to.

After: the barrel publishes one contract — its ChatMessage IS the enhanced type and ChatbotEnhancedMessage is 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's chat-message-contract.test.ts pins, with a pointer that new code here should spell ChatMessage.

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 exported toRuntimeMessages instead 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.md and content/docs/plugins/plugin-chatbot.mdx both gained a section on what actually comes back out of the hook, and why it is neither ChatMessage (repo rule #2).


Generated by Claude Code

claude added 2 commits August 12, 2026 06:15
… 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
@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 12, 2026 6:28am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation plugin tests labels Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-B7R9OEpn.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 4.91KB 0.87KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 489.20KB 108.43KB
core (index.js) 2.99KB 1.14KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 153.42KB 41.19KB
fields (index.js) 228.99KB 56.82KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 62.01KB 17.63KB
plugin-chatbot (index.js) 181.17KB 43.03KB
plugin-dashboard (index.js) 120.75KB 31.38KB
plugin-designer (index.js) 211.16KB 42.76KB
plugin-detail (index.js) 239.03KB 59.77KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 187.99KB 49.92KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.21KB 26.79KB
plugin-map (index.js) 18.05KB 5.80KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.99KB 10.74KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

ACCEPT — PM 复核 (session session_017Qqyix2QcnpUC9XeYVDzx3), closes #4424.

  • The survey produced a better landing than the ruling's own spellings: local mode is deliberately NOT runtime-shaped (normalizeMessages' own doc assigns the fold to the render seam), and no consumer discriminates on mode — so ObjectChatMessage as the widened common supertype is the honest type, where the runtime type would lie about local mode and a union would impose a discrimination nobody performs. This is "the survey decides which is true" executed to the letter, with the subtype/contravariance compatibility PINNED rather than asserted.
  • RV1's fork resolved to the STRONGER branch (restored cast = TS2322, verbatim), doubly netted; RV2's measured compile-vs-runtime asymmetry (5 tsc errors, 313 vitest green, same tree) is exactly why those pins live in the type-check job — and writing that reason into the test header is the standard.
  • Honest limits stated, not overclaimed: pass-through is now compiler-visible but not compiler-enforced (the Assert pins + behavioral test carry it), and the hand-maintained key lists' faith-residue is named with its mitigation. The one deliberate observable break (timestamp instanceof Date branches — always dead) is the card's point, correctly graded minor.
  • Deviations accepted: the renderer prop declarations are the forwarding surface of the ruling's own target; the source-net firing on its own explanatory comment ("prose about a cast is not a cast") is a correct guard refinement; docs per rule Add automated testing infrastructure and CI/CD workflows #2.
  • The enabled follow-up (AiChatPage's 5 casts → the exported toRuntimeMessages) is filed by the PM as its own card — the right disposition for an enabled cleanup.

Flipping ready + arming auto-merge.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation plugin tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] useObjectChat declares its messages as the AUTHORING ChatMessage, but in API mode it returns RUNTIME messages cast to that type

2 participants