fix(rectification): persist the next interview after a choice tap
Independent Staging Quality Gate / validate (push) Successful in 13m56s
Independent Staging Quality Gate / publish (push) Successful in 16m46s

Closing a discriminator used to leave GET without a card after refresh.
Write the next dated question in the same request, skip childhood career
and move probes, and do not continue a read-only turn when that question
is already persisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-28 16:30:09 +08:00
co-authored by Cursor
parent 8c75ebb196
commit 68f0759eef
26 changed files with 2455 additions and 311 deletions
+113 -3
View File
@@ -205,6 +205,14 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(
safePublicEvent({ type: "answer.delta", text: "你好", replace: true }),
{ type: "answer.delta", text: "你好", replace: true },
);
assert.deepEqual(
safePublicEvent({ type: "answer.delta", text: "你好", replace: false }),
{ type: "answer.delta", text: "你好" },
);
assert.equal(safePublicEvent({ type: "thinking.delta", text: "先核对升学" }), null);
assert.deepEqual(
safePublicEvent({ type: "activity.changed", activity: "reading_case" }),
@@ -482,6 +490,8 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
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-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("reasoning-start", { id: "r1" }),
chunk("reasoning-delta", { text: "我应该先……" }),
chunk("reasoning-end"),
@@ -495,8 +505,10 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
assert.equal(result.ok, true);
const deltas = emitted.filter((event) => event.type === "answer.delta");
assert.deepEqual(deltas, [
{ type: "answer.delta", text: "好的,先确认一下:" },
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "先确认一下:" },
]);
assert.equal(deltas.map((event) => event.text).join(""), "好的,先确认一下:");
assert.deepEqual(
emitted.filter((event) => event.type === "thinking.delta"),
[],
@@ -671,6 +683,40 @@ test("a length-limited spoken answer is not billed or persisted as a completed t
assert.equal(turnFinalize?.args.p_successful_attempt_id, null);
});
test("length after a stamped open_question still completes without failing the choice card", async () => {
const prompt = "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = 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("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
}),
chunk("text-delta", { text: "实习和离职的时间点都记下了,谢谢。" }),
chunk("finish", { stepResult: { reason: "length" } }),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "接下来看下面这一问。");
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
});
test("answer deltas and tool activity are published before billing settles", async () => {
let billingStarted = false;
const seenBeforeBilling: string[] = [];
@@ -1399,16 +1445,51 @@ test("timeout after a stamped open_question still completes without pasting the
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(result.answerText, "记下了。");
assert.equal(result.answerText, "接下来看下面这一问。");
assert.doesNotMatch(result.answerText, /有没有/);
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "记下了。" }],
[{ type: "answer.delta", text: "接下来看下面这一问。" }],
);
});
test("open_question acknowledgements stream live instead of waiting for flush", async () => {
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
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 } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: { accepted_count: 1, open_question: { prompt } },
}),
chunk("tool-call", { toolName: "rectification-compare-candidates", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-compare-candidates" }),
chunk("text-delta", { text: "好的," }),
chunk("text-delta", { text: "毕业这条也记下了。" }),
chunk("text-delta", { text: "\n\n再问你一件:2016 年前后那场重要的入学考试有没有发生过?" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "好的,毕业这条也记下了。");
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "毕业这条也记下了。" },
],
);
assert.doesNotMatch(JSON.stringify(emitted.filter((event) => event.type === "answer.delta")), /入学考试/);
});
test("persisted choice prompt replaces a competing model follow-up without a topic denylist", async () => {
const spoken = "好的,2020 年 6 月毕业这条也记下了。\n\n再问你一件:2016 年前后那场重要的入学考试,你当时发挥明显失常、或者压力特别大,有没有发生过?";
const prompt = "2023 年前后 · 入职、升职或职责明显加重";
@@ -1480,6 +1561,35 @@ test("persisted choice card owns a matching year-locked follow-up", async () =>
);
});
test("structured-choice acknowledgements are not kept as a second assistant reply", () => {
const prompt = "2012 年 11 月前后,有没有入职、升职或职责明显加重?";
assert.equal(
bindSpokenToOpenQuestion("已记下你刚才的选择,候选比较也随之更新了。", prompt),
"",
);
assert.equal(
bindSpokenToOpenQuestion("已记录你的选择,并更新了候选比较。接下来这一问和家里有关,请看下方选项。", prompt),
"",
);
assert.equal(bindSpokenToOpenQuestion("记下了。", prompt), "记下了。");
});
test("choice-card acknowledgements do not repeat a different-domain event", () => {
const intern = "实习和离职的时间点都记下了,谢谢。";
assert.equal(
bindSpokenToOpenQuestion(intern, "2005 年 5 月前后,有没有搬家、离乡或长期异地?"),
"",
);
assert.equal(
bindSpokenToOpenQuestion(intern, "2023 年 5 月前后,有没有认真关系进入、结束或关系观明显转变?"),
"",
);
assert.equal(
bindSpokenToOpenQuestion("好,实习和离职都记下了。", "2023 年前后 · 入职、升职或职责明显加重"),
"好,实习和离职都记下了。",
);
});
test("lock-only prompt is not spliced into speech", () => {
const lock = "2023 年前后 · 入职、升职或职责明显加重";
assert.equal(bindSpokenToOpenQuestion("", lock), "");