fix(chat): make the server the only writer of session messages
List GET no longer ships transcripts; consult appends questions after reserve and ignores client history so dual-tab last-write-wins cannot erase messages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,17 +13,40 @@ const values = {
|
||||
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,
|
||||
...values,
|
||||
...createValues,
|
||||
});
|
||||
const id: string = parsed.id;
|
||||
assert.equal(id, sessionId);
|
||||
assert.equal(parsed.title, values.title);
|
||||
assert.equal(parsed.messages[0]?.text, "你好");
|
||||
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", () => {
|
||||
@@ -90,7 +113,7 @@ test("chat session schema keeps structured thinking sections on assistant messag
|
||||
|
||||
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, values, "update", async (url, init) => {
|
||||
await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
@@ -99,12 +122,13 @@ test("chat session writes use same-origin API instead of browser-to-Supabase req
|
||||
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, values, "update", async () => {
|
||||
writeChatSession(sessionId, metadataPatch, "update", async () => {
|
||||
attempts += 1;
|
||||
throw new TypeError("Load failed");
|
||||
}),
|
||||
@@ -117,7 +141,7 @@ test("transient Load failed is retried and never exposed as raw browser copy", a
|
||||
test("owner or validation failures are not retried", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
writeChatSession(sessionId, values, "update", async () => {
|
||||
writeChatSession(sessionId, metadataPatch, "update", async () => {
|
||||
attempts += 1;
|
||||
return Response.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
||||
}),
|
||||
@@ -148,21 +172,30 @@ test("session API owns create and update while answer UI keeps sync failures out
|
||||
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, /thinkingText: message\.thinkingText/);
|
||||
assert.match(page, /thinkingSections: message\.thinkingSections/);
|
||||
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/);
|
||||
assert.match(collectionRoute, /const \{ id, \.\.\.values \} = parsed\.data/);
|
||||
// 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<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string; thinkingSections\?: unknown \}> \}>/);
|
||||
assert.match(contract, /\): z\.ZodType<Output> \{/);
|
||||
assert.match(page, /chartSnapshotForSession/);
|
||||
|
||||
Reference in New Issue
Block a user