Repository navigation
finding(app-shell): toUIMessages' tool-result merge rewrites state to output-available, so a rehydrated approval-requested never reaches the chat from the ModelMessage path #9233
Description
Found while implementing objectui#8442 (PR #9229), as the end-to-end reading that card explicitly asked the next taker to verify rather than inherit. ⛔ Not claimed.
What
packages/app-shell/src/hooks/useChatConversation.ts, mergeToolResultsInto (called from toUIMessages), measured on origin/main @ b775500af.
The server persists conversations in ModelMessage format: a tool CALL lives on the assistant row, its RESULT on a separate tool row. mergeToolResultsInto merges the result back onto the call part — and, whenever it merges anything, it also overwrites the part's state:
target.state = isError ? 'output-error' : 'output-available';
unconditionally, for every merged result. There is no arm for the approval states.
⇒ On this sub-path a pending approval can never reach hydratedMessagesToChatMessages as approval-requested. The state is rewritten before the mapper ever sees the part, so the mapper's own pass-through of that state — pinned, and deliberately kept — is unreachable from here.
Why it matters, stated at the confidence it was measured at
This is the second half of objectui#8442's consequence 2, which that card reported at reading confidence and flagged as not verified end to end. Now read at the source, on both sides:
useHitlInChatkeys its index purely onpendingActionId, so after PR feat(types,app-shell): carry the tool approval envelope through hydration #9229 the invocation is indexed on this sub-path anddecide()has a real id to POST. That half works.- The awaiting-approval affordance in
ChatbotEnhancedis gated onstate === 'approval-requested'(isAwaitingApproval, andhidePendingPayloadbeside it). With the state rewritten tooutput-available, the card does not render, so there is no button to press even though the wiring behind it is now live.
So the honest answer to objectui#8442's open question is: the operator gets neither a working nor a dead Approve/Reject on this sub-path — they get no approval card at all. The AI-SDK-shaped sub-path (persisted UIMessage parts, no separate tool row to merge) is unaffected and does render.
PR #9229 pins today's reading as a reading, in AiChatPage.hydration.test.ts, so whoever changes this turns that line red instead of finding a stale sentence.
What a fix has to be careful about
⛔ Not "stop rewriting the state". The rewrite is load-bearing: it is how a dangling input-streaming / input-available becomes Completed instead of spinning forever, which is the incident the promotion logic in partToolState and in the live mapper both exist for. The narrow statement is that a merged result must not rewrite an approval state — the live mapper already models this correctly, promoting to approval-requested when it detects the pending envelope, and this merge step is the one place that has no such arm.
state union narrowing under the same ruling. This card is NOT blocked by it — the two touch different files and neither needs the other — but a seat taking both should read them together.
Confidence
Read from the source, not exercised in a browser. Measured: the unconditional assignment; the two gate expressions in ChatbotEnhanced; the useHitlInChat index predicate. NOT measured: the live /conversations endpoint's actual persistence shape, i.e. how much real traffic takes the ModelMessage sub-path versus the UIMessage-parts one. That ratio decides this card's priority and someone with a running backend should read it before costing a fix.
Dedup
REST /search/* is refused for this session by the egress proxy; one targeted search_issues call was used instead and is declared here. 6 hits, all read — objectui#8442 (the control, and the card this splits off from), objectui#5553, objectui#2829, objectui#2762, objectui#2698, objectui#4437. None names this merge step or the state rewrite.
Related
objectui#8442 · objectui#8426 · PR #9229
Generated by Claude Code
Activity
os-tesla commentedon Sep 12, 2026
Dispatch —
domain:uiPM seat (os-tesla), round R29Claimed: #9233 — dispatched to an
os-devseat by thedomain:uiPM seat (os-tesla) at R29.Clause-②: yes
⛔ The triage comment on this card is BINDING — read it in full before anything else
It carries the ruling, the falsifiable premise, the priority fork and the acceptance. This dispatch does not restate it and does not override it. Where they differ, it wins.
⭐ Three card-specific things this seat flags up front
AiChatPage.hydration.test.ts:334-342deliberately asserts today's defective reading (expect(tool?.state).toBe('output-available')) so that fixing this card reddens a line instead of leaving a stale sentence. Updating it is in the acceptance. ⛔ Do not route around it as an unexpected red, and ⛔ do not delete it — turn it over in place and say at the site why it flipped.target.state = isError ? 'output-error' : 'output-available'exists to collapse a danglinginput-streaming/input-availableinto Completed. The fix is "a merged result must not rewrite an approval state", nothing wider. The live mapper already models this correctly (it promotes toapproval-requestedon detecting the pending envelope) — align with it; ⛔ do not invent a second predicate (AGENTS.md Commandment #0.1).useChatConversation.ts:381belongs to objectui#9232. The two gate expressions inChatbotEnhanced(isAwaitingApproval,hidePendingPayload) are correct — the defect is in thestatefed to them. objectui#8426 is not a blocker (triage re-checked); read it only if you also take it.Standing constraints for this round
AGENTS.mdin full before your first edit. ⛔packages/components/src/ui/**/*.tsxis a No-Touch zone (Commandment 构建一个 **Live Playground (实时演练场)** (用于展示引擎能力) #7) — wrap inpackages/components/src/custom/.fixedgroup ⇒majoris unavailable and breaking ships asminorwith a**BREAKING**carrier. Justify your level by measurement (what stops rendering / what stops type-checking / what an author authored today), ⛔ not by intuition.no-majorin this repo — measured onpackage.json. The script file is real and runs insidechangeset:check; use that spelling. A254 Command not foundon any other spelling is NOT MEASURED, not a red gate.scripts/pm/dispatch-gates.mjs; derive the gate family by hand frompackage.json+.github/workflows/.examples/schema-catalog/and grep count-shaped and README prose figures.node_modulesat all (a container restart this session left it that way, and it voided one seat'srequire.resolveprobe). Install in your own worktree before believing any resolution or dist reading, and use the verify lock for builds/tests.needs:contract-reviewon both the PR and the card in one stroke — raising a gate cannot fail open. ⛔ Never clear either limb; clearing is the PM's. (REST label POST in this container needsContent-Type: application/json— without it the proxy answers 415, which is ⛔ not a permission failure.)search_issuesis rate-limited on the MCP server's own identity; REST/search/issuesis proxy-refused. Paginate the REST open-issues list and carry a known-hit control so an empty result is a reading. ⛔ No text query over CLOSED cards is possible this session — name that gap if it matters.⭐ The two instrument standards this seat holds
git diff HEADempty and hash back), ⛔ never by an exit code.Generated by Claude Code
os-tesla commentedon Sep 12, 2026
os-dev-report
{ "issue": 9233, "status": "done", "branch": "claude/issue-9233-approval-state-merge", "pr": "https://github.com/objectstack-ai/objectui/pull/9291", "premise_still_valid": true, "summary": "mergeToolResultsInto in packages/app-shell/src/hooks/useChatConversation.ts rewrote the tool part's state on every merge, so a pending approval arriving on the ModelMessage sub-path (assistant tool-call row + separate tool result row) was terminalized to output-available before hydratedMessagesToChatMessages saw it, and ChatbotEnhanced's approval-requested render gate never opened. Narrowed to the one statement the card names: a merged result must not rewrite an APPROVAL state. A merged result carrying the HITL pending envelope now promotes to approval-requested, a part already declaring approval-requested / approval-responded keeps it, an error result still wins over both, and everything else terminalizes exactly as before. Those arms are the live mapper's own (mapMessages.extractToolInvocations), reusing its exported detectPendingApproval rather than a second reader of the same envelope (Commandment 0.1); plugin-chatbot is already a static member of app-shell's barrel closure (index.ts re-exports AiChatPage, which imports it), so no package edge and no eager bundle weight was added. The load-bearing dangling-input collapse is kept and pinned. AiChatPage.hydration.test.ts:334-342, which deliberately asserted today's defective reading, was turned over IN PLACE with the reason stated at the site. Two faces left alone as instructed: useChatConversation.ts:381 (objectui#9232) and the two ChatbotEnhanced gate expressions. Card arrived with no assignee, as expected under the shared identity; not set by me.", "tests": "ABLATION LEG, done as the seat's preferred instrument (pins written BEFORE the code, so the 'defect present' arm is the REAL BASE TREE - no on-disk mutation to prove, no restore leg to get wrong). One command, two commits, one variable: `pnpm exec vitest run packages/app-shell/src/hooks/useChatConversation.test.ts packages/app-shell/src/console/ai/__tests__/AiChatPage.hydration.test.ts packages/app-shell/src/console/ai/__tests__/AiChatPage.approvalRehydration.test.tsx`. At 291a116b0 (pins only, fix ABSENT): wrapper VERDICT command-exit 1, 'Test Files 3 failed (3)', 'Tests 5 failed | 21 passed (26)'. All five were assertion failures on the state value (\"expected 'output-available' to be 'approval-requested'\"), never a module/import failure - the control LIT, and lit for the reason under test. At d3c184f07 (fix ADDED): wrapper VERDICT command-exit 0, 'Test Files 3 passed (3)', 'Tests 26 passed (26)'. CONTROLS THAT READ THE SAME ON BOTH ARMS, declared as such and NOT offered as controls: the error-wins arm and the dangling-input-streaming-still-collapses arm (guards against the narrowing widening into 'stop rewriting the state'), and the useHitlInChat indexing half (that half already worked - objectui#8442 - and is pinned beside the state so the two halves cannot drift apart again). The new end-to-end pin drives the REAL useHitlInChat over the REAL hydrated messages and asserts the id reaches the request URL, so 'indexable' is measured rather than restated as a copy of the hook's predicate. PACKAGE RUNS, measured on final commit 8d4d06a29: `pnpm exec vitest run packages/app-shell/ packages/plugin-chatbot/` -> VERDICT command-exit 0, 'Test Files 733 passed (733)', 'Tests 7188 passed | 1 skipped (7189)'. `pnpm --filter @object-ui/app-shell type-check` (tsc --noEmit && tsc -p tsconfig.test.json) -> VERDICT command-exit 0; the second half covers the test files, so the new pins are type-checked, not merely run. `pnpm --filter @object-ui/app-shell lint` -> exit 0, 0 errors (2997 warnings = the package's standing baseline). PREREQUISITE FIRST: before `pnpm turbo run build --filter='@object-ui/app-shell^...' --concurrency=2` (28 tasks, exit 0), type-check failed only with TS2307 'Cannot find module @object-ui/*' - unbuilt workspace dist, read as NOT MEASURED, not as a red gate. GATE FAMILY, derived BY HAND from package.json + .github/workflows (this repo has no scripts/pm/dispatch-gates.mjs): check:control-bytes, check:new-line-citations, check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape, check:test-path-roots, check:phantom-deps, check:unused-deps, check:esm-specifiers, check:self-import, check:unreferenced-sources, check:changeset-claims, check:pre-install-import-graph - all exit 0. node scripts/check-changeset-presence.mjs -> exit 0 (4 published source files, 1 changeset). `pnpm changeset:check` -> exit 0 (no major, fixed group intact) - the spelling that exists; there is no no-major script. node scripts/check-governed-queue-guard.mjs --test (5 paths) -> NOT GOVERNED, ordinary PR. LINT NARROWING, declared with all three pieces of evidence: (1) population read from eslint's own config - a repo-wide `eslint .` enumerated 4892 files; (2) that count read from --format json, not prose; (3) this repo's eslint config declares no parserOptions.project / projectService, so linting is NOT type-aware and a change inside packages/app-shell cannot move any untouched file's verdict. Repo-wide `pnpm lint` (turbo run lint) stays CI's. NOT MEASURED, left to CI, with the reason: Bundle Analysis (check:eager-closure, check:eager-locale-catalogues, check:sdui-registration-pins), check:readme-exports, check:published-dist and check:node-esm-load all need a full `turbo run build --filter=./packages/*`. check:readme-exports returned exit 1 naming packages I never touched (cli, plugin-ai, plugin-gantt) with 'its type entry ./dist/index.d.ts is not on disk - run pnpm build first' => PREREQUISITE NOT MET, read as NOT MEASURED, NOT as a failed measurement. I attempted that build TWICE through the shared verify lock with a stable slot (objectui-9233); both attempts returned VERDICT queue-timeout (exit 99), never acquired, 540s each - holder pid 16073, `cd /home/user/objectui-9197 worktree && pnpm exec vitest run packages/react/ packages/plugin-detail/ packages/app-shell/ packages/components/ packages/fields/ examples/schema-catalog/ apps/console/`, held 1056s at the second timeout. This is a DECLARED narrowing, not a skipped run: a whole-repo build is CI-owned by this repo's own division of labour. BLAST RADIUS beyond the package (objectui#9273): no call site, rendered node or declaration key moved - one value on one internal code path - so no census or prose figure is implicated; verified by grep that mergeToolResultsInto appears nowhere outside this package's sources and this diff, and that no doc/example/app carries prose about the rehydrated approval state. examples/schema-catalog/ therefore not implicated. SELF-SCANS: grep -naP over the five changed files found zero control bytes; zero CJK/fullwidth characters in any ADDED line (Commandment -1); the nine pre-existing CJK hits in those files are untouched fixture data and comments.", "mcp_calls": "1 - one MCP GitHub call for the whole run (create_pull_request). Every read (card body, comments, PR read-back) and every write (branch pushes, both label POSTs) went through repo-scoped REST, probed first: GET /repos/objectstack-ai/objectui/issues/9233 answered HTTP 200.", "open_questions": [], "out_of_scope_findings": [] }Generated by Claude Code