fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user