diff --git a/public/app/modelCapabilities.js b/public/app/modelCapabilities.js
new file mode 100644
index 0000000..3dc8745
--- /dev/null
+++ b/public/app/modelCapabilities.js
@@ -0,0 +1,6 @@
+// Conservative allowlist for models supporting the boolean think API.
+// Unknown models remain usable for normal chat.
+export function supportsReasoning(model) {
+ const id = String(typeof model === "string" ? model : model?.model || model?.name || "").toLowerCase().split("/").at(-1);
+ return /^(qwen3|deepseek-r1|deepseek-v3\.1)(:|$)/.test(id);
+}
diff --git a/public/app/reasoning.js b/public/app/reasoning.js
new file mode 100644
index 0000000..745c07d
--- /dev/null
+++ b/public/app/reasoning.js
@@ -0,0 +1,57 @@
+import {supportsReasoning} from "./modelCapabilities.js";
+
+export function createReasoningToggle(button) {
+ let reasoningEnabled = false;
+ let supported = false;
+ let busy = false;
+ const render = () => {
+ button.disabled = !supported || busy;
+ button.setAttribute("aria-pressed", String(reasoningEnabled));
+ button.title = !supported ? "This model does not support reasoning"
+ : reasoningEnabled ? "Reasoning enabled" : "Enable reasoning";
+ button.setAttribute("aria-label", button.title);
+ };
+ button.addEventListener("click", () => {
+ if (!supported || busy) return;
+ reasoningEnabled = !reasoningEnabled;
+ render();
+ });
+ render();
+ return {
+ get enabled() { return reasoningEnabled; },
+ reset() { reasoningEnabled = false; render(); },
+ setModel(model) { supported = supportsReasoning(model); if (!supported) reasoningEnabled = false; render(); },
+ setBusy(value) { busy = value; render(); }
+ };
+}
+
+export function updateReasoning(messageElement, thinking, streaming = false) {
+ let details = messageElement.querySelector(".msg-reasoning");
+ if (!thinking) {
+ details?.remove();
+ return;
+ }
+ if (!details) {
+ details = document.createElement("details");
+ details.className = "msg-reasoning";
+ details.open = streaming;
+ const summary = document.createElement("summary");
+ const content = document.createElement("div");
+ content.className = "msg-reasoning-content";
+ details.append(summary, content);
+ messageElement.insertBefore(details, messageElement.querySelector(".msg-content"));
+ }
+ details.querySelector("summary").textContent = streaming ? "Reasoning · Thinking…" : "Reasoning";
+ const content = details.querySelector(".msg-reasoning-content");
+ const follow = content.scrollHeight - content.scrollTop - content.clientHeight < 40;
+ content.textContent = thinking;
+ if (streaming && follow) content.scrollTop = content.scrollHeight;
+}
+
+export function finishReasoning(messageElement) {
+ const details = messageElement.querySelector(".msg-reasoning");
+ if (!details || details.dataset.complete) return;
+ details.dataset.complete = "true";
+ details.open = false;
+ details.querySelector("summary").textContent = "Reasoning · Completed";
+}
diff --git a/test/modelStreaming.test.js b/test/modelStreaming.test.js
new file mode 100644
index 0000000..b0f0551
--- /dev/null
+++ b/test/modelStreaming.test.js
@@ -0,0 +1,128 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {createServer} from "node:http";
+import {createModelService} from "../src/services/modelService.js";
+import {createApp} from "../src/app.js";
+import {stream} from "../public/app/api.js";
+
+const config = {
+ modelApiBaseUrl: "http://fixture", modelRequestTimeoutMs: 20,
+ modelConnectTimeoutMs: 1000, modelInactivityTimeoutMs: 1000,
+ modelMaxGenerationMs: 3000, modelUnloadAfterMs: 60000
+};
+const line = data => new TextEncoder().encode(JSON.stringify(data) + "\n");
+
+test("active tokens outlive the legacy total timeout and arrive before completion", async () => {
+ let finish;
+ let first;
+ const firstChunk = new Promise(resolve => { first = resolve; });
+ let interval;
+ const service = createModelService(config, {fetch: async () => new Response(new ReadableStream({
+ start(controller) {
+ interval = setInterval(() => controller.enqueue(line({message: {content: "x"}})), 15);
+ finish = () => { clearInterval(interval); controller.enqueue(line({done: true})); controller.close(); };
+ },
+ cancel() { clearInterval(interval); }
+ }))});
+ let completed = false;
+ const pending = service.streamReply("qwen3:14b", [], () => first()).then(reply => { completed = true; return reply; });
+ try {
+ await firstChunk;
+ assert.equal(completed, false);
+ await new Promise(resolve => setTimeout(resolve, 80));
+ assert.equal(completed, false);
+ finish();
+ assert.ok((await pending).length >= 2);
+ } finally { clearInterval(interval); }
+});
+
+test("connection, inactivity and overall deadlines abort generation", async (t) => {
+ for (const kind of ["connection", "inactivity", "overall"]) await t.test(kind, async () => {
+ let cancelled = false;
+ let interval;
+ const service = createModelService({
+ ...config,
+ modelConnectTimeoutMs: kind === "connection" ? 25 : 1000,
+ modelInactivityTimeoutMs: kind === "inactivity" ? 25 : 1000,
+ modelMaxGenerationMs: kind === "overall" ? 70 : 1000
+ }, {fetch: async (_url, {signal}) => {
+ if (kind === "connection") return new Promise((_resolve, reject) => {
+ signal.addEventListener("abort", () => { cancelled = true; reject(new DOMException("Aborted", "AbortError")); });
+ });
+ return new Response(new ReadableStream({
+ start(controller) {
+ if (kind === "overall") interval = setInterval(() => controller.enqueue(line({message: {content: "x"}})), 10);
+ },
+ cancel() { cancelled = true; clearInterval(interval); }
+ }));
+ }});
+ await assert.rejects(service.streamReply("qwen3:14b", [], () => {}), {code: "MODEL_TIMEOUT"});
+ assert.equal(cancelled, true);
+ });
+});
+
+test("stream validation rejects truncated output, upstream errors and invalid fields", async () => {
+ for (const body of [
+ '{"message":{"content":"partial"}}\n',
+ '{"error":"internal upstream details"}\n',
+ '{"message":{"content":42},"done":true}\n',
+ '{broken}\n'
+ ]) {
+ const service = createModelService(config, {fetch: async () => new Response(body)});
+ await assert.rejects(service.generateReply("qwen3:14b", []));
+ }
+ const service = createModelService(config, {fetch: async () => { throw new Error("Connection refused"); }});
+ await assert.rejects(service.generateReply("qwen3:14b", []), {code: "MODEL_UNAVAILABLE"});
+});
+
+test("SSE EOF is a failure rather than successful completion", async (t) => {
+ const original = globalThis.fetch;
+ t.after(() => { globalThis.fetch = original; });
+ globalThis.fetch = async () => new Response('event: chunk\ndata: {"fullReply":"partial"}\n\n');
+ const chunks = [];
+ await assert.rejects(stream("/fixture", {}, {onChunk: data => chunks.push(data.fullReply)}), /interrupted/);
+ assert.deepEqual(chunks, ["partial"]);
+});
+
+test("browser disconnect aborts Ollama and incomplete output is not persisted", async (t) => {
+ let signal;
+ let releaseAbort;
+ const aborted = new Promise(resolve => { releaseAbort = resolve; });
+ const app = createApp({config: {
+ dbPath: ":memory:", testMode: true, sessionSecret: "test-only",
+ chatSummaryUpdateEveryMessages: 0, structuredRequestLoggingEnabled: false,
+ eventLoopLagMonitorEnabled: false, slowRequestLoggingEnabled: false
+ }, modelOverrides: {fetch: async (_url, options) => {
+ signal = options.signal;
+ signal.addEventListener("abort", releaseAbort, {once: true});
+ return new Response(new ReadableStream({start(controller) {
+ controller.enqueue(line({message: {content: "partial"}}));
+ }}));
+ }}});
+ const server = createServer(app);
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
+ t.after(async () => {
+ await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); });
+ app.locals.close();
+ });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const registered = await fetch(base + "/register", {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({username: "disconnect", password: "test-password"})});
+ const cookie = registered.headers.get("set-cookie").split(";")[0];
+ const headers = {"Content-Type": "application/json", Cookie: cookie};
+ const chat = await (await fetch(base + "/chats", {method: "POST", headers, body: '{"title":"Disconnect"}'})).json();
+ const controller = new AbortController();
+ const response = await fetch(base + "/chat/stream", {
+ method: "POST", headers, signal: controller.signal,
+ body: JSON.stringify({chatId: chat.chat.id, message: "Hello"})
+ });
+ const reader = response.body.getReader();
+ let received = "";
+ while (!received.includes("event: chunk")) received += new TextDecoder().decode((await reader.read()).value);
+ controller.abort();
+ await Promise.race([aborted, new Promise((_resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("Upstream was not aborted")), 1000);
+ timer.unref();
+ })]);
+ assert.equal(signal.aborted, true);
+ assert.equal(app.locals.db.prepare("SELECT COUNT(*) AS count FROM chat_messages WHERE role = 'bot'").get().count, 0);
+});
diff --git a/test/reasoning.test.js b/test/reasoning.test.js
new file mode 100644
index 0000000..1dd51ff
--- /dev/null
+++ b/test/reasoning.test.js
@@ -0,0 +1,190 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {createServer} from "node:http";
+import {createApp} from "../src/app.js";
+import {createModelService} from "../src/services/modelService.js";
+import {stream} from "../public/app/api.js";
+import {createReasoningToggle, finishReasoning, updateReasoning} from "../public/app/reasoning.js";
+
+test("thinking streams separately and survives reload, retry, branch and import", async (t) => {
+ const requests = [];
+ let searches = 0;
+ const searchServer = createServer((_req, res) => {
+ searches++;
+ res.setHeader("Content-Type", "application/json");
+ res.end(JSON.stringify({results: [{title: "Latest news", url: "https://example.org/news", content: "Current news report"}]}));
+ });
+ await new Promise(resolve => searchServer.listen(0, "127.0.0.1", resolve));
+ t.after(() => new Promise(resolve => { searchServer.closeAllConnections(); searchServer.close(resolve); }));
+ const modelService = createModelService({
+ modelApiBaseUrl: "http://fixture", modelRequestTimeoutMs: 1000, modelUnloadAfterMs: 60000
+ }, {fetch: async (_url, options) => {
+ requests.push(JSON.parse(options.body));
+ const chunks = [
+ '{"message":{"thinking":"Consider "}}\n{"message":{"thi',
+ 'nking":"carefully.","content":""}}\n',
+ '{"message":{"content":"Answer"},"done":true}'
+ ];
+ return new Response(new ReadableStream({start(controller) {
+ for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk));
+ controller.close();
+ }}));
+ }});
+ const app = createApp({modelService, config: {
+ dbPath: ":memory:", testMode: true, sessionSecret: "test-only",
+ searxngUrl: `http://127.0.0.1:${searchServer.address().port}`,
+ chatSummaryUpdateEveryMessages: 0, eventLoopLagMonitorEnabled: false,
+ structuredRequestLoggingEnabled: false, slowRequestLoggingEnabled: false
+ }});
+ const server = createServer(app);
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
+ t.after(async () => {
+ await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); });
+ app.locals.close();
+ });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ let cookie;
+ const api = async (path, body) => {
+ const response = await fetch(base + path, {
+ method: body ? "POST" : "GET",
+ headers: {"Content-Type": "application/json", ...(cookie ? {Cookie: cookie} : {})},
+ body: body ? JSON.stringify(body) : undefined
+ });
+ if (response.headers.get("set-cookie")) cookie = response.headers.get("set-cookie").split(";")[0];
+ assert.equal(response.status, 200);
+ return response.json();
+ };
+ await api("/register", {username: "thinker", password: "test-password"});
+ const chatId = (await api("/chats", {title: "Reasoning"})).chat.id;
+ const response = await fetch(base + "/chat/stream", {
+ method: "POST", headers: {"Content-Type": "application/json", Cookie: cookie},
+ body: JSON.stringify({chatId, message: "Hello", reasoning: true})
+ });
+ const wire = await response.text();
+ const events = [];
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => new Response(wire);
+ try {
+ await stream("/fixture", {}, {
+ onThinking: data => events.push(["thinking", data.fullThinking]),
+ onChunk: data => events.push(["answer", data.fullReply])
+ });
+ } finally { globalThis.fetch = originalFetch; }
+ assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "Consider carefully."], ["answer", "Answer"]]);
+ const bot = (await api(`/chats/${chatId}/messages`)).messages.at(-1);
+ assert.equal(bot.thinking, "Consider carefully.");
+ assert.equal(bot.content, "Answer");
+ const retry = await api(`/chats/${chatId}/messages/${bot.id}/retry`, {});
+ assert.equal(retry.message.thinking, undefined);
+ const branch = await api(`/chats/${chatId}/messages/${bot.id}/branch`, {confirmed: true});
+ assert.equal(branch.messages.at(-1).thinking, bot.thinking);
+ const exported = await api(`/exports/chats/${chatId}?format=json`);
+ assert.equal(exported.messages.find(message => message.id === bot.id).thinking, bot.thinking);
+ const {workspace} = await api("/exports/workspace");
+ await api("/imports/workspace", {workspace, allowDuplicates: true});
+ assert.ok(app.locals.db.prepare("SELECT id FROM chat_messages WHERE thinking = ?").all(bot.thinking).length >= 4);
+ await api("/chat", {chatId, message: "Continue"});
+ for (const request of requests) {
+ assert.ok(request.messages.every(message => !message.content.includes("Consider carefully.")));
+ assert.equal(request.stream, true);
+ assert.equal(request.keep_alive, "30m");
+ }
+ assert.equal(requests[0].think, true);
+ assert.equal(requests.at(-1).think, false);
+ for (const webSearch of [false, true]) for (const reasoning of [false, true]) {
+ const response = await fetch(base + "/chat/stream", {
+ method: "POST", headers: {"Content-Type": "application/json", Cookie: cookie},
+ body: JSON.stringify({chatId, message: "What is the latest news today?", model: "qwen3:14b", webSearch, reasoning})
+ });
+ const wire = await response.text();
+ assert.match(wire, /event: done/);
+ assert.equal(wire.includes("event: thinking"), reasoning);
+ assert.equal(wire.includes('"status":"searching"'), webSearch);
+ assert.equal(wire.includes('"sources":['), webSearch);
+ assert.equal(requests.at(-1).think, reasoning);
+ assert.equal(requests.at(-1).model, "qwen3:14b");
+ const saved = (await api(`/chats/${chatId}/messages`)).messages.at(-1);
+ assert.equal(saved.thinking, reasoning ? bot.thinking : undefined);
+ assert.equal(Boolean(saved.sources?.length), webSearch);
+ }
+ assert.equal(searches, 2);
+ await api("/chat", {chatId, message: "Hello", model: "mistral:latest", reasoning: true});
+ assert.equal(requests.at(-1).think, false);
+ assert.equal((await api(`/chats/${chatId}/messages`)).messages.at(-1).thinking, undefined);
+ const persona = (await api("/personas", {personaType: "assistant", name: "Guide", details: "Patient and direct"})).persona;
+ const roleplay = await api("/roleplays/start", {assistantPersonaId: persona.id, model: "qwen3:14b"});
+ assert.equal(requests.at(-1).think, false);
+ const roleplayResponse = await fetch(base + "/chat/stream", {
+ method: "POST", headers: {"Content-Type": "application/json", Cookie: cookie},
+ body: JSON.stringify({chatId: roleplay.chat.id, message: "Hello Guide", model: "qwen3:14b", reasoning: true})
+ });
+ const roleplayWire = await roleplayResponse.text();
+ assert.match(roleplayWire, /event: thinking/);
+ assert.match(roleplayWire, /event: chunk/);
+ assert.match(roleplayWire, /event: done/);
+ assert.ok(requests.at(-1).messages.some(message => message.content.includes("Guide")));
+ assert.equal(requests.at(-1).think, true);
+});
+
+test("reasoning disclosure is collapsed, preserves open state and renders text safely", (t) => {
+ class Element {
+ constructor(tag) { this.tag = tag; this.children = []; this.open = false; this.dataset = {}; }
+ append(...children) { this.children.push(...children); }
+ insertBefore(child, before) { this.children.splice(this.children.indexOf(before), 0, child); }
+ querySelector(selector) {
+ for (const child of this.children) {
+ if (selector === child.tag || selector === "." + child.className) return child;
+ const nested = child.querySelector(selector);
+ if (nested) return nested;
+ }
+ return null;
+ }
+ }
+ const original = globalThis.document;
+ globalThis.document = {createElement: tag => new Element(tag)};
+ t.after(() => { globalThis.document = original; });
+ const message = new Element("article");
+ const body = new Element("div");
+ body.className = "msg-content";
+ message.append(body);
+ updateReasoning(message, "");
+ assert.equal(message.children.length, 1);
+ updateReasoning(message, "
", true);
+ const details = message.children[0];
+ assert.equal(details.open, true);
+ assert.equal(details.children[1].textContent, "
");
+ details.open = true;
+ updateReasoning(message, "Complete");
+ assert.equal(details.open, true);
+ assert.equal(details.children[0].textContent, "Reasoning");
+ assert.equal(message.children.length, 2);
+ finishReasoning(message);
+ assert.equal(details.open, false);
+ details.open = true;
+ finishReasoning(message);
+ assert.equal(details.open, true);
+});
+
+test("reasoning toggle defaults off, resets, and rejects unsupported models", () => {
+ const button = {setAttribute() {}, addEventListener(_event, callback) { this.click = callback; }};
+ const toggle = createReasoningToggle(button);
+ toggle.setModel("qwen3:14b");
+ assert.equal(toggle.enabled, false);
+ assert.equal(button.title, "Enable reasoning");
+ button.click();
+ assert.equal(toggle.enabled, true);
+ assert.equal(button.title, "Reasoning enabled");
+ toggle.setBusy(true);
+ button.click();
+ assert.equal(toggle.enabled, true);
+ toggle.reset();
+ assert.equal(toggle.enabled, false);
+ toggle.setBusy(false);
+ button.click();
+ toggle.setModel("mistral:latest");
+ assert.equal(toggle.enabled, false);
+ assert.equal(button.disabled, true);
+ assert.equal(button.title, "This model does not support reasoning");
+ button.click();
+ assert.equal(toggle.enabled, false);
+});