709 lines
24 KiB
TypeScript
709 lines
24 KiB
TypeScript
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,
|
|
FOCUS_ID,
|
|
RESULT_ID,
|
|
SESSION_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
activeFocusFixture,
|
|
candidateSnapshotFixture,
|
|
computeFixture,
|
|
conversationSummaryFixture,
|
|
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("confirm-birth-time binds consent to the server-owned current turn", async () => {
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
confirm_agentic_rectification_birth_time: () => ({
|
|
success: true,
|
|
saved_time: "05:02",
|
|
status: "confirmed",
|
|
result_id: RESULT_ID,
|
|
case_status: "confirmed",
|
|
idempotent: false,
|
|
}),
|
|
});
|
|
const tools = createRectificationV9Tools({
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
turnId: TURN_ID,
|
|
accounting: accounting.client as never,
|
|
});
|
|
const confirm = tools["rectification-confirm-birth-time"] as unknown as {
|
|
inputSchema: { safeParse(value: unknown): { success: boolean } };
|
|
execute(input: unknown): Promise<unknown>;
|
|
};
|
|
const input = {
|
|
caseId: CASE_ID,
|
|
resultId: RESULT_ID,
|
|
candidateId: "05:02",
|
|
consentQuote: "就用05:02",
|
|
};
|
|
assert.equal(confirm.inputSchema.safeParse(input).success, true);
|
|
assert.equal(confirm.inputSchema.safeParse({ ...input, sourceTurnId: "77777777-7777-4777-8777-777777777777" }).success, false);
|
|
|
|
await confirm.execute(input);
|
|
const call = accounting.calls.find((item) => item.fn === "confirm_agentic_rectification_birth_time");
|
|
assert.ok(call);
|
|
assert.equal(call.args.p_source_turn_id, TURN_ID);
|
|
});
|
|
|
|
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<unknown> }).execute({
|
|
caseId: CASE_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("read-case gives the Agent authoritative birth context without raw internals", 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<Record<string, unknown>>;
|
|
}).execute({ caseId: CASE_ID });
|
|
assert.deepEqual(projection.birth_context, {
|
|
birth_date: "1997-08-08",
|
|
reported_birth_time: "05:00",
|
|
active_birth_time: null,
|
|
candidate_range: CANDIDATE_RANGE,
|
|
birthplace: {
|
|
label: "河北省邯郸市",
|
|
latitude: 36.420487,
|
|
longitude: 114.209936,
|
|
},
|
|
timezone: {
|
|
id: "Asia/Shanghai",
|
|
offset: 8,
|
|
},
|
|
});
|
|
const serialized = JSON.stringify(projection);
|
|
assert.doesNotMatch(serialized, /baseline_birth_snapshot/);
|
|
assert.doesNotMatch(serialized, /event_contribution_matrix|rule_ids|raw_score|weight/);
|
|
});
|
|
|
|
test("read-case exposes bounded safe context for multi-turn follow-ups without raw ledger internals", async () => {
|
|
const revisionId = "88888888-8888-4888-8888-888888888888";
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture({
|
|
turnCount: 5,
|
|
evidenceCount: 2,
|
|
turns: [
|
|
{
|
|
id: TURN_ID,
|
|
role: "user",
|
|
text: "2016年9月离开家去北京开始工作,2020年又搬到了上海",
|
|
status: "completed",
|
|
created_at: "2026-08-12T10:00:00.000Z",
|
|
},
|
|
{
|
|
id: TURN_ID,
|
|
role: "assistant",
|
|
text: "第二次搬家大约发生在哪个月?",
|
|
status: "completed",
|
|
created_at: "2026-08-12T10:00:00.000Z",
|
|
},
|
|
{
|
|
id: "99999999-9999-4999-8999-999999999998",
|
|
role: "user",
|
|
text: "月份记不清了,换个方向吧",
|
|
status: "completed",
|
|
created_at: "2026-08-12T10:01:00.000Z",
|
|
},
|
|
],
|
|
conversationSummary: conversationSummaryFixture({
|
|
declinedSkippedTopics: [{
|
|
focus_id: FOCUS_ID,
|
|
intent: "collect_event_date",
|
|
target_evidence_id: revisionId,
|
|
target_domain: "relocation",
|
|
target_kind: "relocation",
|
|
status: "declined",
|
|
asked_at: "2026-08-12T10:00:00.000Z",
|
|
resolved_at: "2026-08-12T10:01:00.000Z",
|
|
}],
|
|
}),
|
|
evidence: [
|
|
{
|
|
id: EVIDENCE_ID,
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "career_entry",
|
|
domain: "career",
|
|
occurred_from: "2016-09-01",
|
|
occurred_to: null,
|
|
date_precision: "month",
|
|
summary: "2016年9月离开家去北京开始工作",
|
|
status: "confirmed",
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-08-12T10:00:06.000Z",
|
|
},
|
|
{
|
|
id: revisionId,
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "relocation",
|
|
domain: "relocation",
|
|
occurred_from: "2020-01-01",
|
|
occurred_to: null,
|
|
date_precision: "year",
|
|
summary: "2020年搬到上海",
|
|
status: "pending_confirmation",
|
|
supersedes_evidence_id: null,
|
|
created_at: "2026-08-12T10:00:07.000Z",
|
|
},
|
|
],
|
|
}),
|
|
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<Record<string, unknown>>;
|
|
}).execute({ caseId: CASE_ID });
|
|
|
|
assert.deepEqual(projection.evidence_context, [
|
|
{
|
|
evidence_id: EVIDENCE_ID,
|
|
status: "confirmed",
|
|
summary: "2016年9月离开家去北京开始工作",
|
|
event_kind: "career_entry",
|
|
domain: "career",
|
|
date_precision: "month",
|
|
occurred_from: "2016-09-01",
|
|
occurred_to: null,
|
|
},
|
|
{
|
|
evidence_id: revisionId,
|
|
status: "pending_confirmation",
|
|
summary: "2020年搬到上海",
|
|
event_kind: "relocation",
|
|
domain: "relocation",
|
|
date_precision: "year",
|
|
occurred_from: "2020-01-01",
|
|
occurred_to: null,
|
|
},
|
|
]);
|
|
assert.deepEqual(projection.conversation_context, {
|
|
active_followup: null,
|
|
recent_turns: [
|
|
{ role: "user", text: "2016年9月离开家去北京开始工作,2020年又搬到了上海" },
|
|
{ role: "assistant", text: "第二次搬家大约发生在哪个月?" },
|
|
{ role: "user", text: "月份记不清了,换个方向吧" },
|
|
],
|
|
declined_targets: [{
|
|
focus_id: FOCUS_ID,
|
|
intent: "collect_event_date",
|
|
target_evidence_id: revisionId,
|
|
target_domain: "relocation",
|
|
target_kind: "relocation",
|
|
status: "declined",
|
|
asked_at: "2026-08-12T10:00:00.000Z",
|
|
resolved_at: "2026-08-12T10:01:00.000Z",
|
|
}],
|
|
});
|
|
const serialized = JSON.stringify(projection);
|
|
assert.doesNotMatch(serialized, /source_turn_id|user_quote|supersedes_evidence_id|created_at/);
|
|
});
|
|
|
|
test("read-case includes the current pending user turn so a refusal closes the active follow-up immediately", async () => {
|
|
const revisionId = "88888888-8888-4888-8888-888888888888";
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture({
|
|
turns: [
|
|
{
|
|
id: "99999999-9999-4999-8999-999999999997",
|
|
role: "assistant",
|
|
text: "你是把入职时间改成2017年,对吗?",
|
|
status: "completed",
|
|
created_at: "2026-08-12T10:00:00.000Z",
|
|
},
|
|
{
|
|
id: TURN_ID,
|
|
role: "user",
|
|
text: "记不清了,换个方向吧",
|
|
status: "pending",
|
|
created_at: "2026-08-12T10:01:00.000Z",
|
|
},
|
|
],
|
|
conversationSummary: conversationSummaryFixture({
|
|
declinedSkippedTopics: [{
|
|
focus_id: FOCUS_ID,
|
|
intent: "confirm_revision",
|
|
target_evidence_id: revisionId,
|
|
target_domain: "career",
|
|
target_kind: "career_entry",
|
|
status: "declined",
|
|
asked_at: "2026-08-12T10:00:00.000Z",
|
|
resolved_at: "2026-08-12T10:01:00.000Z",
|
|
}],
|
|
}),
|
|
evidence: [{
|
|
id: revisionId,
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "career_entry",
|
|
domain: "career",
|
|
occurred_from: "2017-01-01",
|
|
occurred_to: null,
|
|
date_precision: "year",
|
|
summary: "2017年开始工作",
|
|
status: "pending_confirmation",
|
|
supersedes_evidence_id: EVIDENCE_ID,
|
|
created_at: "2026-08-12T10:00:07.000Z",
|
|
}],
|
|
}),
|
|
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<Record<string, unknown>>;
|
|
}).execute({ caseId: CASE_ID });
|
|
|
|
assert.deepEqual(projection.conversation_context, {
|
|
active_followup: null,
|
|
recent_turns: [
|
|
{ role: "assistant", text: "你是把入职时间改成2017年,对吗?" },
|
|
{ role: "user", text: "记不清了,换个方向吧" },
|
|
],
|
|
declined_targets: [{
|
|
focus_id: FOCUS_ID,
|
|
intent: "confirm_revision",
|
|
target_evidence_id: revisionId,
|
|
target_domain: "career",
|
|
target_kind: "career_entry",
|
|
status: "declined",
|
|
asked_at: "2026-08-12T10:00:00.000Z",
|
|
resolved_at: "2026-08-12T10:01:00.000Z",
|
|
}],
|
|
});
|
|
});
|
|
|
|
test("read-case keeps an unresolved pending revision as the active follow-up", async () => {
|
|
const revisionId = "88888888-8888-4888-8888-888888888888";
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture({
|
|
turns: [
|
|
{
|
|
id: TURN_ID,
|
|
role: "assistant",
|
|
text: "你是把入职时间改成2017年,对吗?",
|
|
status: "completed",
|
|
created_at: "2026-08-12T10:00:00.000Z",
|
|
},
|
|
],
|
|
conversationSummary: conversationSummaryFixture({
|
|
activeFocus: activeFocusFixture({
|
|
targetEvidenceId: revisionId,
|
|
intent: "confirm_revision",
|
|
targetDomain: "career",
|
|
targetKind: "career_entry",
|
|
expectedAnswerSchema: { type: "confirmation" },
|
|
}),
|
|
}),
|
|
evidence: [{
|
|
id: revisionId,
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "career_entry",
|
|
domain: "career",
|
|
occurred_from: "2017-01-01",
|
|
occurred_to: null,
|
|
date_precision: "year",
|
|
summary: "2017年开始工作",
|
|
status: "pending_confirmation",
|
|
supersedes_evidence_id: EVIDENCE_ID,
|
|
created_at: "2026-08-12T10:00:07.000Z",
|
|
}],
|
|
}),
|
|
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<Record<string, unknown>>;
|
|
}).execute({ caseId: CASE_ID });
|
|
assert.deepEqual((projection.conversation_context as { active_followup: unknown }).active_followup, {
|
|
focus_id: FOCUS_ID,
|
|
question_id: "question-1",
|
|
intent: "confirm_revision",
|
|
evidence_id: revisionId,
|
|
target_domain: "career",
|
|
target_kind: "career_entry",
|
|
expected_answer_schema: { type: "confirmation" },
|
|
});
|
|
});
|
|
|
|
test("read-case keeps conversation and evidence context within the public safety bounds", async () => {
|
|
const longQuestion = "这是一段需要截断的追问".repeat(180);
|
|
const turns = [
|
|
...Array.from({ length: 4 }, (_, index) => ({
|
|
id: `prefix-${index}`,
|
|
role: index % 2 === 0 ? "assistant" : "user",
|
|
text: `较早轮次-${index}`,
|
|
status: "completed",
|
|
created_at: `2026-08-12T09:00:${String(index).padStart(2, "0")}.000Z`,
|
|
})),
|
|
{
|
|
id: "outside-scan-question",
|
|
role: "assistant",
|
|
text: "扫描窗口外的问题",
|
|
status: "completed",
|
|
created_at: "2026-08-12T09:00:04.000Z",
|
|
},
|
|
{
|
|
id: "outside-scan-refusal",
|
|
role: "user",
|
|
text: "记不清了,换个方向吧",
|
|
status: "completed",
|
|
created_at: "2026-08-12T09:00:05.000Z",
|
|
},
|
|
...Array.from({ length: 12 }, (_, pairIndex) => [
|
|
{
|
|
id: `question-${pairIndex}`,
|
|
role: "assistant",
|
|
text: `问题-${pairIndex}-${longQuestion}`,
|
|
status: pairIndex === 8 ? "failed" : pairIndex === 9 ? "retryable" : "completed",
|
|
created_at: `2026-08-12T10:${String(pairIndex).padStart(2, "0")}:00.000Z`,
|
|
},
|
|
{
|
|
id: `answer-${pairIndex}`,
|
|
role: "user",
|
|
text: "记不清了,换个方向吧",
|
|
status: "completed",
|
|
created_at: `2026-08-12T10:${String(pairIndex).padStart(2, "0")}:01.000Z`,
|
|
},
|
|
]).flat(),
|
|
];
|
|
const evidence = Array.from({ length: 25 }, (_, index) => ({
|
|
id: `evidence-${index}`,
|
|
source_turn_id: TURN_ID,
|
|
subject: "self",
|
|
event_kind: "career_entry",
|
|
domain: "career",
|
|
occurred_from: `${2000 + index}-01-01`,
|
|
occurred_to: null,
|
|
date_precision: "year",
|
|
summary: `${2000 + index}年发生的事件`,
|
|
status: "confirmed",
|
|
supersedes_evidence_id: null,
|
|
created_at: `2026-08-12T11:${String(index).padStart(2, "0")}:00.000Z`,
|
|
}));
|
|
const accounting = fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => dossierFixture({
|
|
turnCount: turns.length,
|
|
evidenceCount: evidence.length,
|
|
conversationSummary: conversationSummaryFixture({
|
|
declinedSkippedTopics: [7, 10, 11].map((index) => ({
|
|
focus_id: `focus-${index}`,
|
|
intent: "collect_event_date",
|
|
target_evidence_id: `evidence-${index}`,
|
|
target_domain: "career",
|
|
target_kind: "career_entry",
|
|
status: "declined",
|
|
asked_at: `2026-08-12T10:${String(index).padStart(2, "0")}:00.000Z`,
|
|
resolved_at: `2026-08-12T10:${String(index).padStart(2, "0")}:01.000Z`,
|
|
})),
|
|
}),
|
|
turns,
|
|
evidence,
|
|
}),
|
|
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<Record<string, unknown>>;
|
|
}).execute({ caseId: CASE_ID });
|
|
const evidenceContext = projection.evidence_context as Array<{ evidence_id: string }>;
|
|
const conversationContext = projection.conversation_context as {
|
|
recent_turns: Array<{ role: string; text: string }>;
|
|
declined_targets: Array<{ focus_id: string }>;
|
|
};
|
|
|
|
assert.equal(evidenceContext.length, 20);
|
|
assert.equal(evidenceContext[0]?.evidence_id, "evidence-5");
|
|
assert.equal(evidenceContext.at(-1)?.evidence_id, "evidence-24");
|
|
assert.equal(conversationContext.recent_turns.length, 6);
|
|
assert.ok(conversationContext.recent_turns.every((turn) => turn.text.length <= 1_600));
|
|
assert.equal(conversationContext.declined_targets.length, 3);
|
|
assert.deepEqual(
|
|
conversationContext.declined_targets.map((target) => target.focus_id),
|
|
["focus-7", "focus-10", "focus-11"],
|
|
);
|
|
const serialized = JSON.stringify(projection.conversation_context);
|
|
assert.doesNotMatch(serialized, /扫描窗口外的问题|问题-8-|问题-9-/);
|
|
});
|
|
|
|
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<unknown> }).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;
|
|
});
|