Files
Jyotisha/frontend/tests/rectification-provisional-adopt.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

704 lines
27 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 } from "node:fs";
import test from "node:test";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
decideRectification,
engineCapabilityCeilingFromReceipt,
publicDecisionFields,
publicNextAction,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
overlayPublicDecision,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { persistNextInterviewAfterChoice, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
canRenderRectificationSelectionCards,
parseRectificationCandidateResult,
} from "../src/lib/rectification-candidate-result.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 INCIDENT_TIMES = ["05:00", "05:07", "04:53"] as const;
const INCIDENT_SCORES = { "05:00": 24, "05:07": 19, "04:53": 8 } as const;
const INCIDENT_CANDIDATE_IDS = {
"05:00": "88888888-8888-4888-8888-888888888881",
"05:07": "88888888-8888-4888-8888-888888888882",
"04:53": "88888888-8888-4888-8888-888888888883",
} as const;
const ENGINE_OPEN = {
acceptanceAllowed: true,
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: true,
} as const;
const INCIDENT_EVIDENCE = [
{
id: "e-education",
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_start",
},
{
id: "e-rel-start",
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
},
{
id: "e-rel-end",
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
},
{
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",
},
] as const;
function answeredProbe(index: number, domain: string, year: number): ConflictProbe {
const semantic = `${domain}.${year}.dasha_boundary`;
return {
id: `probe:${semantic}`,
semantic_key: semantic,
candidate_split_hash: `${semantic}:split`,
domain,
year,
question: `${year} 年前后这件事有没有发生?`,
candidate_ids: [...INCIDENT_TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00", "05:07", "04:53"], conflicts: [] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: [], conflicts: ["05:00", "05:07", "04:53"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const ANSWERED_PROBES: ConflictProbe[] = [
answeredProbe(1, "education", 2016),
answeredProbe(2, "relationship", 2024),
answeredProbe(3, "relationship", 2023),
answeredProbe(4, "career", 2020),
answeredProbe(5, "career", 2023),
];
const REMAINING_UNSPLIT: ConflictProbe = {
id: "probe:career.2023.dasha_activation",
semantic_key: "career.2023.dasha_activation",
candidate_split_hash: "career.2023.activation",
domain: "career",
year: 2023,
question: "2023 年前后大运有没有启动?",
candidate_ids: [...INCIDENT_TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00", "05:07", "04:53"], conflicts: [] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: [], conflicts: ["05:00", "05:07", "04:53"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.56,
source: "dasha_activation",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
function incidentEvents(evidence: typeof INCIDENT_EVIDENCE | readonly DecisionDossier["evidence"][number][]) {
return evidence.map((item) => {
const yearRaw = item.occurredFrom ? Number(String(item.occurredFrom).slice(0, 4)) : NaN;
const year = Number.isInteger(yearRaw) ? yearRaw : null;
return {
id: item.id ?? `${item.domain}:${item.occurredFrom ?? "undated"}`,
domain: item.domain,
year,
precision: (year === null
? "unknown"
: item.datePrecision === "day" || item.datePrecision === "month"
? item.datePrecision
: "year") as "day" | "month" | "year" | "unknown",
usage: item.domain === "education"
? "holdout" as const
: year === null
? "unused" as const
: "training" as const,
};
});
}
function incidentInference(evidence: typeof INCIDENT_EVIDENCE | readonly DecisionDossier["evidence"][number][]): InferenceState {
const ranked = [...INCIDENT_TIMES].sort((left, right) => INCIDENT_SCORES[right] - INCIDENT_SCORES[left]);
return {
algorithm_version: "rectification-inference-v1",
candidate_set_id: candidateSetId("04:53", "05:07", INCIDENT_TIMES),
revision: 6,
phase: "discrimination",
result_status: "discriminating",
range_start: "04:53",
range_end: "05:07",
candidates: ranked.map((time, index) => ({
id: INCIDENT_CANDIDATE_IDS[time],
time,
cluster_range: [time, time] as const,
prior_score: INCIDENT_SCORES[time],
posterior_score: INCIDENT_SCORES[time],
probability: INCIDENT_SCORES[time] / 51,
status: "active" as const,
rank: index + 1,
strong_conflict_count: 0,
})),
events: incidentEvents(evidence),
probes: [...ANSWERED_PROBES, REMAINING_UNSPLIT],
answered_probes: ANSWERED_PROBES.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: "no" as const,
classified_from: "choice" as const,
})),
rounds: [],
last_inference_round: null,
entropy: 0.86,
representative_time: "05:00",
credible_range: ["05:00", "05:07"],
holdout_passed: null,
};
}
function openEngineReceipt(overrides: Record<string, unknown> = {}) {
return {
acceptance_allowed: true,
accept_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
diagnostic_quality: { passed: false, margin_percent: 19.94 },
event_fit_rate: { band: "high", rate: 0.8 },
oos_blind_prompts: [
{ domain: "family", user_meaning: "家里有没有结婚、添丁或住院这类记得住时间的事?", used_for_scoring: false },
{ domain: "finance", user_meaning: "有没有记得住时间的收入变化?", used_for_scoring: false },
{ domain: "health_pressure", user_meaning: "有没有记得住时间的健康压力事件?", used_for_scoring: false },
],
...overrides,
};
}
function incidentDossier(input: {
occupationCovered?: "declined" | "confirmed_note" | false;
evidence?: DecisionDossier["evidence"];
receipt?: Record<string, unknown>;
} = {}): DecisionDossier {
const occupationCovered = input.occupationCovered ?? "declined";
const evidence = [
...(input.evidence ?? INCIDENT_EVIDENCE),
...(occupationCovered === "confirmed_note"
? [{
id: "e-occupation",
status: "confirmed",
domain: "occupation",
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
}]
: []),
];
const declined = [
{ target_domain: "family", status: "declined" },
...(occupationCovered === "declined"
? [{ target_domain: "occupation", status: "declined", question_id: "collect:occupation:collect_method_evidence" }]
: []),
];
const state = incidentInference(evidence);
return {
evidence,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: declined,
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never),
candidates: state.candidates.map((candidate) => ({
candidateId: INCIDENT_CANDIDATE_IDS[candidate.time as keyof typeof INCIDENT_CANDIDATE_IDS],
time: candidate.time,
rank: candidate.rank,
relativeSupport: candidate.posterior_score,
tiedMinuteCount: 1,
})),
representativeTime: "05:00",
decisionReceipt: {
...openEngineReceipt(input.receipt),
inference_state: state,
},
},
case: { acceptedTime: null },
};
}
function publicFields(dossier: DecisionDossier) {
const parsed = dossier;
const decision = decideFromDossier(parsed, { birthDate: "1997-08-08" });
const overlaid = overlayPublicDecision(parsed.latestResult ?? {}, decision);
return { decision, overlaid, fields: publicDecisionFields(decision) };
}
function rpcEvidenceRows(decision: DecisionDossier) {
return 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"}`.trim(),
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-01T00:00:00.000Z",
}));
}
function rpcDossier(decision: DecisionDossier, activeFocus: Record<string, unknown> | null = null) {
const evidence = rpcEvidenceRows(decision);
const evidenceFingerprint = 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);
return dossierFixture({
evidence,
evidenceCount: decision.evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: decision.latestResult?.representativeTime ?? null,
evidenceLedgerFingerprint: evidenceFingerprint,
candidates: decision.latestResult?.candidates?.map((item) => ({
candidate_id: item.candidateId,
time: item.time,
rank: item.rank,
relative_support: item.relativeSupport,
tied_minute_count: item.tiedMinuteCount ?? 1,
})),
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-01T00:00:00.000Z",
},
});
}
test("incident shape 1a: occupation coverage keeps collecting until every line is asked", () => {
for (const occupationCovered of ["declined", "confirmed_note"] as const) {
const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered }));
assert.equal(decision.separation.lead, 5, occupationCovered);
assert.equal(decision.separation.sufficient, false, occupationCovered);
// 原值: can_adopt=true / session_outcome=adopt_representative
// 新值: can_adopt=false / session_outcome=collect_evidence
// 原因: D5 让七条线全部轮到 + D6「所有线问完才交付」;财务等线还没问(BUG-751)
// 原值(2): can_adopt=false / collect_evidence;新值(2): can_adopt=true / adopt_representative;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(overlaid.can_adopt, true, occupationCovered);
assert.equal(overlaid.session_outcome, "adopt_representative", occupationCovered);
assert.equal(overlaid.can_confirm_exact_minute, false, occupationCovered);
assert.equal(decision.canConfirmExactMinute, false, occupationCovered);
}
});
test("incident shape 1b: exact-minute confirm stays closed on the accident case", () => {
for (const occupationCovered of ["declined", "confirmed_note", false] as const) {
const { overlaid } = publicFields(incidentDossier({ occupationCovered }));
assert.equal(overlaid.can_confirm_exact_minute, false, String(occupationCovered));
}
});
test("incident shape 1c: uncovered occupation still collects remaining dated events", async () => {
const dossier = incidentDossier({ occupationCovered: false });
const { decision, overlaid } = publicFields(dossier);
// 原值: can_adopt=true / session_outcome=adopt_representative
// 新值: can_adopt=false / session_outcome=collect_evidence
// 原因: 同 1a——D5 + D6(BUG-751)
// 原值(2): can_adopt=false / collect_evidence;新值(2): can_adopt=true / adopt_representative;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(overlaid.can_adopt, true);
assert.equal(overlaid.session_outcome, "adopt_representative");
assert.equal(overlaid.can_confirm_exact_minute, false);
let writtenDomain: string | null = null;
const raw = rpcDossier(dossier);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => ({
...raw,
conversation_summary: {
...(raw.conversation_summary as Record<string, unknown>),
active_focus: writtenDomain
? {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: `collect:${writtenDomain}:collect_method_evidence`,
intent: "collect_method_evidence",
target_domain: writtenDomain,
status: "active",
}
: null,
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
writtenDomain = typeof args.p_target_domain === "string" ? args.p_target_domain : null;
return {
focus: {
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-01T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
};
},
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
const persisted = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
// 原值: 财务口述
// 新值: 训练门开后不轮转财务;职业层未覆盖仍可收职业口述
// 原因: BUG-648
assert.notEqual(writtenDomain, "finance");
assert.notEqual(persisted.hostNarration, USER_COLLECT_QUESTION.finance);
assert.equal(decision.canConfirmExactMinute, false);
assert.equal(canRenderRectificationSelectionCards(parseRectificationCandidateResult({
resultId: dossier.latestResult?.resultId,
candidates: overlaid.candidates,
selectionAllowed: overlaid.selection_allowed,
selection_allowed: overlaid.selection_allowed,
canAdopt: overlaid.can_adopt,
can_adopt: overlaid.can_adopt,
representativeTime: overlaid.representative_time,
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
// 原值: true
// 新值: false
// 原因: D6——线没问完不渲染可点卡(BUG-751)
// 原值(2): false;新值(2): true;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
})), true);
});
test("after-choice persist collects remaining dated events after family denial", async () => {
const dossier = incidentDossier({ occupationCovered: false });
const decision = decideFromDossier(dossier);
// 原值: canAdopt=true
// 新值: canAdopt=false
// 原因: D6——线没问完时交付能力位按 waitToNarrow 收起(BUG-751)
// 原值(2): false;新值(2): true;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(decision.canAdopt, true);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
return {
focus: {
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-01T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
};
},
});
const next = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: null,
nextAction: publicNextAction(decision),
});
// 原值: 财务口述
// 新值: 训练门开后不轮转财务;职业未覆盖可收职业口述
// 原因: BUG-648
assert.notEqual(next.followup?.domain, "finance");
});
test("incident shape 2: evidence floor still blocks adopt", () => {
const twoEvents = publicFields(incidentDossier({
occupationCovered: "declined",
evidence: INCIDENT_EVIDENCE.slice(0, 2),
}));
assert.equal(twoEvents.overlaid.can_adopt, false);
assert.equal(twoEvents.decision.nextAction, "ask_fact_collection");
const oneDomain = publicFields(incidentDossier({
occupationCovered: "declined",
evidence: INCIDENT_EVIDENCE.filter((item) => item.domain === "career"),
}));
assert.equal(oneDomain.overlaid.can_adopt, false);
assert.equal(oneDomain.decision.nextAction, "ask_fact_collection");
});
test("incident shape 3: engine ceiling still fail-closes adopt", () => {
const closed = publicFields(incidentDossier({
occupationCovered: "declined",
receipt: { acceptance_allowed: false },
}));
assert.equal(closed.overlaid.can_adopt, false);
const inconsistent = publicFields(incidentDossier({
occupationCovered: "declined",
receipt: { accept_allowed: false },
}));
assert.equal(inconsistent.overlaid.can_adopt, false);
assert.deepEqual(
engineCapabilityCeilingFromReceipt(inconsistent.decision && incidentDossier({
occupationCovered: "declined",
receipt: { accept_allowed: false },
}).latestResult?.decisionReceipt),
{
acceptanceAllowed: false,
selectionAllowed: false,
proposeAllowed: false,
confirmationAllowed: false,
},
);
});
test("incident shape 4: exact-minute confirm stays fail-closed even when local gates pass", () => {
const separated = [
{ time: "05:00", score: 58 },
{ time: "05:07", score: 42 },
];
const engineFalse = decideRectification({
methodCoverageAll: true,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: separated,
holdoutValidation: "passed",
confirmationAllowed: true,
datedEventCount: 5,
datedDomainCount: 3,
engineCeiling: {
acceptanceAllowed: true,
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
},
});
assert.equal(engineFalse.separation.sufficient, true);
assert.equal(engineFalse.canConfirmExactMinute, false);
const syntheticOpen = decideRectification({
methodCoverageAll: true,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: separated,
holdoutValidation: "passed",
confirmationAllowed: true,
datedEventCount: 5,
datedDomainCount: 3,
engineCeiling: ENGINE_OPEN,
});
assert.equal(syntheticOpen.canConfirmExactMinute, true);
});
test("delivery B1: GET overlay after occupation coverage renders representative cards", () => {
const dossier = incidentDossier({ occupationCovered: "declined" });
const { decision, overlaid } = publicFields(dossier);
// 原值: sessionOutcome=adopt_representative / nextAction!=ask_fact_collection
// 新值: sessionOutcome=collect_evidence / nextAction=ask_fact_collection
// 原因: D5 + D6——职业拒答只关一条线,其余线仍要问(BUG-751)
// 原值(2): collect_evidence / ask_fact_collection;新值(2): adopt_representative / 非采集;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.notEqual(decision.nextAction, "ask_fact_collection");
const parsed = parseRectificationCandidateResult({
resultId: dossier.latestResult?.resultId,
candidates: overlaid.candidates,
selectionAllowed: overlaid.selection_allowed,
selection_allowed: overlaid.selection_allowed,
canAdopt: overlaid.can_adopt,
can_adopt: overlaid.can_adopt,
representativeTime: overlaid.representative_time,
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
});
assert.ok(parsed);
// 原值: 渲染可点卡(BUG-648 训练门开即公开)
// 新值: 不渲染可点卡
// 原因: D6——线没问完不出交付卡(BUG-751)
// 原值(2): false;新值(2): true;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(canRenderRectificationSelectionCards(parsed), true);
assert.deepEqual(parsed.candidates.map((item) => item.time), ["05:00", "05:07", "04:53"]);
assert.equal(parsed.representativeTime, "05:00");
});
test("delivery B2: remaining dated collect stays in front of the adopt early-exit", async () => {
const dossier = incidentDossier({ occupationCovered: "declined" });
const { decision } = publicFields(dossier);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
});
// 原值: plan.session_outcome != collect_evidence
// 新值: plan.session_outcome == collect_evidence
// 原因: D6——采集线未问完,计划层保持采集(BUG-751)
// 原值(2): collect_evidence;新值(2): 不是 collect_evidence;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.notEqual(plan.session_outcome, "collect_evidence");
assert.notEqual(plan.next_followup?.domain, "finance");
const action = buildNextUserAction({
scorableCount: 5,
evidenceCount: 5,
hasLatestResult: true,
selectionAllowed: decision.selectionAllowed,
sessionOutcome: decision.sessionOutcome,
nextFollowup: plan.next_followup,
workingTime: "05:00",
});
// 原值: adopt_representative(BUG-648 训练门开即交付)
// 新值: ask_method_followup
// 原因: D6——采集线没问完,下一动作仍是问(BUG-751)
// 原值(2): ask_method_followup;新值(2): adopt_representative;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(action.id, "adopt_representative");
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => {
return {
focus: {
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-01T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
};
},
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
// 原值: 写入财务采集
// 新值: 训练门开后不落财务轮转
// 原因: BUG-648
assert.notEqual(idle.hostNarration, USER_COLLECT_QUESTION.finance);
});
test("delivery B3: accept projection stays open from engine receipt, not exact-minute confirm", () => {
const { decision, overlaid } = publicFields(incidentDossier({ occupationCovered: "declined" }));
// 原值: selectionAllowed / overlaid.selection_allowed = true
// 新值: false
// 原因: D6——线没问完时不开放选择(BUG-751)
// 原值(2): false;新值(2): true;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(decision.selectionAllowed, true);
assert.equal(overlaid.selection_allowed, true);
assert.equal(overlaid.can_confirm_exact_minute, false);
assert.equal(decision.canConfirmExactMinute, false);
});
test("capability is computed only in deliveryCapability and spread into every exit", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url), "utf8");
assert.match(source, /function deliveryCapability\(/);
assert.equal((source.match(/\.\.\.capability/g) ?? []).length >= 5, true);
assert.doesNotMatch(source, /canAdopt:\s*true/);
assert.doesNotMatch(source, /canConfirmExactMinute:\s*true/);
});