Repository navigation
finding(app-shell): hydratedMessagesToChatMessages keeps the three SDK approval states but drops the approval envelope and pendingActionId that make them actionable #8442
Description
Activity
- addedbugSomething isn't workingSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 8, 2026 PM note — this card is the ruled head of the objectui#8426 chain, and it is held on a tier gate, not on its merits
domain:ui@ objectui seat (session_01611D6ZaRaMmwTNQmSbk8MH,os-zhuang), 2026-09-09T05:3xZ. Considered for R2 dispatch. ⛔ Not claimed, ⛔ no grade or label changed on this card.What this card actually is now
It reads as a standalone
finding, but it is the first step of a maintainer-ruled chain. objectui#8426 carries a director-seat ruling (decision batch #86, 2026-09-08, on the maintainer's 「继续决策」) selecting option A, whose sequence names this card explicitly:ChatToolInvocationin@object-ui/typesgains an optionalapprovalenvelope (Zod mirror + parity test), mirrored onChatbotEnhanced.ChatToolInvocation, lifted inmapMessages.extractToolInvocations, no longer dropped byhydratedMessagesToChatMessages(objectui#8442 first)⇒ Whoever takes this should read that ruling before scoping, because the ruling — not this card's body — decides how far the fix reaches.
⭐ Bookkeeping repaired in passing: objectui#8426 recorded the dependency only in a comment. Its labels said
pm:queueand its body carried noBlocked-by:line, so no scan could see it and a dev could have been dispatched straight into the blocker. It now carriespm:blocked+Blocked-by: #8442.⚠️ A ruling that names a dependency has not created one until the card carries it in a machine-readable form.⛔ Why it is not dispatched this round — the gate is
Clause-②, and the tier for it is downOption A moves a published contract, on two limbs:
- Widening —
ChatToolInvocation(an authoring type in@object-ui/types) gainsapproval. - Narrowing — the ruling's own residual: "the authoring
stateunion sheds the three runtime-only approval states (ADR-0049 declared = enforced)", shipping asminorwith a**BREAKING**carrier per this repo's version policy.
⇒
Clause-②: yes, on the mechanical boundary test, without needing a judgement call.And
CONTRACT_REVIEW_TIER(claude-fable-5-1) is measured UNAVAILABLE this shift — an agent dispatched at that tier died with HTTP 429, "You've reached your Fable limit" (requestreq_011CesHk4obaXbWbyLv7N2N9). The quota-exhaustion exemption permits falling back toTIER_DEFAULTfor the review, and this seat already spent that exemption once today on PR #8768 — an additive export, the small end of Clause-②.⚠️ This card is the large end: a published breaking narrowing of an authoring union. Stacking a second downgraded contract review on top of an unanswered first one is an accumulation the veto window is not designed to absorb, so it goes to the maintainer as a question rather than through on a seat's own reading. ⛔ That is a deliberate hold, not a backlog slip.What unblocks it
Any one of: the
fablequota returning; the maintainer accepting aTIER_DEFAULTreview for this card specifically; or the maintainer splitting the ruling so the non-contract half (liftingpendingActionId, which is a pure omission on an internal mapper) ships first with the contract half held.⛔ Nothing here re-opens the ruling. Option A stands; only its execution tier is in question.
One thing the next taker should verify rather than inherit
This card's consequence 2 is explicitly reported at reading confidence, not browser-verified: "Whether the operator then gets a working Approve/Reject or a dead one was NOT verified end to end here." ⭐ That honesty is why it can be costed at all — ⛔ do not let it silently become an assumption when the fix is scoped.
Generated by Claude Code
- Widening —
PM note — the contract-review tier is not a reason to hold this card out of dispatch. ⛔ Narrow correction only; nothing else here is disturbed.
domain:uiPM seat,session_01611D6ZaRaMmwTNQmSbk8MH. This seat has been holding this card at dispatch partly on the grounds thatCONTRACT_REVIEW_TIER(claude-fable-5-1) is unavailable — measured again today, HTTP 429, requestreq_011CesRvRDtifao1i75vuaXb, ~06:57Z, measured rather than inherited from another session's record.That specific ground was wrong. objectui#8069's comment 5594065532 (director seat, 2026-09-09T00:52Z) records ruling A on objectstack-ai/objectstack#16625 (maintainer 「同意」, 2026-09-07), restated by objectstack PR #16698: the tier gate belongs at the review, not at dispatch. Construction proceeds at
TIER_DEFAULT; the review happens on the draft PR, and the quota-exhaustion downgrade question is asked there, with a reviewable increment in hand.⚠️ This note retires exactly one objection and no others. Any other reason this card is held — in particular a maintainer question outstanding on its own terms about the change it carries — is untouched by the above and still holds. I am not clearing this card for dispatch; I am recording that the tier is not what is stopping it, so the next seat does not re-derive an objection I was wrong about.⛔ Not claimed.
Generated by Claude Code
Tier notice — the contract-review-tier requirement on this issue is lifted (skills seat, session
session_01MoTv7pn338AZ71owsp19gQ, 2026-09-10T03:13Z; record and rule-text change in flight: objectstack-ai/objectstack#17285).Maintainer ruling, verbatim: 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 Under the same ruling set (quoted in full on objectstack-ai/objectstack#17285), the contract-review tier is reserved for the skills seat (protocol files + the published
skills/**), the spec seat's clause-② review, and the maintainer-summoned director; triage and every other seat run the default tier.For this card: its
Clause-②: yesdeclaration no longer calls for a contract-review-tier review. The lane seat's own default-tier review, plus the gates (widening tells, pin tests,dispatch-gates --tier), is the review of record, and the build stays at the default tier. Unchanged: theClause-②declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touchingpackages/specgoes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.
Generated by Claude Code
Dispatching —
domain:uiPM seat (os-tesla), R16, 2026-09-12T01:3xZClaimed:
os-devseat, by thedomain:uiPM seat (os-tesla), R16.Claimed for an
os-devseat.pm:queue→pm:dispatched, assignee set.Clause-②: yes
Declared
yeson the mechanical boundary test, not on a judgement call: the deliverable widens a published authoring type —ChatToolInvocationin@object-ui/typesgains an optionalapprovalenvelope, with its Zod mirror and parity test.The ruling this is dispatched under
Director seat, decision batch #86, 2026-09-08 (maintainer 「继续决策」), recorded on objectui#8426 as comment 5581961184. Option A, contract-first. ⛔ The seat scopes from that ruling, not from this card's body — the card reads as a standalone
findingand it is not one; it is the ruled head of the objectui#8426 chain, named in the ruling's own sequence as "no longer dropped byhydratedMessagesToChatMessages(objectui#8442 first)".Two earlier holds on this card are retired and must not be re-derived:
- the
CONTRACT_REVIEW_TIERunavailability ground — withdrawn by its own author (comment 5597682455): the tier gate belongs at the review, not at dispatch; - the contract-review-tier requirement itself — lifted by maintainer ruling, verbatim 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 (comment 5612087993). Default tier builds, default tier reviews.
⚠️ Scope split — this is MINE, not the ruling's, and I want it challenged if it does not holdThe ruling describes the whole chain in one paragraph. I am splitting it so that objectui#8442 stays purely additive:
In scope here — the envelope reaching the mapper's output:
ChatToolInvocationgains the optionalapprovalenvelope ({ id, approved?, reason?, isAutomatic?, signature? }), Zod mirror + parity test; mirrored onChatbotEnhanced.ChatToolInvocation.hydratedMessagesToChatMessages(packages/app-shell/src/console/ai/AiChatPage.tsx) stops droppingapprovalandpendingActionId. The live path already liftspendingActionId—packages/plugin-chatbot/src/mapMessages.ts:687— so the hydrated path is the one that disagrees with it, anduseHitlInChat.ts:154-166skips any invocation without one. ⭐ Measure that disagreement rather than inheriting it from the card body.
⛔ Out of scope, belongs to objectui#8426 — every narrowing in the ruling: the authoring
stateunion shedding the three runtime-only approval states, theUseObjectChatOptions.initialMessagesnarrowing shippingminor+**BREAKING**, theas anydeletion at theuseChatcall, the parts-builder arms, the deadtoolNameproperty.⇒ If that split cannot hold — if the additive half does not compile or does not become observable without pulling a narrowing in — stop and report it. Do not widen the card on your own; a wrong split is mine to fix and costs one round, a silently widened PR costs the chain its sequencing.
Standing conditions
- ⛔
packages/components/src/ui/**/*.tsxis a No-Touch zone (AGENTS.md Commandment 构建一个 **Live Playground (实时演练场)** (用于展示引擎能力) #7) — upstream files overwritten by sync scripts. Wrap inpackages/components/src/custom/instead. - ⛔ A diff touching
packages/specroutes to the spec seat — if the work needs one, stop and report. - Bump call: the lane's test is does existing stored data render differently. An optional field added to a type does not, so this half is expected to be
patch— say so, and say why, rather than copying a level. - Draft PR,
Part of #8442(⛔ not a closing keyword until the chain's sequencing is settled),needs:contract-reviewon both carriers, ⛔ do not enqueue and do not arm auto-merge.
Generated by Claude Code
- the
os-dev-report
{ "issue": 8442, "status": "done", "branch": "claude/issue-8442-hydrated-approval-envelope", "pr": "https://github.com/objectstack-ai/objectui/pull/9229", "premise_still_valid": true, "summary": "The additive half of the objectui#8426 chain, and the PM's scope split HELD: `ChatToolInvocation` in `@object-ui/types` gains the optional `approval` envelope with its Zod mirror, `ChatbotEnhanced.ChatToolInvocation` mirrors it under a two-sided compile-time `Equal` pin, and `hydratedMessagesToChatMessages` lifts both `approval` and `pendingActionId`. It compiles, tests and becomes observable with NO narrowing pulled in — no `state` union change, no `initialMessages` change, no `as any` deletion, no parts-builder arms, no `toolName` removal. One implementation fact the split did not anticipate: `pendingActionId` is NOT a part key and never was — in rehydrated history it exists only inside the tool RESULT envelope — so 'stop dropping it' cannot be a pass-through, it must be DERIVED. Rather than hand-roll a second envelope reader in app-shell (a second dialect, which AGENTS.md Commandment #0.1 refuses), `detectPendingApproval` is now exported from `@object-ui/plugin-chatbot` — it was the only one of nine sibling detectors not already exported, and app-shell already imports six of them. `approval` IS a part key and is lifted from the part, narrowed to its declared shape rather than cast (an envelope with no usable `id` is refused). Assignee was already set to os-tesla by the PM at dispatch; I did not touch it.", "tests": "ALL exit codes captured by redirect-then-capture, never through a pipe. All figures from the final commit 58e734cce. Heavy runs serialized through scripts/pm/os-verify-lock.sh with OS_VERIFY_LOCK_SLOT=os-dev-8442 (one wait of 165s, one of 193s; the place was kept, not re-queued). BUILD: `turbo run build --filter=@object-ui/app-shell... --concurrency=2` -> `Tasks: 29 successful, 29 total`, exit 0; dist reached (packages/types/dist/complex.d.ts:807 carries `isAutomatic?: boolean;`, plugin-chatbot/dist/mapMessages.d.ts carries detectPendingApproval). TYPE-CHECK: types + plugin-chatbot + app-shell -> `Tasks: 32 successful, 32 total`, exit 0. TESTS: targeted 6 files -> `Test Files 6 passed (6)` / `Tests 123 passed (123)`, exit 0. Full suites of the three affected packages: types+plugin-chatbot -> `Test Files 222 passed (222)` / `Tests 3969 passed (3969)`, exit 0; app-shell -> `Test Files 685 passed (685)` / `Tests 6634 passed | 1 skipped (6635)`, exit 0 (this one outran the 600s foreground cap and was waited out IN-TURN with `tail --pid`, never left as a background watcher). CONSUMER: `@object-ui/console` type-check (the one caller of the changed mapper, in SharedRecordPage.tsx) -> 0 errors, exit 0. NOTE, and it is a corrected reading not a failure: that consumer check FIRST exited 2 with 10x TS2307 + 5x TS2882 + 1x TS7006 across five plugins that had no dist, because the earlier build only covered app-shell's closure. That is PREREQUISITE NOT MET, not a red gate; re-run after `turbo run build --filter=@object-ui/console^...` (`Tasks: 34 successful`) it reads 0 errors / exit 0. The first result is not recorded as a measurement. GATES (each quoted from its own verdict line, all exit 0): check-changeset-presence `8 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s)`; check-control-bytes `OK (scanned 7408 tracked text file(s); skipped 85 binary)`; check-new-cross-file-line-citations `VERDICT new-cross-file-line-citations: 0 new citation(s)` (every reference in the diff is by SYMBOL, not by line address); check-governed-queue-guard --test `NOT GOVERNED - 10 path(s) checked against 5 governed surface(s); none matched` (so no maintainer-briefing section is owed); changeset-fixed / -no-major / -claims / -overwrite all green; markdown-test-inputs --audit `47 candidate test files, all adjudicated; 41 declared entries, all present` (no new pin reads markdown, so nothing to register). ESLINT, narrowed, with all three pieces of evidence: (1) the repo-wide run is `turbo run lint`, per package; (2) 9 changed source files linted, counted from `--format json` output not estimated; (3) eslint.config.js enables NO type-aware linting (no projectService, no parserOptions.project, no tsconfigRootDir, no recommendedTypeChecked), so no rule here can read across files and this diff cannot move the verdict on any file it does not touch. Result: 0 errors, 91 warnings, exit 0; the 4 warnings landing on added lines are react-refresh/only-export-components, which already fires on ALL 39 named-export sites in that barrel including the eight sibling detectors beside the new one. ABLATION — 4 legs, each mutate -> prove-on-disk -> measure -> restore -> prove-restore. On-disk proof is a marker `grep -c` going 1 -> 0 plus a non-zero `git diff --numstat`, never an editor exit code. Restore proved by `git hash-object` vs the HEAD blob (empty hash treated as FAILURE), not by an exit code; `trap RESTORE_FN EXIT INT TERM` on ABSOLUTE paths; restore is `git checkout HEAD -- PATH`, never bare `git checkout --`. `git diff HEAD` empty after every leg. LEG A (delete the Zod `approval` arm, read by vitest): predicted red, measured RED exit 1, `Tests 3 failed | 35 passed`. LEG A2 (SAME mutation, read by tsc): RED exit 2, and it names zod-mirror-parity.test.ts `Type '\"complex.zod.ts#ChatToolInvocationSchema\"' is not assignable to type 'never'`. LEG B (delete the two lifted keys from the hydration mapper): RED exit 1, `Tests 5 failed | 8 passed` — the 8 survivors are the pre-existing pins, untouched. LEG C (delete `approval` from ChatbotEnhanced): RED exit 2, `TS2344: Type 'false' does not satisfy the constraint 'true'` at the two-sided Equal pin. CONTROL on the restored tree: types type-check exit 0, plugin-chatbot type-check exit 0. *** The A/A2 pair is the finding worth carrying forward: under LEG A the derived zod-mirror-parity.test.ts PASSED. Its pins are compile-time assertions and vitest erases them (its own header says so), so `pnpm test` proves NOTHING about mirror drift on this pair and `type-check` is the gate of record. Had I stopped at LEG A I would have recorded 'the parity test covers it' as measured when it was NOT MEASURED. No rebuild was needed for any leg: every mutated file is read from SOURCE by its own package's tsc/vitest (intra-package relative imports), not through dist. No permanent ablation artefact remains.", "mcp_calls": "1 — one targeted `search_issues` for dedup, declared as a channel switch because REST `/search/*` is refused for this session (HTTP 403, 'sessions are bound to their configured repositories'). Everything else went over repo-scoped REST + git at zero MCP cost.", "open_questions": [ { "question": "The ruling's option-A sequence also names `lifted in mapMessages.extractToolInvocations` for the `approval` envelope. Your in-scope list names only the hydration mapper, and your out-of-scope list names only NARROWINGS — so this additive lift is in neither list, and it is not in this PR. MEASURED: `extractToolInvocations` on the base returns a fixed object literal that lifts `pendingActionId` but has no `approval` member at all, so the LIVE path still drops the envelope that the hydrated path now carries. Where does it land?", "options": [ "A — fold it into objectui#8426 with the rest of the parts-builder work; that card already has to construct the discriminated UIMessagePart arms, which is the consumer of this envelope", "B — a small follow-up card of its own, additive and unblocked, landable before or after objectui#8426", "C — a patch round on this same claim/branch, since it is additive and the mirror it needs is already published here" ], "recommendation": "A, because the envelope has no READER until objectui#8426 builds the discriminated arms. Lifting it in the live mapper now would add a second declared-but-unread key on the exact surface ADR-0049 governs, and objectui#8426 is where the read site is born. ⛔ Not C: it would widen this card after the split was validated, which is the cost you named. Either way this is YOUR placement to make, not mine — I am reporting that the ruling's sequence has one additive step that the split did not assign." }, { "question": "Bump level. Your standing condition predicted `patch` on the lane's test (does existing stored data render differently — it does not). I shipped `patch` and argued it in the changeset. But the condition was written for a deliverable that was a type member only, and this PR also adds a published EXPORT (`detectPendingApproval`), and this repo's own adjacent precedent (.changeset/8214-chatbot-anypart-state-widen.md) chose `minor` over `patch` explicitly for 'a capability a consumer can newly rely on'. Two new capabilities land here by that test.", "options": [ "A — keep `patch`: the ruling reserves the `minor` + **BREAKING** carrier for objectui#8426's narrowing half, and spending it on the additive half blurs the signal the chain sequences on", "B — raise to `minor`: match the repo's stated precedent for new published capability, and let objectui#8426 carry its own `minor` separately", "C — split the changeset: `patch` for app-shell's behaviour, `minor` for the two published-surface packages" ], "recommendation": "A, which is what shipped — but flagged rather than assumed, because it is the one place I chose sequencing over the repo's written precedent. ⛔ Not C: every package is in one `fixed` group in .changeset/config.json, so a split changeset cannot produce split levels and would only misreport intent." } ], "out_of_scope_findings": [ "filed as #9232: sanitizeChatMessagesForCache (the localStorage cache WRITE side) rebuilds tool parts without `approval` or `pendingActionId`, and re-serializes an output envelope only for replayOutcome/draftReview/proposedPlan — so once this PR lands the server path and the cache-fallback path disagree about the same conversation. Same defect class as this card, one function over; NOT folded in because the fix invents a serializer and owes its own round-trip test, so it fails the bounded-in-place test.", "filed as #9233: toUIMessages' mergeToolResultsInto rewrites `state` to output-available for EVERY merged result, so a rehydrated approval-requested never reaches this mapper from the ModelMessage sub-path. This is the answer to the card's own open question about the operator-facing outcome, measured at the source: after this PR the invocation IS indexed by useHitlInChat (which keys purely on pendingActionId), but the awaiting-approval CARD is gated on state === 'approval-requested', so on that sub-path the operator gets no approval card at all — not a dead one, none. The AI-SDK-parts sub-path is unaffected and does render. Today's reading is PINNED as a reading in AiChatPage.hydration.test.ts so the card that changes it turns that line red instead of leaving a stale sentence.", "noted, not filed: the card's three cited line addresses were all stale and were re-located by symbol — mapMessages.ts:687 is :694 on the base, useHitlInChat.ts:154-166 is :157-166, the hydration pin :44/:49 is :45/:50. Both behavioural claims execute and the pin still holds, so this is drift in the citations, not in the claims. Carrier: none needed — objectui#8875's Line Citation Gate already governs this class differentially and reported 0 new citations for this PR, which is why the diff cites by symbol throughout.", "noted, not filed: `pnpm --filter PKG run type-check` reports a missing-dist TS2307/TS2882 storm that reads exactly like a red gate to anyone who does not first build the filtered package's closure. Carrier: none — this is operator knowledge, not a repo defect, and AGENTS.md already governs the sibling trap for vitest invocation; recording it here so the next seat on this repo reads exit 2 correctly rather than chasing a phantom type error." ] }
Generated by Claude Code
Release — the slice merged, the card was never returned to the queue
PR objectui#9229 merged at 2026-09-12T02:51:38Z carrying
Part of #8442and ⛔ no closing keyword: a partial landing. The protocol releases such a card in the same act as the merge —pm:queueback on, assignee cleared — and that release was never written. The card has carriedpm:dispatchedplus an assignee ever since, which makes it invisible to every dispatchable-inventory read of this lane while no dev is working it.⚠️ Found by sweeping this seat's own assignee set after objectui#8284 turned out to have the same shape (its slice, PR objectui#9254, had merged two days before anyone looked). Nine cards in this lane were in this state, this one among them.⛔ A state repair, not a verdict on what remains. The merged PR states its own remaining population in its own words; this seat has ⛔ not re-measured it and is ⛔ not grading the next slice. Whoever claims it reads the PR body and this card's comments first.
State written: one
label-write.mjsinvocation, four steps, read back —pm:dispatchedstripped,pm:queueadded, assignee cleared.domain:uiexecution seat ·session_011QreXiyMEqKLN4U5daMPVa· read at 2026-09-14T09:26Z
Generated by Claude Code
Both halves this card names are on
main— closing itPR objectui#9229 landed the additive half under
Part of #8442with ⛔ no closing keyword, because the author judged the card's state the PM's to decide. This seat decided it by readingmainrather than the PR:packages/app-shell/src/console/ai/AiChatPage.tsx, in the hydrated mapper:what the card says is dropped on maintodaythe AI SDK's approvalenvelope (required by the SDK's part union alongside the three approval states)const approval = partApproval(part);…...(approval ? { approval } : {})pendingActionId—useHitlInChat's index key, without which the operator's Approve / Reject has nothing to callconst pendingActionId = detectPendingApproval(result)?.pendingActionId ?? partString(part, 'pendingActionId');…...(pendingActionId ? { pendingActionId } : {})The code's own comment names this card and both halves: "objectui#8442 — the two halves of an actionable approval, dropped here until now, and they arrive from DIFFERENT places" — the envelope rides the persisted PART, the id rides the tool RESULT and 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". objectui#9232 later refined the fallback order for the cache path, and that is recorded in place too.
What is ⛔ NOT this card, and where it lives
The narrowing half of the objectui#8426 chain — the authoring
stateunion shedding the three runtime-only approval states, theUseObjectChatOptions.initialMessagesnarrowing that shipsminor+ BREAKING, theas anydeletion at theuseChatcall, the parts-builder discriminated arms, the deadtoolNameexcess property. ⭐ That belongs to objectui#8426 (currentlypm:blocked), and PR #9229's pin states the envelope is optional precisely so a later tidy-up cannot ship #8426's break under this change's name. ⛔ Nothing of it is owed here.Closing as completed,
pm:queuestripped in the same act.domain:uiexecution seat ·session_011QreXiyMEqKLN4U5daMPVa· read at 2026-09-14T16:22Z
Generated by Claude Code
Found while measuring objectui#8426 (typing
useObjectChat'saiInitialMessagesbuilder). Not fixed there — out of that card's scope. ⛔ Not claimed.What
packages/app-shell/src/console/ai/AiChatPage.tsx,partToolState(currently:135) andhydratedMessagesToChatMessages(currently:165), measured onorigin/main@ca3942729.partToolStatereturns'approval-requested','approval-responded'and'output-denied'verbatim from the server-persisted part (:146-151). The part it reads isHydratedUIMessagePart, declared{ type: string; text?: string; [key: string]: unknown }— so whatever else the server persisted on that part is present in the value and reachable.The mapper then builds the invocation from exactly six things:
toolCallId,toolName,state,result, the four detected ObjectStack envelopes, anderrorText(:205-217). It never reads:approvalenvelope on the part ({ id, approved?, reason?, isAutomatic?, signature? }), which the SDK's part union makes required for each of those three states; andpendingActionId— whichmapMessages.extractToolInvocationsdoes lift on the live path (packages/plugin-chatbot/src/mapMessages.ts:687), and whichuseHitlInChatuses as its index key (useHitlInChat.ts:154-166: it indexestoolCallId -> pendingActionIdand skips any invocation without one).Why it matters
Two consequences, stated at the confidence each was measured at:
Measured by type, at objectui#8426. These three states are not constructible as a declared AI SDK
UIMessagePartwithout theapprovalenvelope. Three explicit pushes, each a compiler error TS2345 againstUIMessagePartinpackages/plugin-chatbot:{ type: 'tool-NAME', toolCallId, state: 'approval-requested', input }, and the same for'approval-responded'and'output-denied'. The seven other authored states build fine. So this drop is the direct blocker on objectui#8426's contract-first fix — the state survives the hydration hop, the data that state requires does not.Read from the source, not exercised in a browser. A rehydrated pending approval reaches
useHitlInChatwithstate: 'approval-requested'and nopendingActionId, so it is not indexed by that hook.ChatbotEnhanced.tsx:2128gates the awaiting-approval affordance onstateandonToolApproveonly, so the card still renders as awaiting. Whether the operator then gets a working Approve/Reject or a dead one was NOT verified end to end here — the reading is the missing index entry, and someone should confirm the UX before costing a fix.The live path does not have this gap:
mapMessagessynthesizesstate: 'approval-requested'from the result envelope and liftspendingActionIdin the same pass, so the two always arrive together there. Only the hydration path can produce one without the other.Note on scope
The
output-deniedpass-through is deliberate and pinned:packages/app-shell/src/console/ai/__tests__/AiChatPage.hydration.test.ts:44feeds a persistedstate: 'output-denied'part and:49asserts the invocation keeps that state. So the fix direction is "carry the envelope too", not "stop carrying the state".Dedup
search_issuesover this repo for the hydration mapper and the dropped approval id — 2 hits, both read: objectui#2477 (open, Console AI chat post-cleanup UX follow-ups; does not name this) and objectui#2450 (closed, second-handoff auto-send). Neither names the dropped approval envelope orpendingActionId.Related
objectui#8426 (blocked on the ruling this feeds) · objectui#8378 · objectui#8342 · objectui#5695