Files
Jyotisha/frontend/tests/rectification-v9-status-security.test.ts
T
jesse-ux 8b982baf64
Independent Staging Quality Gate / validate (push) Canceled after 4m2s
Independent Staging Quality Gate / publish (push) Canceled after 0s
fix(rectification): 分类器失败、引擎忙、整轮超时不再归因错
点选题把分类器两次异常说成用户没说清(BUG-722);引擎 429 被压成坏了且重算静默失败(BUG-723);两次 attempt 总预算大于路由 maxDuration(BUG-724)。本单只改归因:classifier_unavailable 请用户重发、busy 分档并可见「这次没有重新比较」、整轮 225s 预算不够则 host fallback。不改计费、分类模型、并发闸门。
2026-09-16 07:06:57 +08:00

881 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
acceptV9Candidate,
confirmV9BirthTime,
candidateRangeFingerprint,
evidenceLedgerFingerprint,
parseV9CaseDossier,
parseV9ComputeProjection,
RectificationToolServiceError,
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,
CANDIDATE_ID,
SECOND_CANDIDATE_ID,
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_v2: () => ({
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, CANDIDATE_ID, TURN_ID);
assert.equal(result.status, "accepted");
assert.equal(result.caseStatus, "candidate_accepted");
assert.notEqual(result.status, "confirmed");
});
test("accept fails closed if the accept-only RPC reports confirmed", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case_v2: () => ({
success: true,
saved_time: "05:02",
status: "confirmed",
result_id: RESULT_ID,
candidate_id: CANDIDATE_ID,
case_status: "confirmed",
idempotent: false,
}),
});
await assert.rejects(
acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID),
(error: unknown) => error instanceof Error && error.message.includes("invalid_accept_result"),
);
});
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_candidate_for_case_v2: () => {
throw new Error("agentic_rectification_confirmation_blocked");
},
});
await assert.rejects(
confirmV9BirthTime(blocked.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
candidateId: CANDIDATE_ID,
requestId: TURN_ID,
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_candidate_for_case_v2: () => {
throw new Error("agentic_rectification_confirm_time_mismatch");
},
});
await assert.rejects(
confirmV9BirthTime(mismatch.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
candidateId: SECOND_CANDIDATE_ID,
requestId: TURN_ID,
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_candidate_for_case_v2: () => {
throw new Error("agentic_rectification_consent_not_grounded");
},
});
await assert.rejects(
confirmV9BirthTime(ungrounded.client, USER_ID, CASE_ID, {
resultId: RESULT_ID,
candidateId: CANDIDATE_ID,
requestId: TURN_ID,
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,
get_agentic_rectification_case_dossier: () => dossierFixture({
latestResult: candidateSnapshotFixture({
confirmationAllowed: true,
representativeTime: "05:02",
candidates: [
{ candidate_id: CANDIDATE_ID, rank: 1, time: "05:02", relative_support: 70, tied_minute_count: 1 },
],
}),
}),
confirm_agentic_rectification_candidate_for_case_v2: () => ({
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: CANDIDATE_ID,
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 assert.rejects(
confirm.execute(input),
(error: unknown) => error instanceof Error && error.message.includes("confirmation_blocked"),
);
assert.equal(
accounting.calls.some((item) => item.fn === "confirm_agentic_rectification_candidate_for_case_v2"),
false,
);
});
test("candidate ownership is case-scoped: the RPC always receives the case id", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case_v2: () => ({
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, CANDIDATE_ID, TURN_ID);
const call = accounting.calls.find((item) => item.fn === "accept_agentic_rectification_candidate_for_case_v2");
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);
assert.equal(call.args.p_candidate_id, CANDIDATE_ID);
assert.equal(call.args.p_request_id, TURN_ID);
assert.equal("p_time" in call.args, false);
});
test("accept is idempotent: replaying the same selection succeeds without a second write", async () => {
const accounting = fakeAccounting({
accept_agentic_rectification_candidate_for_case_v2: () => ({
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, CANDIDATE_ID, TURN_ID);
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, projection: "full_diagnostics" });
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, projection: "full_diagnostics" });
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,
display_date_label: "2016-09",
},
{
evidence_id: revisionId,
status: "pending_confirmation",
summary: "2020年搬到上海",
event_kind: "relocation",
domain: "relocation",
date_precision: "year",
occurred_from: "2020-01-01",
occurred_to: null,
display_date_label: "2020年",
},
]);
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, projection: "full_diagnostics" });
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, projection: "full_diagnostics" });
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, projection: "full_diagnostics" });
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");
assert.equal(safeToolErrorCode(new RectificationToolServiceError("invalid_case_id")), "invalid_case_id");
assert.equal(safeToolErrorCode(new RectificationToolServiceError("invalid_event_kind")), "invalid_event_kind");
});
test("compare-candidates reuses a matching fingerprint without calling the engine", async () => {
const rawDossier = dossierFixture();
const parsed = parseV9CaseDossier(rawDossier);
const compute = parseV9ComputeProjection(computeFixture());
assert.ok(parsed);
assert.ok(compute);
assert.ok(parsed.case.candidateRange);
const evidenceFp = evidenceLedgerFingerprint(parsed.evidence);
const rangeFp = candidateRangeFingerprint(parsed.case.candidateRange, compute.baselineProfileFingerprint);
const previous = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/rectification/v5/versions")) {
return {
ok: true,
json: async () => ({
algorithm_version: "rectification-v5",
decision_policy_version: "rectification-candidate-policy-v2",
}),
} as Response;
}
throw new Error("engine must not run on a matching fingerprint");
}) as unknown as typeof fetch;
try {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
latestResult: {
...candidateSnapshotFixture({ evidenceLedgerFingerprint: evidenceFp }),
candidate_range_fingerprint: rangeFp,
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
persist_agentic_rectification_candidate_v2: () => {
throw new Error("persist must not run on a matching fingerprint");
},
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
const result = await (tools["rectification-compare-candidates"] as unknown as {
execute(input: unknown): Promise<{ cached?: boolean; executed_methods?: string[] }>;
}).execute({ caseId: CASE_ID });
assert.equal(result.cached, true);
assert.ok((result.executed_methods ?? []).includes("d1-rashi"));
assert.equal(
accounting.calls.some((call) => call.fn === "persist_agentic_rectification_candidate_v2"),
false,
);
} finally {
globalThis.fetch = previous;
}
});
test("compare-candidates rescores when stored policy lags the live engine", async () => {
const rawDossier = dossierFixture();
const parsed = parseV9CaseDossier(rawDossier);
const compute = parseV9ComputeProjection(computeFixture());
assert.ok(parsed);
assert.ok(compute);
assert.ok(parsed.case.candidateRange);
const evidenceFp = evidenceLedgerFingerprint(parsed.evidence);
const rangeFp = candidateRangeFingerprint(parsed.case.candidateRange, compute.baselineProfileFingerprint);
const previous = globalThis.fetch;
let scoreRequested = false;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/rectification/v5/versions")) {
return {
ok: true,
json: async () => ({
algorithm_version: "rectification-v5-matrix-scoring-7",
decision_policy_version: "rectification-candidate-policy-v3",
}),
} as Response;
}
if (url.includes("/api/rectification/v5/score")) {
scoreRequested = true;
}
throw new Error("stale policy must rescore");
}) as unknown as typeof fetch;
try {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
latestResult: {
...candidateSnapshotFixture({ evidenceLedgerFingerprint: evidenceFp }),
candidate_range_fingerprint: rangeFp,
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
persist_agentic_rectification_candidate_v2: () => {
throw new Error("persist must not run until the live engine scores");
},
});
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 文案 "stale policy must rescore" 原样冒出
// 新值: 引擎客户端把非 2xx / 抛错收成 RectificationEngineErrorhttp_error
// 原因: BUG-723 不得把失败原因压平或把上游原文泄漏给工具层
return error instanceof Error
&& error.name === "RectificationEngineError"
&& scoreRequested;
},
);
assert.equal(scoreRequested, true);
} finally {
globalThis.fetch = previous;
}
});
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: CANDIDATE_ID });
assert.equal(valid.success, true);
const withBirth = schema.safeParse({
caseId: CASE_ID,
resultId: RESULT_ID,
candidateId: CANDIDATE_ID,
birth_date: "1997-08-08",
active_birth_time: "05:00",
});
assert.equal(withBirth.success, false);
void CANDIDATE_RANGE;
void EVIDENCE_ID;
void SESSION_ID;
});