366 lines
12 KiB
TypeScript
366 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
confirmV10Evidence,
|
|
recordV10EvidenceBatch,
|
|
resolveV10ConversationFocus,
|
|
reviseV10Evidence,
|
|
setV10ConversationFocus,
|
|
type V10EvidenceBatchItem,
|
|
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
|
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
|
import {
|
|
CASE_ID,
|
|
EVIDENCE_ID,
|
|
FOCUS_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
activeFocusFixture,
|
|
dossierFixture,
|
|
fakeAccounting,
|
|
receiptHandlers,
|
|
} from "./rectification-v9-test-support.ts";
|
|
|
|
type ExecutableTool<T = unknown> = {
|
|
execute(input: unknown): Promise<T>;
|
|
};
|
|
|
|
const SECOND_EVIDENCE_ID = "45454545-4545-4454-8454-454545454545";
|
|
|
|
function toolSet(accounting: ReturnType<typeof fakeAccounting>) {
|
|
return createRectificationV9Tools({
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
turnId: TURN_ID,
|
|
accounting: accounting.client as never,
|
|
});
|
|
}
|
|
|
|
test("set/resolve focus services preserve the exact durable RPC contract", async () => {
|
|
const accounting = fakeAccounting({
|
|
set_agentic_rectification_conversation_focus: () => ({
|
|
focus: activeFocusFixture({
|
|
targetEvidenceId: EVIDENCE_ID,
|
|
expectedAnswerSchema: { required: ["month"] },
|
|
}),
|
|
idempotent: false,
|
|
}),
|
|
resolve_agentic_rectification_conversation_focus: () => ({
|
|
focus_id: FOCUS_ID,
|
|
status: "declined",
|
|
evidence_id: EVIDENCE_ID,
|
|
idempotent: true,
|
|
}),
|
|
});
|
|
|
|
const setResult = await setV10ConversationFocus(accounting.client, USER_ID, CASE_ID, {
|
|
questionId: "career-month-question",
|
|
intent: "clarify_event_date",
|
|
targetEvidenceId: EVIDENCE_ID,
|
|
targetDomain: "career",
|
|
targetKind: "career_entry",
|
|
expectedAnswerSchema: { required: ["month"] },
|
|
});
|
|
assert.equal(setResult.focus.id, FOCUS_ID);
|
|
assert.equal(setResult.focus.targetEvidenceId, EVIDENCE_ID);
|
|
assert.deepEqual(setResult.focus.expectedAnswerSchema, { required: ["month"] });
|
|
assert.equal(setResult.idempotent, false);
|
|
assert.deepEqual(accounting.calls[0], {
|
|
fn: "set_agentic_rectification_conversation_focus",
|
|
args: {
|
|
p_user_id: USER_ID,
|
|
p_case_id: CASE_ID,
|
|
p_question_id: "career-month-question",
|
|
p_intent: "clarify_event_date",
|
|
p_target_evidence_id: EVIDENCE_ID,
|
|
p_target_domain: "career",
|
|
p_target_kind: "career_entry",
|
|
p_expected_answer_schema: { required: ["month"] },
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveV10ConversationFocus(accounting.client, USER_ID, CASE_ID, {
|
|
focusId: FOCUS_ID,
|
|
status: "declined",
|
|
evidenceId: EVIDENCE_ID,
|
|
});
|
|
assert.deepEqual(resolved, {
|
|
focusId: FOCUS_ID,
|
|
status: "declined",
|
|
evidenceId: EVIDENCE_ID,
|
|
idempotent: true,
|
|
});
|
|
assert.deepEqual(accounting.calls[1], {
|
|
fn: "resolve_agentic_rectification_conversation_focus",
|
|
args: {
|
|
p_user_id: USER_ID,
|
|
p_case_id: CASE_ID,
|
|
p_focus_id: FOCUS_ID,
|
|
p_status: "declined",
|
|
p_evidence_id: EVIDENCE_ID,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("set/resolve focus tools return safe projections and never infer the active focus", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
set_agentic_rectification_conversation_focus: () => ({
|
|
focus: activeFocusFixture({ targetEvidenceId: EVIDENCE_ID }),
|
|
idempotent: false,
|
|
}),
|
|
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
|
|
focus_id: args.p_focus_id,
|
|
status: args.p_status,
|
|
evidence_id: args.p_evidence_id,
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
const tools = toolSet(accounting);
|
|
|
|
const setResult = await (tools["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
|
caseId: CASE_ID,
|
|
questionId: "career-month-question",
|
|
intent: "clarify_event_date",
|
|
targetEvidenceId: EVIDENCE_ID,
|
|
targetDomain: "career",
|
|
targetKind: "career_entry",
|
|
});
|
|
assert.deepEqual(setResult, {
|
|
focus_id: FOCUS_ID,
|
|
question_id: "question-1",
|
|
intent: "confirm_revision",
|
|
target_evidence_id: EVIDENCE_ID,
|
|
target_domain: "career",
|
|
target_kind: "career_entry",
|
|
expected_answer_schema: {},
|
|
status: "active",
|
|
asked_at: "2026-08-12T10:00:05.000Z",
|
|
idempotent: false,
|
|
});
|
|
|
|
const resolved = await (tools["rectification-resolve-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute({
|
|
caseId: CASE_ID,
|
|
focusId: FOCUS_ID,
|
|
status: "skipped",
|
|
evidenceId: EVIDENCE_ID,
|
|
});
|
|
assert.deepEqual(resolved, {
|
|
focus_id: FOCUS_ID,
|
|
evidence_id: EVIDENCE_ID,
|
|
status: "skipped",
|
|
idempotent: false,
|
|
});
|
|
const resolveCall = accounting.calls.find((call) => call.fn === "resolve_agentic_rectification_conversation_focus");
|
|
assert.equal(resolveCall?.args.p_focus_id, FOCUS_ID);
|
|
assert.equal(resolveCall?.args.p_evidence_id, EVIDENCE_ID);
|
|
});
|
|
|
|
test("confirm and revise services bind both focusId and evidenceId", async () => {
|
|
const accounting = fakeAccounting({
|
|
confirm_agentic_rectification_evidence_v10: () => ({
|
|
focus_id: FOCUS_ID,
|
|
evidence_id: EVIDENCE_ID,
|
|
status: "confirmed",
|
|
idempotent: false,
|
|
}),
|
|
revise_agentic_rectification_evidence_v10: () => ({
|
|
focus_id: FOCUS_ID,
|
|
evidence_id: SECOND_EVIDENCE_ID,
|
|
supersedes_evidence_id: EVIDENCE_ID,
|
|
idempotent: true,
|
|
}),
|
|
});
|
|
|
|
const confirmed = await confirmV10Evidence(accounting.client, USER_ID, CASE_ID, FOCUS_ID, EVIDENCE_ID);
|
|
assert.deepEqual(confirmed, {
|
|
focusId: FOCUS_ID,
|
|
evidenceId: EVIDENCE_ID,
|
|
status: "confirmed",
|
|
idempotent: false,
|
|
});
|
|
|
|
const revised = await reviseV10Evidence(accounting.client, USER_ID, CASE_ID, {
|
|
focusId: FOCUS_ID,
|
|
evidenceId: EVIDENCE_ID,
|
|
quote: "不是9月,是10月",
|
|
occurredFrom: "2016-10-01",
|
|
occurredTo: null,
|
|
datePrecision: "month",
|
|
summary: "更正为2016年10月去北京工作",
|
|
});
|
|
assert.deepEqual(revised, {
|
|
focusId: FOCUS_ID,
|
|
evidenceId: SECOND_EVIDENCE_ID,
|
|
supersedesEvidenceId: EVIDENCE_ID,
|
|
idempotent: true,
|
|
});
|
|
|
|
const confirmCall = accounting.calls.find((call) => call.fn === "confirm_agentic_rectification_evidence_v10");
|
|
assert.equal(confirmCall?.args.p_focus_id, FOCUS_ID);
|
|
assert.equal(confirmCall?.args.p_evidence_id, EVIDENCE_ID);
|
|
const reviseCall = accounting.calls.find((call) => call.fn === "revise_agentic_rectification_evidence_v10");
|
|
assert.equal(reviseCall?.args.p_focus_id, FOCUS_ID);
|
|
assert.equal(reviseCall?.args.p_evidence_id, EVIDENCE_ID);
|
|
});
|
|
|
|
test("confirm/revise tools forward the durable focus/evidence binding", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
|
confirm_agentic_rectification_evidence_v10: (_fn, args) => ({
|
|
focus_id: args.p_focus_id,
|
|
evidence_id: args.p_evidence_id,
|
|
status: "confirmed",
|
|
idempotent: false,
|
|
}),
|
|
revise_agentic_rectification_evidence_v10: (_fn, args) => ({
|
|
focus_id: args.p_focus_id,
|
|
evidence_id: SECOND_EVIDENCE_ID,
|
|
supersedes_evidence_id: args.p_evidence_id,
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
const tools = toolSet(accounting);
|
|
|
|
await (tools["rectification-confirm-evidence"] as unknown as ExecutableTool).execute({
|
|
caseId: CASE_ID,
|
|
focusId: FOCUS_ID,
|
|
evidenceId: EVIDENCE_ID,
|
|
});
|
|
await (tools["rectification-revise-evidence"] as unknown as ExecutableTool).execute({
|
|
caseId: CASE_ID,
|
|
focusId: FOCUS_ID,
|
|
evidenceId: EVIDENCE_ID,
|
|
quote: "不是9月,是10月",
|
|
datePrecision: "month",
|
|
occurredFrom: "2016-10",
|
|
summary: "更正为2016年10月去北京工作",
|
|
});
|
|
|
|
const writes = accounting.calls.filter((call) =>
|
|
call.fn === "confirm_agentic_rectification_evidence_v10"
|
|
|| call.fn === "revise_agentic_rectification_evidence_v10");
|
|
assert.deepEqual(writes.map((call) => ({
|
|
fn: call.fn,
|
|
focusId: call.args.p_focus_id,
|
|
evidenceId: call.args.p_evidence_id,
|
|
})), [
|
|
{ fn: "confirm_agentic_rectification_evidence_v10", focusId: FOCUS_ID, evidenceId: EVIDENCE_ID },
|
|
{ fn: "revise_agentic_rectification_evidence_v10", focusId: FOCUS_ID, evidenceId: EVIDENCE_ID },
|
|
]);
|
|
});
|
|
|
|
test("batch preserves three independent items, parses all outcomes, and keeps item idempotency stable", async () => {
|
|
let invocation = 0;
|
|
const accounting = fakeAccounting({
|
|
record_agentic_rectification_evidence_batch: () => {
|
|
invocation += 1;
|
|
return {
|
|
items: [
|
|
{
|
|
index: 0,
|
|
outcome: "accepted",
|
|
evidence_id: EVIDENCE_ID,
|
|
status: "confirmed",
|
|
idempotent: invocation > 1,
|
|
clarification_fields: [],
|
|
error_code: null,
|
|
},
|
|
{
|
|
index: 1,
|
|
outcome: "needs_clarification",
|
|
evidence_id: null,
|
|
status: "pending_confirmation",
|
|
idempotent: invocation > 1,
|
|
clarification_fields: ["occurred_from"],
|
|
error_code: null,
|
|
},
|
|
{
|
|
index: 2,
|
|
outcome: "rejected",
|
|
evidence_id: null,
|
|
status: "rejected",
|
|
idempotent: invocation > 1,
|
|
clarification_fields: [],
|
|
error_code: "quote_not_grounded",
|
|
},
|
|
],
|
|
accepted_count: 1,
|
|
needs_clarification_count: 1,
|
|
rejected_count: 1,
|
|
focus_id: FOCUS_ID,
|
|
};
|
|
},
|
|
});
|
|
const items: V10EvidenceBatchItem[] = [
|
|
{
|
|
quote: "2016年9月去北京工作",
|
|
subject: "self" as const,
|
|
eventKind: "career_entry" as const,
|
|
domain: "career",
|
|
occurredFrom: "2016-09-01",
|
|
occurredTo: null,
|
|
datePrecision: "month",
|
|
summary: "2016年9月去北京工作",
|
|
},
|
|
{
|
|
quote: "后来搬过一次家",
|
|
subject: "self" as const,
|
|
eventKind: "relocation" as const,
|
|
domain: "relocation",
|
|
occurredFrom: null,
|
|
occurredTo: null,
|
|
datePrecision: "unknown",
|
|
summary: "后来搬过一次家",
|
|
},
|
|
{
|
|
quote: "助手猜测我2020年结婚",
|
|
subject: "self" as const,
|
|
eventKind: "relationship_commitment",
|
|
domain: "relationship",
|
|
occurredFrom: "2020-01-01",
|
|
occurredTo: null,
|
|
datePrecision: "year",
|
|
summary: "助手猜测的婚姻事件",
|
|
},
|
|
];
|
|
|
|
const first = await recordV10EvidenceBatch(accounting.client, USER_ID, CASE_ID, TURN_ID, FOCUS_ID, items);
|
|
const second = await recordV10EvidenceBatch(accounting.client, USER_ID, CASE_ID, TURN_ID, FOCUS_ID, items);
|
|
|
|
assert.deepEqual(first.items.map((item) => item.outcome), [
|
|
"accepted",
|
|
"needs_clarification",
|
|
"rejected",
|
|
]);
|
|
assert.deepEqual(first.items.map((item) => item.idempotent), [false, false, false]);
|
|
assert.deepEqual(second.items.map((item) => item.idempotent), [true, true, true]);
|
|
assert.deepEqual({
|
|
accepted: first.acceptedCount,
|
|
needsClarification: first.needsClarificationCount,
|
|
rejected: first.rejectedCount,
|
|
focusId: first.focusId,
|
|
}, {
|
|
accepted: 1,
|
|
needsClarification: 1,
|
|
rejected: 1,
|
|
focusId: FOCUS_ID,
|
|
});
|
|
assert.deepEqual(first.items[1]?.clarificationFields, ["occurred_from"]);
|
|
assert.equal(first.items[2]?.errorCode, "quote_not_grounded");
|
|
|
|
const calls = accounting.calls.filter((call) => call.fn === "record_agentic_rectification_evidence_batch");
|
|
assert.equal(calls.length, 2);
|
|
const firstItems = calls[0]!.args.p_items as Array<Record<string, unknown>>;
|
|
const secondItems = calls[1]!.args.p_items as Array<Record<string, unknown>>;
|
|
assert.deepEqual(firstItems.map((item) => item.quote), items.map((item) => item.quote));
|
|
assert.deepEqual(firstItems.map((item) => item.event_kind), items.map((item) => item.eventKind));
|
|
assert.equal(new Set(firstItems.map((item) => item.idempotency_key)).size, 3);
|
|
assert.deepEqual(
|
|
secondItems.map((item) => item.idempotency_key),
|
|
firstItems.map((item) => item.idempotency_key),
|
|
);
|
|
});
|