diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 22:21:06 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 22:21:06 +0900 |
| commit | 901bd56228ecb105bdbbf18bb9f848192f134791 (patch) | |
| tree | d25ea66950c729387aa92a5e019f7c704a980dbe /backend-handoff.md | |
| parent | 89956f09a45db8bdd302f49d4028c0acc92a58a2 (diff) | |
| download | dispatch-web-901bd56228ecb105bdbbf18bb9f848192f134791.tar.gz dispatch-web-901bd56228ecb105bdbbf18bb9f848192f134791.zip | |
feat(chat): add "Off" option to Reasoning Effort dropdown (thinking on/off)
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 <select> (UX); the wire does not.
Pure logic (reasoning-effort.ts): ThinkingSelection = "off" | ReasoningEffort,
selectionOptions() (Off first + the ladder, default marked), isThinkingSelection,
effectiveSelection(persistedEffort, persistedThinking), + FE-local PROPOSED wire
types (ThinkingResponse, SetThinkingRequest) + SaveThinkingSelection port. The
existing effortOptions()/isReasoningEffort()/effectiveEffort() are UNCHANGED — the
heartbeat feature reuses them (its own dropdown is unaffected).
Store: `thinking` state (boolean|null, null⇒ON default), refreshThinking() (GET,
alongside refreshReasoningEffort() on every focus/draft/switch), setThinking()
(PUT). App.svelte's saveThinkingSelection adapter fans one selection out: "off"
→ PUT /thinking {thinking:false} (effort untouched); a level → ensure thinking ON
(if off) then PUT /reasoning-effort {level}.
Backend contract gap (CR-14, backend-handoff.md §2l): the backend has NO
thinking-off mechanism today (umans mapReasoningEffort can't emit "none"). The FE
is built against the PROPOSED additive contract (GET/PUT /conversations/:id/thinking
+ ThinkingResponse/SetThinkingRequest; umans maps thinking===false →
reasoning_effort:"none"). Until shipped, GET /thinking 404s and refreshThinking()
leaves `thinking` null⇒ON — the selector gracefully shows the effort level, no crash.
Verification: typecheck 0/0, 1128 tests green (run TWICE — touches the shared fetch
fake), biome clean, build OK. 9 files (8 source + backend-handoff.md).
Diffstat (limited to 'backend-handoff.md')
| -rw-r--r-- | backend-handoff.md | 95 |
1 files changed, 94 insertions, 1 deletions
diff --git a/backend-handoff.md b/backend-handoff.md index 4e3c6df..a9aaa5a 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,6 +5,17 @@ > **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. +_Last updated: 2026-06-28 (§2l — **thinking on/off** as a SEPARATE per-conversation axis from the +reasoning-effort level. An `Off` option is now the FIRST choice in the Reasoning Effort dropdown; +selecting it disables thinking entirely (the umans route maps it to `reasoning_effort: "none"`), NOT +"effort to its lowest". The two axes are kept separate on the wire: a new `thinking: boolean|null` +persisted per-conversation (null⇒ON default) + PROPOSED `GET`/`PUT /conversations/:id/thinking` +(CR-14); the effort ladder + heartbeat dropdown are UNCHANGED. FE built against the proposed contract +(FE-local `ThinkingResponse`/`SetThinkingRequest` in `reasoning-effort.ts`, swapped for the imported +ones once the backend ships); a 404 (endpoint not yet shipped) leaves `thinking` null⇒ON, so the selector +gracefully shows the effort level until then. typecheck 0/0, 1128 tests green (run TWICE), biome clean, +build OK. Worktree env note: an untracked `dispatch-backend → backend` symlink in the worktree parent so +the canonical `file:../dispatch-backend/...` paths resolve — NOT committed.)_ _Last updated: 2026-06-27 (FE-only slice: **workspace-active indicator** — loading-dots on workspace cards when a workspace has ≥1 active/queued conversation. New `AppStore.workspaceHasActiveConversations(workspaceId)` derives from the existing open-tab set (every active/queued conversation has an open tab stamped with its `workspaceId`) × the backend lifecycle statuses; a @@ -26,7 +37,7 @@ _Last updated: 2026-06-27 (workspace starring — backend `feature/workspace-sta return the updated `Workspace`). FE consumed: `adapter/http` `star`/`unstar`, pure `sortWorkspaces`/`applyStarred`, `store.setStarred` (optimistic + revert, `$derived`-sorted list), `WorkspaceCard` star toggle (filled gold ★ / outline ☆). Re-mirrored `.dispatch/wire.reference.md`. typecheck 0/0, 1045 tests green (+27), biome clean, build OK. No open backend asks.)_ -**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** +**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-14** (thinking on/off — §2l; FE built against the proposed contract, awaiting the `GET`/`PUT /conversations/:id/thinking` endpoint + `ThinkingResponse`/`SetThinkingRequest` types), **CR-9** _Last updated: 2026-06-26 (backend: concurrency limits now PERSISTED across reboots — no API contract change, no FE re-pin/re-mirror needed; §2j updated. FE: brief "Saved." confirmation on the limit row after a successful save. 926 tests green.) Prior: CR-13 (`"queued"` ConversationStatus) RESOLVED; dev merged (e81df4c)._ @@ -1194,6 +1205,88 @@ click "Restore to N" → confirm the banner drops on the next poll. --- +## 2l. Thinking on/off (separate from the reasoning-effort level) → **FE BUILT; 1 BACKEND ASK (CR-14)** + +A per-conversation **"thinking off"** affordance in the Reasoning Effort dropdown: an `Off` option as the +FIRST choice (before `Low`). 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): `reasoningEffort` is the thinking-DEPTH ladder (`low`→`max`); `thinking` is the on/off switch. +Turning thinking off PRESERVES the persisted effort level, so an off→on toggle restores the previously-chosen +depth. The per-conversation selector CONFLATES the two axes into one `<select>` (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 |
