Occupation collect was wiping year-month into an unscored note, and idle gap copy never joined the evidence turn. Co-authored-by: Cursor <cursoragent@cursor.com>
654 lines
24 KiB
TypeScript
654 lines
24 KiB
TypeScript
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,
|
||
type DecisionDossier,
|
||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||
import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.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 { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
|
||
import { composeIdleGapIntoSpoken } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
|
||
import {
|
||
COLLECT_KIND_ORDER,
|
||
anchoredFollowups,
|
||
collectionQuestionPool,
|
||
preciseGapNarration,
|
||
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
|
||
import { applyOccupationCollectLedgerNorm, trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
|
||
import { buildMethodFollowupPlan, followupFromPoolItem } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import { parseAgentChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
|
||
import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||
import {
|
||
interviewCollectWaiting,
|
||
rectificationQuestionGapState,
|
||
} from "../src/lib/rectification-surface-state.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 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 health2024 = {
|
||
id: "e-health-2024",
|
||
status: "confirmed" as const,
|
||
domain: "health_pressure",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2024-10-01",
|
||
occurredTo: "2024-10-31",
|
||
eventKind: "self_health_event",
|
||
summary: "2024年10月一次身体事故",
|
||
};
|
||
|
||
const career2024 = {
|
||
id: "e-career-2024",
|
||
status: "confirmed" as const,
|
||
domain: "career",
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2024-04-01",
|
||
occurredTo: null,
|
||
eventKind: "career_entry",
|
||
summary: "2024年4月开始做程序员",
|
||
};
|
||
|
||
const occupationNote = {
|
||
id: "e-occupation-note",
|
||
status: "confirmed" as const,
|
||
domain: "occupation",
|
||
datePrecision: "unknown" as const,
|
||
occurredFrom: null,
|
||
occurredTo: null,
|
||
eventKind: "occupation_note",
|
||
summary: "程序员",
|
||
};
|
||
|
||
const inviteDeclinedTopic = {
|
||
target_domain: "other",
|
||
status: "declined",
|
||
intent: "collect_method_evidence",
|
||
questionId: "collect:invite:more",
|
||
target_kind: "invite_more",
|
||
};
|
||
|
||
const OCCUPATION_FOCUS = {
|
||
intent: "collect_method_evidence" as const,
|
||
targetDomain: "occupation",
|
||
targetKind: "occupation_note",
|
||
questionId: "collect:occupation:collect_method_evidence",
|
||
};
|
||
|
||
function askedPoolTopics() {
|
||
return [
|
||
inviteDeclinedTopic,
|
||
{
|
||
target_domain: "career",
|
||
status: "resolved",
|
||
intent: "collect_method_evidence",
|
||
questionId: "collect:anchor:education_completion:2020",
|
||
target_kind: "anchor:education_completion:2020",
|
||
},
|
||
{
|
||
target_domain: "relocation",
|
||
status: "resolved",
|
||
intent: "collect_method_evidence",
|
||
questionId: "collect:anchor:education_start:2016",
|
||
target_kind: "anchor:education_start:2016",
|
||
},
|
||
...COLLECT_KIND_ORDER.flatMap((kind) => [
|
||
{
|
||
target_domain: kind,
|
||
status: "resolved",
|
||
intent: "collect_method_evidence",
|
||
questionId: `collect:anchor:after_event:${kind === "education" ? "2016" : "2020"}`,
|
||
target_kind: `anchor:after_event:${kind === "education" ? "2016" : "2020"}`,
|
||
},
|
||
{
|
||
target_domain: kind,
|
||
status: "resolved",
|
||
intent: "collect_method_evidence",
|
||
questionId: `collect:generic:${kind}`,
|
||
target_kind: `generic:${kind}`,
|
||
},
|
||
]),
|
||
];
|
||
}
|
||
|
||
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 ASKED_PROBES = [
|
||
existenceProbe({
|
||
key: "career.2023.05.dasha_boundary",
|
||
domain: "career",
|
||
year: 2023,
|
||
question: "2023 年 5 月前后有没有入职或换工作",
|
||
}),
|
||
];
|
||
const LEFTOVER_PROBE = existenceProbe({
|
||
key: "career.2021.04.dasha_boundary",
|
||
domain: "career",
|
||
year: 2021,
|
||
question: "2021 年 4 月前后有没有入职或换工作",
|
||
});
|
||
|
||
function liveState(extraProbes: readonly ConflictProbe[] = []) {
|
||
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 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: 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: health2024.id, domain: "health_pressure", year: 2024, precision: "month" as const, usage: "training" as const },
|
||
{ id: career2024.id, domain: "career", year: 2024, precision: "month" as const, usage: "holdout" as const },
|
||
],
|
||
probes,
|
||
answered_probes: ASKED_PROBES.map((probe) => ({
|
||
probe_id: probe.id,
|
||
semantic_key: probe.semantic_key,
|
||
candidate_split_hash: probe.candidate_split_hash,
|
||
answer_class: "no" as const,
|
||
classified_from: "choice" as const,
|
||
})),
|
||
rounds: [],
|
||
last_inference_round: null,
|
||
entropy: 1.2,
|
||
representative_time: "04:51",
|
||
credible_range: ["04:47", "04:53"] as const,
|
||
holdout_passed: null,
|
||
};
|
||
const loaded = asInferenceState(raw);
|
||
assert.ok(loaded);
|
||
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 fourEventDossier(): DecisionDossier {
|
||
const evidence = [educationStart, educationEnd, health2024, career2024, occupationNote];
|
||
const state = liveState([LEFTOVER_PROBE]);
|
||
const fingerprint = evidenceLedgerFingerprint(evidence as never);
|
||
return {
|
||
evidence,
|
||
conversationSummary: {
|
||
activeFocus: null,
|
||
declinedSkippedTopics: [inviteDeclinedTopic],
|
||
},
|
||
latestResult: {
|
||
resultId: "55555555-5555-4555-8555-555555555555",
|
||
selectionAllowed: false,
|
||
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: false,
|
||
acceptance_allowed: false,
|
||
propose_allowed: false,
|
||
selection_allowed: false,
|
||
confirmation_allowed: false,
|
||
acceptance_reasons: ["insufficient_events"],
|
||
inference_state: state,
|
||
discriminating_event_probes: [
|
||
...ASKED_PROBES.map(eventProbeRow),
|
||
eventProbeRow(LEFTOVER_PROBE),
|
||
],
|
||
oos_blind_prompts: [],
|
||
},
|
||
},
|
||
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-11T00: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,
|
||
})) ?? [],
|
||
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-11T00: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 }),
|
||
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-11T00: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 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 stays 10.0.23", () => {
|
||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.23");
|
||
});
|
||
|
||
test("two education events do not spawn birth-year reverse questions", () => {
|
||
const pool = collectionQuestionPool([educationStart, educationEnd]);
|
||
const prompts = pool.map((item) => item.prompt).join("\n");
|
||
assert.equal(pool[0]?.kind, "invite");
|
||
assert.doesNotMatch(prompts, /年前后/);
|
||
const plan = buildMethodFollowupPlan({
|
||
evidence: [educationStart, educationEnd],
|
||
birthDate: "1997-08-08",
|
||
});
|
||
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /年前后/);
|
||
assert.doesNotMatch(plan.next_followup?.spoken_prompt ?? "", /年前后/);
|
||
});
|
||
|
||
test("after declining invite, the next follow-up is the user-year career anchor", () => {
|
||
const asked = new Set<string>();
|
||
const anchors = anchoredFollowups([educationStart, educationEnd], new Set(), asked);
|
||
const job = anchors.find((item) => item.key === "collect:anchor:education_completion:2020");
|
||
assert.ok(job);
|
||
assert.equal(job?.domain, "career");
|
||
const followup = followupFromPoolItem(job!);
|
||
assert.equal(followup.domain, "career");
|
||
assert.match(followup.user_prompt_hint, /2020 年毕业后第一份工作/);
|
||
const afterDecline = collectionQuestionPool(
|
||
[educationStart, educationEnd],
|
||
[inviteDeclinedTopic],
|
||
);
|
||
assert.doesNotMatch(afterDecline.map((item) => item.prompt).join("\n"), /年前后/);
|
||
assert.equal(afterDecline[0]?.domain, "career");
|
||
});
|
||
|
||
test("dated occupation answer plus the replay ledger opens the training gate with one holdout", () => {
|
||
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{
|
||
domain: "career" as const,
|
||
eventKind: "career_entry" as const,
|
||
datePrecision: "month" as const,
|
||
occurredFrom: "2024-04-01",
|
||
occurredTo: null,
|
||
}]);
|
||
const evidence = [
|
||
educationStart,
|
||
educationEnd,
|
||
health2024,
|
||
...remapped.map((item, index) => ({
|
||
id: index === 0 ? "e-occupation-note" : "e-career-2024",
|
||
status: "confirmed" as const,
|
||
domain: item.domain,
|
||
datePrecision: item.datePrecision,
|
||
occurredFrom: item.occurredFrom,
|
||
occurredTo: item.occurredTo,
|
||
eventKind: item.eventKind,
|
||
summary: index === 0 ? "程序员" : "2024年4月开始做程序员",
|
||
})),
|
||
];
|
||
const gate = trainingScoreableGate(evidence);
|
||
assert.equal(gate.holdoutCount, 1);
|
||
assert.equal(gate.trainingCount, 3);
|
||
assert.ok(gate.trainingDomainCount >= 2);
|
||
assert.equal(gate.open, true);
|
||
assert.equal(gate.holdoutStatus, "reserved");
|
||
});
|
||
|
||
test("four dated month events persist a leftover discriminator card", async () => {
|
||
resetDeliveryTurnGuardForTests();
|
||
const dossier = fourEventDossier();
|
||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||
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.equal(focusCalls[0]?.args.p_intent, "distinguish_candidates");
|
||
const schema = focusCalls[0]?.args.p_expected_answer_schema;
|
||
const copy = parseAgentChoiceCopy(schema);
|
||
assert.ok(copy, "persisted focus must carry a choice schema");
|
||
assert.equal(isRenderableChoiceOpenQuestion({
|
||
question_id: String(focusCalls[0]?.args.p_question_id ?? ""),
|
||
prompt: copy.prompt,
|
||
status: "created",
|
||
kind: "choice",
|
||
focus_id: FOCUS_ID,
|
||
probe_id: typeof (schema as { probe_id?: unknown } | undefined)?.probe_id === "string"
|
||
? (schema as { probe_id: string }).probe_id
|
||
: null,
|
||
}), true);
|
||
assert.equal(persisted.choiceReady, true);
|
||
});
|
||
|
||
test("idle gap copy joins the evidence recap instead of opening a second turn", () => {
|
||
const joined = composeIdleGapIntoSpoken("记下了。", "现在记下的是2016 年 9 月上大学和2020 年 6 月毕业。再来一件不是上学的、记得大概年月的事就能开始筛,比如第一份工作、谈恋爱或结婚。");
|
||
assert.match(joined, /^记下了。现在记下的是/);
|
||
assert.match(joined, /就能开始筛/);
|
||
assert.equal(composeIdleGapIntoSpoken(joined, "现在记下的是重复。"), joined);
|
||
assert.equal(
|
||
composeIdleGapIntoSpoken("这次给出的范围 04:49–04:53。", "这次给出的范围 04:49–04:53。"),
|
||
"这次给出的范围 04:49–04:53。",
|
||
);
|
||
});
|
||
|
||
test("undated occupation answer keeps the training gate closed and writes a precise gap", async () => {
|
||
resetDeliveryTurnGuardForTests();
|
||
const evidence = [educationStart, educationEnd, occupationNote];
|
||
const declinedSkippedTopics = askedPoolTopics();
|
||
assert.equal(collectionQuestionPool(evidence, declinedSkippedTopics).length, 0);
|
||
const gap = preciseGapNarration(evidence, declinedSkippedTopics);
|
||
assert.match(gap, /现在记下的是/);
|
||
assert.match(gap, /就能开始筛/);
|
||
assert.doesNotMatch(gap, /领域|做不了|还差 \d+ 件/);
|
||
const careerOrLoveOrFamily = ["第一份工作", "谈恋爱", "家里"].filter((token) => gap.includes(token));
|
||
assert.ok(careerOrLoveOrFamily.length >= 2, gap);
|
||
const dossier: DecisionDossier = {
|
||
evidence,
|
||
conversationSummary: {
|
||
activeFocus: null,
|
||
declinedSkippedTopics: declinedSkippedTopics,
|
||
},
|
||
latestResult: null,
|
||
case: { acceptedTime: null, status: "collecting_evidence" },
|
||
};
|
||
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||
assert.equal(trainingScoreableGate(evidence).open, false);
|
||
assert.equal(interviewCollectWaiting({
|
||
stopReason: decision.stopReason,
|
||
sessionOutcome: decision.sessionOutcome,
|
||
questionMissing: true,
|
||
}), true);
|
||
assert.equal(rectificationQuestionGapState({
|
||
liveQuestionVisible: false,
|
||
questionMissing: true,
|
||
questionLoadFailed: false,
|
||
collectWaiting: true,
|
||
busy: false,
|
||
readonly: false,
|
||
regenerating: false,
|
||
snapshotLoaded: true,
|
||
resumableCase: true,
|
||
retryAttempts: 0,
|
||
}), "collect_waiting");
|
||
|
||
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.equal(persisted.terminalNote, true);
|
||
assert.match(persisted.hostNarration ?? "", /现在记下的是/);
|
||
assert.match(persisted.hostNarration ?? "", /就能开始筛/);
|
||
assert.doesNotMatch(persisted.hostNarration ?? "", /领域|做不了|还差 \d+ 件/);
|
||
|
||
const agentAccounting = idleHandlers(dossier);
|
||
const result = await runV9AgentTurn({
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
sessionId: SESSION_ID,
|
||
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||
action: "evidence",
|
||
message: "程序员",
|
||
modelName: "gpt-4o-mini",
|
||
accounting: agentAccounting.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);
|
||
assert.match(result.answerText, /现在记下的是/);
|
||
assert.match(result.answerText, /就能开始筛/);
|
||
assert.doesNotMatch(result.answerText, /领域|做不了|还差 \d+ 件/);
|
||
const finalized = agentAccounting.calls.find((item) => item.fn === "finalize_agentic_rectification_turn");
|
||
const appended = agentAccounting.calls.find((item) => (
|
||
item.fn === "append_agentic_rectification_turn"
|
||
&& typeof item.args.p_assistant_message === "string"
|
||
&& String(item.args.p_assistant_message).includes("就能开始筛")
|
||
));
|
||
assert.ok(finalized || appended, "gap copy must land on the evidence turn");
|
||
});
|