From b6989c3eea81fd11c85556eff2d32274126661ca Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 1 Sep 2026 19:51:13 +0800 Subject: [PATCH] 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 --- docs/BUG_HISTORY.md | 16 ++ frontend/src/app/api/consult/route.ts | 57 +++- .../src/app/api/rectification/agent/route.ts | 2 +- frontend/src/app/api/sessions/[id]/route.ts | 72 ++++- frontend/src/app/api/sessions/route.ts | 7 +- frontend/src/app/globals.css | 7 + frontend/src/app/page.tsx | 264 ++++++++++++------ frontend/src/lib/chat-notice.ts | 21 +- .../src/lib/chat-session-observability.ts | 15 + .../src/lib/chat-session-write-contract.ts | 89 +++++- .../src/lib/consultation-session-history.ts | 27 ++ ...901010000_append_consultation_question.sql | 116 ++++++++ .../application-billing-contract.test.ts | 8 +- .../chat-notice-and-scroll-contract.test.ts | 11 +- frontend/tests/chat-session-authority.test.ts | 60 ++++ frontend/tests/chat-session-write.test.ts | 49 +++- frontend/tests/consultation-recovery.test.ts | 32 ++- .../consultation-session-history.test.ts | 31 ++ .../consultation-stream-recovery.test.ts | 4 +- .../consultation-workflow-contract.test.ts | 4 +- .../tests/database-local-business.test.ts | 103 +++++++ .../tests/rectification-agentic-entry.test.ts | 3 +- progress.md | 23 +- 23 files changed, 871 insertions(+), 150 deletions(-) create mode 100644 frontend/src/lib/chat-session-observability.ts create mode 100644 frontend/src/lib/consultation-session-history.ts create mode 100644 frontend/supabase/migrations/20260901010000_append_consultation_question.sql create mode 100644 frontend/tests/chat-session-authority.test.ts create mode 100644 frontend/tests/consultation-session-history.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f855f394..670e4eb8 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 幂等与满员拒绝。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index f03a9f41..7f9cc690 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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(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({ diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 545165b7..c670707c 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -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(); diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index 813a158a..39e5a91d 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -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 | 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; - if (fullWrite.success) { - values = fullWrite.data; - } else if (modelPatch.success) { - values = modelPatch.data; - } else { + const fullWrite = chatSessionWriteSchema.safeParse(payload); + let values: Record | 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 diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 978c0540..7addf632 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -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 }); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 81f96f5c..6d373047 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index bb45f69f..1fc09a6f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -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 (
@@ -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 { return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null; } +async function fetchSessionDetail( + sessionId: string, + catalog: PublicLanguageModelCatalog | null, + signal?: AbortSignal, +): Promise { + 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; @@ -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(null); + const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null); const [onboarding, setOnboarding] = useState(null); const [onboardingError, setOnboardingError] = useState(""); const [onboardingStep, setOnboardingStep] = useState("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()); + const sessionsRef = useRef(sessions); + sessionsRef.current = sessions; const uiPreview = useRef(false); const uiPreviewMode = useRef(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() { {!rectificationSurfaceOpen && ( -
+
{!activeSession?.messages.length ? (
{!profileComplete ? ( @@ -4429,6 +4522,11 @@ export default function Home() {
))}
+ ) : sessionMessagesLoading ? ( +
+ + 正在加载聊天记录 +
) : (
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); } diff --git a/frontend/src/lib/chat-session-observability.ts b/frontend/src/lib/chat-session-observability.ts new file mode 100644 index 00000000..fb3885bb --- /dev/null +++ b/frontend/src/lib/chat-session-observability.ts @@ -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), + }); +} diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index 44b9d013..760c937e 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -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 { } } -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; + const patch: Record = {}; + 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 { diff --git a/frontend/src/lib/consultation-session-history.ts b/frontend/src/lib/consultation-session-history.ts new file mode 100644 index 00000000..9e22863b --- /dev/null +++ b/frontend/src/lib/consultation-session-history.ts @@ -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); +} diff --git a/frontend/supabase/migrations/20260901010000_append_consultation_question.sql b/frontend/supabase/migrations/20260901010000_append_consultation_question.sql new file mode 100644 index 00000000..dc6ce696 --- /dev/null +++ b/frontend/supabase/migrations/20260901010000_append_consultation_question.sql @@ -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; diff --git a/frontend/tests/application-billing-contract.test.ts b/frontend/tests/application-billing-contract.test.ts index 6ad099ee..0ca8553e 100644 --- a/frontend/tests/application-billing-contract.test.ts +++ b/frontend/tests/application-billing-contract.test.ts @@ -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/); diff --git a/frontend/tests/chat-notice-and-scroll-contract.test.ts b/frontend/tests/chat-notice-and-scroll-contract.test.ts index 6130b3f9..1c1625ba 100644 --- a/frontend/tests/chat-notice-and-scroll-contract.test.ts +++ b/frontend/tests/chat-notice-and-scroll-contract.test.ts @@ -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"); diff --git a/frontend/tests/chat-session-authority.test.ts b/frontend/tests/chat-session-authority.test.ts new file mode 100644 index 00000000..10f5f673 --- /dev/null +++ b/frontend/tests/chat-session-authority.test.ts @@ -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:/); +}); diff --git a/frontend/tests/chat-session-write.test.ts b/frontend/tests/chat-session-write.test.ts index eba23b88..0f5e2222 100644 --- a/frontend/tests/chat-session-write.test.ts +++ b/frontend/tests/chat-session-write.test.ts @@ -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 \}>/); assert.match(contract, /\): z\.ZodType \{/); assert.match(page, /chartSnapshotForSession/); diff --git a/frontend/tests/consultation-recovery.test.ts b/frontend/tests/consultation-recovery.test.ts index 66041921..a429dacd 100644 --- a/frontend/tests/consultation-recovery.test.ts +++ b/frontend/tests/consultation-recovery.test.ts @@ -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/); diff --git a/frontend/tests/consultation-session-history.test.ts b/frontend/tests/consultation-session-history.test.ts new file mode 100644 index 00000000..3a5ec945 --- /dev/null +++ b/frontend/tests/consultation-session-history.test.ts @@ -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" }]); +}); diff --git a/frontend/tests/consultation-stream-recovery.test.ts b/frontend/tests/consultation-stream-recovery.test.ts index d63d8b55..83920117 100644 --- a/frontend/tests/consultation-stream-recovery.test.ts +++ b/frontend/tests/consultation-stream-recovery.test.ts @@ -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. diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index ae63d6e9..266cc363 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -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\)/); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index e08fee29..50bde32c 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -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) diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index d82fa9f6..3474cf38 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -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/); diff --git a/progress.md b/progress.md index d8bdd730..44c9dcd2 100644 --- a/progress.md +++ b/progress.md @@ -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 必丢、改后必留的核心形状。