Skip to content

fix(plugin-chatbot): make useObjectChat's setMessages honest and drop the chatResult cast - #8377

Merged
os-justin merged 1 commit into
mainfrom
claude/issue-8342-setmessages-honest-surface
Sep 7, 2026
Merged

fix(plugin-chatbot): make useObjectChat's setMessages honest and drop the chatResult cast#8377
os-justin merged 1 commit into
mainfrom
claude/issue-8342-setmessages-honest-surface

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8342

Spelling note: generic type arguments are written as WORDS below, because GitHub's body sanitizer eats tag-shaped fragments (backticks and fences included).

The defect

useObjectChat declares

setMessages?: (messages: unknown[]) => void;

and returned @ai-sdk/react's own setMessages, which accepts only its UIMessage array (or an updater). Parameters are contravariant, so that assignment is unsound: the published type promised every consumer they could hand over an arbitrary unknown[] when the function underneath could not take one. The only thing keeping tsc quiet was } = chatResult as any; at the destructure.

Re-measured on this branch's base (37149ec4f, i.e. after objectui#8214 / PR #8341 landed). Removing the cast alone leaves exactly one diagnostic, the chain the card quotes, now at :803:

src/useObjectChat.ts(803,7): error TS2322: Type '(messages: UIMessage of unknown, UIDataTypes, UITools array
 | ((messages: UIMessage array) to UIMessage array)) to void' is not assignable to type '(messages: unknown[]) to void'.
  Types of parameters 'messages' and 'messages' are incompatible.
    Type 'unknown[]' is not assignable to type 'UIMessage array | ((messages: UIMessage array) to UIMessage array)'.
      Type 'unknown[]' is not assignable to type 'UIMessage array'.
        Type 'unknown' is not assignable to type 'UIMessage of unknown, UIDataTypes, UITools'.

(One correction to the card: the measured chain has five levels, not four — the card elided the unknown[] is-not-assignable-to UIMessage array step between the union and the element.)

The fix — ruling B, and why it does not collapse into A

The declared parameter stays unknown[]. This package does not republish the SDK's pinned UIMessage on its own surface; that is the same call objectui#8214 made one file over for AnyPart.state. The hook now wraps the SDK function and narrows internally.

The narrowing target is derived from the SDK function, not restated:

type SdkChatMessage = Extract(
  Parameters(ReturnType(typeof useChat)['setMessages'])[0],
  readonly unknown[]
)[number];

(spelled with parentheses above for the sanitizer; the source uses the real bracket syntax)

No SDK type is named in a type position anywhere on this path — the alias is derived from useChat, which the hook already imports as a value — so a dependency bump moves this internal alias and the published surface never has to move. B therefore does not collapse into A, and the package already made the mirror-image call on the inbound side — mapMessages.ts's AnyPart / AnyUIMessage are locally-owned structural types for exactly this reason.

Verified on the emitted declaration: dist/useObjectChat.d.ts:265 is still setMessages?: (messages: unknown[]) => void;, and every UIMessage occurrence in that file is inside a doc comment, never a type position. SdkChatMessage is module-private and does not appear at all.

What happens to an element that does not survive: REFUSE LOUDLY

The three available contracts are refuse / filter / pass through, and they are genuinely different. This picks refuse:

  • a value that is not a chat message throws a TypeError naming the offending index;
  • nothing is written — the whole array is validated before anything reaches the SDK, so a refusal leaves the thread exactly as it was.

Grounds: this is a re-hydration path whose return type is void. The caller's statement is "the thread is now exactly these messages", so filtering would install a silently shorter thread with no way to notice — the same silent-deletion class objectui#4424 was graded on. Pass-through is the status quo the card rejects.

The check is exactly the three members UIMessage requires: a string id, a 'user' | 'assistant' | 'system' role, and a parts array. parts is checked for array-ness only — the part union is open (a data- prefixed part carries an author-defined payload, UIDataTypes being a Record of string to unknown), so there is no closed set to check against, and restating it would be exactly the coupling this change exists to avoid. That residual is stated in the guard's doc comment rather than papered over.

Pinned by packages/plugin-chatbot/src/__tests__/useObjectChat.setMessagesHonest-8342.test.tsx — 13 cases, including the explicit anti-filter pin ("writes NOTHING — the store never sees a truncated thread"), plus a compile-time block that fails if anyone later takes option A.

The two internal callers — only one goes through the wrapper

  • :724 setMessages([]) (clear) — now routed through the wrapper. An empty array survives trivially; pinned.
  • :622 chat.setMessages(cur.slice(0, -1))not affected. It reaches the SDK function through chatRef.current (typed any), not through the returned member, and its value is the SDK's own live messages minus the last element, i.e. already the right shape. Left as-is with a comment saying so.

The one real downstream consumer

@object-ui/app-shell's useReconcileOnError declares its sink as (m: unknown[]) => void and calls setMessagesRef.current(ui as unknown[]) — it takes the published type at its word, which is precisely the consumer the card's Reachability section describes. Its payload comes from toUIMessages, which emits exactly id / role / parts, so it passes the check unchanged. And the call already sits inside a try/catch that falls through to the ordinary error banner, so a future malformed server payload degrades to "show the error" rather than to a quietly-truncated transcript. pnpm --filter @object-ui/app-shell test: 647 files, 6234 passed, 1 skipped.

Ablation — four legs, run from the committed implementation

Every leg proved the mutation reached disk (git hash-object differs from git rev-parse HEAD:PATH) and restored by state (git diff HEAD empty AND the blob equal again), under a trap ... EXIT INT TERM with absolute paths.

leg mutation tsc --noEmit tsc -p tsconfig.test.json vitest
1 wrapper removed (cast stays off) RED TS2322 at 916,7, full chain by name RED same RED 11/13
2 wrapper removed and chatResult as any restored — the exact pre-fix hook body green green RED 11/13
3 chatResult as any restored, wrapper kept green green green
4 declaration re-narrowed to the SDK message type (the option-A counterfactual) green RED TS2344 at (103,32) and (108,3) not run

The red sets of legs 1 and 2 intersect at vitest: the behavioural pin catches the regression whether or not someone re-introduces the cast to silence tsc. Their union covers both instruments.

Two results worth reading closely, because they correct the brief's model of this change:

  • Leg 3 is all green. After the fix the as any is no longer load-bearing at all — the wrapper is what makes the assignment sound, so the cast becomes merely redundant. Removing it is therefore a consequence of the fix, not a second half of it, and it cannot be half of an ablation pair.
  • Leg 4 is red only in the test program. tsc --noEmit stays green under option A because the compile-time pins live in a *.test.tsx file, which tsconfig.json excludes by directory. That is a direct measurement of why type-check runs both projects.

--listFiles proof that both of my files are in the programs that read them:

  • test project: src/useObjectChat.ts and src/__tests__/useObjectChat.setMessagesHonest-8342.test.tsx are both listed.
  • main project: src/useObjectChat.ts is listed; zero test files are (lit control: the same regex matches 39 entries in the test project's list).

Verification

what result
pnpm --filter '@object-ui/plugin-chatbot^...' build exit 0 (run first — without it type-check reports TS2307/TS2882, the stale-dist signature)
pnpm --filter @object-ui/plugin-chatbot type-check exit 0
pnpm --filter @object-ui/plugin-chatbot test exit 0 — 40 files, 474 passed; all 13 new cases named in the verbose run
pnpm --filter '...@object-ui/plugin-chatbot' type-check exit 0 — Scope: 7 of 47 workspace projects; plugin-chatbot, examples/schema-catalog, app-shell, examples/console-starter, examples/byo-backend-console, apps/site, apps/console — every one Done, 0 error TS
eslint . in packages/plugin-chatbot exit 0, 0 errors, 89 warnings — all pre-existing; 0 in the new test file, and the 6 in useObjectChat.ts are the pre-existing react-hooks/refs sites at 542/590/627/629/637/736, none in the new code
code-level as any in useObjectChat.ts 4 to 3; :684 } = chatResult as any; is the one removed (the whole-file grep -c still reads 4 because the new doc comment names the cast in prose — not a reading)

Changeset: minor, and the direction

.changeset/setmessages-honest-surface-8342.md declares @object-ui/plugin-chatbot: minor.

The declared type does not move, so nothing that compiled stops compiling. What narrows is the set of values a consumer can successfully pass at runtime: a call that used to hand junk to the SDK now throws. That is the opposite direction from objectui#8214's widen, and it is an observable behaviour change on a published member — so minor under this repo's "breaking ships minor, never major" rule.

Two things for the reviewer to weigh, stated rather than decided here:

  1. The alternative reading is patch: no caller loses a capability it actually had, since passing a non-message array was undefined behaviour that merely happened not to throw at the call site. If the maintainer prefers that reading, the changeset is a one-word edit.
  2. plugin-chatbot sits in the fixed version group in .changeset/config.json, so a minor here bumps the entire @object-ui/* line 17.6.x to 17.7.0. That is a real consequence of the level, not a reason to under-report it — flagging it so the choice is made with the cost visible.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

… the chatResult cast

`useObjectChat` declared `setMessages?: (messages: unknown[]) => void` and
returned `@ai-sdk/react`'s own `setMessages`, which accepts only its
`UIMessage[]`. Parameters are contravariant, so that assignment is unsound;
the only thing keeping `tsc` quiet was `} = chatResult as any;` at the
destructure. Removing the cast leaves exactly one diagnostic, a TS2322 on the
return site.

The parameter stays `unknown[]` — this package does not republish the SDK's
pinned `UIMessage` on its own surface. The hook now wraps the SDK function and
narrows internally, against a message type DERIVED from the SDK function rather
than restated, so a dependency bump moves the internal alias and never the
published surface.

A value that is not a chat message is refused loudly: a `TypeError` naming the
index, with nothing written, because the whole array is checked before anything
reaches the store. Filtering was rejected deliberately — on a re-hydration path
whose return type is `void`, a silently shorter thread is undetectable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3472.4 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-O2mhjivx.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 498.55KB 114.03KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 189.15KB 52.56KB
fields (index.js) 243.15KB 61.40KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.49KB 46.33KB
plugin-dashboard (index.js) 131.48KB 34.45KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.65KB 63.91KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.58KB 56.63KB
plugin-kanban (index.js) 55.38KB 15.72KB
plugin-list (index.js) 113.38KB 27.73KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.46KB 20.80KB
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.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

PM contract review — accepted, flipped to ready, auto-merge armed. Verified by content against origin/main, and two of my three first readings were blunt counts that lied; the printed lines are the readings:

chatResult as any        main :684  } = chatResult as any;        ← code
                       branch :368  * …was a `chatResult as any`  ← DOC COMMENT only
code-position `as any`   4 → 3   (the three left — :704 :735 :808 — are pre-existing,
                                  none of them the one this card was about)
published member       main :360  ==  branch :394   setMessages?: (messages: unknown[]) => void;
                                                    byte-identical ⇒ B did NOT collapse into A
SdkChatMessage           main 0 → branch 6, module-private (:441 :458 :478 :484 :486 :834)
pin                      6 it() + compile-time pins incl. _HonestSignature / _AcceptsUnknownArray

Ruling on the open question: A — minor stands, and the cost you flagged is zero at the margin

You measured something my brief did not name and were right to raise it: plugin-chatbot sits in the single 40-package fixed group, so a minor bumps the whole @object-ui/* line, not just this package. Measured on current main, that bump is already committed to by other changesets:

pending changesets on main:  477 × minor   ·   625 × patch

The line is going to 17.7.0 whatever this PR declares, so minor costs nothing extra here — and your own argument for it stands on its own: the declared type does not move, but the set of values a consumer can successfully pass at runtime narrows, which is the direction that deserves the louder level. B would have been defensible in a world where this were the only pending changeset; it isn't. No edit needed.

Two corrections against the brief, both accepted, and the second one reverses my own warning

My ablation recipe did not fit the fix. There is no "old declaration" to restore, because under ruling B the declared parameter deliberately did not move — the only meaningful declaration-side mutation is the option-A counterfactual, which is exactly what leg 4 is.

And my conjunction warning was measurably false here, in the reverse direction from the one it anticipated. Leg 3 — cast restored, wrapper kept — is all green on all three instruments. Once the wrapper exists the as any suppresses nothing, so removing it is a consequence of the fix rather than half of it, and it cannot serve as an ablation leg at all. The load-bearing conjunct is the wrapper alone. I had carried that warning forward from two dispatches where it did fire; you measured that it does not fire here, which is the right way to handle a heuristic.

The measurement I am carrying into every future brief: leg 4 is red ONLY in the test program. tsc --noEmit stays green under option A because the compile-time pins live in a *.test.tsx that tsconfig.json excludes by directory. That is a direct, reproducible demonstration of why type-check must run both projects — and of why a type-level pin verified against the main program alone would be a pin that cannot fail.

On the parts of the fix that are judgement, not measurement

Refusing loudly — a TypeError naming the index, all-or-nothing, nothing written — is the right call and the grounds are the ones that matter: this is a re-hydration path returning void, so a filtered shorter thread is undetectable by the caller, which is the silent-deletion class objectui#4424 was graded on. Checking parts for array-ness only, because the part union is open (data- prefixed parts carry author-defined payloads), is the honest boundary rather than a gap — and documenting that residual in the guard's own doc comment instead of papering over it is what makes it reviewable.

Leaving :622 alone is also right, and the reason is the one worth keeping in the file: it reaches the SDK function through chatRef.current, not through the returned member, and its value is the SDK's own live messages minus the last element — already the right shape.

Findings

objectui#8378 (the other cast at :735 is dead — measured, zero diagnostics without it) and objectui#8379 (ui as unknown[] on a value already assignable) are both labelled finding / p3 and queued. Your correction to objectui#8342's own text — the quoted TS2322 chain shows four levels where the measured chain has five — is recorded here rather than given a card; every other line of that card, including all five line numbers, re-measured exactly as written.

The attribution-footer duplication needs no action; the platform appends its own block and both forms now sit in this body.


Generated by Claude Code

@os-justin
os-justin added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit b842035 Sep 7, 2026
34 checks passed
@os-justin
os-justin deleted the claude/issue-8342-setmessages-honest-surface branch September 7, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants