Files
Jyotisha/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts
T
Jesse_ChenandCursor dd8f35f7ba fix(rectification): invite-first collect, holdout at 4 events, Skill 10.0.23 (BUG-646–648)
Stop domain-wheel collecting and age-band years in prompts. Ask until the training gate, then discriminate until convergence, then deliver a range plus a concrete follow-up. Reserve holdout only with four dated events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 01:43:02 +08:00

411 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import test from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { publicCanAdopt } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
buildMethodFollowupPlan,
exhaustionSpokenCollectFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { ensureNonTerminalTurnExit } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const STYLE_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 COLLECT_EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-exit",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
},
{
id: "e-rel-end",
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
},
{
id: "e-rel-start",
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
},
] as const;
const OPENING_SKIPPED = {
// 原值: collect:other:collect_method_evidence
// 新值: collect:other:collect_method_evidence
// 原因: 开场跳过记录仍按 other 身份,避免把 unknown 写入账本
questionId: "collect:other:collect_method_evidence",
target_domain: "other",
status: "skipped",
};
function collectDecisionDossier(declinedTopics: ReadonlyArray<Record<string, unknown>> = []): DecisionDossier {
const state = buildInferenceState({
range_start: "04:53",
range_end: "05:06",
candidates: [
{ id: "05:06", time: "05:06", relative_support: 14 },
{ id: "04:53", time: "04:53", relative_support: 13 },
{ id: "05:03", time: "05:03", relative_support: 13 },
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
],
probes: [],
});
return {
evidence: COLLECT_EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: declinedTopics,
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(COLLECT_EVIDENCE as never),
candidates: state.candidates.map((candidate) => ({
candidateId: candidate.id,
time: candidate.time,
rank: candidate.rank,
relativeSupport: Math.round(candidate.posterior_score),
})),
representativeTime: state.representative_time,
decisionReceipt: {
acceptance_allowed: true,
accept_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
},
},
case: { acceptedTime: null },
};
}
function rpcDossier(decision: DecisionDossier, activeFocus: Record<string, unknown> | null = null) {
const evidence = decision.evidence.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? "event",
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary ?? `${item.occurredFrom} ${item.eventKind ?? "event"}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-03T00:00:00.000Z",
}));
return dossierFixture({
evidence,
latestResult: candidateSnapshotFixture({
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: item.tiedMinuteCount ?? 1,
})),
representativeTime: decision.latestResult?.representativeTime ?? null,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence.map((item) => ({
id: item.id,
eventKind: item.event_kind,
domain: item.domain,
occurredFrom: item.occurred_from,
occurredTo: item.occurred_to,
datePrecision: item.date_precision,
summary: item.summary,
status: item.status,
})) as never),
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: activeFocus,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-03T00:00:00.000Z",
},
});
}
function createdFocusFromArgs(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-09-03T00:00:00.000Z",
resolved_at: null,
};
}
test("collect-phase nonterminal exit persists a spoken collect when public can_adopt is closed", async () => {
const decisionDossier = collectDecisionDossier();
const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" });
// 原值: sessionOutcome=collect_evidencepublicCanAdopt=false,补写家人等 leftover 口述采集
// 新值: 四件两类已开训练门,leftover 带年份轮转关闭,公开可采用,S3 交付
// 原因: 训练门开后不再按未覆盖领域挡住采用(BUG-648)
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(decision.nextAction, "offer_provisional_range");
assert.equal(decision.canAdopt, true);
assert.equal(publicCanAdopt(decision), true);
const store: { focus: ReturnType<typeof createdFocusFromArgs> | null } = { focus: null };
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => {
warnings.push(args.map((item) => String(item)).join(" "));
};
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decisionDossier, store.focus),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
store.focus = createdFocusFromArgs(args);
return { focus: store.focus, idempotent: false };
},
});
try {
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(repaired.persisted, false);
} finally {
console.warn = originalWarn;
}
assert.equal(warnings.some((item) => item.includes("rectification_nonterminal_exit_repaired")), false);
assert.equal(store.focus, null);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
test("adopt_representative nonterminal exit stays satisfied without a new question", async () => {
const decisionDossier = collectDecisionDossier([
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "education", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "finance", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "relocation", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
{ target_domain: "occupation", status: "declined", intent: "collect_method_evidence" },
]);
const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" });
// 旧:家人拒答即可 adopt_representative。新:带年份采集未完不得出牌;本夹具把剩余域拒答后才满足。
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(publicCanAdopt(decision), true);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decisionDossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: () => {
throw new Error("adoptable offer must not persist another question");
},
});
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(repaired.persisted, false);
assert.equal(repaired.hostNarration, null);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
});
test("adopted time is shown on the card row, not a composer status bar", () => {
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");
// 原值: 「改选」是状态条按钮,滚动到出卡消息
// 新值: 点其他候选行即改选;无 rectification-adopt-status
// 原因: BUG-595 决策 3
assert.doesNotMatch(chat, /rectification-adopt-status__link/);
assert.doesNotMatch(chat, /offerSectionRef/);
assert.match(chat, /showSelectionCards && candidateResult && message\.renderKey === selectionCardMessageKey/);
assert.doesNotMatch(styles, /\.rectification-adopt-status__link:focus-visible/);
});
test("skipped opening collect other still yields education without collect_retry", () => {
const family = {
id: "e-family",
status: "confirmed",
domain: "family",
datePrecision: "year",
occurredFrom: "2021-01-01",
occurredTo: null,
};
const exhausted = exhaustionSpokenCollectFollowup({
evidence: [family],
declinedTopics: [OPENING_SKIPPED],
});
// 原值: 跳过开场 collect:other 后按轮转到 education
// 新值: 开场 other 过期后邀请可替换,domain=other / collect:invite:more
// 原因: 用户先说完再追问;跳过开场 other 不得当成拒答全部 otherBUG-648
assert.equal(exhausted?.domain, "other");
assert.equal(exhausted?.collection_key, "collect:invite:more");
assert.equal(exhausted?.kind_hint, "invite_more");
assert.notEqual(exhausted?.collect_retry, true);
const plan = buildMethodFollowupPlan({
evidence: COLLECT_EVIDENCE,
closedCollectFocuses: [OPENING_SKIPPED],
sessionOutcome: "collect_evidence",
});
// 原值: 四件两类仍问家人采集
// 新值: 训练门已开,不再 leftover 带年份轮转
// 原因: S2/S3,开场 other 过期不恢复领域轮盘
assert.equal(plan.next_followup, null);
const educationPlan = buildMethodFollowupPlan({
evidence: [...COLLECT_EVIDENCE, family],
closedCollectFocuses: [
OPENING_SKIPPED,
{
questionId: "collect:occupation:collect_method_evidence",
target_domain: "occupation",
status: "skipped",
},
],
sessionOutcome: "collect_evidence",
precisionStage: "d5_refine",
});
// 原值: 自动落到 education 采集
// 新值: 不是学业轮转;方法层仍可问占问
// 原因: leftover 带年份采集关闭后,horary 仍可 collect_method_evidence
assert.equal(educationPlan.next_followup?.domain, "horary");
assert.equal(educationPlan.next_followup?.intent, "collect_method_evidence");
});
test("consult handoff button and startConsultationAfterRectification are gone", () => {
const srcRoot = new URL("../src/", import.meta.url);
const files = readdirSync(srcRoot, { recursive: true, encoding: "utf8" })
.filter((entry) => /\.(ts|tsx|css)$/.test(entry));
const banned = [
"startConsultationAfterRectification",
"onStartConsultation",
"请用刚才采用的代表性出生时间看盘",
"rectification-consult-handoff",
];
for (const file of files) {
const source = readFileSync(new URL(file.split("\\").join("/"), srcRoot), "utf8");
for (const token of banned) {
assert.equal(source.includes(token), false, `${file} still contains ${token}`);
}
}
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
// 原值: 状态条「之后新建对话即按此时间排盘」
// 新值: 无状态条;采用后旁白走 postAdoptVerifyDoneCopy;开场仍可预告结束后新建对话
// 原因: BUG-595 决策 3
assert.match(chat, /postAdoptVerifyDoneCopy/);
assert.doesNotMatch(chat, /rectification-adopt-status/);
assert.match(chat, /结束后新建对话,按采用的时间再问/);
});
test("keepAcceptedFocus omits probe_year when schema and probes have none", () => {
const followup = readFileSync(
new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url),
"utf8",
);
assert.doesNotMatch(followup, /\/19\\d\{2\}\|20\\d\{2\}\//);
const plan = buildMethodFollowupPlan({
evidence: COLLECT_EVIDENCE,
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [],
activeFocus: {
id: FOCUS_ID,
questionId: "reverse_verify:education_style:score",
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
choice: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: STYLE_OPTIONS[0].label,
option_b: STYLE_OPTIONS[1].label,
option_c: STYLE_OPTIONS[2].label,
option_d: STYLE_OPTIONS[3].label,
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
},
},
});
assert.equal(plan.next_followup?.intent, "reverse_verify");
assert.equal(plan.next_followup?.probe_year, undefined);
});