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
+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);
}