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 criterion | How it's met |
|---|---|
| Levels from the generated enum; no Auto | ReasoningLevel = ThinkingLevel (from @/gen); REASONING_LEVELS is satisfies-checked + a totality guard. No Auto member exists. |
| Render only distinct-resolving levels per active agent | The 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 request | buildAgentSessionTurnRequest() places it at context.providers.posture.thinking_level; the broker floors it. |
| Default = the agent's declared default | The dock reads capabilities.defaultReasoningLevel; Atlas now declares medium (RFC ยงE), not a hardcoded High. |
| Cost = quiet tier hint only | Max โ "higher cost", Ultra Max โ "Frontier ยท highest cost" โ qualitative tier hints, en+hi. No model, provider, or price. |
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
src/components/agents/agent-descriptor.tsโReasoningLevelnow aliases the generatedThinkingLevel;REASONING_LEVELSgains a compile-time totality guard.src/components/agents/atlas-descriptor.tsโ default reasoning levelhigh โ medium(the agent's declared default, RFC ยงE).src/lib/agents/turn-request.tsโ new pure shaper putting the chosen level atcontext.providers.posture.thinking_level.src/components/agents/agent-dock-mount.tsxโsend()builds the turn request and emits it through theonSubmitTurntransport seam (T12).src/lib/agents/__tests__/turn-request.test.tsโ five tests, incl. parsing the built envelope through the generated contract schema.messages/en.json+messages/hi.jsonโ quiet cost-tier hints on Max / Ultra Max (both locales).
Files changed
7 file(s), +194 / -13 lines
| Path | Role | + | - |
|---|---|---|---|
| messages/en.json | Config | +2 | -2 |
| messages/hi.json | Config | +2 | -2 |
| src/components/agents/agent-descriptor.ts | Component | +22 | -8 |
| src/components/agents/agent-dock-mount.tsx | Component | +21 | -0 |
| src/components/agents/atlas-descriptor.ts | Component | +6 | -1 |
| src/lib/agents/__tests__/turn-request.test.ts | Library | +63 | -0 |
| src/lib/agents/turn-request.ts | Library | +78 | -0 |