Merge pull request #32: fix(rectification): hydrate persisted case turns
Staging Backend Quality Gate / validate (push) Successful in 11m16s
Staging Backend Quality Gate / publish (push) Successful in 7m33s

Restore asynchronously loaded persisted rectification turns and preserve receipt methods.
This commit was merged in pull request #32.
This commit is contained in:
2026-08-12 11:03:09 +08:00
5 changed files with 50 additions and 23 deletions
+15
View File
@@ -2960,3 +2960,18 @@
- 防复发:明确区分“服务端内部 Agent 工具结果”和“公开 UI/API projection”;Activity 不得从泛化 phase 文案推导技法,不得公开思维链、Prompt、工具参数、出生资料、原始分数、权重、规则 ID 或 Provider metadata。
- 相关记录:BUG-163、BUG-170、BUG-171
- 修复版本:待提交
## BUG-173 | 恢复生时校正会话时历史消息未随异步 Case GET 渲染
- 状态:resolved(本地候选)
- 首次发现:2026-08-12
- 最近更新:2026-08-12
- 影响面:V9 生时校正恢复入口、历史 turns 和持久化 Activity 技法展示。
- 用户现象:点击“继续上次校正”后只看到页面壳层,Case GET 已返回 assistant turn,但消息区为空。
- 触发条件:恢复已有 Case;聊天组件先以空 `initialTurns` 挂载,Case GET 稍后返回历史 turns。
- 根因:`RectificationAgenticChat` 仅在 `useState` 初始化时投影 `initialTurns`,未在异步 props 更新后同步;同时 `page.tsx` 映射持久化 receipt 时漏掉 `methods`
- 修复:复用同一个 turns→messages 投影函数,并让父层在持久化 turns 到达时用最后一个 Turn ID 重挂载该局部聊天组件;补齐 receipt `methods` 的客户端类型和安全映射。
- 验证:新增静态回归检查覆盖异步 hydration 和 methods 映射;部署后以真实登录浏览器确认历史 Agent 消息及 Activity 渲染。
- 防复发:任何异步加载后传入的初始化数据不能只依赖子组件首次 state 初始化;公开 receipt 新字段必须贯通 API projection、page mapping 与组件类型。
- 相关记录:BUG-172
- 修复版本:待提交
+2 -1
View File
@@ -2372,6 +2372,7 @@ export default function Home() {
status: String((turn.receipt as { status?: unknown }).status ?? ""),
phases: Array.isArray((turn.receipt as { phases?: unknown }).phases) ? (turn.receipt as { phases: unknown[] }).phases.map(String) : [],
tools: Array.isArray((turn.receipt as { tools?: unknown }).tools) ? (turn.receipt as { tools: unknown[] }).tools.map(String) : [],
methods: Array.isArray((turn.receipt as { methods?: unknown }).methods) ? (turn.receipt as { methods: unknown[] }).methods.map(String) : [],
skill_name: typeof (turn.receipt as { skill_name?: unknown }).skill_name === "string" ? (turn.receipt as { skill_name: string }).skill_name : undefined,
skill_version: typeof (turn.receipt as { skill_version?: unknown }).skill_version === "string" ? (turn.receipt as { skill_version: string }).skill_version : undefined,
} : null,
@@ -3480,7 +3481,7 @@ export default function Home() {
{rectificationSurfaceOpen && rectificationCaseId && (
<ConversationalBirthTimeRectification
key={`${rectificationSessionId}-${rectificationCaseId}`}
key={`${rectificationSessionId}-${rectificationCaseId}-${rectificationTurns.at(-1)?.id ?? "loading"}`}
caseId={rectificationCaseId}
sessionId={rectificationSessionId ?? ""}
readonly={rectificationReadonly}
@@ -13,6 +13,7 @@ export type PersistedRectificationTurn = Readonly<{
status: string;
phases: readonly string[];
tools: readonly string[];
methods?: readonly string[];
skill_name?: string;
skill_version?: string;
}> | null;
@@ -112,6 +112,28 @@ function activityFromReceipt(receipt: PersistedTurn["receipt"]): ReceiptActivity
};
}
function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] {
return initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
return [{
role: "assistant",
text: turn.text ?? "",
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
receiptActivity: activityFromReceipt(turn.receipt),
receiptStatus: turn.receipt?.status,
}];
}
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
});
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
@@ -131,27 +153,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
onRestart,
} = props;
const [messages, setMessages] = useState<RenderMessage[]>(() =>
initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
return [{
role: "assistant",
text: turn.text ?? "",
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
receiptActivity: activityFromReceipt(turn.receipt),
receiptStatus: turn.receipt?.status,
}];
}
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
}),
);
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -29,6 +29,14 @@ test("birth-time rectification entry mounts the V9 case-ref chat", () => {
assert.match(component, /initialTurns:/);
});
test("persisted rectification turns hydrate after the async Case refresh", () => {
assert.match(chat, /function messagesFromTurns\(initialTurns:/);
assert.match(chat, /useState<RenderMessage\[\]>\(\(\) => messagesFromTurns\(initialTurns\)\)/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
assert.match(component, /methods\?: readonly string\[\]/);
});
test("opening is server-owned: shouldStartOpening drives the first turn, never client history", () => {
assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/);
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
@@ -100,7 +108,7 @@ test("agent tool calls never end silently; the runner owns completion and failur
test("persisted turns survive remounts; duplicate openings are suppressed by the server", () => {
assert.match(chat, /initialTurns/);
assert.match(chat, /const openingStarted = useRef\(false\)/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}`\}/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
});