fix(rectification): keep one spoken paragraph and strip body questions (BUG-584, BUG-585)
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:
@@ -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:00–05: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:00–05: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:00–05: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:00–05: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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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:00–05: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:00–05: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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user