Files
Jyotisha/frontend/tests/rectification-exhaustion-exit-20260906.test.ts
T
Jesse_ChenandCursor 150d7ef189 fix(web): exit rectification when the probe pool is exhausted (BUG-558)
Stop falling through to "say another event" after dated and occupation collect are done. Deliver an adopt path or a gate sentence, and add a spoken-collect stop control.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 23:03:11 +08:00

587 lines
20 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { RECTIFICATION_USER_COPY, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.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 { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const EXISTENCE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const TIMES = [
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
] as const;
const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]);
const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const;
const SCORES: Record<string, number> = {
"04:47": 16,
"04:51": 20,
"04:53": 16,
"04:59": 10,
"05:00": 4,
"05:07": 3,
"05:12": 2,
"05:14": 1,
"05:15": 1,
};
const PROBABILITY: Record<string, number> = {
"04:47": 0.25,
"04:51": 0.4,
"04:53": 0.25,
"04:59": 0.1,
};
const EVIDENCE = [
{
id: "e-edu-start",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "education start",
},
{
id: "e-edu-end",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2020-06-01",
occurredTo: "2020-06-30",
eventKind: "education_completion",
summary: "education completion",
},
{
id: "e-rel-start",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "month" as const,
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "relationship start",
},
{
id: "e-rel-end",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "day" as const,
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
summary: "relationship end",
},
{
id: "e-finance",
status: "confirmed" as const,
domain: "finance",
datePrecision: "month" as const,
occurredFrom: "2026-01-01",
occurredTo: "2026-01-31",
eventKind: "finance_loss",
summary: "finance change",
},
{
id: "e-reloc",
status: "confirmed" as const,
domain: "relocation",
datePrecision: "month" as const,
occurredFrom: "2023-07-01",
occurredTo: "2023-07-31",
eventKind: "relocation",
summary: "relocation",
},
{
id: "e-health",
status: "confirmed" as const,
domain: "health_pressure",
datePrecision: "month" as const,
occurredFrom: "2026-01-01",
occurredTo: "2026-08-31",
eventKind: "pressure_period",
summary: "health pressure",
},
{
id: "e-occupation",
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
summary: "occupation note",
},
] as const;
function uuidAt(index: number) {
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
}
function existenceProbe(input: {
key: string;
domain: string;
year: number;
question: string;
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: input.domain,
year: input.year,
question: input.question,
candidate_ids: [...ACTIVE],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: ["04:47"], conflicts: ["04:51"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const D9: ConflictProbe = {
id: "contrast:varga.d9.style",
semantic_key: "varga.d9.style",
candidate_split_hash: "varga.d9.style",
domain: "relationship",
year: 0,
question: "亲密关系里更接近哪一种",
candidate_ids: [...ACTIVE],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
{ answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.3,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10: ConflictProbe = {
id: "contrast:varga.d10.style",
semantic_key: "varga.d10.style",
candidate_split_hash: "varga.d10.style",
domain: "career",
year: 0,
question: "平时做事更接近哪一种",
candidate_ids: [...ACTIVE],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
{ answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.3,
source: "varga_contrast",
choice_kind: "varga_style",
};
const CAREER_MONTH = existenceProbe({
key: "career.2023.05.dasha_boundary",
domain: "career",
year: 2023,
question: "2023 年 5 月前后有没有入职或换工作",
});
const CAREER_YEAR = existenceProbe({
key: "career.2023.dasha_activation",
domain: "career",
year: 2023,
question: "2023 年前后工作上有没有入职或换工作",
});
const RELOC = existenceProbe({
key: "relocation.2018.05.dasha_boundary",
domain: "relocation",
year: 2018,
question: "2018 年 5 月前后有没有搬家",
});
const FINANCE = existenceProbe({
key: "finance.2024.03.dasha_boundary",
domain: "finance",
year: 2024,
question: "2024 年 3 月前后钱上有没有明显变化",
});
const ASKED_PROBES = [D9, D10, CAREER_MONTH, CAREER_YEAR, RELOC, FINANCE];
function liveState(): InferenceState {
const probes = ASKED_PROBES;
const rankedActive = [...ACTIVE].sort((left, right) => (
(PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0)
|| (SCORES[right] ?? 0) - (SCORES[left] ?? 0)
|| left.localeCompare(right)
));
const candidates = TIMES.map((time, index) => {
const eliminated = ELIMINATED.has(time);
const activeRank = (rankedActive as readonly string[]).indexOf(time);
return {
id: time,
time,
cluster_range: [time, time] as const,
prior_score: SCORES[time] ?? 0,
posterior_score: SCORES[time] ?? 0,
probability: eliminated ? 0 : (PROBABILITY[time] ?? 0),
status: eliminated ? "eliminated" as const : "active" as const,
rank: eliminated ? ACTIVE.length + index : activeRank + 1,
strong_conflict_count: eliminated ? 3 : 0,
};
});
const answered = [
{ probe: D9, answer_class: "yes" as const },
{ probe: D10, answer_class: "yes" as const },
{ probe: CAREER_MONTH, answer_class: "yes" as const },
{ probe: CAREER_YEAR, answer_class: "no" as const },
{ probe: RELOC, answer_class: "no" as const },
{ probe: FINANCE, answer_class: "no" as const },
].map((item) => ({
probe_id: item.probe.id,
semantic_key: item.probe.semantic_key,
candidate_split_hash: item.probe.candidate_split_hash,
answer_class: item.answer_class,
classified_from: "choice" as const,
}));
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
revision: 6,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:47",
range_end: "05:15",
candidates,
events: [
{ id: "e-edu-start", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: "e-edu-end", domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "holdout" as const },
{ id: "e-finance", domain: "finance", year: 2026, precision: "month" as const, usage: "training" as const },
{ id: "e-reloc", domain: "relocation", year: 2023, precision: "month" as const, usage: "training" as const },
{ id: "e-health", domain: "health_pressure", year: 2026, precision: "month" as const, usage: "training" as const },
],
probes,
answered_probes: answered,
rounds: [],
last_inference_round: null,
entropy: 1.2,
representative_time: "04:51",
credible_range: ["04:47", "04:53"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded, "accident-shape inference must pass asInferenceState");
return loaded;
}
function eventProbeRow(probe: ConflictProbe) {
return {
year: probe.year,
year_label: probe.year > 0 ? `${probe.year} 年前后` : "",
domain: probe.domain,
event_family: probe.domain === "career" ? "入职、换工作或职责加重" : probe.domain,
source: probe.source,
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
};
}
function snapshotCandidates() {
return TIMES.map((time, index) => ({
candidate_id: uuidAt(index),
rank: index + 1,
time,
relative_support: Math.round(SCORES[time] ?? 0),
tied_minute_count: 1,
}));
}
function accidentDossier(extra: {
acceptanceAllowed?: boolean;
acceptanceReasons?: string[];
mismatchSnapshot?: boolean;
} = {}): DecisionDossier {
const state = liveState();
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
const acceptanceAllowed = extra.acceptanceAllowed !== false;
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [{ target_domain: "family", status: "declined" }],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: acceptanceAllowed,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: extra.mismatchSnapshot
? [{ candidateId: uuidAt(0), time: "03:00", rank: 1, relativeSupport: 10 }]
: TIMES.map((time, index) => ({
candidateId: uuidAt(index),
time,
rank: index + 1,
relativeSupport: Math.round(SCORES[time] ?? 0),
})),
representativeTime: "04:51",
decisionReceipt: {
accept_allowed: acceptanceAllowed,
acceptance_allowed: acceptanceAllowed,
propose_allowed: extra.acceptanceAllowed === false ? false : true,
selection_allowed: extra.acceptanceAllowed === false ? false : true,
confirmation_allowed: false,
...(extra.acceptanceReasons ? { acceptance_reasons: extra.acceptanceReasons } : {}),
inference_state: state,
discriminating_event_probes: ASKED_PROBES.map(eventProbeRow),
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
};
}
function rpcDossier(decision: DecisionDossier) {
const evidence = EVIDENCE.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: item.domain === "occupation" ? "self" : "self",
event_kind: item.eventKind,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-06T00:00:00.000Z",
}));
return dossierFixture({
evidence,
latestResult: candidateSnapshotFixture({
selectionAllowed: decision.latestResult?.selectionAllowed ?? true,
confirmationAllowed: false,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: item.candidateId ?? uuidAt(index),
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: 1,
})) ?? snapshotCandidates(),
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-06T00:00:00.000Z",
},
});
}
function idleHandlers(decision: DecisionDossier) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision),
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: (_fn, args) => {
throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`);
},
});
}
function warnLines(run: () => Promise<unknown> | unknown) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
original.apply(console, args);
};
return Promise.resolve(run()).finally(() => {
console.warn = original;
}).then((result) => ({ result, lines }));
}
test("skill version stays 10.0.14 for the exhaustion-exit fix", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14");
});
test("USER_COLLECT_QUESTION.other remains only on the opening collect path", () => {
const srcRoot = new URL("../src/", import.meta.url);
const files = [
"lib/rectification-agentic/v9/method-followup.ts",
"lib/rectification-agentic/v9/answer-choice.ts",
"mastra/rectification-v9-tools.ts",
"app/api/rectification/agent/route.ts",
];
const hits: string[] = [];
for (const relative of files) {
const source = readFileSync(new URL(relative, srcRoot), "utf8");
for (const line of source.split("\n")) {
if (line.includes("USER_COLLECT_QUESTION.other") || line.includes("collectQuestionRetryByDomain.other")) {
hits.push(`${relative}: ${line.trim()}`);
}
}
}
assert.equal(hits.some((line) => line.includes("answer-choice.ts")), false, hits.join("\n"));
assert.equal(hits.some((line) => line.includes("rectification-v9-tools.ts")), false, hits.join("\n"));
assert.ok(hits.some((line) => line.includes("method-followup.ts")), hits.join("\n"));
});
test("collect spoken stop button and range line share CHOICE_STOP_LABEL", () => {
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
assert.match(chat, /rectification-collect-stop/);
assert.match(chat, /CHOICE_STOP_LABEL/);
assert.match(chat, /kind === "collect_spoken"/);
assert.match(chat, /function submitStop/);
assert.match(chat, /RectificationReadonlyRange/);
assert.match(route, /ask_about_result/);
assert.match(route, /STOP_ACTION/);
assert.equal(CHOICE_STOP_LABEL, "先这样,先看当前范围");
});
test("covered accident shape adopts instead of collecting other", async () => {
const dossier = accidentDossier();
const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" });
assert.notEqual(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.match(decision.stopReason ?? "", /probe_pool_exhausted/);
const accounting = idleHandlers(dossier);
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.ok(persisted.hostNarration);
assert.match(persisted.hostNarration ?? "", new RegExp(RECTIFICATION_USER_COPY.probePoolExhaustedStop));
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
assert.match(persisted.hostNarration ?? "", /04:47|04:51|范围/);
});
test("closed ceiling translates the gate and does not persist a focus", async () => {
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
});
const accounting = idleHandlers(dossier);
const { result: idle, lines } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.ok(persisted.hostNarration);
assert.match(persisted.hostNarration ?? "", /还差|带月份的经历|日期还没对清|排不出可比较的候选/);
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.ok(repaired.hostNarration === null || repaired.terminalNote !== false);
const log = lines.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return null;
}
}).find((item) => item?.event === "rectification_exhaustion_collect");
assert.ok(log);
for (const key of [
"event", "case_id", "can_adopt", "ceiling", "training_gate",
"stop_class", "ranked_count", "probe_key", "budget", "next_domain", "next_source",
]) {
assert.ok(key in (log ?? {}), key);
}
assert.equal("summary" in (log ?? {}), false);
assert.equal("year" in (log ?? {}), false);
assert.equal("evidence" in (log ?? {}), false);
});
test("inconsistent projection logs ranked_count 0 and still delivers a gate", async () => {
const dossier = accidentDossier({ mismatchSnapshot: true });
const accounting = idleHandlers(dossier);
const { result: idle, lines } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
const log = lines.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return null;
}
}).find((item) => item?.event === "rectification_exhaustion_collect");
assert.equal(log?.ranked_count, 0);
assert.match(persisted.hostNarration ?? "", /还差|带月份|排不出可比较的候选|范围/);
});