Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit 3a43a15

Browse files
os-teslaclaude
andauthored
feat(types,app-shell): carry the tool approval envelope through hydration (#9229)
* feat(types,app-shell): carry the tool approval envelope through hydration 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ * docs(changeset): declare the approval envelope as `minor`, per the repo's written precedent 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 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55ba3ff commit 3a43a15

10 files changed

Lines changed: 516 additions & 1 deletion

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/plugin-chatbot': minor
4+
'@object-ui/app-shell': minor
5+
---
6+
7+
`ChatToolInvocation` gains an optional `approval` envelope, and the Console's
8+
hydration mapper stops dropping it — together with `pendingActionId`
9+
(objectui#8442).
10+
11+
Three of the ten declared `state` values — `approval-requested`,
12+
`approval-responded` and `output-denied` — are states the AI SDK's own tool-part
13+
union cannot express WITHOUT an `{ id, approved?, reason?, isAutomatic?,
14+
signature? }` envelope. `hydratedMessagesToChatMessages` built each invocation
15+
from six fields and neither the envelope nor `pendingActionId` was one of them,
16+
so a rehydrated pending approval arrived carrying a state that says "a human must
17+
decide" and nothing a decision could be made with: `useHitlInChat` keys its index
18+
on `pendingActionId` and skips any invocation without one.
19+
20+
The two halves arrive from different places and are lifted separately. The SDK
21+
envelope is persisted ON THE PART and is narrowed to its declared shape rather
22+
than cast (an `approval` with no usable `id` is refused, not passed through).
23+
`pendingActionId` is never a part key — in rehydrated history it exists only
24+
inside the tool RESULT — so it is derived with `detectPendingApproval`, the same
25+
parse the live mapper uses, now exported from `@object-ui/plugin-chatbot` so the
26+
two paths cannot disagree about one envelope rather than growing a second
27+
dialect of it.
28+
29+
**`minor`, on the repo's written precedent — PM ruling, overriding the `patch`
30+
this was drafted at.** The lane's runtime test answers no: the member is optional,
31+
every value that parsed before still parses, and no chip, card or affordance
32+
changes for data that carries no envelope. But that test is about stored data,
33+
and what lands here is published SURFACE — two capabilities a consumer can newly
34+
rely on: the `ChatToolInvocation.approval` member, and the `detectPendingApproval`
35+
export. `.changeset/8214-chatbot-anypart-state-widen.md` settles that case in this
36+
repo in those words: *"`minor` rather than `patch` because a published signature
37+
accepts input it refused before, which is a capability a consumer can newly rely
38+
on."*
39+
40+
The sequencing argument for `patch` was that objectui#8426's `minor` + `**BREAKING**`
41+
carrier should not be spent early. ⛔ It does not hold, for two measured reasons.
42+
**First, the level was never the signal.** This repo ships a breaking change AS
43+
`minor`, so what distinguishes objectui#8426's half is the `**BREAKING**` carrier,
44+
not the number beside the package — and that carrier is untouched by this
45+
declaration. **Second, `.changeset/config.json` puts every package in ONE `fixed`
46+
group**, so the released level is the maximum across all pending changesets
47+
regardless of what this file says. Declaring `patch` here therefore buys no
48+
smaller release and no preserved signal; it only makes the changelog line
49+
under-describe what shipped. ⇒ the accurate declaration is the cheap one.
50+
51+
⛔ Nothing is narrowed here. The envelope stays optional on purpose: an
52+
invocation may still declare an approval state and carry no envelope, and the
53+
pin that says so is deliberate, so that a later tidy-up cannot ship
54+
objectui#8426's break under this card's name.

‎packages/app-shell/src/console/ai/AiChatPage.tsx‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import {
7777
detectProposedChanges,
7878
detectReplayOutcome,
7979
detectBuiltAppPackage,
80+
detectPendingApproval,
8081
buildProgressFromDraftReview,
8182
// The authoring/honest -> runtime message seam (objectui#4399 / PR #4416),
8283
// 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
133134
return typeof value === 'string' && value.length > 0 ? value : undefined;
134135
}
135136

137+
/**
138+
* The AI SDK approval envelope as the server persisted it on a tool part.
139+
*
140+
* `HydratedUIMessagePart` is an open record, so the envelope is REACHABLE here
141+
* but unverified. This narrows it to the declared shape and drops what does not
142+
* match rather than asserting a cast: `id` is the envelope's only required
143+
* member, so a value without a usable one is not an envelope at all.
144+
*/
145+
function partApproval(
146+
part: HydratedUIMessagePart,
147+
): NonNullable<ChatbotEnhancedToolInvocation['approval']> | undefined {
148+
const raw = part.approval;
149+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
150+
const envelope = raw as Record<string, unknown>;
151+
const id = envelope.id;
152+
if (typeof id !== 'string' || id.length === 0) return undefined;
153+
return {
154+
id,
155+
...(typeof envelope.approved === 'boolean' ? { approved: envelope.approved } : {}),
156+
...(typeof envelope.reason === 'string' ? { reason: envelope.reason } : {}),
157+
...(typeof envelope.isAutomatic === 'boolean' ? { isAutomatic: envelope.isAutomatic } : {}),
158+
...(typeof envelope.signature === 'string' ? { signature: envelope.signature } : {}),
159+
};
160+
}
161+
136162
function partToolState(part: HydratedUIMessagePart): ChatbotEnhancedToolInvocation['state'] | undefined {
137163
const state = partString(part, 'state');
138164
switch (state) {
@@ -203,11 +229,26 @@ export function hydratedMessagesToChatMessages(messages: HydratedUIMessage[]): C
203229
// draft card (a rolled-back publish would get a live Publish button).
204230
// Mirrors the live mapper's suppression exactly.
205231
const replayOutcome = detectReplayOutcome(toolCallId, result);
232+
// objectui#8442 — the two halves of an actionable approval, dropped
233+
// here until now, and they arrive from DIFFERENT places:
234+
// * the AI SDK's `approval` envelope rides the persisted PART (the
235+
// SDK's tool-part union requires it alongside the three approval
236+
// states this mapper already carries through);
237+
// * the ObjectStack `pendingActionId` rides the tool RESULT and is
238+
// never persisted as a part key, so it is derived with the same
239+
// detector the live mapper uses — one parse, so the hydrated path
240+
// cannot disagree with the live one about the same envelope.
241+
// Without the id, `useHitlInChat` never indexes the invocation and the
242+
// operator's Approve / Reject has nothing to call.
243+
const approval = partApproval(part);
244+
const pendingActionId = detectPendingApproval(result)?.pendingActionId;
206245
toolInvocations.push({
207246
toolCallId,
208247
toolName,
209248
...(state ? { state } : {}),
210249
...(result !== undefined ? { result } : {}),
250+
...(approval ? { approval } : {}),
251+
...(pendingActionId ? { pendingActionId } : {}),
211252
...(draftReview && !replayOutcome ? { draftReview } : {}),
212253
...(proposedPlan ? { proposedPlan } : {}),
213254
...(builderHandoff ? { builderHandoff } : {}),

‎packages/app-shell/src/console/ai/__tests__/AiChatPage.hydration.test.ts‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,184 @@ describe('shared-conversation render — flat ai_messages rows → proposed-plan
161161
});
162162
});
163163
});
164+
165+
describe('AiChatPage hydration — the approval envelope and the pending-action id (objectui#8442)', () => {
166+
// The mapper used to build an invocation from six things and neither of these
167+
// was one of them, so a rehydrated pending approval arrived carrying a state
168+
// that says "a human must decide" and nothing a decision could be made WITH.
169+
//
170+
// The two halves arrive from different places, which is why they are pinned
171+
// separately: the AI SDK's `approval` envelope is persisted ON THE PART, and
172+
// the ObjectStack `pendingActionId` lives only inside the tool RESULT — it is
173+
// never a part key — so it is derived by the same detector the live mapper
174+
// (`mapMessages.extractToolInvocations`) uses.
175+
176+
it('carries the AI SDK approval envelope persisted on the part', () => {
177+
const [msg] = hydratedMessagesToChatMessages(
178+
assistantWith([
179+
{
180+
type: 'tool-action_delete_task',
181+
toolCallId: 't1',
182+
toolName: 'action_delete_task',
183+
state: 'approval-requested',
184+
approval: { id: 'apr_1', isAutomatic: false },
185+
},
186+
]),
187+
);
188+
expect(msg.toolInvocations?.[0]).toMatchObject({
189+
state: 'approval-requested',
190+
approval: { id: 'apr_1', isAutomatic: false },
191+
});
192+
});
193+
194+
it('keeps every declared member of a full envelope and drops nothing declared', () => {
195+
const [msg] = hydratedMessagesToChatMessages(
196+
assistantWith([
197+
{
198+
type: 'tool-action_delete_task',
199+
toolCallId: 't1',
200+
toolName: 'action_delete_task',
201+
state: 'approval-responded',
202+
approval: {
203+
id: 'apr_2',
204+
approved: true,
205+
reason: 'operator confirmed',
206+
isAutomatic: false,
207+
signature: 'sig_abc',
208+
},
209+
},
210+
]),
211+
);
212+
expect(msg.toolInvocations?.[0]?.approval).toEqual({
213+
id: 'apr_2',
214+
approved: true,
215+
reason: 'operator confirmed',
216+
isAutomatic: false,
217+
signature: 'sig_abc',
218+
});
219+
});
220+
221+
it('REFUSES an envelope with no usable id rather than passing the shape through', () => {
222+
// `HydratedUIMessagePart` is an open record: whatever the server wrote is
223+
// reachable and UNVERIFIED. An `approval` without an `id` cannot be replied
224+
// on, so carrying it would hand the UI a half-envelope to guess at.
225+
const [msg] = hydratedMessagesToChatMessages(
226+
assistantWith([
227+
{ type: 'tool-x', toolCallId: 't1', toolName: 'x', approval: { approved: true } },
228+
{ type: 'tool-y', toolCallId: 't2', toolName: 'y', approval: 'apr_3' },
229+
{ type: 'tool-z', toolCallId: 't3', toolName: 'z', approval: { id: '' } },
230+
]),
231+
);
232+
expect(msg.toolInvocations?.map((t) => t.approval)).toEqual([
233+
undefined,
234+
undefined,
235+
undefined,
236+
]);
237+
});
238+
239+
it('lifts pendingActionId out of the persisted HITL result envelope', () => {
240+
const [msg] = hydratedMessagesToChatMessages(
241+
assistantWith([
242+
{
243+
type: 'tool-call',
244+
toolCallId: 't1',
245+
toolName: 'action_delete_task',
246+
output: { status: 'pending_approval', pendingActionId: 'pa_42' },
247+
},
248+
]),
249+
);
250+
// Without this the invocation reaches `useHitlInChat` un-indexed — the hook
251+
// keys its map on `pendingActionId` and skips any invocation without one,
252+
// so Approve / Reject has no id to POST.
253+
expect(msg.toolInvocations?.[0]?.pendingActionId).toBe('pa_42');
254+
});
255+
256+
it('lifts it through the persisted {type:text,value} wrapper too', () => {
257+
// The shape the server really persists for a tool result on this path.
258+
const [msg] = hydratedMessagesToChatMessages(
259+
assistantWith([
260+
{
261+
type: 'tool-call',
262+
toolCallId: 't1',
263+
toolName: 'action_delete_task',
264+
output: {
265+
type: 'text',
266+
value: JSON.stringify({ status: 'pending_approval', pendingActionId: 'pa_43' }),
267+
},
268+
},
269+
]),
270+
);
271+
expect(msg.toolInvocations?.[0]?.pendingActionId).toBe('pa_43');
272+
});
273+
274+
it('leaves pendingActionId absent when the result is not a HITL proposal', () => {
275+
const [msg] = hydratedMessagesToChatMessages(
276+
assistantWith([
277+
{
278+
type: 'tool-call',
279+
toolCallId: 't1',
280+
toolName: 'verify_build',
281+
output: { status: 'ok' },
282+
},
283+
]),
284+
);
285+
expect(msg.toolInvocations?.[0]?.pendingActionId).toBeUndefined();
286+
expect('pendingActionId' in (msg.toolInvocations?.[0] ?? {})).toBe(false);
287+
});
288+
289+
it('lifts it through the full ModelMessage round trip (call row + separate tool-result row)', () => {
290+
// The server-backed shape: the CALL and its RESULT are different rows, and
291+
// `toUIMessages` merges the result onto the call part.
292+
const chat = hydratedMessagesToChatMessages(
293+
toUIMessages(
294+
aiMessageRowsToServerMessages([
295+
{ id: 'u1', role: 'user', content: 'delete the task' },
296+
{
297+
id: 'a1',
298+
role: 'assistant',
299+
content: 'This needs your approval.',
300+
tool_calls: JSON.stringify([
301+
{
302+
type: 'tool-call',
303+
toolCallId: 'c1',
304+
toolName: 'action_delete_task',
305+
input: {},
306+
},
307+
]),
308+
},
309+
{
310+
id: 't1',
311+
role: 'tool',
312+
tool_call_id: 'c1',
313+
content: JSON.stringify([
314+
{
315+
type: 'tool-result',
316+
toolCallId: 'c1',
317+
toolName: 'action_delete_task',
318+
output: {
319+
type: 'text',
320+
value: JSON.stringify({
321+
status: 'pending_approval',
322+
pendingActionId: 'pa_44',
323+
}),
324+
},
325+
},
326+
]),
327+
},
328+
]),
329+
),
330+
);
331+
const tool = chat[1]?.toolInvocations?.[0];
332+
expect(tool?.pendingActionId).toBe('pa_44');
333+
// ⚠️ MEASURED, and recorded here rather than asserted as desirable: on THIS
334+
// sub-path the merge step rewrites the part's state to `output-available`
335+
// whenever a result is merged, so the state never reaches this mapper as
336+
// `approval-requested`. The id is what `useHitlInChat` indexes on, so the
337+
// hook now sees this invocation either way — but the awaiting-approval CARD
338+
// is gated on the state, so it does not render from this sub-path. That
339+
// state rewrite is out of this card's scope (it lives in the hydration
340+
// pipeline, not in this mapper); a card that fixes it turns this line red,
341+
// which is the point of pinning the reading instead of describing it.
342+
expect(tool?.state).toBe('output-available');
343+
});
344+
});

‎packages/plugin-chatbot/src/ChatbotEnhanced.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,24 @@ export interface ChatToolInvocation {
234234
| 'output-available'
235235
| 'output-error'
236236
| 'output-denied';
237+
/**
238+
* AI SDK v6 approval envelope, mirrored from `@object-ui/types`'
239+
* `ChatToolInvocation` (objectui#8442). The SDK requires it alongside the
240+
* three approval states; it is what carries a rehydrated pending approval's
241+
* identity and, once decided, the decision itself.
242+
*
243+
* ⚠️ Distinct from `pendingActionId` below and NOT a replacement for it:
244+
* this is the SDK's own request id, while `pendingActionId` is the
245+
* ObjectStack `pending_actions` row the REST approve/reject endpoints take.
246+
* The approval affordance is wired on the latter.
247+
*/
248+
approval?: {
249+
id: string;
250+
approved?: boolean;
251+
reason?: string;
252+
isAutomatic?: boolean;
253+
signature?: string;
254+
};
237255
/**
238256
* ObjectStack HITL extension. When the framework's `action-tools.ts`
239257
* proposes a destructive action that requires human approval, the tool

‎packages/plugin-chatbot/src/__tests__/chat-message-contract.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,3 +471,53 @@ describe('the barrel no longer declares a message shape of its own', () => {
471471
);
472472
});
473473
});
474+
475+
describe('the approval envelope is MIRRORED, not merely present on both sides (objectui#8442)', () => {
476+
it('is pinned at compile time', () => {
477+
// The word "mirrored" is the whole requirement here. Two independent
478+
// declarations that happen to share a NAME would satisfy a `Has<>` probe
479+
// while disagreeing about what they hold — and this member crosses the
480+
// adapter as an untouched spread (`toRuntimeToolInvocation` destructures
481+
// `state` and passes everything else through), so a divergence would be
482+
// invisible at the seam and surface as a render-side read of a member the
483+
// producer never wrote that way.
484+
type AuthoredTool = NonNullable<AuthoredChatMessage['toolInvocations']>[number];
485+
type EnhancedTool = NonNullable<EnhancedChatMessage['toolInvocations']>[number];
486+
487+
type _AuthoredHasApproval = Assert<Has<AuthoredTool, 'approval'>>;
488+
type _EnhancedHasApproval = Assert<Has<EnhancedTool, 'approval'>>;
489+
490+
// Both directions, so neither side may widen or narrow alone.
491+
type _ApprovalIsTheSameType = Assert<
492+
Equal<AuthoredTool['approval'], EnhancedTool['approval']>
493+
>;
494+
495+
// And it is not an erased slot pretending to agree.
496+
type _NotAny = Assert<Equal<IsAny<NonNullable<AuthoredTool['approval']>>, false>>;
497+
type _NotUnknown = Assert<Equal<IsUnknown<NonNullable<AuthoredTool['approval']>>, false>>;
498+
499+
// `id` is required inside the envelope; everything else is optional. This
500+
// is what makes the envelope repliable rather than decorative.
501+
type Envelope = NonNullable<AuthoredTool['approval']>;
502+
type _IdRequired = Assert<Equal<Envelope extends { id: string } ? true : false, true>>;
503+
type _IdIsNotOptional = Assert<Equal<Equal<Envelope, Partial<Envelope>>, false>>;
504+
505+
// It reaches the seam's input and the hook's output for free — both derive
506+
// from the authoring declaration — so a future narrowing of either says
507+
// WHICH capability it dropped.
508+
type SeamTool = NonNullable<SeamChatMessage['toolInvocations']>[number];
509+
type HookTool = NonNullable<ObjectChatMessage['toolInvocations']>[number];
510+
type _SeamToolHasApproval = Assert<Has<SeamTool, 'approval'>>;
511+
type _HookToolHasApproval = Assert<Has<HookTool, 'approval'>>;
512+
513+
// ⚠️ The envelope is OPTIONAL on purpose: this card ships the widening, and
514+
// objectui#8426 owns the narrowing that pairs it with the three approval
515+
// states. Pinning that the member is NOT required keeps a later "tidy-up"
516+
// from shipping that break under this card's name.
517+
type _ApprovalIsOptional = Assert<
518+
Equal<Omit<AuthoredTool, 'approval'> extends AuthoredTool ? true : false, true>
519+
>;
520+
521+
expect(true).toBe(true);
522+
});
523+
});

‎packages/plugin-chatbot/src/index.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,7 @@ export {
363363
detectReplayOutcome,
364364
detectAuthoringVerdict,
365365
detectBuiltAppPackage,
366+
detectPendingApproval,
366367
buildProgressFromDraftReview,
367368
} from './mapMessages';
368369
export type {

0 commit comments

Comments
 (0)