Files
Jyotisha/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts
T
Jesse_Chen e8c98c37cf
Independent Staging Quality Gate / validate (push) Successful in 8m29s
Independent Staging Quality Gate / publish (push) Successful in 1m45s
fix(rectification): restore collect-phase exit and drop the consult handoff
Public can_adopt no longer starves the collect fallback, 改选 scrolls to
the offer, opening collect uses other, and probe_year stays on the
schema. The status bar no longer starts an empty consultation chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 10:41:36 +08:00

390 lines
14 KiB
TypeScript

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 { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.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 = {
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" });
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.canAdopt, true);
assert.equal(publicCanAdopt(decision), false);
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, true);
} finally {
console.warn = originalWarn;
}
assert.ok(warnings.some((item) => item.includes("rectification_nonterminal_exit_repaired")));
assert.equal(store.focus?.intent, "collect_method_evidence");
const question = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(store.focus?.question_id ?? ""),
intent: String(store.focus?.intent ?? ""),
targetDomain: typeof store.focus?.target_domain === "string" ? store.focus.target_domain : null,
expectedAnswerSchema: store.focus?.expected_answer_schema as Record<string, unknown>,
});
assert.equal(question?.kind, "collect_spoken");
});
test("adopt_representative nonterminal exit stays satisfied without a new question", async () => {
const decisionDossier = collectDecisionDossier([{ target_domain: "family", status: "declined" }]);
const decision = decideFromDossier(decisionDossier, { birthDate: "1997-08-08" });
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("改选 is a button that scrolls to the offering message", () => {
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, /<button\s+type="button"\s+className="rectification-adopt-status__link"/);
assert.match(chat, /offerSectionRef\.current\?\.scrollIntoView\(\{ block: "center", behavior: "smooth" \}\)/);
assert.match(chat, /showSelectionCards && \(/);
assert.match(styles, /\.rectification-adopt-status__link:focus-visible/);
assert.match(styles, /@keyframes rectification-message-flash/);
});
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],
});
assert.equal(exhausted?.domain, "education");
assert.notEqual(exhausted?.collect_retry, true);
const plan = buildMethodFollowupPlan({
evidence: COLLECT_EVIDENCE,
closedCollectFocuses: [OPENING_SKIPPED],
sessionOutcome: "collect_evidence",
});
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.domain, "family");
assert.notEqual(plan.next_followup?.collect_retry, true);
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",
});
const educationCollect = educationPlan.next_followup?.intent === "collect_method_evidence"
&& educationPlan.next_followup.domain === "education"
? educationPlan.next_followup
: exhausted;
assert.equal(educationCollect?.domain, "education");
assert.notEqual(educationCollect?.collect_retry, true);
});
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",
);
assert.match(chat, /之后新建对话即按此时间排盘/);
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);
});