253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import test from "node:test";
|
|
import { fileURLToPath } from "node:url";
|
|
import Fastify from "fastify";
|
|
import sensible from "@fastify/sensible";
|
|
|
|
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
|
|
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
|
process.env.OPENAI_API_KEY = "";
|
|
delete process.env.ADMIN_TOKEN;
|
|
|
|
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy"], {
|
|
cwd: serverRoot,
|
|
env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1" },
|
|
stdio: "pipe",
|
|
});
|
|
|
|
const [{ clearActiveChatStream, registerActiveChatStream, registerRoutes }, { prisma }] = await Promise.all([
|
|
import("../src/routes.js"),
|
|
import("../src/db.js"),
|
|
]);
|
|
const app = Fastify({ logger: false });
|
|
await app.register(sensible);
|
|
await registerRoutes(app);
|
|
await app.ready();
|
|
|
|
test.after(async () => {
|
|
await app.close();
|
|
await prisma.$disconnect();
|
|
rmSync(databaseDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test("forks copy bounded history and keep every child grouped under the root", async () => {
|
|
const timestamp = new Date("2026-08-16T12:00:00.000Z");
|
|
const source = await prisma.chat.create({
|
|
data: {
|
|
title: "Planning session",
|
|
initiatedProvider: "openai",
|
|
initiatedModel: "gpt-4.1-mini",
|
|
lastUsedProvider: "anthropic",
|
|
lastUsedModel: "claude-sonnet-4-20250514",
|
|
additionalSystemPrompt: "Keep answers concise.",
|
|
enabledTools: ["web_search"],
|
|
messages: {
|
|
create: [
|
|
{
|
|
id: "source-message-0001",
|
|
createdAt: timestamp,
|
|
role: "user",
|
|
content: "Plan a trip",
|
|
metadata: {
|
|
clientRequestId: "source-request",
|
|
attachments: [{ kind: "text", filename: "notes.txt" }],
|
|
},
|
|
},
|
|
{
|
|
id: "source-message-0002",
|
|
createdAt: timestamp,
|
|
role: "assistant",
|
|
content: "First assistant response",
|
|
metadata: { clientRequestId: "source-request", citations: ["https://example.com"] },
|
|
},
|
|
{
|
|
id: "source-message-0003",
|
|
createdAt: timestamp,
|
|
role: "user",
|
|
content: "Only visible in a whole-chat fork",
|
|
},
|
|
],
|
|
},
|
|
calls: {
|
|
create: {
|
|
provider: "openai",
|
|
model: "gpt-4.1-mini",
|
|
request: { input: "Plan a trip" },
|
|
},
|
|
},
|
|
},
|
|
include: { messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] } },
|
|
});
|
|
|
|
const forkResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/chats/${source.id}/fork`,
|
|
payload: { messageId: source.messages[1].id },
|
|
});
|
|
assert.equal(forkResponse.statusCode, 200, forkResponse.body);
|
|
const forkSummary = forkResponse.json().chat;
|
|
assert.equal(forkSummary.parentChatId, source.id);
|
|
assert.equal(forkSummary.title, "Fork of 'First assistant response'");
|
|
assert.equal(forkSummary.titleGenerationPending, true);
|
|
assert.equal(forkSummary.starred, false);
|
|
|
|
const detailResponse = await app.inject({ method: "GET", url: `/v1/chats/${forkSummary.id}` });
|
|
assert.equal(detailResponse.statusCode, 200, detailResponse.body);
|
|
const fork = detailResponse.json().chat;
|
|
assert.deepEqual(
|
|
fork.messages.map((message: any) => [message.role, message.content]),
|
|
[
|
|
["user", "Plan a trip"],
|
|
["assistant", "First assistant response"],
|
|
]
|
|
);
|
|
assert.notEqual(fork.messages[0].id, source.messages[0].id);
|
|
assert.equal(fork.messages[0].createdAt, timestamp.toISOString());
|
|
assert.deepEqual(fork.messages[0].metadata, {
|
|
attachments: [{ kind: "text", filename: "notes.txt" }],
|
|
});
|
|
assert.deepEqual(fork.messages[1].metadata, { citations: ["https://example.com"] });
|
|
assert.equal(fork.initiatedProvider, "openai");
|
|
assert.equal(fork.initiatedModel, "gpt-4.1-mini");
|
|
assert.equal(fork.lastUsedProvider, "anthropic");
|
|
assert.equal(fork.lastUsedModel, "claude-sonnet-4-20250514");
|
|
assert.equal(fork.additionalSystemPrompt, "Keep answers concise.");
|
|
assert.deepEqual(fork.enabledTools, ["web_search"]);
|
|
assert.deepEqual(fork.calls, []);
|
|
|
|
const childForkResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/chats/${forkSummary.id}/fork`,
|
|
payload: {},
|
|
});
|
|
assert.equal(childForkResponse.statusCode, 200, childForkResponse.body);
|
|
const childFork = childForkResponse.json().chat;
|
|
assert.equal(childFork.parentChatId, source.id);
|
|
assert.equal(childFork.title, "Fork of Fork of 'First assistant response'");
|
|
assert.equal(childFork.titleGenerationPending, true);
|
|
|
|
const wholeForkResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/chats/${source.id}/fork`,
|
|
payload: {},
|
|
});
|
|
assert.equal(wholeForkResponse.statusCode, 200, wholeForkResponse.body);
|
|
const wholeFork = wholeForkResponse.json().chat;
|
|
assert.equal(wholeFork.parentChatId, source.id);
|
|
assert.equal(wholeFork.title, "Fork of Planning session");
|
|
const wholeForkDetail = await app.inject({ method: "GET", url: `/v1/chats/${wholeFork.id}` });
|
|
assert.equal(wholeForkDetail.statusCode, 200, wholeForkDetail.body);
|
|
assert.deepEqual(
|
|
wholeForkDetail.json().chat.messages.map((message: any) => message.content),
|
|
["Plan a trip", "First assistant response", "Only visible in a whole-chat fork"]
|
|
);
|
|
assert.equal(wholeForkDetail.json().chat.messages[2].metadata, null);
|
|
|
|
const suggestedTitleResponse = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/chats/title/suggest",
|
|
payload: { chatId: wholeFork.id, content: "Compare rail and air options" },
|
|
});
|
|
assert.equal(suggestedTitleResponse.statusCode, 200, suggestedTitleResponse.body);
|
|
assert.equal(suggestedTitleResponse.json().chat.title, "Compare rail and air");
|
|
assert.equal(suggestedTitleResponse.json().chat.titleGenerationPending, false);
|
|
|
|
const repeatedTitleResponse = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/chats/title/suggest",
|
|
payload: { chatId: wholeFork.id, content: "This must not overwrite the generated title" },
|
|
});
|
|
assert.equal(repeatedTitleResponse.statusCode, 200, repeatedTitleResponse.body);
|
|
assert.equal(repeatedTitleResponse.json().chat.title, "Compare rail and air");
|
|
|
|
const sourceAfterForks = await prisma.chat.findUniqueOrThrow({
|
|
where: { id: source.id },
|
|
include: { messages: true, calls: true },
|
|
});
|
|
assert.equal(sourceAfterForks.messages.length, 3);
|
|
assert.equal(sourceAfterForks.calls.length, 1);
|
|
|
|
const manualTitleResponse = await app.inject({
|
|
method: "PATCH",
|
|
url: `/v1/chats/${forkSummary.id}`,
|
|
payload: { title: "Manual branch title" },
|
|
});
|
|
assert.equal(manualTitleResponse.statusCode, 200, manualTitleResponse.body);
|
|
assert.equal(manualTitleResponse.json().chat.titleGenerationPending, false);
|
|
|
|
const activeForkStream = registerActiveChatStream(wholeFork.id);
|
|
try {
|
|
const deleteActiveRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
|
assert.equal(deleteActiveRootResponse.statusCode, 409, deleteActiveRootResponse.body);
|
|
assert.equal(deleteActiveRootResponse.json().message, "chat or fork has an active stream");
|
|
|
|
const deleteActiveForkResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${wholeFork.id}` });
|
|
assert.equal(deleteActiveForkResponse.statusCode, 409, deleteActiveForkResponse.body);
|
|
} finally {
|
|
clearActiveChatStream(wholeFork.id, activeForkStream);
|
|
}
|
|
|
|
const deleteChildResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${childFork.id}` });
|
|
assert.equal(deleteChildResponse.statusCode, 200, deleteChildResponse.body);
|
|
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, wholeFork.id] } } }), 3);
|
|
|
|
const unrelated = await prisma.chat.create({
|
|
data: {
|
|
messages: { create: { role: "assistant", content: "Unrelated response" } },
|
|
},
|
|
include: { messages: true },
|
|
});
|
|
const countBeforeInvalidForks = await prisma.chat.count();
|
|
|
|
const wrongChatResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/chats/${source.id}/fork`,
|
|
payload: { messageId: unrelated.messages[0].id },
|
|
});
|
|
assert.equal(wrongChatResponse.statusCode, 404, wrongChatResponse.body);
|
|
assert.equal(wrongChatResponse.json().message, "message not found in chat");
|
|
|
|
const nonAssistantResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/chats/${source.id}/fork`,
|
|
payload: { messageId: source.messages[0].id },
|
|
});
|
|
assert.equal(nonAssistantResponse.statusCode, 400, nonAssistantResponse.body);
|
|
assert.equal(nonAssistantResponse.json().message, "fork message must be an assistant response");
|
|
assert.equal(await prisma.chat.count(), countBeforeInvalidForks);
|
|
|
|
const concurrentTarget = await prisma.chat.create({ data: { title: "Concurrent delete" } });
|
|
const concurrentDeleteResponses = await Promise.all([
|
|
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
|
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentTarget.id}` }),
|
|
]);
|
|
assert.equal(concurrentDeleteResponses.filter((response) => response.statusCode === 200).length, 1);
|
|
assert.equal(concurrentDeleteResponses.some((response) => response.statusCode >= 500), false);
|
|
|
|
const concurrentRoot = await prisma.chat.create({ data: { title: "Concurrent family delete" } });
|
|
const concurrentChildResponse = await app.inject({
|
|
method: "POST",
|
|
url: `/v1/chats/${concurrentRoot.id}/fork`,
|
|
payload: {},
|
|
});
|
|
assert.equal(concurrentChildResponse.statusCode, 200, concurrentChildResponse.body);
|
|
const concurrentChild = concurrentChildResponse.json().chat;
|
|
const concurrentFamilyDeleteResponses = await Promise.all([
|
|
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentRoot.id}` }),
|
|
app.inject({ method: "DELETE", url: `/v1/chats/${concurrentChild.id}` }),
|
|
]);
|
|
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode >= 500), false);
|
|
assert.equal(concurrentFamilyDeleteResponses.some((response) => response.statusCode === 200), true);
|
|
await prisma.chat.deleteMany({ where: { id: concurrentRoot.id } });
|
|
|
|
const deleteRootResponse = await app.inject({ method: "DELETE", url: `/v1/chats/${source.id}` });
|
|
assert.equal(deleteRootResponse.statusCode, 200, deleteRootResponse.body);
|
|
assert.equal(await prisma.chat.count({ where: { id: { in: [source.id, forkSummary.id, childFork.id, wholeFork.id] } } }), 0);
|
|
assert.equal(await prisma.chat.count({ where: { id: unrelated.id } }), 1);
|
|
});
|