PR: #401: feat(agents): composer reasoning pill wired to generated level enum + carry level on turn [Story-1657] by @ankitmaheshwari-com
View PR →
Story 1657 ยท Epic-1646 Agentic interaction ยท RFC-APB-004 (UI lane)

The reasoning pill stops guessing โ€” it speaks the contract

Story-1649 shipped the composer's reasoning pill against a hand-typed stub of levels and a hardcoded default, with a comment promising to swap onto the generated enum "later." The contract child (#1648 / #1654) has since landed ThinkingLevel in src/gen. This Story collects that debt: the pill's levels now come from the generated enum, the default comes from the agent's descriptor (not a baked-in "High"), and the chosen level is carried on the per-turn request as the rail posture thinking_level the broker resolves and floors.

๐Ÿ›ฐ๏ธ Satellite โ€” why a stub enum on a wire-facing control is a time bomb

The stakes: the reasoning pill is the single user-facing knob over "how hard the agent thinks," and that knob has to route a turn to a model via the broker. The level word the UI shows is the same word the broker resolves (RFC-APB-004). If the UI carries its own private copy of the level set, the day the contract renames a level โ€” extra_high โ†’ max, which is exactly what RFC-APB-004 did โ€” the pill keeps rendering a word the broker no longer accepts, and the turn fails closed. A duplicated enum on a trust boundary is a drift waiting for a deploy.

The naive fix โ€” "just keep the union in sync by hand" โ€” detonates on the first rename: nothing fails the build, the stub still compiles, the tests still pass against the stub, and the break only shows up as a runtime 422 in staging. The real fix makes the generated enum the single source of the type (HR-22) so a rename upstream is a compile error here, and adds a totality guard so an added level the pill forgot to render also fails the build. The contract can't drift away from the UI silently anymore โ€” the type system welds them.

โœˆ๏ธ Airplane โ€” type from src/gen, default from the descriptor, level onto the turn

One analogy: the pill is a gear lever. Before, the lever had its own painted list of gears that someone had to repaint whenever the gearbox changed. Now the lever reads its gears off the gearbox itself โ€” and when you shift, the chosen gear travels down the linkage to the box, which decides what's actually safe to engage (the floor).

Acceptance criterionHow it's met
Levels from the generated enum; no AutoReasoningLevel = ThinkingLevel (from @/gen); REASONING_LEVELS is satisfies-checked + a totality guard. No Auto member exists.
Render only distinct-resolving levels per active agentThe pill renders descriptor.capabilities.reasoningLevels โ€” the per-agent set. Atlas resolves all five to distinct models (RFC ยงA), so it lists five; a collapsed-tier agent lists only its distinct levels.
Chosen level carried on the turn requestbuildAgentSessionTurnRequest() places it at context.providers.posture.thinking_level; the broker floors it.
Default = the agent's declared defaultThe dock reads capabilities.defaultReasoningLevel; Atlas now declares medium (RFC ยงE), not a hardcoded High.
Cost = quiet tier hint onlyMax โ†’ "higher cost", Ultra Max โ†’ "Frontier ยท highest cost" โ€” qualitative tier hints, en+hi. No model, provider, or price.
One repo, no codegen. Per the ticket, this lane imports the already-generated types from src/gen and does not run pnpm codegen. The enum was generated by the contract child; here we only consume it.

๐Ÿ”ฌ Microscopic โ€” the pieces, and why each is shaped the way it is

The level type โ€” sourced, then proven total

agent-descriptor.ts drops the hand-typed union for export type ReasoningLevel = ThinkingLevel (imported from @/gen). The UI-facing name is kept so no call site churns, but the type is now contract-owned. The render-order array uses as const satisfies readonly ReasoningLevel[] โ€” a rename upstream makes a literal stop satisfying the type, failing the build. That only catches renames, so a one-line totality guard (Exclude<ReasoningLevel, (typeof REASONING_LEVELS)[number]> must be never) catches an added level the array forgot โ€” the pill can never silently drop a level the contract gained.

The default โ€” declared by the agent, not the control

atlas-descriptor.ts sets defaultReasoningLevel: "medium" (RFC ยงE: ~90% of turns never touch the pill and ride the agent's default, so the default is the product; the pill is the escape hatch). The dock already read the default from the descriptor โ€” the fix is that the value is now the agent's declared one, not the "High" that was baked in.

The turn request โ€” the level rides as rail posture

src/lib/agents/turn-request.ts is a pure, transport-free shaper. The AgentSessionTurnRequest envelope is strict (extra="forbid"), but its context is the open, versioned provider bag. The posture rides as one named provider โ€” providers.posture.thinking_level โ€” mirroring the agent_invoke.v1 posture. The broker resolves the level to {model, reasoning_effort} LAST and floors it: a per-op floor can raise the level, never silently drop a user's explicit choice below the floor (RFC ยงB / ADR-129 D3). A unit test parses the built request through the generated agentSessionTurnRequestSchema to prove the strict envelope accepts it.

The wiring โ€” the dock builds the request every send

agent-dock-mount.tsx already tracked the chosen level in state. Its send() now builds the turn request from that level via the shaper and emits it through a new onSubmitTurn seam. The live socket that POSTs the request is a separate child (T12), so until it wires onSubmitTurn the composer still appends the counsel's message locally (proving thread continuity) while the request shape โ€” carrying the chosen thinking_level โ€” is built on every send. The seam is named, not hidden.

The cost hint โ€” legible, never a price

RFC ยงF asks the expensive tiers to be legible so counsel chooses deliberately. The Max and Ultra Max tier hints now carry a qualitative cost step ("higher cost", "Frontier ยท highest cost") in both en and hi. This is a tier hint, not a model name, provider, or live price โ€” the exact model/cost stays in audit provenance, off this surface (ADR-129 D2).

Verified

pnpm lint clean, pnpm build (TypeScript) green โ€” including the totality guard that proves the array covers the whole generated enum โ€” and pnpm test all 687 passing, with five new tests on the turn-request shaper (including the generated-schema parse). The existing reasoning-pill and composer Storybook stories continue to render the levels and tier hints with no model/price copy. Per the Story Process, the Project #2 board was not touched.

What shipped

Files changed

7 file(s), +194 / -13 lines

Path Role + -
messages/en.jsonConfig+2-2
messages/hi.jsonConfig+2-2
src/components/agents/agent-descriptor.tsComponent+22-8
src/components/agents/agent-dock-mount.tsxComponent+21-0
src/components/agents/atlas-descriptor.tsComponent+6-1
src/lib/agents/__tests__/turn-request.test.tsLibrary+63-0
src/lib/agents/turn-request.tsLibrary+78-0