Merge pull request #32: fix(rectification): hydrate persisted case turns
Restore asynchronously loaded persisted rectification turns and preserve receipt methods.
This commit was merged in pull request #32.
This commit is contained in:
@@ -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
|
||||
- 修复版本:待提交
|
||||
|
||||
@@ -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\}/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user