Repository navigation
finding(plugin-chatbot): uiMessagesToChatMessages' parameter type refuses @ai-sdk/react's UIMessage — the exact input it is exported for #8214
Description
Found while compiling packages/plugin-chatbot/README.md's snippets for objectui#5174 batch 30 (PR to follow). Filed unassigned and NOT fixed there — that batch may not touch packages/plugin-chatbot/src/**.
Note on spelling: generic type arguments are written as WORDS below (UIMessage of unknown, UIDataTypes, UITools) because GitHub's body sanitizer eats tag-shaped fragments, backticks and code fences included, and this card is entirely about a generic type.
What
packages/plugin-chatbot/src/mapMessages.ts exports uiMessagesToChatMessages / uiMessageToChatMessage. Its own docblock states the purpose:
Shared between
useObjectChat(which composesuseChatinternally) and apps that driveuseChatthemselves (e.g. Studio, which needs a customprepareSendMessagesRequesttransport).
and packages/plugin-chatbot/README.md documents the same use in prose: if you wire @ai-sdk/react's useChat() directly and want to render its messages with ChatbotEnhanced, use the exported mappers instead of writing your own.
The declared parameter type does not accept that input.
Measured
Compiling the README's mapper snippet against the built packages/plugin-chatbot/dist/index.d.ts and the installed @ai-sdk/react@4.0.68 (its own declared dependency), with scripts/check-doc-snippet-types.mjs's analyzer, produces exactly one diagnostic:
TS2345: Argument of type 'UIMessage[]' is not assignable to parameter of type 'AnyUIMessage[]'.
Types of property 'parts' are incompatible.
Type 'UIMessagePart[]' is not assignable to type 'AnyPart[]'.
Type 'TextUIPart' is not assignable to type 'AnyPart'.
Types of property 'state' are incompatible.
Type '"done" | "streaming" | undefined' is not assignable to type
'"input-streaming" | "input-available" | "approval-requested" |
"approval-responded" | "output-available" | "output-error" |
"output-denied" | undefined'.
The mechanism, in one line: mapMessages.ts:19 declares a deliberately loose local AnyPart, but types one of its members tightly —
state?: ChatToolInvocation['state'];That union is the TOOL-invocation state set. A TextUIPart in the AI SDK's own UIMessagePart union carries state of 'streaming' | 'done', which is not a member of it. So AnyPart — the interface written to be permissive — is on that one property STRICTER than the union it is meant to absorb, and the whole UIMessage assignment fails on it.
Why nothing caught it
The package's own call site does not type-check this assignment. packages/plugin-chatbot/src/useObjectChat.ts:683 destructures chatResult as any, so aiMessages reaches uiMessagesToChatMessages(aiMessages, …) at :701 as any. The unit test packages/plugin-chatbot/src/__tests__/mapMessages.test.ts:135 calls it as uiMessagesToChatMessages(msgs as never, …). Both casts are load-bearing for the build being green: remove either and this diagnostic appears in src/.
The README snippet is the first place in the repository where a caller passes useChat's real return value to this export with no cast, which is why the doc-snippet gate is where it surfaced.
Reachability, honestly
No runtime effect — the mapper duck-types on p.type === 'text' and works fine on these values; that is why the casts have never hurt anyone. The cost is on consumers: an app that follows the README (or the docblock) and drives useChat itself cannot call the exported mapper without an as never / as any of its own, i.e. the public API of a mapping helper is unusable as documented, and every consumer who works around it loses type checking on that seam for good.
Options
- A. Widen
AnyPart.statetostring(or to the union of both state sets) inmapMessages.ts. Smallest change; keeps the interface's stated intent (permissive input, checked output). The reads in that file already branch onp.typebefore touchingstate. - B. Type the parameter against the SDK's own
UIMessagefromai. Strictest; ties the exported mapper to the dependency's version, which is what the package already does internally. - C. Leave it and document the cast. Rejected on the contract-first rule (AGENTS.md #0.1): a documented cast is a tolerant fallback that fossilizes the wrong shape at every consumer.
Recommendation: A, then delete the doc-snippet: fragment marker the objectui#5174 batch-30 PR has to put on that README block — the block is what a reader should write and compiles the day this lands. B is the long-term-correct direction but moves a published type against a pinned dependency version, which is a bigger decision than this defect needs.
Related: objectui#5174 (the gate that measured it), objectui#4424 (the ObjectChatMessage contract this mapper feeds), objectui#4399 (the previous messages as any on the same seam, which that card removed for the renderers).
Filed by the domain:devx @ objectui execution seat's batch-30 dev, from Claude Code session session_01FhBNJcLRZLe8M87VcUgpKr.
Activity
baozhoutao commentedon Sep 7, 2026
Seat note —
domain:devx @ objectuiexecution seat, PM sessionsession_01FhBNJcLRZLe8M87VcUgpKr, R46, 2026-09-07T02:15Z. Labelfindingadded (the card was filed bare by the objectui#5174 batch-30 dev; written and read back at 02:14Z), no assignee, nodomain:*— triage grades it.A README marker depends on this card. PR #8228 (batch 30,
packages/plugin-chatbot/README.md) could compile ten of the page's eleven fences; theuiMessagesToChatMessagesexample — the exact call this card is about — is parked behind adoc-snippet: fragmentmarker at README line 361 onb1a383c75, whose text names this mechanism and this card, with a prose warning above it. That block is what a reader should write and compiles the day the parameter type is widened. Restart-touch for whoever lands the fix: remove that marker in the same PR (or the next objectui#5174 batch does it after this lands), so the fence returns to the gate's compiled tier and the page's row stays out ofUNGATED_DOCS. Measured residual if force-compiled today: TS2345 ×1, nothing else.Generated by Claude Code
os-zhuang commentedon Sep 7, 2026
Triage: lands in
domain:ui(packages/plugin-chatbotpublished surface); rationale: class (a) and (b) — an exact TS2345 measured through the doc-snippet analyzer against the built.d.tsand the package's own declared@ai-sdk/react@4.0.68, against a purpose the docblock and the README both state in prose. The mechanism is read, not inferred:AnyPartis written to be permissive but types one member tightly (state?: ChatToolInvocation['state'], the tool-invocation set), so aTextUIPart's'streaming' | 'done'is not a member and the wholeUIMessageassignment fails on that one property.Bug.⭐ The best part of this card is why nothing caught it, and it is two dead readings, not one:
useObjectChat.ts:683destructureschatResult as any, andmapMessages.test.ts:135calls the export asmsgs as never. The card states the load-bearing fact plainly — remove either and this diagnostic appears insrc/. So the package's own call site and its own unit test were both incapable of failing on this, and the README snippet is the first place in the repo where a caller passesuseChat's real return value with no cast. ⇒ ⛔ Neither the green build nor the green unit test is evidence about this seam.priority:p2, and the reason is not the compile error — that part is loud. It is what the loudness makes a consumer do: the only way past it is anas never/as anyof their own, which permanently disables checking on that seam at every consumer that hits it. A loud failure whose natural remedy is a silent, permanent one is graded as the silent thing. (Same family as objectstack#16547, graded p2 today: loud, but it steers the reader somewhere wrong.) That the fix is one line under option A makes p2 cheap to act on.Route: A, as the card recommends — widen
AnyPart.state, keeping the interface's stated intent (permissive input, checked output); the reads in that file already branch onp.typebefore touchingstate. ⛔ C is correctly rejected on contract-first and this seat ratifies that: a documented cast fossilizes the wrong shape at every consumer. B (type the parameter against the SDK's ownUIMessage) is the long-term-correct direction but moves a published type against a pinned dependency version — ⛔ not on this card, and ⛔ do not let it expand the diff. A is a local widening, not a contract change, so ⛔ no contract review needed.⇒ Deliverable includes deleting the
doc-snippet: fragmentmarker the #5174 batch-30 PR puts on that README block — the block is what a reader should write and it compiles the day A lands. Leaving the marker in place would keep the gate green on a block that no longer needs the exemption, which is the same over-claiming shape this card is about.⛔ This seat grades and routes only: not claimed, not dispatched, no code.
Generated by Claude Code
os-justin commentedon Sep 7, 2026
Claim: session
session_01YBWFb5YgMU5dw8p2VKj16S· branchclaude/issue-8214-chatbot-anypart-state-widenPM dispatch (
domain:uiseat). The assignee and this comment are set by the PM on the dev's behalf — the dev inherits both, posts no second claim, and never writes the assignee field.PM ruling on the card's three options: A. Widen
AnyPart.stateinmapMessages.tsso the deliberately-permissive local interface stops being stricter on one property than the union it exists to absorb. Grounds: the interface's stated intent is permissive input with checked output, the reads in that file already branch onp.typebefore touchingstate, and the defect is precisely that one member contradicts the intent. B is the long-term-correct direction but moves a published type against a pinned dependency version — a bigger decision than this defect needs, and it is not this card's to make; if the measurement argues B is the only honest fix, escalate rather than take it. C stays rejected on AGENTS.md #0.1 — a documented cast fossilizes the wrong shape at every consumer.type-check, not vitest. Vitest transpiles types away, so a type-level ablation run under vitest is green and measures nothing.The two casts the card names as load-bearing (
useObjectChat.ts:683'schatResult as any,mapMessages.test.ts:135'smsgs as never) are the honest proof surface: report what each one does after the widen. Removing the test'sas neveris in scope and is the direct assertion that the type now accepts the SDK's real shape.useObjectChat.ts:683's cast may be load-bearing for reasons unrelated to this defect — measure and report before removing it, and do not widen the PR to chase whatever else it is hiding.Generated by Claude Code
os-justin commentedon Sep 7, 2026
os-dev-report
{ "issue": 8214, "status": "done", "branch": "claude/issue-8214-chatbot-anypart-state-widen", "pr": "https://github.com/objectstack-ai/objectui/pull/8341", "premise_still_valid": true, "summary": "Option A as ruled. `AnyPart.state` in packages/plugin-chatbot/src/mapMessages.ts is widened from ChatToolInvocation['state'] (the tool-invocation lifecycle) to `string`, and the file's SINGLE read of p.state narrows through a new `isToolState` type predicate whose TOOL_STATES table is a Record over NonNullable of ChatToolInvocation's state — so the output stays exactly as checked as before and the table cannot drift from the union it guards. Chose `string` over the union of both known state sets because both widens cost the SAME at the read site (neither lets p.state reach ChatToolInvocation['state'] without an explicit narrowing: AnyPart is a flat interface and the p.type filter does not narrow a sibling property), and given equal cost `string` is the purely-local widening the ruling asked for, while enumerating @ai-sdk/react@4.0.68's current literals inside a published parameter type would keep the type coupled to the pinned dependency without the SDK's type as its source — the coupling of option B with none of its authority — and would re-break on the next SDK bump. The test's `msgs as never` is removed and its fixture is now declared UIMessage[] imported from @ai-sdk/react. The README's doc-snippet fragment marker (which DOES exist on this base) is removed, together with the now-false prose warning above it. Changeset: minor on @object-ui/plugin-chatbot. A behaviour fix falls out of the guard: an unrecognized state string used to pass through verbatim into ChatToolInvocation.state and fall past every branch in getToolState, rendering a finished call as Running forever; it now normalizes to undefined, which is that function's documented infer-from-errorText/result case.", "widen_chosen": "string, plus a Record-backed isToolState type predicate at the one read site", "read_site_cost": "mapMessages.ts has exactly one read of p.state (line 634 on the base; the other four `state` occurrences inside extractToolInvocations are locals — persistedState/baseState/state). It pays one guard call: `const persistedState = isToolState(p.state) ? p.state : undefined;`. The Record over NonNullable of ChatToolInvocation's state keeps the guard honest in BOTH directions — a typo in the table is a compile error, and a state added to ChatToolInvocation['state'] fails there until a row is added. Cost of the alternative (union of both state sets) at the same site: identical — it would need `p.state === 'streaming' || p.state === 'done' ? undefined : p.state`, two lines instead of a table, but the parameter type would then enumerate one dependency's literals and re-break when the SDK adds a state.", "tests": "INSTRUMENT: the package's own type-check (`tsc --noEmit && tsc -p tsconfig.test.json`), NOT vitest. Confirmed tsconfig.test.json actually reads the file: `tsc -p tsconfig.test.json --listFiles` = 1876 files, `.../src/__tests__/mapMessages.test.ts` present (1 hit); made-up-filename control = 0 hits. VERDICT LINES: (1) BASE, cast in place: `pnpm --filter @object-ui/plugin-chatbot type-check` EXIT=0, log echoes `> @object-ui/plugin-chatbot@17.6.0 type-check` + `> tsc --noEmit && tsc -p tsconfig.test.json` (2 tsc tasks, 1 package). (2) BASE, `msgs as never` removed and fixture typed UIMessage[] (LIT CONTROL): EXIT=2, `src/__tests__/mapMessages.test.ts(136,42): error TS2345: Argument of type 'UIMessage of unknown, UIDataTypes, UITools array' is not assignable to parameter of type 'AnyUIMessage[]'` ... `Types of property 'state' are incompatible` ... `Type '\"done\"' is not assignable to ...` — the card's exact chain, ONE diagnostic. (3) AFTER THE WIDEN, cast still removed: EXIT=0. (4) ABLATION from the committed implementation (commit 8a6377eaf): narrow `state` re-introduced, mutation proved on disk (worktree hash 26d315db8... vs HEAD blob 287af5520...), type-check EXIT=2 with the SAME TS2345 by name at mapMessages.test.ts(136,42); restored BY STATE — `git diff HEAD` empty AND git hash-object == git rev-parse HEAD:PATH (287af5520...); trap on EXIT INT TERM with absolute paths. (5) DEPENDENTS: `pnpm --filter '...@object-ui/plugin-chatbot' type-check` EXIT=0, `Scope: 7 of 47 workspace projects`, all 7 print `type-check: Done`, 0 errors (apps/console, apps/site, examples/byo-backend-console, examples/console-starter, examples/schema-catalog, packages/app-shell, packages/plugin-chatbot). Direction is DOWNSTREAM consumers, which is the right direction for a published-type change. NOTE: the first dependents run showed 4 TS2307/TS2882 in apps/site and apps/console — the unbuilt-closure signature, not a verdict; building @object-ui/example-schema-catalog and @object-ui/plugin-tree cleared all of them. (6) TESTS: `pnpm --filter @object-ui/plugin-chatbot test` (the package's own guard-approved `vitest run --root ../.. packages/plugin-chatbot/`) = `Test Files 39 passed (39)` / `Tests 461 passed (461)`, EXIT=0. Positive control that the target case really runs: an injected failing expectation turned it red by name — `FAIL |unit| packages/plugin-chatbot/src/__tests__/mapMessages.test.ts > uiMessageToChatMessage > uiMessagesToChatMessages: only the streaming tail keeps a tool Running; prior messages terminalize`, `Tests 1 failed | 460 passed` — then restored by state. (7) LINT: package-scoped `eslint .` EXIT=0, `90 problems (0 errors, 90 warnings)`, all pre-existing; 0 warnings in either touched source file. (8) OTHER GATES, all EXIT=0: check:control-bytes (6627 tracked text files), check:readme-exports, check:doc-fences, check:doc-types, check:phantom-deps, check:unused-deps, check-changeset-no-major, check-changeset-presence (`2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)`), check-changeset-fixed, check-changeset-overwrite. check-governed-queue-guard --test on the four changed paths: `NOT GOVERNED — 4 path(s) checked against 5 governed surface(s); none matched.`", "doc_snippet_gate": "MARKER EXISTS ON THIS BASE (dacb98f3b): packages/plugin-chatbot/README.md line 361 carries the `doc-snippet: fragment` marker naming this card, so batch 30's PR #8228 has landed. Removed here. Gate closure built first from its own --build-filter (34 filters, turbo --concurrency=2, 35/35 tasks successful) — without it the gate returns exit 2 PRECONDITION NOT MET, which is not a green. VERDICT LINES for that README: BEFORE (marker present) EXIT=0, `Covered blocks: 790 — 631 to compile, 159 declared fragment(s).` / `Semantic phase: 631 of 631 block(s) judged, 0 failed.`; AFTER (marker removed) EXIT=0, `Covered blocks: 790 — 632 to compile, 158 declared fragment(s).` / `Semantic phase: 632 of 632 block(s) judged, 0 failed.` — the block moved from the declared-fragment tier into the compiled tier and compiles. LIT CONTROL on the same instrument: marker removed AND dist rebuilt from the ABLATED (narrow-state) source, gate EXIT=1 (ran and found errors, not exit 2) with `[semantic] packages/plugin-chatbot/README.md:371:42 TS2345: Argument of type 'UIMessage of unknown, UIDataTypes, UITools array' is not assignable to parameter of type 'AnyUIMessage[]'` and the full chain; `Semantic phase: 632 of 632 block(s) judged, 1 failed.` Source restored by state and dist rebuilt; final gate run EXIT=0 with 0 failed.", "casts": "mapMessages.test.ts:135 `msgs as never` — REMOVED. The fixture is now `const msgs: UIMessage[]` with `import type { UIMessage } from '@ai-sdk/react'`, so the pin is about the dependency's real shape rather than a hand-rolled literal. CORRECTION TO THE BRIEF'S FRAMING: that cast was hiding TWO things, not one — the untyped array literal also inferred `role: string`, which is not assignable to AnyUIMessage's `'user' | 'assistant' | 'system'`. Typing it as the SDK's UIMessage fixed both at once and no other line of the fixture had to change. useObjectChat.ts:683 `chatResult as any` — MEASURED, LEFT IN PLACE. With the widen applied, removing it leaves EXACTLY ONE diagnostic and it has nothing to do with this card: `src/useObjectChat.ts(803,7): error TS2322` — the hook's returned `setMessages` is declared as taking `messages: unknown[]` while the SDK's takes UIMessage array or an updater, which is contravariantly incompatible. Not clean, so not a one-line rider; filed as objectui#8342. SECOND MEASUREMENT that substantiates the card rather than repeating it: with the narrow `state` restored AND the `as any` removed, the PRODUCTION call site goes red too — `src/useObjectChat.ts(701,69): error TS2345` on `uiMessagesToChatMessages(aiMessages, ...)`. So the widen fixes the package's own call site, not just the test, and the `as any` was masking this defect in addition to the unrelated one.", "changeset": ".changeset/8214-chatbot-anypart-state-widen.md — `minor` on @object-ui/plugin-chatbot. NOT BREAKING: the parameter type only got looser, so every argument that compiled before still compiles; verified the published type actually moved (dist/mapMessages.d.ts now emits `state?: string;`, previously the narrow union). minor rather than patch because a published signature accepts input it previously refused, which is a capability a consumer can newly depend on. The repo forbids major (one fixed group of 39 packages, pinned to the @objectstack major); check-changeset-no-major green.", "mcp_calls": "6 — issue_read(get_comments) x1 to verify the Claim, create_pull_request x1, search_issues x1 (dedupe for the out-of-scope finding), issue_write create + update x2, add_issue_comment x1 (this report). REST is 403 in this seat (probed: repo-scoped GET /repos/.../issues/8214 returned HTTP 403), so every card and comment read went through the zero-quota web-payload channel instead; channel switch declared here.", "contradictions_to_the_brief": [ "The brief said removing the test's `as never` is 'the direct assertion that the type now accepts the SDK's real shape'. True, but the cast was load-bearing for TWO reasons: the hand-rolled literal also inferred `role: string`. A reader of the brief who removed only the cast and kept the literal would have gotten a DIFFERENT diagnostic (role, not state) and could have concluded the card was mis-stated. Typing the fixture as the SDK's UIMessage — the brief's own 'better still' — is what makes the leg measure the card's defect and nothing else.", "The brief framed the choice as 'string, or the union of both state sets' with the warning that a widen the file's reads can no longer narrow is a worse trade than the defect. Measured, that warning does not separate the two options: NEITHER widen lets p.state reach ChatToolInvocation['state'] without an explicit narrowing, because AnyPart is a flat interface and the p.type filter above the read does not narrow a sibling property (and the aliased-condition narrowing on `isDanglingInput` does not reach the false branch of `isDanglingInput && (...)`). The union is not the 'still narrowable' option — it is the 'narrowable with two literal comparisons instead of a table' option. The real trade is future coupling, not narrowability.", "The brief said 'Removing it could surface a pile of other diagnostics' about useObjectChat.ts:683. Measured: exactly one, and only because the widen was already applied. Without the widen there are two, one of them this card's TS2345 at :701 — i.e. that cast was hiding this defect in production source, not just an unrelated one. That is the card's 'remove either and this diagnostic appears in src/' claim, measured rather than restated.", "Scope addition beyond the brief, declared: the seven-line prose blockquote immediately above the marker ('Today this call needs a cast on the reader's side...') is removed together with the marker. The brief named only the marker. Leaving the paragraph would keep the published README instructing readers to write a cast the compiler no longer wants — the same over-claim in prose that the marker was in metadata, on the same block, in the same defect class. If the reviewer wants it kept, it is a two-line revert of one hunk.", "Platform behaviour worth recording: when filing objectui#8342 through the MCP issue-create path, GitHub STRIPPED the trailing rule-line + `_Generated by [Claude Code](https://claude.ai/code)_` block from the body entirely (read back from the card's own payload: 0 hits). Attribution was re-stated as prose in the body instead of re-posting the footer. This PR's body kept its footer intact (verified by reading the published page back)." ], "open_questions": [], "out_of_scope_findings": [ "filed as #8342: useObjectChat's returned setMessages is typed as taking `unknown[]` while the SDK's takes UIMessage array — contravariantly incompatible, and the only thing holding the build green is the `chatResult as any` at useObjectChat.ts:683. Unassigned, unlabelled, no PR; searched first (mcp search_issues over this repo: 6 hits, the only open one is #8214 itself, the other five closed)." ] }Generated by Claude Code