Files
Jyotisha/frontend/tests/rectification-range-offer-deadend.test.ts
T
Jesse_Chen 75fc456d7e
Independent Staging Quality Gate / validate (push) Successful in 10m23s
Independent Staging Quality Gate / publish (push) Failing after 8m59s
fix(rectification): drop duplicate collect cards and false run_failed
Spoken collect no longer renders a second visual prompt; choice legends stay screen-reader only and live cards share the assistant inset. Exhaustion collect avoids colliding with the opening question id, and a successful billed turn no longer surfaces run_failed after the exit gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 15:19:00 +08:00

668 lines
25 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 { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import {
inspectDiscriminatorProbes,
buildCandidateContrastPacket,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import {
decideRectification,
isNonConvergingRangeOffer,
nonConvergingRangeNarration,
REPRESENTATIVE_MINUTE_DISCLAIMER,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideAfterInferenceChange,
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
exhaustionSpokenCollectFollowup,
projectRectificationChoiceCard,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { informationGainAmongActive } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
OPEN_ENGINE_CAPABILITY_CEILING,
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 TIMES = [
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
] as const;
const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]);
const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const;
const SCORES: Record<string, number> = {
"04:47": 16,
"04:51": 20,
"04:53": 16,
"04:59": 10,
"05:00": 4,
"05:07": 3,
"05:12": 2,
"05:14": 1,
"05:15": 1,
};
const PROBABILITY: Record<string, number> = {
"04:47": 0.25,
"04:51": 0.4,
"04:53": 0.25,
"04:59": 0.1,
};
const EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2020-04-01 career_entry",
},
{
id: "e-career-exit",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
summary: "2020-10-01 career_exit",
},
{
id: "e-rel-start",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "month" as const,
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "2024-05-01 relationship_start",
},
{
id: "e-rel-end",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "day" as const,
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
summary: "2024-08-08 relationship_end",
},
] as const;
const DUAL_EXIT = "可以先按当前区间看盘,也可以再补一件记得时间的经历";
function existenceProbe(input: {
key: string;
year: number;
question: string;
gain: number;
source: string;
yes: readonly string[];
no: readonly string[];
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: "career",
year: input.year,
question: input.question,
candidate_ids: [...new Set([...input.yes, ...input.no])],
expected_outcomes: [
{ answer_class: "yes", supports: input.yes, conflicts: input.no },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: input.no, conflicts: input.yes },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: input.gain,
source: input.source,
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const D9: ConflictProbe = {
id: "contrast:varga.d9.巨蟹座/狮子座",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
domain: "relationship",
year: 0,
question: "亲密关系里更接近下面哪一种相处方式?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.1,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10: ConflictProbe = {
id: "contrast:varga.d10.天秤座/天蝎座",
semantic_key: "varga.d10.天秤座/天蝎座",
candidate_split_hash: "varga.d10.天秤座/天蝎座",
domain: "career",
year: 0,
question: "平时做事更接近下面哪一种职责风格?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.05,
source: "varga_contrast",
choice_kind: "varga_style",
};
const CAREER_2023_05 = existenceProbe({
key: "career.2023.05.dasha_boundary",
year: 2023,
question: "2023 年 5 月前后有没有入职或换工作?",
gain: 0.9,
source: "dasha_boundary",
yes: ["05:00"],
no: ["05:07"],
});
const RELOCATION_2015_05: ConflictProbe = {
...existenceProbe({
key: "relocation.2015.05.dasha_boundary",
year: 2015,
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
gain: 0.7,
source: "dasha_boundary",
yes: ["05:00"],
no: ["05:07"],
}),
domain: "relocation",
};
/** Live remaining probe: splits after/before 05:00. Zero split on 04:4704:59. */
const CAREER_2024_04 = existenceProbe({
key: "career.2024.04.dasha_boundary",
year: 2024,
question: "2024 年 4 月前后有没有入职或换工作?",
gain: 1.1712,
source: "dasha_boundary",
yes: ["05:00", "05:07", "05:12", "05:14", "05:15"],
no: ["04:47", "04:51", "04:53", "04:59"],
});
/** Live remaining probe: isolates 05:15. Zero split on 04:4704:59. */
const CAREER_2023_ACTIVATION = existenceProbe({
key: "career.2023.dasha_activation",
year: 2023,
question: "2023 年前后大运有没有启动?",
gain: 0.56,
source: "dasha_activation",
yes: ["05:15"],
no: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
});
/** P1 fixture: dated career probe that still splits the active four minutes. */
const CAREER_ACTIVE_SPLIT = existenceProbe({
key: "career.2022.dasha_boundary",
year: 2022,
question: "2022 年前后有没有入职或换工作?",
gain: 1.4,
source: "dasha_boundary",
yes: ["04:47", "04:51"],
no: ["04:53", "04:59"],
});
function uuidAt(index: number): string {
return `88888888-8888-4888-8888-8888888888${(10 + index).toString(16).padStart(2, "0")}`;
}
function liveState(remaining: readonly ConflictProbe[]): InferenceState {
const probes = [D9, D10, CAREER_2023_05, RELOCATION_2015_05, ...remaining];
const rankedActive = [...ACTIVE].sort((left, right) => (
(PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0)
|| (SCORES[right] ?? 0) - (SCORES[left] ?? 0)
|| left.localeCompare(right)
));
const candidates = TIMES.map((time, index) => {
const eliminated = ELIMINATED.has(time);
const activeRank = (rankedActive as readonly string[]).indexOf(time);
return {
id: time,
time,
cluster_range: [time, time] as const,
prior_score: SCORES[time] ?? 0,
posterior_score: SCORES[time] ?? 0,
probability: eliminated ? 0 : (PROBABILITY[time] ?? 0),
status: eliminated ? "eliminated" as const : "active" as const,
rank: eliminated ? ACTIVE.length + index : activeRank + 1,
strong_conflict_count: eliminated ? 3 : 0,
};
});
const answered = [D9, D10, CAREER_2023_05, RELOCATION_2015_05].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,
}));
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
revision: 5,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:47",
range_end: "05:15",
candidates,
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "training" as const },
],
probes,
answered_probes: answered,
rounds: [],
last_inference_round: null,
entropy: 1.2,
representative_time: "04:51",
credible_range: ["04:47", "04:53"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded, "live inference fixture must pass asInferenceState");
return loaded;
}
function liveDossier(
remaining: readonly ConflictProbe[],
extra: {
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
status?: string;
eventProbes?: boolean;
} = {},
): DecisionDossier {
const state = liveState(remaining);
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: extra.declinedTopics ?? [{ target_domain: "family", status: "declined" }],
},
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: Math.round(SCORES[time] ?? 0),
})),
representativeTime: "04:51",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
...(extra.eventProbes
? {
discriminating_event_probes: remaining.map((probe) => ({
year: probe.year,
year_label: `${probe.year} 年前后`,
domain: probe.domain,
event_family: "入职、换工作或职责加重",
source: probe.source,
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,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
})),
}
: {}),
},
},
case: { acceptedTime: null, status: extra.status },
};
}
function snapshotCandidates() {
return TIMES.map((time, index) => ({
candidate_id: uuidAt(index),
rank: index + 1,
time,
relative_support: Math.round(SCORES[time] ?? 0),
tied_minute_count: 1,
}));
}
function rpcDossier(decision: DecisionDossier) {
const evidence = EVIDENCE.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: `${item.occurredFrom} ${item.eventKind}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-08-29T00:00:00.000Z",
}));
return dossierFixture({
evidence,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
candidates: snapshotCandidates(),
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
...(decision.latestResult?.decisionReceipt ?? {}),
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-08-30T00:00:00.000Z",
},
});
}
test("skill version stays 10.0.14 for the range-offer dead-end fix", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14");
});
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
const decision = "../src/lib/rectification-agentic/core/rectification-decision.ts";
const answer = "../src/lib/rectification-agentic/v9/answer-choice.ts";
const route = "../src/app/api/rectification/agent/route.ts";
for (const relative of [decision, answer, route]) {
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
assert.doesNotMatch(source, new RegExp(DUAL_EXIT));
assert.doesNotMatch(source, /NON_CONVERGING_RANGE_NARRATION|DISCRIMINATOR_EXHAUSTED_NARRATION/);
assert.match(source, /nonConvergingRangeNarration/);
}
const text = nonConvergingRangeNarration({
credibleRange: ["04:47", "04:53"],
representativeTime: "04:51",
});
assert.match(text, /04:4704:53/);
assert.match(text, /04:51/);
assert.match(text, new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
assert.doesNotMatch(text, /可以先按当前区间看盘/);
});
test("zero-split among active is dropped with an explicit reason, not kept silent", () => {
const after = informationGainAmongActive(CAREER_2024_04.expected_outcomes, ACTIVE);
const activation = informationGainAmongActive(CAREER_2023_ACTIVATION.expected_outcomes, ACTIVE);
const split = informationGainAmongActive(CAREER_ACTIVE_SPLIT.expected_outcomes, ACTIVE);
assert.equal(after.splits, false);
assert.equal(activation.splits, false);
assert.equal(split.splits, true);
const packet = buildCandidateContrastPacket({
candidateSetVersion: candidateSetId("04:47", "05:15", TIMES),
engineProbes: [CAREER_2024_04, CAREER_2023_ACTIVATION].map((probe) => ({
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
domain: probe.domain,
year: probe.year,
user_meaning: probe.question,
information_gain: probe.information_gain,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
})),
providedDomains: ["career", "relationship"],
candidateTimes: [...TIMES],
});
assert.equal(packet.probes.some((item) => item.semanticKey === CAREER_2024_04.semantic_key), true);
const inspected = inspectDiscriminatorProbes(packet, { topCandidateTimes: ACTIVE });
assert.equal(inspected.selected, null);
assert.equal(inspected.dropped.some((item) => (
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active"
)), true);
assert.equal(inspected.dropped.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active"
)), true);
});
test("P1: dated career probes live only in inference_state still ask when they split active minutes", () => {
const dossier = liveDossier([CAREER_ACTIVE_SPLIT]);
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(fromDossier.nextAction, "ask_candidate_discriminator");
assert.equal(fromDossier.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
assert.equal(isNonConvergingRangeOffer(fromDossier), false);
const after = decideAfterInferenceChange({
dossier,
state: liveState([CAREER_ACTIVE_SPLIT]),
userStopped: false,
birthDate: "1997-08-08",
});
assert.equal(after.nextAction, "ask_candidate_discriminator");
assert.equal(after.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
});
test("live remaining probes with zero active split enter dropped_probes and the range-offer branch", () => {
const remaining = [CAREER_2024_04, CAREER_2023_ACTIVATION];
const dossier = liveDossier(remaining);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.probe, null);
assert.equal(decision.nextAction, "offer_provisional_range");
assert.equal(isNonConvergingRangeOffer(decision), true);
assert.equal(decision.canAdopt, false);
assert.equal(decision.selectionAllowed, false);
assert.deepEqual(decision.credibleRange, ["04:47", "04:53"]);
assert.equal(decision.representativeTime, "04:51");
assert.equal(decision.droppedProbes.some((item) => (
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active"
)), true);
assert.equal(decision.droppedProbes.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active"
)), true);
});
test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric range", async () => {
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(isNonConvergingRangeOffer(decision), true);
const card = projectRectificationChoiceCard({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
selectionAllowed: decision.selectionAllowed,
});
assert.equal(card, null);
const collect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
});
assert.equal(collect?.domain, "education");
assert.equal(collect?.intent, "collect_method_evidence");
assert.equal(collect?.choice_frame, null);
const spoken = spokenFollowupForUser(collect);
assert.ok(spoken);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_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-08-30T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(idle.persisted, true);
assert.equal(idle.choiceReady, false);
assert.match(idle.hostNarration ?? "", /04:4704:53/);
assert.match(idle.hostNarration ?? "", /04:51/);
assert.match(idle.hostNarration ?? "", new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);
assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT));
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus?.args.p_intent, "collect_method_evidence");
assert.equal(setFocus?.args.p_target_domain, "education");
const schema = setFocus?.args.p_expected_answer_schema as Record<string, unknown> | undefined;
const question = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(setFocus?.args.p_question_id ?? ""),
intent: "collect_method_evidence",
targetDomain: "education",
expectedAnswerSchema: schema ?? null,
});
assert.equal(question?.kind, "collect_spoken");
assert.ok(question?.prompt);
assert.equal(Boolean(idle.hostNarration) && Boolean(question), true);
});
test("userStopped completes with a review-only provisional range", () => {
const stopped = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
userStopped: true,
candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })),
});
assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped");
// 原值是 true;用户停止只结束追问,不能绕过 coverage 和 holdout 门槛。
assert.equal(stopped.canAdopt, false);
assert.equal(stopped.canOfferRange, true);
assert.equal(isNonConvergingRangeOffer(stopped), false);
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" });
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(fromDossier.sessionOutcome, "provisional_range_user_stopped");
// 原值是 true;该 dossier 只有两个同域日期事件,停止不等于证据达标。
assert.equal(fromDossier.canAdopt, false);
});
test("idle persist still decides from the dossier once and does not invent collect_evidence", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const idle = source.slice(
source.indexOf("export async function persistNextInterviewIfIdle"),
source.indexOf("async function persistApplied"),
);
assert.equal(idle.split("decideFromDossier").length - 1, 1);
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
assert.match(idle, /persistExhaustionCollect/);
});
function datedCollectEvidence(domain: string, year: string, extra: { eventKind?: string } = {}) {
return {
status: "confirmed" as const,
domain,
datePrecision: extra.eventKind ? "unknown" as const : "year" as const,
occurredFrom: extra.eventKind ? null : `${year}-01-01`,
occurredTo: null,
...(extra.eventKind ? { eventKind: extra.eventKind } : {}),
};
}
test("exhaustion after family declined and dated domains confirmed asks occupation", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
assert.equal(next?.domain, "occupation");
assert.equal(next?.choice_frame, null);
assert.equal(spokenFollowupForUser(next), "你长期做什么工作?");
});
test("exhaustion generic fallback uses domain other, not unknown", () => {
const next = exhaustionSpokenCollectFollowup({
evidence: [
datedCollectEvidence("education", "2016"),
datedCollectEvidence("career", "2020"),
datedCollectEvidence("relationship", "2018"),
datedCollectEvidence("finance", "2024"),
datedCollectEvidence("relocation", "2022"),
datedCollectEvidence("health_pressure", "2021"),
datedCollectEvidence("occupation", "2020", { eventKind: "occupation_note" }),
],
declinedTopics: [{ target_domain: "family", status: "declined" }],
});
assert.equal(next?.domain, "other");
assert.equal(next?.intent, "collect_method_evidence");
assert.equal(stableFollowupQuestionId(next), "collect:other:collect_method_evidence");
assert.equal(spokenFollowupForUser(next), "可以再说一件记得大概时间的经历。");
});