diff options
| -rw-r--r-- | src/app/store.svelte.ts | 13 | ||||
| -rw-r--r-- | src/app/store.test.ts | 35 |
2 files changed, 48 insertions, 0 deletions
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index 8997ce0..8191b47 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -779,6 +779,11 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { try { const res = await fetchImpl(`${httpBase}/conversations/${encodeURIComponent(id)}/thinking`); if (!res.ok) return; + // Guard against the SPA fallback: when DISPATCH_WEB_DIR is set (production), + // unknown API routes get index.html with HTTP 200 + text/html instead of a + // 404. Treat any non-JSON 200 as "endpoint not shipped" → leave null (ON). + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) return; const data = (await res.json()) as ThinkingResponse; // Guard a slow response losing a race with a conversation switch. if (workspaceConversationId() === id) thinking = data.thinking ?? null; @@ -1700,6 +1705,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { error: errBody?.error ?? `Set thinking failed (HTTP ${res.status})`, }; } + // Guard against the SPA fallback (200 + text/html when the endpoint + // is not shipped). Optimistically apply locally — the backend can't + // persist it, but the UI reflects the user's intent for this session. + const contentType = res.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + if (workspaceConversationId() === id) thinking = enabled; + return { ok: true, thinking: enabled }; + } const data = (await res.json()) as ThinkingResponse; const next = data.thinking ?? enabled; if (workspaceConversationId() === id) thinking = next; diff --git a/src/app/store.test.ts b/src/app/store.test.ts index 47c3977..518a7b2 100644 --- a/src/app/store.test.ts +++ b/src/app/store.test.ts @@ -898,6 +898,7 @@ describe("createAppStore", () => { if (url.endsWith("/thinking")) { return new Response(JSON.stringify({ conversationId: "x", thinking: false }), { status: 200, + headers: { "content-type": "application/json" }, }); } return base(input, init); @@ -946,6 +947,38 @@ describe("createAppStore", () => { store.dispose(); }); + it("treats a SPA-fallback response (200 + text/html) as 'never set' (null ⇒ ON)", async () => { + // In production (DISPATCH_WEB_DIR set), unknown API routes get the SPA + // fallback: index.html with HTTP 200 + text/html. This must NOT crash + // res.json() — it should leave `thinking` null (ON, the default). + 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("<!DOCTYPE html><html><body>SPA fallback</body></html>", { + status: 200, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return base(input, init); + }; + const ws = fakeSocket(); + const store = createAppStore({ + socketFactory: () => ws, + fetchImpl, + localStorage: createFakeStorage(), + }); + ws.resolveOpen(); + + // give the (HTML) 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(); @@ -956,11 +989,13 @@ describe("createAppStore", () => { const sent = JSON.parse(init.body as string) as { thinking: boolean }; return new Response(JSON.stringify({ conversationId: "x", thinking: sent.thinking }), { status: 200, + headers: { "content-type": "application/json" }, }); } if (url.endsWith("/thinking")) { return new Response(JSON.stringify({ conversationId: "x", thinking: null }), { status: 200, + headers: { "content-type": "application/json" }, }); } return base(input, init); |
