\{renderProse\(report, renderMarkdown\)\}<\/div>/);
+ // 原值: `{renderProse(report, renderMarkdown)}` 每次重渲全量 parse。
+ // 新值: 报告层走 `StableMarkdownPrefix`。
+ // 原因: BUG-725 结算态 Markdown 按文本记忆化。
+ assert.match(contentSource, /
\s*
/);
assert.match(contentSource, /\{foldedAudit\}/);
assert.match(contentSource, /const reportBody = !streaming && report/);
assert.match(contentSource, /AnswerDetailDisclosure headings=\{layers\.headings\} streaming=\{streaming\}/);
diff --git a/frontend/tests/chat-bundle-splitting-contract.test.ts b/frontend/tests/chat-bundle-splitting-contract.test.ts
index aeef191a..280459e7 100644
--- a/frontend/tests/chat-bundle-splitting-contract.test.ts
+++ b/frontend/tests/chat-bundle-splitting-contract.test.ts
@@ -57,15 +57,16 @@ test("the pre-markdown fallback renders prose but never raw markdown", () => {
// 原因: 口语层与报告层共用 renderProse,plainParagraphs 只在它内部调用一次。
assert.match(contentSource, /\(plainParagraphs\(text\) \?\? \[\]\)\.map/);
- // 两层必须都走 renderProse,不得一层 markdown 一层裸文本。
- // 口语层若改成 {spoken} / renderMarkdown(spoken)、报告层仍走 renderProse(或反过来),
- // 下面的精确计数会从 2 掉到 1。不得改成 >= 1。
- assert.match(contentSource, /renderProse\(spoken,\s*renderMarkdown\)/);
- assert.match(contentSource, /renderProse\(report,\s*renderMarkdown\)/);
+ // 原值: 口语层与报告层都直接 `renderProse(spoken/report, renderMarkdown)`,计数为 2。
+ // 新值: 结算两层都走 `StableMarkdownPrefix`;流式尾块仍走 `renderProse(split.tail)`。
+ // 原因: BUG-725 结算态按文本记忆化。计数仍锁死为 2,不得放宽成 >= 1。
+ assert.match(contentSource, /
/);
+ assert.match(contentSource, /
/);
assert.equal(
- contentSource.match(/renderProse\((?:spoken|report),\s*renderMarkdown\)/g)?.length,
+ contentSource.match(/
/g)?.length,
2,
);
+ assert.match(contentSource, /renderProse\(split\.tail, renderMarkdown\)/);
assert.doesNotMatch(contentSource, /\{renderMarkdown\((?:spoken|report)\)\}/);
assert.doesNotMatch(contentSource, />\{(?:spoken|report)\}<\/div>/);
});
diff --git a/frontend/tests/chat-composer-queue.test.ts b/frontend/tests/chat-composer-queue.test.ts
index 2ab791a6..2e2b9ac2 100644
--- a/frontend/tests/chat-composer-queue.test.ts
+++ b/frontend/tests/chat-composer-queue.test.ts
@@ -100,7 +100,11 @@ test("rectification abort settles as stopped, not a failed alert", () => {
assert.match(rectification, /stopped: true/);
assert.match(rectification, /failed: false,\s*stopped: true/);
assert.doesNotMatch(rectification, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
- assert.match(rectification, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
+ // 原值: 停止提示 prop 写在 rectification-agentic-chat.tsx。
+ // 新值: 同一表达式在行组件内。
+ // 原因: BUG-725。
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.match(messageRow, /stoppedNotice/);
assert.match(messageRow, /className="message-stopped-notice"/);
assert.equal(RECTIFICATION_STOPPED_NOTICE, "已停止,已生成的内容保留;本次不会扣点。");
diff --git a/frontend/tests/chat-markdown-split.test.ts b/frontend/tests/chat-markdown-split.test.ts
index 0402e56b..c9d3c338 100644
--- a/frontend/tests/chat-markdown-split.test.ts
+++ b/frontend/tests/chat-markdown-split.test.ts
@@ -4,7 +4,7 @@ import test from "node:test";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
-import { StreamingMarkdown } from "../src/components/chat-message-content.tsx";
+import { StableMarkdownPrefix, StreamingMarkdown } from "../src/components/chat-message-content.tsx";
import { splitStableMarkdown } from "../src/lib/chat-markdown-split.ts";
const contentSource = readFileSync(new URL("../src/components/chat-message-content.tsx", import.meta.url), "utf8");
@@ -68,9 +68,22 @@ test("the streaming renderer parses the prefix and the tail as two separate docu
assert.match(contentSource, /const StableMarkdownPrefix = memo\(function StableMarkdownPrefix/);
assert.match(contentSource, /splitStableMarkdown\(text\)/);
assert.match(contentSource, /streaming\s*\?\s*
/);
assert.match(messageRowSource, /streaming=\{message\.state !== "settled"\}/);
});
+
+test("settled markdown parses the same text only once across consecutive renders", () => {
+ const calls: string[] = [];
+ const renderMarkdown = (text: string) => {
+ calls.push(text);
+ return createElement("p", null, text);
+ };
+ const text = "已结算的一段回答,对照了 D9 和 D10。";
+ renderToString(createElement(StableMarkdownPrefix, { text, renderMarkdown }));
+ renderToString(createElement(StableMarkdownPrefix, { text, renderMarkdown }));
+ assert.equal(calls.length, 1);
+ assert.deepEqual(calls, [text]);
+});
diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts
index 3bf0cae7..dfb12044 100644
--- a/frontend/tests/chat-stream-layout.test.ts
+++ b/frontend/tests/chat-stream-layout.test.ts
@@ -189,7 +189,15 @@ test("ordinary consultation replies reuse the shared Agent action bar", () => {
assert.doesNotMatch(rectificationChat, /event\.type === "thinking\.delta"/);
assert.match(rectificationChat, /startActivityTraceStep/);
assert.match(rectificationChat, /completeActivityTraceStep/);
- assert.match(rectificationChat, /
{
diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts
index 8a78bf63..f46850fc 100644
--- a/frontend/tests/rectification-agentic-entry.test.ts
+++ b/frontend/tests/rectification-agentic-entry.test.ts
@@ -12,6 +12,10 @@ const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
+const messageEntry = readFileSync(
+ new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
+ "utf8",
+);
const activityStatus = readFileSync(
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
"utf8",
@@ -352,10 +356,13 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
assert.match(activityHelper, /filter\(isPublicRectificationTool\)/);
assert.match(activityHelper, /receipt\.methods/);
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
- assert.match(chat, /completedReceipt\?: CompletedActivityReceiptView/);
- assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
- assert.match(chat, /vargaSentenceFromMethods/);
- assert.match(chat, /vargaSentence=\{vargaSentence\}/);
+ // 原值: 上述 token 都在 rectification-agentic-chat.tsx 的 messages.map 内。
+ // 新值: RenderMessage / 时间轴 / varga 句随行组件抽出。
+ // 原因: BUG-725 已结算行记忆化,派生不得留在容器每帧计算。
+ assert.match(messageEntry, /completedReceipt\?: CompletedActivityReceiptView/);
+ assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
+ assert.match(messageEntry, /vargaSentenceFromMethods/);
+ assert.match(messageEntry, /vargaSentence=\{vargaSentence\}/);
assert.match(chat, /activityTraceFromReceipt/);
assert.match(activityStatus, /vargaSentence/);
assert.match(activityStatus, /VargaTraceStep/);
@@ -375,9 +382,14 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
- const replyIndex = messageRender.indexOf("= 0);
+ assert.match(messageRender, /`, never open, the "本轮完成 · N 个
// 步骤" summary): that was a second collapsed block under every settled rectification reply.
// Its methods now sit as source chips on the shared timeline's calculate row (BUG-476).
@@ -397,7 +409,10 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
assert.match(chat, /completeActivityTraceStep/);
// Former lock: `activityStatus` contained `activityTrace` — the rectification-only trace panel.
// The trace is now projected onto the shared timeline rows (BUG-476); the panel is a fallback.
- assert.match(chat, /rectificationTimelineRows/);
+ // 原值: chat 源码直接调用 rectificationTimelineRows。
+ // 新值: 调用点在行组件内。
+ // 原因: BUG-725 容器不再每帧派生时间轴行。
+ assert.match(messageEntry, /rectificationTimelineRows/);
assert.doesNotMatch(activityStatus, /activityTrace/);
assert.match(activityStatus, /className="message-thinking"/);
assert.match(activityStatus, />思考);
@@ -409,7 +424,10 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(messageActions, new RegExp(`aria-label="${label}"`));
}
- assert.match(chat, / {"),
chat.indexOf("{savedTime &&"),
);
- const replyIndex = messageRender.indexOf("= 0 && actionsIndex >= 0);
assert.ok(replyIndex < actionsIndex);
assert.doesNotMatch(messageRender, / 0 || displayMinutes.length > 0"),
);
- const rowIndex = messageLoop.indexOf("= 0);
@@ -582,12 +604,18 @@ test("rectification composer can stop a live agent run", () => {
// 新值:isAbortError(caught)
// 原因:选择题与采用路径共用同一中止判定(BUG-552)
assert.match(chat, /isAbortError\(caught\)/);
- assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
+ // 原值: 容器 map 写 `showActivity={displayedMessage.state !== "settled"}`。
+ // 新值: 同一表达式在行组件内。
+ // 原因: BUG-725。
+ assert.match(messageEntry, /showActivity=\{displayedMessage\.state !== "settled"\}/);
});
test("aborting a live run marks the bubble stopped without an alert", () => {
assert.match(chat, /failed: false,\s*stopped: true/);
- assert.match(chat, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
+ // 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
+ // 新值: 同一表达式在行组件内。
+ // 原因: BUG-725。
+ assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /role="alert"[\s\S]{0,80}RECTIFICATION_STOPPED_NOTICE/);
const choiceFetch = chat.slice(chat.indexOf("const submitStructuredChoice"), chat.indexOf("const acceptCandidate"));
assert.match(choiceFetch, /runAbort\.current = abortController/);
@@ -609,9 +637,13 @@ test("time-selection cards use server adoption state and stay mutually exclusive
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
);
- const actionsIndex = messageLoop.indexOf("= 0 && cardsIndex > actionsIndex);
+ assert.ok(entryIndex >= 0 && cardsIndex > entryIndex);
+ assert.match(messageEntry, / {
assert.doesNotMatch(wrap, /rectification-collect-stop/);
assert.doesNotMatch(wrap, /rectification-composer-meta/);
assert.doesNotMatch(wrap, /rectification-adopt-status/);
- assert.match(chat, /CHOICE_STOP_LABEL/);
+ // 原值: 容器源码含 CHOICE_STOP_LABEL(嵌入卡 stop 文案)。
+ // 新值: 常量在行组件的 choiceCardFromQuestion 里;composer-wrap 仍不得托管。
+ // 原因: BUG-725。
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ assert.match(messageEntry, /CHOICE_STOP_LABEL/);
assert.match(chat, /postAdoptVerifyDoneCopy/);
});
diff --git a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts
index d6ed76cc..8d48b380 100644
--- a/frontend/tests/rectification-exhaustion-exit-20260906.test.ts
+++ b/frontend/tests/rectification-exhaustion-exit-20260906.test.ts
@@ -636,7 +636,11 @@ test("collect spoken stop stays on choice cards; range line is status only", ()
);
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
assert.doesNotMatch(wrap, /rectification-collect-stop/);
- assert.match(chat, /CHOICE_STOP_LABEL/);
+ // 原值: 容器源码含 CHOICE_STOP_LABEL。
+ // 新值: 常量在行组件;容器仍有 submitStop,composer-wrap 仍不得托管。
+ // 原因: BUG-725。
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ assert.match(messageEntry, /CHOICE_STOP_LABEL/);
assert.match(chat, /kind === "collect_spoken"/);
assert.match(chat, /function submitStop/);
assert.match(chat, /RectificationReadonlyRange/);
diff --git a/frontend/tests/rectification-question-in-message.test.ts b/frontend/tests/rectification-question-in-message.test.ts
index 6333e8c8..d72e7851 100644
--- a/frontend/tests/rectification-question-in-message.test.ts
+++ b/frontend/tests/rectification-question-in-message.test.ts
@@ -135,9 +135,13 @@ test("copy text is body, blank line, stem, then option lines", () => {
test("one assistant article owns the stem and options; the slot class is gone", () => {
assert.match(row, /afterAnswer/);
- assert.match(chat, /afterAnswer=\{afterAnswer\}/);
+ // 原值: 容器源码含 `afterAnswer={afterAnswer}`。
+ // 新值: 行组件内部构造 afterAnswer;题干类名与 embedded 变体仍在对话面。
+ // 原因: BUG-725。
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
- assert.match(chat, /variant="embedded"/);
+ assert.match(messageEntry, /variant="embedded"/);
assert.match(chat, /function markQuestionAnswered/);
assert.match(chat, /"typed"/);
assert.doesNotMatch(chat, /rectification-question-slot/);
diff --git a/frontend/tests/rectification-settled-render-split.test.ts b/frontend/tests/rectification-settled-render-split.test.ts
new file mode 100644
index 00000000..767a049a
--- /dev/null
+++ b/frontend/tests/rectification-settled-render-split.test.ts
@@ -0,0 +1,160 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { createElement, createRef } from "react";
+import { renderToString } from "react-dom/server";
+import test from "node:test";
+
+import type { ChatMessageFeedback } from "../src/components/chat-message-actions.tsx";
+import {
+ RectificationMessageEntry,
+ UnsplitRectificationMessageList,
+ type RectificationMessageActions,
+ type RectificationMessageEntryProps,
+ type RenderMessage,
+} from "../src/components/rectification-message-entry.tsx";
+import {
+ disableHomeStreamingRenderProbe,
+ enableHomeStreamingRenderProbe,
+ homeStreamingRenderProbeSnapshot,
+ resetHomeStreamingRenderProbe,
+} from "../src/lib/home-streaming-render-probe.ts";
+
+const chatSource = readFileSync(
+ new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
+ "utf8",
+);
+const entrySource = readFileSync(
+ new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
+ "utf8",
+);
+const rowSource = readFileSync(
+ new URL("../src/components/chat-message-row.tsx", import.meta.url),
+ "utf8",
+);
+
+function settled(role: "user" | "assistant", text: string, key: string): RenderMessage {
+ return {
+ role,
+ text,
+ renderKey: key,
+ state: "settled",
+ };
+}
+
+function liveAssistant(text: string): RenderMessage {
+ return {
+ role: "assistant",
+ text,
+ renderKey: "live-assistant",
+ state: "streaming",
+ };
+}
+
+function emptyActions(): RectificationMessageEntryProps["actionsRef"] {
+ const actionsRef = createRef() as RectificationMessageEntryProps["actionsRef"];
+ actionsRef.current = {
+ submitChoice() {},
+ submitStop() {},
+ copyMessage() {},
+ regenerateMessage() {},
+ onFeedback() {},
+ };
+ return actionsRef;
+}
+
+function sharedProps(): Omit<
+ RectificationMessageEntryProps,
+ "message" | "regenerating" | "canRegenerate" | "copied" | "feedback"
+> {
+ return {
+ busy: true,
+ readonly: false,
+ currentQuestionFocusId: null,
+ interactive: false,
+ liveChoiceCard: null,
+ choiceNonce: 0,
+ savedTime: null,
+ actionsRef: emptyActions(),
+ };
+}
+
+function entryProps(
+ message: RenderMessage,
+ extras: Partial> = {},
+): RectificationMessageEntryProps {
+ return {
+ ...sharedProps(),
+ message,
+ regenerating: extras.regenerating ?? false,
+ canRegenerate: extras.canRegenerate ?? false,
+ copied: extras.copied ?? false,
+ feedback: extras.feedback,
+ };
+}
+
+test("the chat container no longer derives per-row timeline, varga, or choice cards", () => {
+ assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
+ assert.doesNotMatch(chatSource, /vargaSentenceFromMethods\s*\(/);
+ assert.doesNotMatch(chatSource, /choiceCardFromQuestion\s*\(/);
+ assert.match(entrySource, /rectificationTimelineRows\s*\(/);
+ assert.match(entrySource, /vargaSentenceFromMethods\s*\(/);
+ assert.match(entrySource, /choiceCardFromQuestion\s*\(/);
+});
+
+test("the extracted row is memoised and takes no function props except actionsRef", () => {
+ assert.match(entrySource, /export const RectificationMessageEntry = memo\(function RectificationMessageEntry/);
+ const propsBlock = entrySource.slice(
+ entrySource.indexOf("export type RectificationMessageEntryProps"),
+ entrySource.indexOf("export function choiceCardFromQuestion"),
+ );
+ assert.match(propsBlock, /actionsRef: MutableRefObject/);
+ assert.doesNotMatch(propsBlock, /=>/);
+ assert.doesNotMatch(propsBlock, /on[A-Z]\w+\?:/);
+ assert.match(rowSource, /export const ChatMessageRow = memo\(function ChatMessageRow/);
+});
+
+test("the split architecture renders settled rectification rows once while streaming tokens", () => {
+ const history: RenderMessage[] = [
+ settled("user", "第一件经历", "u1"),
+ settled("assistant", "记下了第一件。", "a1"),
+ settled("user", "第二件经历", "u2"),
+ settled("assistant", "记下了第二件。", "a2"),
+ ];
+ const tokens = ["甲", "甲乙", "甲乙丙", "甲乙丙丁", "甲乙丙丁戊"];
+ const shared = sharedProps();
+ const feedbackByKey: Readonly> = {};
+
+ resetHomeStreamingRenderProbe();
+ enableHomeStreamingRenderProbe();
+ for (const message of history) {
+ renderToString(createElement(RectificationMessageEntry, entryProps(message)));
+ }
+ for (const streamingText of tokens) {
+ renderToString(createElement(RectificationMessageEntry, entryProps(liveAssistant(streamingText))));
+ }
+ const split = homeStreamingRenderProbeSnapshot();
+ disableHomeStreamingRenderProbe();
+
+ resetHomeStreamingRenderProbe();
+ enableHomeStreamingRenderProbe();
+ for (const streamingText of tokens) {
+ renderToString(createElement(UnsplitRectificationMessageList, {
+ ...shared,
+ messages: [...history, liveAssistant(streamingText)],
+ latestRegeneratableKey: "a2",
+ copiedMessageKey: null,
+ feedbackByKey,
+ regeneratingMessageKey: null,
+ }));
+ }
+ const unsplit = homeStreamingRenderProbeSnapshot();
+ disableHomeStreamingRenderProbe();
+
+ assert.equal(split.settledRowRenders, history.length);
+ assert.ok(split.streamingRowRenders <= tokens.length);
+ assert.ok(split.streamingRowRenders >= 1);
+ assert.equal(unsplit.unsplitListRenders, tokens.length);
+ // Unsplit counts every row on every frame, including the live one.
+ assert.equal(unsplit.settledRowRenders, (history.length + 1) * tokens.length);
+ assert.ok(unsplit.settledRowRenders > split.settledRowRenders);
+});
diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts
index 67390028..c94f6cd1 100644
--- a/frontend/tests/rectification-spoken-collect.test.ts
+++ b/frontend/tests/rectification-spoken-collect.test.ts
@@ -126,9 +126,13 @@ test("cases current_question remains the submit contract, not a visual slot", ()
assert.doesNotMatch(wrap, /rectification-collect-reply/);
assert.doesNotMatch(wrap, /send\("message", "没有"\)/);
assert.doesNotMatch(wrap, /rectification-step-state/);
- const afterAnswer = chat.slice(
- chat.indexOf("const afterAnswer = question"),
- chat.indexOf("key={message.renderKey}"),
+ // 原值: 切片容器 map 里的 `const afterAnswer = question`。
+ // 新值: afterAnswer 在行组件内构造。
+ // 原因: BUG-725。
+ const messageEntryForReplies = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ const afterAnswer = messageEntryForReplies.slice(
+ messageEntryForReplies.indexOf("const afterAnswer = question"),
+ messageEntryForReplies.indexOf("return ("),
);
assert.doesNotMatch(afterAnswer, /CollectSpokenReplies/);
assert.doesNotMatch(afterAnswer, /shouldShowCollectSpokenReplies/);
@@ -155,7 +159,11 @@ test("collect_spoken stem lives on turn.question inside the same assistant artic
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /parseTurnQuestion\(turn\.question\)/);
assert.match(chat, /function markQuestionAnswered/);
- assert.match(chat, /afterAnswer=\{afterAnswer\}/);
+ // 原值: 容器把 afterAnswer 作为 ChatMessageRow prop。
+ // 新值: 行组件内部构造 afterAnswer 再传给 ChatMessageRow。
+ // 原因: BUG-725。
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
assert.match(chat, /rectification-message-question/);
assert.match(row, /afterAnswer/);
assert.doesNotMatch(chat, /composeCollectSpokenAssistantText/);
@@ -212,18 +220,26 @@ test("missing current_question is explicit only for resumable cases", () => {
test("choice options render inside the same assistant message, not a sibling slot", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
const render = chat.slice(
chat.indexOf("{messages.map((message) => {") ,
chat.indexOf("{savedTime &&"),
);
assert.match(chat, /showLiveChoiceCard/);
+ // 原值: 嵌入卡、afterAnswer、onSelect={submitChoice} 都在容器 map 里。
+ // 新值: 嵌入卡与 afterAnswer 在行组件内,经 actionsRef 提交;容器的 persisted 题卡仍直接绑 submitChoice。
+ // 原因: BUG-725 已结算行记忆化,回调不得当 props。
+ assert.match(messageEntry, / {
diff --git a/frontend/tests/rectification-surface-contract.test.ts b/frontend/tests/rectification-surface-contract.test.ts
index a4190f4e..bdb99c3a 100644
--- a/frontend/tests/rectification-surface-contract.test.ts
+++ b/frontend/tests/rectification-surface-contract.test.ts
@@ -6,6 +6,7 @@ import { homeSurface as page } from "./home-surface.ts";
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
const chat = read("../src/components/rectification-agentic-chat.tsx");
+const messageEntry = read("../src/components/rectification-message-entry.tsx");
const hook = read("../src/hooks/use-rectification-surface.ts");
const sessions = read("../src/hooks/use-session-management.ts");
const bootstrap = read("../src/lib/home-bootstrap.ts");
@@ -126,7 +127,10 @@ test("stopping keeps what streamed and says so; a 402 explains itself before lea
// 新值:气泡 stopped + 灰字 RECTIFICATION_STOPPED_NOTICE,error-message 只留给真正失败
// 原因:停止是用户动作,不能穿报错衣服(BUG-552)
assert.match(chat, /failed: false,\s*stopped: true/);
- assert.match(chat, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
+ // 原值: 停止提示 prop 写在容器 map 的 ChatMessageRow 上。
+ // 新值: 同一表达式在行组件内。
+ // 原因: BUG-725。
+ assert.match(messageEntry, /stoppedNotice=\{message\.stopped \? RECTIFICATION_STOPPED_NOTICE : undefined\}/);
assert.doesNotMatch(chat, /if \(raw\.trim\(\)\) setError\(RECTIFICATION_STOPPED_NOTICE\)/);
assert.match(chat, /inputDisabled=\{readonly\}/);
assert.match(chat, /setError\(RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE\);[\s\S]*onOpenBilling\?\.\(\{ source: "rectification" \}\);/);
diff --git a/frontend/tests/rectification-targeted-card-live-20260913.test.ts b/frontend/tests/rectification-targeted-card-live-20260913.test.ts
index 722c39d5..5cdf2aba 100644
--- a/frontend/tests/rectification-targeted-card-live-20260913.test.ts
+++ b/frontend/tests/rectification-targeted-card-live-20260913.test.ts
@@ -174,5 +174,9 @@ test("a choice question without a GET card is the repair path, not collect waiti
assert.match(chat, /interviewChoiceCardUnavailable/);
assert.match(chat, /questionSource === "unavailable" \|\| deadChoice/);
assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/);
- assert.match(chat, /unansweredDeadChoice/);
+ // 原值: 容器 map 内有 `unansweredDeadChoice`。
+ // 新值: 同一判定在行组件内。
+ // 原因: BUG-725。
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ assert.match(messageEntry, /unansweredDeadChoice/);
});
diff --git a/frontend/tests/rectification-timeline-adapter.test.ts b/frontend/tests/rectification-timeline-adapter.test.ts
index fe075e96..67beb06e 100644
--- a/frontend/tests/rectification-timeline-adapter.test.ts
+++ b/frontend/tests/rectification-timeline-adapter.test.ts
@@ -17,6 +17,7 @@ import { PUBLIC_RECTIFICATION_METHODS } from "../src/lib/rectification-agentic/v
const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
const chatSource = read("src/components/rectification-agentic-chat.tsx");
+const entrySource = read("src/components/rectification-message-entry.tsx");
const rowSource = read("src/components/chat-message-row.tsx");
const activitySource = read("src/components/agent-activity-status.tsx");
const packageJson = read("package.json");
@@ -156,8 +157,12 @@ test("after attempt.reset the timeline is empty, and a settled reply without a r
});
test("both chat surfaces render one timeline, one live marker, and no second receipt block", () => {
- assert.match(chatSource, /rectificationTimelineRows\(\{/);
- assert.match(chatSource, /timeline: rectificationTimelineRows/);
+ // 原值: 容器源码直接 `timeline: rectificationTimelineRows(...)`。
+ // 新值: 同一调用在行组件内;容器不得再每帧派生。
+ // 原因: BUG-725 已结算行记忆化。
+ assert.match(entrySource, /rectificationTimelineRows\(\{/);
+ assert.match(entrySource, /timeline: rectificationTimelineRows/);
+ assert.doesNotMatch(chatSource, /rectificationTimelineRows\s*\(/);
assert.doesNotMatch(chatSource, /rectification-activity-failure|\{props\.card\.prompt\}<\/legend>/);
assert.match(card, /variant === "embedded"/);
assert.match(chat, /rectification-message-question__prompt/);
+ // 原值: 容器源码含 variant="embedded"(消息内嵌卡)。
+ // 新值: 消息内嵌卡在行组件;容器 persisted 题卡仍是 embedded。
+ // 原因: BUG-725。
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ assert.match(messageEntry, /variant="embedded"/);
assert.match(chat, /variant="embedded"/);
// 原值: className="rectification-choice-why"(引擎作题简报)
// 新值: className="rectification-choice-why-user"(折叠「为什么问这题」)
diff --git a/frontend/tests/rectification-walkthrough-polish.test.ts b/frontend/tests/rectification-walkthrough-polish.test.ts
index 1b706549..8467ae13 100644
--- a/frontend/tests/rectification-walkthrough-polish.test.ts
+++ b/frontend/tests/rectification-walkthrough-polish.test.ts
@@ -423,7 +423,11 @@ test("agent instructions do not assert that the next question is already on scre
assert.doesNotMatch(agent, /自然过渡到界面上的下一步/);
assert.match(chat, /await loadCaseSnapshot\(\)/);
assert.match(chat, /mergeTurnQuestions/);
- assert.match(chat, /afterAnswer=\{afterAnswer\}/);
+ // 原值: 容器源码含 `afterAnswer={afterAnswer}`。
+ // 新值: 行组件内部构造 afterAnswer。
+ // 原因: BUG-725。
+ const messageEntry = readFileSync(new URL("../src/components/rectification-message-entry.tsx", import.meta.url), "utf8");
+ assert.match(messageEntry, /afterAnswer=\{afterAnswer\}/);
assert.doesNotMatch(chat, /isLatestMessage/);
});