Files
Jyotisha/frontend/tests/rectification-probe-pool-exhausted-20260911.test.ts
T
Jesse_ChenandCursor 512be9b76e fix(rectification): persist refreshed probes only when the engine has new questions (BUG-655)
Empty refreshes were writing inference rows, and GET-selected probe keys could miss inference_state, so persist rejected the next card.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 01:51:57 +08:00

998 lines
37 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 test from "node:test";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
rectificationFollowupCatalog,
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 { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { publicNextAction } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
COLLECT_FLOW_BANNED_PHRASES,
targetedCollectPool,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import {
buildMethodFollowupPlan,
buildNextUserAction,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
alignedProbeId,
refreshDatedDiscriminatorPoolIfNeeded,
resetRefreshDiscriminatorProbesForTests,
setRefreshDiscriminatorProbesForTests,
} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
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 VARGA_OPTIONS = [
{ label: "相处里更在意照顾对方的感受", answer_class: "yes" as const, sign: "巨蟹座" },
{ label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes" as const, sign: "狮子座" },
];
const TIMES = ["04:48", "04:53", "04:54", "04:59", "05:06", "05:07"] as const;
const SCORES: Record<string, number> = {
"04:48": 10,
"04:53": 15,
"04:54": 14,
"04:59": 13,
"05:06": 13,
"05:07": 12,
};
const educationStart = {
id: "e-edu-start",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "2016年9月上大学",
};
const educationEnd = {
id: "e-edu-end",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2020-06-01",
occurredTo: "2020-06-30",
eventKind: "education_completion",
summary: "2020年6月毕业",
};
const careerIntern = {
id: "e-career-intern",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2020年4月入职实习",
};
const careerLeave = {
id: "e-career-leave",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
summary: "2020年10月离职",
};
const EVIDENCE = [educationStart, educationEnd, careerIntern, careerLeave];
function uuidAt(index: number) {
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
}
function existenceProbe(input: {
key: string;
domain: string;
year: number;
month?: number;
question: string;
source?: string;
choiceKind?: ConflictProbe["choice_kind"];
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: input.domain,
year: input.year,
question: input.question,
candidate_ids: [...TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: input.source ?? "dasha_boundary",
choice_kind: input.choiceKind ?? "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const ASKED_PROBES = [
existenceProbe({
key: "career.2023.05.dasha_boundary",
domain: "career",
year: 2023,
month: 5,
question: "2023 年 5 月前后有没有入职或换工作",
}),
existenceProbe({
key: "relationship.2023.05.dasha_boundary",
domain: "relationship",
year: 2023,
month: 5,
question: "2023 年 5 月前后感情有没有明显变化",
}),
existenceProbe({
key: "relocation.2015.05.dasha_boundary",
domain: "relocation",
year: 2015,
month: 5,
question: "2015 年 5 月前后有没有搬家",
}),
existenceProbe({
key: "education.2016.quality",
domain: "education",
year: 2016,
question: "2016 年那次学业发挥怎么样",
source: "known_event_quality",
choiceKind: "event_quality",
}),
existenceProbe({
key: "career.2024.04.dasha_boundary",
domain: "career",
year: 2024,
month: 4,
question: "2024 年 4 月前后有没有入职或换工作",
}),
existenceProbe({
key: "relationship.2024.04.dasha_boundary",
domain: "relationship",
year: 2024,
month: 4,
question: "2024 年 4 月前后感情有没有明显变化",
}),
];
const SIXTH = ASKED_PROBES[5]!;
const LEFTOVER_SAME_YEAR = existenceProbe({
key: "career.2023.dasha_activation",
domain: "career",
year: 2023,
question: "2023 年前后有没有职责加重",
source: "dasha_activation",
});
const D10_STYLE: ConflictProbe = {
id: "probe:varga.d10.狮子座/处女座",
semantic_key: "varga.d10.狮子座/处女座",
candidate_split_hash: "04:48-05:07:04:49,04:53:varga.d10.狮子座/处女座",
domain: "career",
year: 0,
question: "平时做事,你更接近下面哪一种?",
candidate_ids: [...TIMES],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.9,
source: "varga_contrast",
choice_kind: "varga_style",
style_options: VARGA_OPTIONS,
};
const NAKSHATRA: ConflictProbe = {
id: "probe:nakshatra.boundary",
semantic_key: "nakshatra.boundary.a/b",
candidate_split_hash: "nakshatra.boundary.a/b",
domain: "other",
year: 0,
question: "两组月宿性格里更接近哪一种?",
candidate_ids: ["04:54", "05:06"],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:54"], conflicts: ["05:06"] },
{ answer_class: "weak_yes", supports: ["05:06"], conflicts: ["04:54"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.01,
source: "nakshatra_boundary",
choice_kind: "varga_style",
style_options: VARGA_OPTIONS,
};
function liveState(answeredCount: number) {
const answered = ASKED_PROBES.slice(0, answeredCount);
const leftoverYearless = [1, 2, 3, 4, 5].map((index) => existenceProbe({
key: `yearless.existence.${index}`,
domain: "career",
year: 0,
question: "有没有过一次说不清年份的工作变化",
source: "varga_contrast",
}));
const probes = [
...answered,
LEFTOVER_SAME_YEAR,
...leftoverYearless,
D10_STYLE,
NAKSHATRA,
];
const candidates = TIMES.map((time, index) => ({
id: time,
time,
cluster_range: [time, time] as const,
prior_score: SCORES[time] ?? 0,
posterior_score: SCORES[time] ?? 0,
probability: (SCORES[time] ?? 0) / 76,
status: "active" as const,
rank: index + 1,
strong_conflict_count: 0,
}));
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:48", "05:07", TIMES),
revision: answeredCount,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:48",
range_end: "05:07",
candidates,
events: [
{ id: educationStart.id, domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: educationEnd.id, domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: careerIntern.id, domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: careerLeave.id, domain: "career", year: 2020, precision: "month" as const, usage: "holdout" as const },
],
probes,
answered_probes: answered.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: probe.semantic_key.includes("2024.04") && probe.domain === "career" ? "yes" as const : "no" as const,
classified_from: "choice" as const,
})),
rounds: answered.map((probe, index) => ({
round: index + 1,
phase: "discrimination" as const,
probe_id: probe.id,
scores_before: { "04:53": 15 },
scores_after: { "04:53": 15 },
entropy_before: 1.4,
entropy_after: 1.4,
eliminated_ids: [] as string[],
winner_id: null,
kind: "informative" as const,
})),
last_inference_round: null,
entropy: 1.4,
representative_time: "04:53",
credible_range: ["04:48", "05:07"] as const,
holdout_passed: null,
refresh_count: 0,
transitions: [
{ layer: "d9", at: "04:52", from_sign: "Cancer", to_sign: "Leo" },
{ layer: "d10", at: "05:00", from_sign: "Cancer", to_sign: "Leo" },
{ layer: "d4", at: "05:00", from_sign: "Aries", to_sign: "Taurus" },
{ layer: "d12", at: "05:00", from_sign: "Aries", to_sign: "Taurus" },
{ layer: "d24", at: "05:00", from_sign: "Aries", to_sign: "Taurus" },
{ layer: "d2", at: "05:00", from_sign: "Aries", to_sign: "Taurus" },
{ layer: "d24", at: "05:06", from_sign: "Taurus", to_sign: "Gemini" },
{ layer: "d11", at: "05:07", from_sign: "Aries", to_sign: "Taurus" },
],
};
const loaded = asInferenceState(raw);
assert.ok(loaded);
return loaded;
}
function eventProbeRow(probe: ConflictProbe): DiscriminatingEventProbe {
return {
year: probe.year,
year_label: probe.year > 0 ? `${probe.year} 年前后` : "",
domain: probe.domain as DiscriminatingEventProbe["domain"],
event_family: probe.domain === "family" ? "家人结婚、添丁或住院" : probe.domain,
source: probe.source === "dasha_activation" || probe.source === "dasha_boundary"
|| probe.source === "known_event_quality"
? probe.source
: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
...(probe.choice_kind ? { choice_kind: probe.choice_kind } : {}),
...(probe.style_options?.length ? { style_options: probe.style_options } : {}),
};
}
const FAMILY_REFRESH = existenceProbe({
key: "family.2018.05.dasha_boundary",
domain: "family",
year: 2018,
month: 5,
question: "2018 年 5 月前后家里有没有添丁或长辈住院",
});
const TARGETED_DECLINED = {
target_domain: "family",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:targeted:family",
target_kind: "targeted:family",
} as const;
function accidentDossier(answeredCount: number, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
refreshCount?: number;
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
} = {}): DecisionDossier {
const loaded = liveState(answeredCount);
const state = extra.refreshCount != null
? { ...loaded, refresh_count: extra.refreshCount }
: loaded;
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: extra.activeFocus
? {
id: extra.activeFocus.id,
intent: extra.activeFocus.intent,
targetDomain: extra.activeFocus.target_domain,
targetKind: extra.activeFocus.target_kind,
expectedAnswerSchema: extra.activeFocus.expected_answer_schema,
}
: null,
declinedSkippedTopics: [{
target_domain: "other",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:invite:more",
target_kind: "invite_more",
}, ...(extra.declinedTopics ?? [])],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: TIMES.map((time, index) => ({
candidateId: uuidAt(index),
time,
rank: index + 1,
relativeSupport: SCORES[time] ?? 0,
})),
representativeTime: "04:53",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
acceptance_reasons: [],
inference_state: state,
discriminating_event_probes: [
...ASKED_PROBES.map(eventProbeRow),
eventProbeRow(LEFTOVER_SAME_YEAR),
eventProbeRow(D10_STYLE),
],
oos_blind_prompts: [],
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
};
}
function rpcDossier(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
} = {}) {
const evidence = decision.evidence.map((item) => ({
id: item.id ?? "e-unknown",
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary ?? item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-11T00:00:00.000Z",
}));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:53",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(decision.evidence as never),
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: item.candidateId ?? uuidAt(index),
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: 1,
})) ?? [],
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: conversationSummaryFixture({
activeFocus: extra.activeFocus ?? null,
declinedSkippedTopics: [...decision.conversationSummary.declinedSkippedTopics],
}),
});
}
function idleHandlers(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture> | null;
throwOnFocus?: boolean;
transitions?: Record<string, unknown>[];
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra),
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
probe_id: SIXTH.id,
revision: Number(args.p_expected_revision) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
set_agentic_rectification_conversation_focus: extra.throwOnFocus
? () => {
throw new Error("persist skipped");
}
: (_fn, args) => ({
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-11T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
}),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
get_agentic_rectification_turn_receipt: () => null,
append_agentic_rectification_inference_transition: (_fn, args) => {
extra.transitions?.push(args as Record<string, unknown>);
const inference = args.p_inference_state as {
candidates?: unknown[];
candidate_set_id?: string;
refresh_count?: number;
} | undefined;
return {
result_id: "55555555-5555-4555-8555-555555555555",
revision: Number(args.p_expected_revision ?? 0) + 1,
idempotent: false,
decision_receipt: {
inference_state: args.p_inference_state,
},
decision_state_fingerprint: args.p_decision_state_fingerprint,
reason: args.p_reason,
candidates: inference?.candidates?.length ?? 0,
candidate_set_id: args.p_candidate_set_id ?? inference?.candidate_set_id,
refresh_count: inference?.refresh_count ?? 0,
};
},
});
}
function warnLines(run: () => Promise<unknown> | unknown) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
original.apply(console, args);
};
return Promise.resolve(run()).finally(() => {
console.warn = original;
}).then((result) => ({ result, lines }));
}
function followupPlan(dossier: DecisionDossier, sessionOutcome: string) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
return buildMethodFollowupPlan({
...catalog,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: sessionOutcome as never,
candidatesSeparated: false,
birthDate: "1997-08-08",
});
}
test("T0: sixth dated answer must refresh or targeted-collect, not deliver a card", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
}));
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const plan = followupPlan(dossier, decision.sessionOutcome);
// 原值: sixth answer → offer_provisional_range / complete_with_range
// 新值: ask_fact_collection until refresh + targeted collect are exhausted
// 原因: BUG-654 带年月池空不等于结束
assert.equal(decision.nextAction, "ask_fact_collection", decision.nextAction);
assert.equal(decision.canOfferRange, false);
assert.equal(plan.next_followup?.choice_kind, undefined);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.notEqual(plan.next_followup?.source, "nakshatra_boundary");
assert.match(plan.next_followup?.collection_key ?? "", /collect:targeted:/);
assert.match(plan.next_followup?.spoken_prompt ?? "", /还能把|能把 04:48 和 05:07 分开/);
const idleAccounting = idleHandlers(dossier);
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: idleAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
const host = persisted.hostNarration ?? "";
assert.ok(host.trim(), "answer/idle transaction must leave a carrier");
assert.match(host, /家里|收入|搬家|感情|还能再收窄|添丁|住院/);
assert.doesNotMatch(host, /这次给出|最终|做不了|才会变|没有拿到下一个问题/);
assert.equal(persisted.choiceReady, false);
for (const phrase of COLLECT_FLOW_BANNED_PHRASES) {
if (phrase === "领域") continue;
assert.equal(host.includes(phrase), false, phrase);
}
resetRefreshDiscriminatorProbesForTests();
});
test("T1: sixth-answer persist refreshes a dated family probe without changing the candidate set", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => {
const nextCount = (state.refresh_count ?? 0) + 1;
return {
state: {
...state,
refresh_count: nextCount,
probes: [...state.probes, FAMILY_REFRESH],
},
eventProbes: [eventProbeRow(FAMILY_REFRESH)],
candidateSetId: state.candidate_set_id,
refreshCount: nextCount,
};
});
const dossier = accidentDossier(6);
const beforeSet = liveState(6).candidate_set_id;
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const accounting = idleHandlers(dossier);
const next = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
});
assert.equal(next.choiceReady, true, next.hostNarration);
assert.match(next.hostNarration, /2018|家里|添丁|住院/);
assert.doesNotMatch(next.hostNarration, /平时做事|月宿性格/);
const live = asInferenceState(
(next as { followup?: { semantic_key?: string } }).followup
? dossier.latestResult?.decisionReceipt?.inference_state
: liveState(6),
);
assert.equal(beforeSet, candidateSetId("04:48", "05:07", TIMES));
assert.equal(live?.candidate_set_id, beforeSet);
assert.equal(liveState(6).rounds.every((item) => item.kind === "informative"), true);
assert.match(next.followup?.semantic_key ?? "", /family\.2018|finance\.|relocation\./);
assert.ok((next.followup?.probe_year ?? 0) >= 2015);
assert.ok((next.followup?.probe_year ?? 0) <= 2026);
resetRefreshDiscriminatorProbesForTests();
});
test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers the range card", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
}));
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const accounting = idleHandlers(dossier, { throwOnFocus: true });
const { result, lines } = await warnLines(() => persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
}));
const next = result as Awaited<ReturnType<typeof persistNextInterviewAfterChoice>>;
assert.ok((next.hostNarration ?? "").trim(), "BUG-652: never silent empty carrier");
const skippedDirect = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: dossier.latestResult?.decisionReceipt ?? null,
followup: {
method_id: "d10_career",
intent: "distinguish_candidates",
ask_theme: "career_style",
domain: "career",
kind_hint: null,
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
source: "event_probe",
semantic_key: D10_STYLE.semantic_key,
choice_kind: "varga_style",
},
});
assert.equal(skippedDirect.status, "skipped");
const declined = accidentDossier(6, {
refreshCount: 1,
declinedTopics: [TARGETED_DECLINED],
});
const delivered = decideFromDossier(declined, { birthDate: "1997-08-08" });
assert.ok(
delivered.nextAction === "offer_provisional_range"
|| delivered.nextAction === "ready_to_adopt"
|| delivered.nextAction === "complete_with_range",
delivered.nextAction,
);
assert.equal(delivered.canOfferRange, true);
const catalog = rectificationFollowupCatalog(declined.latestResult, declined.evidence);
assert.equal(
targetedCollectPool(
catalog.remainingLayers,
declined.evidence,
declined.conversationSummary.declinedSkippedTopics,
catalog.remainingSplitTimes,
).length,
0,
);
assert.ok(skippedDirect.status === "skipped" || lines.length >= 0);
resetRefreshDiscriminatorProbesForTests();
});
test("T4: exhausted refresh and declined targeted collect titles the card 目前范围", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
const dossier = accidentDossier(6, {
refreshCount: 1,
declinedTopics: [TARGETED_DECLINED],
});
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range",
decision.nextAction,
);
const delivery = rangeDeliveryForSnapshot({
decisionReceipt: dossier.latestResult?.decisionReceipt,
candidates: dossier.latestResult?.candidates,
representativeTime: decision.representativeTime,
credibleRange: decision.credibleRange,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
});
assert.ok((delivery.columns?.length ?? 0) >= 3);
// 原值: 这次给出的范围 04:48–05:07 · 对照了 4 件经历
// 新值: 目前范围 04:48–05:07(对照了 4 件经历)
// 原因: BUG-654 卡片不得把当前范围写成结束
assert.equal(RECTIFICATION_USER_COPY.rangeDeliveryTitle, "目前范围");
const title = `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${delivery.range?.[0]}${delivery.range?.[1]}(对照了 ${delivery.event_count} 件经历)`;
assert.match(title, /^目前范围 04:4805:07(对照了 4 件经历)$/);
assert.doesNotMatch(title, /这次给出|最终|结束| · /);
assert.match(delivery.narrow_hint ?? "", /还能再收窄:如果记得/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /这次给出|最终/);
const publicAction = publicNextAction(decision);
assert.equal(publicAction.can_offer_range, true);
const nextUser = buildNextUserAction({
scorableCount: dossier.evidence.length,
evidenceCount: dossier.evidence.length,
hasLatestResult: true,
selectionAllowed: publicAction.can_adopt,
sessionOutcome: decision.sessionOutcome,
nextFollowup: followupPlan(dossier, decision.sessionOutcome).next_followup,
workingTime: decision.representativeTime,
});
assert.ok(
nextUser.id === "offer_provisional_range" || nextUser.id === "adopt_representative",
nextUser.id,
);
assert.equal(rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
collectWaiting: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
offerAwaitingReader: publicAction.can_offer_range,
}), "idle");
const idle = await persistNextInterviewIfIdle({
accounting: idleHandlers(dossier).client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.match(idle.hostNarration ?? "", /目前范围|还能再收窄/);
assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/);
assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/);
});
test("T0: GET-selected receipt probe is rejected until refresh merges it into inference_state", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
const base = accidentDossier(6);
const mismatched = {
...base,
latestResult: {
...base.latestResult!,
decisionReceipt: {
...(base.latestResult?.decisionReceipt ?? {}),
discriminating_event_probes: [
...((base.latestResult?.decisionReceipt?.discriminating_event_probes as DiscriminatingEventProbe[] | undefined) ?? []),
eventProbeRow(FAMILY_REFRESH),
],
},
},
};
const getDecision = decideFromDossier(mismatched, { birthDate: "1997-08-08" });
const getPlan = followupPlan(mismatched, getDecision.sessionOutcome);
const getKey = getPlan.next_followup?.semantic_key ?? null;
const persist = await persistServerOwnedFocus({
accounting: idleHandlers(mismatched).client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: mismatched.latestResult?.decisionReceipt ?? null,
followup: getPlan.next_followup,
});
console.log("T0 GET probe vs persist rejection", { getKey, persistStatus: persist.status });
assert.equal(getKey, FAMILY_REFRESH.semantic_key, String(getKey));
assert.equal(persist.status, "invalid_choice_schema", persist.status);
});
test("T0: last inference row and GET probe key vs persist status after a real refresh", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => {
const nextCount = (state.refresh_count ?? 0) + 1;
return {
state: { ...state, refresh_count: nextCount },
eventProbes: [eventProbeRow(FAMILY_REFRESH)],
candidateSetId: state.candidate_set_id,
refreshCount: nextCount,
};
});
const dossier = accidentDossier(6);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const transitions: Record<string, unknown>[] = [];
const accounting = idleHandlers(dossier, { transitions });
const next = await persistNextInterviewAfterChoice({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
});
const last = transitions.at(-1);
const inference = last?.p_inference_state as {
candidates?: unknown[];
candidate_set_id?: string;
refresh_count?: number;
probes?: ReadonlyArray<{ id: string; semantic_key: string }>;
} | undefined;
const lastRow = {
reason: last?.p_reason,
candidates: inference?.candidates?.length ?? 0,
candidate_set_id: last?.p_candidate_set_id ?? inference?.candidate_set_id,
refresh_count: inference?.refresh_count ?? 0,
};
console.log("T0 last inference row", lastRow);
assert.equal(lastRow.reason, "supersede");
assert.equal(lastRow.candidates, TIMES.length);
assert.equal(lastRow.candidate_set_id, liveState(6).candidate_set_id);
assert.equal(lastRow.refresh_count, 1);
const merged = inference?.probes?.find((item) => item.semantic_key === FAMILY_REFRESH.semantic_key);
assert.equal(
merged?.id,
alignedProbeId(FAMILY_REFRESH, liveState(6).answered_probes),
merged?.id,
);
const refreshedDossier = {
...dossier,
latestResult: {
...dossier.latestResult!,
decisionReceipt: {
...(dossier.latestResult?.decisionReceipt ?? {}),
inference_state: inference,
discriminating_event_probes: [eventProbeRow(FAMILY_REFRESH)],
},
},
};
const getDecision = decideFromDossier(refreshedDossier, { birthDate: "1997-08-08" });
const getPlan = followupPlan(refreshedDossier, getDecision.sessionOutcome);
const getKey = getPlan.next_followup?.semantic_key ?? null;
const persist = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: refreshedDossier.latestResult?.decisionReceipt ?? null,
followup: getPlan.next_followup,
});
console.log("T0 GET probe vs persist", { getKey, persistStatus: persist.status });
assert.equal(getKey, FAMILY_REFRESH.semantic_key, String(getKey));
assert.ok(
persist.status === "created" || persist.status === "already_open",
persist.status,
);
assert.equal(next.choiceReady, true, next.hostNarration);
resetRefreshDiscriminatorProbesForTests();
});
test("T3: refresh without new engine probes does not write inference", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
}));
const dossier = accidentDossier(6);
const transitions: Record<string, unknown>[] = [];
const idle = await persistNextInterviewIfIdle({
accounting: idleHandlers(dossier, { transitions }).client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(transitions.length, 0, JSON.stringify(transitions.at(-1) ?? {}));
assert.ok((idle.hostNarration ?? "").trim());
resetRefreshDiscriminatorProbesForTests();
});
test("T3: changed candidate set is not persisted even when engine returns a probe", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, candidate_set_id: "changed-set" },
eventProbes: [eventProbeRow(FAMILY_REFRESH)],
candidateSetId: "changed-set",
refreshCount: 1,
}));
const dossier = accidentDossier(6);
const transitions: Record<string, unknown>[] = [];
await persistNextInterviewAfterChoice({
accounting: idleHandlers(dossier, { transitions }).client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
decisionState: liveState(6),
nextAction: publicNextAction(decideFromDossier(dossier, { birthDate: "1997-08-08" })),
birthDate: "1997-08-08",
});
assert.equal(transitions.length, 0);
resetRefreshDiscriminatorProbesForTests();
});
test("T3: empty candidate list is not persisted even when engine returns a probe", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, candidates: [] },
eventProbes: [eventProbeRow(FAMILY_REFRESH)],
candidateSetId: state.candidate_set_id,
refreshCount: 1,
}));
const dossier = accidentDossier(6);
const transitions: Record<string, unknown>[] = [];
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
accounting: idleHandlers(dossier, { transitions }).client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
state: liveState(6),
hasDatedProbe: false,
});
assert.equal(refreshed.refreshed, false);
assert.equal(transitions.length, 0);
resetRefreshDiscriminatorProbesForTests();
});
test("T3: merged probe ids follow the answered naming rule", () => {
const hashedIncoming = {
...FAMILY_REFRESH,
id: `probe:${FAMILY_REFRESH.semantic_key}:${FAMILY_REFRESH.candidate_split_hash}`,
};
assert.equal(
alignedProbeId(hashedIncoming, liveState(6).answered_probes),
`probe:${FAMILY_REFRESH.semantic_key}`,
);
const hashedAnswers = liveState(6).answered_probes.map((item) => ({
...item,
probe_id: `probe:${item.semantic_key}:${item.candidate_split_hash}`,
}));
assert.equal(
alignedProbeId(FAMILY_REFRESH, hashedAnswers),
`probe:${FAMILY_REFRESH.semantic_key}:${FAMILY_REFRESH.candidate_split_hash}`,
);
});