Files
Jyotisha/frontend/tests/rectification-v9-agent.test.ts
Jesse_Chen 37c5c8d356
Independent Staging Quality Gate / validate (push) Successful in 18m9s
Independent Staging Quality Gate / publish (push) Has started running
fix(web): keep spoken rectification prompts on the server choice card
Evidence writes now return the persisted open_question so the model asks that stem instead of a second education probe, and the jump-to-latest chip is centered again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 23:11:23 +08:00

632 lines
27 KiB
TypeScript
Raw Permalink 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 { readFileSync, realpathSync } from "node:fs";
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
RECTIFICATION_AGENT_HARD_STEP_LIMIT,
RECTIFICATION_AGENT_MAX_STEPS,
RECTIFICATION_AGENT_STEP_BUDGETS,
RECTIFICATION_V9_PACKAGE_PATH,
RECTIFICATION_V9_SKILL_PATH,
RECTIFICATION_V9_SKILL_NAME,
getRectificationV9Agent,
getRectificationV9RegenerationAgent,
resolveRectificationStepBudget,
} from "../src/mastra/agentic-rectification.ts";
import {
createRectificationV9ReadOnlyTools,
createRectificationV9Tools,
} from "../src/mastra/rectification-v9-tools.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { persistV9Candidate } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
CANDIDATE_ID,
EVIDENCE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
RESULT_ID,
} from "./rectification-v9-test-support.ts";
const agentSource = readFileSync(
fileURLToPath(new URL("../src/mastra/agentic-rectification.ts", import.meta.url)),
"utf8",
);
test("system prompt carries only high-priority boundaries, never the method copy", () => {
const promptStart = agentSource.indexOf("const agenticRectificationInstructions");
const promptEnd = agentSource.indexOf("export function getRectificationV9Agent");
const prompt = agentSource.slice(promptStart, promptEnd);
// No gate -> scan -> score -> diagnostics orchestration in the prompt.
assert.doesNotMatch(prompt, /rectification-gate[\s\S]*rectification-scan/);
assert.doesNotMatch(prompt, /rectification-score[\s\S]*rectification-diagnostics/);
assert.doesNotMatch(prompt, /rectification-confirm[\s\S]*rectification-save-birth-time/);
assert.doesNotMatch(prompt, /10[-]15 个事件/);
assert.match(prompt, /D9\/D10 类型/);
assert.match(prompt, /80%\/60%/);
assert.match(prompt, /skill_verification_report/);
assert.doesNotMatch(prompt, /run the required gate/);
assert.doesNotMatch(prompt, /candidate_range/);
assert.match(prompt, /jyotish-birth-time-rectification/);
assert.match(prompt, /display_date_label/);
assert.match(prompt, /rectification-record-evidence-batch/);
assert.match(prompt, /不可分区间/);
assert.match(prompt, /confirmation_gate/);
assert.match(prompt, /session_outcome=adopt_representative/);
assert.match(prompt, /本会话以代表性时间收口,不确认唯一分钟/);
assert.match(prompt, /unique_minute_path=closed_at_representative/);
assert.match(prompt, /next_user_action/);
assert.match(prompt, /rectification-offer-candidates/);
assert.match(prompt, /on_user_stop/);
assert.match(prompt, /禁止只说记下了/);
assert.match(prompt, /工具执行过程保持静默/);
assert.match(prompt, /思考过程必须用简体中文/);
assert.match(prompt, /对用户说的话必须自己写在正文里,不要只写规划等服务器代写/);
assert.match(prompt, /skill_verification_report/);
assert.match(prompt, /ask_candidate_discriminator/);
assert.match(prompt, /offer_provisional_range/);
assert.match(prompt, /不要再问整窗 D9\/D24/);
assert.match(prompt, /不得询问外貌、体质、胎记或疤痕/);
assert.match(prompt, /不要调用 rectification-set-focus/);
assert.match(prompt, /open_question\.prompt/);
assert.match(prompt, /current_question\.prompt/);
assert.match(prompt, /不得另起高考发挥/);
assert.match(prompt, /「先这样」由服务器/);
assert.match(prompt, /盘外核对(不计分)/);
assert.match(prompt, /verify_adopted_time/);
assert.match(prompt, /event_probe/);
assert.match(prompt, /不得发明年份/);
assert.match(prompt, /不得根据出生年推算高考或入学年份/);
assert.match(prompt, /不要再问那一件发生在哪一年/);
assert.doesNotMatch(prompt, /两套盘各自的前事/);
assert.doesNotMatch(prompt, /外貌、体质、胎记或疤痕可以问/);
assert.doesNotMatch(prompt, /分盘句和宫位表由界面展示/);
assert.doesNotMatch(prompt, /不是整张宫位表/);
assert.doesNotMatch(prompt, /分别 propose\+confirm/);
// Keep the prompt short (~30 lines max).
assert.ok(prompt.split("\n").length <= 60, "instructions must stay bounded");
});
test("agent pins the dedicated rectification skill and its fixed version", () => {
assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(basename(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_SKILL_NAME);
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.11"));
assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH);
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.11");
});
test("step budgets are bounded per action with a hard ceiling", () => {
assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.opening, 6);
assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.evidence, 8);
assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.rescore, 12);
assert.equal(RECTIFICATION_AGENT_STEP_BUDGETS.accept, 6);
assert.equal(RECTIFICATION_AGENT_MAX_STEPS, 12);
for (const action of ["opening", "read_only", "evidence", "rescore", "accept", "confirm"]) {
const budget = resolveRectificationStepBudget(action as keyof typeof RECTIFICATION_AGENT_STEP_BUDGETS);
assert.ok(budget <= RECTIFICATION_AGENT_HARD_STEP_LIMIT, `${action} must respect the hard ceiling`);
}
assert.equal(resolveRectificationStepBudget("rescore"), 12);
});
test("no tool accepts event arrays, birth data or candidate ranges as input", () => {
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: fakeAccounting({}).client as never,
});
const toolNames = [
"rectification-read-case",
"rectification-propose-evidence",
"rectification-confirm-evidence",
"rectification-revise-evidence",
"rectification-compare-candidates",
"rectification-read-diagnostics",
"rectification-offer-candidates",
"rectification-accept-candidate",
"rectification-confirm-birth-time",
"rectification-close-case",
];
for (const name of toolNames) {
const tool = tools[name as keyof typeof tools] as unknown as { inputSchema: { safeParse(value: unknown): { success: boolean } } };
assert.ok(tool?.inputSchema, `${name} must expose an input schema`);
const malicious = {
caseId: CASE_ID,
userId: USER_ID,
birth_date: "1997-08-08",
latitude: 36.4,
longitude: 114.2,
timezone_offset: 8,
candidate_range: { start_time: "04:00", end_time: "06:00" },
events: [{ id: "e1", domain: "career", date: "2016-09" }],
scores: [10, 20],
confirmationAllowed: true,
};
const result = tool.inputSchema.safeParse(malicious);
assert.equal(result.success, false, `${name} must reject userId/birth/range/events/scores/permissions`);
}
});
type StreamChunk = {
type: string;
payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown };
};
type FakeStreamResult = {
fullStream: AsyncIterable<StreamChunk>;
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
};
function chunk(type: string, payload?: Record<string, unknown>): StreamChunk {
return { type, ...(payload ? { payload } : {}) };
}
function fakeAgentStream(chunks: StreamChunk[]) {
const streamResult: FakeStreamResult = {
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
};
return {
stream: async () => streamResult,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
function runOptions(overrides: Partial<V9AgentRunOptions> = {}): {
options: V9AgentRunOptions;
emitted: Array<{ type: string }>;
billing: { reserved: number; completed: number; released: number };
} {
const emitted: Array<{ type: string }> = [];
const billing = { reserved: 0, completed: 0, released: 0 };
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
get_agentic_rectification_turn_receipt: () => null,
});
const optionsValue: V9AgentRunOptions = {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "2016年9月离开家去北京工作",
modelName: "gpt-4o-mini",
accounting: accounting.client,
billing: {
reserve: async () => { billing.reserved += 1; return { success: true, status: 200 }; },
complete: async () => { billing.completed += 1; return true; },
release: async () => { billing.released += 1; return true; },
},
emit: (event) => { emitted.push(event); },
buildAgent: async () => fakeAgentStream([]) as never,
...overrides,
};
return { options: optionsValue, emitted, billing };
}
test("agent receives the exact server-owned case id for tool calls", async () => {
let observedMessages: unknown[] = [];
const agent = fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好,我是生时校正助手。" }),
chunk("finish"),
]);
const { options } = runOptions({
action: "opening",
message: null,
buildAgent: async () => ({
...agent,
stream: async (messages: unknown[]) => {
observedMessages = messages;
return agent.stream();
},
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
const openingPrompt = JSON.stringify(observedMessages);
assert.match(openingPrompt, new RegExp(CASE_ID));
assert.match(openingPrompt, /服务端 opening brief/);
assert.match(openingPrompt, /不要要求一次说完/);
assert.match(openingPrompt, /当前 active focus/);
assert.doesNotMatch(openingPrompt, /说明你会通过已发生的人生事件来校正出生时间/);
});
test("server-loaded Skill is bound before the provider and the first model step only exposes read-case", async () => {
const skillInstructions = "immutable-skill-instructions-from-server";
let observedMessages: unknown[] = [];
let observedStreamOptions: {
prepareStep?: (input: { stepNumber: number }) => unknown;
modelSettings?: { maxOutputTokens?: number };
providerOptions?: Record<string, { thinking?: { type?: string } }>;
} = {};
const agent = fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好,我先从一件你记得比较清楚的经历开始。" }),
chunk("finish"),
]);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0, turns: [] }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => ({
...agent,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: skillInstructions }),
stream: async (messages: unknown[], streamOptions: typeof observedStreamOptions) => {
observedMessages = messages;
observedStreamOptions = streamOptions;
return agent.stream();
},
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.skillLoaded, true);
assert.equal(result.errorCode, null);
assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
assert.match(JSON.stringify(observedMessages), /服务器已绑定当前 Case 的精确 Skill/);
assert.match(JSON.stringify(observedMessages), /不要重复调用 skill/);
assert.match(JSON.stringify(observedMessages), new RegExp(skillInstructions));
const firstStep = await observedStreamOptions.prepareStep?.({ stepNumber: 0 }) as {
activeTools?: unknown;
toolChoice?: unknown;
};
assert.deepEqual(firstStep, {
activeTools: ["rectification-read-case"],
toolChoice: "auto",
});
assert.equal(typeof firstStep?.toolChoice, "string");
assert.deepEqual(
(await observedStreamOptions.prepareStep?.({ stepNumber: 1 }) as { activeTools?: string[] }).activeTools?.includes("rectification-set-focus"),
false,
);
assert.ok(
((await observedStreamOptions.prepareStep?.({ stepNumber: 1 })) as { activeTools?: string[] }).activeTools?.includes("rectification-read-case"),
);
assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 16_384);
assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "enabled" } });
assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(
accounting.calls.filter((call) => call.fn === "insert_agentic_rectification_skill_run_receipt").length,
1,
);
});
test("first turn with a bound immutable Skill completes and persists receipts", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ turnCount: 0 }),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: "你好,我是生时校正助手。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.turnStatus, "completed");
assert.equal(result.skillLoaded, true);
assert.equal(billing.completed, 1);
assert.equal(billing.released, 0);
const types = emitted.map((event) => event.type);
assert.ok(types.includes("run.started"));
assert.ok(types.includes("skill.bound"));
assert.ok(types.includes("case.loaded"));
assert.ok(types.includes("intent.classified"));
assert.ok(types.includes("answer.composed"));
assert.ok(types.includes("billing.settled"));
assert.ok(types.includes("answer.delta"));
assert.ok(types.includes("run.completed"));
// Reasoning and raw chunks are dropped.
const accountingCalls = accounting.calls.map((call) => call.fn);
assert.ok(accountingCalls.includes("insert_agentic_rectification_run_phase"));
assert.ok(accountingCalls.includes("finalize_agentic_rectification_turn"));
});
test("framework getSkill failure is a controlled retry and then a failed turn", async () => {
const { options, billing } = runOptions({
buildAgent: async () => ({
stream: async () => ({ fullStream: (async function* () {})() }),
getSkill: async () => null,
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "skill_not_loaded");
assert.equal(billing.released, 1);
});
test("distinct evidence calls in one natural turn are not mistaken for a repeated tool loop", async () => {
const firstEvidenceId = EVIDENCE_ID;
const secondEvidenceId = "88888888-8888-4888-8888-888888888888";
const { options, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", {
toolName: "rectification-propose-evidence",
args: { caseId: CASE_ID, quote: "2016年9月离开家去北京开始工作", proposedKind: "career_entry" },
}),
chunk("tool-result", { toolName: "rectification-propose-evidence" }),
chunk("tool-call", { toolName: "rectification-confirm-evidence", args: { caseId: CASE_ID, evidenceId: firstEvidenceId } }),
chunk("tool-result", { toolName: "rectification-confirm-evidence" }),
chunk("tool-call", {
toolName: "rectification-propose-evidence",
args: { caseId: CASE_ID, quote: "2020年又搬到了上海", proposedKind: "relocation" },
}),
chunk("tool-result", { toolName: "rectification-propose-evidence" }),
chunk("tool-call", { toolName: "rectification-confirm-evidence", args: { caseId: CASE_ID, evidenceId: secondEvidenceId } }),
chunk("tool-result", { toolName: "rectification-confirm-evidence" }),
chunk("text-delta", { text: "这两件事已经分别纳入当前校正。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.equal(billing.completed, 1);
assert.deepEqual(result.toolsUsed.filter((name) => name.includes("evidence")), [
"rectification-propose-evidence",
"rectification-confirm-evidence",
]);
});
test("a repeated identical tool call is treated as idempotent and does not abort", async () => {
const { options, emitted, billing } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.errorCode, null);
assert.match(result.answerText, /已经记下|请继续说下一件/);
assert.equal(billing.completed, 1);
assert.equal(billing.released, 0);
assert.equal(emitted.some((event) => event.type === "attempt.reset"), false);
assert.equal(emitted.some((event) => event.type === "run.failed"), false);
assert.equal(
emitted.filter((event) => event.type === "tool.activity"
&& (event as { tool?: string; status?: string }).tool === "rectification-read-case"
&& (event as { tool?: string; status?: string }).status === "started").length,
1,
);
});
test("a failed opening does not let the next turn skip the server Skill load gate", async () => {
let streamCount = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
turnCount: 1,
turns: [{
id: TURN_ID,
role: "user",
text: "(开场)",
status: "failed",
created_at: "2026-08-12T10:00:00.000Z",
completed_at: null,
}],
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
});
const { options, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => ({
getSkill: async () => null,
stream: async () => {
streamCount += 1;
return { fullStream: (async function* () {})() };
},
}) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "skill_not_loaded");
assert.equal(streamCount, 0);
assert.equal(billing.released, 1);
});
test("same evidence + range fingerprints reuse the cached candidate snapshot", async () => {
const inputReceipt = {
receipt_version: "candidate-decision-receipt-v2",
policy_version: "rectification-candidate-policy-v2",
selection_allowed: true,
acceptance_allowed: true,
confirmation_allowed: false,
representative_candidate_id: null,
overall_confidence: "medium",
inference_state: { revision: 1, candidates: [{ id: "05:02", posterior_score: 10 }] },
};
const composedReceipt = {
...inputReceipt,
inference_state: { revision: 2, candidates: [{ id: "05:02", posterior_score: 18 }] },
decision_state_fingerprint: "a".repeat(64),
};
const accounting = fakeAccounting({
persist_agentic_rectification_candidate_v2: () => ({
...candidateSnapshotFixture(),
cached: true,
decision_receipt: composedReceipt,
}),
});
const cached = await persistV9Candidate(accounting.client, USER_ID, CASE_ID, {
engineResultId: "engine-1",
algorithmVersion: "rectification-v5",
evidenceFingerprint: "b".repeat(64),
rangeFingerprint: "c".repeat(64),
skillVersion: "9.0.0",
candidateRange: CANDIDATE_RANGE,
eventContractVersion: "rectification-event-contract-v2",
policyVersion: "rectification-candidate-policy-v2",
candidates: [{ candidateId: CANDIDATE_ID, rank: 1, time: "05:02", relativeSupport: 58, tiedMinuteCount: 2 }],
decisionReceipt: inputReceipt,
executionLedger: [{ method: "d1-rashi", status: "executed" }],
});
assert.equal(cached.cached, true);
assert.equal(cached.resultId, RESULT_ID);
const call = accounting.calls.find((item) => item.fn === "persist_agentic_rectification_candidate_v2");
assert.ok(call);
assert.equal(call.args.p_evidence_ledger_fingerprint, "b".repeat(64));
assert.equal(call.args.p_skill_version, "9.0.0");
assert.equal(call.args.p_event_contract_version, "rectification-event-contract-v2");
assert.equal(call.args.p_decision_policy_version, "rectification-candidate-policy-v2");
assert.deepEqual(call.args.p_decision_receipt, inputReceipt);
assert.notDeepEqual(cached.decisionReceipt, inputReceipt);
assert.deepEqual(cached.decisionReceipt, composedReceipt);
assert.equal(
(cached.decisionReceipt.inference_state as { revision?: number }).revision,
2,
);
assert.equal(cached.decisionReceipt.decision_state_fingerprint, "a".repeat(64));
assert.deepEqual(call.args.p_execution_ledger, cached.executionLedger);
assert.equal("p_selection_allowed" in call.args, false);
assert.equal("p_confirmation_allowed" in call.args, false);
assert.equal("p_representative_time" in call.args, false);
assert.equal("p_margin_percent" in call.args, false);
assert.equal("p_decision_state_fingerprint" in call.args, false);
});
test("accept-candidate requires a server-persisted result; no tool means no minute", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: null }),
accept_agentic_rectification_candidate_for_case_v2: () => {
throw new Error("agentic_rectification_candidate_not_found");
},
});
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
await assert.rejects(
(tools["rectification-accept-candidate"] as unknown as { execute(input: unknown): Promise<unknown> }).execute({
caseId: CASE_ID,
resultId: RESULT_ID,
candidateId: CANDIDATE_ID,
}),
(error: unknown) => error instanceof Error && error.message.includes("candidate_not_found"),
);
});
test("new evidence lets the agent choose diagnostics/compare tools autonomously", () => {
// The tool layer exposes read-diagnostics and compare-candidates; there is
// no hard-coded orchestration forcing a scan before score.
const tools = createRectificationV9Tools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: fakeAccounting({}).client as never,
});
assert.ok("rectification-compare-candidates" in tools);
assert.ok("rectification-read-diagnostics" in tools);
assert.ok("rectification-offer-candidates" in tools);
assert.ok(!("rectification-scan" in tools));
assert.ok(!("rectification-gate" in tools));
});
test("agent construction wires the pinned skill and the ten v9 tools", () => {
const model = {
id: "test-model",
label: "Test",
description: "",
creditCost: 1,
isDefault: true,
mode: "compatible" as const,
model: { provider: "openai", name: "gpt-4o-mini", modelId: "gpt-4o-mini" } as never,
};
const accounting = fakeAccounting({});
const agent = getRectificationV9Agent(model, {
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
assert.equal(agent.id, "rectification-v9-test-model");
assert.ok(agent);
});
test("reply regeneration is a separate Jyotisha agent with only read-case access", () => {
const model = {
id: "test-model",
label: "Test",
description: "",
creditCost: 1,
isDefault: true,
mode: "compatible" as const,
model: { provider: "openai", name: "gpt-4o-mini", modelId: "gpt-4o-mini" } as never,
};
const accounting = fakeAccounting({});
const tools = createRectificationV9ReadOnlyTools({
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
assert.deepEqual(Object.keys(tools), ["rectification-read-case"]);
const agent = getRectificationV9RegenerationAgent(model, {
userId: USER_ID,
caseId: CASE_ID,
turnId: TURN_ID,
accounting: accounting.client as never,
});
assert.equal(agent.id, "rectification-v9-regeneration-test-model");
assert.match(agentSource, /这不是新一轮校正/);
assert.match(agentSource, /只能使用 rectification-read-case/);
});