add response versioning support in messages for regeneration improvements

This commit is contained in:
2026-09-22 14:48:13 +02:00
parent 7602692341
commit 72e429b1b3
15 changed files with 591 additions and 86 deletions
+5 -2
View File
@@ -455,8 +455,11 @@ test("conversation branches, edit-resend, and generation settings work", async (
});
assert.equal(regenerated.status, 200);
let active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
assert.equal(active.json.messages.length, 2);
assert.equal(active.json.messages[1].siblingCount, 2);
assert.equal(active.json.messages.length, 4);
assert.equal(active.json.messages[1].id, firstAssistant.id);
assert.equal(active.json.messages[1].versionCount, 2);
assert.equal(active.json.messages[1].siblingCount, 1);
await request(server.baseUrl, `/chats/${chatId}/messages/${firstAssistant.id}/versions/0`, {method: "POST", body: {}, jar});
await request(server.baseUrl, `/chats/${chatId}/branches/${originalLeaf.id}/activate`, {method: "POST", body: {}, jar});
active = await request(server.baseUrl, `/chats/${chatId}/messages`, {jar});
+5 -1
View File
@@ -151,7 +151,11 @@ test("images feed dedicated vision, cache, streamed reasoning, reload and shared
const exported = await (await s.api("/exports/workspace")).json();
assert.deepEqual(exported.workspace.chats[0].messages[0].attachments, uploads);
const botId = history.messages.at(-1).id;
assert.equal((await s.api(`/chats/${chatId}/messages/${botId}/retry`, {model: "qwen3:14b"})).status, 200);
const retryWire = await (await s.api(`/chats/${chatId}/messages/${botId}/retry/stream`, {model: "qwen3:14b", reasoning: true})).text();
assert.match(retryWire, /event: chunk/);
assert.match(retryWire, /event: thinking/);
assert.match(retryWire, /event: done/);
assert.ok(s.requests.at(-1).messages.some(v => v.content.includes("A blue screenshot")));
assert.equal(s.requests.filter(v => v.model === "gemma3:4b").length, 2, "Retry reuses vision cache");
const branchResponse = await s.api(`/chats/${chatId}/messages/${botId}/branch`, {confirmed: true});
assert.equal(branchResponse.status, 200);
+14
View File
@@ -77,6 +77,7 @@ test("thinking streams separately and survives reload, retry, branch and import"
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`);
@@ -125,6 +126,19 @@ test("thinking streams separately and survives reload, retry, branch and import"
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) => {
+170
View File
@@ -0,0 +1,170 @@
import test from "node:test";
import assert from "node:assert/strict";
import {createServer} from "node:http";
import {createApp} from "../src/app.js";
import Database from "better-sqlite3";
import {runMigrations} from "../src/db/schema.js";
const deferred = () => { let resolve; const promise = new Promise(done => { resolve = done; }); return {promise, resolve}; };
async function fixture(t) {
let mode = "success", sequence = 0, searches = 0;
let started = deferred(), release = deferred();
const calls = [];
const search = createServer((_req, res) => {
searches++;
if (mode === "search-fail") { res.writeHead(503); res.end("Unavailable"); return; }
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({results: [{title: "Satellite launch news", url: `https://example.org/news/${searches}`, content: "Latest satellite launch news today: a research satellite launched successfully."}]}));
});
await new Promise(resolve => search.listen(0, "127.0.0.1", resolve));
const modelService = {
async generateReply() { return `Original ${++sequence}`; },
async streamReply(model, messages, chunk, options) {
calls.push({model, messages, options});
if (options.reasoning) options.onThinking("New reasoning", "New reasoning");
chunk("Partial", "Partial");
started.resolve();
if (mode === "hold") await new Promise((resolve, reject) => {
release.promise.then(resolve);
options.signal.addEventListener("abort", () => reject(new Error("Generation stopped")), {once: true});
});
if (mode === "fail") throw new Error("Model failed after partial output");
if (mode === "empty") return "";
chunk(" answer", "Partial answer");
return "Partial answer";
},
mapError(error) { return {status: 500, body: {error: error.message}}; }
};
const app = createApp({modelService, config: {dbPath: ":memory:", testMode: true, sessionSecret: "regeneration-test",
chatSummaryUpdateEveryMessages: 0, eventLoopLagMonitorEnabled: false, structuredRequestLoggingEnabled: false,
slowRequestLoggingEnabled: false, searxngUrl: `http://127.0.0.1:${search.address().port}`}});
const server = app.listen(0, "127.0.0.1");
await new Promise(resolve => server.once("listening", resolve));
t.after(async () => {
release.resolve();
await new Promise(resolve => { server.closeAllConnections(); server.close(resolve); });
await new Promise(resolve => { search.closeAllConnections(); search.close(resolve); });
await app.locals.close();
});
let cookie;
const api = async (path, body, method = body ? "POST" : "GET") => {
const response = await fetch(`http://127.0.0.1:${server.address().port}${path}`, {method,
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];
return response;
};
await api("/register", {username: "regenerator", password: "test-password"});
const chatId = (await (await api("/chats", {title: "Versions"})).json()).chat.id;
await api("/chat", {chatId, message: "Search the web for the latest satellite launch news today"});
await api("/chat", {chatId, message: "Later private question"});
const messages = (await (await api(`/chats/${chatId}/messages`)).json()).messages;
const bot = messages[1];
app.locals.repositories.updateChatMessageThinking(chatId, bot.id, "Original reasoning");
app.locals.repositories.updateChatMessageSources(chatId, bot.id, [{title: "Old", url: "https://example.org/old", snippet: "Old evidence"}]);
return {app, api, chatId, bot, messages, calls,
get searches() { return searches; },
path: `/chats/${chatId}/messages/${bot.id}`,
hold() { mode = "hold"; started = deferred(); release = deferred(); return started.promise; },
setMode(value) { mode = value; }, release: () => release.resolve(),
history: async () => (await (await api(`/chats/${chatId}/messages`)).json()).messages};
}
test("regeneration streams before commit in the same message; versions retain sources and reasoning and branch from selection", async t => {
const s = await fixture(t);
const started = s.hold();
const response = await s.api(`${s.path}/retry/stream`, {reasoning: true, webSearch: true});
await started;
const reader = response.body.getReader();
let wire = new TextDecoder().decode((await reader.read()).value);
while (!wire.includes("Partial")) wire += new TextDecoder().decode((await reader.read()).value);
assert.match(wire, /event: thinking/);
assert.match(wire, /Partial/);
assert.doesNotMatch(wire, /event: done/);
let history = await s.history();
assert.equal(history[1].content, s.bot.content, "database retains original while streaming");
assert.equal(history[1].versionCount, 1);
assert.equal(history.length, 4);
assert.equal((await s.api(`${s.path}/retry`, {})).status, 409);
assert.equal((await s.api(s.path, {content: "unsafe edit"}, "PUT")).status, 409);
assert.equal((await s.api(s.path, null, "DELETE")).status, 409);
assert.equal((await s.api(`/chats/${s.chatId}/messages/${s.messages[3].id}`, {content: "Other answer edited"}, "PUT")).status, 200);
s.release();
while (true) { const result = await reader.read(); if (result.done) break; wire += new TextDecoder().decode(result.value); }
assert.match(wire, /event: done/);
assert.ok(wire.indexOf('"status":"searching"') < wire.indexOf("event: chunk"));
history = await s.history();
assert.deepEqual(history.map(m => m.id), s.messages.map(m => m.id));
assert.equal(history[1].content, "Partial answer");
assert.equal(history[1].versionCount, 2);
assert.equal(history[1].versionIndex, 1);
assert.equal(history[1].thinking, "New reasoning");
assert.equal(history[1].sources[0].url, "https://example.org/news/1");
assert.equal(s.searches, 1);
assert.ok(s.calls[0].messages.every(m => !m.content.includes("Later private question") && !m.content.includes("Original 2")));
assert.equal(s.calls[0].messages.at(-1).content, s.messages[0].content);
await s.api(`${s.path}/versions/0`, {});
history = await s.history();
assert.equal(history[1].content, s.bot.content);
assert.equal(history[1].thinking, "Original reasoning");
assert.equal(history[1].sources[0].url, "https://example.org/old");
const branch = await (await s.api(`${s.path}/branch`, {confirmed: true})).json();
assert.equal(branch.messages.at(-1).content, s.bot.content);
assert.equal(branch.messages.at(-1).thinking, "Original reasoning");
assert.deepEqual(branch.messages.at(-1).sources, history[1].sources);
await s.api(s.path, {content: "Edited original"}, "PUT");
await s.api(`${s.path}/versions/1`, {});
assert.equal((await s.history())[1].content, "Partial answer");
await s.api(`${s.path}/versions/0`, {});
assert.equal((await s.history())[1].content, "Edited original");
assert.equal((await s.api(`${s.path}/versions/99`, {})).status, 400);
s.setMode("success");
const third = await (await s.api(`${s.path}/retry/stream`, {})).text();
assert.match(third, /event: done/);
const thirdMessage = (await s.history())[1];
assert.equal(thirdMessage.versionCount, 3);
assert.equal(thirdMessage.versionIndex, 2);
assert.equal(thirdMessage.thinking, undefined);
assert.deepEqual(thirdMessage.sources, []);
const backup = (await (await s.api("/exports/workspace")).json()).workspace;
assert.equal((await s.api("/imports/workspace", {workspace: backup, allowDuplicates: true})).status, 200);
const copiedVersions = s.app.locals.db.prepare("SELECT * FROM chat_message_versions WHERE message_id != ?").all(s.bot.id);
assert.equal(copiedVersions.length, 3);
assert.equal(copiedVersions[0].thinking, "Original reasoning");
assert.equal(copiedVersions[1].thinking, "New reasoning");
const reopened = new Database(s.app.locals.db.serialize());
try {
runMigrations(reopened, {dropLegacyChats: false});
assert.equal(reopened.prepare("SELECT count(*) n FROM chat_message_versions WHERE message_id = ?").get(s.bot.id).n, 3);
assert.equal(reopened.prepare("SELECT retry_active_index FROM chat_messages WHERE id = ?").get(s.bot.id).retry_active_index, 2);
} finally { reopened.close(); }
await s.api(s.path, null, "DELETE");
assert.equal(s.app.locals.repositories.listMessageVersions(s.bot.id).length, 0);
});
test("failed, empty, stopped and disconnected regeneration preserve the saved answer and versions", async t => {
const s = await fixture(t);
const original = (await s.history())[1];
for (const mode of ["fail", "empty", "search-fail"]) {
s.setMode(mode);
const wire = await (await s.api(`${s.path}/retry/stream`, {reasoning: true, webSearch: mode === "search-fail"})).text();
assert.match(wire, /event: error/);
assert.doesNotMatch(wire, /event: done/);
assert.deepEqual((await s.history())[1], original);
assert.equal(s.app.locals.repositories.listMessageVersions(s.bot.id).length, 0);
if (mode === "search-fail") assert.match(wire, /"searchState":"failed"/);
}
const started = s.hold();
const response = await s.api(`${s.path}/retry/stream`, {});
await started;
assert.equal((await (await s.api(`/chats/${s.chatId}/stop`, {})).json()).stopped, true);
assert.match(await response.text(), /event: error/);
assert.deepEqual((await s.history())[1], original);
const disconnected = s.hold();
const disconnectResponse = await s.api(`${s.path}/retry/stream`, {});
await disconnected;
await disconnectResponse.body.cancel();
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual((await s.history())[1], original);
assert.equal(s.app.locals.repositories.listMessageVersions(s.bot.id).length, 0);
});
+95
View File
@@ -0,0 +1,95 @@
import test from "node:test";
import assert from "node:assert/strict";
import {readFile} from "node:fs/promises";
import {regenerateInline} from "../public/app/regeneration.js";
class Element {
constructor(tag = "div") {
this.tag = tag; this.children = []; this.style = {}; this.dataset = {}; this.attributes = {};
this.scrollTop = 0; this.bottom = 500; this.height = 200; this.top = 0;
}
get childNodes() { return this.children; }
get childElementCount() { return this.children.length; }
append(...children) { children.forEach(child => { child.parent = this; this.children.push(child); }); }
appendChild(child) { this.append(child); }
replaceChildren(...children) { this.children = []; this.append(...children); }
insertBefore(child, before) { child.parent = this; this.children.splice(this.children.indexOf(before), 0, child); }
remove() { this.parent.children = this.parent.children.filter(child => child !== this); }
cloneNode() { const clone = new Element(this.tag); clone.textContent = this.textContent; return clone; }
setAttribute(key, value) { this.attributes[key] = value; }
removeAttribute(key) { delete this.attributes[key]; }
getBoundingClientRect() { return {top: this.top, bottom: this.bottom, height: this.height}; }
querySelector(selector) {
for (const child of this.children) {
if (selector === child.tag || child.className?.split(" ").some(name => selector === `.${name}`)) return child;
const nested = child.querySelector(selector); if (nested) return nested;
}
return null;
}
}
test("inline renderer streams in place, follows only nearby output, commits or restores all original nodes", async t => {
const oldDocument = globalThis.document, oldFetch = globalThis.fetch;
t.after(() => { globalThis.document = oldDocument; globalThis.fetch = oldFetch; });
globalThis.document = {createElement: tag => new Element(tag)};
for (const outcome of ["success", "error", "disconnect"]) {
const scroller = new Element(), card = new Element(), later = new Element();
const header = new Element(), oldBody = new Element(), actions = new Element(), oldSources = new Element();
oldBody.textContent = "Old answer"; oldBody.className = "msg-content";
oldSources.className = "msg-sources";
card.append(header, oldBody, oldSources, actions);
scroller.append(card, later);
const original = [...card.childNodes];
let controller;
globalThis.fetch = async () => new Response(new ReadableStream({start(value) { controller = value; }}));
let completed;
const pending = regenerateInline({card, scroller, endpoint: "/retry/stream", payload: {},
renderContent: (body, content) => { body.textContent = content; card.bottom += 20; },
complete: message => { completed = message; card.replaceChildren(header, new Element(), actions); }});
const event = async (name, data) => {
controller.enqueue(new TextEncoder().encode(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`));
await new Promise(resolve => setTimeout(resolve, 0));
};
assert.equal(card.attributes["aria-busy"], "true");
assert.equal(card.querySelector(".msg-sources"), null);
assert.ok(!card.childNodes.includes(actions));
await event("status", {status: "searching"});
assert.equal(card.querySelector(".loading-shell").textContent, "Searching the web...");
await event("status", {status: "generating", sources: [{title: "New source", url: "https://example.org/new"}]});
assert.notEqual(card.querySelector(".msg-sources"), oldSources);
await event("thinking", {fullThinking: "New reasoning"});
assert.equal(card.querySelector(".msg-reasoning-content").textContent, "New reasoning");
await event("chunk", {fullReply: "Partial"});
assert.equal(card.querySelector(".msg-content").textContent, "Partial");
assert.equal(scroller.scrollTop, 20);
assert.equal(completed, undefined);
assert.deepEqual(scroller.childNodes, [card, later]);
card.bottom = 1000; // User has scrolled away from this response.
await event("chunk", {fullReply: "Partial answer"});
assert.equal(scroller.scrollTop, 20);
if (outcome === "success") {
await event("done", {reply: "Partial answer", message: {id: 2, content: "Partial answer"}});
await pending;
assert.equal(completed.content, "Partial answer");
assert.notDeepEqual(card.childNodes, original);
} else {
const rejection = assert.rejects(pending, outcome === "error" ? /Model failed/ : /interrupted/);
if (outcome === "error") await event("error", {error: "Model failed"});
else controller.close();
await rejection;
assert.deepEqual(card.childNodes, original);
}
assert.equal(card.attributes["aria-busy"], undefined);
assert.equal(card.style.minHeight, undefined);
assert.deepEqual(scroller.childNodes, [card, later]);
}
});
test("Regenerate uses inline activity and never reloads or appends a chat message", async () => {
const source = await readFile(new URL("../public/script.js", import.meta.url), "utf8");
const regenerate = source.slice(source.indexOf("async function retryLatestAssistantMessage"), source.indexOf("async function switchResponseVersion"));
assert.match(regenerate, /setLoadingState\(true, false\)/);
assert.match(regenerate, /regenerateInline/);
assert.match(regenerate, /detached: true/);
assert.doesNotMatch(regenerate, /setActiveChat\(|renderMessages\(|Generating another version/);
});
+4 -3
View File
@@ -217,9 +217,10 @@ test("chat search integrates with streaming, persistence, personas, settings, re
const botId = messages.at(-1).id;
const retry = await api(`/chats/${chatId}/messages/${botId}/retry`, {});
assert.equal(retry.status, 200);
assert.deepEqual(retry.body.message.sources, [source]);
assert.deepEqual(retry.body.message.sources, []);
await api(`/chats/${chatId}/messages/${botId}/versions/0`, {});
assert.equal(searchCount, 1);
assert.ok(calls.at(-1).messages.some((message) => message.content.includes(source.snippet)));
assert.ok(!calls.at(-1).messages.some((message) => message.content.includes(source.snippet)));
const branch = await api(`/chats/${chatId}/messages/${botId}/branch`, {confirmed: true});
assert.deepEqual(branch.body.messages.at(-1).sources, [source]);
const exported = (await api(`/exports/chats/${chatId}?format=json`)).body;
@@ -228,7 +229,7 @@ test("chat search integrates with streaming, persistence, personas, settings, re
const imported = await api("/imports/workspace", {workspace, allowDuplicates: true});
assert.equal(imported.status, 200);
const allMessages = app.locals.db.prepare("SELECT sources FROM chat_messages WHERE sources IS NOT NULL").all();
assert.ok(allMessages.length >= 6);
assert.ok(allMessages.length >= 4);
for (const message of allMessages) assert.deepEqual(JSON.parse(message.sources), [source]);
emptySearch = true;