fix(rectification): drop duplicate collect cards and false run_failed
Independent Staging Quality Gate / validate (push) Successful in 10m23s
Independent Staging Quality Gate / publish (push) Failing after 8m59s

Spoken collect no longer renders a second visual prompt; choice legends stay screen-reader only and live cards share the assistant inset. Exhaustion collect avoids colliding with the opening question id, and a successful billed turn no longer surfaces run_failed after the exit gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-01 15:19:00 +08:00
co-authored by Cursor
parent e404b6f42b
commit 75fc456d7e
13 changed files with 365 additions and 72 deletions
@@ -661,9 +661,9 @@ test("the Agent prompt cannot offer candidates while asking for more evidence",
assert.doesNotMatch(tools, /offer_selection/);
});
test("choice cards render the persisted prompt as the visible question stem", () => {
assert.match(choiceCardComponent, /<legend>\{props\.card\.prompt\}<\/legend>/);
assert.doesNotMatch(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
test("choice cards expose the persisted prompt only to assistive tech", () => {
assert.match(choiceCardComponent, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
assert.doesNotMatch(choiceCardComponent, /<legend>\{props\.card\.prompt\}<\/legend>/);
});
test("choice card answers and stop share one stacked primary list", () => {
@@ -864,7 +864,7 @@ test("duplicate collect focus reloads the active question instead of returning n
expectedAnswerSchema: { collect: true, prompt: "你长期做什么工作?" },
});
assert.equal(currentQuestion?.question_id, "collect:occupation:collect_method_evidence");
assert.equal(accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, 1);
assert.equal(accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus").length, 2);
});
test("skipped collect focus reloads once and retries persistence", async () => {
@@ -29,6 +29,7 @@ import {
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { informationGainAmongActive } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
@@ -619,3 +620,48 @@ test("idle persist still decides from the dossier once and does not invent colle
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
assert.match(idle, /persistExhaustionCollect/);
});
function datedCollectEvidence(domain: string, year: string, extra: { eventKind?: string } = {}) {
return {
status: "confirmed" as const,
domain,
datePrecision: extra.eventKind ? "unknown" as const : "year" as const,
occurredFrom: extra.eventKind ? null : `${year}-01-01`,
occurredTo: null,
...(extra.eventKind ? { eventKind: extra.eventKind } : {}),
};
}
test("exhaustion after family declined and dated domains confirmed asks occupation", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
assert.equal(next?.domain, "occupation");
assert.equal(next?.choice_frame, null);
assert.equal(spokenFollowupForUser(next), "你长期做什么工作?");
});
test("exhaustion generic fallback uses domain other, not unknown", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
datedCollectEvidence("relocation", "2022"),
datedCollectEvidence("health_pressure", "2021"),
datedCollectEvidence("occupation", "2020", { eventKind: "occupation_note" }),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
assert.equal(next?.domain, "other");
assert.equal(next?.intent, "collect_method_evidence");
assert.equal(stableFollowupQuestionId(next), "collect:other:collect_method_evidence");
assert.equal(spokenFollowupForUser(next), "可以再说一件记得大概时间的经历。");
});
@@ -3,7 +3,9 @@ import test from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
COLLECT_FOCUS_RETRY_SUFFIX,
openQuestionFromPersistedFocus,
persistableFocusDomain,
persistServerOwnedFocus,
shouldSkipDiscriminatorFollowup,
stableFollowupQuestionId,
@@ -599,3 +601,123 @@ test("degraded spoken collect does not keep discriminator identity or block a la
assert.ok(open);
assert.notEqual(open?.unrenderable, true);
});
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "relationship",
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
...overrides,
};
}
function focusRowFromArgs(args: Record<string, unknown>) {
return {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-31T00:00:00.000Z",
resolved_at: null,
idempotent: false,
};
}
test("collect persist maps occupation to other and health_pressure to health", async () => {
assert.equal(persistableFocusDomain("occupation"), "other");
assert.equal(persistableFocusDomain("health_pressure"), "health");
assert.equal(persistableFocusDomain("education"), "education");
assert.equal(persistableFocusDomain("horary"), "horary");
assert.equal(persistableFocusDomain(null), null);
assert.equal(persistableFocusDomain("unknown"), null);
const occupation = collectFollowup({
method_id: "occupation",
ask_theme: "occupation",
domain: "occupation",
kind_hint: "occupation_note",
});
assert.equal(stableFollowupQuestionId(occupation), "collect:occupation:collect_method_evidence");
const health = collectFollowup({
method_id: "d30_health",
ask_theme: "health_pressure",
domain: "health_pressure",
});
const generic = collectFollowup({ domain: "other" });
assert.equal(stableFollowupQuestionId(generic), "collect:other:collect_method_evidence");
assert.doesNotMatch(stableFollowupQuestionId(generic), /unknown/);
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => focusRowFromArgs(args),
});
const occupationPersisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: occupation,
});
const healthPersisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: health,
});
const occupationWrite = accounting.calls.find((item) => (
item.fn === "set_agentic_rectification_conversation_focus"
&& String(item.args.p_question_id).startsWith("collect:occupation:")
));
const healthWrite = accounting.calls.find((item) => (
item.fn === "set_agentic_rectification_conversation_focus"
&& String(item.args.p_question_id).startsWith("collect:health_pressure:")
));
assert.equal(occupationPersisted.status, "created");
assert.equal(healthPersisted.status, "created");
assert.equal(occupationWrite?.args.p_target_domain, "other");
assert.equal(healthWrite?.args.p_target_domain, "health");
});
test("collect focus unique conflict retries with a :next question id", async () => {
const followup = collectFollowup();
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
if (writes === 1) throw new Error("agentic_rectification_focus_idempotency_conflict");
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup,
});
assert.equal(persisted.status, "created");
assert.equal(writes, 2);
assert.equal(
persisted.questionId,
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
);
const retryWrite = accounting.calls.at(-1);
assert.equal(
retryWrite?.args.p_question_id,
`collect:relationship:collect_method_evidence:${COLLECT_FOCUS_RETRY_SUFFIX}`,
);
});
@@ -102,14 +102,21 @@ test("cases current_question drives the unified question slot", () => {
assert.equal(parseRectificationChoiceCard(COLLECT_GET_QUESTION), null);
});
test("collect_spoken current_question shows the server prompt and input hint", () => {
test("collect_spoken current_question drives the composer, not a second visual card", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
assert.match(chat, /currentQuestion\?\.kind === "collect_spoken"/);
assert.match(chat, /const collectSpokenPrompt =/);
assert.match(chat, /rectification-question-slot__prompt/);
assert.match(chat, /请在下方输入框回答/);
assert.match(chat, /aria-describedby=\{collectSpokenPrompt \? questionHintId : undefined\}/);
assert.match(chat, /collectSpokenPrompt\n\s+\? "请回答上面的问题…"/);
assert.match(chat, /<RectificationChoiceCard/);
assert.doesNotMatch(chat, /rectification-question-slot__spoken/);
assert.doesNotMatch(chat, /rectification-question-slot__prompt/);
assert.doesNotMatch(chat, /请在下方输入框回答/);
assert.doesNotMatch(chat, /questionHintId/);
assert.doesNotMatch(chat, /aria-describedby/);
assert.match(styles, /\.rectification-question-slot \{[\s\S]*width: calc\(100% - var\(--assistant-content-inset\)\)/);
assert.match(styles, /\.rectification-question-slot \{[\s\S]*margin-inline-start: var\(--assistant-content-inset\)/);
assert.doesNotMatch(styles, /\.rectification-question-slot \.rectification-choice-card \{/);
});
test("missing current_question is explicit only for resumable cases", () => {
@@ -330,6 +337,9 @@ test("agent route keeps question ownership in the server Case projection", () =>
assert.match(turnExit, /persistNextInterviewIfIdle/);
assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/);
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
const successExit = afterRun.slice(afterRun.indexOf("} else {"), afterRun.indexOf("send({ type: \"done\""));
assert.match(successExit, /await finalizeSuccessfulTurnExit/);
assert.doesNotMatch(successExit, /send\(\{\s*type:\s*"error"/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.doesNotMatch(agentRun, /collectSpokenPromptForNewFocus|composeCollectSpokenAssistantText/);
});
@@ -335,16 +335,16 @@ test("remaining D9/D10 packets use userChoice labels and long-term questions", (
assert.notEqual(d10!.styleOptions?.find((item) => item.answerClass === "yes")?.label, D10_TYPE_TABLE..style);
});
test("choice-card legend spacing does not depend on fieldset grid gap", () => {
const choiceCss = readFileSync(new URL("../src/app/birth-time-choice.css", import.meta.url), "utf8");
test("choice-card legend is screen-reader only so the stem is not duplicated", () => {
const globals = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const card = readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8");
const legendRule = choiceCss.match(/\.birth-time-choice-question legend \{[^}]+\}/)?.[0] ?? "";
const whyRule = globals.match(/\.rectification-choice-why \{[^}]+\}/)?.[0] ?? "";
assert.match(legendRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
const srOnlyLegend = globals.match(
/\.rectification-choice-card \.birth-time-choice-question legend\.sr-only \{[^}]+\}/,
)?.[0] ?? "";
assert.match(whyRule, /margin:\s*0\s+0\s+var\(--space-[3-9]\)/);
assert.match(card, /<legend>\{props\.card\.prompt\}<\/legend>/);
assert.match(srOnlyLegend, /margin:\s*0/);
assert.match(card, /<legend className="sr-only">\{props\.card\.prompt\}<\/legend>/);
assert.match(card, /className="rectification-choice-why"/);
assert.doesNotMatch(card, /choice-question-wrap|choice-stem-wrap|legend-spacer/);
assert.match(card, /<legend>\{props\.card\.prompt\}<\/legend>\s*\{props\.card\.why \? <p className="rectification-choice-why">/);
});