PR: #402: feat(agents): client-tool dispatcher — confirm-first nav + direct on-page tools + stale rejection [Story-1658] by @ankitmaheshwari-com
View PR →
Story 1658 · Epic-1646 Agentic interaction · RFC-APB-001 (confirm-first nav) · RFC-APB-002 §B/§11

The brain proposes, the browser disposes — under guard

Story-1656 gave the brain a map (the surface registry) and a live read of the page (the Current Page Context). Story-1655 gave it a mouth that survives navigation (the Dock + thread). This Story gives it hands: the client-tool dispatcher — the browser half of the agent tool plane. The brain never clicks; it emits typed intents, and this dispatcher turns a plane:"client" intent into a real UI action — resolved by surface_key against the registry, never a string-built URL.

🛰️ Satellite — why an agent that can route itself can also lock the user out

The stakes: the agent's flagship power is moving the user around the app — "show me Acme's matters," "filter to open filings," "open this matter's record." But navigation is the one action that, done eagerly, is hostile. An agent that auto-routes rips the page out from under a counsel mid-thought; an agent that string-builds a URL invents routes that don't exist and 404s; an agent that fires a tool against a page the user already left does the right thing to the wrong screen.

The naive fix — "let the brain call router.push(url)" — detonates three ways at once. (1) The brain doesn't know the URL shape; it knows what a page is. Hand it URL-building and every route rename silently breaks the agent. (2) Cross-page jumps with no confirm turn the chat into a trap door. (3) Between the brain reading context and replying, the user navigates — so the instruction is now about a page that isn't on screen. The real fix keeps the brain in the proposing role and gives the browser a guarded disposing role: resolve by key, confirm before crossing pages, and drop anything that went stale.

✈️ Airplane — decide purely, then act; confirm-first across pages, direct on-page

One analogy: the dispatcher is an air-traffic controller, not a pilot. The brain (a pilot) radios a request — "I'd like to taxi to gate Clients." The controller checks the request is for a gate that exists (registry), checks the plane is still where it said it was (staleness), and for a runway crossing (a cross-page jump) hands back a clearance the user must accept — the "Go to Clients →" chip. For an in-cabin action that doesn't cross a runway (an on-page filter), it just lets it happen, using the plane's own controls (the page's registered handler), not a parallel set.

The dispatcher splits into a pure decision core and a thin React glue. The core is a framework-free function over (instruction, live-context reading) → a typed decision. The glue reads the live surface at dispatch time, performs the chosen effect (route / run / confirm / drop), and emits the live thread items.

Acceptance criterionHow it's met
Execute client-plane intents by surface_key via the registry; never string-build URLs decideClientTool resolves navigate_to through getSurface + buildSurfacePath (surface-registry navigation). An unknown key → rejected:unknown-surface; a missing route param → rejected:missing-nav-params. No URL is ever concatenated.
Cross-page navigate_to → confirm-first chip; on tap route via the Next router + a "took you to X" marker A navigate decision becomes a live navChip thread item ("Go to X →"). It does not auto-route. On tap, the i18n useRouter().push() routes the registry path and a serializable navMarker ("took you to X") is appended to the thread (it survives refresh).
On-page set_filter/open_view/focus execute directly, reusing existing handlers A same-page tool resolves to the page's own CpcClientTool.run registered in the CPC store (Story-1656). The dispatcher invokes that handler — never a parallel implementation — and leaves a quiet applied-note.
Stale-instruction rejection on surfaceKey/contextVersion mismatch A new monotonic contextVersion on the CPC store bumps whenever the active surface or its params change. It rides into the brain's context (stamped on the surface provider). Each client intent echoes the surface key + version it reasoned over; the decision core drops it (stale-surface / stale-version) if either no longer matches the live surface.
State-changing/irreversible → confirm first A CpcClientTool.confirm flag turns a same-page tool's decision into confirm → a proposal card; the handler runs only on explicit confirm. Reversible view ops leave the flag unset and run directly.
i18n en+hi; no pnpm codegen; one repo Four new Agents keys added to both locales. The brain contract (AgentToolIntent) is imported from @/gen as-is; no codegen run. All changes are in pramaan-app.

🔬 Microscopic — the seams that make it correct

Why a pure core and a glue, not one hook

decideClientTool(instruction, live) performs no effect — no router.push, no tool.run, no store write. It returns a discriminated ClientToolDecision. That makes every branch — registry resolution, the confirm-first split, every staleness drop — a plain unit test with no React, no router mock, no DOM. The glue (useClientToolDispatcher) is the only part that touches the world, and it stays small.

Why staleness is read with getState(), not a subscribed value

The whole point of the stale check is "is the surface the brain reasoned over STILL the one in front of the user?" A value captured in a render closure would answer "…as of the last render," which is exactly the stale read we are guarding against. So the glue reads useCpcStore.getState() at the moment of dispatch — the live truth — and compares the intent's echoed surfaceKey/contextVersion against it.

Why the chip is a live item but the marker is stored

A nav chip carries an onConfirm callback — functions can't be serialized, so chips (like proposals) live in the dispatcher's local state and merge after the persisted thread, exactly as the live rail will merge its stream. The resolved navMarker is plain data, so it — and only it — is appended to the thread store and survives a refresh.

The trust boundary: normalize before deciding

clientInstructionFromIntent is where the wire shape is validated and coerced (RFC-APB-002 #1): it drops non-client-plane intents, tolerates snake_case or camelCase envelope keys, pulls only string/number route params, and separates the staleness envelope + display label from the tool's own args. The pure core only ever sees a clean ClientToolInstruction.

Live-data seam (named, per repo doctrine). The brain's intents arrive over the runtime rail (broker, pramaan-spec #356 / T12), which is not yet wired — the same documented STUB boundary as Story-1655's onSubmitTurn. AgentDockMount instantiates the dispatcher and merges its live items today; the rail child calls dispatcher.dispatch(intent) when it lands. The dispatcher itself is fully exercised by unit tests (decision core + wire normalization) and the AgentChatPanel → ClientToolDispatch Storybook story (the chip, the marker, the applied-note). No empty-array "done" is shipped: the logic is complete and tested; only the inbound transport is pending elsewhere.

Files

PathChange
src/lib/agents/client-tool-dispatcher.tsnew pure decision core + wire normalizer
src/lib/agents/use-client-tool-dispatcher.tsnew React glue: live read, router push, live items
src/lib/agents/__tests__/client-tool-dispatcher.test.tsnew 17 tests — registry, confirm-first, staleness, normalization
src/stores/cpc-store.tsedit monotonic contextVersion + tool confirm flag
src/lib/surface-registry/current-context.tsedit contextVersion on snapshot + digest
src/lib/surface-registry/session-context.tsedit stamp contextVersion on the surface provider
src/components/agents/agent-thread-item.tsedit additive navChip item kind
src/components/agents/agent-chat-panel.tsxedit render the confirm-first chip
src/components/agents/agent-dock-mount.tsxedit instantiate dispatcher; merge live items; name the rail seam
src/components/agents/agent-chat-panel.stories.tsxedit ClientToolDispatch story
messages/en.json · messages/hi.jsonedit 4 new Agents keys × 2 locales

Recall: the brain proposes typed intents; the browser disposes them by surface_key — confirm-first across pages, direct on-page, and never against a page the user already left.

Files changed

15 file(s), +827 / -23 lines

Path Role + -
messages/en.jsonConfig+5-1
messages/hi.jsonConfig+5-1
src/components/agents/agent-chat-panel.stories.tsxComponent+31-0
src/components/agents/agent-chat-panel.tsxComponent+17-1
src/components/agents/agent-dock-mount.tsxComponent+22-12
src/components/agents/agent-thread-item.tsComponent+15-0
src/lib/agents/__tests__/client-tool-dispatcher.test.tsLibrary+232-0
src/lib/agents/client-tool-dispatcher.tsLibrary+248-0
src/lib/agents/use-client-tool-dispatcher.tsLibrary+162-0
src/lib/surface-registry/current-context.test.tsLibrary+1-0
src/lib/surface-registry/current-context.tsLibrary+5-0
src/lib/surface-registry/session-context.test.tsLibrary+13-0
src/lib/surface-registry/session-context.tsLibrary+3-0
src/stores/cpc-store.test.tsTest+22-0
src/stores/cpc-store.tsSource+46-8