summaryrefslogtreecommitdiffhomepage
path: root/src/app/store.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-28 22:21:06 +0900
committerAdam Malczewski <[email protected]>2026-06-28 22:21:06 +0900
commit901bd56228ecb105bdbbf18bb9f848192f134791 (patch)
treed25ea66950c729387aa92a5e019f7c704a980dbe /src/app/store.test.ts
parent89956f09a45db8bdd302f49d4028c0acc92a58a2 (diff)
downloaddispatch-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 'src/app/store.test.ts')
-rw-r--r--src/app/store.test.ts94
1 files changed, 94 insertions, 0 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index a945ea7..dbceec5 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -891,6 +891,100 @@ describe("createAppStore", () => {
store.dispose();
});
+ it("seeds thinking from GET /conversations/:id/thinking (null = never set ⇒ ON)", async () => {
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: false }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ await vi.waitFor(() => {
+ expect(store.thinking).toBe(false);
+ });
+
+ store.dispose();
+ });
+
+ it("treats a missing thinking endpoint (404) as 'never set' (null ⇒ ON)", async () => {
+ // The thinking endpoint is PROPOSED (backend-handoff.md); until the backend
+ // ships it, GET 404s and `thinking` stays null ⇒ the selector shows the
+ // effort level (thinking ON, the default) — graceful, never a crash.
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/thinking")) {
+ return new Response("not found", { status: 404 });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // give the (404ing) fetch a tick to settle
+ await vi.waitFor(() => {
+ expect(store.reasoningEffort).not.toBe(undefined);
+ });
+ expect(store.thinking).toBeNull();
+
+ store.dispose();
+ });
+
+ it("setThinking PUTs the flag and updates local state from the echo", async () => {
+ const calls: { url: string; method: string; body: string | undefined }[] = [];
+ const base = fakeFetchImpl();
+ const fetchImpl: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ calls.push({ url, method: init?.method ?? "GET", body: init?.body as string | undefined });
+ if (url.endsWith("/thinking") && init?.method === "PUT") {
+ const sent = JSON.parse(init.body as string) as { thinking: boolean };
+ return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), {
+ status: 200,
+ });
+ }
+ if (url.endsWith("/thinking")) {
+ return new Response(JSON.stringify({ conversationId: "x", thinking: null }), {
+ status: 200,
+ });
+ }
+ return base(input, init);
+ };
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl,
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ const result = await store.setThinking(false);
+ expect(result).toEqual({ ok: true, thinking: false });
+ expect(store.thinking).toBe(false);
+
+ const put = calls.find((c) => c.method === "PUT" && c.url.endsWith("/thinking"));
+ expect(put).toBeDefined();
+ expect(put?.url).toContain(`/conversations/${store.currentConversationId}/`);
+ expect(JSON.parse(put?.body ?? "{}")).toEqual({ thinking: false });
+
+ store.dispose();
+ });
+
it("does NOT re-scope a scope:'global' surface on conversation switch (no churn)", () => {
const ws = fakeSocket();
const store = createAppStore({