From 3ca9d4477cb1d9590c2fb5e06ac6c0a6143bed08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 20:44:44 +0000 Subject: [PATCH] feat(types)!: retire six dark ChatbotSchema keys as ADR-0049 tombstones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loading, showAvatars, userAvatar, assistantAvatar, markdown and height were declared on ChatbotSchema, mirrored on its Zod twin, and read by no plugin-chatbot registration: a schema.KEY census per ComponentRegistry.register body returns 0/0/0 for all six, with placeholder 1/1/1, messages 1/1/1, userAvatarUrl 1/1/1, maxHeight 1/1/0, floatingConfig 0/0/1 and processVisibility 0/1/0 lit on the same instrument. Each becomes `?: never` on complex.ts plus retirementTombstone() on complex.zod.ts — both halves, the convention #6972 / #6355 / #7779 already carry. Deleting them was the wrong route: all six have a Zod arm, and BaseSchema is .passthrough() with a [key: string]: any index signature, so an undeclared key is KEPT, not refused. Enforce was refused per key: , the component this registration renders, declares none of the six, so enforcing means growing a component prop or publishing a second spelling of a key that already works. showAvatars is the one key the FENCE turned dark rather than a key nothing ever read: has such a prop and chatbot-floating's raw props spread delivered an authored value to it until #7708 ruled fence (PR #8077). The distinction is recorded in the tombstone comment, the changeset and the pin. processVisibility is NOT folded in — chatbot-enhanced reads it (0/1/0) — and is pinned live as the scope control. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .changeset/7703-chatbot-dark-keys-retired.md | 116 +++++ content/docs/plugins/plugin-chatbot.mdx | 47 +- .../chatbot-dark-keys-retired-7703.test.ts | 433 ++++++++++++++++++ ...-registration-authoring-faces-7655.test.ts | 22 +- packages/types/src/complex.ts | 193 +++++++- packages/types/src/zod/complex.zod.ts | 53 ++- 6 files changed, 824 insertions(+), 40 deletions(-) create mode 100644 .changeset/7703-chatbot-dark-keys-retired.md create mode 100644 packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts diff --git a/.changeset/7703-chatbot-dark-keys-retired.md b/.changeset/7703-chatbot-dark-keys-retired.md new file mode 100644 index 0000000000..fab64d2596 --- /dev/null +++ b/.changeset/7703-chatbot-dark-keys-retired.md @@ -0,0 +1,116 @@ +--- +'@object-ui/types': minor +--- + +Retire the six `ChatbotSchema` members no `plugin-chatbot` registration reads — +`loading`, `showAvatars`, `userAvatar`, `assistantAvatar`, `markdown` and `height` — as +ADR-0049 retirement tombstones on **both** published faces (objectui#7703). + +⚠️ **BREAKING for anyone authoring one of these six against `ChatbotSchema`, on either +face.** Ships as `minor` per the launch-window convention: objectui's `major` is a +cross-repo pin to `@objectstack`'s so that "same major means compatible" holds across the +two repos (`scripts/check-changeset-no-major.mjs`), and objectui's own breaking changes +ship as `minor` with the break named where it lands — this entry is the channel that +carries it. + +## What was retired, and why + +The published type taught six knobs that did nothing. An author — or an AI author reading +the `.d.ts` — wrote `showAvatars: true` or `height: 400` on a `chatbot` node, got no error +on either face, and saw no change. Two of them advertised a `@default true` for a switch +that did not exist. + +Re-measured on this branch's base (`21d7989fb`, i.e. **after** objectui#7708's fence +landed as PR #8077) rather than inherited from the card: one `schema.KEY` count per +`ComponentRegistry.register(...)` body of `packages/plugin-chatbot/src/renderer.tsx`, the +file split at the three register calls. + +| key | declared type | `schema.KEY` reads (`chatbot` / `chatbot-enhanced` / `chatbot-floating`) | +| :-- | :-- | :-- | +| `loading` | `boolean` | 0 / 0 / 0 | +| `showAvatars` | `boolean` (`@default true`) | 0 / 0 / 0 | +| `userAvatar` | `string` | 0 / 0 / 0 | +| `assistantAvatar` | `string` | 0 / 0 / 0 | +| `markdown` | `boolean` (`@default true`) | 0 / 0 / 0 | +| `height` | `string \| number` | 0 / 0 / 0 | + +Lit controls on the same instrument in the same pass: `placeholder` 1 / 1 / 1, `messages` +1 / 1 / 1, `userAvatarUrl` 1 / 1 / 1, `maxHeight` 1 / 1 / 0, `floatingConfig` 0 / 0 / 1, +`processVisibility` 0 / 1 / 0. The zeros are readings, not a blind grep. + +⛔ `processVisibility` is **not** in this retirement — `chatbot-enhanced` reads it, and +objectui#7655 left the `ChatbotSchema` member as it was. It is pinned live as a control. + +### `showAvatars` is the one key the FENCE turned dark, not a key nothing ever read + +The distinction is recorded rather than smoothed over, because the two provenances are +different facts. `ChatbotEnhanced` really does have a `showAvatars` prop, and until +objectui#7708 the `chatbot-floating` registration ended its panel element with a raw +`{...props}` spread that handed an authored value straight to it — measured live through +the real host. That card was ruled **fence**, not declare, and landed as PR #8077: the +spread is filtered through `toDomProps` and moved ahead of every named prop. So the key is +dark on all three registrations **by ruling**, and this retirement records that, not an +absence. The other five were live on no channel at any time — they are not +`ChatbotEnhancedProps` members either (`markdown` exists there only as `enableMarkdown`), +so the spread had nothing to land them on. + +## Enforce-or-remove, decided per key + +The other arm was taken key by key and refused each time. `` +(`plugin-chatbot/src/index.tsx`) — the component the `chatbot` registration renders — +declares `messages`, `placeholder`, `onSendMessage`, `disabled`, `showTimestamp`, +`userAvatarUrl`, `userAvatarFallback`, `assistantAvatarUrl`, `assistantAvatarFallback` and +`maxHeight`, and **not one of the six**. + +| key | why not enforce | migration | +| :-- | :-- | :-- | +| `loading` | Chat progress is runtime state the chat runtime owns — the registration derives it from `useObjectChat` as `isLoading`. A static authored boolean would fight the runtime, not configure it | delete the key | +| `showAvatars` | No target on ``. Declaring it on the two faces that DO reach `` would re-open by declaration the channel objectui#7708 closed by fence, one card earlier | delete the key — a `chatbot` node already renders an avatar beside every message | +| `userAvatar` | A second authorable spelling of an image `userAvatarUrl` already carries (AGENTS.md #0.1: one strict contract, not N dialects) | `userAvatarUrl` (+ `userAvatarFallback`) | +| `assistantAvatar` | Same | `assistantAvatarUrl` (+ `assistantAvatarFallback`) | +| `markdown` | `` prints message content as text and has no markdown path; on the two nodes that render markdown, `enableMarkdown` is the live key | `type: 'chatbot-enhanced'` with `enableMarkdown` | +| `height` | `` has no `height` prop, and the live `maxHeight` it forwards is a `string`, not this key's `string \| number` union | `maxHeight`, or `floatingConfig.panelHeight` on a floating node | + +## FROM → TO + +Each member goes to `?: never` on `packages/types/src/complex.ts` and to +`retirementTombstone(...)` on `packages/types/src/zod/complex.zod.ts` — both halves, in +lockstep, the convention `MarkdownSchema.sanitize` (objectui#6972), +`TimelineSchema.timeScale` (objectui#6355) and `ObjectViewSchema.viewTabBar` +(objectui#7779) already carry. Each refusal names the key, says why it is retired, and +points at what to write instead; one string feeds both the parse-time message and the +`.describe()` metadata, so the two cannot drift. + +## Accept-set change, one line per face + +- **TypeScript.** A write of any of the six against `ChatbotSchema` used to compile and + now does not — including through a widened (non-fresh) value, which is the half a + deletion would have missed on a `BaseSchema` carrier. +- **Runtime (Zod / `safeValidateSchema`).** A `chatbot` document authoring one of the six + used to parse **green** and now parses **red**, `invalid_type` at the key's own path + with the guidance as the message. This is the narrowing that carries + `needs:contract-review`. +- **`chatbot-enhanced` / `chatbot-floating`.** Unchanged in both directions. Those faces + never declared the six (objectui#7655 censused them out), their twins have no arm to + refuse one, and `BaseSchema` is `.passthrough()` — so a stored node of either type + carrying a retired key parses exactly as it did. Pinned. + +## Why tombstones and not deletions + +All six **have** a Zod arm, and that is what decides the route here. `BaseSchema` is +`.passthrough()` on the Zod side and carries a `[key: string]: any` index signature on the +TS side, so an UNDECLARED key is not refused — it is KEPT. Deleting the members would hand +the authored spelling exactly the silent no-op this card exists to close, on both faces at +once. The two-prong discriminator (`mobile.ts`, objectui#5941 / #7526 / #7678) leaves that +structural hazard to the carrier: where there is no mirror there is "no silent-strip hazard +for prong 2 to guard". Here there is a mirror to host the refusal, and prong 1 holds by the +letter for four of the six. The "deleted" row is pinned live as a control in +`packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts`, so the contrast +cannot rot into prose. + +## Docs + +`content/docs/plugins/plugin-chatbot.mdx` gains the restatement of the six removed keys +with their replacements (objectui#7070: a control is restated, never deleted into a +vacuum), and its three present-tense claims that the `chatbot-floating` props spread is +still unfiltered are corrected to what PR #8077 actually left behind. diff --git a/content/docs/plugins/plugin-chatbot.mdx b/content/docs/plugins/plugin-chatbot.mdx index 6ea255ddf9..48eeab9cb7 100644 --- a/content/docs/plugins/plugin-chatbot.mdx +++ b/content/docs/plugins/plugin-chatbot.mdx @@ -150,14 +150,16 @@ twenty shared rows below are one declaration the two newer faces pick off registration reads it. A key a registration ignores is therefore not a declared member of its type. It still type-checks (`BaseSchema` ends in an index signature, so an unlisted key is `any` rather than an error) and still parses -(the Zod twins are `.passthrough()`). On `chatbot` and `chatbot-enhanced` it is -then dropped silently at render time. `chatbot-floating` is different today: its -registration forwards the whole authored node to the panel through an -unfiltered props spread, so some keys its type does not declare -(`processVisibility`, `surface`, `showAvatars`) do reach the panel - an -accidental channel, measured and tracked as objectui#7708, not a contract to -author against. That is why the scope is spelled out here as well as in the -types. +(the Zod twins are `.passthrough()`). It is then dropped silently at render +time, on all three registrations alike. `chatbot-floating` used to be the +exception: its registration ended its panel element with an unfiltered props +spread, so three keys its type does not declare (`processVisibility`, `surface`, +`showAvatars`) did reach the panel. That channel was accidental, not a contract +to author against, and objectui#7708 closed it - the spread is filtered through +the DOM-safe whitelist and moved ahead of every named prop, the shape the two +sibling registrations already used. A `chatbot-floating` node's declared set is +now also its delivered set. That is why the scope is spelled out here as well as +in the types. The table below is that shared chat surface. `chatbot-floating` declares six more keys of its own - the six `floatingConfig` entries - which no row @@ -189,10 +191,35 @@ to apply. They are documented in their own table after this one, under | `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 | | `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 currently reaches that panel only through the registration's unfiltered props spread (objectui#7708) - not a contract to author against | -| `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 currently reaches its panel only through the registration's unfiltered props spread (objectui#7708), which is not a contract - there is no floating-side substitute to author | +| `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 | | `onError` | function | - | Error callback for streaming/API errors | +**There are no `loading`, `showAvatars`, `userAvatar`, `assistantAvatar`, +`markdown` or `height` keys.** All six were declared on `ChatbotSchema`, mirrored +on its Zod twin, and read by no registration - a `showAvatars: true` or a +`height: 400` on a `chatbot` node type-checked, parsed green, and changed +nothing. objectui#7703 retired them (ADR-0049 enforce-or-remove, one decision per +key): each is a `never` tombstone on the declaration and a named refusal on the +twin, so writing one is now a compile error **and** a parse error that says what +to write instead. Four have a live replacement, and it is the key the +registrations already read: + +| retired | write instead | +|---------|---------------| +| `userAvatar` | `userAvatarUrl` (with `userAvatarFallback`) | +| `assistantAvatar` | `assistantAvatarUrl` (with `assistantAvatarFallback`) | +| `height` | `maxHeight` on `chatbot` / `chatbot-enhanced`; `floatingConfig.panelHeight` on `chatbot-floating` | +| `markdown` | `enableMarkdown`, on a `chatbot-enhanced` or `chatbot-floating` node | +| `loading` | nothing - chat progress is runtime state the chat runtime owns, never authored | +| `showAvatars` | nothing - a `chatbot` node already renders an avatar beside every message; the images are the `*AvatarUrl` keys above | + +`showAvatars` is the one of the six that was not always inert: `ChatbotEnhanced` +has such a prop, and until objectui#7708 fenced it the `chatbot-floating` +registration's unfiltered props spread delivered an authored value to it. It is +dark on all three registrations now. The other five were live on no path at any +time. + ### `chatbot-floating` panel and trigger keys The six keys below are declared in the `chatbot-floating` registration's own diff --git a/packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts b/packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts new file mode 100644 index 0000000000..f79178b447 --- /dev/null +++ b/packages/types/src/__tests__/chatbot-dark-keys-retired-7703.test.ts @@ -0,0 +1,433 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Retirement pin — the six `ChatbotSchema` members no `plugin-chatbot` + * registration reads are REFUSED, not silently ignored (objectui#7703, + * ADR-0049 enforce-or-remove, one decision per key). + * + * ## The failure this pin exists to prevent + * + * `loading`, `showAvatars`, `userAvatar`, `assistantAvatar`, `markdown` and + * `height` were declared on `ChatbotSchema`, mirrored on its Zod twin, and read + * by NOTHING — two of them (`showAvatars`, `markdown`) advertising a + * `@default true` for a switch that did not exist. An author, or an AI author + * reading the `.d.ts`, wrote `showAvatars: true` or `height: 400` on a + * `chatbot` node, got no error on either published face, and saw no change. + * + * The instrument, re-measured on this branch's base (`21d7989fb`, i.e. AFTER + * objectui#7708's fence landed as PR #8077): one `schema.KEY` count per + * `ComponentRegistry.register(...)` body of + * `packages/plugin-chatbot/src/renderer.tsx`, the file split at the three + * register calls. All six read 0 / 0 / 0 across + * `chatbot` / `chatbot-enhanced` / `chatbot-floating`. Lit controls on the same + * instrument in the same pass — `placeholder` 1 / 1 / 1, `messages` 1 / 1 / 1, + * `userAvatarUrl` 1 / 1 / 1, `maxHeight` 1 / 1 / 0, `floatingConfig` 0 / 0 / 1, + * `processVisibility` 0 / 1 / 0 — so the zeros are readings, not a blind grep. + * + * ⭐ `showAvatars` is the one key whose provenance differs, and the difference + * is recorded rather than smoothed over: `` HAS a + * `showAvatars` prop, and until objectui#7708 the `chatbot-floating` + * registration's raw trailing `{...props}` spread delivered an authored value + * to it unfiltered. That card was ruled FENCE and landed as PR #8077 — the + * spread is filtered through `toDomProps` at the head of the element now — so + * `showAvatars` is a key the FENCE turned dark, not a key nothing ever read. + * The other five were live on no channel at any time: they are not + * `ChatbotEnhancedProps` members either (`markdown` exists there only as + * `enableMarkdown`), so the spread had nothing to land them on. + * + * ⛔ `processVisibility` is NOT part of this retirement — `chatbot-enhanced` + * reads it (0 / 1 / 0). It is pinned live below as a control, so the boundary + * cannot rot into prose. + * + * ## Why tombstones and not deletions + * + * All six HAVE a Zod arm, which is what decides the route here: `BaseSchema` is + * `.passthrough()` on the Zod side and carries a `[key: string]: any` index + * signature on the TS side, so an UNDECLARED key is not refused, it is KEPT. + * Deleting the members would hand the authored spelling exactly the silent + * no-op this card exists to close. `?: never` + `retirementTombstone()` is this + * package's convention — `MarkdownSchema.sanitize` / `.components` + * (objectui#6972), `TimelineSchema.timeScale` (objectui#6355), + * `ObjectKanbanSchema` item ① (objectui#7322), `ObjectViewSchema.viewTabBar` + * (objectui#7779) — and it is lockstep: both halves or neither. The "deleted" + * row is pinned live below as a control. + * + * ## Why not ENFORCE, per key — the other arm, refused six times + * + * `` (`plugin-chatbot/src/index.tsx`), the component THIS + * registration renders, declares `messages`, `placeholder`, `onSendMessage`, + * `disabled`, `showTimestamp`, `userAvatarUrl`, `userAvatarFallback`, + * `assistantAvatarUrl`, `assistantAvatarFallback` and `maxHeight` — not one of + * the six. Enforcing any of them means growing a component prop (a feature) or + * publishing a SECOND spelling of a key that already works (AGENTS.md #0.1). + * Per key: `loading` is runtime state the chat runtime owns; `showAvatars` has + * no target on `` and re-declaring it on the faces that DO reach + * `` would re-open by declaration the channel objectui#7708 + * closed by fence; `userAvatar` / `assistantAvatar` / `height` / `markdown` + * each have a live sibling (`userAvatarUrl`, `assistantAvatarUrl`, `maxHeight`, + * `enableMarkdown`) that the registrations already read. + * + * The `@ts-expect-error` directives are REAL enforcement: this package + * type-checks its tests through `tsconfig.test.json`, so re-widening a + * declaration fails the build on the unused directive (TS2578). A green + * `vitest` run is NOT evidence about them — type assertions are erased before + * it runs. + */ + +import { describe, it, expect } from 'vitest'; +import { + ChatbotSchema as ChatbotZod, + ChatbotEnhancedSchema as ChatbotEnhancedZod, + ChatbotFloatingSchema as ChatbotFloatingZod, +} from '../zod/complex.zod.js'; +import type { + ChatbotSchema as ChatbotSchemaTS, + ChatbotEnhancedSchema as ChatbotEnhancedSchemaTS, + ChatbotFloatingSchema as ChatbotFloatingSchemaTS, +} from '../complex.js'; + +/** + * The retired keys, each with the values an author would plausibly have + * written, the FULL guidance string (pinned as a literal so the derived + * assertions below cannot all drift together), and the prescriptive half — the + * sentence that tells the author what is true and what to write instead. + */ +const RETIRED = { + loading: { + values: [true, false] as const, + guidance: + 'RETIRED (objectui#7703, ADR-0049) — never read: chat progress is runtime state the chat runtime owns ' + + '(the registration derives it from `useObjectChat` as `isLoading`), and `` declares no `loading` ' + + 'prop for an authored value to land on. There is no authored spelling that sets it; delete the key.', + prescriptive: 'There is no authored spelling that sets it; delete the key.', + }, + showAvatars: { + values: [true, false] as const, + guidance: + 'RETIRED (objectui#7703, ADR-0049) — no registration reads or forwards this key by name, and a `chatbot` ' + + 'node renders ``, which has no `showAvatars` prop; the one channel that did deliver it — the ' + + "`chatbot-floating` registration's unfiltered props spread — was fenced by objectui#7708. Delete the key: " + + 'a `chatbot` node already renders an avatar beside every message, and the images are `userAvatarUrl` / ' + + '`assistantAvatarUrl` with their `userAvatarFallback` / `assistantAvatarFallback` siblings.', + prescriptive: 'was fenced by objectui#7708', + }, + userAvatar: { + values: ['https://example.com/me.png', ''] as const, + guidance: + 'RETIRED (objectui#7703, ADR-0049) — never read: this spelling has zero hits anywhere in ' + + '`packages/plugin-chatbot`. Write `userAvatarUrl` instead (with `userAvatarFallback` for the text shown ' + + 'while the image loads or fails), the key all three chatbot registrations read.', + prescriptive: 'Write `userAvatarUrl` instead', + }, + assistantAvatar: { + values: ['https://example.com/bot.png', ''] as const, + guidance: + 'RETIRED (objectui#7703, ADR-0049) — never read: this spelling has zero hits anywhere in ' + + '`packages/plugin-chatbot`. Write `assistantAvatarUrl` instead (with `assistantAvatarFallback` for the ' + + 'text shown while the image loads or fails), the key all three chatbot registrations read.', + prescriptive: 'Write `assistantAvatarUrl` instead', + }, + markdown: { + values: [true, false] as const, + guidance: + 'RETIRED (objectui#7703, ADR-0049) — never read: a `chatbot` node renders ``, which prints message ' + + 'content as text and has no markdown path for this switch to reach. Author `type: "chatbot-enhanced"` ' + + '(or `"chatbot-floating"`) with `enableMarkdown` instead — markdown is those nodes\' capability, and ' + + '`enableMarkdown` is the key their registrations read.', + prescriptive: 'with `enableMarkdown` instead', + }, + height: { + // Both arms of the retired `string | number` union — a deletion would have + // left the numeric one riding `.passthrough()` exactly like the string one. + values: ['400px', 400] as const, + guidance: + 'RETIRED (objectui#7703, ADR-0049) — never read: `` has no `height` prop. Write `maxHeight` ' + + 'instead (a CSS length string, default "500px"), the key the `chatbot` and `chatbot-enhanced` ' + + 'registrations forward; size a `chatbot-floating` panel with `floatingConfig.panelHeight`, a number of ' + + 'pixels, which is what that panel reads.', + prescriptive: 'Write `maxHeight` instead', + }, +} as const; + +type RetiredKey = keyof typeof RETIRED; + +const RETIRED_KEYS = Object.keys(RETIRED) as RetiredKey[]; + +/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */ +const VALID_CHATBOT = { + type: 'chatbot', + messages: [{ id: '1', role: 'assistant', content: 'Hello! How can I help you today?' }], +} as const; + +const shapeOf = (schema: unknown): Record => + (schema as { shape: Record }).shape; + +const describeOf = (schema: unknown, key: string): string | undefined => + (shapeOf(schema)[key] as { description?: string } | undefined)?.description; + +/* ── the Zod half: refused BY NAME, with the guidance in the message ─────── */ + +describe.each(RETIRED_KEYS)( + 'ChatbotSchema.%s is RETIRED — the Zod half of the tombstone (objectui#7703)', + (key) => { + const { values, guidance, prescriptive } = RETIRED[key]; + + it.each(values.map((v) => [JSON.stringify(v), v] as const))( + 'REFUSES `%s`, naming the retired key in the path — every value, not one spelling', + (_label, value) => { + // The pin. Before this retirement the same document parsed GREEN — the + // six arms were `z.boolean()` / `z.string()` / `z.union([z.string(), + // z.number()])`, all `.optional()`, measured ACCEPTED on this branch's + // base. Asserting the ENVELOPE — not merely `success:false` — so the + // pin cannot be satisfied by an unrelated rejection. + const result = ChatbotZod.safeParse({ ...VALID_CHATBOT, [key]: value }); + expect( + result.success, + `an authored \`${key}: ${JSON.stringify(value)}\` was ACCEPTED — it parses green and changes nothing`, + ).toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === key); + expect(issue, `parse failed, but not on the \`${key}\` path`).toBeTruthy(); + // The accept-set contract: same address, same code a bare `z.never()` + // reports — `retirementTombstone()` customises the MESSAGE only. + expect(issue?.code).toBe('invalid_type'); + expect((issue as { expected?: string } | undefined)?.expected).toBe('never'); + expect(issue?.path).toEqual([key]); + }, + ); + + it("the refusal CARRIES the guidance — the prescriptive sentence, not zod's generic message", () => { + const result = ChatbotZod.safeParse({ ...VALID_CHATBOT, [key]: values[0] }); + expect(result.success).toBe(false); + if (result.success) return; + + const issue = result.error.issues.find((i) => i.path[0] === key); + expect(issue?.message).not.toContain('Invalid input: expected never, received '); + // The half an author acts on: what is TRUE and what to write INSTEAD. + // Pinned as text because the wording is the contract here — a message + // that only said "retired" would leave the author with no next step, and + // for `showAvatars` it would erase the one fact that distinguishes this + // key from the other five (objectui#7708 fenced it; it was not always + // dark). + expect(issue?.message).toContain(prescriptive); + expect(issue?.message).toBe(guidance); + // ONE string, BOTH channels — asserted derived, so the parse message and + // the generated-docs metadata cannot drift apart. + expect(issue?.message).toBe(describeOf(ChatbotZod, key)); + }); + + it('keeps the key DECLARED on the mirror — a tombstone, not a deletion', () => { + // The route guard. `BaseSchema` is `.passthrough()`, so removing the key + // from the mirror would make the authored spelling parse green again and + // do nothing — the silent no-op reintroduced by the very edit meant to + // remove it. + expect( + Object.keys(shapeOf(ChatbotZod)), + `${key} left the mirror — under .passthrough() the retired key becomes a SILENT no-op again`, + ).toContain(key); + expect(describeOf(ChatbotZod, key)).toContain('RETIRED (objectui#7703'); + }); + + it('does NOT appear on the two sibling twins — the retirement declares nothing new', () => { + // objectui#7655 gave `chatbot-enhanced` and `chatbot-floating` their own + // faces, censused per key, and deliberately left all six off both. A + // tombstone is a REFUSAL, and adding one where the key was never declared + // would be a new declaration — the opposite of retiring. Their twins must + // stay untouched, so a stored `chatbot-enhanced` node carrying the key + // keeps riding `.passthrough()` exactly as it did. + expect(shapeOf(ChatbotEnhancedZod)[key], key).toBeUndefined(); + expect(shapeOf(ChatbotFloatingZod)[key], key).toBeUndefined(); + }); + }, +); + +/* ── the inside of the boundary: everything else is untouched ────────────── */ + +describe('the retirement narrows exactly the six keys and nothing else (objectui#7703)', () => { + it('a document that wrote none of them parses GREEN — `absent` stays valid', () => { + // `.optional()` on all six tombstones. The retirement narrows exactly six + // keys, and every one of them stays omittable. + const result = ChatbotZod.safeParse(VALID_CHATBOT); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('still ACCEPTS every live sibling the retired keys point at — the lit control', () => { + // Without this leg the six refusals above would be satisfied by a schema + // that refuses every optional key: a narrowing that refuses too much passes + // a refusal-only test. These are the exact replacement spellings the + // guidance strings name, plus the shared keys the registrations read. + const result = ChatbotZod.safeParse({ + ...VALID_CHATBOT, + placeholder: 'Type a message...', + showTimestamp: true, + userAvatarUrl: 'https://example.com/me.png', + userAvatarFallback: 'JD', + assistantAvatarUrl: 'https://example.com/bot.png', + assistantAvatarFallback: 'AI', + maxHeight: '400px', + }); + expect(result.success ? null : result.error.issues).toBe(null); + }); + + it('still ACCEPTS `processVisibility` — the seventh key, deliberately NOT retired', () => { + // The scope boundary, pinned rather than argued. `processVisibility` reads + // 0 / 1 / 0 on the same instrument — `chatbot-enhanced` forwards it by name + // — and objectui#7655 left the `ChatbotSchema` member as it was. It is a + // different question from this card and is not folded in. + const result = ChatbotZod.safeParse({ ...VALID_CHATBOT, processVisibility: 'debug' }); + expect(result.success ? null : result.error.issues).toBe(null); + // And it is still VALIDATED, not merely accepted — so "untouched" is a + // reading about a live arm, not about a hole. + expect(ChatbotZod.safeParse({ ...VALID_CHATBOT, processVisibility: 'loud' }).success).toBe(false); + }); + + it('still REFUSES a wrong `messages` — the mirror did not stop validating', () => { + // Counter-probe in the other direction: the schema is not `z.any()` in + // disguise, so the green results above are readings. + const result = ChatbotZod.safeParse({ type: 'chatbot', messages: 'not an array' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.find((i) => i.path[0] === 'messages')).toBeTruthy(); + }); + + it('an UNDECLARED key still rides `.passthrough()` — the DELETED row, measured live', () => { + // This is the contrast that justifies `?: never` over deletion, pinned + // rather than argued: a key the mirror does not declare is neither refused + // nor stripped, it is KEPT. Had the six been deleted instead of tombstoned, + // an authored value would sit exactly where this one sits — green, + // forwarded, and read by nothing. + const result = ChatbotZod.safeParse({ ...VALID_CHATBOT, notAKeyAtAll: 'anything' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toHaveProperty('notAKeyAtAll', 'anything'); + }); + + it('a stored `chatbot-enhanced` / `chatbot-floating` node carrying a retired key still parses GREEN', () => { + // The blast radius, stated as a measurement. The two sibling faces never + // declared these keys, so their twins have no arm to refuse one and + // `.passthrough()` keeps it — before this change and after it. The + // retirement moves the `chatbot` node's accept set and nothing else. + for (const key of RETIRED_KEYS) { + const enhanced = ChatbotEnhancedZod.safeParse({ + type: 'chatbot-enhanced', + messages: VALID_CHATBOT.messages, + [key]: RETIRED[key].values[0], + }); + expect(enhanced.success, `chatbot-enhanced rejected \`${key}\``).toBe(true); + const floating = ChatbotFloatingZod.safeParse({ + type: 'chatbot-floating', + messages: VALID_CHATBOT.messages, + [key]: RETIRED[key].values[0], + }); + expect(floating.success, `chatbot-floating rejected \`${key}\``).toBe(true); + } + }); +}); + +/* ── the TS half: the `tsc` channel ──────────────────────────────────────── */ + +describe('the six keys are RETIRED on the TypeScript face too (objectui#7703)', () => { + it('refuses each retired key at compile time, with a live sibling as the counter-probe', () => { + // On the pre-fix tree each member carried its own value type, so every + // assignment below was LEGAL, the directives were unused, and `tsc` failed + // the build with TS2578 naming the key — these legs are red before the fix + // in `type-check`, not in vitest, which strips types. + + // @ts-expect-error — `loading` is RETIRED (objectui#7703): declared `?: never`, so no value is authorable. + const retiredLoading: ChatbotSchemaTS['loading'] = true; + // @ts-expect-error — `showAvatars` is RETIRED (objectui#7703); objectui#7708 fenced the one channel that read it. + const retiredShowAvatars: ChatbotSchemaTS['showAvatars'] = true; + // @ts-expect-error — `userAvatar` is RETIRED (objectui#7703); write `userAvatarUrl`. + const retiredUserAvatar: ChatbotSchemaTS['userAvatar'] = 'https://example.com/me.png'; + // @ts-expect-error — `assistantAvatar` is RETIRED (objectui#7703); write `assistantAvatarUrl`. + const retiredAssistantAvatar: ChatbotSchemaTS['assistantAvatar'] = 'https://example.com/bot.png'; + // @ts-expect-error — `markdown` is RETIRED (objectui#7703); author `chatbot-enhanced` with `enableMarkdown`. + const retiredMarkdown: ChatbotSchemaTS['markdown'] = true; + // @ts-expect-error — `height` is RETIRED (objectui#7703); write `maxHeight`. + const retiredHeight: ChatbotSchemaTS['height'] = '400px'; + + // Counter-probes on the same surface: the live siblings each guidance + // string names still accept their values, so the directives above pin the + // KEYS' retirement and not a blanket narrowing of the interface. + const liveUserAvatarUrl: ChatbotSchemaTS['userAvatarUrl'] = 'https://example.com/me.png'; + const liveAssistantAvatarUrl: ChatbotSchemaTS['assistantAvatarUrl'] = 'https://example.com/bot.png'; + const liveMaxHeight: ChatbotSchemaTS['maxHeight'] = '400px'; + const liveProcessVisibility: ChatbotSchemaTS['processVisibility'] = 'debug'; + + expect([ + retiredLoading, retiredShowAvatars, retiredUserAvatar, retiredAssistantAvatar, + retiredMarkdown, retiredHeight, + liveUserAvatarUrl, liveAssistantAvatarUrl, liveMaxHeight, liveProcessVisibility, + ]).toHaveLength(10); + }); + + it('refuses them in the form authors actually write — a fresh document literal', () => { + // The leg that proves the tombstones survive `BaseSchema`'s + // `[key: string]: any`: if the index signature won, each key would widen + // back to `any` here and every directive would go unused (TS2578). + const retiredDocument: ChatbotSchemaTS = { + type: 'chatbot', + messages: [], + // @ts-expect-error — `showAvatars` is RETIRED (objectui#7703); a `chatbot` node renders ``, which has no such prop. + showAvatars: true, + // @ts-expect-error — `height` is RETIRED (objectui#7703); write `maxHeight`. + height: 400, + // @ts-expect-error — `markdown` is RETIRED (objectui#7703); author `chatbot-enhanced` with `enableMarkdown`. + markdown: true, + }; + + // The migrated document — every retired key replaced by the live spelling + // its guidance names — still type-checks. + const migratedDocument: ChatbotSchemaTS = { + type: 'chatbot', + messages: [], + userAvatarUrl: 'https://example.com/me.png', + assistantAvatarUrl: 'https://example.com/bot.png', + maxHeight: '400px', + }; + + expect([retiredDocument, migratedDocument]).toHaveLength(2); + }); + + it('refuses them through a WIDENED value too — the half a deletion would have missed', () => { + // Excess-property checking only reaches a FRESH literal (objectui#7654 + // measured the contrast on this very carrier): a deleted key would ride a + // widened value silently, and on a `BaseSchema` carrier even a wrong-TYPED + // value goes quiet, because the index signature defeats the weak-type check + // as well. The declared `never` makes the assignment itself ill-typed, so + // freshness stops mattering. + const raw = { type: 'chatbot' as const, messages: [], userAvatar: 'https://example.com/me.png' }; + // @ts-expect-error — `userAvatar` is RETIRED (objectui#7703), reached through a non-fresh value. + const document: ChatbotSchemaTS = raw; + expect(document.type).toBe('chatbot'); + }); + + it('leaves the two sibling faces alone on the TS channel as well', () => { + // The mirror leg above measured this on the Zod face; this is the same + // boundary on the `tsc` face. `ChatbotEnhancedSchema` and + // `ChatbotFloatingSchema` reach `ChatbotSchema` only through + // `Pick<..., ChatbotSharedKey | ...>`, and none of the six is a picked key, + // so no tombstone can travel to them: the keys stay UNDECLARED there and + // ride `BaseSchema`'s index signature, exactly as before this change. Both + // annotated assignments below are directive-FREE on purpose — if a + // tombstone ever reached either face, they would stop compiling and this + // leg would be the one that says so. + const enhanced: ChatbotEnhancedSchemaTS = { + type: 'chatbot-enhanced', + messages: [], + showAvatars: true, + }; + const floating: ChatbotFloatingSchemaTS = { + type: 'chatbot-floating', + messages: [], + height: 400, + }; + expect([enhanced.type, floating.type]).toEqual(['chatbot-enhanced', 'chatbot-floating']); + }); +}); diff --git a/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts b/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts index 9b5d684c71..bf078e91b1 100644 --- a/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts +++ b/packages/types/src/__tests__/chatbot-registration-authoring-faces-7655.test.ts @@ -54,15 +54,19 @@ * * `assertionFloatingDeclaresWhatItReads` pins the named-read instrument — * `schema.KEY` inside the `chatbot-floating` registration body — and nothing - * else. That registration also ends its `FloatingChatbot` element with a raw - * `{...props}` spread, so every authored key `SchemaRenderer` forwards reaches - * the panel's `ChatbotEnhanced` unfiltered: `processVisibility`, `surface` and - * `showAvatars` are LIVE on a `chatbot-floating` node today (measured through - * the real host with lit/dark pairs, `chatbot-enhanced`'s `toDomProps`-filtered - * spread as the control — pinned as a tripwire in `plugin-chatbot`'s - * `renderer.authoring-faces-7655.test.tsx`). The face does not declare them: - * that channel is accidental and is carded for a declare-vs-fence ruling - * (objectui#7708). + * else. That registration used to ALSO end its `FloatingChatbot` element with a + * raw `{...props}` spread, so every authored key `SchemaRenderer` forwards + * reached the panel's `ChatbotEnhanced` unfiltered, and `processVisibility`, + * `surface` and `showAvatars` were LIVE on a `chatbot-floating` node (measured + * through the real host with lit/dark pairs, `chatbot-enhanced`'s + * `toDomProps`-filtered spread as the control). The face never declared them: + * that channel was accidental, and objectui#7708 ruled FENCE — the spread is + * filtered through `toDomProps` and moved to the head of the element, so the + * named-read census is now the whole channel and the three keys are dark there. + * The tripwires that pinned them live, in `plugin-chatbot`'s + * `renderer.authoring-faces-7655.test.tsx`, flipped to dark with that fix. + * `showAvatars` was subsequently retired from `ChatbotSchema` altogether + * (objectui#7703, `__tests__/chatbot-dark-keys-retired-7703.test.ts`). */ import { describe, it, expect } from 'vitest'; diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index f920088597..f10bd8e474 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -959,7 +959,73 @@ export interface ChatToolInvocation { } /** - * Chatbot component + * Chatbot component — the authoring face of the + * `ComponentRegistry.register('chatbot', ...)` registration in + * `packages/plugin-chatbot/src/renderer.tsx` (objectui#7655). + * + * ## Six ADR-0049 retirement tombstones (objectui#7703) + * + * `loading`, `showAvatars`, `userAvatar`, `assistantAvatar`, `markdown` and + * `height` are `?: never` below, each paired with a `retirementTombstone()` + * arm on the Zod twin (`zod/complex.zod.ts`). They were declared here, + * mirrored there, and read by NO registration — a published type teaching six + * knobs that did nothing. + * + * The instrument, re-measured on this branch's base rather than inherited + * from the card: one `schema.KEY` count per `ComponentRegistry.register(...)` + * body of `renderer.tsx`, the file split at the three register calls + * (`chatbot` / `chatbot-enhanced` / `chatbot-floating`). All six read + * 0 / 0 / 0. Lit controls on the same instrument in the same pass — + * `placeholder` 1 / 1 / 1, `messages` 1 / 1 / 1, `userAvatarUrl` 1 / 1 / 1, + * `maxHeight` 1 / 1 / 0, `floatingConfig` 0 / 0 / 1, `processVisibility` + * 0 / 1 / 0 — so the zeros are readings, not a blind grep. + * + * The named-read census is now the WHOLE channel. It was not always: the + * `chatbot-floating` registration used to end its `` element + * with a raw `{...props}` spread, which handed the panel's `` + * every authored key unfiltered — and that component HAS a `showAvatars` prop, + * so the key was live there by accident. objectui#7708 ruled FENCE: the spread + * is filtered through `toDomProps` and moved to the head of the element, the + * shape the two sibling registrations already used. ⇒ `showAvatars` is a key + * the FENCE turned dark, not a key nothing ever read; the other five were live + * on no channel at any time (`loading`, `userAvatar`, `assistantAvatar`, + * `markdown` and `height` are not `ChatbotEnhancedProps` members either — + * markdown exists there only as `enableMarkdown` — so the spread had nothing + * to land them on). + * + * ⚠️ `processVisibility` is deliberately NOT part of this retirement: + * `chatbot-enhanced` reads it (0 / 1 / 0), and objectui#7655 left the member + * here exactly as it was. + * + * ## Why tombstones and not deletions — the mirror decides it here + * + * All six HAVE a Zod arm, so deleting the declaration would trade one silent + * no-op for another: `BaseSchema` is `.passthrough()` on the Zod side and + * carries a `[key: string]: any` index signature on the TS side, so an + * UNDECLARED key is not refused, it is KEPT. That is the hazard the two-prong + * discriminator leaves to the carrier — where there is no mirror there is "no + * silent-strip hazard for prong 2 to guard" (`mobile.ts`, objectui#5941 / + * #7526 / #7678: a tombstone exists to steer authors to a named live + * replacement KEY, or to keep loud a key the docs taught as working). Here + * there IS a mirror to host the refusal, and prong 1 holds by the letter for + * four of the six — `userAvatar` → `userAvatarUrl`, `assistantAvatar` → + * `assistantAvatarUrl`, `height` → `maxHeight`, `markdown` → + * `enableMarkdown` on a `chatbot-enhanced` node. Each member's own comment + * names its replacement, or says there is none. + * + * ## Why not ENFORCE, decided per key + * + * The other arm of enforce-or-remove was taken key by key and refused each + * time. `` — the component THIS registration renders + * (`plugin-chatbot/src/index.tsx`) — declares `messages`, `placeholder`, + * `onSendMessage`, `disabled`, `showTimestamp`, `userAvatarUrl`, + * `userAvatarFallback`, `assistantAvatarUrl`, `assistantAvatarFallback` and + * `maxHeight`, and not one of the six. Enforcing any of them would mean + * growing a component prop (a feature, not a retirement) or wiring a SECOND + * spelling of a key that already works — the N dialects AGENTS.md #0.1 + * forbids. Per-key argument in each member's comment. + * + * Pinned in `__tests__/chatbot-dark-keys-retired-7703.test.ts`. */ export interface ChatbotSchema extends BaseSchema { type: 'chatbot'; @@ -973,9 +1039,27 @@ export interface ChatbotSchema extends BaseSchema { */ placeholder?: string; /** - * Whether chat is loading (thinking) + * ADR-0049 RETIREMENT TOMBSTONE — `loading` (objectui#7703). See + * {@link ChatbotSchema} for the census, the instrument and the route. + * + * Chat progress is RUNTIME state, not authorable metadata. The `chatbot` + * registration derives it from `useObjectChat` as `isLoading` and spends it + * on `disabled={hostDisabled || isLoading}`; `` declares no + * `loading` prop for an authored value to land on. So `loading: true` never + * showed a spinner and `loading: false` never hid one. + * + * ENFORCE was refused here on the classification, not only on the missing + * prop: an authored boolean would be a static declaration of a value the + * chat runtime owns and updates per token — it would fight the runtime, not + * configure it (AGENTS.md #8: state that a refresh must survive never lives + * in metadata that cannot see the refresh). + * + * There is NO replacement key: nothing authorable selects this. Delete it. + * + * @deprecated Not part of this contract — the value was inert. Loading is + * derived from the chat runtime. */ - loading?: boolean; + loading?: never; /** * RETIRED (objectui#6124, ADR-0049) — JSON has no function value, and the * `chatbot` renderer wires its own `handleSendMessage` and `toDomProps` drops @@ -985,32 +1069,111 @@ export interface ChatbotSchema extends BaseSchema { */ onSendMessage?: never; /** - * Show avatars - * @default true + * ADR-0049 RETIREMENT TOMBSTONE — `showAvatars` (objectui#7703). See + * {@link ChatbotSchema} for the census, the instrument and the route. + * + * ⭐ The one key of the six whose provenance is a FENCE, not an absence. + * `` really does have a `showAvatars` prop, and until + * objectui#7708 the `chatbot-floating` registration's raw trailing + * `{...props}` spread delivered an authored value straight to it — measured + * live through the real host. That ruling was FENCE: the spread now goes + * through `toDomProps` at the head of the element, so the key is dark on all + * three registrations by ruling. It was never live on a `chatbot` node: no + * registration forwards it by name, and this one renders ``, which + * has no such prop — the card's own counter-example, re-confirmed here. + * + * ENFORCE was refused: on a `chatbot` node it has no target to forward to, + * and re-declaring it on the two faces that CAN reach `` + * would re-open by declaration exactly the channel objectui#7708 closed by + * fence, one card earlier. `@default true` was published prose only — the + * plain `` renders an avatar beside every message unconditionally, + * with no gate, and ``'s own prop defaults to `false`. + * + * There is no replacement KEY. The avatar IMAGES are `userAvatarUrl` / + * `assistantAvatarUrl` and their `*Fallback` siblings, which all three + * registrations read; delete this one. + * + * @deprecated Not part of this contract — the value was inert on a `chatbot` + * node and is dark everywhere since objectui#7708. */ - showAvatars?: boolean; + showAvatars?: never; /** - * User avatar + * ADR-0049 RETIREMENT TOMBSTONE — `userAvatar` (objectui#7703). See + * {@link ChatbotSchema} for the census, the instrument and the route. + * + * Write **`userAvatarUrl`** (with `userAvatarFallback` for the text shown + * while it loads or fails) — the live spelling, read 1 / 1 / 1 and declared + * on all three faces through {@link ChatbotSharedKey}. `userAvatar` has zero + * word-boundary hits anywhere in `packages/plugin-chatbot/src`: not a stale + * read, a spelling no code ever had. + * + * ENFORCE was refused: wiring it would give one avatar image TWO authorable + * spellings, the second de-facto contract AGENTS.md #0.1 exists to stop. + * + * @deprecated Not part of this contract — the value was inert. Write + * `userAvatarUrl`. */ - userAvatar?: string; + userAvatar?: never; /** - * Assistant avatar + * ADR-0049 RETIREMENT TOMBSTONE — `assistantAvatar` (objectui#7703). See + * {@link ChatbotSchema} for the census, the instrument and the route. + * + * Write **`assistantAvatarUrl`** (with `assistantAvatarFallback`) — the live + * spelling, read 1 / 1 / 1 and declared on all three faces through + * {@link ChatbotSharedKey}. `assistantAvatar` has zero word-boundary hits + * anywhere in `packages/plugin-chatbot/src`. + * + * ENFORCE was refused for the same reason as `userAvatar`: a second + * authorable spelling of one image is a second contract. + * + * @deprecated Not part of this contract — the value was inert. Write + * `assistantAvatarUrl`. */ - assistantAvatar?: string; + assistantAvatar?: never; /** - * Enable markdown rendering - * @default true + * ADR-0049 RETIREMENT TOMBSTONE — `markdown` (objectui#7703). See + * {@link ChatbotSchema} for the census, the instrument and the route. + * + * The `chatbot` node renders ``, which has no markdown path at all: + * it prints message content as text, so there was never a renderer for this + * switch to reach. Markdown is a `chatbot-enhanced` / `chatbot-floating` + * capability, where the live spelling is **`enableMarkdown`** on + * {@link ChatbotEnhancedSchema} / {@link ChatbotFloatingSchema} (read + * 0 / 1 / 1). `markdown` is not a `ChatbotEnhancedProps` member either. + * + * ENFORCE was refused twice over: on this node it would mean building a + * markdown renderer into `` (a feature), and on the other two it + * would be a second spelling of `enableMarkdown`. `@default true` was + * published prose the plain component never honoured. + * + * @deprecated Not part of this contract — the value was inert. Author + * `type: 'chatbot-enhanced'` with `enableMarkdown` instead. */ - markdown?: boolean; + markdown?: never; /** * How much agent reasoning/tool detail to show. * @default 'summary' */ processVisibility?: 'hidden' | 'summary' | 'debug'; /** - * Chat height + * ADR-0049 RETIREMENT TOMBSTONE — `height` (objectui#7703). See + * {@link ChatbotSchema} for the census, the instrument and the route. + * + * Write **`maxHeight`** (a CSS length string, default `'500px'`) — the live + * spelling, read 1 / 1 / 0 and forwarded to ``'s own `maxHeight` + * prop. On a `chatbot-floating` node neither key applies: size that panel + * with `floatingConfig.panelHeight`, a NUMBER of pixels, which is what the + * panel reads. + * + * ENFORCE was refused: `` has no `height` prop, and adding one + * beside the `maxHeight` it already forwards would publish two authorable + * spellings for one dimension — with a `string | number` union that does not + * even match the live key's `string`. + * + * @deprecated Not part of this contract — the value was inert. Write + * `maxHeight`, or `floatingConfig.panelHeight` on a floating node. */ - height?: string | number; + height?: never; // --- AI / service-ai integration fields --- diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 4bb6bcf1b8..6741fcd958 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -474,14 +474,55 @@ export const ChatbotSchema = BaseSchema.extend({ type: z.literal('chatbot'), messages: z.array(ChatMessageSchema).describe('Chat messages'), placeholder: z.string().optional().describe('Input placeholder'), - loading: z.boolean().optional().describe('Whether chat is loading'), + // --- ADR-0049 retirement tombstones (objectui#7703) --------------------- + // + // Six keys this twin mirrored and no `plugin-chatbot` registration read — + // `loading`, `showAvatars`, `userAvatar`, `assistantAvatar`, `markdown` and + // `height` (that last one below, after `processVisibility`, which is NOT + // part of this retirement: `chatbot-enhanced` reads it). The census, the lit + // controls and the per-key enforce-or-remove argument live on the TS twin's + // `ChatbotSchema` docblock (`../complex.ts`); the pins are in + // `../__tests__/chatbot-dark-keys-retired-7703.test.ts`. + // + // Deleting these arms was the wrong route and is why they stay declared: + // `BaseSchema` is `.passthrough()`, so an undeclared key is not refused, it + // is KEPT — the same silent acceptance the retirement exists to close. + loading: retirementTombstone( + 'RETIRED (objectui#7703, ADR-0049) — never read: chat progress is runtime state the chat runtime owns ' + + '(the registration derives it from `useObjectChat` as `isLoading`), and `` declares no `loading` ' + + 'prop for an authored value to land on. There is no authored spelling that sets it; delete the key.', + ), onSendMessage: handlerKeyRefusal('onSendMessage', 'retired', 'Send message handler'), - showAvatars: z.boolean().optional().describe('Show user avatars'), - userAvatar: z.string().optional().describe('User avatar URL'), - assistantAvatar: z.string().optional().describe('Assistant avatar URL'), - markdown: z.boolean().optional().describe('Enable markdown rendering'), + showAvatars: retirementTombstone( + 'RETIRED (objectui#7703, ADR-0049) — no registration reads or forwards this key by name, and a `chatbot` ' + + 'node renders ``, which has no `showAvatars` prop; the one channel that did deliver it — the ' + + "`chatbot-floating` registration's unfiltered props spread — was fenced by objectui#7708. Delete the key: " + + 'a `chatbot` node already renders an avatar beside every message, and the images are `userAvatarUrl` / ' + + '`assistantAvatarUrl` with their `userAvatarFallback` / `assistantAvatarFallback` siblings.', + ), + userAvatar: retirementTombstone( + 'RETIRED (objectui#7703, ADR-0049) — never read: this spelling has zero hits anywhere in ' + + '`packages/plugin-chatbot`. Write `userAvatarUrl` instead (with `userAvatarFallback` for the text shown ' + + 'while the image loads or fails), the key all three chatbot registrations read.', + ), + assistantAvatar: retirementTombstone( + 'RETIRED (objectui#7703, ADR-0049) — never read: this spelling has zero hits anywhere in ' + + '`packages/plugin-chatbot`. Write `assistantAvatarUrl` instead (with `assistantAvatarFallback` for the ' + + 'text shown while the image loads or fails), the key all three chatbot registrations read.', + ), + markdown: retirementTombstone( + 'RETIRED (objectui#7703, ADR-0049) — never read: a `chatbot` node renders ``, which prints message ' + + 'content as text and has no markdown path for this switch to reach. Author `type: "chatbot-enhanced"` ' + + '(or `"chatbot-floating"`) with `enableMarkdown` instead — markdown is those nodes\' capability, and ' + + '`enableMarkdown` is the key their registrations read.', + ), processVisibility: z.enum(['hidden', 'summary', 'debug']).optional().describe('How much agent reasoning/tool detail to show'), - height: z.union([z.string(), z.number()]).optional().describe('Chatbot height'), + height: retirementTombstone( + 'RETIRED (objectui#7703, ADR-0049) — never read: `` has no `height` prop. Write `maxHeight` ' + + 'instead (a CSS length string, default "500px"), the key the `chatbot` and `chatbot-enhanced` ' + + 'registrations forward; size a `chatbot-floating` panel with `floatingConfig.panelHeight`, a number of ' + + 'pixels, which is what that panel reads.', + ), api: z.string().optional().describe('Backend API endpoint for streaming chat'), conversationId: z.string().optional().describe('Conversation ID for multi-turn context'), systemPrompt: z.string().optional().describe('System prompt for assistant behavior'),