Skip to content

useObjectChat's returned setMessages is typed (messages: unknown[]) to void — contravariantly incompatible with the SDK's, and only the chatResult as any at :683 hides it #8342

Closed

Description

@os-justin

Found while widening AnyPart.state for objectui#8214. Filed unassigned and NOT fixed there — that card's ruling is a local widening in mapMessages.ts and explicitly says not to widen the PR to chase whatever the second cast is hiding.

Filed by the objectui#8214 dev, Claude Code session session_01YBWFb5YgMU5dw8p2VKj16S. (Attribution is stated here in prose: the trailing rule-line footer block was stripped from this body by the platform on creation, so it is not a durable place to put it.)

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

What

packages/plugin-chatbot/src/useObjectChat.ts:683 destructures the useChat() result through chatResult as any. objectui#8214 named that cast load-bearing and asked what it is actually holding up. Measured: with objectui#8214's widen applied, removing the cast leaves exactly one diagnostic, and it is not about the mapper at all.

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 of unknown, UIDataTypes, UITools'.

(Arrow spelled as the word "to" above for the same sanitizer reason.)

The hook returns setMessages declared as taking messages: unknown[]. Parameters are contravariant, so a function that accepts only UIMessage[] is not assignable to one advertised as accepting any unknown[]. The declared type promises callers something the underlying function cannot do.

Reachability

No runtime effect today: the value passed through is the SDK's own setMessages and every in-repo caller happens to hand it real messages. The cost is the same shape objectui#8214 was graded on — the published surface tells a consumer they may pass an arbitrary unknown[], and the only reason nothing breaks is that nobody has taken the type at its word. A consumer who does gets a runtime failure the compiler said was fine.

Measured

  • Base commit: dacb98f3b plus objectui#8214's widen (PR fix(plugin-chatbot): accept the AI SDK's UIMessage in the exported mappers #8341).
  • Instrument: pnpm --filter @object-ui/plugin-chatbot type-check (tsc --noEmit && tsc -p tsconfig.test.json). Vitest cannot see this — it transpiles types away.
  • With the cast in place: exit 0. With } = chatResult as any; changed to } = chatResult;: exit 2, one diagnostic, the TS2322 above. The mutation was proved on disk and restored by state (git diff HEAD empty, git hash-object equal to git rev-parse HEAD:PATH again).

Options, not a ruling

  • A. Type the hook's returned setMessages against what it actually is (the SDK's signature, or a narrowed alias the package owns), and drop the cast. Strictest; ties one more return member to the pinned @ai-sdk/react version, which is the same decision objectui#8214 deliberately declined to make on its parameter type. Probably wants the same eye.
  • B. Keep the returned member deliberately loose but make it honest — accept unknown[] and validate/narrow inside the hook before handing it to the SDK, so the promise on the surface is one the implementation actually keeps.
  • C. Leave the cast and document it. Rejected by the same rule that rejected it on objectui#8214 (AGENTS.md #0.1): a documented cast fossilizes the wrong shape.

Whoever takes this should re-measure before acting — the diagnostic count is measured on top of PR #8341 and may read differently once that lands or if the SDK moves.

Related: objectui#8214 (where the cast was named and this half was deliberately left alone), objectui#4424 (the previous cast removed from this same hook's return path), objectui#4399.

Activity

  1. changed the title [-]useObjectChat's returned setMessages is typed (messages: unknown[]) => void — contravariantly incompatible with the SDK's, and only the `chatResult as any` at :683 hides it[/-] [+]useObjectChat's returned setMessages is typed (messages: unknown[]) to void — contravariantly incompatible with the SDK's, and only the `chatResult as any` at :683 hides it[/+] on Sep 7, 2026
  2. added
    bugSomething isn't working
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    and removed on Sep 7, 2026
  3. self-assigned this
    on Sep 7, 2026
  4. os-justin commented on Sep 7, 2026

    @os-justin
    CollaboratorAuthor

    Claim: session session_01YBWFb5YgMU5dw8p2VKj16S · branch claude/issue-8342-setmessages-honest-surface

    PM dispatch (domain:ui seat). 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: B. Keep the returned member deliberately loose, but make it honest — accept unknown[] and narrow inside the hook before handing it to the SDK, so the promise on the published surface is one the implementation actually keeps. Then drop the chatResult as any.

    Grounds, and the consistency they rest on: this is the same principle objectui#8214 was ruled on one file over. There the fix was to widen AnyPart.state locally so a deliberately-permissive interface stopped being stricter than the union it absorbs — not to type the parameter against the SDK's own UIMessage, because that couples a published type to a pinned dependency version. A here is the mirror image of the option that ruling declined: it would tie one more published return member to @ai-sdk/react's current signature and re-break on the next bump. C stays rejected under AGENTS.md #0.1.

    ⚠️ If the measurement argues the hook genuinely cannot narrow without duplicating the SDK's type — i.e. B collapses into A — escalate; do not take A on your own.

    Re-derive on your own base rather than from the card — objectui#8214's fix (PR #8341) has landed since it was filed and the line numbers moved:

    :684   } = chatResult as any;                              ← the card says :683
    :360   setMessages?: (messages: unknown[]) => void;        ← the declared surface, the file's ONLY `unknown[]`
    :803   setMessages,                                        ← the return site the TS2322 lands on
    :622   chat.setMessages(cur.slice(0, -1));                 ← internal caller
    :724   setMessages([]);                                    ← internal caller
    

    ⚠️ The instrument is type-check, not vitest — vitest transpiles types away, so a type-level ablation under it is green and measures nothing. Prove the change can fail: with your fix in place, re-introduce the as any and the old declaration, and show the TS2322 chain the card quotes coming back by name.


    Generated by Claude Code

  5. os-justin commented on Sep 7, 2026

    @os-justin
    CollaboratorAuthor

    os-dev-report

    {
    "issue": 8342,
    "status": "done",
    "branch": "claude/issue-8342-setmessages-honest-surface",
    "pr": "#8377",
    "premise_still_valid": true,
    "summary": "Ruling B implemented and the chatResult as any at :684 is gone. The published member stays setMessages?: (messages: unknown[]) => void; the hook now wraps the SDK function and narrows inside, against a message type DERIVED from useChat itself (Extract of Parameters of ReturnType of typeof useChat sub setMessages, index 0, with readonly unknown array, then index number) rather than restated -- so no SDK type is named in a type position and the published surface stays version-independent. B DOES NOT COLLAPSE INTO A: proved on the emitted declaration, dist/useObjectChat.d.ts:265 is unchanged and every UIMessage occurrence in that file is inside a doc comment, never a type position; SdkChatMessage is module-private and absent from dist. NON-SURVIVING ELEMENT: REFUSED LOUDLY, not filtered and not passed through -- a TypeError naming the index, with NOTHING written, because the whole array is checked before anything reaches the store. Grounds: this is a re-hydration path returning void, so a filtered (shorter) thread is undetectable by the caller -- the same silent-deletion class objectui#4424 was graded on. The guard checks the three members UIMessage requires (string id, one of the three roles, parts array); parts is checked for array-ness ONLY, because the part union is open (data- prefixed parts carry author-defined payloads) and restating it is the coupling the ruling declined -- that residual is documented in the guard's doc comment, not papered over. Pinned by a new 13-case test file whose compile-time half asserts the published signature is exactly (messages: unknown[]) => void. TWO INTERNAL CALLERS: only ONE goes through the wrapper. :724 setMessages([]) (clear) now routes through it and an empty array survives trivially (pinned). :622 chat.setMessages(cur.slice(0, -1)) is NOT affected -- it reaches the SDK function via chatRef.current (typed any), not via the returned member, and its value is the SDK's own live messages minus the last element, already the right shape; left as-is with a comment saying so. CHANGESET: @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 -- the opposite direction from objectui#8214's widen. Assignee was already set by the PM and left untouched; the newest Claim: comment names this session and this branch.",
    "tests": "INSTRUMENT DISCIPLINE: type-check throughout, never vitest, for type-level claims. listFiles PROOF (both programs, with lit controls): test project tsc -p tsconfig.test.json --listFiles lists BOTH packages/plugin-chatbot/src/useObjectChat.ts (line 1661) and packages/plugin-chatbot/src/__tests__/useObjectChat.setMessagesHonest-8342.test.tsx (line 1747). Main program tsc --noEmit --listFiles lists src/useObjectChat.ts (line 1613) and ZERO test files -- lit control for that zero: the identical regex plugin-chatbot/src/.*\\.test\\. matches 39 entries in the test project's list, and 1 for the new file. GREEN RUNS: 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; a --reporter=verbose --no-passWithNoTests run names all 13 new cases individually. eslint . in packages/plugin-chatbot exit 0, 0 errors, 89 warnings -- 0 in the new test file, and the 6 in useObjectChat.ts are pre-existing react-hooks/refs at 542/590/627/629/637/736, none in new code. DEPENDENTS RUN (downstream direction): pnpm --filter '...@object-ui/plugin-chatbot' type-check exit 0. SCOPE LINE: Scope: 7 of 47 workspace projects. PER-PACKAGE VERDICTS, all Done, 0 error TS total: packages/plugin-chatbot, examples/schema-catalog, packages/app-shell, examples/console-starter, examples/byo-backend-console, apps/site, apps/console. (First attempt exited 2 with pure TS2307/TS2882 across untouched files -- the stale-dist signature, not a verdict; re-run after building every package. Reported as measured, not hidden.) Also ran the real consumer's suite: pnpm --filter @object-ui/app-shell test exit 0, 647 files, 6234 passed, 1 skipped. ABLATION -- FOUR LEGS, each from the COMMITTED implementation (HEAD 5f93455), each proving the mutation reached disk (git hash-object != git rev-parse HEAD:PATH, plus before/after grep counts on the anchor text) and each restored BY STATE (git diff HEAD empty AND blob equal again), under trap '...' EXIT INT TERM with absolute paths. LEG 1 (wrapper removed, cast stays off): tsc --noEmit RED, TS2322 at (916,7), the card's chain reproduced BY NAME including Type 'unknown[]' is not assignable to type 'UIMessage...[]' and Type 'unknown' is not assignable to type 'UIMessage...'; tsconfig.test.json RED with the same; vitest RED 11 of 13. LEG 2 (wrapper removed AND chatResult as any restored = the exact pre-fix hook body): tsc --noEmit GREEN, tsconfig.test.json GREEN, vitest RED 11 of 13. LEG 3 (cast restored, wrapper KEPT): all three GREEN. LEG 4 (declaration re-narrowed to the SDK message type -- the option-A counterfactual): tsc --noEmit GREEN, tsconfig.test.json RED with TS2344 at (103,32) and (108,3) -- exactly my _HonestSignature and _AcceptsUnknownArray pins. RED SETS INTERSECT AT VITEST (legs 1 and 2); their union covers both instruments. The 2 of 13 cases that stay green in legs 1 and 2 are the positive-path ones (forwards untouched; clear() empties) -- correct, the raw SDK function also forwards everything and accepts []. FINDING PROBES (same harness, restored by state): removing as any from useChat({...} as any) -> tsc --noEmit exit 0, ZERO diagnostics (filed #8378); removing ui as unknown[] in app-shell's useReconcileOnError -> app-shell tsc --noEmit exit 0, ZERO diagnostics (filed #8379). CONTROL-CHARACTER SCAN: grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' on both edited/added source files -- no hits.",
    "mcp_calls": "10 -- REST was 403 for this session (GitHub access is not enabled for this session) and the zero-quota public-repo payload channel was blocked by the local command classifier, so channel 3 (MCP) carried every GitHub read and write. Breakdown: 2 issue_read (body + the Claim: comment), 1 create_pull_request, 1 pull_request_read (full body read-back), 3 search_issues (1 known-hit control that returned #8342, 2 dedupe queries), 2 issue_write (the two findings), 1 add_issue_comment (this report). Channel switch declared here as required.",
    "open_questions": [
    {
    "question": "Changeset level: is a published member that starts REFUSING previously-undefined input a minor or a patch? I chose minor and argued it in the PR; the choice has a real cost the brief did not name.",
    "options": [
    "A. minor (what I shipped). The declared type does not move, but the set of values a consumer can successfully pass AT RUNTIME narrows -- an observable behaviour change on a published member, and this repo ships breaking changes as minor since major is forbidden.",
    "B. patch. No caller loses a capability it actually HAD: passing a non-message array was undefined behaviour that merely happened not to throw at the call site, so this is a bug fix rather than a contract change.",
    "C. minor, but split: land the narrowing as patch now and reserve the level decision for the release PR."
    ],
    "recommendation": "A, because over-bumping never breaks a consumer while under-bumping can, and the brief explicitly asked which DIRECTION the surface moved -- it narrows, which is the direction that deserves the louder level. But the PM should weigh one cost I measured and the brief did not mention: plugin-chatbot sits in the fixed version group in .changeset/config.json, so this minor bumps the ENTIRE @object-ui/* line 17.6.x to 17.7.0, not just this package. If that is unwanted, B is a one-word edit to .changeset/setmessages-honest-surface-8342.md and I have no objection to it."
    },
    {
    "question": "Attribution-footer conflict, flagged rather than silently resolved. The dispatch's standing clause says a created PR body ends with the session-URL rule-line footer _Generated by [Claude Code](https://claude.ai/code/session_ID)_; a harness system-reminder in this session says it REPLACES earlier attribution guidance and requires the Generated with [Claude Code](https://claude.com/claude-code) block plus a bare session URL.",
    "options": [
    "A. Follow the system-reminder (what I did), on its own words that it replaces earlier attribution guidance.",
    "B. Follow the dispatch's standing clause, since the dispatch file says it wins on conflict."
    ],
    "recommendation": "A -- and the observed outcome resolves it in practice: after creation the platform APPENDED its own _Generated by [Claude Code](https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S)_ rule-line block below mine, so PR #8377's body now carries both. No action needed; recording it so the next dispatch does not read the duplicate block as a mistake."
    }
    ],
    "out_of_scope_findings": [
    "filed as #8378: finding(plugin-chatbot) -- useObjectChat's OTHER cast, useChat({...} as any) at :735, is DEAD. Measured: removing it leaves tsc --noEmit at exit 0 with zero diagnostics. Not the other half of #8342; a dead suppression that will silently acquire hiding power the next time the options object or the SDK's option type moves. Labelled finding, unassigned. Card records what is still unmeasured (test program, vitest, dependents) and that the nested (aiInitialMessages as any) is a third, separate cast.",
    "filed as #8379: finding(app-shell) -- useReconcileOnError.ts:74 asserts ui as unknown[] on a value already assignable to unknown[]. Measured: removing it leaves app-shell's tsc --noEmit at exit 0 with zero diagnostics. It sits on exactly the seam #8342 was about, so it is the assertion that would erase the diagnostic if either side's type is ever tightened. Labelled finding, unassigned.",
    "NOT filed, reported here instead (no card warranted, it is a correction to the card's text): objectui#8342's quoted TS2322 chain has FOUR levels; the chain measured on current main has FIVE -- the card elided the Type 'unknown[]' is not assignable to type 'UIMessage array' step between the union and the element. Everything else in the card re-measured exactly as written, including the line numbers the PM re-derived (:360, :622, :684, :724, :803)."
    ]
    }


    Generated by Claude Code

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpluginpm:dispatchedpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions