import assert from "node:assert/strict"; import test from "node:test"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { acceptV9Candidate, confirmV9BirthTime, safeToolErrorCode, } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { canTransitToTerminal, evidenceWritesAllowed, isTerminalStatus, } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { CASE_ID, CANDIDATE_RANGE, CANDIDATE_ID, SECOND_CANDIDATE_ID, EVIDENCE_ID, FOCUS_ID, RESULT_ID, SESSION_ID, TURN_ID, USER_ID, activeFocusFixture, candidateSnapshotFixture, computeFixture, conversationSummaryFixture, dossierFixture, fakeAccounting, receiptHandlers, } from "./rectification-v9-test-support.ts"; test("accepted is never upgraded to confirmed by the accept path", async () => { const accounting = fakeAccounting({ accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "accepted", result_id: RESULT_ID, case_status: "candidate_accepted", idempotent: false, }), }); const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID); assert.equal(result.status, "accepted"); assert.equal(result.caseStatus, "candidate_accepted"); assert.notEqual(result.status, "confirmed"); }); test("accept fails closed if the accept-only RPC reports confirmed", async () => { const accounting = fakeAccounting({ accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "confirmed", result_id: RESULT_ID, candidate_id: CANDIDATE_ID, case_status: "confirmed", idempotent: false, }), }); await assert.rejects( acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID), (error: unknown) => error instanceof Error && error.message.includes("invalid_accept_result"), ); }); test("confirmed requires the engine gate plus explicit grounded consent", async () => { // confirmation_allowed=false on the stored result blocks confirmation. const blocked = fakeAccounting({ confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_confirmation_blocked"); }, }); await assert.rejects( confirmV9BirthTime(blocked.client, USER_ID, CASE_ID, { resultId: RESULT_ID, candidateId: CANDIDATE_ID, requestId: TURN_ID, consentQuote: "就用05:02", sourceTurnId: TURN_ID, }), (error: unknown) => error instanceof Error && error.message.includes("confirmation_blocked"), ); // Confirming a time that is not the representative minute is rejected. const mismatch = fakeAccounting({ confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_confirm_time_mismatch"); }, }); await assert.rejects( confirmV9BirthTime(mismatch.client, USER_ID, CASE_ID, { resultId: RESULT_ID, candidateId: SECOND_CANDIDATE_ID, requestId: TURN_ID, consentQuote: "就用04:55", sourceTurnId: TURN_ID, }), (error: unknown) => error instanceof Error && error.message.includes("confirm_time_mismatch"), ); // Consent quote must be grounded in the source turn's message. const ungrounded = fakeAccounting({ confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_consent_not_grounded"); }, }); await assert.rejects( confirmV9BirthTime(ungrounded.client, USER_ID, CASE_ID, { resultId: RESULT_ID, candidateId: CANDIDATE_ID, requestId: TURN_ID, consentQuote: "用户根本没说过这句话", sourceTurnId: TURN_ID, }), (error: unknown) => error instanceof Error && error.message.includes("consent_not_grounded"), ); }); test("confirm-birth-time binds consent to the server-owned current turn", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture({ confirmationAllowed: true, representativeTime: "05:02", candidates: [ { candidate_id: CANDIDATE_ID, rank: 1, time: "05:02", relative_support: 70, tied_minute_count: 1 }, ], }), }), confirm_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "confirmed", result_id: RESULT_ID, case_status: "confirmed", idempotent: false, }), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const confirm = tools["rectification-confirm-birth-time"] as unknown as { inputSchema: { safeParse(value: unknown): { success: boolean } }; execute(input: unknown): Promise; }; const input = { caseId: CASE_ID, resultId: RESULT_ID, candidateId: CANDIDATE_ID, consentQuote: "就用05:02", }; assert.equal(confirm.inputSchema.safeParse(input).success, true); assert.equal(confirm.inputSchema.safeParse({ ...input, sourceTurnId: "77777777-7777-4777-8777-777777777777" }).success, false); await assert.rejects( confirm.execute(input), (error: unknown) => error instanceof Error && error.message.includes("confirmation_blocked"), ); assert.equal( accounting.calls.some((item) => item.fn === "confirm_agentic_rectification_candidate_for_case_v2"), false, ); }); test("candidate ownership is case-scoped: the RPC always receives the case id", async () => { const accounting = fakeAccounting({ accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "accepted", result_id: RESULT_ID, case_status: "candidate_accepted", idempotent: false, }), }); await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID); const call = accounting.calls.find((item) => item.fn === "accept_agentic_rectification_candidate_for_case_v2"); assert.ok(call); assert.equal(call.args.p_case_id, CASE_ID); assert.equal(call.args.p_user_id, USER_ID); assert.equal(call.args.p_result_id, RESULT_ID); assert.equal(call.args.p_candidate_id, CANDIDATE_ID); assert.equal(call.args.p_request_id, TURN_ID); assert.equal("p_time" in call.args, false); }); test("accept is idempotent: replaying the same selection succeeds without a second write", async () => { const accounting = fakeAccounting({ accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", status: "accepted", result_id: RESULT_ID, case_status: "candidate_accepted", idempotent: true, }), }); const result = await acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID); assert.equal(result.idempotent, true); assert.equal(result.status, "accepted"); }); test("terminal cases reject evidence writes and candidate actions", async () => { assert.equal(evidenceWritesAllowed("confirmed"), false); assert.equal(evidenceWritesAllowed("closed"), false); assert.equal(evidenceWritesAllowed("superseded"), false); assert.equal(isTerminalStatus("confirmed"), true); assert.equal(canTransitToTerminal("confirmed", "closed"), false); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ status: "closed" }), propose_agentic_rectification_evidence: () => { throw new Error("agentic_rectification_case_terminal"); }, }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); await assert.rejects( (tools["rectification-propose-evidence"] as unknown as { execute(input: unknown): Promise }).execute({ caseId: CASE_ID, quote: "2016年9月离开家去北京工作", proposedKind: "career_entry", subject: "self", domain: "career", datePrecision: "month", occurredFrom: "2016-09", summary: "2016年9月离家去北京工作", }), (error: unknown) => error instanceof Error && error.message.includes("case_terminal"), ); }); test("baseline change invalidates results and forces needs_rebaseline", () => { // The migration ships a profile guard trigger; the service exposes the // status contract that resumable cases may enter needs_rebaseline. assert.equal(canTransitToTerminal("collecting_evidence", "confirmed"), true); // A needs_rebaseline case can still collect evidence but never reference // stale candidates; evidence writes remain allowed while resumable. assert.equal(evidenceWritesAllowed("needs_rebaseline"), true); }); test("read-case gives the Agent authoritative birth context without raw internals", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture(), }), get_agentic_rectification_case_compute: () => computeFixture(), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const projection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise>; }).execute({ caseId: CASE_ID }); assert.deepEqual(projection.birth_context, { birth_date: "1997-08-08", reported_birth_time: "05:00", active_birth_time: null, candidate_range: CANDIDATE_RANGE, birthplace: { label: "河北省邯郸市", latitude: 36.420487, longitude: 114.209936, }, timezone: { id: "Asia/Shanghai", offset: 8, }, }); const serialized = JSON.stringify(projection); assert.doesNotMatch(serialized, /baseline_birth_snapshot/); assert.doesNotMatch(serialized, /event_contribution_matrix|rule_ids|raw_score|weight/); }); test("read-case exposes bounded safe context for multi-turn follow-ups without raw ledger internals", async () => { const revisionId = "88888888-8888-4888-8888-888888888888"; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 5, evidenceCount: 2, turns: [ { id: TURN_ID, role: "user", text: "2016年9月离开家去北京开始工作,2020年又搬到了上海", status: "completed", created_at: "2026-08-12T10:00:00.000Z", }, { id: TURN_ID, role: "assistant", text: "第二次搬家大约发生在哪个月?", status: "completed", created_at: "2026-08-12T10:00:00.000Z", }, { id: "99999999-9999-4999-8999-999999999998", role: "user", text: "月份记不清了,换个方向吧", status: "completed", created_at: "2026-08-12T10:01:00.000Z", }, ], conversationSummary: conversationSummaryFixture({ declinedSkippedTopics: [{ focus_id: FOCUS_ID, intent: "collect_event_date", target_evidence_id: revisionId, target_domain: "relocation", target_kind: "relocation", status: "declined", asked_at: "2026-08-12T10:00:00.000Z", resolved_at: "2026-08-12T10:01:00.000Z", }], }), evidence: [ { id: EVIDENCE_ID, source_turn_id: TURN_ID, subject: "self", event_kind: "career_entry", domain: "career", occurred_from: "2016-09-01", occurred_to: null, date_precision: "month", summary: "2016年9月离开家去北京开始工作", status: "confirmed", supersedes_evidence_id: null, created_at: "2026-08-12T10:00:06.000Z", }, { id: revisionId, source_turn_id: TURN_ID, subject: "self", event_kind: "relocation", domain: "relocation", occurred_from: "2020-01-01", occurred_to: null, date_precision: "year", summary: "2020年搬到上海", status: "pending_confirmation", supersedes_evidence_id: null, created_at: "2026-08-12T10:00:07.000Z", }, ], }), get_agentic_rectification_case_compute: () => computeFixture(), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const projection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise>; }).execute({ caseId: CASE_ID }); assert.deepEqual(projection.evidence_context, [ { evidence_id: EVIDENCE_ID, status: "confirmed", summary: "2016年9月离开家去北京开始工作", event_kind: "career_entry", domain: "career", date_precision: "month", occurred_from: "2016-09-01", occurred_to: null, display_date_label: "2016-09", }, { evidence_id: revisionId, status: "pending_confirmation", summary: "2020年搬到上海", event_kind: "relocation", domain: "relocation", date_precision: "year", occurred_from: "2020-01-01", occurred_to: null, display_date_label: "2020年", }, ]); assert.deepEqual(projection.conversation_context, { active_followup: null, recent_turns: [ { role: "user", text: "2016年9月离开家去北京开始工作,2020年又搬到了上海" }, { role: "assistant", text: "第二次搬家大约发生在哪个月?" }, { role: "user", text: "月份记不清了,换个方向吧" }, ], declined_targets: [{ focus_id: FOCUS_ID, intent: "collect_event_date", target_evidence_id: revisionId, target_domain: "relocation", target_kind: "relocation", status: "declined", asked_at: "2026-08-12T10:00:00.000Z", resolved_at: "2026-08-12T10:01:00.000Z", }], }); const serialized = JSON.stringify(projection); assert.doesNotMatch(serialized, /source_turn_id|user_quote|supersedes_evidence_id|created_at/); }); test("read-case includes the current pending user turn so a refusal closes the active follow-up immediately", async () => { const revisionId = "88888888-8888-4888-8888-888888888888"; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ turns: [ { id: "99999999-9999-4999-8999-999999999997", role: "assistant", text: "你是把入职时间改成2017年,对吗?", status: "completed", created_at: "2026-08-12T10:00:00.000Z", }, { id: TURN_ID, role: "user", text: "记不清了,换个方向吧", status: "pending", created_at: "2026-08-12T10:01:00.000Z", }, ], conversationSummary: conversationSummaryFixture({ declinedSkippedTopics: [{ focus_id: FOCUS_ID, intent: "confirm_revision", target_evidence_id: revisionId, target_domain: "career", target_kind: "career_entry", status: "declined", asked_at: "2026-08-12T10:00:00.000Z", resolved_at: "2026-08-12T10:01:00.000Z", }], }), evidence: [{ id: revisionId, source_turn_id: TURN_ID, subject: "self", event_kind: "career_entry", domain: "career", occurred_from: "2017-01-01", occurred_to: null, date_precision: "year", summary: "2017年开始工作", status: "pending_confirmation", supersedes_evidence_id: EVIDENCE_ID, created_at: "2026-08-12T10:00:07.000Z", }], }), get_agentic_rectification_case_compute: () => computeFixture(), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const projection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise>; }).execute({ caseId: CASE_ID }); assert.deepEqual(projection.conversation_context, { active_followup: null, recent_turns: [ { role: "assistant", text: "你是把入职时间改成2017年,对吗?" }, { role: "user", text: "记不清了,换个方向吧" }, ], declined_targets: [{ focus_id: FOCUS_ID, intent: "confirm_revision", target_evidence_id: revisionId, target_domain: "career", target_kind: "career_entry", status: "declined", asked_at: "2026-08-12T10:00:00.000Z", resolved_at: "2026-08-12T10:01:00.000Z", }], }); }); test("read-case keeps an unresolved pending revision as the active follow-up", async () => { const revisionId = "88888888-8888-4888-8888-888888888888"; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ turns: [ { id: TURN_ID, role: "assistant", text: "你是把入职时间改成2017年,对吗?", status: "completed", created_at: "2026-08-12T10:00:00.000Z", }, ], conversationSummary: conversationSummaryFixture({ activeFocus: activeFocusFixture({ targetEvidenceId: revisionId, intent: "confirm_revision", targetDomain: "career", targetKind: "career_entry", expectedAnswerSchema: { type: "confirmation" }, }), }), evidence: [{ id: revisionId, source_turn_id: TURN_ID, subject: "self", event_kind: "career_entry", domain: "career", occurred_from: "2017-01-01", occurred_to: null, date_precision: "year", summary: "2017年开始工作", status: "pending_confirmation", supersedes_evidence_id: EVIDENCE_ID, created_at: "2026-08-12T10:00:07.000Z", }], }), get_agentic_rectification_case_compute: () => computeFixture(), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const projection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise>; }).execute({ caseId: CASE_ID }); assert.deepEqual((projection.conversation_context as { active_followup: unknown }).active_followup, { focus_id: FOCUS_ID, question_id: "question-1", intent: "confirm_revision", evidence_id: revisionId, target_domain: "career", target_kind: "career_entry", expected_answer_schema: { type: "confirmation" }, }); }); test("read-case keeps conversation and evidence context within the public safety bounds", async () => { const longQuestion = "这是一段需要截断的追问".repeat(180); const turns = [ ...Array.from({ length: 4 }, (_, index) => ({ id: `prefix-${index}`, role: index % 2 === 0 ? "assistant" : "user", text: `较早轮次-${index}`, status: "completed", created_at: `2026-08-12T09:00:${String(index).padStart(2, "0")}.000Z`, })), { id: "outside-scan-question", role: "assistant", text: "扫描窗口外的问题", status: "completed", created_at: "2026-08-12T09:00:04.000Z", }, { id: "outside-scan-refusal", role: "user", text: "记不清了,换个方向吧", status: "completed", created_at: "2026-08-12T09:00:05.000Z", }, ...Array.from({ length: 12 }, (_, pairIndex) => [ { id: `question-${pairIndex}`, role: "assistant", text: `问题-${pairIndex}-${longQuestion}`, status: pairIndex === 8 ? "failed" : pairIndex === 9 ? "retryable" : "completed", created_at: `2026-08-12T10:${String(pairIndex).padStart(2, "0")}:00.000Z`, }, { id: `answer-${pairIndex}`, role: "user", text: "记不清了,换个方向吧", status: "completed", created_at: `2026-08-12T10:${String(pairIndex).padStart(2, "0")}:01.000Z`, }, ]).flat(), ]; const evidence = Array.from({ length: 25 }, (_, index) => ({ id: `evidence-${index}`, source_turn_id: TURN_ID, subject: "self", event_kind: "career_entry", domain: "career", occurred_from: `${2000 + index}-01-01`, occurred_to: null, date_precision: "year", summary: `${2000 + index}年发生的事件`, status: "confirmed", supersedes_evidence_id: null, created_at: `2026-08-12T11:${String(index).padStart(2, "0")}:00.000Z`, })); const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: turns.length, evidenceCount: evidence.length, conversationSummary: conversationSummaryFixture({ declinedSkippedTopics: [7, 10, 11].map((index) => ({ focus_id: `focus-${index}`, intent: "collect_event_date", target_evidence_id: `evidence-${index}`, target_domain: "career", target_kind: "career_entry", status: "declined", asked_at: `2026-08-12T10:${String(index).padStart(2, "0")}:00.000Z`, resolved_at: `2026-08-12T10:${String(index).padStart(2, "0")}:01.000Z`, })), }), turns, evidence, }), get_agentic_rectification_case_compute: () => computeFixture(), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const projection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise>; }).execute({ caseId: CASE_ID }); const evidenceContext = projection.evidence_context as Array<{ evidence_id: string }>; const conversationContext = projection.conversation_context as { recent_turns: Array<{ role: string; text: string }>; declined_targets: Array<{ focus_id: string }>; }; assert.equal(evidenceContext.length, 20); assert.equal(evidenceContext[0]?.evidence_id, "evidence-5"); assert.equal(evidenceContext.at(-1)?.evidence_id, "evidence-24"); assert.equal(conversationContext.recent_turns.length, 6); assert.ok(conversationContext.recent_turns.every((turn) => turn.text.length <= 1_600)); assert.equal(conversationContext.declined_targets.length, 3); assert.deepEqual( conversationContext.declined_targets.map((target) => target.focus_id), ["focus-7", "focus-10", "focus-11"], ); const serialized = JSON.stringify(projection.conversation_context); assert.doesNotMatch(serialized, /扫描窗口外的问题|问题-8-|问题-9-/); }); test("safe tool error mapping downgrades unknown engine failures", () => { assert.equal( safeToolErrorCode(new Error("agentic_rectification_quote_not_grounded")), "quote_not_grounded", ); assert.equal( safeToolErrorCode(new Error("agentic_rectification_case_terminal")), "case_terminal", ); assert.equal(safeToolErrorCode(new Error("connection refused")), "tool_failed"); }); test("compare-candidates refuses to run without scorable evidence", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ evidence: [], }), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); await assert.rejects( (tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise }).execute({ caseId: CASE_ID }), (error: unknown) => error instanceof Error && error.message.includes("no_scorable_evidence"), ); }); test("close-case is a user completion, never an engine confirmation", async () => { const accounting = fakeAccounting({ ...receiptHandlers, close_agentic_rectification_case: () => ({ success: true, case_id: CASE_ID, status: "closed", idempotent: false, }), }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const result = await (tools["rectification-close-case"] as unknown as { execute(input: unknown): Promise<{ status: string }>; }).execute({ caseId: CASE_ID, reason: "completed_by_user" }); assert.equal(result.status, "closed"); assert.notEqual(result.status, "confirmed"); const call = accounting.calls.find((item) => item.fn === "close_agentic_rectification_case"); assert.ok(call); assert.equal(call.args.p_reason, "completed_by_user"); }); test("no sensitive birth data in candidate accept inputs", () => { const accounting = fakeAccounting({}); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); const schema = (tools["rectification-accept-candidate"] as unknown as { inputSchema: { safeParse(value: unknown): { success: boolean } }; }).inputSchema; const valid = schema.safeParse({ caseId: CASE_ID, resultId: RESULT_ID, candidateId: CANDIDATE_ID }); assert.equal(valid.success, true); const withBirth = schema.safeParse({ caseId: CASE_ID, resultId: RESULT_ID, candidateId: CANDIDATE_ID, birth_date: "1997-08-08", active_birth_time: "05:00", }); assert.equal(withBirth.success, false); void CANDIDATE_RANGE; void EVIDENCE_ID; void SESSION_ID; });