59559d4b24
Thinking disappeared on failure and never reached session storage. Keep the sanitized chain on disk and on errors, and regroup the sidebar around reports, charts, favorites, and dated history titles. Co-authored-by: Cursor <cursoragent@cursor.com>
152 lines
6.5 KiB
TypeScript
152 lines
6.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import { chatSessionCreateSchema, 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;
|
|
|
|
|
|
test("create schema keeps the client-generated session id after transcript limits", () => {
|
|
const parsed = chatSessionCreateSchema.parse({
|
|
id: sessionId,
|
|
...values,
|
|
});
|
|
const id: string = parsed.id;
|
|
assert.equal(id, sessionId);
|
|
assert.equal(parsed.title, values.title);
|
|
assert.equal(parsed.messages[0]?.text, "你好");
|
|
});
|
|
|
|
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 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) => {
|
|
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");
|
|
});
|
|
|
|
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 () => {
|
|
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, values, "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 contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
|
|
|
|
assert.match(page, /thinkingText: message\.thinkingText/);
|
|
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
|
|
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
|
|
assert.match(collectionRoute, /export async function POST/);
|
|
assert.match(itemRoute, /export async function PATCH/);
|
|
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/);
|
|
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string \}> \}>/);
|
|
assert.match(contract, /\): z\.ZodType<Output> \{/);
|
|
});
|
|
|
|
|
|
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/);
|
|
});
|