e8c98c37cf
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>
390 lines
14 KiB
TypeScript
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);
|
|
});
|