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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/8442-chat-tool-approval-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
'@object-ui/types': minor
'@object-ui/plugin-chatbot': minor
'@object-ui/app-shell': minor
---

`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.

**`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
pin that says so is deliberate, so that a later tidy-up cannot ship
objectui#8426's break under this card's name.
41 changes: 41 additions & 0 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<ChatbotEnhancedToolInvocation['approval']> | undefined {
const raw = part.approval;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const envelope = raw as Record<string, unknown>;
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) {
Expand Down Expand Up @@ -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 } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
18 changes: 18 additions & 0 deletions packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthoredChatMessage['toolInvocations']>[number];
type EnhancedTool = NonNullable<EnhancedChatMessage['toolInvocations']>[number];

type _AuthoredHasApproval = Assert<Has<AuthoredTool, 'approval'>>;
type _EnhancedHasApproval = Assert<Has<EnhancedTool, 'approval'>>;

// Both directions, so neither side may widen or narrow alone.
type _ApprovalIsTheSameType = Assert<
Equal<AuthoredTool['approval'], EnhancedTool['approval']>
>;

// And it is not an erased slot pretending to agree.
type _NotAny = Assert<Equal<IsAny<NonNullable<AuthoredTool['approval']>>, false>>;
type _NotUnknown = Assert<Equal<IsUnknown<NonNullable<AuthoredTool['approval']>>, false>>;

// `id` is required inside the envelope; everything else is optional. This
// is what makes the envelope repliable rather than decorative.
type Envelope = NonNullable<AuthoredTool['approval']>;
type _IdRequired = Assert<Equal<Envelope extends { id: string } ? true : false, true>>;
type _IdIsNotOptional = Assert<Equal<Equal<Envelope, Partial<Envelope>>, 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<SeamChatMessage['toolInvocations']>[number];
type HookTool = NonNullable<ObjectChatMessage['toolInvocations']>[number];
type _SeamToolHasApproval = Assert<Has<SeamTool, 'approval'>>;
type _HookToolHasApproval = Assert<Has<HookTool, 'approval'>>;

// ⚠️ 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<Omit<AuthoredTool, 'approval'> extends AuthoredTool ? true : false, true>
>;

expect(true).toBe(true);
});
});
1 change: 1 addition & 0 deletions packages/plugin-chatbot/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export {
detectReplayOutcome,
detectAuthoringVerdict,
detectBuiltAppPackage,
detectPendingApproval,
buildProgressFromDraftReview,
} from './mapMessages';
export type {
Expand Down
Loading
Loading