import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { publicCanAdopt, publicDecisionFields, decideRectification, } from "../src/lib/rectification-agentic/core/rectification-decision.ts"; import { canShowRectificationReadonlyRange, canShowRectificationSelectionCards, parseRectificationCandidateResult, } from "../src/lib/rectification-candidate-result.ts"; import { attachOfferResultToTurns, attachQuestionsToTurns, parseTurnQuestion, } from "../src/lib/rectification-agentic/v9/turn-question.ts"; import { buildMethodFollowupPlan, collectQuestionDomain, OPENING_COLLECT_DOMAIN, parsePersistedFollowupQuestionId, projectRectificationChoiceCard, spokenCollectFallbackFollowup, } from "../src/lib/rectification-agentic/v9/method-followup.ts"; import { CHOICE_SKIP_QUESTION_LABEL, CHOICE_STOP_LABEL, parseRectificationChoiceCard, } from "../src/lib/rectification-agentic/v9/choice-card.ts"; import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts"; import { hasPersistedVedastroValidation } from "../src/lib/rectification-agentic/v9/confirmation-gate.ts"; import { parseToolActivityDetail, loadV9TurnReceipt, } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS, RECTIFICATION_SLOW_STEP_MS, RECTIFICATION_TIMEOUT_WARN_BEFORE_MS, rectificationLiveProgressLabel, } from "../src/lib/rectification-activity-labels.ts"; import { OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, FOCUS_ID, TURN_ID, USER_ID, fakeAccounting } from "./rectification-v9-test-support.ts"; const preAdopt = JSON.parse(readFileSync( new URL("./fixtures/rectification-case-bf7a8de6-pre-adopt.json", import.meta.url), "utf8", )) as Record; const postAdopt = JSON.parse(readFileSync( new URL("./fixtures/rectification-case-bf7a8de6-post-adopt.json", import.meta.url), "utf8", )) as Record; const STYLE_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 EDUCATION_VERIFY = { year: 2016, year_label: "2016 年前后", domain: "education" as const, event_family: "升学结果或学习环境出现明显变化", source: "dasha_activation" as const, tracks: ["vimshottari", "narayana"] as const, tracks_agree: true, unique_minute_claim: false as const, user_meaning: "2016 年前后升学结果或学习环境出现明显变化", role: "reverse_verify" as const, information_gain: 0.4, semantic_key: "education.2016", style_options: STYLE_OPTIONS, }; const VERIFY_COPY = { prompt: "2016 年前后,升学结果或学习环境有没有明显变化?", option_a: STYLE_OPTIONS[0].label, option_b: STYLE_OPTIONS[1].label, option_c: STYLE_OPTIONS[2].label, option_d: STYLE_OPTIONS[3].label, options: STYLE_OPTIONS.map((option, index) => ({ key: (["A", "B", "C", "D"] as const)[index]!, label: option.label, answer_class: option.answer_class, })), } as const; function reverseVerifySchema() { return { prompt: VERIFY_COPY.prompt, probe_year: 2016, choice: VERIFY_COPY, }; } function collectWithInternalAdopt() { return decideRectification({ methodCoverageAll: false, datedMethodCollectOpen: true, datedEventCount: 4, datedDomainCount: 3, trainingGateOpen: true, snapshotCurrent: true, candidateScores: [ { time: "05:06", score: 14 }, { time: "04:53", score: 13 }, { time: "05:03", score: 13 }, ], engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING, confirmationAllowed: false, inferenceCredibleRange: ["04:53", "05:06"], }); } test("public can_adopt is closed during collect even when internal capability is open", () => { const decision = collectWithInternalAdopt(); assert.equal(decision.sessionOutcome, "collect_evidence"); assert.equal(decision.canAdopt, true); const fields = publicDecisionFields(decision); assert.equal(fields.can_adopt, false); assert.equal(fields.selection_allowed, decision.selectionAllowed); assert.equal(publicCanAdopt(decision), false); }); test("public can_adopt stays open only for adopt outcomes", () => { assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true); assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "adopt_representative" }), true); assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "awaiting_confirmation" }), true); assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "validated_range" }), true); assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "discriminate_candidates" }), false); assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "validate_holdout" }), false); assert.equal(publicCanAdopt({ canAdopt: false, sessionOutcome: "adopt_representative" }), false); }); test("accept route 409s before the adopt RPC when public can_adopt is false", () => { const accept = readFileSync( new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url), "utf8", ); const gate = accept.indexOf("overlaid.can_adopt !== true"); const rpc = accept.indexOf("accept_agentic_rectification_candidate_for_case_v2"); assert.ok(gate >= 0, "accept must gate on overlaid.can_adopt"); assert.ok(rpc > gate, "RPC must not run before the session_outcome gate"); assert.match(accept, /adoption_not_allowed/); assert.match(accept, /session_outcome: fields\.session_outcome/); }); test("pre-adopt snapshot does not show adopt cards and does show a readonly range", () => { const result = parseRectificationCandidateResult(preAdopt); assert.ok(result); assert.equal(result.sessionOutcome, "collect_evidence"); assert.equal(result.canAdopt, true); assert.equal(canShowRectificationSelectionCards(result), false); assert.equal(canShowRectificationReadonlyRange(result), true); assert.deepEqual(result.credibleRange, ["04:53", "05:06"]); }); test("post-adopt snapshot keeps the offer on the pre-verify message", () => { const result = parseRectificationCandidateResult(postAdopt); assert.ok(result); assert.equal(result.selectedTime, "05:06"); const turns = Array.isArray(postAdopt.turns) ? postAdopt.turns as Array<{ id: string; role: string; question?: unknown; }> : []; const withQuestions = turns.map((turn) => ({ ...turn, text: null, question: parseTurnQuestion(turn.question), })); const attached = attachOfferResultToTurns(withQuestions, { resultId: result.resultId, canAdopt: true, acceptedTime: "05:06", }); assert.equal(attached[0]?.offer_result_id, result.resultId); assert.equal(attached[1]?.offer_result_id, null); assert.equal(attached[1]?.question?.kind, "reverse_verify"); }); test("accepted reverse_verify keep reuses the persisted question id", () => { const questionId = "reverse_verify:education_style:score"; const plan = buildMethodFollowupPlan({ evidence: [{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null, }], accepted: true, sessionOutcome: "adopt_representative", eventProbes: [EDUCATION_VERIFY], activeFocus: { id: FOCUS_ID, questionId, intent: "reverse_verify", targetDomain: "education", targetKind: "education_milestone", expectedAnswerSchema: reverseVerifySchema(), }, }); const followup = plan.next_followup; assert.equal(followup?.intent, "reverse_verify"); assert.equal(followup?.method_id, "reverse_verify"); assert.equal(followup?.ask_theme, "education_style"); assert.equal(stableFollowupQuestionId(followup!), questionId); assert.match(followup?.user_prompt_hint ?? "", /当前排盘已采用该时间/); const parsed = parsePersistedFollowupQuestionId(questionId); assert.deepEqual(parsed, { method_id: "reverse_verify", ask_theme: "education_style", scoring: true }); const card = projectRectificationChoiceCard({ evidence: [{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null, }], accepted: true, sessionOutcome: "adopt_representative", eventProbes: [EDUCATION_VERIFY], activeFocus: { id: FOCUS_ID, questionId, intent: "reverse_verify", targetDomain: "education", targetKind: "education_milestone", expectedAnswerSchema: reverseVerifySchema(), }, }); assert.ok(card); assert.equal(card.focus_id, FOCUS_ID); assert.equal(card.question_id, questionId); assert.equal(card.stop_label, CHOICE_SKIP_QUESTION_LABEL); assert.ok(parseRectificationChoiceCard(card)); const again = projectRectificationChoiceCard({ evidence: [{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null, }], accepted: true, sessionOutcome: "adopt_representative", eventProbes: [EDUCATION_VERIFY], activeFocus: { id: FOCUS_ID, questionId, intent: "reverse_verify", targetDomain: "education", targetKind: "education_milestone", expectedAnswerSchema: reverseVerifySchema(), }, }); assert.equal(again?.question_id, card.question_id); assert.equal(again?.focus_id, card.focus_id); }); test("distinguish keep still uses probe: semantic_key ids", () => { const questionId = "probe:education.2016"; const plan = buildMethodFollowupPlan({ evidence: [{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-01-01", occurredTo: null, }], sessionOutcome: "discriminate_candidates", eventProbes: [{ ...EDUCATION_VERIFY, role: "distinguish", semantic_key: "education.2016", candidate_split_hash: "education:2016:05:06|04:53", candidate_ids: ["05:06", "04:53"], expected_outcomes: [ { answer_class: "yes", supports: ["05:06"], conflicts: ["04:53"] }, { answer_class: "no", supports: ["04:53"], conflicts: ["05:06"] }, ], }], activeFocus: { id: FOCUS_ID, questionId, intent: "distinguish_candidates", targetDomain: "education", targetKind: "education_milestone", expectedAnswerSchema: { semantic_key: "education.2016", candidate_split_hash: "education:2016:05:06|04:53", probe_id: "education.2016", choice: { prompt: "2016 年前后,升学结果或学习环境有没有明显变化?", option_a: STYLE_OPTIONS[0].label, option_b: STYLE_OPTIONS[1].label, option_c: STYLE_OPTIONS[2].label, option_d: STYLE_OPTIONS[3].label, }, }, }, }); assert.equal(plan.next_followup?.intent, "distinguish_candidates"); assert.equal(plan.next_followup?.semantic_key, "education.2016"); assert.equal(stableFollowupQuestionId(plan.next_followup!), questionId); assert.equal(parsePersistedFollowupQuestionId(questionId), null); }); test("post-adopt live question identity matches the last reverse_verify turn", () => { const last = Array.isArray(postAdopt.turns) ? postAdopt.turns[postAdopt.turns.length - 1] as { question?: unknown } : null; const question = parseTurnQuestion(last?.question); assert.equal(question?.kind, "reverse_verify"); assert.equal(question?.options?.length, 4); const card = projectRectificationChoiceCard({ evidence: [{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null, }], accepted: true, sessionOutcome: "adopt_representative", eventProbes: [EDUCATION_VERIFY], activeFocus: { id: question?.focus_id, questionId: question?.question_id, intent: "reverse_verify", targetDomain: "education", targetKind: "education_milestone", expectedAnswerSchema: reverseVerifySchema(), }, }); assert.equal(card?.focus_id, question?.focus_id); assert.equal(card?.question_id, question?.question_id); }); test("opening collect domain is other rather than unknown", () => { // 旧 education → 新 other → 保留 不得为 unknown、stable id 不含 unknown assert.equal(OPENING_COLLECT_DOMAIN, "other"); assert.equal(collectQuestionDomain(null), "other"); assert.equal(collectQuestionDomain("unknown"), "other"); const fallback = spokenCollectFallbackFollowup({ method_id: "dasha_events", intent: "collect_method_evidence", ask_theme: "dated_event", domain: null, kind_hint: null, user_prompt_hint: "collect", must_not_label: false, choice_frame: null, source: "method_coverage", }); assert.equal(fallback.domain, "other"); assert.equal(stableFollowupQuestionId(fallback), "collect:other:collect_method_evidence"); assert.doesNotMatch(stableFollowupQuestionId(fallback), /unknown/); }); test("skipped collect questions stay attached to their asked turn", () => { const attached = attachQuestionsToTurns( [{ id: TURN_ID, role: "assistant", text: "记下了。", status: "completed" }], [{ id: FOCUS_ID, caseId: CASE_ID, questionId: "collect:family:collect_method_evidence", intent: "collect_method_evidence", targetEvidenceId: null, targetDomain: "family", targetKind: null, expectedAnswerSchema: { collect: true, prompt: "2021 年前后,家里有没有人住院、搬家或职责明显变化?", }, status: "skipped", askedAt: "2026-09-02T00:00:00.000Z", resolvedAt: "2026-09-02T00:03:00.000Z", askedTurnId: TURN_ID, answerOption: "stop", }], ); assert.equal(attached[0]?.question?.status, "skipped"); assert.equal(attached[0]?.question?.kind, "collect_spoken"); }); test("chat owns adopt cards on the offering message and drops the list-end handoff", () => { const chat = readFileSync( new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8", ); assert.match(chat, /canShowRectificationSelectionCards/); assert.match(chat, /canShowRectificationReadonlyRange/); assert.match(chat, /candidateOffer/); assert.match(chat, /!candidateResult\?\.selectedTime/); assert.match(chat, /rectification-adopt-status/); // 旧 状态条含「用这个时间看盘」→ 新 产品删按钮 → 保留 状态条仍在且含「改选」,不得再渲染 consult-handoff assert.doesNotMatch(chat, /用这个时间看盘/); assert.match(chat, /改选/); assert.match(chat, /CHOICE_STOP_LABEL/); assert.doesNotMatch(chat, /rectification-collect-stop/); assert.match( readFileSync(new URL("../src/components/rectification-choice-card.tsx", import.meta.url), "utf8"), /stop_label !== CHOICE_STOP_LABEL/, ); assert.match(chat, /已跳过(已采用/); assert.match(chat, /rectificationLiveProgressLabel/); assert.doesNotMatch(chat, /rectification-consult-handoff/); assert.match(chat, /kind === "reverse_verify"/); assert.match(chat, /scoring: question\.kind !== "reverse_verify"/); }); test("choice cards use 这题跳过 for reverse_verify and 先这样 for collect", () => { assert.equal(CHOICE_SKIP_QUESTION_LABEL, "这题跳过"); assert.equal(CHOICE_STOP_LABEL, "先这样,先看当前范围"); const choiceCard = readFileSync( new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url), "utf8", ); assert.match(choiceCard, /CHOICE_SKIP_QUESTION_LABEL/); const prompt = readFileSync( new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8", ); assert.match(prompt, /case\.accepted_time 非空时,正文第一句要说明已按该时间采用/); assert.match(prompt, /必须先用一句话承接用户本轮给出的事实(年份\+事件)/); const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8"); assert.match(voice, /不要预告选项/); }); test("set-focus failures persist the spoken-prompt reason on the receipt", () => { const tools = readFileSync( new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8", ); assert.match(tools, /resultFingerprint: JSON\.stringify\(\{ reason: spoken\.reason \}\)/); assert.match(tools, /readVedastroMinuteSensitiveStatus\(decisionReceipt\) !== "passed"/); const detail = parseToolActivityDetail({ error: "invalid_spoken_prompt", result_fingerprint: JSON.stringify({ reason: "probe_mismatch" }), }); assert.equal(detail?.reason, "probe_mismatch"); assert.equal(detail?.error, "invalid_spoken_prompt"); }); test("elapsed_ms serializes from the turn receipt and 45s copy follows the live tool", () => { const migration = readFileSync( new URL("../supabase/migrations/20260902020000_rectification_tool_activity_timing.sql", import.meta.url), "utf8", ); assert.match(migration, /'elapsed_ms'/); assert.match(migration, /'started_at'/); assert.equal(existsSync(fileURLToPath(new URL("../db/migrations/20260902020000_rectification_tool_activity_timing.sql", import.meta.url))), false); assert.equal(RECTIFICATION_SLOW_STEP_MS, 45_000); assert.equal( rectificationLiveProgressLabel({ tool: "rectification-compare-candidates", baseLabel: "正在比较候选时间…", stepStartedAt: 0, runStartedAt: 0, now: RECTIFICATION_SLOW_STEP_MS, }), "引擎在比较候选,可能需要一两分钟", ); assert.equal( rectificationLiveProgressLabel({ tool: "rectification-read-case", baseLabel: "正在读取校正记录…", stepStartedAt: 0, runStartedAt: 0, now: RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS, }), "即将超时,会自动重试或提示", ); }); test("read_only compare reuses a passed vedastro validation and still names the helper", () => { assert.equal(hasPersistedVedastroValidation({ vedastro_event_validation: { status: "failed" }, }), true); assert.equal(hasPersistedVedastroValidation({ gates: { exact_confirmation: { vedastro_event_validation: { status: "passed" } } }, }), true); assert.equal(hasPersistedVedastroValidation({}), false); }); test("GET turn receipts expose elapsed_ms and set-focus failure detail", async () => { const accounting = fakeAccounting({ get_agentic_rectification_turn_receipt: () => ({ turn_id: TURN_ID, attempt_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", status: "completed", skill_name: "jyotish-birth-time-rectification", skill_version: "9.0.0", engine_version: null, phases: [], tool_activities: [ { tool: "rectification-compare-candidates", status: "completed", methods: ["d1-rashi"], started_at: "2026-09-02T13:27:10.000Z", elapsed_ms: 120000, result_fingerprint: JSON.stringify({ hash: "abc", engine_compare_ms: 80000, vedastro_validate_ms: 30000, persist_ms: 10000, }), }, { tool: "rectification-set-focus", status: "failed", methods: [], started_at: "2026-09-02T13:29:50.000Z", elapsed_ms: 400, error: "invalid_spoken_prompt", result_fingerprint: JSON.stringify({ reason: "probe_mismatch" }), }, ], tools: ["rectification-compare-candidates"], methods: ["d1-rashi"], started_at: "2026-09-02T13:27:05.000Z", completed_at: "2026-09-02T13:30:03.000Z", }), }); const receipt = await loadV9TurnReceipt(accounting.client, USER_ID, CASE_ID, TURN_ID); assert.equal(receipt?.toolActivities[0]?.elapsedMs, 120000); assert.equal(receipt?.toolActivities[0]?.detail?.engine_compare_ms, 80000); assert.equal(receipt?.toolActivities[1]?.detail?.reason, "probe_mismatch"); });