From fae805054c1bfe3f7f1498fcfd9ebca204f536a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:36:37 +0000 Subject: [PATCH 1/4] fix(types,plugin-chatbot): the authoring tool-invocation state union sheds the three runtime-only approval states The residual clause of the objectui#8426 ruling (decision batch #86): `approval-requested`, `approval-responded` and `output-denied` leave the authoring `ChatToolInvocation.state` union and its Zod mirror, so a schema-authored invocation cannot claim an approval state, with or without an `approval` envelope. The authoring type also served as the base of the chat runtime's seam, so the split is derived rather than copied: `SeamToolInvocation.state` is the union of the authoring and runtime vocabularies, `useObjectChat`'s `initialMessages` is the seam's input shape (app-shell hands it runtime values), and `ChatbotSchema.onSend` hands back the authoring message widened by exactly the three states, named by a non-exported alias. A compile-time Equal pin ties that alias to `ChatbotEnhanced`'s runtime union. No export is added. The un-backed-approval branch in the parts builder is kept: a runtime producer (app-shell's `mergeToolResultsInto`) still constructs an envelope-less `approval-requested`. Its docblock now says so. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../10018-authoring-state-sheds-approval.md | 14 ++ content/docs/plugins/plugin-chatbot.mdx | 14 +- packages/plugin-chatbot/README.md | 15 +- .../__tests__/chat-message-contract.test.ts | 78 +++++++++- .../plugin-chatbot/src/chatMessageAdapter.ts | 27 +++- packages/plugin-chatbot/src/useObjectChat.ts | 66 +++++--- .../chat-tool-approval-envelope-8442.test.ts | 56 +++++-- .../chat-tool-authoring-state-10018.test.ts | 147 ++++++++++++++++++ packages/types/src/complex.ts | 79 ++++++++-- packages/types/src/zod/complex.zod.ts | 13 +- 10 files changed, 430 insertions(+), 79 deletions(-) create mode 100644 .changeset/10018-authoring-state-sheds-approval.md create mode 100644 packages/types/src/__tests__/chat-tool-authoring-state-10018.test.ts diff --git a/.changeset/10018-authoring-state-sheds-approval.md b/.changeset/10018-authoring-state-sheds-approval.md new file mode 100644 index 0000000000..eed86223f5 --- /dev/null +++ b/.changeset/10018-authoring-state-sheds-approval.md @@ -0,0 +1,14 @@ +--- +'@object-ui/types': minor +'@object-ui/plugin-chatbot': minor +--- + +BREAKING (`@object-ui/types`, `@object-ui/plugin-chatbot`): the authoring `ChatToolInvocation.state` union sheds the AI SDK's three runtime-only approval states — `approval-requested`, `approval-responded` and `output-denied` — so a schema-authored tool invocation can no longer claim an approval the runtime has nothing to back (objectui#10018, the residual clause of the objectui#8426 ruling, decision batch #86). + +(The bump is `minor` by this repo's release model — objectui's major is pinned to the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) + +**Break 1 — authored approval states are refused.** `ChatToolInvocation.state` (and so `ChatMessage.toolInvocations[].state` and `ChatbotSchema.messages`) no longer admits the three approval states, and the Zod mirror `ChatToolInvocationSchema` refuses them as an `invalid_value` at `state`. The refusal holds with or without an `approval` envelope: the envelope does not make a runtime-only state authorable. A chat runtime still produces these states — from the SDK's approval envelope, or promoted from an ObjectStack pending-action result — and still renders them; only the authoring face stops declaring them. Migration: author the state the tool call is actually in (`input-available`, `output-available`, `output-error`, or a legacy `call` / `result`), and leave approval states to the runtime. + +**Break 2 — `onSend` handlers typed against the authoring `ChatMessage[]`.** The messages a chat runtime hands back can carry those three states, so they are no longer a subtype of the authoring `ChatMessage`. `ChatbotSchema.onSend`'s `messages` is now typed as the authoring message widened by exactly those states, and `useObjectChat`'s `ObjectChatMessage` is no longer assignable to the authoring `ChatMessage`. A handler that declares its parameter as the authoring `ChatMessage[]` stops type-checking. Migration: let the parameter be inferred from the slot, or declare it as `ObjectChatMessage[]` from `@object-ui/plugin-chatbot` when passing `onSend` to `useObjectChat`. + +Not breaking: `UseObjectChatOptions.initialMessages`, `SeamToolInvocation` and `toRuntimeToolState` now take the seam's state vocabulary (authoring plus runtime), which is the same set of values they accepted before, so every existing caller still compiles. No new name is exported: the three runtime-only states are named only by a non-exported alias in `@object-ui/types`, pinned equal to `ChatbotEnhanced`'s runtime union by `chat-message-contract.test.ts`. The effect on out-of-repo authors and hosts was not measured. diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index a3fd1625bb..bd55905845 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -714,11 +714,15 @@ tool invocation — the approval card, the "Review N changes" affordance, the pl card, the build panel, the inline charts), with `timestamp` narrowed to `string` because both modes absorb an authored `Date` before emitting. -It is a subtype of the authoring `ChatMessage`, so an `onSend` callback that -already declares `ChatMessage[]` keeps type-checking; naming `ObjectChatMessage` -is what lets it read those keys. Rebuilding a message field-by-field from the -authoring type drops every one of them — silently, and with the compiler's -agreement (objectui#4424). +In API mode its tool invocations can also carry the AI SDK's three approval +states — `approval-requested`, `approval-responded` and `output-denied`. Those +are runtime-only: the authoring `ChatToolInvocation` refuses them, with or +without an `approval` envelope (objectui#10018). So `ObjectChatMessage` is +**not** a subtype of the authoring `ChatMessage`, and an `onSend` callback that +declares its parameter as `ChatMessage[]` does not type-check; declare +`ObjectChatMessage[]`, which is also what lets it read those keys. Rebuilding a +message field-by-field from the authoring type drops every one of them — +silently, and with the compiler's agreement (objectui#4424). ## Related Documentation diff --git a/packages/plugin-chatbot/README.md b/packages/plugin-chatbot/README.md index f8e6820959..2cb805b9dd 100644 --- a/packages/plugin-chatbot/README.md +++ b/packages/plugin-chatbot/README.md @@ -130,15 +130,20 @@ modes (objectui#4424): card, the "Review N changes" affordance, the plan card, the build panel and the inline charts. The authoring contract declares none of them, so rebuilding a message field-by-field from it deletes all of them, and the compiler agrees. + API mode also produces the AI SDK's three approval states + (`'approval-requested'` / `'approval-responded'` / `'output-denied'`), which + the authoring contract refuses — they are runtime-only (objectui#10018). - **Not this package's runtime `ChatMessage` either.** In local mode an authored `'tool'` role and the legacy `'partial-call'` / `'call'` / `'result'` tool states pass through untouched; they are folded only at the render seam (`toRuntimeMessages`, `chatMessageAdapter.ts`). -`ObjectChatMessage` is a subtype of the authoring `ChatMessage`, so anything -already typed against that keeps compiling — naming `ObjectChatMessage` is what -lets you *read* the keys above. `timestamp` is always a `string` here: both -modes absorb an authored `Date` before emitting. +`ObjectChatMessage` is **not** a subtype of the authoring `ChatMessage` +(objectui#10018): its tool invocations can carry those three runtime-only +approval states. So an `onSend` callback that declares its parameter as the +authoring `ChatMessage[]` does not type-check — declare `ObjectChatMessage[]`, +which is also what lets you *read* the keys above. `timestamp` is always a +`string` here: both modes absorb an authored `Date` before emitting. ## Schema-Driven Usage @@ -332,7 +337,7 @@ function MyAuthoredChat({ messages }: { messages: AuthoredChatMessage[] }) { |---|---|---|---| | `role` | `'user' \| 'assistant' \| 'system' \| 'tool'` | `'user' \| 'assistant' \| 'system'` | a `'tool'` message **is an assistant message**: it renders as an assistant bubble with its content shown. `'system'` keeps its own role (`` renders it as a centred pill). | | `timestamp` | `string \| Date` | `string` | a `Date` becomes its ISO 8601 string. The runtime renders the timestamp straight into a React child, where an object throws. | -| `toolInvocations[].state` | AI SDK v6 states **+ legacy** `'partial-call' \| 'call' \| 'result'` | v6 states only | the legacy spellings map to `'input-streaming'` / `'input-available'` / `'output-available'` — the mapping the authoring type's own docs declare. | +| `toolInvocations[].state` | AI SDK v6 states **except** the three approval states, **+ legacy** `'partial-call' \| 'call' \| 'result'` | v6 states only | the legacy spellings map to `'input-streaming'` / `'input-available'` / `'output-available'` — the mapping the authoring type's own docs declare. `'approval-requested'` / `'approval-responded'` / `'output-denied'` are runtime-only and not authorable (objectui#10018); the adapter's input still admits them, because API-mode values carry them. | | everything else | — | — | passed through untouched, including keys the runtime contract does not declare. | The three registered SDUI renderers (`chatbot`, `chatbot-enhanced`, diff --git a/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts b/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts index dbdb1ee8bf..8b19228041 100644 --- a/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts +++ b/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts @@ -41,7 +41,7 @@ import type { ChatMessage as BarrelChatMessage, ChatbotEnhancedMessage } from '. /** The shape `` renders and the mappers produce. */ import type { ChatMessage as EnhancedChatMessage } from '../ChatbotEnhanced'; /** The OTHER side of the seam: the JSON/SDUI authoring contract. */ -import type { ChatMessage as AuthoredChatMessage } from '@object-ui/types'; +import type { ChatMessage as AuthoredChatMessage, ChatbotSchema } from '@object-ui/types'; import type { authoredToRuntimeMessage, toRuntimeMessages, @@ -65,6 +65,13 @@ type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? type HasIndexSignature = string extends keyof T ? true : false; type Has = K extends keyof T ? true : false; +/** + * One element of the `messages` a chat runtime hands a schema's `onSend` — read + * off the published slot, because `@object-ui/types` deliberately exports no + * name for it (objectui#10018). + */ +type HandedBackMessage = Parameters>[1][number]; + /** * The retired shape, transcribed verbatim from the declaration this change * deleted from `src/index.tsx`. It exists here ONLY so the negative pin below @@ -285,13 +292,22 @@ describe("useObjectChat's declared message type is honest about both modes", () type _HasProposedChanges = Assert>; type _HasBuilderHandoff = Assert>; - // 5. The compatibility statement, and the reason this is a MINOR and not a - // break: the honest type is a SUBTYPE of the authoring one it replaces. - // Every consumer that correctly accepted `@object-ui/types`' ChatMessage - // still accepts these values — including a host `onSend` typed against - // the authoring contract, which type-checks by contravariance. - type _StillAnAuthoredMessage = Assert< - ObjectChatMessage extends AuthoredChatMessage ? true : false + // 5. The compatibility statement, re-derived (objectui#10018). The honest + // type WAS a subtype of the authoring one until the authoring `state` + // union shed the three runtime-only approval states that API mode + // produces. The values did not change; the authoring contract did. It + // is NOT a subtype now, and that is the break the changeset names: a + // host `onSend` typed against the authoring `ChatMessage[]` stops + // type-checking, because it is handed states that contract refuses. + type _NoLongerAnAuthoredMessage = Assert< + Equal + >; + // What it IS assignable to: the element type `ChatbotSchema.onSend` + // hands a host — the authoring shape widened by exactly those three + // states. This is what lets the three renderers forward `schema.onSend` + // into the hook without a cast. + type _IsWhatOnSendHandsBack = Assert< + ObjectChatMessage extends HandedBackMessage ? true : false >; // 6. And the seam is still NECESSARY — this is not option 2 in disguise @@ -314,6 +330,52 @@ describe("useObjectChat's declared message type is honest about both modes", () }); }); +describe('the runtime-only approval states are ONE vocabulary across the two packages', () => { + it('is pinned at compile time', () => { + // objectui#10018. `@object-ui/types` sheds the AI SDK's three approval + // states from the authoring `state` union and names them only in a + // NON-exported alias, which types `ChatbotSchema.onSend`'s messages. This + // package spells them on `ChatbotEnhanced.ChatToolInvocation['state']`. + // Two spellings of one set, so they are pinned EQUAL here — derived + // through the published slot, since the alias has no name to import. + type AuthoredState = NonNullable< + NonNullable[number]['state'] + >; + type HandedBackState = NonNullable< + NonNullable[number]['state'] + >; + type RuntimeState = NonNullable< + NonNullable[number]['state'] + >; + type TypesRuntimeOnly = Exclude; + type ChatbotRuntimeOnly = Exclude; + + // Probe hygiene: an `any` answers every `Equal`, and two EMPTY sets are + // equal too — so neither side may be `any` or `never`. + type _HandedBackNotAny = Assert, false>>; + type _TypesSideNotEmpty = Assert< + Equal<[TypesRuntimeOnly] extends [never] ? true : false, false> + >; + + // 1. The two spellings are ONE set — neither package can move alone. + type _OneVocabulary = Assert>; + // 2. …and it is exactly the SDK's approval triple, named so a drift says + // WHICH state moved rather than "types differ". + type _IsTheApprovalTriple = Assert< + Equal + >; + // 3. The ruling: the authoring union holds none of them. + type _AuthoringShedsThem = Assert, never>>; + // 4. The slot widens the authoring vocabulary by those states and nothing + // else: every authoring state is still handed back. + type _HandedBackIsAuthoringPlusTriple = Assert< + Equal + >; + + expect(true).toBe(true); + }); +}); + describe("the seam's input contract names the keys it passes through", () => { it('is pinned at compile time', () => { // objectui#4424. PR #4416's adapter preserved the runtime-only keys with a diff --git a/packages/plugin-chatbot/src/chatMessageAdapter.ts b/packages/plugin-chatbot/src/chatMessageAdapter.ts index cb3b1b576b..7b04b23dd2 100644 --- a/packages/plugin-chatbot/src/chatMessageAdapter.ts +++ b/packages/plugin-chatbot/src/chatMessageAdapter.ts @@ -33,7 +33,7 @@ * |------------------------|------------------------------------------|-----------------------------|----------| * | `role` | `'user'\|'assistant'\|'system'\|'tool'` | `'user'\|'assistant'\|'system'` | `'tool'` renders as an **assistant** bubble — see {@link toRuntimeRole} | * | `timestamp` | `string \| Date` | `string` | `Date` -> ISO 8601 — see {@link toRuntimeTimestamp} | - * | `toolInvocations[].state` | v6 states **+ legacy** `'partial-call'\|'call'\|'result'` | v6 states only | legacy -> v6, per the authoring type's own doc comment — see {@link toRuntimeToolState} | + * | `toolInvocations[].state` | v6 states **minus** the three approval states, **+ legacy** `'partial-call'\|'call'\|'result'` | v6 states only | legacy -> v6, per the authoring type's own doc comment — see {@link toRuntimeToolState}. The approval states are runtime-only (objectui#10018): the seam's INPUT admits them for API-mode values — see {@link SeamToolInvocation} | * | `metadata` | `any` | *not declared* | passed through untouched (see "Pass-through" below) | * | everything else | same shape on both sides | — | passed through untouched | * @@ -114,11 +114,22 @@ type RuntimeOnlyToolInvocationKeys = Pick< * plus the render-only extensions it may ALREADY be carrying when it arrives * from API mode. * - * `AuthoredToolInvocation` stays assignable to this (every added key is - * optional), so a host holding plain authored invocations is unaffected. + * `state` is the union of both vocabularies, DERIVED from the two contracts + * rather than listed (objectui#10018). The authoring union sheds the AI SDK's + * three approval states — an author may not claim one — but an API-mode value + * crossing this seam is a RUNTIME value and does carry them: `mapMessages` + * promotes `approval-requested` from a pending-action result, and app-shell's + * hydrated history hands the persisted approval states back in. Typing this + * input as the authoring state alone would make the runtime's own values + * unassignable to the seam that exists to carry them. + * + * `AuthoredToolInvocation` stays assignable to this (its state vocabulary is a + * subset, and every added key is optional), so a host holding plain authored + * invocations is unaffected. */ -export type SeamToolInvocation = AuthoredToolInvocation & - Partial; +export type SeamToolInvocation = Omit & { + state?: AuthoredToolInvocation['state'] | RuntimeToolInvocation['state']; +} & Partial; /** * One message as it actually crosses this seam — the seam's INPUT contract. @@ -206,7 +217,7 @@ export function toRuntimeRole( * rendered result — from a blank badge to the state the author declared. */ export function toRuntimeToolState( - state: AuthoredToolInvocation['state'], + state: SeamToolInvocation['state'], ): RuntimeToolInvocation['state'] { switch (state) { case 'partial-call': @@ -216,8 +227,8 @@ export function toRuntimeToolState( case 'result': return 'output-available'; default: - // Every remaining member of the authoring union IS a runtime state, so - // the compiler proves the narrowing here rather than a cast asserting it. + // Every remaining member of the seam's union IS a runtime state, so the + // compiler proves the narrowing here rather than a cast asserting it. return state; } } diff --git a/packages/plugin-chatbot/src/useObjectChat.ts b/packages/plugin-chatbot/src/useObjectChat.ts index 396284d125..68b7c8a355 100644 --- a/packages/plugin-chatbot/src/useObjectChat.ts +++ b/packages/plugin-chatbot/src/useObjectChat.ts @@ -7,7 +7,6 @@ */ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; -import type { ChatMessage as OuiChatMessage } from '@object-ui/types'; import { useDisplayLocale } from '@object-ui/i18n'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; @@ -36,6 +35,11 @@ import type { SeamChatMessage, SeamToolInvocation } from './chatMessageAdapter'; * surface unchanged and are folded only at the render seam, which is the * decision `chatMessageAdapter.ts` records. So the runtime type would have * been a lie about local mode. + * - **wide where API mode is wide.** The AI SDK's three approval states + * (`approval-requested`, `approval-responded`, `output-denied`) are + * runtime-only — the authoring contract refuses them (objectui#10018) — + * but API mode produces them, so its tool invocations carry them. So the + * authoring type would have been a lie about API mode. * - **narrow where BOTH modes are narrow.** `timestamp` is `string`, never * `Date`: API mode never produces one and local mode absorbs it in * `normalizeMessages` before it is ever handed out. Declaring `Date` here @@ -45,10 +49,15 @@ import type { SeamChatMessage, SeamToolInvocation } from './chatMessageAdapter'; * draft-review / proposed-plan / builder-handoff extensions on each tool * invocation. * - * It is a SUBTYPE of `@object-ui/types`' `ChatMessage`, which is what makes the - * change invisible to correct consumers: anything that accepted the authoring - * type still accepts these values, including a host `onSend` callback that - * declares its parameter as `ChatMessage[]`. + * ⚠️ It is NOT a subtype of `@object-ui/types`' `ChatMessage` (objectui#10018). + * It was one until the authoring `state` union shed the three runtime-only + * approval states; the values did not change, the authoring contract did. So a + * host `onSend` callback that declares its parameter as the authoring + * `ChatMessage[]` no longer type-checks — it was being handed states that + * contract refuses. Declare it as `ObjectChatMessage[]`. `ChatbotSchema.onSend` + * (the schema's runtime slot, forwarded here by the three renderers) is typed + * with the authoring shape widened by exactly those three states, and this type + * is assignable to it. */ export type ObjectChatMessage = Omit & { /** @@ -237,7 +246,19 @@ function warnMaxToolRoundtripsInert(): void { ); } -type InitialMessage = OuiChatMessage & { +/** + * One initial message — the seam's INPUT shape ({@link SeamChatMessage}), not + * the authoring one (objectui#10018). + * + * The two callers hand in different things: the schema renderer passes + * `schema.messages` (authored), and app-shell passes the output of + * `hydratedMessagesToChatMessages` — RUNTIME values restored from server + * history, which carry the AI SDK's approval states and the render-only keys. + * The authoring `ChatMessage` refuses those approval states, so it cannot type + * the second caller; the seam's input admits both, and the authoring shape is + * assignable to it, so no authored caller is affected. + */ +type InitialMessage = SeamChatMessage & { /** * Pre-built chat-runtime parts, handed through to the store untouched when * present. Declared as {@link SdkChatMessage}'s own part array — DERIVED, so @@ -343,10 +364,12 @@ export interface UseObjectChatOptions { * External send callback (fires for both modes). * * `messages` is the thread as it will be after this send, in the same shape - * the hook's own `messages` uses — see {@link ObjectChatMessage}. A callback - * that declares the parameter as `@object-ui/types`' `ChatMessage[]` still - * type-checks (the emitted shape is a subtype); declaring it as - * `ObjectChatMessage[]` is what lets you READ the render-only keys. + * the hook's own `messages` uses — see {@link ObjectChatMessage}. Declare the + * parameter as `ObjectChatMessage[]`: that is also what lets you READ the + * render-only keys. ⚠️ A callback that declares it as `@object-ui/types`' + * authoring `ChatMessage[]` no longer type-checks (objectui#10018) — the + * emitted shape can carry the three runtime-only approval states that the + * authoring contract refuses, so it is not a subtype of it. */ onSend?: (content: string, messages: ObjectChatMessage[]) => void; } @@ -435,7 +458,7 @@ export interface UseObjectChatReturn { * `'assistant'` only at the render seam. That is precisely why the honest * output type is not the runtime one — see {@link ObjectChatMessage}. */ -function normalizeMessages(msgs?: OuiChatMessage[]): ObjectChatMessage[] { +function normalizeMessages(msgs?: SeamChatMessage[]): ObjectChatMessage[] { return (msgs ?? []).map((msg, idx) => ({ id: msg.id || `msg-${idx}`, role: msg.role || 'user', @@ -559,11 +582,17 @@ function reportUnbackedApprovalState(tool: SeamToolInvocation): void { * #0.1) — the producer is wrong and is told so; the state is then derived from * the data the invocation DOES carry so the turn still renders. * - * The authoring `state` union shedding these three runtime-only states is the - * residual clause of this chain's ruling and is deliberately not done in this - * package; once it lands, this branch becomes unreachable by construction and - * goes away with it. See `ChatToolInvocation` in `@object-ui/types`, whose own - * doc records that the narrowing was left to objectui#8426. + * The authoring `state` union has shed these three runtime-only states + * (objectui#10018), so a schema AUTHOR can no longer declare one — but this + * branch is NOT unreachable, and stays. The builder's input is not the + * authoring face alone: `initialMessages` also carries RUNTIME values, and a + * runtime producer still constructs an approval state with no envelope. + * app-shell's server-history path — `mergeToolResultsInto` in + * `useChatConversation.ts` — promotes `approval-requested` from an ObjectStack + * pending-action tool result that carries no SDK envelope, and + * `hydratedMessagesToChatMessages` hands it here. That is the HITL case + * {@link reportUnbackedApprovalState} stays silent for; any OTHER envelope-less + * claim is a runtime producer's bug and is reported here. */ function warnApprovalStateWithoutEnvelope(state: string, toolName: string): void { const key = `${state}:${toolName}`; @@ -704,8 +733,9 @@ function toSdkToolPart(tool: SeamToolInvocation): SdkToolPart { case undefined: break; default: { - // Exhaustiveness. A state added to the authoring union lands here and - // turns this assignment red, instead of silently taking the derived arm. + // Exhaustiveness. A state added to the seam's union (the authoring or the + // runtime vocabulary) lands here and turns this assignment red, instead + // of silently taking the derived arm. const unhandledState: never = tool.state; void unhandledState; break; diff --git a/packages/types/src/__tests__/chat-tool-approval-envelope-8442.test.ts b/packages/types/src/__tests__/chat-tool-approval-envelope-8442.test.ts index a6374d5d24..394cc93cb0 100644 --- a/packages/types/src/__tests__/chat-tool-approval-envelope-8442.test.ts +++ b/packages/types/src/__tests__/chat-tool-approval-envelope-8442.test.ts @@ -5,12 +5,15 @@ * * ## What is pinned, and why each pin is the one that can fail * - * The member exists because three of the ten declared `state` values — + * The member exists because three AI SDK approval states — * `approval-requested`, `approval-responded`, `output-denied` — are states the - * AI SDK's own tool-part union cannot express WITHOUT this envelope. The + * SDK's own tool-part union cannot express WITHOUT this envelope. The * hydration mapper in `@object-ui/app-shell` carried those states through while * dropping the envelope, so the state survived the hop and the data that makes - * it actionable did not. + * it actionable did not. Those three states are RUNTIME-ONLY and are shed from + * the authoring `state` union (objectui#10018), so every fixture below rides an + * authorable state — `output-available`, which the SDK's union lets carry an + * already-decided envelope. * * 1. RETENTION, not `success`. `ChatToolInvocationSchema` is a plain `z.object` * — STRIP mode — so `safeParse` is green for an undeclared key too; it just @@ -19,11 +22,16 @@ * same instrument. Reading the key back OUT of `data` is the assertion that * fails when the mirror has not been widened. * 2. VALUE judgment, separately. `id` is the envelope's only required member, - * so a payload missing it must be REFUSED rather than stripped-and-green. - * 3. OPTIONALITY. This card ships the widening half only; pairing the envelope - * with the three states that require it is objectui#8426's narrowing. An - * invocation that declares one of those states and carries no envelope still - * parses today, and that is a deliberate statement, not an omission. + * so a payload missing it must be REFUSED rather than stripped-and-green — + * and refused AT `approval.id`: while the fixtures rode a state the mirror + * now refuses, a `success: false` here would have been the state's refusal + * masking an id judgment that never ran, so the issue path is asserted. + * 3. OPTIONALITY, and its limit. No authorable state requires the envelope, so + * an invocation without one parses. The three states that DO require it are + * not authorable at all: objectui#10018 shed them rather than pairing them + * with this member, so they are refused with or without an envelope. The + * type-level half of that refusal is pinned in + * `chat-tool-authoring-state-10018.test.ts`. * * The TS-side/Zod-side KEY parity is not restated here: `zod-mirror-parity.test.ts` * registers this pair and derives its key census from the mirror's own `.shape`, @@ -46,7 +54,7 @@ function invocation(extra: Record = {}) { return { toolCallId: 'tc-1', toolName: 'action_delete_task', - state: 'approval-requested', + state: 'output-available', ...extra, }; } @@ -79,19 +87,35 @@ describe('ChatToolInvocation.approval — the mirror declares it', () => { }); it('REFUSES an envelope without a usable `id` — a value judgment, not a strip', () => { + // Judged AT `approval.id`, and nowhere else: the fixture's state is + // authorable, so a refusal elsewhere would mean this case measured the + // wrong thing (objectui#10018 — it once rode a state the mirror refuses). const noId = ChatToolInvocationSchema.safeParse(invocation({ approval: { approved: true } })); expect(noId.success).toBe(false); + expect(noId.success ? [] : noId.error.issues.map((i) => i.path)).toEqual([['approval', 'id']]); const wrongType = ChatToolInvocationSchema.safeParse(invocation({ approval: { id: 42 } })); expect(wrongType.success).toBe(false); + expect(wrongType.success ? [] : wrongType.error.issues.map((i) => i.path)).toEqual([ + ['approval', 'id'], + ]); }); - it('is OPTIONAL — the widening half ships alone, so an approval state with no envelope still parses', () => { - // objectui#8426 owns the narrowing that makes this pair mandatory. Until it - // lands, refusing here would be this card shipping that card's break. + it('is OPTIONAL on an authorable state — and the three runtime-only states are REFUSED, envelope or not', () => { + // The optional half: an authorable state parses with no envelope. + const bare = ChatToolInvocationSchema.safeParse(invocation()); + expect(bare.success).toBe(true); + expect(bare.success && bare.data.approval).toBeUndefined(); + + // The refusal half (objectui#10018): the states that REQUIRE the envelope + // are runtime-only, so the envelope does not make them authorable. for (const state of ['approval-requested', 'approval-responded', 'output-denied'] as const) { - const parsed = ChatToolInvocationSchema.safeParse(invocation({ state })); - expect(parsed.success).toBe(true); - expect(parsed.success && parsed.data.approval).toBeUndefined(); + for (const extra of [{}, { approval: { id: 'apr_8442', approved: state !== 'output-denied' } }]) { + const parsed = ChatToolInvocationSchema.safeParse(invocation({ state, ...extra })); + expect(parsed.success).toBe(false); + expect( + parsed.success ? [] : parsed.error.issues.map((i) => ({ code: i.code, path: i.path })), + ).toEqual([{ code: 'invalid_value', path: ['state'] }]); + } } }); }); @@ -101,7 +125,7 @@ describe('ChatToolInvocation.approval — the declaration admits what the mirror const full: ChatToolInvocation = { toolCallId: 'tc-1', toolName: 'action_delete_task', - state: 'approval-requested', + state: 'output-available', approval: { ...ENVELOPE }, }; const minimal: ChatToolInvocation = { diff --git a/packages/types/src/__tests__/chat-tool-authoring-state-10018.test.ts b/packages/types/src/__tests__/chat-tool-authoring-state-10018.test.ts new file mode 100644 index 0000000000..e56dc7a256 --- /dev/null +++ b/packages/types/src/__tests__/chat-tool-authoring-state-10018.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The authoring `ChatToolInvocation.state` union sheds the three runtime-only + * approval states (objectui#10018 — the residual clause of the objectui#8426 + * ruling, decision batch #86). + * + * The ruling, verbatim: "the authoring `state` union sheds the three + * runtime-only approval states, so a schema-authored invocation cannot claim + * `approval-requested` without an envelope". `approval-requested`, + * `approval-responded` and `output-denied` are produced by a chat RUNTIME — from + * the AI SDK's approval envelope, or promoted from an ObjectStack pending-action + * result — and are never authored. Shedding them refuses the claim outright, + * so an authored invocation carrying an envelope is refused too: the envelope + * does not make a runtime-only state authorable. + * + * ## What is pinned, and why each pin is the one that can fail + * + * 1. TYPE-LEVEL refusal. Each `@ts-expect-error` below is a claim that the + * literal is NOT assignable; while the state is still in the union the + * directive is unused and `tsc -p tsconfig.test.json` fails with TS2578. + * vitest erases these, so only this package's `type-check` reads them. + * 2. PARSE-LEVEL refusal, judged as a VALUE. `ChatToolInvocationSchema` is a + * strip-mode `z.object`, so a `success: false` alone could come from any + * member. Each refusal is therefore asserted to be exactly one + * `invalid_value` issue at `state` — the judgment the narrowing makes, not + * some other member failing beside it. + * 3. CONTROLS on the same instruments: an ordinary v6 state, a legacy + * authoring state and a state-less invocation stay accepted, both as a type + * and as a full `safeParse` success. Without them the refusals above could + * be the schema refusing everything. + */ + +import { describe, it, expect } from 'vitest'; +import { ChatMessageSchema, ChatToolInvocationSchema } from '../zod/complex.zod'; +import type { ChatMessage, ChatToolInvocation } from '../complex'; + +const RUNTIME_ONLY_STATES = ['approval-requested', 'approval-responded', 'output-denied'] as const; + +/** The envelope each state carries in the AI SDK's own tool-part union. */ +const ENVELOPE_FOR: Record<(typeof RUNTIME_ONLY_STATES)[number], Record> = { + 'approval-requested': { id: 'apr_10018' }, + 'approval-responded': { id: 'apr_10018', approved: true }, + 'output-denied': { id: 'apr_10018', approved: false }, +}; + +const BASE = { toolCallId: 'tc-10018', toolName: 'action_delete_task' } as const; + +// ── 1. Type-level refusal ───────────────────────────────────────────────────── +// One literal per line: a `@ts-expect-error` covers only the line after it. + +// @ts-expect-error — `approval-requested` is runtime-only, envelope-less +export const requestedBare: ChatToolInvocation = { ...BASE, state: 'approval-requested' }; +// @ts-expect-error — `approval-requested` is runtime-only, envelope or not +export const requestedEnveloped: ChatToolInvocation = { ...BASE, state: 'approval-requested', approval: { id: 'apr_10018' } }; +// @ts-expect-error — `approval-responded` is runtime-only, envelope-less +export const respondedBare: ChatToolInvocation = { ...BASE, state: 'approval-responded' }; +// @ts-expect-error — `approval-responded` is runtime-only, envelope or not +export const respondedEnveloped: ChatToolInvocation = { ...BASE, state: 'approval-responded', approval: { id: 'apr_10018', approved: true } }; +// @ts-expect-error — `output-denied` is runtime-only, envelope-less +export const deniedBare: ChatToolInvocation = { ...BASE, state: 'output-denied' }; +// @ts-expect-error — `output-denied` is runtime-only, envelope or not +export const deniedEnveloped: ChatToolInvocation = { ...BASE, state: 'output-denied', approval: { id: 'apr_10018', approved: false } }; + +// The same refusal reaches an authored MESSAGE, which is where a schema author +// actually writes an invocation (`ChatbotSchema.messages`). +export const authoredMessage: ChatMessage = { + id: 'm-10018', + role: 'assistant', + content: '', + // @ts-expect-error — the runtime-only state is refused inside a message too + toolInvocations: [{ ...BASE, state: 'approval-requested', approval: { id: 'apr_10018' } }], +}; + +// CONTROLS — these must keep compiling. +export const controlV6: ChatToolInvocation = { ...BASE, state: 'output-available', result: { ok: true } }; +export const controlLegacy: ChatToolInvocation = { ...BASE, state: 'call', args: { id: 1 } }; +export const controlError: ChatToolInvocation = { ...BASE, state: 'output-error', errorText: 'boom' }; +export const controlStateless: ChatToolInvocation = { ...BASE }; + +// ── 2. Parse-level refusal ──────────────────────────────────────────────────── + +function expectStateRefused( + result: ReturnType | ReturnType, + path: ReadonlyArray, +): void { + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.map((i) => ({ code: i.code, path: i.path }))).toEqual([ + { code: 'invalid_value', path: [...path] }, + ]); +} + +describe('the authoring mirror REFUSES the three runtime-only approval states (objectui#10018)', () => { + for (const state of RUNTIME_ONLY_STATES) { + it(`refuses \`${state}\` with no envelope`, () => { + expectStateRefused(ChatToolInvocationSchema.safeParse({ ...BASE, state }), ['state']); + }); + + it(`refuses \`${state}\` WITH its envelope — the envelope does not make it authorable`, () => { + expectStateRefused( + ChatToolInvocationSchema.safeParse({ ...BASE, state, approval: ENVELOPE_FOR[state] }), + ['state'], + ); + }); + + it(`refuses \`${state}\` inside an authored message`, () => { + expectStateRefused( + ChatMessageSchema.safeParse({ + id: 'm-10018', + role: 'assistant', + content: '', + toolInvocations: [{ ...BASE, state }], + }), + ['toolInvocations', 0, 'state'], + ); + }); + } +}); + +describe('CONTROLS — ordinary authoring states stay accepted on the same instrument', () => { + it('accepts a v6 lifecycle state', () => { + const parsed = ChatToolInvocationSchema.safeParse({ ...BASE, state: 'output-available', result: { ok: true } }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.state).toBe('output-available'); + }); + + it('accepts a legacy authoring state', () => { + const parsed = ChatToolInvocationSchema.safeParse({ ...BASE, state: 'call', args: { id: 1 } }); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.state).toBe('call'); + }); + + it('accepts an invocation that declares no state', () => { + expect(ChatToolInvocationSchema.safeParse({ ...BASE }).success).toBe(true); + }); + + it('accepts an ordinary state inside an authored message', () => { + const parsed = ChatMessageSchema.safeParse({ + id: 'm-10018', + role: 'assistant', + content: '', + toolInvocations: [{ ...BASE, state: 'output-error', errorText: 'boom' }], + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index e3787c6aa6..c7c587487d 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -980,7 +980,17 @@ export interface ChatToolInvocation { * Tool invocation state. The legacy `partial-call`/`call`/`result` values * are kept for back-compat; the AI SDK v6 lifecycle states map cleanly to * `input-streaming`/`input-available`/`output-available`/`output-error` - * and friends and are now accepted directly. + * and are accepted directly. + * + * ⛔ The SDK's three APPROVAL states — `approval-requested`, + * `approval-responded` and `output-denied` — are NOT authorable + * (objectui#10018, the residual clause of the objectui#8426 ruling). A chat + * runtime produces them: from the SDK's approval envelope, or promoted from + * an ObjectStack pending-action tool result. An authored claim of one — with + * or without an `approval` envelope — is refused here and by the Zod mirror, + * so a schema cannot declare an approval the runtime has nothing to back. + * They still reach a host on the way OUT, which is why `ChatbotSchema.onSend` + * does not hand back this authoring shape — see {@link ChatMessageHandedBack}. */ state?: | 'partial-call' @@ -988,11 +998,8 @@ export interface ChatToolInvocation { | 'result' | 'input-streaming' | 'input-available' - | 'approval-requested' - | 'approval-responded' | 'output-available' - | 'output-error' - | 'output-denied'; + | 'output-error'; /** * AI SDK v6 approval envelope — the data a human decision on this tool call * is carried by, and the piece that makes the three approval states @@ -1004,10 +1011,12 @@ export interface ChatToolInvocation { * part. Carrying it here is what lets a mapper hand a rehydrated pending * approval to a chat surface without the surface re-parsing the tool result. * - * Optional because the other seven states never carry one. Pairing the - * envelope with the states that require it is objectui#8426's narrowing of - * the `state` union above, deliberately NOT done here — this member is - * purely additive, so nothing an author writes today stops parsing. + * Optional because none of the states an author may declare REQUIRES one: + * the three that do are runtime-only and are shed from the `state` union + * above (objectui#10018), so an authored invocation cannot claim one of them + * with or without this envelope. Of the authorable states, the SDK's union + * admits an already-decided envelope (`approved: true`) on `output-available` + * and `output-error` only; its two input states admit none. */ approval?: { /** Approval request id — the key a decision is replied on. */ @@ -1023,6 +1032,44 @@ export interface ChatToolInvocation { }; } +/** + * The AI SDK's three approval lifecycle states — RUNTIME-ONLY (objectui#10018). + * + * Shed from {@link ChatToolInvocation}'s authoring `state` union: a chat + * runtime produces them (from the SDK's approval envelope, or promoted from an + * ObjectStack pending-action tool result) and an author never does. They are + * named here for ONE reader — {@link ChatMessageHandedBack}, the shape a chat + * runtime hands BACK to a host — and are ⛔ deliberately NOT exported: the + * authoring package gives an author no name to reach for. + * + * `@object-ui/plugin-chatbot` spells the same three states on its runtime + * `ChatToolInvocation`. Its `chat-message-contract.test.ts` derives this alias + * through `ChatbotSchema['onSend']` and pins the two spellings EQUAL, so + * neither can move alone. + */ +type ChatToolRuntimeOnlyState = 'approval-requested' | 'approval-responded' | 'output-denied'; + +/** + * One chat message as a chat runtime hands it BACK to a host — the element + * type of `ChatbotSchema.onSend`'s `messages`. + * + * The authoring {@link ChatMessage} widened by exactly one thing: its tool + * invocations may also be in a {@link ChatToolRuntimeOnlyState}. A runtime + * hands back the thread it holds, and in API mode that thread holds approval + * states the authoring face refuses (objectui#10018) — so typing the callback's + * `messages` as the authoring `ChatMessage[]` would understate the values a + * host receives. ⛔ Not exported, for the same reason as the states above; + * a host that needs to spell it reads it off the slot: + * `Parameters>[1]`. + */ +type ChatMessageHandedBack = Omit & { + toolInvocations?: Array< + Omit & { + state?: ChatToolInvocation['state'] | ChatToolRuntimeOnlyState; + } + >; +}; + /** * Chatbot component — the authoring face of the * `ComponentRegistry.register('chatbot', ...)` registration in @@ -1364,17 +1411,21 @@ export interface ChatbotSchema extends BaseSchema { /** * Called after a message is sent, in both API and local auto-response mode, * with the trimmed content and the full message list at that point. - * `messages` here is the same authoring-side {@link ChatMessage} shape as the - * `messages` field above; the plugin's own runtime message type is a - * structural superset (objectui#4424) and still satisfies a handler typed - * against this narrower, published shape. + * `messages` is the thread the chat runtime HOLDS, not the one that was + * authored: the authoring {@link ChatMessage} shape whose tool invocations + * may also carry the three runtime-only approval states + * ({@link ChatMessageHandedBack}). API mode produces those states and the + * authoring face refuses them (objectui#10018), so ⚠️ a handler that + * declares its parameter as the authoring `ChatMessage[]` no longer + * type-checks against this slot — that shape is narrower than the values + * the handler receives. Let the parameter be inferred from this slot. * * RUNTIME SLOT (objectui#6124) — a host-supplied function, NOT authorable * metadata: JSON has no function value, so the zod twin refuses this key by * name and points at the node-type spelling. Kept callable here because it is * forwarded by `plugin-chatbot` into `useObjectChat({ onSend })`. */ - onSend?: (content: string, messages: ChatMessage[]) => void; + onSend?: (content: string, messages: ChatMessageHandedBack[]) => void; // --- Floating / FAB configuration --- diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 430ab45b47..a1f3bd3639 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -566,6 +566,11 @@ export const ChatToolInvocationSchema = z.object({ args: z.unknown().optional().describe('Tool arguments'), result: z.unknown().optional().describe('Tool result'), errorText: z.string().optional().describe('Tool error text'), + // The AUTHORING state vocabulary (objectui#10018). The AI SDK's three + // approval states — `approval-requested`, `approval-responded` and + // `output-denied` — are runtime-only and are not listed: an authored claim + // of one is refused as an `invalid_value` at `state`, with or without an + // `approval` envelope. Mirrors `ChatToolInvocation.state` in ../complex.ts. state: z .enum([ 'partial-call', @@ -573,18 +578,16 @@ export const ChatToolInvocationSchema = z.object({ 'result', 'input-streaming', 'input-available', - 'approval-requested', - 'approval-responded', 'output-available', 'output-error', - 'output-denied', ]) .optional() .describe('Tool invocation state'), // Mirrors `ChatToolInvocation.approval` in ../complex.ts. The AI SDK v6 // tool-part union requires this envelope alongside the three approval - // states; the pairing itself is objectui#8426's narrowing and is NOT - // enforced here, so this arm stays independently optional (objectui#8442). + // states, which the `state` enum above does not admit (objectui#10018); on + // the states it does admit the envelope is never required, so this arm + // stays independently optional (objectui#8442). approval: z .object({ id: z.string().describe('Approval request id — the key a decision is replied on'), From 3ce449371b63369638ed25393f2df0f44ad57524 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:26:35 +0000 Subject: [PATCH 2/4] docs(plugin-chatbot,changeset): retract the renderer's onSend subtype promise; complete the changeset's disclosures Contract-review follow-ups for objectui#10018 (PR objectui#10308): - renderer.tsx header: the sentence after the `onSend` signature still said a host callback typed against the authoring `ChatMessage[]` type-checks. It now matches the corrected docblocks: infer the parameter from the schema slot; the authoring `ChatMessage[]` no longer fits; `ObjectChatMessage[]` fits `useObjectChat`'s own slot. Comment only. - changeset Break 1: the migration list now names every authorable state, adding `input-streaming` and the legacy `partial-call`. - changeset "Not breaking": discloses that `UseObjectChatOptions.initialMessages` now takes `SeamChatMessage`, declaring nine optional runtime-only keys the hook already forwarded. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .changeset/10018-authoring-state-sheds-approval.md | 4 ++-- packages/plugin-chatbot/src/renderer.tsx | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.changeset/10018-authoring-state-sheds-approval.md b/.changeset/10018-authoring-state-sheds-approval.md index eed86223f5..7a1e47e698 100644 --- a/.changeset/10018-authoring-state-sheds-approval.md +++ b/.changeset/10018-authoring-state-sheds-approval.md @@ -7,8 +7,8 @@ BREAKING (`@object-ui/types`, `@object-ui/plugin-chatbot`): the authoring `ChatT (The bump is `minor` by this repo's release model — objectui's major is pinned to the `@objectstack` family major, and its own breaking changes ship as `minor` with the breaking semantics stated here.) -**Break 1 — authored approval states are refused.** `ChatToolInvocation.state` (and so `ChatMessage.toolInvocations[].state` and `ChatbotSchema.messages`) no longer admits the three approval states, and the Zod mirror `ChatToolInvocationSchema` refuses them as an `invalid_value` at `state`. The refusal holds with or without an `approval` envelope: the envelope does not make a runtime-only state authorable. A chat runtime still produces these states — from the SDK's approval envelope, or promoted from an ObjectStack pending-action result — and still renders them; only the authoring face stops declaring them. Migration: author the state the tool call is actually in (`input-available`, `output-available`, `output-error`, or a legacy `call` / `result`), and leave approval states to the runtime. +**Break 1 — authored approval states are refused.** `ChatToolInvocation.state` (and so `ChatMessage.toolInvocations[].state` and `ChatbotSchema.messages`) no longer admits the three approval states, and the Zod mirror `ChatToolInvocationSchema` refuses them as an `invalid_value` at `state`. The refusal holds with or without an `approval` envelope: the envelope does not make a runtime-only state authorable. A chat runtime still produces these states — from the SDK's approval envelope, or promoted from an ObjectStack pending-action result — and still renders them; only the authoring face stops declaring them. Migration: author the state the tool call is actually in (`input-streaming`, `input-available`, `output-available`, `output-error`, or a legacy `partial-call` / `call` / `result`), and leave approval states to the runtime. **Break 2 — `onSend` handlers typed against the authoring `ChatMessage[]`.** The messages a chat runtime hands back can carry those three states, so they are no longer a subtype of the authoring `ChatMessage`. `ChatbotSchema.onSend`'s `messages` is now typed as the authoring message widened by exactly those states, and `useObjectChat`'s `ObjectChatMessage` is no longer assignable to the authoring `ChatMessage`. A handler that declares its parameter as the authoring `ChatMessage[]` stops type-checking. Migration: let the parameter be inferred from the slot, or declare it as `ObjectChatMessage[]` from `@object-ui/plugin-chatbot` when passing `onSend` to `useObjectChat`. -Not breaking: `UseObjectChatOptions.initialMessages`, `SeamToolInvocation` and `toRuntimeToolState` now take the seam's state vocabulary (authoring plus runtime), which is the same set of values they accepted before, so every existing caller still compiles. No new name is exported: the three runtime-only states are named only by a non-exported alias in `@object-ui/types`, pinned equal to `ChatbotEnhanced`'s runtime union by `chat-message-contract.test.ts`. The effect on out-of-repo authors and hosts was not measured. +Not breaking: `UseObjectChatOptions.initialMessages`, `SeamToolInvocation` and `toRuntimeToolState` now take the seam's state vocabulary (authoring plus runtime), which is the same set of values they accepted before, so every existing caller still compiles. `UseObjectChatOptions.initialMessages` also moves from the authoring `ChatMessage` to the exported `SeamChatMessage`, so nine optional runtime-only keys are now declared on that option: `buildProgress`, `blueprintProgress` and `charts` on a message, and `pendingActionId`, `draftReview`, `proposedPlan`, `proposedChanges`, `builderHandoff` and `replayOutcome` on each tool invocation. Nothing new is accepted at runtime (the hook already forwarded these keys), and each was already declared on `SeamChatMessage`; what changes is that a host passing one in an object literal is no longer told it is an excess property. No new name is exported: the three runtime-only states are named only by a non-exported alias in `@object-ui/types`, pinned equal to `ChatbotEnhanced`'s runtime union by `chat-message-contract.test.ts`. The effect on out-of-repo authors and hosts was not measured. diff --git a/packages/plugin-chatbot/src/renderer.tsx b/packages/plugin-chatbot/src/renderer.tsx index 1c2ec65385..5f7e8a8a99 100644 --- a/packages/plugin-chatbot/src/renderer.tsx +++ b/packages/plugin-chatbot/src/renderer.tsx @@ -33,9 +33,15 @@ import { toRuntimeMessages } from './chatMessageAdapter'; * * Both modes support the `onSend` callback: * - Signature: `onSend(content: string, messages: ObjectChatMessage[]): void` - * — the hook's own message shape (objectui#4424). A host callback that - * declares `@object-ui/types`' `ChatMessage[]` still type-checks; naming - * `ObjectChatMessage` is what lets it read the render-only keys. + * — the hook's own message shape (objectui#4424). The schema's slot, + * `ChatbotSchema.onSend`, types its parameter as the authoring message + * widened by the three runtime-only approval states, so let a host + * callback's parameter be inferred from that slot. A callback that declares + * `@object-ui/types`' authoring `ChatMessage[]` no longer type-checks + * (objectui#10018): the messages it receives can carry approval states that + * contract refuses. `ObjectChatMessage[]` is the parameter type that fits + * `useObjectChat`'s own `onSend` slot, and naming it is what lets a callback + * read the render-only keys. * * ## What reaches the host element (objectui#4431) * From 04ed6186989596079f6b818039a970ed225785bd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:35:08 +0000 Subject: [PATCH 3/4] docs(changeset): correct the two pending sibling notes objectui#10018 makes false Prose only, frontmatter byte-identical. Both notes ship in the same release as objectui#10018, which is what moved the facts they state (the objectui#10019 precedent: the change that moves a fact corrects the pending note stating it). - 8442-chat-tool-approval-envelope: "Three of the ten declared `state` values" becomes "Three AI SDK `state` values" (the authoring union now declares seven). The closing paragraph said an invocation may still declare an approval state with no envelope and pointed at a pin saying so. Both are now false for the authoring type: the pin became a refusal pin and the narrowing shipped under objectui#10018. It now says so, and that the runtime type still admits an envelope-less approval state. - 6169-chatbot-authoring-face-type: the `onSend` bullet no longer quotes a `ChatMessage[]` signature. `messages` is the authoring message widened by the three runtime-only approval states. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .../6169-chatbot-authoring-face-type.md | 7 ++++--- .../8442-chat-tool-approval-envelope.md | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.changeset/6169-chatbot-authoring-face-type.md b/.changeset/6169-chatbot-authoring-face-type.md index ad7987201b..a20f99e348 100644 --- a/.changeset/6169-chatbot-authoring-face-type.md +++ b/.changeset/6169-chatbot-authoring-face-type.md @@ -18,9 +18,10 @@ to anything outside that one file: - `autoResponse`, `autoResponseText`, `autoResponseDelay` — the local auto-response (demo/playground) fields, already live via a real consumer (`packages/app-shell/src/console/ai/AiChatPage.tsx`). -- `onSend?: (content: string, messages: ChatMessage[]) => void` — the - send-callback, now typed against the published `ChatMessage` shape rather - than the plugin's internal runtime message type. +- `onSend` — the send-callback, now typed on the published authoring face + rather than against the plugin's internal runtime message type. Its + `messages` element is the authoring `ChatMessage` widened by the three + runtime-only approval states a chat runtime hands back (objectui#10018). Each was read-site-censused before being declared (renderer.tsx and/or `useObjectChat.ts` reads every one); none were dead, so none took the diff --git a/.changeset/8442-chat-tool-approval-envelope.md b/.changeset/8442-chat-tool-approval-envelope.md index 798816aa53..6360aea078 100644 --- a/.changeset/8442-chat-tool-approval-envelope.md +++ b/.changeset/8442-chat-tool-approval-envelope.md @@ -8,10 +8,10 @@ hydration mapper stops dropping it — together with `pendingActionId` (objectui#8442). -Three of the ten declared `state` values — `approval-requested`, -`approval-responded` and `output-denied` — are states the AI SDK's own tool-part -union cannot express WITHOUT an `{ id, approved?, reason?, isAutomatic?, -signature? }` envelope. `hydratedMessagesToChatMessages` built each invocation +Three AI SDK `state` values — `approval-requested`, `approval-responded` and +`output-denied` — are states the SDK's own tool-part union cannot express +WITHOUT an `{ id, approved?, reason?, isAutomatic?, signature? }` envelope. +`hydratedMessagesToChatMessages` built each invocation from six fields and neither the envelope nor `pendingActionId` was one of them, so a rehydrated pending approval arrived carrying a state that says "a human must decide" and nothing a decision could be made with: `useHitlInChat` keys its index @@ -48,7 +48,12 @@ regardless of what this file says. Declaring `patch` here therefore buys no smaller release and no preserved signal; it only makes the changelog line under-describe what shipped. ⇒ the accurate declaration is the cheap one. -⛔ Nothing is narrowed here. The envelope stays optional on purpose: an -invocation may still declare an approval state and carry no envelope, and the -pin that says so is deliberate, so that a later tidy-up cannot ship -objectui#8426's break under this card's name. +⛔ Nothing is narrowed here. The envelope stays optional on purpose, and this +card does not pair it with the approval states. That narrowing — the residual +clause of the objectui#8426 ruling — ships under its own name and its own +BREAKING banner: objectui#10018 sheds the three approval states from the +authoring `state` union, so an AUTHORED invocation cannot declare one at all, +with or without an envelope. On the runtime `ChatbotEnhanced.ChatToolInvocation` +an approval state may still arrive with no envelope — an ObjectStack +pending-action approval is carried by `pendingActionId` instead — and the +envelope stays optional there too. From 340efe602f3f851c5c10f0c92d9c4532cf74f988 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:47:17 +0000 Subject: [PATCH 4/4] docs(changeset): the hook does not forward the nine declared initialMessages keys; say what it does Contract review of 3aa259d8..04ed6186 (objectui#10018, PR objectui#10308): the "Not breaking" paragraph claimed the hook "already forwarded these keys". It does not. Local mode's `normalizeMessages` forwards `toolInvocations` whole but copies only the base message fields, so `buildProgress`, `blueprintProgress` and `charts` are dropped. API mode's `aiInitialMessages` rebuilds SDK parts that carry none of the nine. Re-derived by printing both builders at head. The executable lines are unchanged since the base (only type annotations moved), so this is a prose fix. Frontmatter byte-identical. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC --- .changeset/10018-authoring-state-sheds-approval.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/10018-authoring-state-sheds-approval.md b/.changeset/10018-authoring-state-sheds-approval.md index 7a1e47e698..d13f7d4da1 100644 --- a/.changeset/10018-authoring-state-sheds-approval.md +++ b/.changeset/10018-authoring-state-sheds-approval.md @@ -11,4 +11,4 @@ BREAKING (`@object-ui/types`, `@object-ui/plugin-chatbot`): the authoring `ChatT **Break 2 — `onSend` handlers typed against the authoring `ChatMessage[]`.** The messages a chat runtime hands back can carry those three states, so they are no longer a subtype of the authoring `ChatMessage`. `ChatbotSchema.onSend`'s `messages` is now typed as the authoring message widened by exactly those states, and `useObjectChat`'s `ObjectChatMessage` is no longer assignable to the authoring `ChatMessage`. A handler that declares its parameter as the authoring `ChatMessage[]` stops type-checking. Migration: let the parameter be inferred from the slot, or declare it as `ObjectChatMessage[]` from `@object-ui/plugin-chatbot` when passing `onSend` to `useObjectChat`. -Not breaking: `UseObjectChatOptions.initialMessages`, `SeamToolInvocation` and `toRuntimeToolState` now take the seam's state vocabulary (authoring plus runtime), which is the same set of values they accepted before, so every existing caller still compiles. `UseObjectChatOptions.initialMessages` also moves from the authoring `ChatMessage` to the exported `SeamChatMessage`, so nine optional runtime-only keys are now declared on that option: `buildProgress`, `blueprintProgress` and `charts` on a message, and `pendingActionId`, `draftReview`, `proposedPlan`, `proposedChanges`, `builderHandoff` and `replayOutcome` on each tool invocation. Nothing new is accepted at runtime (the hook already forwarded these keys), and each was already declared on `SeamChatMessage`; what changes is that a host passing one in an object literal is no longer told it is an excess property. No new name is exported: the three runtime-only states are named only by a non-exported alias in `@object-ui/types`, pinned equal to `ChatbotEnhanced`'s runtime union by `chat-message-contract.test.ts`. The effect on out-of-repo authors and hosts was not measured. +Not breaking: `UseObjectChatOptions.initialMessages`, `SeamToolInvocation` and `toRuntimeToolState` now take the seam's state vocabulary (authoring plus runtime), which is the same set of values they accepted before, so every existing caller still compiles. `UseObjectChatOptions.initialMessages` also moves from the authoring `ChatMessage` to the exported `SeamChatMessage`, so nine optional runtime-only keys are now declared on that option: `buildProgress`, `blueprintProgress` and `charts` on a message, and `pendingActionId`, `draftReview`, `proposedPlan`, `proposedChanges`, `builderHandoff` and `replayOutcome` on each tool invocation. Nothing changes at runtime, because the hook's two builders behave exactly as before: local mode's `normalizeMessages` forwards `toolInvocations` whole (so the six tool-invocation keys survive) but copies only the base message fields (so `buildProgress`, `blueprintProgress` and `charts` are dropped), and API mode's `aiInitialMessages` rebuilds each message as SDK parts that carry none of the nine. Each key was already declared on `SeamChatMessage`; what changes is that a host passing one in an object literal is no longer told it is an excess property. No new name is exported: the three runtime-only states are named only by a non-exported alias in `@object-ui/types`, pinned equal to `ChatbotEnhanced`'s runtime union by `chat-message-contract.test.ts`. The effect on out-of-repo authors and hosts was not measured.