Files
Jyotisha/frontend/tests/rectification-exhaustion-exit-20260906.test.ts
T

872 lines
32 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 { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import {
applyRectificationChoice,
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { CHOICE_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { finalizeSuccessfulTurnExit } from "../src/lib/rectification-agentic/v9/turn-exit.ts";
import {
CASE_ID,
FOCUS_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
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-edu-start",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "education start",
},
{
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: "education completion",
},
{
id: "e-rel-start",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "month" as const,
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "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: "relationship end",
},
{
id: "e-finance",
status: "confirmed" as const,
domain: "finance",
datePrecision: "month" as const,
occurredFrom: "2026-01-01",
occurredTo: "2026-01-31",
eventKind: "finance_loss",
summary: "finance change",
},
{
id: "e-reloc",
status: "confirmed" as const,
domain: "relocation",
datePrecision: "month" as const,
occurredFrom: "2023-07-01",
occurredTo: "2023-07-31",
eventKind: "relocation",
summary: "relocation",
},
{
id: "e-health",
status: "confirmed" as const,
domain: "health_pressure",
datePrecision: "month" as const,
occurredFrom: "2026-01-01",
occurredTo: "2026-08-31",
eventKind: "pressure_period",
summary: "health pressure",
},
{
id: "e-occupation",
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
summary: "occupation note",
},
] as const;
function uuidAt(index: number) {
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
}
function existenceProbe(input: {
key: string;
domain: string;
year: number;
question: string;
}): 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: [...ACTIVE],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: ["04:47"], conflicts: ["04:51"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const D9: ConflictProbe = {
id: "contrast:varga.d9.style",
semantic_key: "varga.d9.style",
candidate_split_hash: "varga.d9.style",
domain: "relationship",
year: 0,
question: "亲密关系里更接近哪一种",
candidate_ids: [...ACTIVE],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
{ answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.3,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10: ConflictProbe = {
id: "contrast:varga.d10.style",
semantic_key: "varga.d10.style",
candidate_split_hash: "varga.d10.style",
domain: "career",
year: 0,
question: "平时做事更接近哪一种",
candidate_ids: [...ACTIVE],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
{ answer_class: "weak_yes", supports: ["04:47"], conflicts: ["04:51"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.3,
source: "varga_contrast",
choice_kind: "varga_style",
};
const CAREER_MONTH = existenceProbe({
key: "career.2023.05.dasha_boundary",
domain: "career",
year: 2023,
question: "2023 年 5 月前后有没有入职或换工作",
});
const CAREER_YEAR = existenceProbe({
key: "career.2023.dasha_activation",
domain: "career",
year: 2023,
question: "2023 年前后工作上有没有入职或换工作",
});
const RELOC = existenceProbe({
key: "relocation.2018.05.dasha_boundary",
domain: "relocation",
year: 2018,
question: "2018 年 5 月前后有没有搬家",
});
const FINANCE = existenceProbe({
key: "finance.2024.03.dasha_boundary",
domain: "finance",
year: 2024,
question: "2024 年 3 月前后钱上有没有明显变化",
});
const ASKED_PROBES = [D9, D10, CAREER_MONTH, CAREER_YEAR, RELOC, FINANCE];
const LEFTOVER_PROBE = existenceProbe({
key: "career.2021.04.dasha_boundary",
domain: "career",
year: 2021,
question: "2021 年 4 月前后有没有入职或换工作",
});
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const GATE_SENTENCE = /还差(?: \d+ 件)?带月份的经历|两件事的日期还没对清|当前还排不出可比较的候选时间|还差另一个领域的带月份经历/;
function liveState(extraProbes: readonly ConflictProbe[] = []): InferenceState {
const probes = [...ASKED_PROBES, ...extraProbes];
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 = [
{ probe: D9, answer_class: "yes" as const },
{ probe: D10, answer_class: "yes" as const },
{ probe: CAREER_MONTH, answer_class: "yes" as const },
{ probe: CAREER_YEAR, answer_class: "no" as const },
{ probe: RELOC, answer_class: "no" as const },
{ probe: FINANCE, answer_class: "no" as const },
].map((item) => ({
probe_id: item.probe.id,
semantic_key: item.probe.semantic_key,
candidate_split_hash: item.probe.candidate_split_hash,
answer_class: item.answer_class,
classified_from: "choice" as const,
}));
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
revision: 6,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:47",
range_end: "05:15",
candidates,
events: [
{ id: "e-edu-start", domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: "e-edu-end", domain: "education", 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: "holdout" as const },
{ id: "e-finance", domain: "finance", year: 2026, precision: "month" as const, usage: "training" as const },
{ id: "e-reloc", domain: "relocation", year: 2023, precision: "month" as const, usage: "training" as const },
{ id: "e-health", domain: "health_pressure", year: 2026, precision: "month" 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, "accident-shape inference must pass asInferenceState");
return loaded;
}
function eventProbeRow(probe: ConflictProbe) {
return {
year: probe.year,
year_label: probe.year > 0 ? `${probe.year} 年前后` : "",
domain: probe.domain,
event_family: probe.domain === "career" ? "入职、换工作或职责加重" : probe.domain,
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,
};
}
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 accidentDossier(extra: {
acceptanceAllowed?: boolean;
acceptanceReasons?: string[];
mismatchSnapshot?: boolean;
leftoverProbe?: ConflictProbe;
holdoutUnavailable?: boolean;
} = {}): DecisionDossier {
const base = liveState(extra.leftoverProbe ? [extra.leftoverProbe] : []);
const evidence = extra.holdoutUnavailable
? EVIDENCE.filter((item) => item.id !== "e-rel-end")
: EVIDENCE;
const state = {
...base,
events: extra.holdoutUnavailable
? base.events.filter((item) => item.usage !== "holdout" && item.id !== "e-rel-end")
: base.events,
holdout_passed: extra.holdoutUnavailable ? true : base.holdout_passed,
};
const fingerprint = evidenceLedgerFingerprint(evidence as never);
const acceptanceAllowed = extra.acceptanceAllowed !== false;
return {
evidence,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [{ target_domain: "family", status: "declined" }],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: acceptanceAllowed,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: extra.mismatchSnapshot
? [{ candidateId: uuidAt(0), time: "03:00", rank: 1, relativeSupport: 10 }]
: TIMES.map((time, index) => ({
candidateId: uuidAt(index),
time,
rank: index + 1,
relativeSupport: Math.round(SCORES[time] ?? 0),
})),
representativeTime: "04:51",
decisionReceipt: {
accept_allowed: acceptanceAllowed,
acceptance_allowed: acceptanceAllowed,
propose_allowed: extra.acceptanceAllowed === false ? false : true,
selection_allowed: extra.acceptanceAllowed === false ? false : true,
confirmation_allowed: false,
...(extra.acceptanceReasons ? { acceptance_reasons: extra.acceptanceReasons } : {}),
inference_state: state,
discriminating_event_probes: [
...ASKED_PROBES.map(eventProbeRow),
...(extra.leftoverProbe ? [eventProbeRow(extra.leftoverProbe)] : []),
],
oos_blind_prompts: extra.holdoutUnavailable
? []
: [{
domain: "career",
user_meaning: "工作这条线还没用过。有没有记得大概时间的入职或换工作?",
used_for_scoring: false,
}],
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
};
}
function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType<typeof activeFocusFixture> } = {}) {
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-06T00:00:00.000Z",
}));
return dossierFixture({
evidence,
latestResult: candidateSnapshotFixture({
selectionAllowed: decision.latestResult?.selectionAllowed ?? true,
confirmationAllowed: false,
representativeTime: "04:51",
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,
})) ?? snapshotCandidates(),
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: extra.activeFocus ?? null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-06T00:00:00.000Z",
},
});
}
function idleHandlers(decision: DecisionDossier, extra: { activeFocus?: ReturnType<typeof activeFocusFixture>; allowFocus?: boolean } = {}) {
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: args.p_inference && typeof args.p_inference === "object"
? (args.p_inference as { probe_id?: string }).probe_id ?? "p-cd"
: "p-cd",
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.allowFocus
? (_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-06T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
})
: (_fn, args) => {
throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`);
},
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
get_agentic_rectification_turn_receipt: () => null,
});
}
function leftoverFocus(probe: ConflictProbe) {
return activeFocusFixture({
intent: "distinguish_candidates",
targetDomain: probe.domain,
questionId: `probe:${probe.semantic_key}`,
expectedAnswerSchema: {
choice: {
prompt: probe.question,
option_a: EXISTENCE_OPTIONS[0].label,
option_b: EXISTENCE_OPTIONS[1].label,
option_c: EXISTENCE_OPTIONS[2].label,
option_d: EXISTENCE_OPTIONS[3].label,
options: EXISTENCE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
},
});
}
function assistantAppendCalls(calls: Array<{ fn: string; args: Record<string, unknown> }>) {
return calls.filter((item) => (
item.fn === "append_agentic_rectification_turn"
&& typeof item.args.p_assistant_message === "string"
&& String(item.args.p_assistant_message).trim()
));
}
function gateAppendCalls(calls: Array<{ fn: string; args: Record<string, unknown> }>) {
return assistantAppendCalls(calls).filter((item) => GATE_SENTENCE.test(String(item.args.p_assistant_message)));
}
function gateBodyCount(text: string | null | undefined) {
return (text ?? "").match(new RegExp(GATE_SENTENCE.source, "g"))?.length ?? 0;
}
function fakeAgentStream(chunks: Array<{ type: string; payload?: Record<string, unknown> }>) {
const streamResult = {
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
};
return {
stream: async () => streamResult,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
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 }));
}
test("skill version is 10.0.19 after the delivery UI simplify bump", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.19");
});
test("USER_COLLECT_QUESTION no longer has an other fallback", () => {
const srcRoot = new URL("../src/", import.meta.url);
const files = [
"lib/rectification-agentic/v9/method-followup.ts",
"lib/rectification-agentic/v9/answer-choice.ts",
"mastra/rectification-v9-tools.ts",
"app/api/rectification/agent/route.ts",
"lib/rectification-agentic/user-copy.ts",
];
const hits: string[] = [];
for (const relative of files) {
const source = readFileSync(new URL(relative, srcRoot), "utf8");
for (const line of source.split("\n")) {
if (line.includes("USER_COLLECT_QUESTION.other") || line.includes("collectQuestionRetryByDomain.other")) {
hits.push(`${relative}: ${line.trim()}`);
}
}
}
assert.deepEqual(hits, []);
});
test("collect spoken stop stays on choice cards; range line is status only", () => {
// 原值: 口述停止按钮和范围小字共用 CHOICE_STOP_LABEL / onStop
// 新值: CHOICE_STOP_LABEL 只在选择题卡;composer-wrap 不再挂先这样
// 原因: BUG-595 决策 2
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const rangeFn = chat.slice(
chat.indexOf("function RectificationReadonlyRange"),
chat.indexOf("type RectificationAgenticChatProps"),
);
const wrap = chat.slice(chat.indexOf("className=\"composer-wrap\""), chat.indexOf("className=\"composer-footer\""));
assert.doesNotMatch(wrap, /rectification-collect-stop/);
assert.match(chat, /CHOICE_STOP_LABEL/);
assert.match(chat, /kind === "collect_spoken"/);
assert.match(chat, /function submitStop/);
assert.match(chat, /RectificationReadonlyRange/);
assert.match(rangeFn, /role="status"/);
assert.doesNotMatch(rangeFn, /onStop/);
assert.doesNotMatch(rangeFn, /<button/);
assert.match(route, /ask_about_result/);
assert.match(route, /STOP_ACTION/);
assert.equal(CHOICE_STOP_LABEL, "先这样,先看当前范围");
});
test("covered accident shape adopts instead of collecting other", async () => {
const dossier = accidentDossier();
const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" });
assert.notEqual(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.match(decision.stopReason ?? "", /probe_pool_exhausted/);
const accounting = idleHandlers(dossier);
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.ok(persisted.hostNarration);
// 原值: probePoolExhaustedStop「能分开候选的问题已经问完…」
// 新值: 三句交付正文
// 原因: BUG-595 决策 4
assert.match(persisted.hostNarration ?? "", /这次给出的范围 04:4704:53/);
assert.doesNotMatch(persisted.hostNarration ?? "", /排盘用/);
assert.match(persisted.hostNarration ?? "", /对照了 7 件经历/);
assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false);
assert.match(persisted.hostNarration ?? "", /这只是代表性候选,不是已确认的唯一出生分钟/);
assert.equal(persisted.terminalNote, true);
});
test("closed ceiling translates the gate and does not persist a focus", async () => {
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
holdoutUnavailable: true,
});
const accounting = idleHandlers(dossier);
const { result: idle, lines } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.ok(persisted.hostNarration);
assert.match(persisted.hostNarration ?? "", /还差|带月份的经历|日期还没对清|排不出可比较的候选/);
assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false);
const repaired = await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.ok(repaired.hostNarration === null || repaired.terminalNote !== false);
const log = lines.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return null;
}
}).find((item) => item?.event === "rectification_exhaustion_collect");
assert.ok(log);
for (const key of [
"event", "case_id", "can_adopt", "ceiling", "training_gate",
"stop_class", "ranked_count", "probe_key", "budget", "next_domain", "next_source",
]) {
assert.ok(key in (log ?? {}), key);
}
assert.equal("summary" in (log ?? {}), false);
assert.equal("year" in (log ?? {}), false);
assert.equal("evidence" in (log ?? {}), false);
});
test("inconsistent projection logs ranked_count 0 and still delivers a gate", async () => {
const dossier = accidentDossier({ mismatchSnapshot: true, holdoutUnavailable: true });
const accounting = idleHandlers(dossier);
const { result: idle, lines } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.equal((persisted.hostNarration ?? "").includes("也可以再" + "说一件"), false);
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
const log = lines.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return null;
}
}).find((item) => item?.event === "rectification_exhaustion_collect");
assert.equal(log?.ranked_count, 0);
assert.match(persisted.hostNarration ?? "", /还差|带月份|排不出可比较的候选|范围/);
});
test("finalizeSuccessfulTurnExit writes one gate body and repair does not add another", async () => {
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
holdoutUnavailable: true,
});
const accounting = idleHandlers(dossier);
await warnLines(() => finalizeSuccessfulTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
action: "message",
}));
const first = gateAppendCalls(accounting.calls);
assert.equal(first.length, 1, JSON.stringify(first.map((item) => item.args.p_assistant_message)));
assert.equal(gateBodyCount(String(first[0]?.args.p_assistant_message)), 1);
const beforeRepair = accounting.calls.filter((item) => item.fn === "append_agentic_rectification_turn").length;
await ensureNonTerminalTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(
accounting.calls.filter((item) => item.fn === "append_agentic_rectification_turn").length,
beforeRepair,
);
});
test("agent evidence path writes one gate body", async () => {
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
holdoutUnavailable: true,
});
const accounting = idleHandlers(dossier);
const result = await runV9AgentTurn({
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "2016年9月离开家去北京工作",
modelName: "gpt-4o-mini",
accounting: accounting.client,
billing: {
reserve: async () => ({ success: true, status: 200 }),
complete: async () => true,
release: async () => true,
},
emit: async () => {},
buildAgent: async () => fakeAgentStream([
{ type: "start" },
{ type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } },
{ type: "tool-result", payload: { toolName: "skill" } },
{ type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } },
{ type: "tool-result", payload: { toolName: "rectification-read-case" } },
{ type: "text-delta", payload: { text: "先记下这件事。" } },
{ type: "finish" },
]) as never,
});
assert.equal(result.ok, true);
// 原值: persistExhaustionGateTurn 再写一条门槛句
// 新值: 本轮已有 agent 正文(askedTurnId)时不再写第二条确定性门槛 turn
// 原因: BUG-596
const gates = gateAppendCalls(accounting.calls);
assert.equal(gates.length, 0, JSON.stringify(gates.map((item) => item.args.p_assistant_message)));
});
test("last closed-ceiling card concatenates the gate into one append", async () => {
const dossier = accidentDossier({
acceptanceAllowed: false,
acceptanceReasons: ["insufficient_events"],
leftoverProbe: LEFTOVER_PROBE,
holdoutUnavailable: true,
});
const accounting = idleHandlers(dossier, {
activeFocus: leftoverFocus(LEFTOVER_PROBE),
});
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: CHOICE_ACTION,
focusId: FOCUS_ID,
questionId: `probe:${LEFTOVER_PROBE.semantic_key}`,
probeId: LEFTOVER_PROBE.id,
optionId: "A",
expectedRevision: 6,
});
const gates = gateAppendCalls(accounting.calls);
assert.equal(gates.length, 1, JSON.stringify(gates.map((item) => item.args.p_assistant_message)));
assert.equal(gateBodyCount(String(gates[0]?.args.p_assistant_message)), 1);
assert.match(applied.narration, GATE_SENTENCE);
assert.equal(gateBodyCount(applied.narration), 1);
});
test("closed ceiling with a leftover discriminator persists the card not the gate", async () => {
const dossier = accidentDossier({
acceptanceAllowed: false,
leftoverProbe: LEFTOVER_PROBE,
});
const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" });
assert.equal(decision.nextAction, "ask_candidate_discriminator");
const accounting = idleHandlers(dossier, { allowFocus: true });
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(focusCalls.length > 0, true);
assert.match(String(focusCalls[0]?.args.p_question_id ?? ""), /career\.2021/);
assert.equal(focusCalls[0]?.args.p_intent, "distinguish_candidates");
assert.equal(gateAppendCalls(accounting.calls).length, 0);
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
assert.match(persisted.hostNarration ?? "", /2021/);
});
test("closed ceiling with holdout still open persists holdout not the gate", async () => {
const dossier = accidentDossier({ acceptanceAllowed: false });
const decision = decideFromDossier(dossier, { birthDate: "1998-03-15" });
assert.equal(decision.nextAction, "ask_holdout_validation");
const accounting = idleHandlers(dossier, { allowFocus: true });
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(focusCalls.length > 0, true);
assert.equal(focusCalls[0]?.args.p_intent, "collect_method_evidence");
assert.equal(focusCalls[0]?.args.p_target_domain, "career");
assert.equal(gateAppendCalls(accounting.calls).length, 0);
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
assert.match(persisted.hostNarration ?? "", /入职|换工作|工作/);
});