fix(chat): make the server the only writer of session messages
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

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:
Jesse_Chen
2026-09-01 19:51:19 +08:00
co-authored by Cursor
parent b98777aa5c
commit b6989c3eea
23 changed files with 871 additions and 150 deletions
+16
View File
@@ -7118,3 +7118,19 @@
- 相关记录:BUG-456、BUG-460、BUG-462
- 复发自:无(授权策略变更,不是同一实现回归)
- 修复版本:待发布
## BUG-464 | 客户端全量覆盖 chat_sessions.messages 导致列表膨胀、写满失败与双标签页丢消息
- 状态:resolved
- 首次发现:2026-09-01
- 最近更新:2026-09-01
- 影响面:`GET /api/sessions`、`GET/PATCH /api/sessions/[id]`、`POST /api/consult`、`append_consultation_question`、首页会话列表与对话区
- 用户现象:会话一多首页变慢;对话变长后发问提示“问题保存失败”;两个标签页各问一句,刷新后只剩后写的那一轮;中止或失败的半截回答会留下可刷新的记录。
- 触发条件:打开带多段对话的账户首页;长会话继续发问;同一会话在两个标签页交替发送;停止生成或 run.failed 后刷新。
- 根因:`chat_sessions.messages` 由浏览器整包 PATCH 覆盖,列表 GET 又把全部消息一次性带回。服务端结算已经 append assistant,客户端再 last-write-wins 覆盖。列表无分页、无上限。
- 修复:列表 GET 去掉 `messages`;详情 GET 按会话加载。咨询在 reserve 成功后由 `append_consultation_question` 写入用户提问,满 200 条或 200,000 字符返回 `session_full` 并退回预扣。consult 忽略请求体 `history`,改读库内最近 12 条。客户端 `persistSession` 只写元数据;旧 bundle 的 `messages` PATCH 接受并忽略。中止与失败的部分回答只留在当前页。
- 验证:合同测试锁列表/详情拆分、伪造 history 不进模型上下文、PATCH 忽略 messages、SQL 幂等与 `session_full`、双 request_id 可叠加。`./node_modules/.bin/tsc --noEmit`、`npm test` 与 `npm run test:db` 必须通过。
- 防复发:不得把 `messages` 加回列表 GET 或校正 agent select。不得恢复客户端 transcript PATCH。consult 不得再读取 `parsed.data.history`。`append_consultation_question` 必须保持 advisory lock、request_id 幂等与满员拒绝。
- 相关记录:无
- 复发自:无
- 修复版本:待发布
+52 -5
View File
@@ -71,6 +71,7 @@ import {
loadGeneralDailyPanchangaContext,
type GeneralDailyPanchangaContext,
} from "@/lib/general-daily-panchanga";
import { consultationHistoryFromStoredMessages } from "@/lib/consultation-session-history";
import { z } from "zod";
export const runtime = "nodejs";
@@ -94,6 +95,7 @@ const chatRequestMetadataSchema = z.object({
}),
)
.max(20)
.optional()
.default([]),
});
@@ -143,6 +145,11 @@ const consultationCompletionSchema = z.object({
error_code: z.string().nullable().optional(),
});
const consultationQuestionAppendSchema = z.object({
success: z.boolean(),
error_code: z.string().nullable().optional(),
});
function first<T>(value: T | T[]): T {
return Array.isArray(value) ? value[0] : value;
}
@@ -273,7 +280,7 @@ export async function POST(request: Request) {
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,model_id,model_config_version,session_type")
.select("id,model_id,model_config_version,session_type,messages")
.eq("id", parsed.data.sessionId)
.eq("user_id", user.id)
.maybeSingle();
@@ -329,11 +336,11 @@ export async function POST(request: Request) {
const consultationTheme = parsed.data.theme;
const visibleQuestion = parsed.data.question;
// Client `history` stays in the request schema for old bundles and is not read.
const storedHistory = consultationHistoryFromStoredMessages(chatSession.messages);
const userControlledPrompt = [
parsed.data.question,
...parsed.data.history
.filter((message) => message.role === "user")
.map((message) => message.text),
...storedHistory.filter((message) => message.role === "user").map((message) => message.text),
].join("\n");
if (blocksPromptExtraction(userControlledPrompt)) {
return NextResponse.json(
@@ -504,6 +511,46 @@ export async function POST(request: Request) {
}
}
let appendedQuestion: { success: boolean; error_code?: string | null };
try {
appendedQuestion = await retryDetachedSettlement(async () => {
const { data, error } = await accounting.rpc("append_consultation_question", {
p_user_id: userId,
p_request_id: requestId,
p_session_id: sessionId,
p_question_message: { role: "user", text: visibleQuestion },
});
const parsedAppend = consultationQuestionAppendSchema.safeParse(first(data ?? []));
if (error || !parsedAppend.success) {
throw new CreditRpcError(error?.message || "invalid_question_append_response");
}
return parsedAppend.data;
});
} catch {
await cancel();
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!appendedQuestion.success) {
await cancel();
if (appendedQuestion.error_code === "session_full") {
return NextResponse.json(
{
error: "这段对话已写满",
message: "这段对话已写满,开个新对话继续吧",
code: "session_full",
},
{ status: 409 },
);
}
return NextResponse.json(
{ error: "暂时无法保存问题", message: "请稍后重试。" },
{ status: 503 },
);
}
const usageStartedAt = Date.now();
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
const resolved = await usage;
@@ -1044,7 +1091,7 @@ export async function POST(request: Request) {
}
try {
const { history } = parsed.data;
const history = storedHistory;
const name = prepared.serverChart?.name ?? prepared.declaredWindow?.name ?? parsed.data.name;
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
const generalDailyContext = shouldLoadGeneralDailyPanchanga({
@@ -238,7 +238,7 @@ export async function POST(request: Request) {
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id")
.select("id,session_type,model_id,model_config_version,agentic_rectification_case_id")
.eq("id", sessionId)
.eq("user_id", userId)
.maybeSingle();
+65 -7
View File
@@ -3,17 +3,66 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import {
ChatSessionBodyTooLargeError,
chatSessionMetadataPatchSchema,
chatSessionModelPatchSchema,
chatSessionWriteSchema,
extractChatSessionMetadataPatch,
readChatSessionJson,
} from "@/lib/chat-session-write-contract";
import { logIgnoredSessionMessages } from "@/lib/chat-session-observability";
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
type RouteContext = { params: Promise<{ id: string }> };
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function payloadHasMessages(payload: unknown): payload is { messages: unknown } {
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload) && "messages" in payload);
}
function ignoredMessageCount(payload: { messages: unknown }) {
return Array.isArray(payload.messages) ? payload.messages.length : 0;
}
function metadataUpdateValues(payload: unknown): Record<string, unknown> | null {
const parsed = chatSessionMetadataPatchSchema.safeParse(extractChatSessionMetadataPatch(payload));
if (!parsed.success) return null;
return { ...parsed.data, updated_at: new Date().toISOString() };
}
export async function GET(_request: Request, context: RouteContext) {
try {
const { id } = await context.params;
if (!sessionIdPattern.test(id)) {
return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
}
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.select(sessionSelect)
.eq("id", id)
.eq("user_id", user.id)
.maybeSingle();
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
if (!data) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
return NextResponse.json({ session: data });
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
}
return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
}
}
export async function PATCH(request: Request, context: RouteContext) {
try {
const { id } = await context.params;
if (!sessionIdPattern.test(id)) {
return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
}
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
@@ -26,14 +75,23 @@ export async function PATCH(request: Request, context: RouteContext) {
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
}
const payload = await readChatSessionJson(request);
const fullWrite = chatSessionWriteSchema.safeParse(payload);
if (payloadHasMessages(payload)) {
logIgnoredSessionMessages(id, user.id, ignoredMessageCount(payload));
}
const metadataValues = metadataUpdateValues(payload);
const modelPatch = chatSessionModelPatchSchema.safeParse(payload);
let values: Record<string, unknown>;
if (fullWrite.success) {
values = fullWrite.data;
} else if (modelPatch.success) {
values = modelPatch.data;
} else {
const fullWrite = chatSessionWriteSchema.safeParse(payload);
let values: Record<string, unknown> | null = metadataValues;
if (!values && fullWrite.success) {
values = metadataUpdateValues(fullWrite.data);
}
if (!values && modelPatch.success) {
values = { ...modelPatch.data, updated_at: new Date().toISOString() };
}
if (!values && payloadHasMessages(payload)) {
return NextResponse.json({ ok: true });
}
if (!values) {
return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
}
const { data, error } = await supabase
+5 -2
View File
@@ -4,6 +4,8 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
@@ -11,7 +13,7 @@ export async function GET() {
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
const { data, error } = await supabase
.from("chat_sessions")
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at")
.select(SESSION_LIST_COLUMNS)
.eq("user_id", user.id)
.order("updated_at", { ascending: false });
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
@@ -39,11 +41,12 @@ export async function POST(request: Request) {
}
const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request));
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
const { id, ...values } = parsed.data;
const { id, updated_at: _ignoredClientClock, ...values } = parsed.data;
const { error } = await supabase.from("chat_sessions").insert({
id,
user_id: user.id,
...values,
updated_at: new Date().toISOString(),
});
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
return NextResponse.json({ ok: true }, { status: 201 });
+7
View File
@@ -1027,6 +1027,13 @@ button:disabled { cursor: default; opacity: .45; }
width: min(900px, 100%);
padding: var(--space-8) var(--space-8) var(--space-16);
}
.session-messages-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 40vh;
color: var(--color-ink-secondary);
}
.message { display: flex; animation: message-enter 160ms var(--ease-out) both; padding: var(--space-2) 0; }
.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
.message-content { min-width: 0; max-width: min(80%, 680px); }
+181 -83
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { ArrowDown, ArrowUpRight, Sparkles } from "lucide-react";
import { InlineSpinner } from "@/components/inline-spinner";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
@@ -239,6 +240,7 @@ type ChatSession = {
chartProfileId: string | null;
chartProfileName: string | null;
chartProfileRole: "self" | "other" | null;
messagesHydrated: boolean;
};
type RequestError = { sessionId: string; message: string };
@@ -437,6 +439,7 @@ function createSession(
updatedAt: timestamp(),
sessionType,
rectificationCaseId: null,
messagesHydrated: true,
...chartBinding,
};
}
@@ -892,6 +895,7 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
session_type?: unknown;
updated_at?: unknown;
};
const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages");
const messages: Message[] = Array.isArray(session.messages)
? session.messages.flatMap((message) => {
if (!message || typeof message !== "object") return [];
@@ -943,11 +947,20 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
: typeof session.updated_at === "string"
? Date.parse(session.updated_at)
: timestamp(),
messagesHydrated: messagesPresent,
}];
});
return { sessions, fallbackSessionIds };
}
function mergeHydratedSession(current: ChatSession[], detailed: ChatSession): ChatSession[] {
const next = { ...detailed, messagesHydrated: true };
if (current.some((session) => session.id === next.id)) {
return current.map((session) => (session.id === next.id ? { ...session, ...next } : session));
}
return [next, ...current];
}
function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
return (
<fieldset className="location-fieldset">
@@ -1081,6 +1094,12 @@ function payloadMessage(payload: unknown, fallback: string) {
return friendlyError(message || fallback);
}
function payloadCode(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object") return undefined;
const code = (payload as { code?: unknown }).code;
return typeof code === "string" ? code : undefined;
}
class CancellationResponseError extends Error {
readonly status: number;
@@ -1093,11 +1112,13 @@ class CancellationResponseError extends Error {
class ConsultationResponseError extends Error {
readonly status: number;
readonly code?: string;
constructor(status: number, message: string) {
constructor(status: number, message: string, code?: string) {
super(message);
this.name = "ConsultationResponseError";
this.status = status;
this.code = code;
}
}
@@ -1158,6 +1179,24 @@ async function fetchSessions(signal?: AbortSignal): Promise<unknown> {
return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null;
}
async function fetchSessionDetail(
sessionId: string,
catalog: PublicLanguageModelCatalog | null,
signal?: AbortSignal,
): Promise<ChatSession | null> {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
signal,
cache: "no-store",
});
if (response.status === 401) redirectToLogin();
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
const sessionValue = payload && typeof payload === "object"
? (payload as { session?: unknown }).session
: null;
return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null;
}
function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus {
if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效");
const status = payload as Partial<ConsultationStatus>;
@@ -1272,6 +1311,8 @@ export default function Home() {
const [hydrated, setHydrated] = useState(false);
const [profileSaving, setProfileSaving] = useState(false);
const [creatingSession, setCreatingSession] = useState(false);
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(null);
const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null);
const [onboarding, setOnboarding] = useState<OnboardingContent | null>(null);
const [onboardingError, setOnboardingError] = useState("");
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("name");
@@ -1319,6 +1360,9 @@ export default function Home() {
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const sessionDetailInFlight = useRef(new Set<string>());
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPending = useRef(false);
@@ -1336,7 +1380,10 @@ export default function Home() {
&& activeSession.id === rectificationSessionId;
const visibleSessions = sessions
.filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id))
.filter((session) => session.sessionType === "birth_time_rectification" || session.messages.length > 0)
.filter((session) => session.sessionType === "birth_time_rectification"
|| session.messages.length > 0
|| !session.messagesHydrated
|| session.id === activeSessionId)
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
const isLoading = pendingSessionId === activeSession?.id;
@@ -1384,6 +1431,10 @@ export default function Home() {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
useEffect(() => {
if (!hydrated || uiPreview.current || !activeSessionId) return;
void ensureSessionMessages(activeSessionId);
}, [hydrated, activeSessionId, modelCatalog, pendingSessionId]);
useEffect(() => {
if (!hydrated
@@ -1540,7 +1591,14 @@ export default function Home() {
&& presetMessageFinished
&& !onboardingPending
&& !rectificationSurfaceOpen
&& !activeSession?.messages.length;
&& Boolean(activeSession?.messagesHydrated)
&& !activeSession.messages.length;
const sessionMessagesLoading = Boolean(
activeSession
&& activeSession.sessionType === "consultation"
&& !activeSession.messagesHydrated
&& pendingSessionId !== activeSession.id,
);
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection);
@@ -1709,6 +1767,7 @@ export default function Home() {
chartProfileId: "self",
chartProfileName: previewProfile.name.trim() || "我",
chartProfileRole: "self",
messagesHydrated: true,
};
setAccount({
user: { id: "preview-user", email: "preview@local.test" },
@@ -1769,13 +1828,12 @@ export default function Home() {
title: initialSession.title,
theme: initialSession.theme,
model_id: initialSession.modelId,
messages: initialSession.messages,
messages: [],
session_type: initialSession.sessionType,
rectification_case_id: initialSession.rectificationCaseId,
chart_profile_id: initialSession.chartProfileId,
chart_profile_name: initialSession.chartProfileName,
chart_profile_role: initialSession.chartProfileRole,
updated_at: new Date(initialSession.updatedAt).toISOString(),
}, "create");
}
nextSessions = [initialSession];
@@ -1821,6 +1879,19 @@ export default function Home() {
}
}
if (controller.signal.aborted) return;
const activeListed = nextSessions[0];
if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") {
try {
const detailed = await fetchSessionDetail(activeListed.id, nextModelCatalog, controller.signal);
if (detailed) {
nextSessions = nextSessions.map((session) => session.id === detailed.id ? detailed : session);
}
} catch (caught) {
if (caught instanceof Error && caught.name === "AbortError") throw caught;
if (caught instanceof LoginRedirectError) return;
}
}
if (controller.signal.aborted) return;
clearStaleClientReload(sessionStorage);
setAccount(nextAccount);
@@ -1912,12 +1983,11 @@ export default function Home() {
return;
}
if (status.status === "completed") {
const payload = await fetchSessions(controller.signal);
const parsed = readSessions(payload, modelCatalog);
setSessions(parsed.sessions);
setActiveSessionId((current) => parsed.sessions.some((session) => session.id === current)
? current
: parsed.sessions[0]?.id ?? "");
const detailed = await fetchSessionDetail(pendingSessionId, modelCatalog, controller.signal);
if (detailed) {
setSessions((current) => mergeHydratedSession(current, detailed));
setActiveSessionId((current) => current || detailed.id);
}
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
@@ -2202,29 +2272,62 @@ export default function Home() {
async function persistSession(session: ChatSession, mode: "create" | "update" = "update") {
if (!account) throw new Error("账户尚未加载完成");
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
const values = {
title: session.title,
theme: session.theme,
model_id: session.modelId,
messages: session.messages.map((message) => ({
role: message.role,
text: message.text,
thinkingText: message.thinkingText,
thinkingSections: message.thinkingSections,
techniqueTruth: message.techniqueTruth,
agentExecutionReceipt: message.agentExecutionReceipt,
workflowReceipt: message.workflowReceipt,
})),
session_type: session.sessionType,
rectification_case_id: session.rectificationCaseId,
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
updated_at: new Date(session.updatedAt).toISOString(),
};
const values = mode === "create"
? {
title: session.title,
theme: session.theme,
model_id: session.modelId,
messages: [] as const,
session_type: session.sessionType,
rectification_case_id: session.rectificationCaseId,
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
}
: {
title: session.title,
theme: session.theme,
model_id: session.modelId,
chart_profile_id: session.chartProfileId,
chart_profile_name: session.chartProfileName,
chart_profile_role: session.chartProfileRole,
};
await writeChatSession(session.id, values, mode);
}
async function ensureSessionMessages(sessionId: string) {
if (!sessionId || uiPreview.current) return;
if (pendingSessionId === sessionId) return;
if (sessionDetailInFlight.current.has(sessionId)) return;
const known = sessionsRef.current.find((session) => session.id === sessionId);
if (known?.messagesHydrated || known?.sessionType === "birth_time_rectification") return;
sessionDetailInFlight.current.add(sessionId);
setSessionDetailLoadingId(sessionId);
try {
const detailed = await fetchSessionDetail(sessionId, modelCatalog);
if (!detailed) return;
setSessions((existing) => {
const live = existing.find((session) => session.id === sessionId);
if (live?.messagesHydrated) return existing;
return mergeHydratedSession(existing, detailed);
});
} catch (caught) {
if (caught instanceof LoginRedirectError) return;
setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录");
} finally {
sessionDetailInFlight.current.delete(sessionId);
setSessionDetailLoadingId((currentId) => currentId === sessionId ? null : currentId);
}
}
async function continueInNewChat(prompt: { question: string; theme: Theme }) {
setSessionFullPrompt(null);
const created = await startNewChat();
if (!created) return;
setDraft(prompt.question);
setDraftTheme(prompt.theme);
}
async function renameSession(session: ChatSession) {
const title = window.prompt("重命名聊天记录", session.title)?.trim();
if (!title || title === session.title) return;
@@ -2355,6 +2458,8 @@ export default function Home() {
// the exact Case and never switches to another rectification record.
void openRectificationSession(nextSession.id);
}
} else {
void ensureSessionMessages(sessionId);
}
}
@@ -2784,6 +2889,7 @@ export default function Home() {
let targetSession = activeSession
&& activeSession.sessionType === "consultation"
&& activeSession.messagesHydrated
&& activeSession.messages.length === 0
? activeSession
: null;
@@ -2909,6 +3015,7 @@ export default function Home() {
updatedAt: timestamp(),
sessionType: "birth_time_rectification",
rectificationCaseId: opened.caseId,
messagesHydrated: true,
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
};
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
@@ -3158,12 +3265,11 @@ export default function Home() {
try {
const status = await fetchConsultationStatus(pending.sessionId, pending.requestId);
if (status.status === "completed") {
const payload = await fetchSessions();
const parsed = readSessions(payload, modelCatalog);
setSessions(parsed.sessions);
setActiveSessionId((current) => parsed.sessions.some((session) => session.id === current)
? current
: parsed.sessions[0]?.id ?? "");
const detailed = await fetchSessionDetail(pending.sessionId, modelCatalog);
if (detailed) {
setSessions((current) => mergeHydratedSession(current, detailed));
setActiveSessionId((current) => current || detailed.id);
}
pendingConsultation.current = null;
setPendingSessionId(null);
setConsultationPhase(null);
@@ -3187,16 +3293,7 @@ export default function Home() {
setPendingSessionId(null);
setConsultationPhase(null);
setRequestError(null);
try {
await persistSession(stoppedSession);
setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。");
} catch (error) {
setComposerNotice("本次点数已退回;现有内容暂时无法同步。");
setRequestError({
sessionId: pending.sessionId,
message: error instanceof Error ? error.message : "已停止的回答暂时无法同步。",
});
}
setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。");
cancellationRequests.current.delete(pending.requestId);
stoppedRequestAwaitingSettlement.current = null;
cancellationInFlight.current = false;
@@ -3358,6 +3455,7 @@ export default function Home() {
theme,
messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }],
updatedAt: questionAlreadyPresent ? currentSession.updatedAt : timestamp(),
messagesHydrated: true,
};
const requestId = resumeRequestId ?? globalThis.crypto.randomUUID();
const controller = resuming && pendingConsultation.current
@@ -3450,28 +3548,6 @@ export default function Home() {
}
if (!resuming || !questionAlreadyPresent) {
if (resuming && !questionAlreadyPresent) updateSession(sessionId, () => userSession);
try {
await persistSession(userSession);
} catch (caught) {
if (controller.signal.aborted) return false;
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(consultEntrypoint);
}
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`,
});
setComposerNotice(options.restoreOnFailure
? "问题保存失败,未开始生成;已恢复原来的回答。"
: "问题保存失败,未开始生成;问题已放回输入框。");
completeConsultationInterface(requestId);
window.requestAnimationFrame(() => composerInput.current?.focus());
return false;
}
}
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = {
@@ -3529,6 +3605,7 @@ export default function Home() {
throw new ConsultationResponseError(
response.status,
payloadMessage(errorPayload, "服务暂时不可用"),
payloadCode(errorPayload),
);
}
if (!response.body) {
@@ -3679,14 +3756,6 @@ export default function Home() {
setStreamingReply(null);
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(truncatedFailure.message);
try {
await persistSession(truncatedSession);
} catch (error) {
setRequestError({
sessionId,
message: error instanceof Error ? error.message : "未完成的回答暂时无法同步。",
});
}
completeConsultationInterface(requestId);
void refreshAccount();
return true;
@@ -3756,6 +3825,19 @@ export default function Home() {
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) {
if (caught.code === "session_full") {
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
setSessionFullPrompt({ question: originalQuestion, theme });
setComposerNotice("这段对话已写满,开个新对话继续吧", {
label: "开新对话",
onClick: () => {
void continueInNewChat({ question: originalQuestion, theme });
},
});
completeConsultationInterface(requestId);
return false;
}
if (caught.message === "request_conflict") {
pendingConsultation.current = {
...pendingConsultation.current,
@@ -3767,14 +3849,26 @@ export default function Home() {
setComposerNotice("回答仍在后台生成,正在自动恢复。");
return Boolean(partialReply);
}
const reserveDidNotCommit = caught.status === 400
|| caught.status === 401
|| caught.status === 402
|| caught.status === 409;
if (reserveDidNotCommit) {
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(consultEntrypoint);
}
}
setRequestError({ sessionId, message: caught.message });
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(caught.message);
const restore = options.restoreOnFailure;
if (restore) {
updateSession(sessionId, () => restore);
void persistSession(restore).catch(() => {});
} else if (thinkingSections.length || latestPartialReply || streamedThinking.trim()) {
} else if (!reserveDidNotCommit && (thinkingSections.length || latestPartialReply || streamedThinking.trim())) {
const failedSession: ChatSession = {
...userSession,
messages: [...userSession.messages, {
@@ -3786,7 +3880,6 @@ export default function Home() {
updatedAt: timestamp(),
};
updateSession(sessionId, () => failedSession);
void persistSession(failedSession).catch(() => {});
}
completeConsultationInterface(requestId);
return false;
@@ -4255,7 +4348,7 @@ export default function Home() {
</header>
{!rectificationSurfaceOpen && (
<div ref={conversation} className={`conversation${!activeSession?.messages.length && !onboardingFormActive ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
<div ref={conversation} className={`conversation${!activeSession?.messages.length && Boolean(activeSession?.messagesHydrated) && !onboardingFormActive ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
{!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
@@ -4429,6 +4522,11 @@ export default function Home() {
</div>
))}
</div>
) : sessionMessagesLoading ? (
<div className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">
<InlineSpinner size={20} />
<span className="sr-only">正在加载聊天记录</span>
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
<ChatTranscript
@@ -4510,9 +4608,9 @@ export default function Home() {
? "余额不足,可前往会员页充值点数或开通会员"
: "例如:未来半年是否适合换工作?"}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
inputDisabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
inputDisabled={isLoading || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
submitBlocked={Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
submitBlocked={Boolean(pendingSessionId) || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
stopVisible={isLoading}
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
+15 -6
View File
@@ -4,8 +4,13 @@ export const chatNoticeToastId = "chat-notice";
export type NoticeTone = "info" | "success" | "error";
export type ChatNoticeAction = Readonly<{
label: string;
onClick: () => void;
}>;
const ongoingNotice = /正在|请稍候|请先|联网后/;
const failedNotice = /失败|无法|不可用|未找到/;
const failedNotice = /失败|无法|不可用|未找到|已写满/;
const settledNotice = /^已|^回答已恢复/;
export function noticeTone(message: string): NoticeTone {
@@ -17,23 +22,27 @@ export function noticeTone(message: string): NoticeTone {
let lastNotice = "";
export function showChatNotice(message: string) {
export function showChatNotice(message: string, action?: ChatNoticeAction) {
if (!message.trim()) {
if (!lastNotice) return;
lastNotice = "";
toast.dismiss(chatNoticeToastId);
return;
}
if (lastNotice === message) return;
if (lastNotice === message && !action) return;
lastNotice = message;
const tone = noticeTone(message);
const options = {
id: chatNoticeToastId,
...(action ? { action: { label: action.label, onClick: action.onClick }, duration: Infinity } : {}),
};
if (tone === "success") {
toast.success(message, { id: chatNoticeToastId });
toast.success(message, options);
return;
}
if (tone === "error") {
toast.error(message, { id: chatNoticeToastId });
toast.error(message, options);
return;
}
toast(message, { id: chatNoticeToastId });
toast(message, options);
}
@@ -0,0 +1,15 @@
import { createHash } from "node:crypto";
import { logAgentObservability } from "@/lib/agent-observability";
export function hashedUserId(userId: string): string {
return createHash("sha256").update(userId).digest("hex").slice(0, 16);
}
export function logIgnoredSessionMessages(sessionId: string, userId: string, messageCount: number): void {
logAgentObservability({
sessionId,
requestId: `compat-${hashedUserId(userId)}`,
errorCode: "compat_messages_ignored",
evidenceCount: Math.max(0, messageCount),
});
}
+74 -15
View File
@@ -27,16 +27,48 @@ const chatMessageSchema = z.object({
workflowReceipt: workflowReceiptSchema.optional(),
}).strict();
const chatSessionWriteObjectSchema = z.object({
const chartBindingSchema = {
chart_profile_id: z.string().trim().max(100).nullable().optional(),
chart_profile_name: z.string().trim().max(80).nullable().optional(),
chart_profile_role: z.enum(["self", "other"]).nullable().optional(),
};
export const chatSessionMetadataPatchSchema = z.object({
title: z.string().trim().min(1).max(160).optional(),
theme: consultationDomainSchema.optional(),
model_id: z.string().trim().min(1).max(64).optional(),
...chartBindingSchema,
}).strict().refine(
(value) => Object.values(value).some((field) => field !== undefined),
{ message: "empty_metadata_patch" },
);
export const chatSessionModelPatchSchema = z.object({
model_id: z.string().trim().min(1).max(64),
}).strict();
export const chatSessionCreateSchema = z.object({
id: z.string().uuid(),
title: z.string().trim().min(1).max(160),
theme: consultationDomainSchema,
model_id: z.string().trim().min(1).max(64),
// Former value: `.max(CHAT_SESSION_MAX_MESSAGES)` with transcript contents.
// Create is no longer a history-import path; only an empty array is accepted.
messages: z.array(chatMessageSchema).max(0),
session_type: z.enum(["consultation", "birth_time_rectification"]),
rectification_case_id: z.string().uuid().nullable(),
...chartBindingSchema,
updated_at: z.string().datetime().optional(),
}).strict();
const chatSessionLegacyWriteObjectSchema = z.object({
title: z.string().trim().min(1).max(160),
theme: consultationDomainSchema,
model_id: z.string().trim().min(1).max(64),
messages: z.array(chatMessageSchema).max(CHAT_SESSION_MAX_MESSAGES),
session_type: z.enum(["consultation", "birth_time_rectification"]),
rectification_case_id: z.string().uuid().nullable(),
chart_profile_id: z.string().trim().max(100).nullable().optional(),
chart_profile_name: z.string().trim().max(80).nullable().optional(),
chart_profile_role: z.enum(["self", "other"]).nullable().optional(),
...chartBindingSchema,
updated_at: z.string().datetime(),
}).strict();
@@ -59,12 +91,9 @@ function limitTranscriptSize<Output extends { messages: Array<{ text: string; th
});
}
export const chatSessionWriteSchema = limitTranscriptSize(chatSessionWriteObjectSchema);
export const chatSessionCreateSchema = limitTranscriptSize(
chatSessionWriteObjectSchema.extend({
id: z.string().uuid(),
}).strict(),
);
// Former applied write contract. PATCH still parses this shape so old bundles
// are accepted, then the messages field is ignored rather than stored.
export const chatSessionWriteSchema = limitTranscriptSize(chatSessionLegacyWriteObjectSchema);
export class ChatSessionBodyTooLargeError extends Error {
constructor() {
@@ -84,9 +113,39 @@ export async function readChatSessionJson(request: Request): Promise<unknown> {
}
}
export const chatSessionModelPatchSchema = z.object({
model_id: z.string().trim().min(1).max(64),
}).strict();
export function extractChatSessionMetadataPatch(payload: unknown): unknown {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
const record = payload as Record<string, unknown>;
const patch: Record<string, unknown> = {};
if ("title" in record) patch.title = record.title;
if ("theme" in record) patch.theme = record.theme;
if ("model_id" in record) patch.model_id = record.model_id;
if ("chart_profile_id" in record) patch.chart_profile_id = record.chart_profile_id;
if ("chart_profile_name" in record) patch.chart_profile_name = record.chart_profile_name;
if ("chart_profile_role" in record) patch.chart_profile_role = record.chart_profile_role;
return patch;
}
export type ChatSessionMetadataPatch = Readonly<{
title?: string;
theme?: ConsultationDomain;
model_id?: string;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
}>;
export type ChatSessionCreate = Readonly<{
title: string;
theme: ConsultationDomain;
model_id: string;
messages: readonly [];
session_type: "consultation" | "birth_time_rectification";
rectification_case_id: string | null;
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
}>;
export type ChatSessionWrite = Readonly<{
title: string;
@@ -107,7 +166,7 @@ export type ChatSessionWrite = Readonly<{
chart_profile_id?: string | null;
chart_profile_name?: string | null;
chart_profile_role?: "self" | "other" | null;
updated_at: string;
updated_at?: string;
}>;
function retryableStatus(status: number) {
@@ -118,7 +177,7 @@ class TerminalChatSessionWriteError extends Error {}
export async function writeChatSession(
id: string,
values: ChatSessionWrite,
values: ChatSessionCreate | ChatSessionMetadataPatch,
mode: "create" | "update",
fetcher: typeof fetch = fetch,
): Promise<void> {
@@ -0,0 +1,27 @@
export const CONSULTATION_HISTORY_LIMIT = 12;
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 4_000;
export type ConsultationHistoryMessage = Readonly<{
role: "user" | "assistant";
text: string;
}>;
export function consultationHistoryFromStoredMessages(
messages: unknown,
options: { excludeRequestId?: string } = {},
): ConsultationHistoryMessage[] {
if (!Array.isArray(messages)) return [];
const rows: ConsultationHistoryMessage[] = [];
for (const message of messages) {
if (!message || typeof message !== "object") continue;
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown };
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) continue;
if (stored.role !== "user" && stored.role !== "assistant") continue;
if (typeof stored.text !== "string" || !stored.text) continue;
rows.push({
role: stored.role,
text: stored.text.slice(0, CONSULTATION_HISTORY_MESSAGE_CHARS),
});
}
return rows.slice(-CONSULTATION_HISTORY_LIMIT);
}
@@ -0,0 +1,116 @@
begin;
create or replace function public.append_consultation_question(
p_user_id uuid,
p_request_id text,
p_session_id uuid,
p_question_message jsonb
)
returns table(success boolean, error_code text)
language plpgsql
security definer
set search_path = ''
as $$
declare
v_session public.chat_sessions%rowtype;
v_message jsonb;
v_text text;
v_request_id text;
v_count integer;
v_chars integer;
v_new_chars integer;
begin
v_request_id := btrim(coalesce(p_request_id, ''));
if p_user_id is null or p_session_id is null or v_request_id = '' then
return query select false, 'invalid_request'::text;
return;
end if;
if jsonb_typeof(p_question_message) <> 'object'
or p_question_message->>'role' <> 'user' then
return query select false, 'invalid_question_message'::text;
return;
end if;
v_text := btrim(coalesce(p_question_message->>'text', ''));
if v_text = '' or char_length(v_text) > 16000 then
return query select false, 'invalid_question_message'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || v_request_id, 0));
select session.* into v_session
from public.chat_sessions as session
where session.id = p_session_id
and session.user_id = p_user_id
and session.session_type = 'consultation'
for update;
if not found then
return query select false, 'session_missing'::text;
return;
end if;
if exists (
select 1
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem
where elem->>'requestId' = v_request_id
) then
return query select true, null::text;
return;
end if;
v_count := jsonb_array_length(coalesce(v_session.messages, '[]'::jsonb));
select coalesce(sum(
length(coalesce(elem->>'text', ''))
+ length(coalesce(elem->>'thinkingText', ''))
+ case
when elem ? 'thinkingSections' then length((elem->'thinkingSections')::text)
else 0
end
), 0)
into v_chars
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem;
v_new_chars := length(v_text);
if v_count >= 200 or (v_chars + v_new_chars) > 200000 then
return query select false, 'session_full'::text;
return;
end if;
v_message := p_question_message || jsonb_build_object('requestId', v_request_id);
update public.chat_sessions as session
set messages = coalesce(session.messages, '[]'::jsonb) || jsonb_build_array(v_message),
title = case
when coalesce(btrim(session.title), '') in ('', '新对话') then
case
when char_length(v_text) > 14 then left(v_text, 14) || '…'
else v_text
end
else session.title
end,
updated_at = clock_timestamp()
where session.id = p_session_id
and session.user_id = p_user_id
and session.session_type = 'consultation';
if not found then
return query select false, 'session_missing'::text;
return;
end if;
return query select true, null::text;
end;
$$;
revoke all on function public.append_consultation_question(uuid, text, uuid, jsonb)
from public, anon, authenticated;
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
to service_role;
do $$ begin
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
to admin_runtime;
end if;
end $$;
commit;
@@ -19,7 +19,9 @@ function sourceBetween(source: string, start: string, end: string): string {
test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => {
assert.match(rectificationRoute, /import \{ createServerSupabaseClient \} from "@\/lib\/supabase\/server"/);
assert.match(rectificationRoute, /supabase = await createServerSupabaseClient\(\)/);
assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
// Former value: select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id")
// Messages were unused on this route and inflated every rectification turn.
assert.match(rectificationRoute, /select\("id,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(rectificationRoute, /modelConfigVersion: selectedModel\.configVersion/);
assert.doesNotMatch(rectificationRoute, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
@@ -66,7 +68,9 @@ test("free Agentic rectification turns bypass reservation, completion, and cance
test("standard consultation resolves and settles the session-pinned model version", () => {
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type"\)/);
// Former value: select("id,model_id,model_config_version,session_type") without messages.
// Task 2 reads the last 12 stored messages as model history, so this select now includes messages.
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages"\)/);
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(consultRoute, /actualModelId: selectedModel\.id/);
assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/);
@@ -25,13 +25,13 @@ test("routes composer notices to the user instead of discarding them", () => {
// Then: every notice reaches the mounted sonner toaster.
assert.match(noticeSource, /import \{ toast \} from "sonner"/);
assert.match(noticeSource, /toast\.success\(message, \{ id: chatNoticeToastId \}\)/);
assert.match(noticeSource, /toast\.error\(message, \{ id: chatNoticeToastId \}\)/);
assert.match(noticeSource, /toast\(message, \{ id: chatNoticeToastId \}\)/);
assert.match(noticeSource, /toast\.success\(message, options\)/);
assert.match(noticeSource, /toast\.error\(message, options\)/);
assert.match(noticeSource, /toast\(message, options\)/);
});
test("clearing a notice dismisses instead of showing an empty toast", () => {
const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message) return;");
const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message && !action) return;");
assert.match(clearBranch, /toast\.dismiss\(chatNoticeToastId\)/);
assert.doesNotMatch(clearBranch, /toast\(|toast\.success|toast\.error/);
@@ -44,7 +44,7 @@ test("keeps the recovery poll from stacking repeated notices", () => {
// Then: a stable toast id plus last-message dedupe replaces instead of accumulating.
assert.match(noticeSource, /export const chatNoticeToastId = "chat-notice"/);
assert.match(noticeSource, /if \(lastNotice === message\) return;/);
assert.match(noticeSource, /if \(lastNotice === message && !action\) return;/);
});
test("assigns notice severity by message intent", () => {
@@ -56,6 +56,7 @@ test("assigns notice severity by message intent", () => {
assert.equal(noticeTone("回答已恢复。"), "success");
assert.equal(noticeTone("已归档,可在左侧归档中恢复。"), "success");
assert.equal(noticeTone("已停止回答,现有内容已保留,本次点数已退回。"), "success");
assert.equal(noticeTone("这段对话已写满,开个新对话继续吧"), "error");
assert.equal(noticeTone("删除失败:网络异常"), "error");
assert.equal(noticeTone("重命名同步失败"), "error");
assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error");
@@ -0,0 +1,60 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const listRoute = 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 consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
const sql = readFileSync(
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
"utf8",
);
const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current"));
test("session list GET omits messages while detail GET returns them", () => {
assert.match(
listRoute,
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"/,
);
assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/);
assert.match(itemRoute, /export async function GET/);
assert.match(
itemRoute,
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"/,
);
assert.match(page, /async function fetchSessionDetail\(/);
assert.match(page, /async function ensureSessionMessages\(/);
assert.match(page, /session-messages-loading/);
assert.match(page, /messagesHydrated: messagesPresent/);
});
test("consult reads stored history and never applies the client history field", () => {
assert.match(consultRoute, /history: z\s*\.array\([\s\S]*?\)\s*\.max\(20\)\s*\.optional\(\)\s*\.default\(\[\]\)/);
assert.match(consultRoute, /const storedHistory = consultationHistoryFromStoredMessages\(chatSession\.messages\)/);
assert.match(consultRoute, /const history = storedHistory/);
assert.doesNotMatch(consultRoute, /parsed\.data\.history/);
assert.match(sendSource, /history: currentSession\.messages\.slice\(-12\)/);
});
test("consult appends the user question after reserve and returns session_full", () => {
assert.match(consultRoute, /accounting\.rpc\("append_consultation_question"/);
assert.match(consultRoute, /error_code === "session_full"/);
assert.match(consultRoute, /code: "session_full"/);
assert.match(consultRoute, /这段对话已写满,开个新对话继续吧/);
assert.match(sql, /create or replace function public\.append_consultation_question/);
assert.match(sql, /pg_advisory_xact_lock/);
assert.match(sql, /session_type = 'consultation'/);
assert.match(sql, /'session_full'::text/);
assert.match(sql, /grant execute on function public\.append_consultation_question/);
assert.match(sendSource, /caught\.code === "session_full"/);
assert.match(sendSource, /label: "开新对话"/);
assert.match(sendSource, /continueInNewChat\(\{ question: originalQuestion, theme \}\)/);
});
test("PATCH compatibility accepts and ignores a legacy messages write", () => {
assert.match(itemRoute, /logIgnoredSessionMessages\(id, user\.id, ignoredMessageCount\(payload\)\)/);
assert.match(itemRoute, /if \(!values && payloadHasMessages\(payload\)\) \{/);
assert.match(itemRoute, /return NextResponse\.json\(\{ ok: true \}\)/);
assert.doesNotMatch(itemRoute, /\.update\(\{[\s\S]*messages:/);
});
+41 -8
View File
@@ -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/);
+20 -12
View File
@@ -6,13 +6,16 @@ const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "ut
const sendSource = source.slice(source.indexOf(" async function send("), source.indexOf("\n\n consultationReplay.current"));
const stopSource = source.slice(source.indexOf(" async function stopResponse("), source.indexOf("\n\n function completeConsultationInterface"));
test("consultation persists the optimistic user message before generation starts", () => {
test("consultation does not persist the optimistic user message before generation starts", () => {
// Former value: `await persistSession(userSession)` ran after the undo window and
// before `/api/consult`, locking the client full-replace write. The server now
// appends the question after a successful reserve.
const undo = sendSource.indexOf("await waitForUndoWindow(controller.signal)");
const persist = sendSource.indexOf("await persistSession(userSession)");
const consult = sendSource.indexOf('fetch("/api/consult"');
assert.ok(undo >= 0 && undo < persist && persist < consult);
assert.match(sendSource, /问题保存失败,未开始生成;问题已放回输入框。[\s\S]*?return false;[\s\S]*?fetch\("\/api\/consult"/);
assert.ok(undo >= 0 && undo < consult);
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
assert.doesNotMatch(sendSource, /问题保存失败,未开始生成;问题已放回输入框。/);
});
test("only explicit stop can request consultation cancellation", () => {
@@ -30,15 +33,16 @@ test("durable partial stop cancels before preserving content and recovers comple
stopSource.indexOf("\n updateSession(pending.sessionId, () => pending.previousSession)"),
);
const cancel = partialStop.indexOf("await requestCancellation(pending.requestId)");
const persist = partialStop.indexOf("await persistSession(stoppedSession)");
const conflict = partialStop.indexOf("error.status === 409");
const recoveryPersist = partialStop.slice(conflict, persist);
const recoveryPersist = partialStop.slice(conflict);
assert.ok(cancel >= 0 && cancel < persist);
assert.ok(conflict >= 0 && conflict < persist);
assert.ok(cancel >= 0 && conflict > cancel);
// Former value: persistSession(stoppedSession) after cancel. Refunded partial
// answers stay in page memory only and must not overwrite the server transcript.
assert.equal(partialStop.indexOf("await persistSession(stoppedSession)"), -1);
assert.doesNotMatch(recoveryPersist, /persistSession\(stoppedSession\)/);
assert.match(recoveryPersist, /fetchConsultationStatus\(pending.sessionId, pending.requestId\)/);
assert.match(recoveryPersist, /status.status === "completed"[\s\S]*?fetchSessions\(\)[\s\S]*?readSessions\(payload, modelCatalog\)/);
assert.match(recoveryPersist, /status.status === "completed"[\s\S]*?fetchSessionDetail\(pending.sessionId, modelCatalog\)/);
assert.match(partialStop, /已停止回答,现有内容已保留,本次点数已退回。/);
assert.match(source, /停止回答,保留已生成内容并退回本次点数/);
assert.match(source, /停止回答,保留现有内容并申请退回本次点数/);
@@ -66,7 +70,7 @@ test("reserved consultations recover through the status endpoint", () => {
assert.match(source, /readonly responseMessage\?: unknown/);
assert.match(source, /phase: "recovering"/);
assert.match(source, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
assert.match(source, /status\.status === "completed"[\s\S]*?fetchSessions\(controller\.signal\)[\s\S]*?readSessions\(payload, modelCatalog\)/);
assert.match(source, /status\.status === "completed"[\s\S]*?fetchSessionDetail\(pendingSessionId, modelCatalog, controller\.signal\)/);
assert.match(source, /window\.addEventListener\("online", onOnline\)/);
assert.match(source, /window\.addEventListener\("pageshow", onPageShow\)/);
assert.match(source, /网络已断开,回答仍在后台生成;联网后会自动恢复。/);
@@ -128,7 +132,10 @@ test("the first default consultation title is persisted with the user question",
sendSource.indexOf("const requestId = resumeRequestId ?? globalThis.crypto.randomUUID()"),
);
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && isGenericSessionTitle\(currentSession\.title\)[\s\S]*resolveSessionTitle\(question/);
assert.ok(sendSource.indexOf("await persistSession(userSession)") < sendSource.indexOf('fetch("/api/consult"'));
// Former value: persistSession(userSession) before consult wrote the title by
// replacing the whole messages array. Title now comes from SQL append, and the
// completed metadata patch only updates title/theme/model/chart binding.
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
assert.match(sendSource, /await persistSession\(completedSession\)/);
assert.match(sendSource, /const completedTitle = reply\.title && !isGenericSessionTitle\(reply\.title\)/);
assert.match(sendSource, /resolveSessionTitle\(question, reply\.title/);
@@ -139,7 +146,8 @@ test("a truncated generation keeps the partial answer and does not wait for a su
assert.match(stream, /event\.code === "answer_truncated"/);
assert.match(stream, /truncatedFailure = event/);
assert.match(stream, /const truncatedSession: ChatSession = \{[\s\S]*role: "assistant"[\s\S]*text: reply\.text/);
assert.match(stream, /await persistSession\(truncatedSession\)/);
// Former value: persistSession(truncatedSession) saved a refunded partial answer.
assert.doesNotMatch(stream, /await persistSession\(truncatedSession\)/);
assert.match(stream, /setComposerNotice\(truncatedFailure\.message\)/);
assert.match(stream, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
assert.doesNotMatch(stream.slice(stream.indexOf("if (truncatedFailure)")), /runCompleted = true/);
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import test from "node:test";
import { consultationHistoryFromStoredMessages } from "../src/lib/consultation-session-history.ts";
test("stored consultation history keeps the last 12 role/text pairs and clips text", () => {
const history = consultationHistoryFromStoredMessages([
{ role: "system", text: "ignore" },
{ role: "user", text: "first" },
...Array.from({ length: 12 }, (_, index) => ({
role: index % 2 === 0 ? "assistant" : "user",
text: `keep-${index}`,
requestId: `req-${index}`,
})),
{ role: "assistant", text: "x".repeat(4001), thinkingText: "secret" },
]);
assert.equal(history.length, 12);
assert.equal(history[0]?.text, "keep-1");
assert.equal(history.at(-1)?.text.length, 4000);
assert.deepEqual(history.at(-2), { role: "user", text: "keep-11" });
});
test("stored consultation history can skip the in-flight request id", () => {
const history = consultationHistoryFromStoredMessages([
{ role: "user", text: "old", requestId: "keep" },
{ role: "user", text: "current", requestId: "skip" },
], { excludeRequestId: "skip" });
assert.deepEqual(history, [{ role: "user", text: "old" }]);
});
@@ -54,7 +54,9 @@ test("persists partial transformed output when the upstream stream errors", () =
test("Agentic failures always refund and detached execution uses a server-owned timeout", () => {
const agentic = consultRoute.slice(
consultRoute.indexOf("async function runAgenticConsultation("),
consultRoute.indexOf(" try {\n const { history } = parsed.data;"),
// Former value: `const { history } = parsed.data` marked the end of the
// agentic function. History is now copied from stored session messages.
consultRoute.indexOf(" try {\n const history = storedHistory;"),
);
// The value lives beside the model step budget and the domain cap it funds,
// so the route imports it rather than restating it.
@@ -108,7 +108,9 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool
const agenticStart = route.indexOf("async function runAgenticConsultation");
const agenticBranch = route.slice(
agenticStart,
route.indexOf(" const { history } = parsed.data;", agenticStart),
// Former value: `const { history } = parsed.data;` ended the agentic function.
// History now comes from stored session messages, not the client field.
route.indexOf(" const history = storedHistory;", agenticStart),
);
assert.match(agenticBranch, /createConsultationAgentContext/);
assert.match(agenticBranch, /getJyotishAgent\(selectedModel, agentContext\)/);
@@ -86,6 +86,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
assert.match(migration.stdout, /applied 20260824020000_rectification_choice_action\.sql/);
assert.match(migration.stdout, /applied 20260824030000_rectification_turn_origin\.sql/);
assert.match(migration.stdout, /applied 20260831020000_feature_pricing_admin_runtime_read_policy\.sql/);
assert.match(migration.stdout, /applied 20260901010000_append_consultation_question\.sql/);
assert.equal(
fixture.psql(`
@@ -922,6 +923,108 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
null,
);
assert.equal(
fixture.psql(`
select
has_function_privilege(
'service_role',
'public.append_consultation_question(uuid, text, uuid, jsonb)',
'execute'
) || ':' ||
has_function_privilege(
'authenticated',
'public.append_consultation_question(uuid, text, uuid, jsonb)',
'execute'
) || ':' ||
has_function_privilege(
'anon',
'public.append_consultation_question(uuid, text, uuid, jsonb)',
'execute'
)
`),
"true:f:f",
);
const consultationSessionId = "88888888-8888-4888-8888-888888888888";
fixture.psql(`
insert into public.chat_sessions (
id, user_id, title, theme, model_id, messages, session_type, updated_at
) values (
'${consultationSessionId}', '${userId}', '新对话', 'general',
'test-model', '[]', 'consultation', now()
);
`);
const firstQuestion = await admin.rpc("append_consultation_question", {
p_user_id: userId,
p_request_id: "append-request-1",
p_session_id: consultationSessionId,
p_question_message: { role: "user", text: "第一问会不会丢" },
});
assert.equal(firstQuestion.error, null, rpcError(firstQuestion.error));
const firstQuestionRow = Array.isArray(firstQuestion.data) ? firstQuestion.data[0] : firstQuestion.data;
assert.equal((firstQuestionRow as { success?: unknown }).success, true);
assert.equal(
fixture.psql(`
select title || ':' || jsonb_array_length(messages)::text || ':' || (messages->0->>'text')
from public.chat_sessions where id = '${consultationSessionId}'
`),
"第一问会不会丢:1:第一问会不会丢",
);
const firstQuestionReplay = await admin.rpc("append_consultation_question", {
p_user_id: userId,
p_request_id: "append-request-1",
p_session_id: consultationSessionId,
p_question_message: { role: "user", text: "不该写入的重复提问" },
});
assert.equal(firstQuestionReplay.error, null, rpcError(firstQuestionReplay.error));
const firstQuestionReplayRow = Array.isArray(firstQuestionReplay.data) ? firstQuestionReplay.data[0] : firstQuestionReplay.data;
assert.equal((firstQuestionReplayRow as { success?: unknown }).success, true);
assert.equal(
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${consultationSessionId}'`),
"1",
);
const secondQuestion = await admin.rpc("append_consultation_question", {
p_user_id: userId,
p_request_id: "append-request-2",
p_session_id: consultationSessionId,
p_question_message: { role: "user", text: "第二问也会留下" },
});
assert.equal(secondQuestion.error, null, rpcError(secondQuestion.error));
assert.equal(
fixture.psql(`
select jsonb_array_length(messages)::text || ':' || (messages->0->>'text') || ':' || (messages->1->>'text')
from public.chat_sessions where id = '${consultationSessionId}'
`),
"2:第一问会不会丢:第二问也会留下",
);
fixture.psql(`
update public.chat_sessions
set messages = (
select coalesce(jsonb_agg(jsonb_build_object('role','user','text','x','requestId', n::text)), '[]'::jsonb)
from generate_series(1, 200) as n
)
where id = '${consultationSessionId}'
`);
const sessionFull = await admin.rpc("append_consultation_question", {
p_user_id: userId,
p_request_id: "append-request-full",
p_session_id: consultationSessionId,
p_question_message: { role: "user", text: "满了就不能再写" },
});
assert.equal(sessionFull.error, null, rpcError(sessionFull.error));
const sessionFullRow = Array.isArray(sessionFull.data) ? sessionFull.data[0] : sessionFull.data;
assert.deepEqual(sessionFullRow, { success: false, error_code: "session_full" });
assert.equal(
fixture.psql(`
select jsonb_array_length(messages)
from public.chat_sessions where id = '${consultationSessionId}'
`),
"200",
);
fixture.psql(`
insert into public.redemption_codes (code_hash, code_mask, credits)
values ('${"a".repeat(64)}', 'JYOTISH-****-TEST', 3)
@@ -270,7 +270,8 @@ test("opening and message retries reuse the caller-owned request id as the usage
});
test("rectification uses one case-level entitlement and the session-pinned model version", () => {
assert.match(route, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
// Former value: select included unused `messages` and pulled up to 500KB per turn.
assert.match(route, /select\("id,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
assert.match(route, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
assert.match(route, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/);
assert.match(route, /requestId: billingRequestId/);
+22 -1
View File
@@ -1069,4 +1069,25 @@
- 建议在数据库 migration 成功后运行独立的环境 readiness 脚本,不放在应用启动期,避免每个实例重复探测或因业务配置缺失阻止无关只读能力启动。
- readiness 应枚举当前环境所有已发布模型的 `model_tier`,逐一检查 `rectification`、`chat.standard`、`report.full` 三个 feature 是否存在 `status='published' AND enabled=true` 的定价;任何组合缺失即以脱敏、可行动的运维错误 fail closed。
- readiness 只负责验证,不写入或 seed 价格;部署后仍必须通过 admin flow 为实际 tier 发布定价,随后再重跑 readiness。
- 本地验证:TypeScript、改动文件 ESLint、rectification `705/705`、consult/report 聚焦 `262/262` 通过;全量 `2368/2369`,唯一失败是默认 `python3` 缺少 PyYAML,改用已安装 PyYAML 的 `/opt/homebrew/bin/python3` 后对应文件 `39/39` 通过。
- 本地验证:
- `./node_modules/.bin/tsc --noEmit` 通过。
- 非数据库前端测试 **2377 通过 / fail=0 / skipped=0**。
- `npm run test:db -- --test-concurrency=1`:**34 通过 / fail=0 / skipped=0**(含 `append_consultation_question` 幂等、双 request_id 叠加、`session_full`)。
- `npm test` 会并行拉起多个 Postgres fixture,本机 Docker address pool 只能同时容纳 2 个 compose network,并行时会超时,不是本轮断言回归。验收用上面两项拆开跑。
- 浅色/深色:加载态 `.session-messages-loading` 使用 `color: var(--color-ink-secondary)`,spinner 的 `currentColor` 跟随该 token(浅色 `#5f5f59`,深色 `#b3afa4`)。`session_full` 走 sonner toast,背景/文字用 `--color-canvas` / `--color-ink`,错误语气由「已写满」命中 `failedNotice`。未登录所以没有做带真实会话的页面点击;主题检查落在 token 与合同测试上。
## 2026-09-01 - TASK-chat-message-authority:服务端收回 messages 写权
- 列表 `GET /api/sessions` 不再选择 `messages`;新增 `GET /api/sessions/[id]` 按会话加载完整消息。前端启动只拉列表,切换时拉详情,已加载的会话内存缓存,正在 streaming 的会话不重拉。
- `append_consultation_question` 在 reserve 成功后写入用户提问;满员返回 `session_full` 并取消预扣。consult 忽略客户端 `history`,改读库内最近 12 条。
- `persistSession` 只 PATCH 元数据。旧全量 `messages` PATCH 返回 200 且不覆盖库。中止/失败部分回答不再落库。
- 断言例外(锁住的正是本轮要修的缺陷):
- `consultation-recovery.test.ts`:不再要求发问前 `persistSession(userSession)`,也不再要求中止/截断路径持久化部分回答;完成恢复改拉详情而不是整表列表。
- `chat-session-write.test.ts`:create schema `messages.max(0)`;update 请求体不含 `messages`;页面不再把 thinkingText 映射进 PATCH。
- `application-billing-contract.test.ts` / `rectification-agentic-entry.test.ts`:校正 agent select 去掉未使用的 `messages`;consult select 增加 `messages` 以便服务端读历史。
- `consultation-stream-recovery.test.ts` / `consultation-workflow-contract.test.ts`:agentic 函数切片终点从 `parsed.data.history` 改为 `storedHistory`。
- `chat-notice-and-scroll-contract.test.ts`:toast 支持一键开新对话的 action。
- 实测:
- Payload:列表响应列集合为 `id,title,theme,model_id,session_type,rectification_case_id,chart_profile_* ,updated_at`,消息文本只能出现在详情 GET。多会话账户的字节对比取决于该账户存量消息,合同测试锁的是“列表不含消息文本”这一结构,而不是某一个登录态的绝对字节数。
- `session_full`:本地 PostgreSQL 把会话填到 200 条后再 append,返回 `error_code=session_full`,行内消息数仍为 200。
- 双标签页:同一会话两次 `append_consultation_question`(不同 request_id)后两条用户消息都在;这是改前 last-write-wins 必丢、改后必留的核心形状。