Skip to content

Commit 5c75dd6

Browse files
os-teslaclaude
andauthored
fix(app-shell): keep the approval envelope + pending-action id in the chat cache (#9450)
* fix(app-shell): keep the approval envelope + pending-action id in the chat cache (objectui#9232) `sanitizeChatMessagesForCache` rebuilds each tool part field by field and wrote neither the AI SDK `approval` envelope nor the ObjectStack `pendingActionId`. `state` survived, so a cached `approval-requested` came back with the awaiting-approval card lit and nothing behind it: `useHitlInChat` indexes on `pendingActionId`, so Approve could only answer "No pending-action id found for this tool call". The mirror of objectui#8442, which closed the same gap on the way OUT of persisted server history. The id travels asymmetrically, and the fix follows that rather than flattening it. `approval` is a persisted PART key on the server path, so the cache writes it as one and `partApproval` reads it straight back. `pendingActionId` is never a part key anywhere, so it round-trips through a new `pendingApprovalToCachedResult` inverse that re-mints the minimal `{ status: 'pending_approval', pendingActionId }` envelope `detectPendingApproval` re-parses — one parse for one contract, no second reader on the cache side. Entries written by the old shape are kept and read, not discarded: both readers of the new keys already answer "absent" rather than throwing, a version bump would blank the transcript and cards the old writer did keep, and old entries self-heal on the next server-backed load. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa * fix(app-shell): carry the pending-action id on a part key, not by displacing the draft envelope Patch round on objectui#9232. The first version put the pending-approval arm FIRST in the `cachedOutput` chain, on the argument that the four detectors are "disjoint by construction, so the order is unobservable". That argument was wrong and `AiChatPage.runtimeMessageSeam.test.tsx` already pinned the counter-example: the detectors are disjoint over one RESULT, but `draftReview` and `pendingActionId` are independent KEYS on an invocation and a turn can carry both. Pending-first therefore stopped the draft envelope reaching the cache for such a turn — the same "Review N changes / Publish" loss on a cache-fallback reload that the other three arms exist to prevent, and the same loss this card's own stale-cache reasoning refused to accept from a key bump. `output` holds one envelope, so the three existing arms keep it exactly as before and the pending envelope is minted only when none of them claims it. The id is no longer hostage to that slot: it is also written as a cache-side part key, and `hydratedMessagesToChatMessages` consults `detectPendingApproval` FIRST and only falls back to the key. On the server path the envelope always answers, so that line behaves exactly as objectui#8442 left it and no second dialect of the contract is introduced. The two carriers are not redundant — each reaches where the other cannot. `output` is the only one that survives API mode's SDK store, because `aiInitialMessages` rebuilds each part from {type,toolCallId,toolName,input, output,errorText,state} and drops every other key; the part key is the only one left when a richer envelope has taken `output`, and local mode keeps it because `normalizeMessages` passes `toolInvocations` through verbatim. The falsified precedence pin is replaced by a both-at-once pin that drives the round trip: the draft card comes back AND Approve reaches the pending action. The over-reaching prose claim is replaced by the narrow claim that is true, as an executable pin with lit controls: over ONE result the two envelopes are mutually exclusive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3df7c5c commit 5c75dd6

4 files changed

Lines changed: 473 additions & 2 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
A pending approval now survives the AI-chat localStorage cache, so a cache-fallback reload keeps an Approve / Reject the operator can actually complete.
6+
7+
`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.
8+
9+
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.
10+
11+
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.
12+
13+
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.
14+
15+
**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.

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,18 @@ export function hydratedMessagesToChatMessages(messages: HydratedUIMessage[]): C
241241
// Without the id, `useHitlInChat` never indexes the invocation and the
242242
// operator's Approve / Reject has nothing to call.
243243
const approval = partApproval(part);
244-
const pendingActionId = detectPendingApproval(result)?.pendingActionId;
244+
// objectui#9232 — the envelope still decides wherever it has anything
245+
// to say; the part key is only consulted when it does not. That order
246+
// is what keeps this a single contract rather than two dialects: on the
247+
// SERVER path the id exists only inside the result, so `??` never
248+
// reaches its right-hand side and this line behaves exactly as
249+
// objectui#8442 left it. The fallback exists for the CACHE path, where
250+
// `output` holds one envelope and a turn carrying both a draft and a
251+
// pending approval has to put its draft there — leaving the part key as
252+
// the only place the id can ride. `sanitizeChatMessagesForCache` is the
253+
// only writer of that key.
254+
const pendingActionId =
255+
detectPendingApproval(result)?.pendingActionId ?? partString(part, 'pendingActionId');
245256
toolInvocations.push({
246257
toolCallId,
247258
toolName,

0 commit comments

Comments
 (0)