From 58e734cce234b268e80890313b9b42f5d4bc7ce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 01:40:47 +0000 Subject: [PATCH 1/2] feat(types,app-shell): carry the tool approval envelope through hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of `ChatToolInvocation`'s ten declared `state` values — `approval-requested`, `approval-responded`, `output-denied` — are states the AI SDK's own tool-part union cannot express without an `{ id, approved?, reason?, isAutomatic?, signature? }` envelope. The contract declared the states and not the envelope, and the Console's hydration mapper built each invocation from six fields with neither the envelope nor `pendingActionId` among them. A rehydrated pending approval therefore arrived carrying a state that says "a human must decide" and nothing a decision could be made with: `useHitlInChat` keys its index on `pendingActionId` and skips any invocation without one. Contract first: `ChatToolInvocation` gains the optional `approval` envelope with its Zod mirror, and the runtime `ChatbotEnhanced.ChatToolInvocation` mirrors it. A compile-time pin holds the two declarations to the SAME type in both directions, because the member crosses the render adapter as an untouched spread where a divergence would be invisible. `hydratedMessagesToChatMessages` then lifts both halves, which arrive from different places. The SDK envelope is persisted ON THE PART and is narrowed to its declared shape rather than cast — an `approval` with no usable `id` is refused, not passed through. `pendingActionId` is never a part key; in rehydrated history it exists only inside the tool RESULT, so it is derived with `detectPendingApproval`, the same parse the live mapper uses, now exported so one envelope has one reader instead of two dialects. Nothing is narrowed. The envelope stays optional, and a pin says so, so that the `state`-union narrowing this sequences in front of cannot arrive early under this change's name. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- .../8442-chat-tool-approval-envelope.md | 45 +++++ .../app-shell/src/console/ai/AiChatPage.tsx | 41 ++++ .../ai/__tests__/AiChatPage.hydration.test.ts | 181 ++++++++++++++++++ .../plugin-chatbot/src/ChatbotEnhanced.tsx | 18 ++ .../__tests__/chat-message-contract.test.ts | 50 +++++ packages/plugin-chatbot/src/index.tsx | 1 + packages/plugin-chatbot/src/mapMessages.ts | 13 +- .../chat-tool-approval-envelope-8442.test.ts | 117 +++++++++++ packages/types/src/complex.ts | 28 +++ packages/types/src/zod/complex.zod.ts | 14 ++ 10 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 .changeset/8442-chat-tool-approval-envelope.md create mode 100644 packages/types/src/__tests__/chat-tool-approval-envelope-8442.test.ts diff --git a/.changeset/8442-chat-tool-approval-envelope.md b/.changeset/8442-chat-tool-approval-envelope.md new file mode 100644 index 0000000000..ca865508ee --- /dev/null +++ b/.changeset/8442-chat-tool-approval-envelope.md @@ -0,0 +1,45 @@ +--- +'@object-ui/types': patch +'@object-ui/plugin-chatbot': patch +'@object-ui/app-shell': patch +--- + +`ChatToolInvocation` gains an optional `approval` envelope, and the Console's +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 +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 +on `pendingActionId` and skips any invocation without one. + +The two halves arrive from different places and are lifted separately. The SDK +envelope is persisted ON THE PART and is narrowed to its declared shape rather +than cast (an `approval` with no usable `id` is refused, not passed through). +`pendingActionId` is never a part key — in rehydrated history it exists only +inside the tool RESULT — so it is derived with `detectPendingApproval`, the same +parse the live mapper uses, now exported from `@object-ui/plugin-chatbot` so the +two paths cannot disagree about one envelope rather than growing a second +dialect of it. + +**`patch`, and the reason is the chain's sequencing, not the size of the diff.** +The lane's test — does existing stored data render differently — answers no: the +member is optional, every value that parsed before still parses, and no chip, +card or affordance changes for data that does not carry an envelope. The +counter-reading is real and worth naming: two published capabilities DO land here +(the type member, and the `detectPendingApproval` export), and this repo's own +recent precedent bumped `minor` for "a capability a consumer can newly rely on". +It still loses. The ruling on objectui#8426 reserves the `minor` + `**BREAKING**` +carrier for the NARROWING half — the authoring `state` union shedding the three +runtime-only approval states, and the `UseObjectChatOptions.initialMessages` +narrowing. This card is deliberately the additive half that ships first; spending +that carrier here would blur the one signal the chain uses to sequence itself. + +⛔ 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. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index 75ec2dc38f..030288650a 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -77,6 +77,7 @@ import { detectProposedChanges, detectReplayOutcome, detectBuiltAppPackage, + detectPendingApproval, buildProgressFromDraftReview, // The authoring/honest -> runtime message seam (objectui#4399 / PR #4416), // consumed here one hop up from the plugin's own renderers (objectui#4437). @@ -133,6 +134,31 @@ function partString(part: HydratedUIMessagePart, key: string): string | undefine return typeof value === 'string' && value.length > 0 ? value : undefined; } +/** + * The AI SDK approval envelope as the server persisted it on a tool part. + * + * `HydratedUIMessagePart` is an open record, so the envelope is REACHABLE here + * but unverified. This narrows it to the declared shape and drops what does not + * match rather than asserting a cast: `id` is the envelope's only required + * member, so a value without a usable one is not an envelope at all. + */ +function partApproval( + part: HydratedUIMessagePart, +): NonNullable | undefined { + const raw = part.approval; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const envelope = raw as Record; + const id = envelope.id; + if (typeof id !== 'string' || id.length === 0) return undefined; + return { + id, + ...(typeof envelope.approved === 'boolean' ? { approved: envelope.approved } : {}), + ...(typeof envelope.reason === 'string' ? { reason: envelope.reason } : {}), + ...(typeof envelope.isAutomatic === 'boolean' ? { isAutomatic: envelope.isAutomatic } : {}), + ...(typeof envelope.signature === 'string' ? { signature: envelope.signature } : {}), + }; +} + function partToolState(part: HydratedUIMessagePart): ChatbotEnhancedToolInvocation['state'] | undefined { const state = partString(part, 'state'); switch (state) { @@ -203,11 +229,26 @@ export function hydratedMessagesToChatMessages(messages: HydratedUIMessage[]): C // draft card (a rolled-back publish would get a live Publish button). // Mirrors the live mapper's suppression exactly. const replayOutcome = detectReplayOutcome(toolCallId, result); + // objectui#8442 — the two halves of an actionable approval, dropped + // here until now, and they arrive from DIFFERENT places: + // * the AI SDK's `approval` envelope rides the persisted PART (the + // SDK's tool-part union requires it alongside the three approval + // states this mapper already carries through); + // * the ObjectStack `pendingActionId` rides the tool RESULT and is + // never persisted as a part key, so it is derived with the same + // detector the live mapper uses — one parse, so the hydrated path + // cannot disagree with the live one about the same envelope. + // Without the id, `useHitlInChat` never indexes the invocation and the + // operator's Approve / Reject has nothing to call. + const approval = partApproval(part); + const pendingActionId = detectPendingApproval(result)?.pendingActionId; toolInvocations.push({ toolCallId, toolName, ...(state ? { state } : {}), ...(result !== undefined ? { result } : {}), + ...(approval ? { approval } : {}), + ...(pendingActionId ? { pendingActionId } : {}), ...(draftReview && !replayOutcome ? { draftReview } : {}), ...(proposedPlan ? { proposedPlan } : {}), ...(builderHandoff ? { builderHandoff } : {}), diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.hydration.test.ts b/packages/app-shell/src/console/ai/__tests__/AiChatPage.hydration.test.ts index 2e1bee4e9d..a77a815cc5 100644 --- a/packages/app-shell/src/console/ai/__tests__/AiChatPage.hydration.test.ts +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.hydration.test.ts @@ -161,3 +161,184 @@ describe('shared-conversation render — flat ai_messages rows → proposed-plan }); }); }); + +describe('AiChatPage hydration — the approval envelope and the pending-action id (objectui#8442)', () => { + // The mapper used to build an invocation from six things and neither of these + // 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. + // + // The two halves arrive from different places, which is why they are pinned + // separately: the AI SDK's `approval` envelope is persisted ON THE PART, and + // the ObjectStack `pendingActionId` lives only inside the tool RESULT — it is + // never a part key — so it is derived by the same detector the live mapper + // (`mapMessages.extractToolInvocations`) uses. + + it('carries the AI SDK approval envelope persisted on the part', () => { + const [msg] = hydratedMessagesToChatMessages( + assistantWith([ + { + type: 'tool-action_delete_task', + toolCallId: 't1', + toolName: 'action_delete_task', + state: 'approval-requested', + approval: { id: 'apr_1', isAutomatic: false }, + }, + ]), + ); + expect(msg.toolInvocations?.[0]).toMatchObject({ + state: 'approval-requested', + approval: { id: 'apr_1', isAutomatic: false }, + }); + }); + + it('keeps every declared member of a full envelope and drops nothing declared', () => { + const [msg] = hydratedMessagesToChatMessages( + assistantWith([ + { + type: 'tool-action_delete_task', + toolCallId: 't1', + toolName: 'action_delete_task', + state: 'approval-responded', + approval: { + id: 'apr_2', + approved: true, + reason: 'operator confirmed', + isAutomatic: false, + signature: 'sig_abc', + }, + }, + ]), + ); + expect(msg.toolInvocations?.[0]?.approval).toEqual({ + id: 'apr_2', + approved: true, + reason: 'operator confirmed', + isAutomatic: false, + signature: 'sig_abc', + }); + }); + + it('REFUSES an envelope with no usable id rather than passing the shape through', () => { + // `HydratedUIMessagePart` is an open record: whatever the server wrote is + // reachable and UNVERIFIED. An `approval` without an `id` cannot be replied + // on, so carrying it would hand the UI a half-envelope to guess at. + const [msg] = hydratedMessagesToChatMessages( + assistantWith([ + { type: 'tool-x', toolCallId: 't1', toolName: 'x', approval: { approved: true } }, + { type: 'tool-y', toolCallId: 't2', toolName: 'y', approval: 'apr_3' }, + { type: 'tool-z', toolCallId: 't3', toolName: 'z', approval: { id: '' } }, + ]), + ); + expect(msg.toolInvocations?.map((t) => t.approval)).toEqual([ + undefined, + undefined, + undefined, + ]); + }); + + it('lifts pendingActionId out of the persisted HITL result envelope', () => { + const [msg] = hydratedMessagesToChatMessages( + assistantWith([ + { + type: 'tool-call', + toolCallId: 't1', + toolName: 'action_delete_task', + output: { status: 'pending_approval', pendingActionId: 'pa_42' }, + }, + ]), + ); + // Without this the invocation reaches `useHitlInChat` un-indexed — the hook + // keys its map on `pendingActionId` and skips any invocation without one, + // so Approve / Reject has no id to POST. + expect(msg.toolInvocations?.[0]?.pendingActionId).toBe('pa_42'); + }); + + it('lifts it through the persisted {type:text,value} wrapper too', () => { + // The shape the server really persists for a tool result on this path. + const [msg] = hydratedMessagesToChatMessages( + assistantWith([ + { + type: 'tool-call', + toolCallId: 't1', + toolName: 'action_delete_task', + output: { + type: 'text', + value: JSON.stringify({ status: 'pending_approval', pendingActionId: 'pa_43' }), + }, + }, + ]), + ); + expect(msg.toolInvocations?.[0]?.pendingActionId).toBe('pa_43'); + }); + + it('leaves pendingActionId absent when the result is not a HITL proposal', () => { + const [msg] = hydratedMessagesToChatMessages( + assistantWith([ + { + type: 'tool-call', + toolCallId: 't1', + toolName: 'verify_build', + output: { status: 'ok' }, + }, + ]), + ); + expect(msg.toolInvocations?.[0]?.pendingActionId).toBeUndefined(); + expect('pendingActionId' in (msg.toolInvocations?.[0] ?? {})).toBe(false); + }); + + it('lifts it through the full ModelMessage round trip (call row + separate tool-result row)', () => { + // The server-backed shape: the CALL and its RESULT are different rows, and + // `toUIMessages` merges the result onto the call part. + const chat = hydratedMessagesToChatMessages( + toUIMessages( + aiMessageRowsToServerMessages([ + { id: 'u1', role: 'user', content: 'delete the task' }, + { + id: 'a1', + role: 'assistant', + content: 'This needs your approval.', + tool_calls: JSON.stringify([ + { + type: 'tool-call', + toolCallId: 'c1', + toolName: 'action_delete_task', + input: {}, + }, + ]), + }, + { + id: 't1', + role: 'tool', + tool_call_id: 'c1', + content: JSON.stringify([ + { + type: 'tool-result', + toolCallId: 'c1', + toolName: 'action_delete_task', + output: { + type: 'text', + value: JSON.stringify({ + status: 'pending_approval', + pendingActionId: 'pa_44', + }), + }, + }, + ]), + }, + ]), + ), + ); + const tool = chat[1]?.toolInvocations?.[0]; + expect(tool?.pendingActionId).toBe('pa_44'); + // ⚠️ MEASURED, and recorded here rather than asserted as desirable: on THIS + // sub-path the merge step rewrites the part's state to `output-available` + // whenever a result is merged, so the state never reaches this mapper as + // `approval-requested`. The id is what `useHitlInChat` indexes on, so the + // hook now sees this invocation either way — but the awaiting-approval CARD + // is gated on the state, so it does not render from this sub-path. That + // state rewrite is out of this card's scope (it lives in the hydration + // pipeline, not in this mapper); a card that fixes it turns this line red, + // which is the point of pinning the reading instead of describing it. + expect(tool?.state).toBe('output-available'); + }); +}); diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index b35dead958..a380066420 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -234,6 +234,24 @@ export interface ChatToolInvocation { | 'output-available' | 'output-error' | 'output-denied'; + /** + * AI SDK v6 approval envelope, mirrored from `@object-ui/types`' + * `ChatToolInvocation` (objectui#8442). The SDK requires it alongside the + * three approval states; it is what carries a rehydrated pending approval's + * identity and, once decided, the decision itself. + * + * ⚠️ Distinct from `pendingActionId` below and NOT a replacement for it: + * this is the SDK's own request id, while `pendingActionId` is the + * ObjectStack `pending_actions` row the REST approve/reject endpoints take. + * The approval affordance is wired on the latter. + */ + approval?: { + id: string; + approved?: boolean; + reason?: string; + isAutomatic?: boolean; + signature?: string; + }; /** * ObjectStack HITL extension. When the framework's `action-tools.ts` * proposes a destructive action that requires human approval, the tool diff --git a/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts b/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts index 3f507eb26c..dbdb1ee8bf 100644 --- a/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts +++ b/packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts @@ -471,3 +471,53 @@ describe('the barrel no longer declares a message shape of its own', () => { ); }); }); + +describe('the approval envelope is MIRRORED, not merely present on both sides (objectui#8442)', () => { + it('is pinned at compile time', () => { + // The word "mirrored" is the whole requirement here. Two independent + // declarations that happen to share a NAME would satisfy a `Has<>` probe + // while disagreeing about what they hold — and this member crosses the + // adapter as an untouched spread (`toRuntimeToolInvocation` destructures + // `state` and passes everything else through), so a divergence would be + // invisible at the seam and surface as a render-side read of a member the + // producer never wrote that way. + type AuthoredTool = NonNullable[number]; + type EnhancedTool = NonNullable[number]; + + type _AuthoredHasApproval = Assert>; + type _EnhancedHasApproval = Assert>; + + // Both directions, so neither side may widen or narrow alone. + type _ApprovalIsTheSameType = Assert< + Equal + >; + + // And it is not an erased slot pretending to agree. + type _NotAny = Assert>, false>>; + type _NotUnknown = Assert>, false>>; + + // `id` is required inside the envelope; everything else is optional. This + // is what makes the envelope repliable rather than decorative. + type Envelope = NonNullable; + type _IdRequired = Assert>; + type _IdIsNotOptional = Assert>, false>>; + + // It reaches the seam's input and the hook's output for free — both derive + // from the authoring declaration — so a future narrowing of either says + // WHICH capability it dropped. + type SeamTool = NonNullable[number]; + type HookTool = NonNullable[number]; + type _SeamToolHasApproval = Assert>; + type _HookToolHasApproval = Assert>; + + // ⚠️ The envelope is OPTIONAL on purpose: this card ships the widening, and + // objectui#8426 owns the narrowing that pairs it with the three approval + // states. Pinning that the member is NOT required keeps a later "tidy-up" + // from shipping that break under this card's name. + type _ApprovalIsOptional = Assert< + Equal extends AuthoredTool ? true : false, true> + >; + + expect(true).toBe(true); + }); +}); diff --git a/packages/plugin-chatbot/src/index.tsx b/packages/plugin-chatbot/src/index.tsx index 8283606f5a..2505c35caf 100644 --- a/packages/plugin-chatbot/src/index.tsx +++ b/packages/plugin-chatbot/src/index.tsx @@ -363,6 +363,7 @@ export { detectReplayOutcome, detectAuthoringVerdict, detectBuiltAppPackage, + detectPendingApproval, buildProgressFromDraftReview, } from './mapMessages'; export type { diff --git a/packages/plugin-chatbot/src/mapMessages.ts b/packages/plugin-chatbot/src/mapMessages.ts index 287af55201..4e202c7da7 100644 --- a/packages/plugin-chatbot/src/mapMessages.ts +++ b/packages/plugin-chatbot/src/mapMessages.ts @@ -157,7 +157,18 @@ export function parseResultEnvelope(result: unknown): Record | return fallback ?? wrapperFallback; } -function detectPendingApproval( +/** + * The ObjectStack HITL envelope a tool result carries when the framework's + * `action-tools.ts` proposes a destructive action: + * `{ status: 'pending_approval', pendingActionId: 'pa_…', … }`. + * + * Exported (objectui#8442) so the app-shell's HYDRATION mapper derives the id + * from the same parse the live mapper uses. The id is never persisted as a part + * key — it exists in rehydrated history only inside this envelope — so a second + * hand-rolled reader there would be a second dialect of one contract, which is + * exactly what AGENTS.md Commandment #0.1 refuses. + */ +export function detectPendingApproval( result: unknown, ): { pendingActionId: string; raw: Record } | undefined { const obj = parseResultEnvelope(result); diff --git a/packages/types/src/__tests__/chat-tool-approval-envelope-8442.test.ts b/packages/types/src/__tests__/chat-tool-approval-envelope-8442.test.ts new file mode 100644 index 0000000000..a6374d5d24 --- /dev/null +++ b/packages/types/src/__tests__/chat-tool-approval-envelope-8442.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `ChatToolInvocation.approval` — declared, mirrored, and OPTIONAL (objectui#8442). + * + * ## What is pinned, and why each pin is the one that can fail + * + * The member exists because three of the ten declared `state` values — + * `approval-requested`, `approval-responded`, `output-denied` — are states the + * AI SDK's own tool-part union cannot express WITHOUT this envelope. The + * hydration mapper in `@object-ui/app-shell` carried those states through while + * dropping the envelope, so the state survived the hop and the data that makes + * it actionable did not. + * + * 1. RETENTION, not `success`. `ChatToolInvocationSchema` is a plain `z.object` + * — STRIP mode — so `safeParse` is green for an undeclared key too; it just + * deletes it. A `.success` assertion therefore cannot tell "declared" from + * "silently dropped", and the control below demonstrates exactly that on the + * same instrument. Reading the key back OUT of `data` is the assertion that + * fails when the mirror has not been widened. + * 2. VALUE judgment, separately. `id` is the envelope's only required member, + * so a payload missing it must be REFUSED rather than stripped-and-green. + * 3. OPTIONALITY. This card ships the widening half only; pairing the envelope + * with the three states that require it is objectui#8426's narrowing. An + * invocation that declares one of those states and carries no envelope still + * parses today, and that is a deliberate statement, not an omission. + * + * The TS-side/Zod-side KEY parity is not restated here: `zod-mirror-parity.test.ts` + * registers this pair and derives its key census from the mirror's own `.shape`, + * so a member added on one side only fails there with no list to maintain. + */ + +import { describe, it, expect } from 'vitest'; +import { ChatToolInvocationSchema } from '../zod/complex.zod'; +import type { ChatToolInvocation } from '../complex'; + +const ENVELOPE = { + id: 'apr_8442', + approved: true, + reason: 'operator confirmed', + isAutomatic: false, + signature: 'sig_abc', +} as const; + +function invocation(extra: Record = {}) { + return { + toolCallId: 'tc-1', + toolName: 'action_delete_task', + state: 'approval-requested', + ...extra, + }; +} + +describe('ChatToolInvocation.approval — the mirror declares it', () => { + it('KEEPS a full envelope on the parsed output (strip mode: an undeclared key would be gone)', () => { + const parsed = ChatToolInvocationSchema.safeParse(invocation({ approval: ENVELOPE })); + expect(parsed.success).toBe(true); + // The load-bearing line. Before the mirror was widened this read `undefined` + // while `success` stayed `true`. + expect(parsed.success && parsed.data.approval).toEqual(ENVELOPE); + }); + + it('CONTROL — an undeclared sibling key is stripped while `success` stays true', () => { + // Same instrument, same fixture shape, a key nothing declares. This is what + // the assertion above would read if `approval` were still unmirrored, and it + // is why `success` alone proves nothing here. + const parsed = ChatToolInvocationSchema.safeParse( + invocation({ approvalEnvelope: ENVELOPE }), + ); + expect(parsed.success).toBe(true); + expect(parsed.success && 'approvalEnvelope' in parsed.data).toBe(false); + }); + + it('keeps a MINIMAL envelope — only `id` is required', () => { + const parsed = ChatToolInvocationSchema.safeParse( + invocation({ approval: { id: 'apr_min' } }), + ); + expect(parsed.success && parsed.data.approval).toEqual({ id: 'apr_min' }); + }); + + it('REFUSES an envelope without a usable `id` — a value judgment, not a strip', () => { + const noId = ChatToolInvocationSchema.safeParse(invocation({ approval: { approved: true } })); + expect(noId.success).toBe(false); + const wrongType = ChatToolInvocationSchema.safeParse(invocation({ approval: { id: 42 } })); + expect(wrongType.success).toBe(false); + }); + + it('is OPTIONAL — the widening half ships alone, so an approval state with no envelope still parses', () => { + // objectui#8426 owns the narrowing that makes this pair mandatory. Until it + // lands, refusing here would be this card shipping that card's break. + for (const state of ['approval-requested', 'approval-responded', 'output-denied'] as const) { + const parsed = ChatToolInvocationSchema.safeParse(invocation({ state })); + expect(parsed.success).toBe(true); + expect(parsed.success && parsed.data.approval).toBeUndefined(); + } + }); +}); + +describe('ChatToolInvocation.approval — the declaration admits what the mirror keeps', () => { + it('type-checks the full and the minimal envelope', () => { + const full: ChatToolInvocation = { + toolCallId: 'tc-1', + toolName: 'action_delete_task', + state: 'approval-requested', + approval: { ...ENVELOPE }, + }; + const minimal: ChatToolInvocation = { + toolCallId: 'tc-2', + toolName: 'action_delete_task', + approval: { id: 'apr_min' }, + }; + // `id` is non-optional on the declared envelope; the two values above are + // the compile-time statement and these reads keep them from being elided. + expect(full.approval?.id).toBe('apr_8442'); + expect(minimal.approval?.approved).toBeUndefined(); + }); +}); diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index ee3d93112c..c72c042af1 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -831,6 +831,34 @@ export interface ChatToolInvocation { | 'output-available' | 'output-error' | 'output-denied'; + /** + * AI SDK v6 approval envelope — the data a human decision on this tool call + * is carried by, and the piece that makes the three approval states + * ACTIONABLE rather than merely displayable (objectui#8442). + * + * The SDK's own tool-part union makes this envelope REQUIRED alongside + * `approval-requested`, `approval-responded` and `output-denied`: a value + * that claims one of those states without it is not a constructible SDK + * part. Carrying it here is what lets a mapper hand a rehydrated pending + * approval to a chat surface without the surface re-parsing the tool result. + * + * Optional because the other seven states never carry one. Pairing the + * envelope with the states that require it is objectui#8426's narrowing of + * the `state` union above, deliberately NOT done here — this member is + * purely additive, so nothing an author writes today stops parsing. + */ + approval?: { + /** Approval request id — the key a decision is replied on. */ + id: string; + /** The decision, once made. Absent while the request is outstanding. */ + approved?: boolean; + /** Free-text reason supplied with the decision. */ + reason?: string; + /** True when policy decided without asking a human. */ + isAutomatic?: boolean; + /** Signature over the approval, when the transport signs decisions. */ + signature?: string; + }; } /** diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 41a9ec1e03..ece36965ff 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -537,6 +537,20 @@ export const ChatToolInvocationSchema = z.object({ ]) .optional() .describe('Tool invocation state'), + // Mirrors `ChatToolInvocation.approval` in ../complex.ts. The AI SDK v6 + // tool-part union requires this envelope alongside the three approval + // states; the pairing itself is objectui#8426's narrowing and is NOT + // enforced here, so this arm stays independently optional (objectui#8442). + approval: z + .object({ + id: z.string().describe('Approval request id — the key a decision is replied on'), + approved: z.boolean().optional().describe('The decision, once made'), + reason: z.string().optional().describe('Free-text reason supplied with the decision'), + isAutomatic: z.boolean().optional().describe('True when policy decided without a human'), + signature: z.string().optional().describe('Signature over the approval, when signed'), + }) + .optional() + .describe('AI SDK approval envelope for a tool call awaiting or carrying a human decision'), }); export const ChatMessageSourceSchema = z.object({ From c09d16a5cb57612c5e25933267a79daf7c1f8178 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 02:13:49 +0000 Subject: [PATCH 2/2] docs(changeset): declare the approval envelope as `minor`, per the repo's written precedent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PM ruling on the bump the seat flagged rather than assumed. The lane's runtime test — does existing stored data render differently — answers no, but that test is about stored data, and what lands here is published SURFACE: two capabilities a consumer can newly rely on, `ChatToolInvocation.approval` and the `detectPendingApproval` export. `.changeset/8214-chatbot-anypart-state-widen.md` settles that case in this repo in those words. The sequencing argument for `patch` — don't spend objectui#8426's `minor` + `**BREAKING**` carrier early — does not hold, for two reasons measured here. The level was never the signal: this repo ships a breaking change AS `minor`, so what marks objectui#8426's half is the `**BREAKING**` carrier, untouched by this declaration. And `.changeset/config.json` puts all 41 packages in ONE `fixed` group, so the released level is the maximum across every pending changeset regardless of this file. `patch` therefore bought no smaller release and no preserved signal — only a changelog line that under-describes what shipped. Prose and frontmatter only; no source file is touched. Ref: objectui#8442 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ Co-authored-by: Claude --- .../8442-chat-tool-approval-envelope.md | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/.changeset/8442-chat-tool-approval-envelope.md b/.changeset/8442-chat-tool-approval-envelope.md index ca865508ee..798816aa53 100644 --- a/.changeset/8442-chat-tool-approval-envelope.md +++ b/.changeset/8442-chat-tool-approval-envelope.md @@ -1,7 +1,7 @@ --- -'@object-ui/types': patch -'@object-ui/plugin-chatbot': patch -'@object-ui/app-shell': patch +'@object-ui/types': minor +'@object-ui/plugin-chatbot': minor +'@object-ui/app-shell': minor --- `ChatToolInvocation` gains an optional `approval` envelope, and the Console's @@ -26,18 +26,27 @@ parse the live mapper uses, now exported from `@object-ui/plugin-chatbot` so the two paths cannot disagree about one envelope rather than growing a second dialect of it. -**`patch`, and the reason is the chain's sequencing, not the size of the diff.** -The lane's test — does existing stored data render differently — answers no: the -member is optional, every value that parsed before still parses, and no chip, -card or affordance changes for data that does not carry an envelope. The -counter-reading is real and worth naming: two published capabilities DO land here -(the type member, and the `detectPendingApproval` export), and this repo's own -recent precedent bumped `minor` for "a capability a consumer can newly rely on". -It still loses. The ruling on objectui#8426 reserves the `minor` + `**BREAKING**` -carrier for the NARROWING half — the authoring `state` union shedding the three -runtime-only approval states, and the `UseObjectChatOptions.initialMessages` -narrowing. This card is deliberately the additive half that ships first; spending -that carrier here would blur the one signal the chain uses to sequence itself. +**`minor`, on the repo's written precedent — PM ruling, overriding the `patch` +this was drafted at.** The lane's runtime test answers no: the member is optional, +every value that parsed before still parses, and no chip, card or affordance +changes for data that carries no envelope. But that test is about stored data, +and what lands here is published SURFACE — two capabilities a consumer can newly +rely on: the `ChatToolInvocation.approval` member, and the `detectPendingApproval` +export. `.changeset/8214-chatbot-anypart-state-widen.md` settles that case in this +repo in those words: *"`minor` rather than `patch` because a published signature +accepts input it refused before, which is a capability a consumer can newly rely +on."* + +The sequencing argument for `patch` was that objectui#8426's `minor` + `**BREAKING**` +carrier should not be spent early. ⛔ It does not hold, for two measured reasons. +**First, the level was never the signal.** This repo ships a breaking change AS +`minor`, so what distinguishes objectui#8426's half is the `**BREAKING**` carrier, +not the number beside the package — and that carrier is untouched by this +declaration. **Second, `.changeset/config.json` puts every package in ONE `fixed` +group**, so the released level is the maximum across all pending changesets +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