fix(rectification): persist the next interview after a choice tap
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:
@@ -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), "");
|
||||
|
||||
Reference in New Issue
Block a user