255 lines
13 KiB
JavaScript
255 lines
13 KiB
JavaScript
/**
|
|
* @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) => {
|
|
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);
|
|
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, "<img src=x onerror=alert(1)>", true);
|
|
const details = message.children[0];
|
|
assert.equal(details.open, true);
|
|
assert.equal(details.children[1].textContent, "<img src=x onerror=alert(1)>");
|
|
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);
|
|
}
|
|
});
|