fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627)
Independent Staging Quality Gate / validate (push) Successful in 12m11s
Independent Staging Quality Gate / publish (push) Successful in 7m54s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 11:59:47 +08:00
co-authored by Cursor
parent 0263658477
commit d96b24c276
16 changed files with 615 additions and 76 deletions
@@ -896,3 +896,24 @@ test("holdout waits until dated collect and occupation are asked", () => {
assert.equal(plan.next_followup?.domain, "relocation");
assert.notEqual(plan.next_followup?.domain, "health_pressure");
});
test("skipped ledger health blocks health_pressure holdout", () => {
const evidence: MethodFollowupEvidence[] = [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-07-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2017-04-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
];
const oos = [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false as const,
}];
assert.equal(holdoutFollowupFor({
evidence,
oosBlindPrompts: oos,
}, new Set(["family", "health"])), null);
});
@@ -16,6 +16,7 @@ import {
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts";
@@ -463,10 +464,17 @@ function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType
});
}
function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType<typeof activeFocusFixture>; allowFocus?: boolean } = {}) {
function idleHandlers(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture>;
allowFocus?: boolean;
turns?: unknown[];
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra),
get_agentic_rectification_case_dossier: () => {
const row = rpcDossier(decision, extra);
return extra.turns ? { ...row, turns: extra.turns } : row;
},
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
@@ -869,3 +877,48 @@ test("closed ceiling with holdout still open persists holdout not the gate", asy
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
assert.match(persisted.hostNarration ?? "", /入职|换工作|工作/);
});
test("ensureNonTerminalTurnExit writes a host turn when exhausted with no carrier", async () => {
resetDeliveryTurnGuardForTests();
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
holdoutUnavailable: true,
});
const accounting = idleHandlers(dossier);
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.ok(repaired.hostNarration);
assert.equal(repaired.terminalNote, true);
assert.equal(gateAppendCalls(accounting.calls).length, 1);
});
test("ensureNonTerminalTurnExit does not rewrite when a terminal-note host turn exists", async () => {
resetDeliveryTurnGuardForTests();
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
holdoutUnavailable: true,
});
const accounting = idleHandlers(dossier, {
turns: [
{
id: "99999999-9999-4999-8999-999999999999",
role: "assistant",
text: RECTIFICATION_USER_COPY.noCandidatesGate,
status: "completed",
created_at: "2026-09-09T00:00:00.000Z",
},
],
});
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(repaired.hostNarration, null);
assert.equal(gateAppendCalls(accounting.calls).length, 0);
});
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import {
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_REPAIR_FAILED_COPY,
} from "../src/lib/rectification-surface-state.ts";
import {
holdoutFollowupFor,
type MethodFollowupEvidence,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
const SEVEN_WITHOUT_HEALTH: MethodFollowupEvidence[] = [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-04-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
];
function rpcEvidence(item: MethodFollowupEvidence & { id: string }) {
return {
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-09T00:00:00.000Z",
};
}
function skippedHealthDossier() {
const evidence = SEVEN_WITHOUT_HEALTH.map((item) => rpcEvidence({ ...item, id: item.id ?? `e-${item.domain}` }));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "14:45",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(SEVEN_WITHOUT_HEALTH as never),
decisionReceipt: {
oos_blind_prompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: [
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{
target_domain: "health",
status: "skipped",
intent: "collect_method_evidence",
question_id: "collect:health_pressure:collect_method_evidence",
},
],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-09T00:00:00.000Z",
},
});
}
function warnLines(run: () => Promise<unknown>) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
};
return Promise.resolve()
.then(run)
.then((result) => ({ result, lines }))
.finally(() => {
console.warn = original;
});
}
test("skipped health ledger domain keeps holdoutFollowupFor null", () => {
assert.equal(holdoutFollowupFor({
evidence: SEVEN_WITHOUT_HEALTH,
oosBlindPrompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
}, new Set(["health"])), null);
});
test("persistNextInterviewIfIdle still narrates after duplicate_focus", async () => {
const raw = skippedHealthDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: () => {
throw new Error("focus_idempotency_conflict");
},
});
const { result, lines } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const idle = result as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.ok((idle.hostNarration ?? "").trim());
assert.notEqual(idle.hostNarration, "");
assert.ok(
idle.terminalNote === true || Boolean(idle.hostNarration),
"duplicate_focus must still leave a visible carrier",
);
assert.ok(lines.some((line) => line.includes("rectification_focus_duplicate") || line.includes("rectification_exhaustion_collect")));
});
test("unavailable gap button posts repair-exit and says 接着问", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const surface = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
const repair = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts", import.meta.url),
"utf8",
);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.equal(RECTIFICATION_QUESTION_RELOAD_LABEL, "接着问");
assert.match(surface, /RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问"/);
assert.match(surface, /暂时接不上,请新建一次校正/);
assert.equal(RECTIFICATION_QUESTION_REPAIR_FAILED_COPY, "暂时接不上,请新建一次校正。");
assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/);
assert.match(chat, /RECTIFICATION_QUESTION_REPAIR_FAILED_COPY/);
assert.doesNotMatch(chat, /function reloadQuestion/);
assert.match(repair, /ensureNonTerminalTurnExit/);
assert.match(repair, /POST \/api\/rectification\/cases\/\[caseId\]\/repair-exit/);
const catchBlock = agentRun.slice(agentRun.indexOf("persist interview before collect attach failed"));
assert.match(catchBlock.slice(0, 500), /ensureNonTerminalTurnExit/);
});