… 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
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
useObjectChatdeclaresand returned
@ai-sdk/react's ownsetMessages, which accepts only itsUIMessagearray (or an updater). Parameters are contravariant, so that assignment is unsound: the published type promised every consumer they could hand over an arbitraryunknown[]when the function underneath could not take one. The only thing keepingtscquiet 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:(One correction to the card: the measured chain has five levels, not four — the card elided the
unknown[]is-not-assignable-toUIMessage arraystep 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 pinnedUIMessageon its own surface; that is the same call objectui#8214 made one file over forAnyPart.state. The hook now wraps the SDK function and narrows internally.The narrowing target is derived from the SDK function, not restated:
(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'sAnyPart/AnyUIMessageare locally-owned structural types for exactly this reason.Verified on the emitted declaration:
dist/useObjectChat.d.ts:265is stillsetMessages?: (messages: unknown[]) => void;, and everyUIMessageoccurrence in that file is inside a doc comment, never a type position.SdkChatMessageis 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:
TypeErrornaming the offending index;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
UIMessagerequires: a stringid, a'user' | 'assistant' | 'system'role, and apartsarray.partsis checked for array-ness only — the part union is open (adata-prefixed part carries an author-defined payload,UIDataTypesbeing aRecordof 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 throughchatRef.current(typedany), not through the returned member, and its value is the SDK's own livemessagesminus 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'suseReconcileOnErrordeclares its sink as(m: unknown[]) => voidand callssetMessagesRef.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 fromtoUIMessages, which emits exactlyid/role/parts, so it passes the check unchanged. And the call already sits inside atry/catchthat 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-objectdiffers fromgit rev-parse HEAD:PATH) and restored by state (git diff HEADempty AND the blob equal again), under atrap ... EXIT INT TERMwith absolute paths.tsc --noEmittsc -p tsconfig.test.json916,7, full chain by namechatResult as anyrestored — the exact pre-fix hook bodychatResult as anyrestored, wrapper kept(103,32)and(108,3)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:
as anyis 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.tsc --noEmitstays green under option A because the compile-time pins live in a*.test.tsxfile, whichtsconfig.jsonexcludes by directory. That is a direct measurement of whytype-checkruns both projects.--listFilesproof that both of my files are in the programs that read them:src/useObjectChat.tsandsrc/__tests__/useObjectChat.setMessagesHonest-8342.test.tsxare both listed.src/useObjectChat.tsis listed; zero test files are (lit control: the same regex matches 39 entries in the test project's list).Verification
pnpm --filter '@object-ui/plugin-chatbot^...' buildtype-checkreports TS2307/TS2882, the stale-distsignature)pnpm --filter @object-ui/plugin-chatbot type-checkpnpm --filter @object-ui/plugin-chatbot testpnpm --filter '...@object-ui/plugin-chatbot' type-checkScope: 7 of 47 workspace projects; plugin-chatbot, examples/schema-catalog, app-shell, examples/console-starter, examples/byo-backend-console, apps/site, apps/console — every oneDone, 0error TSeslint .inpackages/plugin-chatbotuseObjectChat.tsare the pre-existingreact-hooks/refssites at 542/590/627/629/637/736, none in the new codeas anyinuseObjectChat.ts:684 } = chatResult as any;is the one removed (the whole-filegrep -cstill 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.mddeclares@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
minorunder this repo's "breaking ships minor, never major" rule.Two things for the reviewer to weigh, stated rather than decided here:
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.plugin-chatbotsits in thefixedversion group in.changeset/config.json, so aminorhere 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