fix(chat): dispatch orchestrator plan follow-ups from grid panes - #6040
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of The repo has an established Design-Verdict: CONCERNS Sound parity port at the right layer; but the fail-closed latch turns any ambiguous failure into a silently dead chip — including Cancel, the stop control. Watch
[DESIGN-REVIEWED] bac4a60 |
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: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS The fix and its hardening are derived and declared, but the root cause has one uncounted sibling: ChatEmbed drops What this change shipsIntent: FIX — a plan approval chip clicked in a grid pane must dispatch the plan action instead of typing the label into the composer.
Watch
[FIRST-PRINCIPLES-REVIEWED] bac4a60 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe single candidate hinges on a plan/options row falling through No self-originated finding survives the same bar. No findings. [OPUS-REVIEWED] bac4a60 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of UX-Verdict: CONCERNS The fix makes the chip keep its promise, but every new dispatch path — success, refusal, and failure — is completely silent, including a wedged Cancel. Watch
[UX-REVIEWED] bac4a60 |
a2884c0 to
2caa07c
Compare
|
🤖 Local adversarial review round 1 (pre-server-lanes) — two pinned lanes, both BLOCK-MERGE; every finding verified against source and dispositioned in head GPT lane (
Opus lane (
Re-verified after the round: typecheck + eslint clean, 1552 test files / 24,305 passed / 0 failed, electron 1332/0, 11/11 mutations killed, evidence regenerated from a true |
2caa07c to
9b4ae58
Compare
|
🤖 Local adversarial review round 2 on head Adopted:
Accepted, not changed (with rationale): Re-verified on |
9b4ae58 to
5881a6f
Compare
|
🤖 Local adversarial review round 3 on head Adopted:
Filed, not changed here: Accepted: the latch-release flush style (single microtask await under fake timers — fails loud, not false-green) and the no-client-timeout trade-off are now named in the docstring. Re-verified on |
5881a6f to
9c89a3a
Compare
a1a2ddf to
dc9e44e
Compare
Round 5 disposition — head
|
| Failure | Verdict | Why |
|---|---|---|
ApiError 4xx excluding 408/429 (400, 403, 404, 409, …) |
RELEASE | A response the server produced declining to act. Nothing was mutated, so retry is safe — and not releasing would leave a dead chip on a recoverable error. |
ApiError 5xx |
RETAIN | Can be raised after queue_append committed. Not proof of non-mutation. |
Non-ApiError (bare TypeError from fetch) |
RETAIN | Never reached j(), so there is no status at all. The request may have been fully served with only the response lost. |
ApiError 408 / 429 |
RETAIN | 4xx status, ambiguous meaning: 408 is an edge giving up on a request it may already have forwarded, and 429 is the tunnel throttle that api/queryClient.ts itself treats as retryable. A retryable rejection is by definition not proof of non-mutation, so the release cannot key on the 4xx range alone. |
No new plumbing: api.planAction already funnels through j(), which throws the exported ApiError carrying .status.
The source-key guard is unchanged and still applies on top. The two conditions are independent and both necessary — the classification narrows which failures qualify, the guard narrows whose latch is freed. The latch structure and the settled success/ack lifecycle are untouched.
2. Trade-off, stated in code
A genuinely lost dispatch now keeps that action class latched until a different plan row arrives (or a reload). On the Go map that is the safe direction; on the Cancel map it wedges the stop control for that slot. The user-visible cost is that the wedged retry has no affordance — the chip silently does nothing, because this hook renders no pending or error state. Recorded in the hook's latch docstring and in the onError comment, both citing #6056, which is the right home for the fix (the shared FollowUpBar, so every chip surface gains it at once). Deliberately not fixed here.
3. Tests — split, red-before, mutants
The old single case a failed dispatch releases its own class for retry — and only its own became four, all in website/src/test/ChatPane.followUpOptions.test.tsx:
| Test | Rationale |
|---|---|
a DEFINITIVE 4xx rejection releases its own class for retry — and only its own |
the original per-class assertions, now rejecting with ApiError(400) — the only release-eligible shape |
a 5xx failure KEEPS the latch — the server may have committed and lost the response |
ApiError(500); the retry Cancel must be dropped |
a TRANSPORT rejection with no response KEEPS the latch |
TypeError('Failed to fetch'), the no-status path |
a retryable %i KEEPS the latch even though it is a 4xx (it.each([408, 429]), one slot key each) |
pins that the release excludes the ambiguous 4xx, so a future refactor to a plain range check fails here |
Red-before, proven by stashing only the hook change and re-running: the 4 retain cases fail, AssertionError: expected "vi.fn()" to be called 1 times, but got 2 times; the other 23 pass. With the hook change: 27/27.
Mutants — 3 dispatched, 3 killed, one test each:
| Mutant | Killed by |
|---|---|
release on 5xx too (drop the < 500 bound) |
a 5xx failure KEEPS the latch (1 failed / 26 passed) |
release on non-ApiError too |
a TRANSPORT rejection with no response KEEPS the latch (1 failed / 26 passed) |
release on 429 (drop it from AMBIGUOUS_4XX) |
a retryable 429 KEEPS the latch — 408 still passed (1 failed / 26 passed) |
Two pre-existing tests were re-typed, not weakened — both rejected with a plain Error, which the new rule correctly retains, so they had to name a release-eligible error to keep testing what they were written to test:
a Go superseded by a Cancel … still runs its error release→ApiError(409). Verified necessary: left asnew Error('502')it fails against the fixed hook (expected 3 times, got 2), because the retain is then doing the work and the test can no longer see whetheronErrorran at all.a LATE failure from an old dispatch does not free a newer dispatch's latch→ApiError(409), i.e. an error that is release-eligible. Otherwise the classifier would hold the latch on its own and the test would pass green without ever exercising the source-key guard it exists to pin.
4. First Principles subtraction: delete releasePlanLatches — ACCEPTED, deleted
Verified honestly, repo-wide (grep -rn releasePlanLatches over .ts/.tsx/.js/.mjs/.md, excluding node_modules): three hits total — the definition, and one import + one beforeEach call in ChatPage.followUpToggle.test.tsx. Zero production consumers. No rebuttal available, so the export is gone.
The escape hatch existed because both plan tests in that file shared the fixed slot key chat-1, so it took the lane's suggested remedy: makeStore / renderPage now take a slot key and the two plan tests dispatch on chat-plan-dispatch and chat-plan-allowlist. Unique keys make the module-latch collision structurally impossible rather than reset per test, which is the stronger version of the same guarantee — and it removes an exported production-facing function whose only purpose was test hygiene.
5. Design lane: file a tracking issue for the onFollowUpSend double-click path — filed as #6240
#6240. FollowUpBar's handleDoubleClick and the visible Send now split-button segment both call onSend?.(…), skipping the onSelect branch where this PR's plan gate lives — so on a plan footer they send the literal label as ordinary chat. Cancel is the sharp edge (not special-cased server-side, so the plan is never stopped); Go/Go All degrade less visibly via chat_handlers.py's typed-approval path, losing Go All's auto-run escalation. Both gestures also bypass the single-flight latches entirely.
Separable because routing onSend through the plan gate is a shape change in the shared chip component affecting every follow-up surface (ChatPage, ChatPane, SideChat, ChatEmbed) — a different decision than this parity fix makes, and not one to ride along on it.
Verification run (docker node:24-bookworm; the host is node 16)
tsc -b— cleaneslinton the three touched files — clean (1 pre-existingno-consolewarning on the unchangedconsole.error('plan action failed', e))vitest runonusePlanActionMutation.test.ts,ChatPane.followUpOptions.test.tsx,ChatPage.followUpToggle.test.tsx— 57/57 passed (ChatPane 23 → 27)vitest run src/test/ChatPa(wider blast radius for the shared hook) — 58 files, 565/565 passedjscpd .— 0 clones
Not rebased: main is ahead but has zero overlap with this PR's files, behind_by 0.
🤖 AI-assisted review response. Amended onto the single commit (PR Hygiene 3-commit cap); force-pushed with a SHA-pinned lease from a1a2ddf1c → dc9e44eeb.
dc9e44e to
aa4c1a7
Compare
|
Rebase + semantic-merge fix — head The previous head failed The wheel/desktop/E2E failures were all downstream of that one frontend build. Resolution. The note branch now returns a row identity like every other options-bearing branch. The identity expression was duplicated in the process, so it is extracted into one This is not merely a compile fix: a Verification (docker
Still one commit; no behavioural change to the transport-classification fix dispositioned above. |
aa4c1a7 to
917f058
Compare
Round 6 disposition — debounce/stale-click finding ACCEPTED and applied
The mechanism
Neither existing guard covered it:
Net effect: one click on a footer the user was looking at approved the stage that had silently replaced it. The design (as prescribed)
Every existing guard is intact — the Why no existing caller breaks
Tests — 5 added, each red before the source changeRed-before proven by stashing only the five source files and re-running: 5 failures.
Two of these (the matching-key and no-key controls) assert unchanged behaviour and so are green before the change by construction; they earn their place as the detectors for mutant (c) below. The other four are red before, green after. One existing assertion was updated: Mutants — 3 planted, 3 killed
Gates (docker
|
deriveFollowUpOptions flags plan follow-ups (followUpIsPlan) and ChatPage dispatches them to the slot-scoped plan-action endpoint, but ChatPane dropped the flag, so clicking Go in a grid pane appended the literal label to the composer -- one Enter away from sending it to the agent as an ordinary chat message. Port ChatPage's plan branch to ChatPane with the same ordering (guard ahead of quick-send/toggle), dispatching against the pane's OWN slot, gated on the slot record's mode (the same source ChatPage's effectiveMode reads). The mutation is lifted into a shared usePlanActionMutation hook used by both hosts, which also: - allowlists the action (isPlanAction: go / go all / cancel -- the only labels the plan pipeline emits and the only actions the endpoint accepts), so a plan-SHAPED message with non-protocol labels keeps the composer path instead of firing a dispatch the server would 400; - single-flights dispatches per slot per action class with synchronous module-level latches (the render-snapshot isPending cannot stop two clicks in one tick, and one session can occupy two grid panes): a duplicate Go/Go All is dropped (it would advance an extra stage), a duplicate Cancel is dropped (the server appends 'Plan cancelled' on every POST), and Cancel is never blocked by a pending Go -- the stop control must not be swallowed while a Go settles. The hosts' own render-scoped isPending pre-checks are removed for the same reason; - does not expose mutateAsync (it would bypass the single-flight); - logs dispatch failures. ChatPane additionally no-ops a plan click while its slot record is unresolved (reload with a restored grid: transcript hydrates before the first WS slots snapshot) -- the mode is unknown in that window, so neither dispatching nor appending is safe. Deletes ChatPane's stale 'panes have no orchestrator plan mutation' comment. SideChat is deliberately NOT changed: its transcript is a separate side session that never legitimately carries the parent's plan, so a plan branch there would actuate the parent's plan from side-agent output. The server-side cancel-before-tracker race surfaced during review is pre-existing and tracked in #6046. Fixes #5893 Latch release is classified rather than unconditional: onError frees a latch only for a DEFINITIVE pre-mutation rejection -- an ApiError in the 4xx range excluding the retryable 408/429. Any 5xx, a bare transport rejection (fetch rejects with no status), and 408/429 RETAIN the latch, because the server may have committed the action and lost the response on the way back; releasing there would let a retry queue a second Go or write a duplicate cancellation row. The existing source-key guard is unchanged and still applies on top -- the classification narrows WHICH failures qualify, the guard narrows WHOSE latch is freed. Trade-off: a genuinely lost dispatch stays latched until the next plan row arrives, a wedged retry with no visible affordance, tracked as #6056. Drops the releasePlanLatches test escape hatch (no production consumer); the two ChatPage plan tests now dispatch on their own slot keys, which makes the module-latch collision it guarded against structurally impossible. The onFollowUpSend double-click / Send-now path still bypasses the plan gate and is tracked in #6240. A chip click is DEBOUNCED by FollowUpBar (220ms, so a double-click can cancel it), and a byte-identical replacement footer re-renders the same chips WITHOUT remounting them -- so the pending timer outlives the row it was armed on and fires after the transcript already advanced. Neither guard stopped that: the acknowledgement effect had already freed the latch for the NEW row, and a live row was on screen so the null-source refusal did not apply. One click on a stale footer therefore approved the stage that replaced it. FollowUpBar now takes an optional `sourceKey` (the host's derived `followUpSourceKey`), Chip snapshots it at CLICK time alongside the existing shiftKey/detail snapshot, and hands it to `onSelect` as an optional THIRD argument; ChatInput forwards it, and both hosts pass it to the hook as `clickedSourceKey`. `mutate` refuses a supplied key that no longer matches the current row -- before touching the latch, so a stale click cannot consume the new row's single-flight slot either. A caller that supplies NO key behaves exactly as before (`undefined` is not a mismatch), so refusing it wholesale cannot silently disable dispatch for an unwired chip surface. The non-debounced instant-send path is unchanged: it calls onSelect synchronously from the click, so there is no window for the row to advance. Every existing guard is untouched (null-source refusal, per-slot per-class single-flight, the onError isDefinitiveRejection classification and its source-key guard), and the latch/acknowledgement lifecycle is not restructured.
917f058 to
bac4a60
Compare
|
Round-7 disposition — ACCEPTED and applied. Head The lane asked to prefer
So the store's row key is Worth recording: this is the same defect the Opus lane flagged as F1 (Critical) several rounds ago — Verification (docker
Still one commit. Process note: this is the seventh validated finding on the plan-dispatch span. Every one has been mechanically real and none has inverted an earlier demand, so I applied it rather than escalating — but the span has now absorbed seven rounds, and a maintainer may reasonably want to draw a line even on legitimate findings. |
|
Advisory disposition — all three CONCERNS lanes on The three advisory lanes converge on one substantive concern, so I'll take it first. 1. The wedged Cancel — raised independently by Design, First Principles and UX. NOT changed, and deliberately surfaced for a human.All three make the same argument and it is correct on its own terms: fail-closed is the right direction for I am not applying it, for a reason the lanes cannot see from their own vantage point: it directly reverses this PR's round-5 blocking demand from the GPT lane, which named this exact behaviour as the defect —
So the two review families disagree on which harm dominates for UX's "smallest fix inside this PR's surface" — mark the clicked chip pending/disabled and surface definitive rejections inline — is the right fix and I agree it has one root cause and one fix point. It is also a new rendering surface across every chip host, i.e. a scope extension beyond this PR's parity fix, which is exactly why it is #6056. Landing #6056 before this reaches real users is a reasonable release gate and I'd support sequencing it that way. 2. First Principles — "unfixed sibling:
|
Problem / Motivation
deriveFollowUpOptionsreturns{ followUpOptions, followUpIsPlan }on every branch. ChatPage reads the flag and dispatches plan follow-ups (Go/Go All/Cancel) toPOST /api/chat/slots/{slot}/plan-action.ChatPanedropped the flag — it destructured{ followUpOptions }only, with zeroplanActionreferences — so a plan chip in a grid pane fell through to the generic composer-append path.Corrections to the issue as filed, plus one to the initial triage of this PR itself:
needs-investigationparking reason is obsolete. It was parked because the chips were not onmain; fix(chat): pass derived follow-up options into ChatPane's composer (#5870) #5895 merged 2026-08-26T04:33Z and put them there (its own body names this exact delta as "Tracked as follow-up ChatPane renders plan-approval follow-up chips but cannot dispatch plan actions #5893"). The defect is live.Cancelwould stop the parent's orchestrator tracker and cancel its running subagents from a panel documented as read-only background). Both adversarial review lanes flagged this independently; the branch was removed. Net scope: ChatPane + the shared hook + ChatPage consuming it.Scope decision: parity, not suppression
The issue offered two designs: port plan-dispatch into the pane, or suppress plan chips in panes with a pointer to main chat. This PR takes the port, deliberately: the same control must not mean two different things depending on which surface renders it; suppression trades a misrouting bug for a discoverability one; and
plan-actionis already slot-scoped — which every pane has.What changed
website/src/hooks/usePlanActionMutation.ts(new): the plan-action mutation, lifted from ChatPage's inline copy so both hosts share one convention. Review-hardened beyond the lift:isPlanActionallowlist — the endpoint accepts onlygo/go all/cancel(case-normalized server-side), and the plan pipeline normalizes every real plan footer to[OPTION: Go | Go All | Cancel]. Hosts gate on it so a plan-shaped message with non-protocol labels (an agent quoting a plan while offering its own choices) keeps the composer path instead of firing a dispatch the server would 400 — which would also skip the append, leaving a dead chip.mutation.isPendingis a render snapshot and one session can occupy two grid panes, so the latches are module-level and synchronous. Lifecycle (converged over two adversarial-review rounds): a FAILED dispatch releases its class immediately for retry; a SUCCESSFUL one stores the identity of the options-bearing transcript row (followUpSourceKey, new field onderiveFollowUpOptions: the row'smid/ts) and stays held until the hook observes a different non-null row — never a host remount, a slot re-entry, or option-LABEL equality. That closes every stale-chip path: an HTTP 200 with the WS down leaves the chips stale and the latch held (a re-click wouldqueue_appendan unintended extraGo); a pane remount on a warmstaleTime: Infinitycache re-derives the SAME row and releases nothing; and a single-write reconnect hydration whose stage-2 footer has byte-identical labels still releases, because the new row's identity differs. The acknowledgement lives INSIDE the hook — hosts pass(slot, followUpSourceKey)as arguments, so wiring it cannot be forgotten (a missing argument is a type error). A duplicateGo/Go Allis dropped over the whole window; a re-Cancellikewise;Cancelis never blocked by a pendingGo. The hosts' own render-scopedisPendingpre-checks are removed for the same reason.ChatPane.tsx: destructuresfollowUpIsPlanfrom the existing memo; plan guard prepended ahead of quick-send/toggle with ChatPage's ordering andisPendingcheck; dispatches against the pane's ownslotKey, gated on the slot record'smode(the same source ChatPage'seffectiveModeresolves from). Additionally no-ops a plan click while the slot record is unresolved: on a reload with a restored grid the pane hydrates its transcript before the first WS slots snapshot (the store deliberately refuses pre-slotsLoadedempty frames), so the mode is unknown in that window — dispatching is unsafe and appending re-creates the reported bug. Deletes the stale "panes have no orchestrator plan mutation" comment.ChatPage.tsx: consumes the shared hook and the sameisPlanActiongate (non-protocol labels now append instead of silently 400ing — previously unreachable on the main slot thanks to the footer normalization, but now uniformly guarded).Deliberate non-change, named so it reads as a decision: the chips' double-click-to-send path (
onFollowUpSend) sends the raw label as a chat message on all hosts including ChatPage — pre-existing on the reference surface, untouched here.Tests
ChatPane.followUpOptions.test.tsx(+10),ChatPage.followUpToggle.test.tsx(+2 — the dispatch and the allowlist pinned on the MAIN surface too), andusePlanActionMutation.test.ts(isPlanAction unit, mirroring the server's.strip().lower()), all against the real protocol labels:api.planActioncalled with the pane's own slot andGo) and does NOT append to the composer;Go+Go Alllanding in one tick (both debounce timers advanced inside oneact, no render in between) dispatch once — only the synchronous latch can stop the second;Cancelgoes through while aGois still in flight; a double-Cancel dedupes; a failed dispatch releases only its own class (retry works, the pending Go stays latched); a Go superseded by a Cancel still runs its error release (pinned rather than assumed from query-core internals); a successful dispatch stays latched over the stale-chip window and frees on transcript acknowledgement; an identical next-stage footer in a single hydration write still releases (row identity, not labels); a pane remount on a latched slot with a warm cache does NOT release (same stale row);Mutation-checked (25/25 killed across six rounds; one documented survivor — the null-never-releases guard is defense-in-depth whose bypass requires a live stream, which contradicts the stale-chip premise): guard disabled / always-on / wrong slot / allowlist dropped (each host) / unresolved-slot fall-through restored / latch removed / latch-release (
onSettled) dropped / cancel-bypass removed /isPlanActionnormalization dropped — each confirmed red, then restored.Verification
tsc -bclean.origin/mainworktree produced identical failure-id sets (one delta reproduced on main with the same node_modules present — host Node version, environmental).black==26.3.1, 0 python files in scope).gpt-5.6-sol,claude-opus-5) ran pre-push; every finding was verified against source and addressed (SideChat removal, allowlist, synchronous latch, unresolved-slot no-op, protocol-label fixtures) — disposition details in the PR comment below.Screenshots / video
Evidence harness
website/scripts/capture-chatpane-plan-dispatch.mjsruns the real built SPA with stubbed/api, seeds a split view whose first pane is an orchestrator session mid-plan (footer exactly[OPTION: Go | Go All | Cancel]), clicksGo, and asserts the wire behaviour before shooting — pre-fix build (trueorigin/maindist): zero plan POSTs and the composer containsGo; fixed build: exactly onePOST /api/chat/slots/chat-orchestrator/plan-actionwith{"action":"Go"}and an empty composer.Before (origin/main build): the click types the label into the pane's composer:
After (this PR): the click dispatches the plan action; composer stays empty:
Plan chips rendering in the pane pre-click
Related Issues
Fixes #5893
The pre-existing server-side cancel-before-tracker race surfaced during review is tracked in #6046 (not introduced here; the client behaviour is correct given the server contract).
Checklist