From baa6f6c9d21de2f6ffc60e00f53c61d026155933 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 12 Jun 2026 20:38:57 +0900 Subject: feat(chat): reasoning-effort selector — sticky per-conversation thinking-depth knob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the backend's reasoning-effort handoff (wire@0.7.0 ReasoningEffort + transport-contract@0.11.0 GET/PUT /conversations/:id/reasoning-effort, ChatRequest.reasoningEffort): a 5-level selector in the sidebar Model view, under the provider + model dropdowns. null renders as 'high (default)' per the server-owned resolution chain; PUT on change (effective next turn); error + revert on 400; per-conversation re-mount incl. drafts (the draft id survives promotion, so an effort set on a draft applies from turn 1). Re-mirrored .dispatch references; GLOSSARY 'reasoning effort'; handoff updated. 616 tests green; live curl probe passed. --- src/features/chat/index.ts | 13 ++++ src/features/chat/reasoning-effort.test.ts | 45 +++++++++++++ src/features/chat/reasoning-effort.ts | 66 +++++++++++++++++++ src/features/chat/ui.test.ts | 74 +++++++++++++++++++++ .../chat/ui/ReasoningEffortSelector.svelte | 75 ++++++++++++++++++++++ 5 files changed, 273 insertions(+) create mode 100644 src/features/chat/reasoning-effort.test.ts create mode 100644 src/features/chat/reasoning-effort.ts create mode 100644 src/features/chat/ui/ReasoningEffortSelector.svelte (limited to 'src/features') diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index 139a64f..9b94392 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -2,11 +2,24 @@ export type { RenderedChunk, RenderGroup, ToolBatchEntry } from "../../core/chun export { groupRenderedChunks } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; +export type { + EffortOption, + ReasoningEffortSaveResult, + SaveReasoningEffort, +} from "./reasoning-effort"; +export { + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, +} from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; export { default as ChatView } from "./ui/ChatView.svelte"; export { default as Composer } from "./ui/Composer.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; +export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ export const manifest = { diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts new file mode 100644 index 0000000..8f76dea --- /dev/null +++ b/src/features/chat/reasoning-effort.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, + isReasoningEffort, + REASONING_EFFORT_LEVELS, +} from "./reasoning-effort"; + +describe("reasoning-effort helpers", () => { + it("ladder matches the wire contract, in ascending depth order", () => { + expect(REASONING_EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); + + it("the server default is high", () => { + expect(DEFAULT_REASONING_EFFORT).toBe("high"); + }); + + it("isReasoningEffort narrows ladder strings and rejects everything else", () => { + for (const level of REASONING_EFFORT_LEVELS) { + expect(isReasoningEffort(level)).toBe(true); + } + expect(isReasoningEffort("banana")).toBe(false); + expect(isReasoningEffort("")).toBe(false); + expect(isReasoningEffort("HIGH")).toBe(false); + }); + + it("effectiveEffort maps null (never set) to the default, not 'off'", () => { + expect(effectiveEffort(null)).toBe("high"); + }); + + it("effectiveEffort passes a persisted value through", () => { + expect(effectiveEffort("xhigh")).toBe("xhigh"); + expect(effectiveEffort("low")).toBe("low"); + }); + + it("effortOptions lists every level once and marks only the default", () => { + const options = effortOptions(); + expect(options.map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + for (const option of options) { + if (option.value !== "high") expect(option.label).toBe(option.value); + } + }); +}); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts new file mode 100644 index 0000000..2a55089 --- /dev/null +++ b/src/features/chat/reasoning-effort.ts @@ -0,0 +1,66 @@ +import type { ReasoningEffort } from "@dispatch/transport-contract"; + +/** + * Pure helpers for the reasoning-effort selector (the thinking-depth knob). + * + * The canonical ladder + resolution chain are SERVER-owned (`wire@0.7.0` + * `ReasoningEffort`; per-turn override → persisted conversation value → default + * `"high"`). These helpers only shape the persisted value for display: a `null` + * from `GET /conversations/:id/reasoning-effort` means "never set ⇒ the default + * applies", so the selector shows `high (default)` — never "off". Zero DOM, + * zero Svelte. + */ + +/** The canonical ladder, in ascending thinking-depth order (`wire@0.7.0`). */ +export const REASONING_EFFORT_LEVELS: readonly ReasoningEffort[] = [ + "low", + "medium", + "high", + "xhigh", + "max", +]; + +/** The server's fallback when nothing is set (the resolution chain's tail). */ +export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high"; + +/** Narrow an untrusted string (e.g. a ` handleChange(e.currentTarget.value)} + aria-label="Reasoning effort" + > + {#each options as option (option.value)} + + {/each} + + {#if saving} + + {/if} + + {#if error} +

{error}

+ {:else if justSaved} +

Saved — applies from the next turn.

+ {:else} +

+ How long the model thinks before answering. Changing it can re-prefill the prompt cache once. +

+ {/if} + -- cgit v1.2.3