fix(rectification): keep one spoken paragraph and strip body questions (BUG-584, BUG-585)
Independent Staging Quality Gate / validate (push) Successful in 9m48s
Independent Staging Quality Gate / publish (push) Successful in 7m33s

Set-focus must not append a second narration, and a server-owned stem must not also appear as a question in the assistant body.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-08 09:05:01 +08:00
co-authored by Cursor
parent 9aec502961
commit 9d1c08cab1
14 changed files with 430 additions and 53 deletions
@@ -14,6 +14,8 @@ import {
stopReasonPrefix,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
/** Task 0 frozen “before” snapshot: accident case f83d9b42, origin/staging @ 8617eb56. */
export const ACCIDENT_CASE_BEFORE_COPY = {
@@ -165,6 +167,33 @@ test("GENERIC_COLLECT_QUESTION is only allowed on opening collect:other focus",
assert.equal(listUserVisibleCopy().some((item) => item.includes(banned)), false);
});
test("settled assistant body with a focus has no question-mark sentences", () => {
const stem = "你大概是哪一年搬的家?";
const linked = attachQuestionsToTurns([{
id: TURN_ID,
role: "assistant",
text: "范围收到 05:0005:10。你大概哪一年搬过家?",
}], [{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:relocation:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "relocation",
targetKind: null,
expectedAnswerSchema: { prompt: stem, collect: true },
status: "active",
askedAt: "2026-09-07T00:00:00.000Z",
resolvedAt: null,
askedTurnId: TURN_ID,
}]);
assert.doesNotMatch(linked[0]?.text ?? "", /[?]/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.collectHandoff));
assert.doesNotMatch(RECTIFICATION_USER_COPY.collectHandoff, /请回答下面的问题/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(agentRun, /stripQuestionSentences/);
});
test("delivery narration defers career windows and the range card replaces minute cards", () => {
const choice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText, stripQuestionSentences } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
test("composeCollectSpokenAssistantText joins by exact prompt identity", () => {
@@ -88,7 +88,36 @@ test("runtime no longer composes the stem into assistant_message", () => {
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /composeCollectSpokenAssistantText/);
assert.match(attach, /detachCollectSpokenAssistantText/);
assert.match(attach, /stripQuestionSentences/);
assert.match(attach, /if \(!focus\.askedTurnId\) continue/);
});
test("stripQuestionSentences drops a rewritten trailing question and its tag", () => {
const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。";
const stem = "你大概是哪一年搬的家?";
assert.equal(stripQuestionSentences(body, stem), "范围已经收到,收在 05:0005:10。");
});
test("stripQuestionSentences drops a mid-body stem and keeps the surrounding sentences", () => {
const stem = "你大概是哪一年搬的家?";
const body = "记下了。你大概是哪一年搬的家?范围还在 05:00–05:10。";
assert.equal(stripQuestionSentences(body, stem), "记下了。范围还在 05:0005:10。");
});
test("stripQuestionSentences returns empty when the body is only a question", () => {
const stem = "你大概是哪一年搬的家?";
assert.equal(stripQuestionSentences("你大概哪一年搬过家?", stem), "");
assert.equal(RECTIFICATION_USER_COPY.collectHandoff.includes("请回答下面的问题"), false);
});
test("attachQuestionsToTurns leaves body unchanged when the turn has no focus", () => {
const body = "范围收到 05:0005:10。你大概哪一年搬过家?";
const turns = attachQuestionsToTurns([{
id: TURN_ID,
role: "assistant" as const,
text: body,
}], []);
assert.equal(turns[0]?.text, body);
assert.equal(turns[0]?.question, null);
});
@@ -132,13 +132,80 @@ test("keeps a live opening greeting when the next public tool is set-focus", ()
).kind,
"none",
);
assert.deepEqual(
// 原值: set-focus 后第二段 live 发布
// 新值: discard
// 原因: BUG-584 决策 2,保留 set-focus 前的问候,丢掉工具结果后的复述
assert.equal(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" }),
isPublicTool,
).kind,
"discard",
);
});
test("set-focus with no prior spoken text still publishes the later greeting", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-result", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"none",
);
assert.deepEqual(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "你好,我们从你最容易想起的经历开始就行。" }),
isPublicTool,
),
{ kind: "live", text: "我们慢慢来就好——想到哪件就聊哪件,不用一次说完。" },
{ kind: "live", text: "你好,我们从你最容易想起的经历开始就行。" },
);
});
test("set-focus publishes an unfinished CJK remainder instead of dropping it", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(
state,
chunk("text-delta", { text: "范围已经收到 05:00 到 05:10" }),
isPublicTool,
).kind,
"none",
);
assert.deepEqual(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
),
{ kind: "publish", pieces: ["范围已经收到 05:00 到 05:10"] },
);
});
test("English planning before set-focus is still discarded", () => {
const state = createStepAnswerState();
assert.equal(
applyStepAnswerChunk(state, chunk("text-delta", { text: "Let me set the next collect focus" }), isPublicTool).kind,
"none",
);
assert.equal(
applyStepAnswerChunk(
state,
chunk("tool-call", { toolName: "rectification-set-focus" }),
isPublicTool,
).kind,
"discard",
);
});
+110 -2
View File
@@ -23,7 +23,7 @@ import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
@@ -393,7 +393,6 @@ test("collect_spoken stem is persisted on the same turn via asked_turn_id, not a
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, greeting);
assert.equal(result.collectSpokenEmitted, false);
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
@@ -677,3 +676,112 @@ test("reply regeneration is a separate Jyotisha agent with only read-case access
assert.match(agentSource, /这不是新一轮校正/);
assert.match(agentSource, /只能使用 rectification-read-case/);
});
test("set-focus after spoken text does not append a second paragraph", async () => {
const first = "2016 年 9 月去北京工作,记下了。范围收到 05:00 到 05:10。";
const second = "2016 年那件事对校正很有帮助,我们再对一下搬家。";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0 }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = runOptions({
accounting: accounting.client,
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("text-delta", { text: first }),
chunk("tool-call", { toolName: "rectification-set-focus" }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: second }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, first);
assert.equal(result.answerText.includes(second), false);
const deltas = emitted.filter((event) => event.type === "answer.delta") as Array<{
text?: string;
replace?: boolean;
}>;
assert.equal(deltas.some((item) => (item.text ?? "").includes(second)), false);
assert.equal(deltas.some((item) => item.replace === true && item.text === ""), false);
});
test("agent-run strips question sentences when this turn owns the focus", async () => {
const body = "范围已经收到,收在 05:00–05:10。你大概哪一年搬过家?有年份就行。";
const stem = "你大概是哪一年搬的家?";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
askedTurnId: TURN_ID,
intent: "collect_method_evidence",
expectedAnswerSchema: { prompt: stem, collect: true },
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = runOptions({
accounting: accounting.client,
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("text-delta", { text: body }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "范围已经收到,收在 05:0005:10。");
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
assert.equal(replaced?.text, "范围已经收到,收在 05:0005:10。");
});
test("agent-run uses collectHandoff when the owned-focus body is only a question", async () => {
const stem = "你大概是哪一年搬的家?";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
askedTurnId: TURN_ID,
intent: "collect_method_evidence",
expectedAnswerSchema: { prompt: stem, collect: true },
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = runOptions({
accounting: accounting.client,
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("text-delta", { text: "你大概哪一年搬过家?" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, RECTIFICATION_USER_COPY.collectHandoff);
});