import assert from "node:assert/strict"; import test from "node:test"; import { regenerateV9AssistantTurn, type RectificationRegenerationAgent, } from "../src/lib/rectification-agentic/v9/regenerate-turn.ts"; import { RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { CASE_ID, SESSION_ID, SOURCE_TURN_ID, TURN_ID, USER_ID, RECTIFICATION_SKILL_SHA256, RECTIFICATION_SKILL_SOURCE_COMMIT, dossierFixture, fakeAccounting, } from "./rectification-v9-test-support.ts"; const REQUEST_ID = "88888888-8888-4888-8888-888888888888"; const OLD_REPLY = "旧的 Agent 正文"; const NEW_REPLY = "新的自然回复"; const SKILL_PACKAGE = { name: "jyotish-birth-time-rectification", version: "9.0.0", sha256: RECTIFICATION_SKILL_SHA256, sourceCommit: RECTIFICATION_SKILL_SOURCE_COMMIT, packagePath: "skills/jyotish-birth-time-rectification", status: "active" as const, resolvedPath: "/repo/skills/jyotish-birth-time-rectification", }; function completedTurns() { return [ { id: TURN_ID, role: "user", text: "2020 年 4 月开始实习", status: "completed", created_at: "2026-08-13T01:00:00.000Z", }, { id: TURN_ID, role: "assistant", text: OLD_REPLY, status: "completed", created_at: "2026-08-13T01:00:00.000Z", }, ]; } function fakeAgent(overrides: Partial = {}) { const calls = { getSkill: 0, generate: 0 }; const agent: RectificationRegenerationAgent = { async getSkill() { calls.getSkill += 1; return { name: "jyotish-birth-time-rectification" }; }, async generate() { calls.generate += 1; return { text: NEW_REPLY }; }, ...overrides, }; return { agent, calls }; } function validAccounting() { return fakeAccounting({ get_agentic_rectification_turn_regeneration: () => null, get_agentic_rectification_case_dossier: () => dossierFixture({ turns: completedTurns() }), insert_agentic_rectification_skill_run_receipt: () => ({ receipt_id: "99999999-9999-4999-8999-999999999999", }), regenerate_agentic_rectification_turn: () => ({ ok: true, turn_id: TURN_ID, assistant_message: NEW_REPLY, idempotent: false, }), }); } function options( accounting: ReturnType["client"], agent: RectificationRegenerationAgent, overrides: Partial[0]> = {}, ) { return { userId: USER_ID, caseId: CASE_ID, sessionId: SESSION_ID, turnId: TURN_ID, requestId: REQUEST_ID, accounting, agent, skillPackage: SKILL_PACKAGE, ...overrides, }; } async function rejectsWithCode(promise: Promise, code: string) { await assert.rejects(promise, (error: unknown) => ( error instanceof RectificationToolServiceError && error.code === code )); } test("regeneration replaces the latest completed Assistant text in place", async () => { const accounting = validAccounting(); const { agent, calls } = fakeAgent(); const result = await regenerateV9AssistantTurn(options(accounting.client, agent)); assert.deepEqual(result, { ok: true, turnId: TURN_ID, assistantMessage: NEW_REPLY, idempotent: false, }); assert.equal(calls.getSkill, 1); assert.equal(calls.generate, 1); assert.deepEqual(accounting.calls.map((call) => call.fn), [ "get_agentic_rectification_turn_regeneration", "get_agentic_rectification_case_dossier", "insert_agentic_rectification_skill_run_receipt", "regenerate_agentic_rectification_turn", ]); const receipt = accounting.calls.find((call) => call.fn === "insert_agentic_rectification_skill_run_receipt"); assert.deepEqual(receipt?.args, { p_user_id: USER_ID, p_case_id: CASE_ID, p_turn_id: TURN_ID, p_request_id: REQUEST_ID, p_run_kind: "regeneration", p_skill_name: SKILL_PACKAGE.name, p_skill_version: SKILL_PACKAGE.version, p_skill_sha256: SKILL_PACKAGE.sha256, p_source_commit: SKILL_PACKAGE.sourceCommit, }); const update = accounting.calls.at(-1); assert.equal(update?.args.p_assistant_message, NEW_REPLY); assert.equal(accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn"), false); }); test("same request id returns the stored replacement without invoking the Agent again", async () => { const accounting = fakeAccounting({ get_agentic_rectification_turn_regeneration: () => ({ ok: true, turn_id: TURN_ID, assistant_message: NEW_REPLY, idempotent: true, }), }); const { agent, calls } = fakeAgent(); const result = await regenerateV9AssistantTurn(options(accounting.client, agent)); assert.equal(result.idempotent, true); assert.equal(calls.getSkill, 0); assert.equal(calls.generate, 0); assert.deepEqual(accounting.calls.map((call) => call.fn), [ "get_agentic_rectification_turn_regeneration", ]); }); test("terminal cases cannot regenerate replies", async () => { const accounting = fakeAccounting({ get_agentic_rectification_turn_regeneration: () => null, get_agentic_rectification_case_dossier: () => dossierFixture({ status: "closed", turns: completedTurns(), }), }); const { agent, calls } = fakeAgent(); await rejectsWithCode( regenerateV9AssistantTurn(options(accounting.client, agent)), "agentic_rectification_case_terminal", ); assert.equal(calls.generate, 0); }); test("case and session must remain exactly bound", async () => { const accounting = validAccounting(); const { agent, calls } = fakeAgent(); await rejectsWithCode( regenerateV9AssistantTurn(options(accounting.client, agent, { sessionId: "99999999-9999-4999-8999-999999999999", })), "agentic_rectification_case_session_mismatch", ); assert.equal(calls.generate, 0); }); test("only the latest completed Assistant turn can be regenerated", async () => { const turns = [ { id: SOURCE_TURN_ID, role: "assistant", text: "更早的回答", status: "completed", created_at: "2026-08-13T00:00:00.000Z", }, ...completedTurns(), ]; const accounting = fakeAccounting({ get_agentic_rectification_turn_regeneration: () => null, get_agentic_rectification_case_dossier: () => dossierFixture({ turns }), }); const { agent, calls } = fakeAgent(); await rejectsWithCode( regenerateV9AssistantTurn(options(accounting.client, agent, { turnId: SOURCE_TURN_ID })), "agentic_rectification_turn_not_latest", ); assert.equal(calls.generate, 0); assert.equal(accounting.calls.some((call) => call.fn === "regenerate_agentic_rectification_turn"), false); }); test("missing or empty completed Assistant text is rejected", async () => { const accounting = fakeAccounting({ get_agentic_rectification_turn_regeneration: () => null, get_agentic_rectification_case_dossier: () => dossierFixture({ turns: [{ id: TURN_ID, role: "assistant", text: " ", status: "completed", created_at: "2026-08-13T01:00:00.000Z", }], }), }); const { agent, calls } = fakeAgent(); await rejectsWithCode( regenerateV9AssistantTurn(options(accounting.client, agent)), "agentic_rectification_turn_not_found", ); assert.equal(calls.generate, 0); }); test("an empty regenerated body never reaches the update RPC", async () => { const accounting = validAccounting(); const { agent } = fakeAgent({ async generate() { return { text: " " }; }, }); await rejectsWithCode( regenerateV9AssistantTurn(options(accounting.client, agent)), "agentic_rectification_regeneration_empty", ); assert.equal(accounting.calls.some((call) => call.fn === "regenerate_agentic_rectification_turn"), false); });