Allow message retries for "pending" status, refactor message context handling, remove workspace import/export routes, and add SQLite-backed rate limiting.
CI / check (22) (push) Failing after 16m26s
CI / check (24) (push) Failing after 15m49s
CI / check (22) (pull_request) Failing after 16m26s
CI / check (24) (pull_request) Failing after 15m50s

This commit is contained in:
2026-10-09 19:28:43 +02:00
parent 8666e09049
commit f67d918d09
13 changed files with 313 additions and 193 deletions
+4 -1
View File
@@ -235,9 +235,12 @@ test("conversation memory exposes context estimates and supports validated summa
const repo = server.app.locals.repositories;
const chat = repo.createChat("memory-editor", "Memory");
repo.createChatMemory("memory-editor", chat, "Prefers concise answers.");
repo.updateChatContextSummary(chat.id, "memory-editor", "Old summary", 0);
const userMessage = repo.insertChatMessage(chat.id, "user", "I prefer short answers.");
const assistantMessage = repo.insertChatMessage(chat.id, "bot", "I will keep them concise.", null, null, "complete", null, Number(userMessage.lastInsertRowid));
repo.updateChatContextSummary(chat.id, "memory-editor", "Old summary", Number(assistantMessage.lastInsertRowid));
const loaded = (await request(server.baseUrl, `/chats/${chat.id}/memories`, {jar})).json;
assert.equal(loaded.summary, "Old summary"); assert.ok(loaded.context.estimatedTokens > 0);
assert.deepEqual(loaded.summarySources.map(message => message.content), ["I prefer short answers.", "I will keep them concise."]);
const update = body => request(server.baseUrl, `/chats/${chat.id}/summary`, {method: "PUT", jar, body});
assert.equal((await update({summary: "Corrected summary", previousSummary: "Old summary"})).status, 200);
assert.equal(repo.getChat(chat.id, "memory-editor").context_summary, "Corrected summary");
+21 -1
View File
@@ -75,7 +75,7 @@ async function setup(t, {visionResult = null, replyResult = null, visionModel =
const image = format => sharp({create: {width: 20, height: 10, channels: 3, background: "blue"}}).toFormat(format).toBuffer();
const chat = async () => (await (await api("/chats", {title: "Image test"})).json()).chat.id;
return {app, api, upload, image, register, directory, requests, order, searchQueries, chat,
failVision: () => { visionFailure = true; }, holdVision: () => { holdVision = true; return visionStarted; },
failVision: () => { visionFailure = true; }, recoverVision: () => { visionFailure = false; }, holdVision: () => { holdVision = true; return visionStarted; },
get visionAborted() { return visionAborted; }};
}
@@ -239,6 +239,26 @@ test("vision failure prevents an ungrounded answer and malformed JSON falls back
assert.throws(() => parseVisionAnalysis(""));
});
test("resuming a failed image turn reuses its branch node and attachment", async t => {
const s = await setup(t);
const attachment = await (await s.upload(await s.image("png"))).json();
const chatId = await s.chat();
s.failVision();
const failedWire = await (await s.api("/chat/stream", {chatId, message: "Describe the image", attachments: [attachment.id]})).text();
assert.match(failedWire, /event: error/);
const failedMessages = (await (await s.api(`/chats/${chatId}/messages`)).json()).messages;
const interrupted = failedMessages.find(message => message.role === "user");
assert.equal(interrupted.deliveryStatus, "failed");
s.recoverVision();
const resumedWire = await (await s.api("/chat/stream", {chatId, message: interrupted.content,
attachments: [attachment.id], userMessageId: interrupted.id})).text();
assert.match(resumedWire, /event: done/);
const messages = (await (await s.api(`/chats/${chatId}/messages`)).json()).messages;
assert.equal(messages.filter(message => message.role === "user").length, 1);
assert.equal(messages[0].attachments[0].id, attachment.id);
assert.equal(messages[0].deliveryStatus, "complete");
});
test("browser disconnect cancels in-progress vision and never starts the final model", async t => {
const s = await setup(t);
const attachment = await (await s.upload(await s.image("png"))).json();
+24
View File
@@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
import Database from "better-sqlite3";
import {createRateLimiter, createSqliteRateLimitStore} from "../src/middleware/rateLimit.js";
test("SQLite rate limit store shares rolling-window hits between limiter instances", () => {
const db = new Database(":memory:");
const firstStore = createSqliteRateLimitStore(db);
const secondStore = createSqliteRateLimitStore(db);
const options = {windowMs: 1000, max: 2, keyGenerator: () => "account:test", message: "limited", cleanupIntervalMs: 0};
const first = createRateLimiter({...options, store: firstStore});
const second = createRateLimiter({...options, store: secondStore});
const hit = limiter => {
let allowed = false, status = null;
limiter({}, {status(value) {status = value; return this;}, json() {}}, () => {allowed = true;});
return {allowed, status};
};
assert.deepEqual(hit(first), {allowed: true, status: null});
assert.deepEqual(hit(second), {allowed: true, status: null});
assert.deepEqual(hit(first), {allowed: false, status: 429});
assert.equal(firstStore.consume("account:test", Date.now() + 1001, 1000, 2), true);
first.close(); second.close(); db.close();
});