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
15 changes: 15 additions & 0 deletions .changeset/9232-cache-approval-envelope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': patch
---

A pending approval now survives the AI-chat localStorage cache, so a cache-fallback reload keeps an Approve / Reject the operator can actually complete.

`sanitizeChatMessagesForCache` rebuilds each tool part field by field, and neither the AI SDK `approval` envelope nor the ObjectStack `pendingActionId` was among the fields it wrote. `state` was, so a cached `approval-requested` came back with the awaiting-approval card lit and nothing behind it: `useHitlInChat` indexes decisions on `pendingActionId`, so pressing Approve could only answer "No pending-action id found for this tool call". The `output` re-serialization covered `replayOutcome` / `draftReview` / `proposedPlan` only, so the id was not recoverable from the cached output either.

This is the mirror of objectui#8442, which closed the same gap in the other direction (`hydratedMessagesToChatMessages`, the way OUT of persisted server history). Until now the two hydration sources disagreed on `main`: the server path yielded an invocation `useHitlInChat` could index, the cache-fallback path yielded one carrying `approval-requested` and nothing to decide with. The cache fallback is not a corner — it is what renders the thread whenever the server returns no messages.

The id travels asymmetrically and the fix follows that asymmetry rather than flattening it. `approval` is a persisted PART key on the server path, so the cache writes it as one and the hydration mapper reads it straight back. `pendingActionId` is never a part key on the server path — it exists in rehydrated history only inside the tool RESULT — so the cache re-mints the minimal `{ status: 'pending_approval', pendingActionId }` envelope `detectPendingApproval` re-parses, through a new `pendingApprovalToCachedResult` inverse. The envelope's operator-facing prose and proposed arguments are not re-serialized, matching the leanness the draft and plan inverses already take.

The id is ALSO written as a cache-side part key, and the two carriers are not redundant — each reaches where the other cannot. `output` holds one envelope, and an invocation can carry several affordances at once (`draftReview` and `pendingActionId` are independent keys, not two readings of one result). So a turn carrying both keeps its DRAFT envelope in `output` — the pending arm is last in the chain, and the "Review N changes / Publish" card is never displaced — while the id rides the part key. In the other direction, `output` is the only carrier that survives API mode's SDK store, because `useObjectChat`'s `aiInitialMessages` rebuilds each part from `{type,toolCallId,toolName,input,output,errorText,state}` and drops every other key; a pending-only turn, the shape that path can actually produce, lands there. `hydratedMessagesToChatMessages` consults `detectPendingApproval` first and only falls back to the part key, so the framework envelope still decides wherever it has anything to say and no second dialect of it is introduced.

**Cached entries written by the previous version are kept and read, not discarded.** No cache-key version bump: both readers of the new keys already answer "absent" rather than throwing, so an old entry restores exactly as it does today, and a discard would blank the transcript, the draft card and the plan card that the old writer did keep — on the one path that renders when the server has nothing. Old entries self-heal, because the first server-backed load after this ships restores the id through the objectui#8442 path and rewrites the cache in the new shape.
13 changes: 12 additions & 1 deletion packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,18 @@ export function hydratedMessagesToChatMessages(messages: HydratedUIMessage[]): C
// 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;
// objectui#9232 — the envelope still decides wherever it has anything
// to say; the part key is only consulted when it does not. That order
// is what keeps this a single contract rather than two dialects: on the
// SERVER path the id exists only inside the result, so `??` never
// reaches its right-hand side and this line behaves exactly as
// objectui#8442 left it. The fallback exists for the CACHE path, where
// `output` holds one envelope and a turn carrying both a draft and a
// pending approval has to put its draft there — leaving the part key as
// the only place the id can ride. `sanitizeChatMessagesForCache` is the
// only writer of that key.
const pendingActionId =
detectPendingApproval(result)?.pendingActionId ?? partString(part, 'pendingActionId');
toolInvocations.push({
toolCallId,
toolName,
Expand Down
Loading
Loading