Skip to content

refactor(plugin-chatbot): one typed adapter at the @object-ui/types ChatMessage seam - #4416

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-4399-chatmessage-seam-adapter
Aug 12, 2026
Merged

refactor(plugin-chatbot): one typed adapter at the @object-ui/types ChatMessage seam#4416
yinlianghui merged 2 commits into
mainfrom
claude/issue-4399-chatmessage-seam-adapter

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4399

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 — 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 new packages/plugin-chatbot/src/chatMessageAdapter.ts, with every narrowing decision named, documented and tested.

Narrowing decisions

key authoring (@object-ui/types) runtime (plugin-chatbot) decision
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.
timestamp string | Date string Date becomes 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 v6 states plus legacy 'partial-call' | 'call' | 'result' v6 states only legacy spellings map to '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.
metadata any not declared passed through — graded below.
everything else same shape both sides passed through untouched.

metadata, graded

Measured, not assumed: nothing in the package reads metadata. There is no {...message} spread, no key walk, and no reference to it anywhere under src/elements/ or in either chat component — the only mentions are in useObjectChat (which produces it) and mapMessages (an input type). So carrying it or stripping it is inert to rendering either way. It is passed through rather than stripped because useObjectChat splices 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 useObjectChat builds its return value as uiMessagesToChatMessages(...) as OuiChatMessage[] — so the values arriving at the seam are already runtime messages wearing the authoring type, carrying buildProgress, blueprintProgress, charts, and tool invocations bearing pendingActionId / 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 toRuntimeRole unassignable, 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 the Date to ISO ternary inline. Measured outcome:

  • It cannot simply move. normalizeMessages feeds localMessages, which the hook returns as its own authoring-typed messages and hands to onSend(content, messages). Hosts have always received an ISO string there, never a Date. Dropping the coercion from the hook and doing it only at the render seam would change that observable output.
  • So it is expressed once and consumed twice. The ternary is now toRuntimeTimestamp in the adapter — the seam owns the decision — and normalizeMessages calls it. One expression, two consumers, no restatement. A test pins it: useObjectChat.ts must import toRuntimeTimestamp and must contain no toISOString() 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

  • Every pre-existing test passes unchanged: pnpm exec vitest run packages/plugin-chatbot/ was 18 files / 286 tests on origin/main and is 20 files / 306 tests here — same 286, plus 20 new pins. No existing assertion was edited.
  • Two of the new pins drive the real renderer path (renderer.seam.test.tsx): they resolve the registered plugin-chatbot:chatbot component out of ComponentRegistry and render it, so an authored role: 'tool' message is asserted to produce the assistant bubble (assistant avatar fallback, not row-reversed, not the system pill) and an authored Date timestamp is asserted to reach the DOM as 2026-08-12T02:08:13.000Z. Both describe what main already 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 reached getToolState unrecognised, fell through to 'running', and rendered a status badge whose label lookup was undefined; it now renders as the state the author declared. Only schema-authored toolInvocations can carry the legacy spelling (API-mode messages come from mapMessages, 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 by tsc -p tsconfig.test.json):

  • the adapter's return type is the runtime ChatMessage (and the array form is its array);
  • the drift is real — the authoring shape is not assignable to the runtime one, so the adapter is load-bearing rather than ceremony;
  • each drift named individually: the authoring role union, the absence of 'tool' at runtime, the authoring timestamp accepting Date, the runtime one not, and the legacy tool states being authorable but not renderable.

Runtime nets for the vitest lane: renderer.tsx passes no cast to a messages prop, all three registered components are fed from the adapter, and useObjectChat.ts routes through toRuntimeTimestamp instead of restating the coercion.

Reverse verification

Each probe predicted first, then run, then reverted (commit-then-restore; no git stash).

probe predicted observed
restore one call site to a direct messages pass (no cast) tsc red red — TS2322 ... Type '"tool"' is not assignable to type '"system" | "user" | "assistant"'
restore messages as any at one call site tsc green, source net red as predicted: tsc exit 0, two net assertions failed. This is exactly why the net sits next to the type pin
break the named decision ('tool' folds to 'system') renderer pin red red — the tool message rendered as the centred system pill, so the assistant row was absent
delete the Date absorption timestamp pin red red — Objects are not valid as a React child (found: [object Date]), through the real renderer path. That it goes red at all is the proof that normalizeMessages consumes the shared expression: a surviving second copy would have absorbed the Date in local mode and left this green
add a required key to the runtime ChatMessage adapter red red — chatMessageAdapter.ts(180,3): error TS2741: Property 'mustExist' is missing — the type error the cast used to swallow, now surfacing at the seam

Also

  • toRuntimeMessages and friends are exported from the barrel, for the same reason uiMessagesToChatMessages is: a host holding authored messages hits the same drift, and an unexported adapter guarantees the next one writes as any. Additive only — no existing export changed, and turbo run type-check is green repo-wide (78/78).
  • README gains an "Authoring to runtime" section with the decision table, next to the existing note that the two ChatMessage types both exist.
  • Changeset: patch for @object-ui/plugin-chatbot.

Generated by Claude Code

claude added 2 commits August 12, 2026 04:44
`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
@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 5:17am

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-zCrTsNMC.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.69KB 56.74KB
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.57KB 31.32KB
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 #4399.

  • The pass-through measurement is the review's centerpiece: discovering that API-mode messages carry runtime-only keys under the authoring type — so the "obvious" field-by-field rebuild would have deleted the HITL approval card, draft review, and build panel with the compiler's agreement — is what separates an adapter that types the seam from one that breaks it. Narrow-by-name-spread-the-rest is the right shape, and the three runtime tests plus [finding] useObjectChat declares its messages as the AUTHORING ChatMessage, but in API mode it returns RUNTIME messages cast to that type #4424 (the root cause, correctly filed one layer up) cover the non-compiler-enforced remainder.
  • The fourth drift handled to standard: found beyond the card's table, mapped per the authoring type's own doc comment, and the one rendered-output change (legacy tool states no longer falling through to an undefined badge) reported under its own heading instead of absorbed into the zero-change claim. That is how an acceptance bar survives contact with reality.
  • The five probes are the day's best reverse-verification set — PROBE 2's cast-compiles-forever asymmetry justifying the source net, PROBE 4 proving the timestamp absorption is singly-expressed (a surviving copy would have kept it green), PROBE 5 surfacing the exact vocabulary-move error the card exists for.
  • The barrel-export deviation is accepted on its stated reason (an unexported adapter guarantees the next host writes as any), paid in full with 78/78. Named decisions distinct from formatMessageProps' different question, README table per AGENTS.md Add automated testing infrastructure and CI/CD workflows #2 — all correct.

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] plugin-chatbot's SDUI renderer casts messages as any across the @object-ui/types ↔ plugin-chatbot ChatMessage boundary

2 participants