fix rectification session persistence

This commit is contained in:
Jesse_Chen
2026-08-04 00:38:16 +08:00
parent e65c8eeda2
commit b9fe6a6be3
8 changed files with 207 additions and 38 deletions
+14 -5
View File
@@ -2024,9 +2024,18 @@
- 复发自:BUG-114
- 修复版本:待本次 staging 修复提交与部署验收
## 2026-08-03 — Agentic rectification could finish after tool calls without a visible reply
## BUG-116 | Agent 工具步骤耗尽后静默完成且校正对话刷新即丢失
- Symptom: `/api/rectification/agent` returned `{"type":"done","emitted":false}` after a user supplied another dated life event.
- Root cause: the Mastra stream used its default step limit. A turn that consumed all available steps on `rectification-*` tool calls ended before the model could produce the final user-facing Chinese response, while the route treated an empty `textStream` as a normal `done` event.
- Fix: allow eight Agent steps so the existing tool workflow can continue from tool results to a final response. Empty streams now return an explicit recoverable error event and remain on the existing refund path instead of silently reporting completion.
- Regression coverage: `frontend/tests/rectification-agentic-entry.test.ts` checks the multi-step stream boundary and rejects the former silent `done` contract.
- 状态:resolved(local,空流修复已先部署)
- 首次发现:2026-08-03
- 最近更新:2026-08-03
- 影响面:`POST /api/rectification/agent`、Agentic 生时校正消息持久化、首次 opening、余额显示与刷新恢复
- 用户现象:提交新的人生事件后接口只返回 `{"type":"done","emitted":false}`,页面没有 Agent 回复;刷新页面后此前校正对话全部消失,并再次自动发送 opening、再次预扣咨询点数;页面余额可能保持旧值,让一次请求看起来像多次扣费。
- 触发条件:Agent 在默认步骤上限内连续调用 `rectification-*` 工具但没有剩余步骤生成公开文本;或 Agentic 校正组件卸载/刷新,而 `chat_sessions.messages` 仍为空。
- 根因:Mastra 默认步骤上限不足,路由又把空 `textStream` 当作正常完成;新版组件只把消息保存在 React 本地状态,没有复用现有 `chat_sessions` 持久化边界,自动 opening 也只检查本次组件实例的 ref;新 Session 还可能在数据库创建完成前挂载 Agent;请求完成后没有刷新账户余额。
- 修复:Agent 步骤上限提升为 8,解析后仍无可见文本时返回明确 error 并退款,不再发送 `done false`;请求绑定当前用户的 `birth_time_rectification` Session,成功回复先原子更新完整消息再发送 `done` 并完成扣费;已有持久化消息拒绝重复 opening;客户端从 Session 初始化、成功后同步首页状态并刷新一次账户余额,未收到持久化成功的 `done` 时移除临时 Assistant;新 Session 先创建成功再挂载 Agent,并按 Session key 重建本地对话状态。
- 验证:`frontend/tests/rectification-agentic-entry.test.ts` 覆盖多步公开回复、空流退款合同、Session 归属与写回、刷新抑制 opening、失败流清理、新 Session 创建顺序;完整测试、lint、build 与 staging 真实刷新/扣费 smoke 随本次发布执行。
- 数据边界:复用现有 `chat_sessions.messages`,不新增平行对话存储;不从用户粘贴内容擅自回填旧 Session;不修改身份、credits 历史或出生资料。
- 防复发:公开回复必须同时满足“可见文本 + Session 持久化成功”才能发送完成事件;自动 opening 必须以服务端 Session 历史为准,不能只依赖组件内存。
- 相关记录:BUG-113、BUG-114、BUG-115
- 修复版本:空流修复 `e65c8eeda2ff5916f88f18dd345c02beff045e8b` / Session 持久化待本次 staging 发布
@@ -1,5 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage } from "@/lib/chat-message-view";
import { getAgenticRectificationAgent } from "@/mastra/agentic-rectification";
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
import { blocksPromptExtraction } from "@/lib/consult-safety";
@@ -17,6 +19,7 @@ export const maxDuration = 120;
const agenticRectificationRequestFields = {
requestId: z.string().uuid(),
sessionId: z.string().uuid(),
modelId: z.string().trim().min(1).max(64).optional(),
name: z.string().trim().max(80).optional().default(""),
history: z
@@ -45,6 +48,22 @@ const agenticRectificationRequestSchema = z.discriminatedUnion("action", [
const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event.";
const agenticRectificationMaxSteps = 8;
function readPersistedMessages(value: unknown): ChatMessage[] {
if (!Array.isArray(value)) return [];
return value.flatMap((item): ChatMessage[] => {
if (!item || typeof item !== "object") return [];
const message = item as Partial<ChatMessage>;
if ((message.role !== "user" && message.role !== "assistant") || typeof message.text !== "string") return [];
return [{
role: message.role,
text: message.text.slice(0, 100_000),
...(Array.isArray(message.suggestions)
? { suggestions: message.suggestions.filter((suggestion): suggestion is string => typeof suggestion === "string").slice(0, 3) }
: {}),
}];
});
}
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
@@ -127,6 +146,32 @@ export async function POST(request: Request) {
const requestId = parsed.data.requestId;
const requestTime = new Date();
const { data: chatSession, error: chatSessionError } = await supabase
.from("chat_sessions")
.select("id,messages,session_type")
.eq("id", parsed.data.sessionId)
.eq("user_id", userId)
.maybeSingle();
if (chatSessionError) {
return NextResponse.json(
{ error: "暂时无法读取生时校正会话", message: "请稍后重试。" },
{ status: 503 },
);
}
if (!chatSession || chatSession.session_type !== "birth_time_rectification") {
return NextResponse.json(
{ error: "生时校正会话不存在", message: "请重新进入生时校正。" },
{ status: 404 },
);
}
const persistedMessages = readPersistedMessages(chatSession.messages);
if (parsed.data.action === "opening" && persistedMessages.length > 0) {
return NextResponse.json(
{ code: "opening_already_started", error: "生时校正已开始", message: "已有校正记录,无需重复生成首次引导。" },
{ status: 409 },
);
}
let profile;
try {
profile = await loadAgenticRectificationProfile(accounting, userId);
@@ -196,6 +241,7 @@ export async function POST(request: Request) {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
let emitted = false;
let raw = "";
let settled = false;
const settle = async (complete: boolean) => {
if (settled) return;
@@ -233,6 +279,7 @@ export async function POST(request: Request) {
);
for await (const chunk of result.textStream) {
if (/\S/.test(chunk)) emitted = true;
raw += chunk;
send({ type: "delta", text: chunk });
}
void recordModelUsage(
@@ -242,13 +289,37 @@ export async function POST(request: Request) {
selectedModel.id,
result.totalUsage,
);
if (!emitted) {
const reply = parseAgentReply(raw, "general");
if (!emitted || !reply.text) {
console.warn(`[agentic-rectification] empty response request=${requestId}`);
send({ type: "error", message: "生时校正没有生成有效回复,本次不会扣除点数,请重新发送。" });
await settle(false);
controller.close();
return;
}
const requestHistory = parsed.data.history.map((message) => ({
role: message.role,
text: message.text,
} satisfies ChatMessage));
const baseMessages = requestHistory.length > persistedMessages.length
? requestHistory
: persistedMessages;
const nextMessages: ChatMessage[] = [
...baseMessages,
...(parsed.data.action === "message"
? [{ role: "user" as const, text: parsed.data.message }]
: []),
{ role: "assistant" as const, text: reply.text, suggestions: reply.suggestions },
].slice(-500);
const { data: savedSession, error: saveError } = await supabase
.from("chat_sessions")
.update({ messages: nextMessages, updated_at: new Date().toISOString() })
.eq("id", parsed.data.sessionId)
.eq("user_id", userId)
.eq("session_type", "birth_time_rectification")
.select("id")
.maybeSingle();
if (saveError || !savedSession) throw new Error("RectificationSessionPersistenceError");
send({ type: "done", emitted: true });
await settle(true);
controller.close();
+23 -9
View File
@@ -2048,24 +2048,24 @@ export default function Home() {
rectificationOpenInFlight.current = true;
setRectificationLoading(true);
setRectificationError("");
setRectificationPendingQuestion(requestedQuestion);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setRectificationSessionId(rectificationSession.id);
activeSessionIdRef.current = rectificationSession.id;
setActiveSessionId(rectificationSession.id);
try {
if (!existing) {
setSessions((current) => [rectificationSession, ...current.filter((session) => session.id !== rectificationSession.id)]);
await rectificationPersistence.current.enqueue(
rectificationSession.id,
() => persistSession(rectificationSession, "create"),
);
setSessions((current) => [rectificationSession, ...current.filter((session) => session.id !== rectificationSession.id)]);
}
setRectificationPendingQuestion(requestedQuestion);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
setRectificationSessionId(rectificationSession.id);
activeSessionIdRef.current = rectificationSession.id;
setActiveSessionId(rectificationSession.id);
} catch {
setComposerNotice("生时校正已打开,但会话列表暂时未同步到云端。");
setComposerNotice("生时校正会话暂时无法创建,请稍后重试。");
} finally {
rectificationOpenInFlight.current = false;
setRectificationLoading(false);
@@ -2091,6 +2091,15 @@ export default function Home() {
void refreshAccount();
}
function handleRectificationMessagesChange(messages: Message[]) {
if (!rectificationSessionId) return;
updateSession(rectificationSessionId, (session) => ({
...session,
messages,
updatedAt: timestamp(),
}));
}
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) {
if (record.role !== "other") return;
if (synastryPendingId) return;
@@ -2959,9 +2968,14 @@ export default function Home() {
{rectificationSurfaceOpen && (
<ConversationalBirthTimeRectification
key={rectificationSessionId}
sessionId={rectificationSessionId}
initialMessages={activeSession?.messages ?? []}
models={modelCatalog?.models ?? []}
selectedModelId={activeSession?.modelId ?? ""}
onSelectModel={(modelId) => void selectSessionModel(modelId)}
onMessagesChange={handleRectificationMessagesChange}
onCompleted={() => void refreshAccount()}
pendingConsultationQuestion={rectificationPendingQuestion}
onPendingChange={setRectificationMutationPending}
onProfileIncomplete={handleRectificationProfileIncomplete}
@@ -1,12 +1,17 @@
"use client";
import type { PublicLanguageModel } from "../lib/public-models.ts";
import type { ChatMessage } from "../lib/chat-message-view.ts";
import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx";
export type ConversationalBirthTimeRectificationProps = Readonly<{
sessionId: string;
initialMessages: readonly ChatMessage[];
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
pendingConsultationQuestion?: string | null;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
@@ -3,7 +3,7 @@
import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessageView } from "@/lib/chat-message-view";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import { ModelSelector } from "./model-selector";
@@ -11,9 +11,13 @@ import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
type AgenticRectificationChatProps = Readonly<{
sessionId: string;
initialMessages: readonly ChatMessage[];
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
pendingConsultationQuestion?: string | null;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
@@ -31,21 +35,32 @@ type AgenticRectificationRequest = Readonly<
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const {
sessionId,
initialMessages,
models,
selectedModelId,
onSelectModel,
onMessagesChange,
onCompleted,
pendingConsultationQuestion,
onPendingChange,
onProfileIncomplete,
onSaved,
} = props;
const pendingQuestion = pendingConsultationQuestion?.trim();
const [messages, setMessages] = useState<RenderMessage[]>(() => pendingQuestion ? [{
role: "assistant",
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingQuestion}`,
renderKey: "agentic-pending-consultation",
state: "settled",
}] : []);
const [messages, setMessages] = useState<RenderMessage[]>(() => [
...initialMessages.map((message, index) => ({
...message,
renderKey: `agentic-message-${index}`,
state: "settled" as const,
})),
...(initialMessages.length === 0 && pendingQuestion ? [{
role: "assistant" as const,
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingQuestion}`,
renderKey: "agentic-pending-consultation",
state: "settled" as const,
}] : []),
]);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -79,9 +94,14 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
const history = messages
const settledMessages = messages
.filter((message) => message.state === "settled")
.map((message) => ({ role: message.role, text: message.text }));
.map((message) => ({
role: message.role,
text: message.text,
...(message.suggestions ? { suggestions: message.suggestions } : {}),
}));
const history = settledMessages.map((message) => ({ role: message.role, text: message.text }));
const turnKey = keyCounter.current;
const userRenderKey = `agentic-user-${turnKey}`;
const assistantRenderKey = `agentic-assistant-${turnKey}`;
@@ -102,6 +122,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
headers: { "content-type": "application/json" },
body: JSON.stringify({
requestId,
sessionId,
modelId: selectedModelId,
history,
action: request.action,
@@ -111,6 +132,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
if (!response.ok) {
const payload = await response.json().catch(() => null);
const message = payload?.message || payload?.error || `请求失败(${response.status}`;
setMessages((current) => current.filter((item) => item.renderKey !== assistantRenderKey));
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
@@ -121,6 +143,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
return;
}
if (!response.body) {
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
setError("服务暂时不可用,请稍后再试。");
return;
}
@@ -128,6 +151,8 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let completed = false;
let streamFailed = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
@@ -152,16 +177,30 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const saved = raw.match(savedSentinel);
if (saved) setSavedTime(saved[1]);
} else if (event.type === "error") {
streamFailed = true;
setError(event.message || "生时校正暂时不可用,请稍后再试。");
} else if (event.type === "done") {
completed = true;
}
}
}
const parsed = parseAgentReply(raw, "general");
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, state: "settled" }
: message));
setSuggestions(parsed.suggestions);
const succeeded = completed && !streamFailed && Boolean(parsed.text);
setMessages((current) => succeeded
? current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, suggestions: parsed.suggestions, state: "settled" }
: message)
: current.filter((message) => message.renderKey !== assistantRenderKey));
setSuggestions(succeeded ? parsed.suggestions : []);
if (succeeded) {
onMessagesChange?.([
...settledMessages,
...(request.action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text, suggestions: parsed.suggestions },
]);
onCompleted?.();
}
const saved = raw.match(savedSentinel);
if (saved) {
setSavedTime(saved[1]);
@@ -173,13 +212,13 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
} finally {
setPending(false);
}
}, [busy, messages, onProfileIncomplete, onSaved, selectedModelId, setPending]);
}, [busy, messages, onCompleted, onMessagesChange, onProfileIncomplete, onSaved, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (openingStarted.current) return;
if (initialMessages.length > 0 || openingStarted.current) return;
openingStarted.current = true;
void send({ action: "opening" }, false);
}, [send]);
}, [initialMessages.length, send]);
async function submit(event: React.FormEvent) {
event.preventDefault();
@@ -117,19 +117,20 @@ test("a stale v4 mutation refreshes the same case after a 409", () => {
assert.match(hook, /loadRectificationV4\(caseId\)/);
});
test("homepage birth-time card opens its dedicated session before the Agent starts", () => {
test("homepage creates a new dedicated session before the Agent surface starts", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
const create = handler.indexOf('createSession(modelCatalog.defaultModelId, "birth_time_rectification")');
const firstAwait = handler.indexOf("await ");
const persist = handler.indexOf("await rectificationPersistence.current.enqueue");
const addToSessionList = handler.indexOf("setSessions((current) => [", persist);
const reveal = handler.indexOf("setActiveSessionId(rectificationSession.id)");
assert.ok(create >= 0);
assert.ok(firstAwait > create);
assert.ok(reveal > create && reveal < firstAwait);
assert.ok(handler.indexOf("setSessions((current) => [") < firstAwait);
assert.ok(persist > create);
assert.ok(addToSessionList > persist);
assert.ok(reveal > addToSessionList);
assert.match(handler, /rectificationOpenInFlight\.current/);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.doesNotMatch(handler, /onNarrativeDelta|sendConversationalRectificationCommand/);
@@ -114,7 +114,7 @@ test("agentic rectification requests a server-owned opening when the surface mou
assert.match(component, /const openingStarted = useRef\(false\)/);
assert.match(
component,
/useEffect\(\(\) => \{[\s\S]*?openingStarted\.current = true;[\s\S]*?void send\(\{ action: "opening" \}, false\);[\s\S]*?\}, \[send\]\);/,
/useEffect\(\(\) => \{[\s\S]*?initialMessages\.length > 0[\s\S]*?openingStarted\.current = true;[\s\S]*?void send\(\{ action: "opening" \}, false\);[\s\S]*?\}, \[initialMessages\.length, send\]\);/,
);
assert.match(component, /const send = useCallback\(async \(request: AgenticRectificationRequest, showUserMessage = true\) =>/);
assert.match(component, /showUserMessage && request\.action === "message"/);
@@ -70,6 +70,36 @@ test("account rehydration normalizes persisted ISO birth dates before completene
test("agent tool calls leave a final step for visible prose and never end silently", () => {
assert.match(route, /const agenticRectificationMaxSteps = 8/);
assert.match(route, /\{ maxSteps: agenticRectificationMaxSteps \}/);
assert.match(route, /if \(!emitted\) \{[\s\S]*type: "error"[\s\S]*await settle\(false\)[\s\S]*return;/);
assert.match(route, /if \(!emitted \|\| !reply\.text\) \{[\s\S]*type: "error"[\s\S]*await settle\(false\)[\s\S]*return;/);
assert.doesNotMatch(route, /send\(\{ type: "done", emitted \}\)/);
});
test("rectification messages survive remounts and suppress duplicate openings", () => {
assert.match(chat, /initialMessages: readonly ChatMessage\[\]/);
assert.match(chat, /if \(initialMessages\.length > 0 \|\| openingStarted\.current\) return/);
assert.match(chat, /sessionId,/);
assert.match(chat, /onMessagesChange\?\.\(/);
assert.match(page, /key=\{rectificationSessionId\}/);
assert.match(page, /initialMessages=\{activeSession\?\.messages \?\? \[\]\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
});
test("successful Agent turns are persisted by the authenticated rectification route", () => {
assert.match(route, /sessionId: z\.string\(\)\.uuid\(\)/);
assert.match(route, /\.from\("chat_sessions"\)[\s\S]*\.eq\("user_id", userId\)/);
assert.match(route, /parsed\.data\.action === "opening" && persistedMessages\.length > 0/);
assert.match(route, /\.update\(\{ messages: nextMessages, updated_at:/);
assert.match(route, /if \(saveError \|\| !savedSession\) throw new Error\("RectificationSessionPersistenceError"\)/);
});
test("stream failures remove empty assistant placeholders", () => {
assert.match(chat, /streamFailed = true/);
assert.match(chat, /const succeeded = completed && !streamFailed && Boolean\(parsed\.text\)/);
assert.match(chat, /current\.filter\(\(message\) => message\.renderKey !== assistantRenderKey\)/);
assert.match(chat, /if \(succeeded\)/);
});
test("new rectification sessions are created before the Agent surface mounts", () => {
assert.match(page, /await rectificationPersistence\.current\.enqueue[\s\S]*setRectificationSessionId\(rectificationSession\.id\)/);
assert.doesNotMatch(page, /setRectificationSessionId\(rectificationSession\.id\)[\s\S]{0,500}persistSession\(rectificationSession, "create"\)/);
});