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
10 changes: 10 additions & 0 deletions .changeset/8344-node-recursion-point-redirect.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ none. The published `ChatbotSchema` is untouched — whether its own `body` shou
chat API's params is a separate question, recorded on objectui#8572 and deliberately not
decided here.

⚠️ **AMENDED (objectui#8572, ruling A — landing in THIS release).** Two statements in the
paragraph above were true of this card's head and are no longer true of the release they
ship in, so they are corrected here rather than left to contradict the entry beside them:
`ChatbotSchema.body` is no longer the record — it is an ADR-0049 retirement tombstone on
both published faces, pointing the author at `requestBody` — and the root document above is
now REFUSED, not accepted. The separate question this paragraph deferred has been answered,
and the deferral itself is the part that stayed true. ⛔ Nothing else in the paragraph moved:
the nested verdict, the installed arm's check and the "narrows at all 109, widens at none"
reading are all readings of THIS card's head and cannot rot.

**3. ⚠️ KNOWN GAP, declared rather than papered over: a bundled consumer that never reads
`AnyComponentSchema` can tree-shake the redirect away.** This package declares
`"sideEffects": false` and the arm is filled by a statement in the `./zod` barrel's body, so a
Expand Down
117 changes: 117 additions & 0 deletions .changeset/8572-chatbot-body-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
'@object-ui/types': minor
---

**BREAKING** — `ChatbotSchema` no longer accepts `body`, on either published face
(objectui#8572). The chat API's body params are authored as `requestBody`, which is what
the renderer has always read.

**FROM** `body?: Record<string, unknown>` on the zod mirror ("Additional API body params")
**TO** a refusal by name. On the TypeScript face the key was never the record: it arrived
from `BaseSchema` as the content slot, and it is now `?: never` there, so `tsc` says the
same thing the mirror says.

```ts
// before
{ type: 'chatbot', messages: [], body: { model: 'gpt-4', temperature: 0.2 } }
// after
{ type: 'chatbot', messages: [], requestBody: { model: 'gpt-4', temperature: 0.2 } }
```

Executes the maintainer ruling recorded on objectui#8572 (decision batch #137, item 4,
2026-09-15, verbatim 「同意」), letter **A**. **B** — keep the record and write it into the
contract — was refused; **C** — narrow `body` back to the node slot — was refused because
`chatbot` reads NEITHER content channel, so a node-slot `body` would be legal and inert.
The governing precedent is objectui#9256 / ADR-0049, the `children` tombstone on this same
node, whose reason sentence already named `body` as equally unread.

**Disposition: an ADR-0087 D2 tombstone, not a deletion.** The key stays declared and
unwritable — `?: never` on the interface, `retirementTombstone()` on the zod mirror — so an
author gets `invalid_type` at `body` carrying the prescription, not a bare
`unrecognized_keys` and not silence. Deleting the arm would have been the silent route:
`BaseSchema` carries `[key: string]: any` and its mirror ends `.passthrough()`, so a dropped
member is KEPT rather than refused, which trades one no-op for another.

**Why this key and not another.** It was the ONE place in this vocabulary where `body` did
not mean "what goes inside this component": `zod-mirror-parity.test.ts` carried the pair
under `KnownDrift` as "two different meanings of one key", and the same collision was the
whole reason `chatbot` was the single arm of the component union whose output was not
assignable to `SchemaNode`. Both ledger rows move with this change, and the two pins that
recorded the old state are INVERTED rather than deleted (see below).

**Delivery surface, measured — this is where the refusal does and does not arrive.** The
refusal is PARSE-TIME on the mirror and COMPILE-TIME on the declaration. Run against the
BUILT artifacts (`@object-ui/cli` and its dependency closure built from this branch, so the
`dist` bytes a consumer installs):

```
objectui validate <root chatbot with body: { model, temperature }>
exit 1 · Path: body · Code: invalid_type · message names `requestBody` <- SUBJECT
objectui validate <the same document spelled requestBody>
exit 0 · "Schema is valid!" <- LIT CONTROL
objectui validate <the same chatbot inside card.body[]>
exit 1 · refused through the node union <- DEPTH
```

⚠️ **A consumer path DOES skip the root parse, and it is the one authors hit most.** The
runtime render path never parses through these mirrors: `SchemaRenderer` validates in dev
builds only, through `@object-ui/core`'s hand-written `validateSchema`, which knows base
keys and recursion and no per-component key at all. Measured on the same document, rendered:

```
chatbot + body: { model: 'gpt-4' } console.warn "schema.children.type: type is required"
chatbot + requestBody (no warning) <- LIT CONTROL
chatbot, neither key (no warning) <- LIT CONTROL
card + body: [{ label: 'x' }] console.warn "schema.children[0].type: type is required"
<- FIRING CONTROL
```

That warning is NOT this retirement arriving. It is the generic child walker mistaking the
API params for a child node — it names `children` for a key the author spelled `body`, and
it prescribes adding a `type` to the params. It says the same thing before and after this
change, and in a production build it is not emitted at all. The VS Code extension's
validator is the same shape: its own walker, no mirror. ⇒ the retirement is delivered where
documents are AUTHORED and CHECKED (`tsc`, `objectui validate`, `objectui check`, and any
consumer that calls `safeValidateSchema`), and ⛔ not where they are RENDERED. Making the
render path agree is a different change on a different package and is not made here.

**Nothing that rendered stops rendering.** No renderer read consumes `body` for this node:
the `chatbot` registration spells the chat runtime's `body` option off `requestBody`, and
`SchemaRenderer` strips both content keys out of the props bag it spreads. An authored
`body` drew nothing before this change and draws nothing after it — it is refused first
instead of dropped silently, on the two faces that refuse anything.

**Migration, in this repository: no authored document changed.** Every tracked file carrying
a `chatbot` node is in `examples/schema-catalog/src/schemas/plugin-chatbot/`, and parsing all
three and reading their key sets structurally (rather than grepping a name that also appears
in comments) finds `body` in none of them and `requestBody` in none of them, against
`messages` present in all three as the control that the reader can see keys at all.

⚠️ This repository's census cannot see a consumer outside it that authored the key. Such a
consumer gets `invalid_type` at `body` with the replacement spelled in the message, or a
compile error naming the member — which is why the FROM/TO is written out above.

`minor` rather than `major` for the reason this repo's version policy gives: `major` is
forbidden in any changeset here (41 packages in one `fixed` group), and `minor` plus an
explicit breaking note is the spelling for a breaking change.

**Pins moved, and how.** `node-recursion-point-8344.test.ts` keeps both directions and
inverts the two the ruling names: the root case now asserts the refusal (with the issue path,
the code and the named replacement) instead of acceptance, and the
`ArmsNotAssignableToSchemaNode` type pin now reads `never` instead of `'chatbot'`. ⚠️ That
type alias needed an empty-set guard, measured rather than assumed: its bare projection
`Exclude< … > extends { type: infer K } ? K : never` resolves to `unknown`, not `never`, when
the exclusion set is empty, so the naive inversion could only ever be red.
`zod-mirror-parity.test.ts` loses `body` from `ChatbotSchema`'s `KnownDrift` row (the entry
survives on its two runtime slots) and loses the whole `WiderThanDeclared` entry, with the
header figures re-derived from the ledgers by that file's own pins.

**Two pending changesets in this release were amended, prose only.**
`8344-node-recursion-point-redirect.md` said the root document was "accepted at the root
before and after" and that "the published `ChatbotSchema` is untouched"; both halves sat in
one paragraph and both are falsified by this entry, so that paragraph now carries an AMENDED
note naming this card. `9256-content-channel-family-d.md` recorded this key as "a naming
collision awaiting a ruling" and now records that the ruling landed for one of the three
chatbot faces. A third — `7655-chatbot-registration-authoring-faces.md` — says the twins
"do not copy `ChatbotSchema`'s `body` naming collision"; that sentence's claim about the
TWINS is still true and is left alone, and the collision it names is the one retired here.
7 changes: 7 additions & 0 deletions .changeset/9256-content-channel-family-d.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ shorthand face, whose registrations are `any`-typed), `AppComponentSchema` and
`body` key of the three chatbot faces (the parity ledger records it as a naming
collision awaiting a ruling — their `children` is narrowed).

⚠️ **AMENDED (objectui#8572, ruling A — landing in THIS release).** That ruling has since
been made, so the hold-out above has ended for ONE of the three faces: `ChatbotSchema`'s own
`body` is retired on both published faces and points the author at `requestBody`. The two
twins are unchanged and go on inheriting `body` as the content slot, and the reason this card
held the key out — it was a naming collision to rule on, not a family-D read-site verdict —
is the part that stayed true.

Four published documents in the schema catalog and three component reference pages were
authoring `children` on `dialog`, `drawer`, `popover` and `collapsible`, all of which
read `content`. They rendered empty boxes and are corrected here.
2 changes: 1 addition & 1 deletion content/docs/plugins/plugin-chatbot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ to apply. They are documented in their own table after this one, under
| `model` | string | - | AI model identifier (e.g., `gpt-4o`) |
| `streamingEnabled` | boolean | `true` | Enable SSE streaming for AI responses |
| `headers` | object | - | Additional headers for API requests |
| `requestBody` | object | - | Additional body parameters sent with each API request. Authored on the node as `requestBody`; the renderer forwards it to the chat runtime under its own `body` option. Writing `body` on the node instead sets the base schema's children container and never reaches the API |
| `requestBody` | object | - | Additional body parameters sent with each API request. Authored on the node as `requestBody`; the renderer forwards it to the chat runtime under its own `body` option. Writing `body` on a `chatbot` node instead is **refused by name** - that key is retired there (objectui#8572) and the refusal names `requestBody` as the replacement. The refusal is delivered when the document is parsed (`objectui validate` / `objectui check`) or type-checked, not while it renders |
| `maxToolRoundtrips` | number | - | **Deprecated - has no effect.** Nothing reads this value, so it never capped anything. Cap tool-calling loops on the agent instead (`planning.maxIterations`). Still accepted so existing documents keep parsing; slated for removal in a future major |
| `surface` | `'card' \| 'plain'` | `'card'` | **`chatbot-enhanced` only** (declared on `ChatbotEnhancedSchema`). Controls whether the chat renders as a bordered panel (`'card'`) or a frameless full-page workspace (`'plain'`). `chatbot` renders the plain chat component, which has no such chrome to switch, and does not read this key. `chatbot-floating` has no named read for it and `ChatbotFloatingSchema` does not declare it; its panel is a `ChatbotEnhanced`, and an authored value used to reach that panel through the registration's unfiltered props spread, an accidental channel objectui#7708 fenced - it is dark there now, so switch the node to `chatbot-enhanced` if you need the frameless chrome |
| `processVisibility` | `'hidden' \| 'summary' \| 'debug'` | `'summary'` | **`chatbot-enhanced` only** (declared on `ChatbotEnhancedSchema`; `ChatbotSchema` still declares it too, though the `chatbot` registration has no read for it). Controls how much agent reasoning and tool detail is shown. `chatbot` renders the plain chat component, which has no agent-process display to configure at all - switch the node to `chatbot-enhanced` if you need one. `chatbot-floating` has no named read for it and `ChatbotFloatingSchema` does not declare it; an authored value used to reach its panel through the registration's unfiltered props spread, an accidental channel objectui#7708 fenced - it is dark there now, and there is no floating-side substitute to author |
Expand Down
50 changes: 30 additions & 20 deletions packages/types/src/__tests__/content-channel-family-d-9256.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@
* records that key as "two different meanings of one key — a naming
* collision to rule on". Their `children` channel is narrowed here, and the
* LIVE CONTROL below proves `body` still parses there.
* ⚠️ AMENDED (objectui#8572): that ruling has since been made and it reaches
* ONE of the three faces — `ChatbotSchema`'s own `body`, whose disposition is
* that card's to state and ⛔ is not restated here. The hold-out this card
* recorded still stands on the two TWIN faces, so both controls below are
* RE-POINTED at a twin rather than inverted; what this card asserts — that it
* narrowed `children` on the chatbot faces and left their `body` alone — is
* unmoved.
*
* ## ⚠️ Half of this file is a COMPILE-TIME assertion and vitest CANNOT read it
*
Expand Down Expand Up @@ -197,7 +204,6 @@ import {
FilterUISchema as FilterUIMirror,
SortUISchema as SortUIMirror,
} from '../zod/views.zod';
import { ChatbotSchema as ChatbotBodyControl } from '../zod/complex.zod';
import { AnyComponentSchema } from '../zod/index.zod';
import type { CollapsibleSchema } from '../disclosure';
import type { DialogSchema } from '../overlay';
Expand All @@ -207,7 +213,7 @@ import type { KbdSchema, PivotTableSchema, ListSchema, TimelineSchema } from '..
import type { SkeletonSchema } from '../feedback';
import type { PaginationSchema } from '../navigation';
import type { ObjectGallerySchema } from '../objectql';
import type { CarouselSchema, ChatbotSchema } from '../complex';
import type { CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema } from '../complex';
import type { ReportViewerSchema } from '../reports';
import type { ViewSwitcherSchema } from '../views';
import type { NLQuerySchema } from '../ai';
Expand Down Expand Up @@ -367,14 +373,18 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel,
expect(Object.keys(mirror.shape)).toContain(key);
});

it('LIVE CONTROL — `chatbot` still accepts `body`, the key held out of this card', () => {
// The parity ledger records `body` here as "two different meanings of one
// key — a naming collision to rule on": the mirror declares it as API
// request params, the declaration inherits the base's content channel.
// Refusing it would have been a guess, so it is untouched — and this line
// is what keeps the row above a reading about `children`.
const r = (ChatbotBodyControl as unknown as Mirror)
.safeParse({ type: 'chatbot', messages: [], body: { temperature: 0.2 } });
it('LIVE CONTROL — the chatbot family still accepts `body`, the channel held out of this card', () => {
// The held-out channel, still held out — RE-POINTED, not inverted, by
// objectui#8572. This control was aimed at `ChatbotSchema`, whose own `body`
// the parity ledger recorded as "two different meanings of one key — a naming
// collision to rule on"; that ruling has since been made and belongs to its
// own card, ⛔ which is why this line does not restate its verdict here. The
// hold-out this card recorded still stands on the two TWIN faces: they
// inherit `body` as the content channel and only their `children` is
// narrowed above — so the control keeps doing its job, which is to keep the
// chatbot rows a reading about `children` rather than about the whole mirror.
const r = (ChatbotEnhancedMirror as unknown as Mirror)
.safeParse({ type: 'chatbot-enhanced', messages: [], body: CONTENT });
expect(r.success).toBe(true);
});

Expand Down Expand Up @@ -561,15 +571,15 @@ describe('objectui#9256 — the TypeScript face refuses both channels at the AUT
expect(ok).toHaveLength(7);
});

it('CONTROL — `chatbot` still TYPE-CHECKS with `body`, the held-out key', () => {
// ⚠️ The VALUE differs from the mirror control above, and that difference
// IS the ledgered collision: the TypeScript face inherits `body` from
// `BaseSchema` as a content channel (`SchemaNode | SchemaNode[]`), while
// the mirror declares it as `Record` of unknown, "additional API body
// params". One key, two meanings, on the two published faces of one node —
// which is why objectui#9256 refuses to tombstone it on a measurement and
// leaves it for a ruling.
const held = { type: 'chatbot', messages: [], body: CONTENT } satisfies ChatbotSchema;
expect(held.type).toBe('chatbot');
it('CONTROL — the chatbot family still TYPE-CHECKS with `body`, the held-out channel', () => {
// RE-POINTED by objectui#8572, for the reason spelled out at the mirror
// control above, and ⛔ deliberately not restating that card's verdict here.
// What survives unchanged is this card's own claim: it narrowed `children`
// on the chatbot faces and left their `body` channel alone. On a TWIN that
// is still visible on both faces — `body` arrives from `BaseSchema` as the
// content channel and `tsc` admits it — and `tsc`, not vitest, is the reader
// of this line.
const held = { type: 'chatbot-enhanced', messages: [], body: CONTENT } satisfies ChatbotEnhancedSchema;
expect(held.type).toBe('chatbot-enhanced');
});
});
Loading
Loading