import assert from "node:assert/strict"; import { readFileSync, realpathSync } from "node:fs"; import { basename } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { RECTIFICATION_AGENT_HARD_STEP_LIMIT, RECTIFICATION_AGENT_MAX_STEPS, RECTIFICATION_AGENT_STEP_BUDGETS, RECTIFICATION_V9_PACKAGE_PATH, RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_SKILL_NAME, getRectificationV9Agent, getRectificationV9RegenerationAgent, resolveRectificationStepBudget, } from "../src/mastra/agentic-rectification.ts"; import { createRectificationV9ReadOnlyTools, createRectificationV9Tools, } from "../src/mastra/rectification-v9-tools.ts"; import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts"; import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts"; import { CASE_ID, CANDIDATE_RANGE, CANDIDATE_ID, EVIDENCE_ID, SESSION_ID, TURN_ID, USER_ID, candidateSnapshotFixture, dossierFixture, fakeAccounting, receiptHandlers, RESULT_ID, } from "./rectification-v9-test-support.ts"; const agentSource = readFileSync( fileURLToPath(new URL("../src/mastra/agentic-rectification.ts", import.meta.url)), "utf8", ); test("system prompt carries only high-priority boundaries, never the method copy", () => { const promptStart = agentSource.indexOf("const agenticRectificationInstructions"); const promptEnd = agentSource.indexOf("export function getRectificationV9Agent"); const prompt = agentSource.slice(promptStart, promptEnd); // No gate -> scan -> score -> diagnostics orchestration in the prompt. assert.doesNotMatch(prompt, /rectification-gate[\s\S]*rectification-scan/); assert.doesNotMatch(prompt, /rectification-score[\s\S]*rectification-diagnostics/); assert.doesNotMatch(prompt, /rectification-confirm[\s\S]*rectification-save-birth-time/); assert.doesNotMatch(prompt, /80%\/60%/); assert.doesNotMatch(prompt, /10[–-]15 个事件/); assert.doesNotMatch(prompt, /D9\/D10 类型表/); assert.doesNotMatch(prompt, /run the required gate/); assert.doesNotMatch(prompt, /candidate_range/); assert.match(prompt, /jyotish-birth-time-rectification/); // Keep the prompt short (~30 lines max). assert.ok(prompt.split("\n").length <= 60, "instructions must stay bounded"); }); test("agent pins the dedicated rectification skill and its fixed version", () => { assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification"); assert.equal(basename(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_SKILL_NAME); assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.0")); assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH); assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH); assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification"); assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.0"); }); test("step budgets are bounded per action with a hard ceiling", () => { assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.opening, 6); assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.evidence, 8); assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.rescore, 12); assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.accept, 6); assert.equal(RECTIFICATION_AGENT_MAX_STEPS, 12); for (const action of ["opening", "read_only", "evidence", "rescore", "accept", "confirm"]) { const budget = resolveRectificationStepBudget(action as keyof typeof RECTIFICATION_AGENT_STEP_BUDGETS); assert.ok(budget <= RECTIFICATION_AGENT_HARD_STEP_LIMIT, `${action} must respect the hard ceiling`); } assert.equal(resolveRectificationStepBudget("rescore"), 12); }); test("no tool accepts event arrays, birth data or candidate ranges as input", () => { const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: fakeAccounting({}).client as never, }); const toolNames = [ "rectification-read-case", "rectification-propose-evidence", "rectification-confirm-evidence", "rectification-revise-evidence", "rectification-compare-candidates", "rectification-read-diagnostics", "rectification-offer-candidates", "rectification-accept-candidate", "rectification-confirm-birth-time", "rectification-close-case", ]; for (const name of toolNames) { const tool = tools[name as keyof typeof tools] as unknown as { inputSchema: { safeParse(value: unknown): { success: boolean } } }; assert.ok(tool?.inputSchema, `${name} must expose an input schema`); const malicious = { caseId: CASE_ID, userId: USER_ID, birth_date: "1997-08-08", latitude: 36.4, longitude: 114.2, timezone_offset: 8, candidate_range: { start_time: "04:00", end_time: "06:00" }, events: [{ id: "e1", domain: "career", date: "2016-09" }], scores: [10, 20], confirmationAllowed: true, }; const result = tool.inputSchema.safeParse(malicious); assert.equal(result.success, false, `${name} must reject userId/birth/range/events/scores/permissions`); } }); type StreamChunk = { type: string; payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown }; }; type FakeStreamResult = { fullStream: AsyncIterable; totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>; }; function chunk(type: string, payload?: Record): StreamChunk { return { type, ...(payload ? { payload } : {}) }; } function fakeAgentStream(chunks: StreamChunk[]) { const streamResult: FakeStreamResult = { 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 runOptions(overrides: Partial = {}): { options: V9AgentRunOptions; emitted: Array<{ type: string }>; billing: { reserved: number; completed: number; released: number }; } { const emitted: Array<{ type: string }> = []; const billing = { reserved: 0, completed: 0, released: 0 }; const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture(), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), get_agentic_rectification_turn_receipt: () => null, }); const optionsValue: V9AgentRunOptions = { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", action: "evidence", message: "2016年9月离开家去北京工作", modelName: "gpt-4o-mini", accounting: accounting.client, billing: { reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; }, complete: async () => { billing.completed += 1; return true; }, release: async () => { billing.released += 1; return true; }, }, emit: (event) => { emitted.push(event); }, buildAgent: async () => fakeAgentStream([]) as never, ...overrides, }; return { options: optionsValue, emitted, billing }; } test("agent receives the exact server-owned case id for tool calls", async () => { let observedMessages: unknown[] = []; const agent = fakeAgentStream([ chunk("start"), chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), chunk("tool-result", { toolName: "skill" }), chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), chunk("tool-result", { toolName: "rectification-read-case" }), chunk("text-delta", { text: "你好,我是生时校正助手。" }), chunk("finish"), ]); const { options } = runOptions({ action: "opening", message: null, buildAgent: async () => ({ ...agent, stream: async (messages: unknown[]) => { observedMessages = messages; return agent.stream(); }, }) as never, }); const result = await runV9AgentTurn(options); assert.equal(result.ok, true); const openingPrompt = JSON.stringify(observedMessages); assert.match(openingPrompt, new RegExp(CASE_ID)); assert.match(openingPrompt, /服务端 opening brief/); assert.match(openingPrompt, /不要要求一次说完/); assert.match(openingPrompt, /当前 active focus/); assert.doesNotMatch(openingPrompt, /说明你会通过已发生的人生事件来校正出生时间/); }); test("first turn with no real skill evidence retries once then fails without saving success", async () => { const { options, emitted, billing } = runOptions({ accounting: fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0, turns: [] }), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }), }).client, buildAgent: async () => fakeAgentStream([ chunk("start"), chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), chunk("tool-result", { toolName: "rectification-read-case" }), chunk("text-delta", { text: "你好," }), chunk("finish"), ]) as never, }); const result = await runV9AgentTurn(options); assert.equal(result.ok, false); assert.equal(result.turnStatus, "retryable"); assert.equal(result.skillLoaded, false); assert.equal(result.errorCode, "skill_not_bound"); assert.equal(billing.released, 1, "failed first turn must release usage"); assert.equal(billing.completed, 0); assert.equal(emitted.some((event) => event.type === "run.failed"), true); assert.equal(emitted.some((event) => event.type === "run.completed"), false); }); test("first turn with a bound immutable Skill completes and persists receipts", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0 }), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), }); const { options, emitted, billing } = runOptions({ accounting: accounting.client, buildAgent: async () => fakeAgentStream([ chunk("start"), chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), chunk("tool-result", { toolName: "skill" }), chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), chunk("tool-result", { toolName: "rectification-read-case" }), chunk("text-delta", { text: "你好,我是生时校正助手。" }), chunk("finish"), ]) as never, }); const result = await runV9AgentTurn(options); assert.equal(result.ok, true); assert.equal(result.turnStatus, "completed"); assert.equal(result.skillLoaded, true); assert.equal(billing.completed, 1); assert.equal(billing.released, 0); const types = emitted.map((event) => event.type); assert.ok(types.includes("run.started")); assert.ok(types.includes("skill.bound")); assert.ok(types.includes("case.loaded")); assert.ok(types.includes("intent.classified")); assert.ok(types.includes("answer.composed")); assert.ok(types.includes("billing.settled")); assert.ok(types.includes("answer.delta")); assert.ok(types.includes("run.completed")); // Reasoning and raw chunks are dropped. const accountingCalls = accounting.calls.map((call) => call.fn); assert.ok(accountingCalls.includes("insert_agentic_rectification_run_phase")); assert.ok(accountingCalls.includes("finalize_agentic_rectification_turn")); }); test("framework getSkill failure is a controlled retry and then a failed turn", async () => { const { options, billing } = runOptions({ buildAgent: async () => ({ stream: async () => ({ fullStream: (async function* () {})() }), getSkill: async () => null, }) as never, }); const result = await runV9AgentTurn(options); assert.equal(result.ok, false); assert.equal(result.errorCode, "skill_not_loaded"); assert.equal(billing.released, 1); }); test("distinct evidence calls in one natural turn are not mistaken for a repeated tool loop", async () => { const firstEvidenceId = EVIDENCE_ID; const secondEvidenceId = "88888888-8888-4888-8888-888888888888"; const { options, billing } = runOptions({ buildAgent: async () => fakeAgentStream([ chunk("start"), chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), chunk("tool-result", { toolName: "skill" }), chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), chunk("tool-result", { toolName: "rectification-read-case" }), chunk("tool-call", { toolName: "rectification-propose-evidence", args: { caseId: CASE_ID, quote: "2016年9月离开家去北京开始工作", proposedKind: "career_entry" }, }), chunk("tool-result", { toolName: "rectification-propose-evidence" }), chunk("tool-call", { toolName: "rectification-confirm-evidence", args: { caseId: CASE_ID, evidenceId: firstEvidenceId } }), chunk("tool-result", { toolName: "rectification-confirm-evidence" }), chunk("tool-call", { toolName: "rectification-propose-evidence", args: { caseId: CASE_ID, quote: "2020年又搬到了上海", proposedKind: "relocation" }, }), chunk("tool-result", { toolName: "rectification-propose-evidence" }), chunk("tool-call", { toolName: "rectification-confirm-evidence", args: { caseId: CASE_ID, evidenceId: secondEvidenceId } }), chunk("tool-result", { toolName: "rectification-confirm-evidence" }), chunk("text-delta", { text: "这两件事已经分别纳入当前校正。" }), chunk("finish"), ]) as never, }); const result = await runV9AgentTurn(options); assert.equal(result.ok, true); assert.equal(result.errorCode, null); assert.equal(billing.completed, 1); assert.deepEqual(result.toolsUsed.filter((name) => name.includes("evidence")), [ "rectification-propose-evidence", "rectification-confirm-evidence", ]); }); test("a repeated identical tool call is detected and aborts the turn", async () => { const { options, billing } = runOptions({ buildAgent: async () => fakeAgentStream([ chunk("start"), chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }), chunk("tool-result", { toolName: "skill" }), ...Array.from({ length: 4 }, () => chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } })), chunk("finish"), ]) as never, }); const result = await runV9AgentTurn(options); assert.equal(result.ok, false); assert.equal(result.errorCode, "repeated_tool_call"); assert.equal(billing.released, 1); }); test("a failed opening does not let the next turn skip the real skill gate", async () => { // The dossier has one failed turn and no completed turn: the skill gate // must still apply, so an agent that never invokes the skill tool fails. const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 1, turns: [{ id: TURN_ID, role: "user", text: "(开场)", status: "failed", created_at: "2026-08-12T10:00:00.000Z", completed_at: null, }], }), append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }), }); const { options, billing } = runOptions({ accounting: accounting.client, buildAgent: async () => fakeAgentStream([ chunk("start"), chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }), chunk("tool-result", { toolName: "rectification-read-case" }), chunk("text-delta", { text: "你好," }), chunk("finish"), ]) as never, }); const result = await runV9AgentTurn(options); assert.equal(result.ok, false); assert.equal(result.errorCode, "skill_not_bound"); assert.equal(billing.released, 1); }); test("same evidence + range fingerprints reuse the cached candidate snapshot", async () => { const accounting = fakeAccounting({ persist_agentic_rectification_candidate_v2: () => ({ ...candidateSnapshotFixture(), cached: true, }), }); const cached = await persistV9Candidate(accounting.client, USER_ID, CASE_ID, { engineResultId: "engine-1", algorithmVersion: "rectification-v5", evidenceFingerprint: "b".repeat(64), rangeFingerprint: "c".repeat(64), skillVersion: "9.0.0", candidateRange: CANDIDATE_RANGE, eventContractVersion: "rectification-event-contract-v2", policyVersion: "rectification-candidate-policy-v2", candidates: [{ candidateId: CANDIDATE_ID, rank: 1, time: "05:02", relativeSupport: 58, tiedMinuteCount: 2 }], decisionReceipt: { receipt_version: "candidate-decision-receipt-v2", policy_version: "rectification-candidate-policy-v2", selection_allowed: true, acceptance_allowed: true, confirmation_allowed: false, representative_candidate_id: null, overall_confidence: "medium", }, executionLedger: [{ method: "d1-rashi", status: "executed" }], }); assert.equal(cached.cached, true); assert.equal(cached.resultId, RESULT_ID); const call = accounting.calls.find((item) => item.fn === "persist_agentic_rectification_candidate_v2"); assert.ok(call); assert.equal(call.args.p_evidence_ledger_fingerprint, "b".repeat(64)); assert.equal(call.args.p_skill_version, "9.0.0"); assert.equal(call.args.p_event_contract_version, "rectification-event-contract-v2"); assert.equal(call.args.p_decision_policy_version, "rectification-candidate-policy-v2"); assert.deepEqual(call.args.p_decision_receipt, cached.decisionReceipt); assert.deepEqual(call.args.p_execution_ledger, cached.executionLedger); assert.equal("p_selection_allowed" in call.args, false); assert.equal("p_confirmation_allowed" in call.args, false); assert.equal("p_representative_time" in call.args, false); assert.equal("p_margin_percent" in call.args, false); }); test("accept-candidate requires a server-persisted result; no tool means no minute", async () => { const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: null }), accept_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_candidate_not_found"); }, }); const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); await assert.rejects( (tools["rectification-accept-candidate"] as unknown as { execute(input: unknown): Promise }).execute({ caseId: CASE_ID, resultId: RESULT_ID, candidateId: CANDIDATE_ID, }), (error: unknown) => error instanceof Error && error.message.includes("candidate_not_found"), ); }); test("new evidence lets the agent choose diagnostics/compare tools autonomously", () => { // The tool layer exposes read-diagnostics and compare-candidates; there is // no hard-coded orchestration forcing a scan before score. const tools = createRectificationV9Tools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: fakeAccounting({}).client as never, }); assert.ok("rectification-compare-candidates" in tools); assert.ok("rectification-read-diagnostics" in tools); assert.ok("rectification-offer-candidates" in tools); assert.ok(!("rectification-scan" in tools)); assert.ok(!("rectification-gate" in tools)); }); test("agent construction wires the pinned skill and the ten v9 tools", () => { const model = { id: "test-model", label: "Test", description: "", creditCost: 1, isDefault: true, mode: "compatible" as const, model: { provider: "openai", name: "gpt-4o-mini", modelId: "gpt-4o-mini" } as never, }; const accounting = fakeAccounting({}); const agent = getRectificationV9Agent(model, { userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); assert.equal(agent.id, "rectification-v9-test-model"); assert.ok(agent); }); test("reply regeneration is a separate Jyotisha agent with only read-case access", () => { const model = { id: "test-model", label: "Test", description: "", creditCost: 1, isDefault: true, mode: "compatible" as const, model: { provider: "openai", name: "gpt-4o-mini", modelId: "gpt-4o-mini" } as never, }; const accounting = fakeAccounting({}); const tools = createRectificationV9ReadOnlyTools({ userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); assert.deepEqual(Object.keys(tools), ["rectification-read-case"]); const agent = getRectificationV9RegenerationAgent(model, { userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID, accounting: accounting.client as never, }); assert.equal(agent.id, "rectification-v9-regeneration-test-model"); assert.match(agentSource, /这不是新一轮校正/); assert.match(agentSource, /只能使用 rectification-read-case/); });