import assert from "node:assert/strict"; import {mkdtempSync, rmSync, writeFileSync} from "node:fs"; import http from "node:http"; import {tmpdir} from "node:os"; import path from "node:path"; import {spawn} from "node:child_process"; import {Database} from "bun:sqlite"; import {createHash} from "node:crypto"; import {createMessageObject} from "./lib/message-object.ts"; const root = process.cwd(); const temporaryDirectory = mkdtempSync(path.join(tmpdir(), "xiteng-chat-history-test-")); const databasePath = path.join(temporaryDirectory, "chat.db"); const tokenPath = path.join(temporaryDirectory, "service-token"); writeFileSync(tokenPath, "history-test-token"); const legacyDatabase = new Database(databasePath, {create: true}); legacyDatabase.run(` CREATE TABLE chat_conversation ( id TEXT PRIMARY KEY, owner_issuer TEXT NOT NULL, owner_sub TEXT NOT NULL, title TEXT NOT NULL, provider_id TEXT NOT NULL, model TEXT NOT NULL, settings_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE chat_message ( conversation_id TEXT NOT NULL REFERENCES chat_conversation(id) ON DELETE CASCADE, id TEXT NOT NULL, ordinal INTEGER NOT NULL, role TEXT NOT NULL, parts_json TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (conversation_id, id), UNIQUE (conversation_id, ordinal) ); `); const legacyTimestamp = new Date().toISOString(); legacyDatabase.query("INSERT INTO chat_conversation VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") .run("legacy-conversation", "https://auth.xiteng.site", "legacy-sub", "legacy/chat", "openai", "gpt-legacy", "{}", legacyTimestamp, legacyTimestamp); legacyDatabase.query("INSERT INTO chat_message VALUES (?, ?, ?, ?, ?, ?)") .run("legacy-conversation", "legacy-user", 0, "user", JSON.stringify([{type: "text", text: "legacy question"}]), legacyTimestamp); legacyDatabase.query("INSERT INTO chat_message VALUES (?, ?, ?, ?, ?, ?)") .run("legacy-conversation", "legacy-assistant", 1, "assistant", JSON.stringify([{type: "text", text: "legacy answer"}]), legacyTimestamp); legacyDatabase.close(); function availablePort() { return new Promise((resolve, reject) => { const server = http.createServer(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const port = server.address().port; server.close(() => resolve(port)); }); }); } async function waitForServer(url, child) { const deadline = Date.now() + 30000; while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`Bun server exited with code ${child.exitCode}`); try { if ((await fetch(url)).ok) return; } catch {} await new Promise((resolve) => setTimeout(resolve, 100)); } throw new Error("Timed out waiting for history test server"); } async function startServer() { const port = await availablePort(); const child = spawn(process.execPath, [path.join(root, "src/server.ts")], { cwd: root, env: { ...process.env, PORT: String(port), STATIC_ROOT: path.join(root, "dist"), CHAT_DATABASE_PATH: databasePath, KEY_VAULT_URL: "http://127.0.0.1:1", KEY_VAULT_TOKEN_FILE: tokenPath }, stdio: "ignore" }); await waitForServer(`http://127.0.0.1:${port}/chat/api/health`, child); return {child, origin: `http://127.0.0.1:${port}`}; } function stopServer(child) { if (child.exitCode !== null) return Promise.resolve(); return new Promise((resolve) => { child.once("exit", resolve); child.kill("SIGTERM"); }); } async function api(origin, identity, pathname, init = {}) { const response = await fetch(`${origin}/chat${pathname}`, { ...init, headers: { "X-Authentik-Username": identity.username, "X-Authentik-Uid": identity.sub, ...(init.body ? {"Content-Type": "application/json"} : {}), ...(init.headers || {}) } }); const payload = response.status === 204 ? null : await response.json(); return {response, payload}; } const owner = {username: "history-owner", sub: "owner-sub"}; const other = {username: "history-other", sub: "other-sub"}; const legacyOwner = {username: "history-legacy", sub: "legacy-sub"}; const distributedOwner = {username: "distributed-owner", sub: "distributed-sub"}; const localRepositoryOwner = {username: "local-repository-owner", sub: "local-repository-sub"}; const untitledOwner = {username: "untitled-owner", sub: "untitled-sub"}; let server; try { server = await startServer(); const publicConfigResponse = await fetch(`${server.origin}/chat/api/public-config`); assert.equal(publicConfigResponse.status, 200); const publicConfig = await publicConfigResponse.json(); assert.ok(publicConfig.providers.length > 0); assert.ok(publicConfig.providers.every((provider) => provider.connection.type === "frontend" && provider.credentials.length === 0)); const anonymousPrivateConfig = await fetch(`${server.origin}/chat/api/config`); assert.equal(anonymousPrivateConfig.status, 401); const migratedLegacy = await api(server.origin, legacyOwner, "/api/conversations/legacy-conversation"); assert.equal(migratedLegacy.response.status, 200); assert.equal(migratedLegacy.payload.conversation.name, "legacy/chat"); assert.equal(migratedLegacy.payload.conversation.messages.length, 2); assert.ok(migratedLegacy.payload.conversation.messages.every((message) => message.id.startsWith("sha256:"))); assert.equal(migratedLegacy.payload.conversation.messages[1].parentMessageId, migratedLegacy.payload.conversation.messages[0].id); const initialSettings = {reasoning: "low", showReasoningSummary: false, temperature: null, maxOutputTokens: null}; const createdResult = await api(server.origin, owner, "/api/conversations", { method: "POST", body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings, name: "work/chat"}) }); assert.equal(createdResult.response.status, 201); assert.deepEqual(createdResult.payload.conversation.generationSettings, initialSettings); assert.equal(createdResult.payload.conversation.name, "work/chat"); assert.equal(createdResult.payload.conversation.headMessageId, null); const untitledResult = await api(server.origin, untitledOwner, "/api/conversations", { method: "POST", body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings}) }); assert.equal(untitledResult.response.status, 201); assert.equal(untitledResult.payload.conversation.name, ""); const untitledUser = await api(server.origin, untitledOwner, `/api/conversations/${untitledResult.payload.conversation.id}/messages`, { method: "POST", body: JSON.stringify({ id: "untitled-user", expectedHeadId: null, parentMessageId: null, role: "user", parts: [{type: "text", text: "这段文字不能直接成为标题"}], origin: {type: "user"}, completion: {status: "complete"} }) }); assert.equal(untitledUser.response.status, 201); assert.equal(untitledUser.payload.conversation.name, ""); const conversationId = createdResult.payload.conversation.id; const userResult = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { method: "POST", body: JSON.stringify({ id: "user-1", expectedHeadId: null, parentMessageId: null, role: "user", parts: [{type: "text", text: "persistent question"}], origin: {type: "user", clientId: "history-test"}, completion: {status: "complete"} }) }); assert.equal(userResult.response.status, 201); assert.equal(userResult.payload.conversation.headMessageId, "user-1"); const responseMetadata = {providerId: "openai", model: "gpt-test", durationMs: 2000, outputTokens: 40, tokensPerSecond: 20}; const assistantResult = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { method: "POST", body: JSON.stringify({ id: "assistant-1", expectedHeadId: "user-1", parentMessageId: "user-1", role: "assistant", parts: [{type: "reasoning", text: "persistent reasoning"}, {type: "text", text: "persistent answer"}], origin: {type: "model", providerId: "openai", model: "gpt-test", attemptId: "attempt-1"}, completion: {status: "complete"}, metadata: {custom: {response: responseMetadata}} }) }); assert.equal(assistantResult.response.status, 201); assert.equal(assistantResult.payload.conversation.messageCount, 2); const conflict = await api(server.origin, owner, `/api/conversations/${conversationId}/messages`, { method: "POST", body: JSON.stringify({id: "conflict", expectedHeadId: "user-1", parentMessageId: "user-1", role: "assistant", parts: [], origin: {type: "legacy"}, completion: {status: "complete"}}) }); assert.equal(conflict.response.status, 409); const updatedSettings = {reasoning: "high", showReasoningSummary: true, temperature: 0.7, maxOutputTokens: 4096}; const settingsResult = await api(server.origin, owner, `/api/conversations/${conversationId}`, { method: "PATCH", body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: updatedSettings, name: "work/chat/main"}) }); assert.equal(settingsResult.response.status, 200); assert.equal(settingsResult.payload.conversation.name, "work/chat/main"); const branchResult = await api(server.origin, owner, "/api/conversations", { method: "POST", body: JSON.stringify({providerId: "openai", model: "gpt-test", generationSettings: initialSettings, name: "work/chat/branch", headMessageId: "user-1"}) }); assert.equal(branchResult.response.status, 201); assert.equal(branchResult.payload.conversation.messageCount, 1); assert.equal(branchResult.payload.conversation.messages[0].id, "user-1"); const namespace = createHash("sha256").update(`https://auth.xiteng.site\0${distributedOwner.sub}`).digest("hex").slice(0, 32); const objectTimestamp = new Date().toISOString(); const distributedMessage = await createMessageObject({ parentMessageId: null, role: "user", parts: [{type: "text", text: "local-first message"}], origin: {type: "user", clientId: "test-replica"}, completion: {status: "complete"}, createdAt: objectTimestamp, completedAt: objectTimestamp }, namespace); const distributedRef = { conversationId: "distributed-conversation", expectedHeadMessageId: null, expectedHeadVersion: 0, expectedMetadataVersion: 0, headMessageId: distributedMessage.id, name: "", providerId: "openai", model: "gpt-test", generationSettings: initialSettings, createdAt: objectTimestamp, updatedAt: objectTimestamp }; const pushed = await api(server.origin, distributedOwner, "/api/sync/push", { method: "POST", body: JSON.stringify({objects: [distributedMessage], refs: [distributedRef]}) }); assert.equal(pushed.response.status, 200); assert.equal(pushed.payload.refs[0].status, "ok"); assert.equal(pushed.payload.refs[0].ref.headMessageId, distributedMessage.id); const fetched = await api(server.origin, distributedOwner, "/api/sync/fetch", { method: "POST", body: JSON.stringify({haveObjectIds: []}) }); assert.equal(fetched.response.status, 200); assert.equal(fetched.payload.objects[0].id, distributedMessage.id); assert.equal(fetched.payload.refs[0].id, "distributed-conversation"); assert.equal(fetched.payload.refs[0].name, ""); const fetchedWithHave = await api(server.origin, distributedOwner, "/api/sync/fetch", { method: "POST", body: JSON.stringify({haveObjectIds: [distributedMessage.id]}) }); assert.deepEqual(fetchedWithHave.payload.objects, []); const alternateMessage = await createMessageObject({ parentMessageId: null, role: "user", parts: [{type: "text", text: "alternate root"}], origin: {type: "user", clientId: "test-replica", sourceMessageId: distributedMessage.id}, completion: {status: "complete"}, createdAt: new Date(Date.parse(objectTimestamp) + 1000).toISOString(), completedAt: new Date(Date.parse(objectTimestamp) + 1000).toISOString() }, namespace); const movedRef = await api(server.origin, distributedOwner, "/api/sync/push", { method: "POST", body: JSON.stringify({ objects: [alternateMessage], refs: [{ ...distributedRef, expectedHeadMessageId: distributedMessage.id, expectedHeadVersion: pushed.payload.refs[0].ref.headVersion, expectedMetadataVersion: pushed.payload.refs[0].ref.metadataVersion, headMessageId: alternateMessage.id }] }) }); assert.equal(movedRef.payload.refs[0].status, "ok"); const graphFetch = await api(server.origin, distributedOwner, "/api/sync/fetch", { method: "POST", body: JSON.stringify({haveObjectIds: []}) }); assert.deepEqual(new Set(graphFetch.payload.objects.map((message) => message.id)), new Set([distributedMessage.id, alternateMessage.id])); const localRepositoryId = "local:12345678-1234-4234-8234-123456789abc"; const localRepositoryMessage = await createMessageObject({ parentMessageId: null, role: "user", parts: [{type: "text", text: "created before login"}], origin: {type: "user", clientId: "12345678-1234-4234-8234-123456789abc"}, completion: {status: "complete"}, createdAt: objectTimestamp, completedAt: objectTimestamp }, localRepositoryId); const localRepositoryPush = await api(server.origin, localRepositoryOwner, "/api/sync/push", { method: "POST", body: JSON.stringify({ repositoryId: localRepositoryId, objects: [localRepositoryMessage], refs: [{...distributedRef, conversationId: "pre-login-conversation", headMessageId: localRepositoryMessage.id}] }) }); assert.equal(localRepositoryPush.response.status, 200); assert.equal(localRepositoryPush.payload.refs[0].status, "ok"); const rejectedHash = await api(server.origin, distributedOwner, "/api/sync/push", { method: "POST", body: JSON.stringify({objects: [{...distributedMessage, id: `sha256:${"0".repeat(64)}`}], refs: []}) }); assert.equal(rejectedHash.response.status, 400); const rejectedLease = await api(server.origin, distributedOwner, "/api/sync/push", { method: "POST", body: JSON.stringify({objects: [], refs: [{...distributedRef, expectedHeadVersion: 0, headMessageId: null}]}) }); assert.equal(rejectedLease.payload.refs[0].status, "conflict"); await stopServer(server.child); server = await startServer(); const restored = await api(server.origin, owner, `/api/conversations/${conversationId}`); assert.equal(restored.response.status, 200); assert.equal(restored.payload.conversation.messages.length, 2); assert.ok(restored.payload.conversation.messages.every((message) => message.id.startsWith("sha256:"))); assert.equal(restored.payload.conversation.messages[1].parentMessageId, restored.payload.conversation.messages[0].id); assert.deepEqual(restored.payload.conversation.messages[1].metadata.custom.response, responseMetadata); assert.deepEqual(restored.payload.conversation.generationSettings, updatedSettings); const ownerList = await api(server.origin, owner, "/api/conversations"); assert.equal(ownerList.payload.conversations.length, 2); const otherList = await api(server.origin, other, "/api/conversations"); assert.deepEqual(otherList.payload.conversations, []); assert.equal((await api(server.origin, other, `/api/conversations/${conversationId}`)).response.status, 404); assert.equal((await api(server.origin, other, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 404); assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`, {method: "DELETE"})).response.status, 204); assert.equal((await api(server.origin, owner, `/api/conversations/${conversationId}`)).response.status, 404); assert.equal((await api(server.origin, owner, `/api/conversations/${branchResult.payload.conversation.id}`)).payload.conversation.messages[0].parts[0].text, "persistent question"); console.log("Chat history API tests passed"); } finally { if (server) await stopServer(server.child); rmSync(temporaryDirectory, {recursive: true, force: true}); }