import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { chatSessionCreateSchema, chatSessionMetadataPatchSchema, chatSessionWriteSchema, writeChatSession, type ChatSessionWrite } from "../src/lib/chat-session-write-contract.ts"; const sessionId = "11111111-1111-4111-8111-111111111111"; const values = { title: "事业方向", theme: "career", model_id: "gpt-test", messages: [{ role: "user", text: "你好" }], session_type: "consultation", rectification_case_id: null, updated_at: "2026-07-22T00:00:00.000Z", } satisfies ChatSessionWrite; const createValues = { title: values.title, theme: values.theme, model_id: values.model_id, messages: [] as const, session_type: values.session_type, rectification_case_id: values.rectification_case_id, }; const metadataPatch = { title: values.title, theme: values.theme, model_id: values.model_id, }; test("create schema keeps the client-generated session id after transcript limits", () => { const parsed = chatSessionCreateSchema.parse({ id: sessionId, ...createValues, }); const id: string = parsed.id; assert.equal(id, sessionId); assert.equal(parsed.title, values.title); assert.equal(parsed.messages.length, 0); }); test("create schema rejects a non-empty transcript", () => { // Former value: create accepted up to CHAT_SESSION_MAX_MESSAGES and was a // history-import path. Create is now only an empty session. const parsed = chatSessionCreateSchema.safeParse({ id: sessionId, ...values, }); assert.equal(parsed.success, false); }); test("chat session schema accepts chart profile snapshots and keeps legacy writes valid", () => { const parsed = chatSessionWriteSchema.parse({ ...values, chart_profile_id: "other-profile-id", chart_profile_name: "张三", chart_profile_role: "other", }); assert.equal(parsed.chart_profile_id, "other-profile-id"); assert.equal(parsed.chart_profile_name, "张三"); assert.equal(parsed.chart_profile_role, "other"); assert.equal(chatSessionWriteSchema.parse(values).chart_profile_id, undefined); }); test("chat session schema preserves the safe agent execution receipt", () => { const receipt = { runId: "run-1", runtime: "mastra-agentic" as const, skill: { name: "jyotish-vedic-astrology" as const, loaded: true, referenceReads: 0, methodologySections: 0 }, steps: [{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const }], workflow: { route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["general", "timing"] }, techniqueTruth: "verified", }; const workflowReceipt = { route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["general", "timing"] as const, }; const parsed = chatSessionWriteSchema.parse({ ...values, messages: [{ role: "assistant", text: "回答", workflowReceipt, agentExecutionReceipt: receipt }], }); assert.deepEqual(parsed.messages[0]?.workflowReceipt, workflowReceipt); assert.deepEqual(parsed.messages[0]?.agentExecutionReceipt, receipt); }); test("chat session schema keeps sanitized thinking text on assistant messages", () => { const parsed = chatSessionWriteSchema.parse({ ...values, messages: [{ role: "assistant", text: "回答", thinkingText: "先看今日节奏。" }], }); assert.equal(parsed.messages[0]?.thinkingText, "先看今日节奏。"); }); test("chat session schema keeps structured thinking sections on assistant messages", () => { const parsed = chatSessionWriteSchema.parse({ ...values, messages: [{ role: "assistant", text: "回答", thinkingSections: [{ id: "foundation", title: "先整理本盘的统一参数", heading: "统一参数与原始结构", steps: [{ id: "raw", label: "列出岁差、上升与宫位结构", status: "pending" }], }], }], }); assert.equal(parsed.messages[0]?.thinkingSections?.[0]?.heading, "统一参数与原始结构"); }); test("metadata patch accepts pin and archive fields without a transcript", () => { assert.deepEqual(chatSessionMetadataPatchSchema.parse({ pinned: true }), { pinned: true }); assert.deepEqual( chatSessionMetadataPatchSchema.parse({ archived_at: "2026-09-01T00:00:00.000Z" }), { archived_at: "2026-09-01T00:00:00.000Z" }, ); assert.deepEqual(chatSessionMetadataPatchSchema.parse({ archived_at: null }), { archived_at: null }); }); test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => { const calls: Array<{ url: string; init?: RequestInit }> = []; await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => { calls.push({ url: String(url), init }); return Response.json({ ok: true }); }); assert.equal(calls.length, 1); assert.equal(calls[0]?.url, `/api/sessions/${sessionId}`); assert.equal(calls[0]?.init?.method, "PATCH"); assert.equal(calls[0]?.init?.credentials, "same-origin"); assert.equal(JSON.parse(String(calls[0]?.init?.body)).messages, undefined); }); test("transient Load failed is retried and never exposed as raw browser copy", async () => { let attempts = 0; await assert.rejects( writeChatSession(sessionId, metadataPatch, "update", async () => { attempts += 1; throw new TypeError("Load failed"); }), (error: unknown) => error instanceof Error && error.message === "网络暂时不可用,云端记录尚未更新", ); assert.equal(attempts, 2); }); test("owner or validation failures are not retried", async () => { let attempts = 0; await assert.rejects( writeChatSession(sessionId, metadataPatch, "update", async () => { attempts += 1; return Response.json({ error: "聊天记录不存在或已被删除" }, { status: 404 }); }), /聊天记录不存在或已被删除/, ); assert.equal(attempts, 1); }); test("session writes reject oversized transcripts before they reach storage", () => { const oversized = chatSessionWriteSchema.safeParse({ ...values, messages: [{ role: "user", text: "字".repeat(16_001) }], }); const tooMany = chatSessionWriteSchema.safeParse({ ...values, messages: Array.from({ length: 201 }, () => ({ role: "user" as const, text: "你好" })), }); const tooMuchText = chatSessionWriteSchema.safeParse({ ...values, messages: Array.from({ length: 20 }, () => ({ role: "user" as const, text: "字".repeat(12_000) })), }); assert.equal(oversized.success, false); assert.equal(tooMany.success, false); assert.equal(tooMuchText.success, false); }); test("session API owns create and update while answer UI keeps sync failures out of reply errors", () => { const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8"); const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8"); const observability = readFileSync(new URL("../src/lib/chat-session-observability.ts", import.meta.url), "utf8"); const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8"); const migration = readFileSync(new URL("../supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql", import.meta.url), "utf8"); assert.match(page, /writeChatSession\(session\.id, values, mode\)/); // Former value: persistSession mapped thinkingText/thinkingSections into a // client transcript PATCH. Messages are now server-appended; this client // write is metadata only. assert.doesNotMatch(page, /thinkingText: message\.thinkingText/); assert.doesNotMatch(page, /thinkingSections: message\.thinkingSections/); assert.match(page, /messages: \[\] as const/); assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/); assert.match(collectionRoute, /export async function POST/); assert.match(itemRoute, /export async function GET/); assert.match(itemRoute, /export async function PATCH/); assert.match(itemRoute, /logIgnoredSessionMessages/); assert.match(observability, /compat_messages_ignored/); assert.match(itemRoute, /\.eq\("user_id", user\.id\)/); assert.match(collectionRoute, /readChatSessionJson/); assert.match(itemRoute, /readChatSessionJson/); assert.match(collectionRoute, /ChatSessionBodyTooLargeError/); assert.match(itemRoute, /ChatSessionBodyTooLargeError/); // Former value: `const { id, ...values } = parsed.data` trusted the client clock. assert.match(collectionRoute, /const \{ id, updated_at: _ignoredClientClock, \.\.\.values \} = parsed\.data/); assert.match(contract, /function limitTranscriptSize \}>/); assert.match(contract, /\): z\.ZodType \{/); assert.match(page, /chartSnapshotForSession/); assert.match(page, /chart_profile_name: session\.chartProfileName/); assert.match(page, /分析对象:/); assert.match(migration, /add column if not exists chart_profile_id text/); assert.match(migration, /grant insert \(chart_profile_id, chart_profile_name, chart_profile_role\)/); assert.match(migration, /grant update \(chart_profile_id, chart_profile_name, chart_profile_role\)/); }); test("self-hosted staging bootstrap reads profile and sessions through same-origin APIs", () => { const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const accountRoute = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8"); assert.doesNotMatch(page, /createBrowserSupabaseClient/); assert.match(page, /fetch\("\/api\/account"/); assert.match(page, /fetch\("\/api\/sessions"/); assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/); assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/); assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/); assert.match(accountRoute, /profile,/); assert.doesNotMatch(accountRoute, /rectificationCase/); });