This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
-- Add durable root grouping and one-time fork title generation state.
|
||||
ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId");
|
||||
@@ -51,7 +51,8 @@ model Chat {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
title String?
|
||||
title String?
|
||||
titleGenerationPending Boolean @default(false)
|
||||
|
||||
initiatedProvider Provider?
|
||||
initiatedModel String?
|
||||
@@ -64,11 +65,17 @@ model Chat {
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
|
||||
// Forks always point directly to the single root chat, never to another fork.
|
||||
parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade)
|
||||
parentChatId String?
|
||||
childChats Chat[] @relation("ChatForks")
|
||||
|
||||
messages Message[]
|
||||
calls LlmCall[]
|
||||
projectItems ProjectItem[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([parentChatId])
|
||||
}
|
||||
|
||||
model Message {
|
||||
|
||||
+194
-48
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { z } from "zod";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
@@ -18,6 +19,7 @@ import type { ChatAttachment } from "./llm/types.js";
|
||||
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||
const MAX_ADDITIONAL_SYSTEM_PROMPT_CHARS = 12_000;
|
||||
const MAX_FORK_MESSAGE_SNIPPET_CHARS = 48;
|
||||
const EnabledToolsSchema = z.array(z.string().trim().min(1).max(80)).max(20).transform((value) => normalizeEnabledChatTools(value));
|
||||
|
||||
type IncomingChatMessage = {
|
||||
@@ -94,7 +96,7 @@ async function storeNonAssistantMessages(chatId: string, messages: IncomingChatM
|
||||
|
||||
const existing = await prisma.message.findMany({
|
||||
where: { chatId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||
select: { role: true, content: true, name: true, metadata: true },
|
||||
});
|
||||
const existingNonAssistant = existing.filter((m) => m.role !== "assistant" && !isToolCallLogMessage(m));
|
||||
@@ -317,6 +319,21 @@ function normalizeSuggestedTitle(raw: string, fallback: string) {
|
||||
return words.slice(0, 4).join(" ").slice(0, 64).trim() || fallback;
|
||||
}
|
||||
|
||||
export function buildForkTitle(originalTitle: string | null, messageContent?: string) {
|
||||
if (messageContent !== undefined) {
|
||||
const snippet = truncateContextPart(messageContent.replace(/\s+/g, " "), MAX_FORK_MESSAGE_SNIPPET_CHARS) ?? "message";
|
||||
return `Fork of '${snippet}'`;
|
||||
}
|
||||
return `Fork of ${originalTitle?.trim() || "Untitled chat"}`;
|
||||
}
|
||||
|
||||
export function copyForkMessageMetadata(metadata: unknown) {
|
||||
if (metadata === null || metadata === undefined) return undefined;
|
||||
if (typeof metadata !== "object" || Array.isArray(metadata)) return metadata;
|
||||
const { clientRequestId: _clientRequestId, ...copied } = metadata as Record<string, unknown>;
|
||||
return Object.keys(copied).length ? copied : undefined;
|
||||
}
|
||||
|
||||
async function generateChatTitle(content: string) {
|
||||
const systemPrompt =
|
||||
"You create short chat titles. Return exactly one line, maximum 4 words, no quotes, no trailing punctuation.";
|
||||
@@ -415,6 +432,7 @@ type SearchRunRequest = z.infer<typeof SearchRunBody>;
|
||||
|
||||
const activeChatStreams = new Map<string, ActiveSseStream>();
|
||||
const activeChatStreamRequestIds = new Map<string, string>();
|
||||
const chatDeletionRoots = new Set<string>();
|
||||
const activeSearchStreams = new Map<string, ActiveSseStream>();
|
||||
const STARRED_PROJECT_ID = "starred";
|
||||
|
||||
@@ -427,6 +445,8 @@ const starredProjectItemsSelect = {
|
||||
const chatSummarySelect = {
|
||||
id: true,
|
||||
title: true,
|
||||
titleGenerationPending: true,
|
||||
parentChatId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
initiatedProvider: true,
|
||||
@@ -507,6 +527,29 @@ async function getSearchSummary(searchId: string) {
|
||||
return search ? serializeSearchLike(search) : null;
|
||||
}
|
||||
|
||||
async function listRecentChatsWithRoots() {
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
const includedIds = new Set(chats.map((chat) => chat.id));
|
||||
const missingRootIds = [
|
||||
...new Set(
|
||||
chats
|
||||
.map((chat) => chat.parentChatId)
|
||||
.filter((id): id is string => id !== null && !includedIds.has(id))
|
||||
),
|
||||
];
|
||||
if (!missingRootIds.length) return chats;
|
||||
|
||||
const roots = await prisma.chat.findMany({
|
||||
where: { id: { in: missingRootIds } },
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return [...chats, ...roots].sort(compareUpdatedAtDesc);
|
||||
}
|
||||
|
||||
async function setChatStarred(chatId: string, starred: boolean) {
|
||||
const exists = await prisma.chat.findUnique({ where: { id: chatId }, select: { id: true } });
|
||||
if (!exists) return null;
|
||||
@@ -545,11 +588,7 @@ async function setSearchStarred(searchId: string, starred: boolean) {
|
||||
|
||||
async function listWorkspaceItems() {
|
||||
const [chats, searches] = await Promise.all([
|
||||
prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
}),
|
||||
listRecentChatsWithRoots(),
|
||||
prisma.search.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
@@ -605,7 +644,7 @@ function mapChatStreamEvent(ev: StreamEvent): SseStreamEvent {
|
||||
return { event: ev.type, data: ev };
|
||||
}
|
||||
|
||||
function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||
export function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||
const stream = new ActiveSseStream();
|
||||
activeChatStreams.set(chatId, stream);
|
||||
if (clientRequestId) {
|
||||
@@ -616,7 +655,7 @@ function registerActiveChatStream(chatId: string, clientRequestId?: string) {
|
||||
return stream;
|
||||
}
|
||||
|
||||
function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
|
||||
export function clearActiveChatStream(chatId: string, stream: ActiveSseStream) {
|
||||
if (activeChatStreams.get(chatId) !== stream) return;
|
||||
activeChatStreams.delete(chatId);
|
||||
activeChatStreamRequestIds.delete(chatId);
|
||||
@@ -647,12 +686,6 @@ function executeActiveChatStream(chatId: string, body: z.infer<typeof Completion
|
||||
})();
|
||||
}
|
||||
|
||||
function startActiveChatStream(chatId: string, body: z.infer<typeof CompletionStreamBody>) {
|
||||
const stream = registerActiveChatStream(chatId, body.clientRequestId);
|
||||
executeActiveChatStream(chatId, body, stream);
|
||||
return stream;
|
||||
}
|
||||
|
||||
function getMetadataClientRequestId(metadata: unknown) {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return null;
|
||||
const clientRequestId = (metadata as Record<string, unknown>).clientRequestId;
|
||||
@@ -888,11 +921,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get("/v1/chats", async (req) => {
|
||||
requireAdmin(req);
|
||||
const chats = await prisma.chat.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 100,
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
const chats = await listRecentChatsWithRoots();
|
||||
return { chats: chats.map((chat) => serializeChatLike(chat)) };
|
||||
});
|
||||
|
||||
@@ -951,6 +980,80 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return { chat: serializeChatLike(chat) };
|
||||
});
|
||||
|
||||
app.post("/v1/chats/:chatId/fork", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
const Body = z.object({ messageId: z.string().trim().min(1).optional() });
|
||||
const { chatId } = Params.parse(req.params);
|
||||
const parsed = Body.safeParse(req.body ?? {});
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const source = await tx.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
parentChatId: true,
|
||||
initiatedProvider: true,
|
||||
initiatedModel: true,
|
||||
lastUsedProvider: true,
|
||||
lastUsedModel: true,
|
||||
additionalSystemPrompt: true,
|
||||
enabledTools: true,
|
||||
userId: true,
|
||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!source) return { status: "chat-not-found" as const };
|
||||
|
||||
let messages = source.messages;
|
||||
let selectedMessage: (typeof source.messages)[number] | undefined;
|
||||
if (parsed.data.messageId) {
|
||||
const selectedIndex = messages.findIndex((message) => message.id === parsed.data.messageId);
|
||||
if (selectedIndex < 0) return { status: "message-not-found" as const };
|
||||
selectedMessage = messages[selectedIndex];
|
||||
if (selectedMessage.role !== "assistant") return { status: "message-not-assistant" as const };
|
||||
messages = messages.slice(0, selectedIndex + 1);
|
||||
}
|
||||
|
||||
const forkMessageIdPrefix = `fork-${randomUUID()}`;
|
||||
const chat = await tx.chat.create({
|
||||
data: {
|
||||
title: buildForkTitle(source.title, selectedMessage?.content),
|
||||
titleGenerationPending: true,
|
||||
parentChatId: source.parentChatId ?? source.id,
|
||||
initiatedProvider: source.initiatedProvider,
|
||||
initiatedModel: source.initiatedModel,
|
||||
lastUsedProvider: source.lastUsedProvider,
|
||||
lastUsedModel: source.lastUsedModel,
|
||||
additionalSystemPrompt: source.additionalSystemPrompt,
|
||||
enabledTools: (source.enabledTools ?? undefined) as any,
|
||||
userId: source.userId,
|
||||
messages: messages.length
|
||||
? {
|
||||
create: messages.map((message, index) => ({
|
||||
id: `${forkMessageIdPrefix}-${String(index).padStart(8, "0")}`,
|
||||
createdAt: message.createdAt,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
name: message.name,
|
||||
metadata: copyForkMessageMetadata(message.metadata) as any,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
return { status: "created" as const, chat };
|
||||
});
|
||||
|
||||
if (result.status === "chat-not-found") return app.httpErrors.notFound("chat not found");
|
||||
if (result.status === "message-not-found") return app.httpErrors.notFound("message not found in chat");
|
||||
if (result.status === "message-not-assistant") return app.httpErrors.badRequest("fork message must be an assistant response");
|
||||
return { chat: serializeChatLike(result.chat) };
|
||||
});
|
||||
|
||||
app.patch("/v1/chats/:chatId", async (req) => {
|
||||
requireAdmin(req);
|
||||
const Params = z.object({ chatId: z.string() });
|
||||
@@ -963,7 +1066,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const body = Body.parse(req.body ?? {});
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.title !== undefined) data.title = body.title;
|
||||
if (body.title !== undefined) {
|
||||
data.title = body.title;
|
||||
data.titleGenerationPending = false;
|
||||
}
|
||||
if (body.additionalSystemPrompt !== undefined) data.additionalSystemPrompt = normalizeAdditionalSystemPrompt(body.additionalSystemPrompt);
|
||||
if (body.enabledTools !== undefined) data.enabledTools = body.enabledTools;
|
||||
|
||||
@@ -1004,7 +1110,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
select: chatSummarySelect,
|
||||
});
|
||||
if (!existing) return app.httpErrors.notFound("chat not found");
|
||||
if (existing.title?.trim()) return { chat: serializeChatLike(existing) };
|
||||
if (existing.title?.trim() && !existing.titleGenerationPending) return { chat: serializeChatLike(existing) };
|
||||
|
||||
const fallback = body.content.split(/\r?\n/)[0]?.trim().slice(0, 48) || "New chat";
|
||||
let suggestedRaw = "";
|
||||
@@ -1022,8 +1128,12 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const title = normalizeSuggestedTitle(suggestedRaw, fallback);
|
||||
|
||||
await prisma.chat.updateMany({
|
||||
where: { id: body.chatId, title: existing.title },
|
||||
data: { title },
|
||||
where: {
|
||||
id: body.chatId,
|
||||
title: existing.title,
|
||||
titleGenerationPending: existing.titleGenerationPending,
|
||||
},
|
||||
data: { title, titleGenerationPending: false },
|
||||
});
|
||||
|
||||
const chat = await getChatSummary(body.chatId);
|
||||
@@ -1039,14 +1149,46 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
req.log.info({ chatId }, "delete chat requested");
|
||||
|
||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||
if (result.count === 0) {
|
||||
const target = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { parentChatId: true },
|
||||
});
|
||||
if (!target) {
|
||||
req.log.warn({ chatId }, "delete chat target not found");
|
||||
return app.httpErrors.notFound("chat not found");
|
||||
}
|
||||
|
||||
req.log.info({ chatId }, "chat deleted");
|
||||
return { deleted: true };
|
||||
const familyRootId = target.parentChatId ?? chatId;
|
||||
if (chatDeletionRoots.has(familyRootId)) {
|
||||
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||
}
|
||||
|
||||
chatDeletionRoots.add(familyRootId);
|
||||
try {
|
||||
const familyIds = target.parentChatId
|
||||
? [chatId]
|
||||
: (
|
||||
await prisma.chat.findMany({
|
||||
where: { OR: [{ id: chatId }, { parentChatId: chatId }] },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((chat) => chat.id);
|
||||
if (familyIds.some((id) => activeChatStreams.has(id))) {
|
||||
req.log.warn({ chatId }, "delete chat rejected while chat family is active");
|
||||
return app.httpErrors.conflict("chat or fork has an active stream");
|
||||
}
|
||||
|
||||
const result = await prisma.chat.deleteMany({ where: { id: chatId } });
|
||||
if (result.count === 0) {
|
||||
req.log.warn({ chatId }, "delete chat target no longer exists");
|
||||
return app.httpErrors.notFound("chat not found");
|
||||
}
|
||||
|
||||
req.log.info({ chatId }, "chat deleted");
|
||||
return { deleted: true };
|
||||
} finally {
|
||||
chatDeletionRoots.delete(familyRootId);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/v1/searches", async (req) => {
|
||||
@@ -1336,7 +1478,7 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
messages: { orderBy: { createdAt: "asc" } },
|
||||
messages: { orderBy: [{ createdAt: "asc" }, { id: "asc" }] },
|
||||
calls: { orderBy: { createdAt: "desc" } },
|
||||
projectItems: starredProjectItemsSelect,
|
||||
},
|
||||
@@ -1430,10 +1572,15 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
if (!parsed.success) return app.httpErrors.badRequest(parsed.error.message);
|
||||
const body = withRequestUserLocation(parsed.data, req);
|
||||
|
||||
// ensure chat exists if provided
|
||||
// Ensure the chat exists and identify its family before reserving a stream.
|
||||
let chatFamilyRootId: string | null = null;
|
||||
if (body.chatId) {
|
||||
const exists = await prisma.chat.findUnique({ where: { id: body.chatId }, select: { id: true } });
|
||||
const exists = await prisma.chat.findUnique({
|
||||
where: { id: body.chatId },
|
||||
select: { id: true, parentChatId: true },
|
||||
});
|
||||
if (!exists) return app.httpErrors.notFound("chat not found");
|
||||
chatFamilyRootId = exists.parentChatId ?? exists.id;
|
||||
}
|
||||
|
||||
if (body.persist !== false && body.chatId) {
|
||||
@@ -1445,32 +1592,31 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
return app.httpErrors.conflict("chat completion already running");
|
||||
}
|
||||
|
||||
if (body.clientRequestId) {
|
||||
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
||||
try {
|
||||
if (chatFamilyRootId && chatDeletionRoots.has(chatFamilyRootId)) {
|
||||
return app.httpErrors.conflict("chat family deletion already in progress");
|
||||
}
|
||||
|
||||
const reservedStream = registerActiveChatStream(body.chatId, body.clientRequestId);
|
||||
try {
|
||||
if (body.clientRequestId) {
|
||||
const completedSubmission = await findCompletedChatSubmission(body.chatId, body.clientRequestId);
|
||||
if (completedSubmission) {
|
||||
completeChatSubmissionStream(reservedStream, body.chatId, body, completedSubmission.content);
|
||||
clearActiveChatStream(body.chatId, reservedStream);
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
}
|
||||
|
||||
// Store only new non-assistant messages to avoid duplicate history entries.
|
||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
||||
const configuredBody = await applyStoredChatSettings(body);
|
||||
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
} catch (err) {
|
||||
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||
clearActiveChatStream(body.chatId, reservedStream);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy requests without an idempotency key retain the original behavior.
|
||||
await storeNonAssistantMessages(body.chatId, body.messages);
|
||||
const stream = startActiveChatStream(body.chatId, await applyStoredChatSettings(body));
|
||||
return streamActiveRun(req, reply, stream);
|
||||
// Reserve the stream before persistence so deletion cannot interleave with setup.
|
||||
await storeNonAssistantMessages(body.chatId, body.messages, body.clientRequestId);
|
||||
const configuredBody = await applyStoredChatSettings(body);
|
||||
executeActiveChatStream(body.chatId, configuredBody, reservedStream);
|
||||
return streamActiveRun(req, reply, reservedStream);
|
||||
} catch (err) {
|
||||
reservedStream.complete({ event: "error", data: { message: getErrorMessage(err) } });
|
||||
clearActiveChatStream(body.chatId, reservedStream);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
reply.raw.writeHead(200, buildSseHeaders(typeof req.headers.origin === "string" ? req.headers.origin : undefined));
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user