Stop falling through to "say another event" after dated and occupation collect are done. Deliver an adopt path or a gate sentence, and add a spoken-collect stop control. Co-authored-by: Cursor <cursoragent@cursor.com>
587 lines
20 KiB
TypeScript
587 lines
20 KiB
TypeScript
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, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
|
|
import {
|
|
ensureNonTerminalTurnExit,
|
|
persistNextInterviewIfIdle,
|
|
} from "../src/lib/rectification-agentic/v9/answer-choice.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 { CHOICE_STOP_LABEL } from "../src/lib/rectification-agentic/v9/choice-card.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 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];
|
|
|
|
function liveState(): InferenceState {
|
|
const probes = ASKED_PROBES;
|
|
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;
|
|
} = {}): DecisionDossier {
|
|
const state = liveState();
|
|
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
|
|
const acceptanceAllowed = extra.acceptanceAllowed !== false;
|
|
return {
|
|
evidence: 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),
|
|
},
|
|
},
|
|
case: { acceptedTime: null, status: "collecting_evidence" },
|
|
};
|
|
}
|
|
|
|
function rpcDossier(decision: DecisionDossier) {
|
|
const evidence = EVIDENCE.map((item) => ({
|
|
id: item.id,
|
|
source_turn_id: TURN_ID,
|
|
subject: item.domain === "occupation" ? "self" : "self",
|
|
event_kind: item.eventKind,
|
|
domain: item.domain,
|
|
occurred_from: item.occurredFrom,
|
|
occurred_to: item.occurredTo,
|
|
date_precision: item.datePrecision,
|
|
summary: item.summary,
|
|
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(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: 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) {
|
|
return fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => rpcDossier(decision),
|
|
get_agentic_rectification_case_compute: () => computeFixture(),
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
|
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
|
throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`);
|
|
},
|
|
});
|
|
}
|
|
|
|
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 stays 10.0.14 for the exhaustion-exit fix", () => {
|
|
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.14");
|
|
});
|
|
|
|
test("USER_COLLECT_QUESTION.other remains only on the opening collect path", () => {
|
|
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",
|
|
];
|
|
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.equal(hits.some((line) => line.includes("answer-choice.ts")), false, hits.join("\n"));
|
|
assert.equal(hits.some((line) => line.includes("rectification-v9-tools.ts")), false, hits.join("\n"));
|
|
assert.ok(hits.some((line) => line.includes("method-followup.ts")), hits.join("\n"));
|
|
});
|
|
|
|
test("collect spoken stop button and range line share CHOICE_STOP_LABEL", () => {
|
|
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");
|
|
assert.match(chat, /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(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);
|
|
assert.match(persisted.hostNarration ?? "", new RegExp(RECTIFICATION_USER_COPY.probePoolExhaustedStop));
|
|
assert.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
|
|
assert.match(persisted.hostNarration ?? "", /04:47|04:51|范围/);
|
|
});
|
|
|
|
test("closed ceiling translates the gate and does not persist a focus", async () => {
|
|
const dossier = accidentDossier({
|
|
acceptanceAllowed: false,
|
|
acceptanceReasons: ["insufficient_events"],
|
|
});
|
|
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.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
|
|
|
|
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 });
|
|
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.doesNotMatch(persisted.hostNarration ?? "", new RegExp(USER_COLLECT_QUESTION.other));
|
|
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 ?? "", /还差|带月份|排不出可比较的候选|范围/);
|
|
});
|