fix(chat): give each same-tick tool row its own disclosure identity - #8289
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Right seam: disambiguates the load-bearing disclosure identity without disturbing the pinned row-key contract or remounting rows; residue and alternatives are evidenced, not asserted. Verified against the code: the wrapper keys at [DESIGN-REVIEWED] f05f0c2 |
UX Review (Fable 5) — ✅ PASSUX-level review of The diff is small and self-contained — one function plus a call-site change in UX-Verdict: PASS Pure interaction bug fix — expanding one tool row no longer expands its same-tick siblings; no strings, layout, or flows touched. [UX-REVIEWED] f05f0c2 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All claims verified. I have what I need for the verdict: the fix is cause-level for its scope, the pinned First-Principles-Verdict: CONCERNS The tool-pill fix is cause-level and complete for its scope, but the key-doubles-as-disclosure-identity cause has four unfixed sibling sites in the same file. What this change shipsIntent: make expanding one tool pill stop expanding every tool pill appended in the same server tick — a FIX.
Watch
The deeper alternative — stamping tool rows so [FIRST-PRINCIPLES-REVIEWED] f05f0c2 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
8379d23 to
1b59b9d
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
…8204) Two or more tool rows appended in the same server tick share one messageRowKey (${role}-${ts}; tool rows are never clientTs-stamped), and ChatPage keyed the toolDisclosure map by that row key — so expanding or collapsing one row expanded/collapsed every same-tick sibling. Fold meta.tool_call_id (ACP-issued, globally unique) into a separate disclosure identity via a new toolDisclosureKey helper, applied only at the ToolCallLine render site. The React key and messageRowKey itself are deliberately untouched: sibling uniqueness is owned by the keyed wrapper, and the key-stability suite pins messageRowKey(tool) === 'tool-<ts>'. Every other role keeps its existing disclosure key shape. Regression test adapts the issue's reducer-driven probe: 3 same-tick tool rows with distinct tool_call_ids yield 3 distinct disclosure keys, with per-item pairing assertions. Closes #8204 Co-authored-by: chenmingwei23 <45910466+chenmingwei23@users.noreply.github.com>
1b59b9d to
f05f0c2
Compare
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix -- gives each same-tick tool row its own disclosure identity so expanding one no longer expands all (#8204).
dwu96
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: clear root cause (#8204) — tool rows are never clientTs-stamped, so a burst appended in one server tick all shared the messageRowKey 'tool-', and ChatPage reused that string as the toolDisclosure map key, cross-attributing one row's expand/collapse to every same-tick sibling; the new toolDisclosureKey folds meta.tool_call_id into the disclosure identity only, leaving the React key and every other role's key shape untouched. Two frontend regression specs added.
Problem / Motivation
Two or more tool rows appended in the same server tick share a single disclosure entry: expanding one tool pill expands every same-tick sibling, and collapsing one collapses them all.
Root cause (verified on main):
messageRowKey(website/src/pages/ChatPage.tsx) returns${role}-${clientTs ?? ts}, and tool rows are neverclientTs-stamped — so a coarse OS clock maps a burst of tool rows onto one key. The render site passed that same string as BOTH the React key and thetoolDisclosuremap key, and the disclosure-map role is the load-bearing one (eachrenderMessageelement is the sole child of a separately keyed wrapper, so sibling uniqueness was never at stake).Split out of #4394 as the measured residue after #7207 closed the primary site.
Why it matters
Any turn where the agent fires several fast tool calls (the common case: parallel reads, batched checks) lands multiple tool rows on one tick. A user trying to inspect ONE tool's output gets every sibling's panel flung open at once, and cannot collapse one without collapsing all — the disclosure control is effectively broken for exactly the busy turns where it is most needed.
What changed (motivation → approach → change)
Symptom → root cause: same-tick tool rows collide on
messageRowKey, and the disclosure map was keyed by it.Approach: disambiguate the disclosure identity only, leaving
messageRowKeyand the React key untouched. Two constraints picked this seam:chatSlice.streamingKeyStability.test.ts) pinsmessageRowKey(tool)to exactlytool-<ts>— folding the id intomessageRowKeyitself would break that adjudicated contract.Change: a new exported
toolDisclosureKey(m, key)appendsmeta.tool_call_id(ACP-issued, globally unique) to the row key forrole === 'tool'rows that carry one, and returns the key unchanged for every other role and for id-less tool rows. The ToolCallLine render site now reads and writes thetoolDisclosuremap under that key. The sibling SDK render path (app-sdk/ChatMessageList.tsx) needs no change — itsmsgKeyis index-bearing and never collides.Residual scope, deliberately not covered: tool rows persisted with no
tool_call_id(the backend's_tool_meta()returnsNonewhen the ACP event carries no id) keep the pre-existing colliding behavior. Both alternatives fail against evidence:meta.midnever appears on tool rows (measured in #8204 with a control mutation), and an index-based fallback would break disclosure identity across a history prepend — a worse defect than the one it patches. This PR closes the defect for all id-carrying rows, which is every live-path tool row.Tests
website/src/test/toolDisclosureKey.collision.test.ts— adapts Same-tick tool rows share one disclosure key (messageRowKey) #8204's reducer-driven probe (credit @chenmingwei23): 3 same-tick tool rows with distincttool_call_ids through the realrefreshSlotreducer yield 3 distinct disclosure keys, with per-item pairing assertions (not set-size). Plus: id-less tool rows and non-tool roles keep their exact existing key shape, andmessageRowKey's own contract is untouched.website/src/test/toolDisclosureKey.renderSite.test.tsx— pins the render-site wiring through the real ChatPage: two same-tick tool pills, click one, only that one expands (and the mirror: collapsing one leaves the other open). Verified red against a revert of the wiring (dKey→key): the test fails with both pills expanded, so the exact line that regressed is covered.npx tsc -bclean. Backend isort/flake8/mypy clean (no backend files changed).Manual verification
N/A — the render-site test drives the real ChatPage render path end to end (real reducer, real ToolCallLine, real disclosure state), which is the exact user interaction.
Screenshots / video
Why no screenshot: no at-rest pixel change — the delta is interaction-state only (which pill's panel opens on click), pinned by the committed render-site test; same evidence basis as the merged sibling fix #7207.
Related Issues
Closes #8204
Pattern harvest
Rule candidate: review-prompt
Pattern: a string passed as both a React key and a state-map key couples two identities with different uniqueness requirements — check that a colliding render key cannot alias user-visible state.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)