From c1082294dd98cadfecda58d854174610659cadec Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 28 Jun 2026 22:21:06 +0900 Subject: feat(chat): add "Off" option to Reasoning Effort dropdown (thinking on/off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an "Off" choice as the FIRST option in the per-conversation Reasoning Effort selector. Selecting it disables extended thinking ENTIRELY for the conversation's turns — NOT "set the effort to its lowest level". The two axes are kept SEPARATE on the wire (per the umans API model, where thinking-off is `reasoning_effort: "none"`, distinct from the effort level): - `reasoningEffort` is the thinking-DEPTH ladder (low→max) — UNCHANGED. - `thinking` is the on/off switch — a NEW per-conversation boolean. Turning thinking off PRESERVES the persisted effort level, so an off→on toggle restores the previously-chosen depth. The selector CONFLATES the two axes into one ` (UX), but the WIRE does not. + +### Why a separate signal (not a new ladder level) + +The umans route (`provider-umans/src/reasoning.ts`) accepts `reasoning_effort: "none"|"low"|"medium"|"high"` +— `"none"` disables thinking. The current `mapReasoningEffort` can only emit `"low"|"medium"|"high"` (it +caps `xhigh`/`max`→`"high"`, and emits NO field when `reasoningEffort` is `undefined`, which the umans +route treats as "default on"). So there is today NO path through the backend to `reasoning_effort: "none"`, +i.e. no way to turn thinking off. Extending `ReasoningEffort` with `"none"` would work for umans but +CONFLATES the two axes the user explicitly wants kept separate, and would also bleed into the heartbeat +config dropdown (which reuses `effortOptions()`/`isReasoningEffort()`). A separate `thinking` boolean keeps +the axes independent and leaves the existing ladder + heartbeat control untouched. + +### What the FE built (against the PROPOSED contract below) + +- Pure logic (`src/features/chat/reasoning-effort.ts`): a `ThinkingSelection = "off" | ReasoningEffort` + type, `selectionOptions()` (`Off` first, then the ladder w/ default marked), `isThinkingSelection()`, + `effectiveSelection(persistedEffort, persistedThinking)`, + FE-local proposed wire types + `ThinkingResponse` / `SetThinkingRequest` + a `SaveThinkingSelection` port. The existing + `effortOptions()` / `isReasoningEffort()` / `effectiveEffort()` are UNCHANGED (the heartbeat feature + reuses them — its own dropdown is unaffected). +- Selector (`ReasoningEffortSelector.svelte`): renders `Off` first; new props `persistedEffort` + + `persistedThinking` + `save`; the displayed selection derives from BOTH axes (`thinking===false` ⇒ `off`, + else the effective effort level). Selecting `Off` calls `save("off")`; selecting a level calls + `save(level)`. +- Store (`store.svelte.ts`): `thinking` state (`boolean | null`, `null`=never set⇒ON), `refreshThinking()` + (GET, called alongside `refreshReasoningEffort()` on every focus/draft/switch), `setThinking()` + (PUT). `App.svelte`'s `saveThinkingSelection` adapter fans one selection out to the right PUT(s): + `"off"` → `PUT /thinking {thinking:false}` (leaves effort untouched); a level → ensure thinking ON + (`PUT /thinking {thinking:true}` if currently off) then `PUT /reasoning-effort {level}`. +- **Graceful pending-backend behavior:** `refreshThinking()` returns on `!res.ok` (a 404 — endpoint not + yet shipped — leaves `thinking` `null` ⇒ ON/default), so the selector simply shows the effort level + until the backend ships CR-14. No crash, no broken UI. +- Tests: pure-logic (`selectionOptions`/`isThinkingSelection`/`effectiveSelection` — incl. `thinking===false` + ⇒ `off` while a level is still persisted), component (`Off` first, selecting `Off` sends `"off"` not a + level, off shows `off` even with a persisted level, failed-save revert, in-flight disable), store + (seeds from GET, treats a 404 as null⇒ON, PUT round-trips + echoes). typecheck 0/0, **1128 tests green** + (run TWICE — touches the shared fetch fake), biome clean, build OK. + +### CR-14 — PROPOSED backend contract (the ask) + +A NEW, ADDITIVE per-conversation "thinking" boolean, fully separate from `reasoningEffort`. Mirrors the +existing `reasoning-effort` shape so it slots in with minimal surface: + +- **Wire / transport-contract (additive, NO version bump needed):** + ```ts + // Response of GET /conversations/:id/thinking + export interface ThinkingResponse { + readonly conversationId: string; + readonly thinking: boolean | null; // null = never set ⇒ thinking ON (the default) + } + // Body of PUT /conversations/:id/thinking + export interface SetThinkingRequest { readonly thinking: boolean } + ``` + Optionally also a per-turn `ChatRequest.thinking?: boolean` override (mirrors `reasoningEffort`). +- **Endpoints (new, mirror `reasoning-effort`):** + - `GET /conversations/:id/thinking` → `ThinkingResponse` + - `PUT /conversations/:id/thinking` (body `SetThinkingRequest`) → `ThinkingResponse` +- **Resolution (server-owned — do NOT re-implement in the FE):** per-turn `thinking` → persisted + conversation `thinking` → default **ON** (`true`). When resolved **OFF**, the orchestrator signals the + provider to DISABLE extended thinking; `provider-umans` maps that to `reasoning_effort: "none"` (its + `mapReasoningEffort` would gain a `thinking===false ⇒ "none"` short-circuit, ignoring the effort level + while off). Providers without a thinking knob ignore it (safe, as today). +- **No change to `ReasoningEffort` / `reasoning-effort` endpoint** — the ladder and its validation are + untouched (so the heartbeat config + existing callers are unaffected). + +**FE re-pin/re-mirror on shipment:** once the backend ships CR-14, move `ThinkingResponse` / +`SetThinkingRequest` into `@dispatch/transport-contract`, re-pin the `file:` dep, re-mirror +`.dispatch/transport-contract.reference.md`, and swap the FE-local types for the imported ones (the store ++ `reasoning-effort.ts` already reference them by name — a one-line import-source change). The graceful-404 +fallback can then be tightened. **A live integration probe is pending** the endpoint existing +(`scripts/live-probe.ts` would gain a `/thinking` round-trip). + +--- + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns diff --git a/src/app/App.svelte b/src/app/App.svelte index 7e5ac7d..f87eddb 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -1,5 +1,5 @@ @@ -69,7 +80,7 @@

Saved — applies from the next turn.

{:else}

- How long the model thinks before answering. Changing it can re-prefill the prompt cache once. + How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once.

{/if} -- cgit v1.2.3