Files
Jyotisha/frontend/tests/rectification-adopt-flow-fix-20260903.test.ts
T
Jesse_ChenandClaude Opus 5.5 6ad0bea320 feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)
TASK-rectification-in-chat-step1-20261006, direct execution.

- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
  are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
  definition) and calls offer-birth-time-rectification; the server decides
  whether a card may show (self, product on, no case in progress,
  feature_pricing price). The offer is stored with the answer and travels on
  run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
  messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
  find the source and go back: one divider (time changed or not), earlier
  answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
  when clicked. Auto-return after the post-adopt check or a terminal case, only
  for work done in this view.
- Model history skips the divider and notes answers written with the old time.

Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-07 12:58:58 +08:00

439 lines
18 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";
import { rectificationChatSurface } from "./rectification-chat-surface.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",
},
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
{
id: "e-relocation",
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2018-03-01",
occurredTo: null,
eventKind: "relocation",
},
] 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" },
// R1(BUG-1193):补第 3 域,保持原场景。
{ id: "e-relocation", domain: "relocation", year: 2018, 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" });
// 原值: adopt_representative / offer_provisional_range / publicCanAdopt=true(BUG-648)
// 新值: collect_evidence / ask_fact_collection / publicCanAdopt=false
// 原因: D1 门槛(13 分钟、14:13:13 并列)+ D6「线问完才交付」,
// 本用例回到 BUG-648 之前的采集出口(BUG-751)
// 原值(2): collect_evidence / ask_fact_collection / publicCanAdopt=false
// 新值(2): adopt_representative / offer_provisional_range / publicCanAdopt=true
// 原因(2): 门开后定向线不再挡卡(BUG-1084,2026-09-29 D1);题名描述的「采集出口」
// 只剩训练门关的情形,由 rectification-futile-collect-stop-20260929.test.ts 覆盖
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(decision.nextAction, "offer_provisional_range");
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,
});
// 原值: persisted=false / 无 repair 警告 / 不写 focus(S3 交付轮)
// 新值: persisted=true / 写 repair 警告 / 写一条采集 focus
// 原因: 同上——本轮回到采集,非终结轮出口必须补一个问题(BUG-751)
// 原值(2): persisted=true / 写 repair 警告 / 写一条采集 focus
// 新值(2): persisted=false / 无 repair 警告 / 不写 focus(交付轮,同下一条用例)
// 原因(2): BUG-1084,2026-09-29 D1
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", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
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 不得当成拒答全部 other(BUG-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",
});
// 原值: plan.next_followup = null(训练门开后不再轮转)
// 新值: 学业线的定向存在性题 collect:targeted:education
// 原因: D5——七条线按 COLLECT_KIND_ORDER 全部轮到,不再按分盘层剔除;
// 题名说的「yields education」正是这一条(BUG-751)
// 原值(2): 学业线定向存在性题 collect:targeted:education
// 新值(2): next_followup === null(回到 BUG-751 之前「训练门开后不再轮转」)
// 原因(2): BUG-1084,2026-09-29 D1
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",
});
// 原值: horary(leftover 带年份采集关闭后方法层轮到占问)
// 新值: education
// 原因: D5——定向七条线排在方法层之前,学业线仍未覆盖(BUG-751)
// 原值(2): education(定向学业线)
// 新值(2): horary(回到 BUG-751 之前的值:定向线关闭后方法层轮到占问)
// 原因(2): 门开后定向七条线不再问(BUG-1084,2026-09-29 D1)
assert.equal(educationPlan.next_followup?.domain, "horary");
});
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}`);
}
}
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
// 原值: 状态条「之后新建对话即按此时间排盘」
// 新值: 无状态条;采用后旁白走 postAdoptVerifyDoneCopy;开场仍可预告结束后新建对话
// 原因: BUG-595 决策 3
assert.match(chat, /postAdoptVerifyDoneCopy/);
assert.doesNotMatch(chat, /rectification-adopt-status/);
// 原值(2): 开场预告「结束后新建对话,按采用的时间再问」
// 新值(2): 不再预告新建对话;从对话进来的校正做完回到来源对话(C5,BUG-1250)
// 原因(2): BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入
assert.doesNotMatch(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);
});