337 lines
14 KiB
TypeScript
337 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
import {
|
||
chatSessionCreateInsertRow,
|
||
chatSessionCreateSchema,
|
||
chatSessionMetadataPatchSchema,
|
||
chatSessionWriteSchema,
|
||
writeChatSession,
|
||
type ChatSessionWrite,
|
||
} from "../src/lib/chat-session-write-contract.ts";
|
||
import { metadataUpdateValues } from "../src/lib/session-metadata-update.ts";
|
||
import { homeSurface } from "./home-surface.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 accepts continued_from_session_id and rejects client-supplied context_summary", () => {
|
||
const parsed = chatSessionCreateSchema.parse({
|
||
id: sessionId,
|
||
...createValues,
|
||
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
|
||
});
|
||
assert.equal(parsed.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
|
||
assert.equal("context_summary" in parsed, false);
|
||
assert.equal("context_summary" in chatSessionCreateSchema.shape, false);
|
||
|
||
const withSummary = chatSessionCreateSchema.safeParse({
|
||
id: sessionId,
|
||
...createValues,
|
||
context_summary: { version: 1, text: "injected" },
|
||
});
|
||
assert.equal(withSummary.success, false);
|
||
|
||
const badId = chatSessionCreateSchema.safeParse({
|
||
id: sessionId,
|
||
...createValues,
|
||
continued_from_session_id: "not-a-uuid",
|
||
});
|
||
assert.equal(badId.success, false);
|
||
});
|
||
|
||
test("create insert copies an owned summary and omits a missing one", () => {
|
||
const source = {
|
||
version: 1 as const,
|
||
text: "已问过的问题\n事业时机",
|
||
throughRequestId: "a1",
|
||
throughMessageIndex: 3,
|
||
messageCount: 4,
|
||
updatedAt: "2026-09-15T00:00:00.000Z",
|
||
};
|
||
const copied = chatSessionCreateInsertRow({
|
||
id: sessionId,
|
||
userId: "owner",
|
||
values: createValues,
|
||
inheritedSummary: source,
|
||
updatedAt: "2026-09-16T00:00:00.000Z",
|
||
});
|
||
assert.deepEqual(copied.context_summary, source);
|
||
assert.equal("continued_from_session_id" in copied, false);
|
||
|
||
const skipped = chatSessionCreateInsertRow({
|
||
id: sessionId,
|
||
userId: "owner",
|
||
values: createValues,
|
||
inheritedSummary: null,
|
||
updatedAt: "2026-09-16T00:00:00.000Z",
|
||
});
|
||
assert.equal("context_summary" in skipped, false);
|
||
assert.equal(skipped.user_id, "owner");
|
||
});
|
||
|
||
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 });
|
||
// 原值:schema 接受 archived_at 并写入 patch
|
||
// 新值:schema 不含 archived_at;extract 读到后忽略
|
||
// 原因:BUG-991 归档下线,旧 bundle 仍可发送不得 400
|
||
assert.equal(chatSessionMetadataPatchSchema.safeParse({ archived_at: "2026-09-01T00:00:00.000Z" }).success, false);
|
||
assert.equal(chatSessionMetadataPatchSchema.safeParse({ archived_at: null }).success, false);
|
||
});
|
||
|
||
test("create write may carry continued_from_session_id and never context_summary", async () => {
|
||
const calls: Array<Record<string, unknown>> = [];
|
||
await writeChatSession(sessionId, {
|
||
...createValues,
|
||
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
|
||
}, "create", async (_url, init) => {
|
||
calls.push(JSON.parse(String(init?.body)));
|
||
return Response.json({ ok: true }, { status: 201 });
|
||
});
|
||
assert.equal(calls[0]?.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
|
||
assert.equal("context_summary" in (calls[0] ?? {}), false);
|
||
});
|
||
|
||
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 = homeSurface;
|
||
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, updated_at: _ignoredClientClock, ...values } = parsed.data`
|
||
// New value: also peel `continued_from_session_id` so it is never inserted as a column.
|
||
assert.match(
|
||
collectionRoute,
|
||
/const \{ id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, \.\.\.values \} = parsed\.data/,
|
||
);
|
||
assert.match(collectionRoute, /resolveInheritedContextSummary/);
|
||
assert.match(collectionRoute, /chatSessionCreateInsertRow/);
|
||
assert.match(collectionRoute, /\.eq\("user_id", user\.id\)/);
|
||
assert.match(collectionRoute, /status: 201/);
|
||
assert.doesNotMatch(collectionRoute, /context_summary: parsed/);
|
||
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string; thinkingSections\?: unknown \}> \}>/);
|
||
assert.match(contract, /\): z\.ZodType<Output> \{/);
|
||
assert.match(page, /chartSnapshotForSession/);
|
||
assert.match(page, /chart_profile_name: session\.chartProfileName/);
|
||
assert.match(page, /emptySessionBindingWrite/);
|
||
const bind = page.slice(page.indexOf("async function bindEmptyChatSubject("), page.indexOf("async function openChatBoundToProfile("));
|
||
assert.doesNotMatch(bind, /chart_profile_name/);
|
||
// 原值 `分析对象:{...}` 副标题 / 新值 `.chat-header-chart` 静默 chip
|
||
// / 原因:顶栏从 68px 收到 46px 单行;「分析对象」是内部术语,标签去掉只留盘名。
|
||
// 会话与星盘资料的绑定关系本身没变,下面两条仍然锁住它。
|
||
assert.match(page, /className="chat-header-chart"/);
|
||
assert.match(page, /sessionChartLabel\(activeSession, chartLibrary\)/);
|
||
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 = homeSurface;
|
||
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"/);
|
||
// 原值:启动时空列表立刻 writeChatSession(initialSession.id, ..., "create")
|
||
// 新值:本地 createSession,第一问 send() 才 persistSession(..., "create")
|
||
// 原因:BUG-989 第一问之前不落库
|
||
assert.doesNotMatch(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
|
||
assert.match(page, /await persistSession\(currentSession, "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/);
|
||
});
|
||
|
||
test("metadata PATCH no longer writes updated_at", () => {
|
||
const values = metadataUpdateValues({ title: "半年内换工作时机" });
|
||
assert.ok(values);
|
||
assert.equal("updated_at" in values, false);
|
||
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
||
assert.doesNotMatch(itemRoute, /updated_at: new Date\(\)\.toISOString\(\)/);
|
||
});
|