/** * @file Prüft Reasoning-Auswahl, getrennte Gedankenstreams und deren Speicherung sowie Darstellung. */ 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/js/api.js"; import { createReasoningToggle, finishReasoning, updateReasoning } from "../public/js/reasoning.js"; import { getModelFamily, supportsReasoning } from "../public/js/modelCapabilities.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: researchers announced a successful satellite launch." }] })); }); 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) => { const body = JSON.parse(options.body); if (body.format?.properties?.search) { const {message} = JSON.parse(body.messages.at(-1).content); return new Response(JSON.stringify({message: {content: JSON.stringify({search: message.includes("latest news"), query: message})}, done: true})); } requests.push(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.chunk]), onChunk: data => events.push(["answer", data.chunk]) }); } finally { globalThis.fetch = originalFetch; } assert.deepEqual(events, [["thinking", "Consider "], ["thinking", "carefully."], ["answer", "Answer"]]); assert.doesNotMatch(wire, /fullReply|fullThinking/); 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); await api(`/chats/${chatId}/messages/${bot.id}/versions/0`, {}); 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); const roleplayMessages = (await api(`/chats/${roleplay.chat.id}/messages`)).messages; for (const target of [roleplayMessages[0], roleplayMessages.at(-1)]) { const retryResponse = await fetch(base + `/chats/${roleplay.chat.id}/messages/${target.id}/retry/stream`, { method: "POST", headers: { "Content-Type": "application/json", Cookie: cookie }, body: JSON.stringify({ model: "qwen3:14b", reasoning: true }) }); const retryWire = await retryResponse.text(); assert.match(retryWire, /event: thinking/); assert.match(retryWire, /event: chunk/); assert.match(retryWire, /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(button.disabled, false); assert.equal(toggle.enabled, false); assert.equal(button.title, "Enable reasoning"); button.click(); assert.equal(toggle.enabled, true); assert.equal(button.title, "Reasoning enabled"); assert.equal(button.disabled, false); toggle.setBusy(true); button.click(); assert.equal(toggle.enabled, true); toggle.reset(); assert.equal(toggle.enabled, false); toggle.setBusy(false); assert.equal(button.disabled, 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); }); test("tagged Qwen3 models enable the control without activating reasoning", () => { for (const model of [ "qwen3:14b", "qwen3:8b", "qwen3:30b", "qwen3:latest", " QWEN3:14b ", "library/qwen3:14b", { model: "qwen3:14b" }, { name: "qwen3:8b" }, { id: "qwen3:latest" } ]) { assert.equal(getModelFamily(model), "qwen3"); assert.equal(supportsReasoning(model), true); const attributes = {}; const button = { disabled: true, setAttribute(key, value) { attributes[key] = value; }, addEventListener(_event, callback) { this.click = callback; } }; const toggle = createReasoningToggle(button); toggle.setModel(""); // Model list has not loaded yet. assert.equal(button.disabled, true); toggle.setModel(model); assert.equal(button.disabled, false); assert.equal(toggle.enabled, false); assert.equal(attributes["aria-pressed"], "false"); assert.equal(button.title, "Enable reasoning"); button.click(); assert.equal(toggle.enabled, true); assert.equal(attributes["aria-pressed"], "true"); toggle.setModel(model); // A UI refresh must preserve the choice. assert.equal(toggle.enabled, true); toggle.reset(); assert.equal(toggle.enabled, false); assert.equal(button.disabled, false); assert.equal(button.title, "Enable reasoning"); } for (const model of ["mistral:latest", "qwen2.5:14b", "qwen3-coder:30b", "qwen30:14b", "", null]) { assert.equal(supportsReasoning(model), false); } });