Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/10018-authoring-state-sheds-approval.md
Original file line number Diff line number Diff line change
@@ -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-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. `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.
7 changes: 4 additions & 3 deletions .changeset/6169-chatbot-authoring-face-type.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 13 additions & 8 deletions .changeset/8442-chat-tool-approval-envelope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
14 changes: 9 additions & 5 deletions content/docs/plugins/plugin-chatbot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 10 additions & 5 deletions packages/plugin-chatbot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 (`<Chatbot>` 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`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import type { ChatMessage as BarrelChatMessage, ChatbotEnhancedMessage } from '.
/** The shape `<ChatbotEnhanced>` 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,
Expand All @@ -65,6 +65,13 @@ type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ?
type HasIndexSignature<T> = string extends keyof T ? true : false;
type Has<T, K extends string> = 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<NonNullable<ChatbotSchema['onSend']>>[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
Expand Down Expand Up @@ -285,13 +292,22 @@ describe("useObjectChat's declared message type is honest about both modes", ()
type _HasProposedChanges = Assert<Has<HookToolInvocation, 'proposedChanges'>>;
type _HasBuilderHandoff = Assert<Has<HookToolInvocation, 'builderHandoff'>>;

// 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<ObjectChatMessage extends AuthoredChatMessage ? true : false, false>
>;
// 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
Expand All @@ -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<AuthoredChatMessage['toolInvocations']>[number]['state']
>;
type HandedBackState = NonNullable<
NonNullable<HandedBackMessage['toolInvocations']>[number]['state']
>;
type RuntimeState = NonNullable<
NonNullable<EnhancedChatMessage['toolInvocations']>[number]['state']
>;
type TypesRuntimeOnly = Exclude<HandedBackState, AuthoredState>;
type ChatbotRuntimeOnly = Exclude<RuntimeState, AuthoredState>;

// 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<Equal<IsAny<HandedBackMessage>, 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<Equal<TypesRuntimeOnly, ChatbotRuntimeOnly>>;
// 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<ChatbotRuntimeOnly, 'approval-requested' | 'approval-responded' | 'output-denied'>
>;
// 3. The ruling: the authoring union holds none of them.
type _AuthoringShedsThem = Assert<Equal<Extract<AuthoredState, ChatbotRuntimeOnly>, 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<HandedBackState, AuthoredState | ChatbotRuntimeOnly>
>;

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
Expand Down
Loading
Loading