fix(web): split rectification process talk from the spoken reply

Keep provider thinking off, classify CoT as 思考, and stop remounted sessions from firing a second opening.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-23 10:14:35 +08:00
co-authored by Cursor
parent f699c35f56
commit f03a2706b4
13 changed files with 376 additions and 32 deletions
+5 -2
View File
@@ -68,8 +68,10 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(messageRowSource, /正在处理…/);
assert.match(messageRowSource, /showActivity = message\.state !== "settled"/);
assert.match(messageRowSource, /showThinkingPanel &&/);
assert.match(messageRowSource, /message\.text && \(/);
assert.match(messageRowSource, /showSpokenAnswer/);
assert.match(messageRowSource, /<ChatMessageContent[\s\S]*text=\{message\.text\}[\s\S]*auditRows=\{message\.agentExecutionReceipt\?\.techniqueAuditTable\}/);
assert.match(messageRowSource, /stackedThinkingAndAnswer/);
assert.match(messageRowSource, /aria-label="回复"/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
assert.match(activitySource, /className="agent-activity-status__text"/);
@@ -99,7 +101,7 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(reportSource, /groups=\{progressed\.map/);
assert.doesNotMatch(reportSource, /analysisForSection|splitAnswerByHeadings/);
assert.match(globalStyles, /\.markdown-list/);
assert.match(activitySource, /思考过程/);
assert.match(activitySource, />思考</);
assert.match(pageSource, /event\.type === "run\.failed"/);
assert.match(pageSource, /event\.code === "answer_truncated"/);
assert.match(pageSource, /throw new ConsultationResponseError/);
@@ -109,6 +111,7 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(pageSource, /\.\.\.\(thinkingSections\.length \? \{ thinkingSections \} : \{\}\)/);
assert.match(globalStyles, /\.consultation-report-analysis/);
assert.match(globalStyles, /\.consultation-thinking-report/);
assert.match(globalStyles, /\.consultation-thinking-report \.message-thinking/);
});
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
@@ -83,7 +83,9 @@ test("birth-time rectification entry mounts the V9 case-ref chat", () => {
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(chat, /finalizeRectificationSpokenAndThinking/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
assert.match(component, /methods\?: readonly string\[\]/);
});
@@ -92,11 +94,14 @@ test("opening is server-owned: shouldStartOpening drives the first turn, never c
assert.doesNotMatch(chat, /initialMessages\.length > 0 \|\| openingStarted/);
assert.doesNotMatch(chat, /agenticOpeningInstruction|用户刚进入生时校正会话/);
assert.match(chat, /shouldStartOpening/);
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
assert.match(chat, /if \(readonly \|\| openingStarted\.current \|\| !shouldStartOpening\) return/);
assert.match(chat, /onOpeningConsumed\?\.\(\)/);
assert.match(chat, /void send\("opening", ""\)/);
assert.match(route, /action: z\.enum\(\["opening", "message", "read_only"\]\)/);
assert.match(page, /shouldStartOpening=\{rectificationShouldStartOpening\}/);
assert.match(page, /setRectificationShouldStartOpening\(opened\.shouldStartOpening\)/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
});
test("incomplete profiles stay in the shared onboarding flow before any open request", () => {
@@ -186,9 +191,11 @@ 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\}-\$\{rectificationTurns\.at\(-1\)\?\.id \?\? "loading"\}`\}/);
assert.match(chat, /if \(initialTurns\.length > 0\) \{/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
assert.match(page, /initialTurns=\{rectificationTurns\}/);
assert.match(page, /onMessagesChange=\{handleRectificationMessagesChange\}/);
assert.match(page, /onOpeningConsumed=\{\(\) => setRectificationShouldStartOpening\(false\)\}/);
});
test("the agent route verifies the exact Case/Session binding before any turn", () => {
@@ -229,6 +236,7 @@ test("usage completes or releases without hiding settlement failures", () => {
assert.match(run, /usage_settlement_failed/);
assert.match(run, /thinking: "disabled"/);
assert.match(run, /toPublicThinkingDelta/);
assert.match(run, /splitRectificationSpokenAndThinking/);
assert.match(route, /featureKey: "rectification"/);
assert.match(route, /rectification:case:\$\{caseId\}/);
});
@@ -301,7 +309,7 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
assert.match(chat, /event.type === "thinking.delta"/);
assert.match(activityStatus, /className="message-thinking"/);
assert.match(activityStatus, /思考过程/);
assert.match(activityStatus, />思考</);
assert.match(activityStatus, /userOpen \?\? !hasAnswer/);
assert.match(styles, /\.message-thinking-body/);
});
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
finalizeRectificationSpokenAndThinking,
nextStableChannelDelta,
splitRectificationSpokenAndThinking,
} from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
test("a plain spoken follow-up stays on the answer channel", () => {
const spoken = [
"记下了,大约六岁入学小学。",
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
].join("\n\n");
assert.deepEqual(splitRectificationSpokenAndThinking(spoken), {
thinking: "",
spoken,
});
});
test("process self-talk leaves the spoken conclusion on the answer channel", () => {
const processTalk = [
"用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。",
"但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 yearoccurredFrom 只能按用户原话来。",
"我决定先按六岁入学记下来,再在正文里确认那句晚年份是不是口误。",
"让我调用 batch 写入 education_start。",
].join("\n\n");
const spoken = [
"入学小学这条先按大约六岁记下。你第一句提到的那个很晚的年份,我理解是口误对吗?",
"接下来你大概哪一年上的初中?说个大概年份或范围就行。",
].join("\n\n");
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`), {
thinking: processTalk,
spoken,
});
});
test("English process talk is classified as thinking, not the spoken answer", () => {
const split = splitRectificationSpokenAndThinking([
"The proposedKind value was rejected. Retrying with education_start.",
"记下了,那年九月上大学。",
].join("\n\n"));
assert.match(split.thinking, /proposedKind/);
assert.equal(split.spoken, "记下了,那年九月上大学。");
});
test("stable channel deltas only emit the newly classified suffix", () => {
assert.equal(nextStableChannelDelta("", "先核对升学年份。"), "先核对升学年份。");
assert.equal(
nextStableChannelDelta("先核对升学年份。", "先核对升学年份。\n\n再问初中。"),
"\n\n再问初中。",
);
assert.equal(nextStableChannelDelta("先核对升学年份。", "另一段"), "");
});
test("a lone process paragraph stays in thinking until a spoken conclusion arrives", () => {
const processTalk = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
assert.deepEqual(splitRectificationSpokenAndThinking(`${processTalk}\n\n`), {
thinking: processTalk,
spoken: "",
});
});
test("all-process text keeps a spoken fallback only when the turn is finalized", () => {
const first = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
const last = "但按 skill 规则,日期精度真实保留,不得猜补。";
assert.deepEqual(finalizeRectificationSpokenAndThinking(`${first}\n\n${last}`), {
thinking: first,
spoken: last,
});
});
@@ -530,6 +530,47 @@ test("English tool-retry narration never becomes the spoken answer", async () =>
assert.doesNotMatch(publicText, /invalid_event_kind/);
});
test("Chinese process self-talk after tools is thinking, not the spoken answer", async () => {
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", {
toolName: "rectification-record-evidence-batch",
args: { caseId: CASE_ID, proposedKind: "education_start" },
}),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("text-delta", {
text: "用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。\n\n",
}),
chunk("text-delta", {
text: "但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 year。\n\n",
}),
chunk("text-delta", {
text: "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?",
}),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta").map((event) => event.text).join(""),
"记下了,大约六岁入学小学。接下来你大概哪一年上的初中?",
);
const thinking = emitted
.filter((event) => event.type === "thinking.delta")
.map((event) => event.text)
.join("");
assert.match(thinking, /内部矛盾/);
assert.match(thinking, /datePrecision/);
assert.doesNotMatch(thinking, /哪一年上的初中/);
assert.equal(result.answerText, "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?");
});
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
const pinched = "**先看候选结构(本会话以代表性时间收口";
const accounting = fakeAccounting({