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, EVIDENCE_ID, RESULT_ID, SESSION_ID, TURN_ID, USER_ID, candidateSnapshotFixture, computeFixture, 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: () => ({ 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, "05:02"); assert.equal(result.status, "accepted"); assert.equal(result.caseStatus, "candidate_accepted"); assert.notEqual(result.status, "confirmed"); }); 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_birth_time: () => { throw new Error("agentic_rectification_confirmation_blocked"); }, }); await assert.rejects( confirmV9BirthTime(blocked.client, USER_ID, CASE_ID, { resultId: RESULT_ID, time: "05:02", 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_birth_time: () => { throw new Error("agentic_rectification_confirm_time_mismatch"); }, }); await assert.rejects( confirmV9BirthTime(mismatch.client, USER_ID, CASE_ID, { resultId: RESULT_ID, time: "04:55", 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_birth_time: () => { throw new Error("agentic_rectification_consent_not_grounded"); }, }); await assert.rejects( confirmV9BirthTime(ungrounded.client, USER_ID, CASE_ID, { resultId: RESULT_ID, time: "05:02", consentQuote: "用户根本没说过这句话", sourceTurnId: TURN_ID, }), (error: unknown) => error instanceof Error && error.message.includes("consent_not_grounded"), ); }); test("candidate ownership is case-scoped: the RPC always receives the case id", async () => { const accounting = fakeAccounting({ accept_agentic_rectification_candidate_for_case: () => ({ 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, "05:02"); const call = accounting.calls.find((item) => item.fn === "accept_agentic_rectification_candidate_for_case"); 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); }); test("accept is idempotent: replaying the same selection succeeds without a second write", async () => { const accounting = fakeAccounting({ accept_agentic_rectification_candidate_for_case: () => ({ 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, "05:02"); 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, sourceTurnId: TURN_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("tool outputs never leak birth data, raw scores or the baseline snapshot", 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 }); const serialized = JSON.stringify(projection); assert.doesNotMatch(serialized, /birth_date/); assert.doesNotMatch(serialized, /1997-08-08/); assert.doesNotMatch(serialized, /latitude/); assert.doesNotMatch(serialized, /longitude/); assert.doesNotMatch(serialized, /baseline_birth_snapshot/); assert.doesNotMatch(serialized, /reported_birth_time/); }); 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: "05:02" }); assert.equal(valid.success, true); const withBirth = schema.safeParse({ caseId: CASE_ID, resultId: RESULT_ID, candidateId: "05:02", birth_date: "1997-08-08", active_birth_time: "05:00", }); assert.equal(withBirth.success, false); void CANDIDATE_RANGE; void EVIDENCE_ID; void SESSION_ID; });