259 lines
7.7 KiB
TypeScript
259 lines
7.7 KiB
TypeScript
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<RectificationRegenerationAgent> = {}) {
|
|
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<typeof fakeAccounting>["client"],
|
|
agent: RectificationRegenerationAgent,
|
|
overrides: Partial<Parameters<typeof regenerateV9AssistantTurn>[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<unknown>, 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);
|
|
});
|