Files
Jyotisha/frontend/tests/rectification-v9-agent.test.ts
T
Jesse_ChenandClaude Opus 5.5 84eb23152a feat(rectification): remove the rectification regenerate button and endpoint (BUG-1056)
T2 of TASK-rectification-grounding-20260927 (product decision P1).
The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that
persisted unchecked text (invented range / fit rate reproduced). Removed:
the regenerate route, regenerate-turn.ts, the regeneration agent and its
read-only tool set, the client regenerate action/state and the
regenerating/canRegenerate props. ChatMessageActions renders the regenerate
button only when onRegenerate is passed; ordinary consultation is unchanged.
The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6,
retire in a later round). DESIGN.md / VOICE.md updated; source-contract
tests follow with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 03:28:27 +08:00

1002 lines
45 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 { 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,
resolveRectificationStepBudget,
} from "../src/mastra/agentic-rectification.ts";
import * as agenticRectification from "../src/mastra/agentic-rectification.ts";
import * as rectificationV9Tools from "../src/mastra/rectification-v9-tools.ts";
import {
createRectificationV9Tools,
} from "../src/mastra/rectification-v9-tools.ts";
import { buildOpeningBrief, runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { parseV9CaseDossier, 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 { composeCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import {
GENERIC_COLLECT_QUESTION,
OPENING_COLLECT_DOMAINS,
RECTIFICATION_USER_COPY,
isAcceptableOpeningBody,
openingSpokenBody,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
CASE_ID,
CANDIDATE_RANGE,
CANDIDATE_ID,
EVIDENCE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
RESULT_ID,
computeFixture,
} 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);
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.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, /skill_verification_report/);
assert.match(prompt, /80%\/60%/);
assert.match(prompt, /confirmation_allowed/);
assert.doesNotMatch(prompt, /本会话以代表性时间收口|本轮校正已收口/);
assert.match(prompt, /工具执行保持静默/);
assert.match(prompt, /思考用简体中文写在思维链/);
assert.match(prompt, /对用户说的话必须自己写在正文里/);
assert.match(prompt, /用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问/);
// 旧:每轮正文 2-4 句 → 新:证据轮正文只写一句复述 → BUG-606 决策 3
assert.match(prompt, /证据轮正文只写一句复述/);
assert.match(prompt, /「先这样」由服务器/);
assert.match(prompt, /职业题只问平时做什么/);
assert.match(prompt, /焦点 domain 是 career 不是 occupation/);
assert.match(prompt, /collection_progress/);
assert.match(prompt, /不得写「范围在收窄」/);
assert.doesNotMatch(prompt, /不得询问外貌、体质、胎记或疤痕/);
assert.doesNotMatch(prompt, /财务与健康只有用户主动说才问/);
assert.doesNotMatch(prompt, /id 不是 adopt_representative/);
assert.doesNotMatch(prompt, /不要调用 rectification-set-focus/);
assert.doesNotMatch(prompt, /自己写一句自然语言追问/);
assert.doesNotMatch(prompt, /运行器会把口语接到这句题干/);
assert.doesNotMatch(prompt, /外貌、体质、胎记或疤痕可以问/);
assert.doesNotMatch(prompt, /分盘句和宫位表由界面展示/);
assert.doesNotMatch(prompt, /不是整张宫位表/);
assert.doesNotMatch(prompt, /分别 propose\+confirm/);
assert.ok(prompt.split("\n").length <= 30, "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);
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.31"));
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");
// 原值: "10.0.25"
// 原值: "10.0.26"
// 新值: "10.0.27"
// 原因: 出卡精度门槛 + 引导式补经历
// 原因: BUG-668 定向补事第四选项写进 Skill
// 原值: "10.0.28" / 新值: "10.0.29" / 原因: 年月阶段改口述并禁止追问本人
// 原值: "10.0.29" / 新值: "10.0.30" / 原因: D2 出卡句与 D4 卡上百分比规则写进 Skill(2026-09-26)
// 原值: "10.0.30" / 新值: "10.0.31" / 原因: 开场改大白话、题干带例子和示例回答写进 Skill OpeningPolicy(BUG-1049,2026-09-26)
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.31");
});
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-stop-and-review",
"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; result?: 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/);
// 旧:不要要求一次说完、不要举大学/工作/搬家的例子
// 新:窗口 + 六类领域 + 一条消息可以报多件
// 原因:BUG-604 决策 1,重算按轮计费
assert.match(openingPrompt, /一条消息可以报多件/);
assert.match(openingPrompt, new RegExp(OPENING_COLLECT_DOMAINS.join("、")));
assert.match(openingPrompt, /04:50–05:10|当前搜索窗口/);
assert.match(openingPrompt, /intake 声明的不确定档/);
assert.match(openingPrompt, /spokenPrompt 写出当前采集题/);
assert.match(openingPrompt, /不要提问/);
assert.doesNotMatch(openingPrompt, /接在正文末尾并写入聊天历史/);
assert.doesNotMatch(openingPrompt, /当前可询问范围/);
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"),
true,
);
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("collect_spoken stem is persisted on the same turn via asked_turn_id, not assistant_message", async () => {
const greeting = "你好,我是生时校正助手。";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "collect_method_evidence",
questionId: "collect:unknown:collect_method_evidence",
expectedAnswerSchema: {
prompt: GENERIC_COLLECT_QUESTION,
collect: true,
},
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = runOptions({
action: "opening",
message: null,
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: greeting }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
// 旧:模型一句招呼原样落库 → 新:开场正文必须过六类领域验收,不合格则换成模板
// 原因:BUG-604 开场 ≤4 句、≥5 类、不含年份
const spoken = openingSpokenBody(["04:50", "05:10"]);
assert.equal(isAcceptableOpeningBody(result.answerText), true);
assert.equal(result.answerText, spoken);
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
assert.notEqual(replaced?.text, composeCollectSpokenAssistantText(greeting, GENERIC_COLLECT_QUESTION));
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(finalize?.args.p_assistant_message, spoken);
assert.equal(
accounting.calls.some((call) => (
call.fn === "set_agentic_rectification_conversation_focus"
&& call.args.p_asked_turn_id === TURN_ID
)),
true,
);
});
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("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.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,
}),
// 原值 candidate_not_found;新值 result_identity_read_only;原因:无来源在写 RPC 前即拒绝,仍不得产生候选。
(error: unknown) => error instanceof Error && error.message.includes("result_identity_read_only"),
);
});
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("rectification has no reply-regeneration agent or read-only tool set (BUG-1056)", () => {
// 原值: 「reply regeneration is a separate Jyotisha agent with only read-case access」——
// 断言 createRectificationV9ReadOnlyTools 只含 read-case、getRectificationV9RegenerationAgent
// 的 id 与提示词。
// 新值: 两个导出都不存在,提示词里也没有重新生成的指令。
// 原因: TASK-rectification-grounding-20260927 P1 产品决定去掉生时校正的「重试(重新生成)」;
// 这两个导出只服务已删除的重新生成接口(BUG-1056:盲写、不校验就落库)。
assert.equal("getRectificationV9RegenerationAgent" in agenticRectification, false);
assert.equal("createRectificationV9ReadOnlyTools" in rectificationV9Tools, false);
assert.doesNotMatch(agentSource, /这不是新一轮校正/);
assert.doesNotMatch(agentSource, /重新生成最近一条 Agent 正文/);
});
test("set-focus after spoken text does not append a second paragraph", async () => {
// 原值: first 第二句「范围收到 05:00 到 05:10。」。
// 新值: 第二句「这件事拿去和星盘对照了。」。
// 原因: BUG-1055 / P3 起证据轮模型正文里与当轮服务端事实不符的时刻句整句不落库;本夹具无
// 候选快照,05:00–05:10 是手写数字。本用例锁的是 set-focus 之后不接第二段,与数字无关。
const first = "2016 年 9 月去北京工作,记下了。这件事拿去和星盘对照了。";
const second = "2016 年那件事对校正很有帮助,我们再对一下搬家。";
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 } = 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: first }),
chunk("tool-call", { toolName: "rectification-set-focus" }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", { text: second }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, first);
assert.equal(result.answerText.includes(second), false);
const deltas = emitted.filter((event) => event.type === "answer.delta") as Array<{
text?: string;
replace?: boolean;
}>;
assert.equal(deltas.some((item) => (item.text ?? "").includes(second)), false);
assert.equal(deltas.some((item) => item.replace === true && item.text === ""), false);
});
test("agent-run strips question sentences when this turn owns the focus", async () => {
// 原值: body 首句「范围已经收到,收在 05:00–05:10。」,断言落库与 replace 都等于该句。
// 新值: 首句「2016 年 9 月去北京工作,这件事拿去和星盘对照。」,断言同样等于首句。
// 原因: BUG-1055 / P3:本夹具没有候选快照,05:00–05:10 不是服务端事实,整句会被数字白名单
// 丢弃。本用例锁的是「本轮拥有焦点时剪掉问句」,换成不带时刻的首句后剪问句断言不变。
const body = "2016 年 9 月去北京工作,这件事拿去和星盘对照。你大概哪一年搬过家?有年份就行。";
const stem = "你大概是哪一年搬的家?";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
askedTurnId: TURN_ID,
intent: "collect_method_evidence",
expectedAnswerSchema: { prompt: stem, collect: true },
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options, emitted } = 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: body }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.equal(result.answerText, "2016 年 9 月去北京工作,这件事拿去和星盘对照。");
const replaced = emitted.find((event) => (
event.type === "answer.delta" && (event as { replace?: unknown }).replace === true
)) as { text?: string } | undefined;
assert.equal(replaced?.text, "2016 年 9 月去北京工作,这件事拿去和星盘对照。");
});
test("agent-run uses collectHandoff when the owned-focus body is only a question", async () => {
const stem = "你大概是哪一年搬的家?";
const collectDossier = dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
askedTurnId: TURN_ID,
intent: "collect_method_evidence",
expectedAnswerSchema: { prompt: stem, collect: true },
}),
}),
});
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => collectDossier,
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const { options } = 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.answerText, RECTIFICATION_USER_COPY.collectHandoff);
});
test("buildOpeningBrief names the search window, intake source, and six domains", () => {
const dossier = parseV9CaseDossier(dossierFixture({
candidateRange: { start_time: "04:45", end_time: "05:15" },
evidence: [],
evidenceCount: 0,
}));
assert.ok(dossier);
const brief = buildOpeningBrief(dossier);
assert.match(brief, /当前搜索窗口:04:45–05:15/);
assert.match(brief, /intake 声明的不确定档/);
for (const domain of OPENING_COLLECT_DOMAINS) {
assert.match(brief, new RegExp(domain));
}
// 原值: /先说你最容易想起的一两件,年月大概就行/(brief 让模型照抄旧题干)
// 新值: brief 说明开场题干由服务端固定,正文两句大白话、不用行话、不重复例子
// 原因: BUG-1049 开场题干改为服务端固定(带例子与示例回答),模型只写正文;brief 仍不得含年份
assert.match(brief, /开场题干由服务端固定/);
assert.match(brief, /我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找/);
assert.match(brief, /做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照/);
assert.match(brief, /正文不要重复这些例子/);
assert.doesNotMatch(brief, /最后给区间和代表分钟/);
assert.doesNotMatch(brief, /先说你最容易想起的一两件/);
assert.doesNotMatch(brief, /不要要求一次说完/);
assert.doesNotMatch(brief, /不要举大学、工作、搬家的例子/);
assert.doesNotMatch(brief, /(?:19|20)\d{2}/);
});
test("evidence persist keeps the first sentence when the model writes three", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({ intent: "collect_method_evidence" }),
}),
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
// 原夹具:read-case 后直接写三句「记下了」,无 batch。
// 新夹具:batch completed 后再写那三句。
// 原因:BUG-635 把无写入的「记下了」收口成「这件我还没记上」;本用例锁的是已 persist 后只留第一句。
const { options } = runOptions({
action: "evidence",
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", {
toolName: "rectification-record-evidence-batch",
result: {
accepted_recaps: [
{ display_date_label: "2016 年 9 月", event_phrase: "入学" },
{ display_date_label: "2020 年 6 月", event_phrase: "毕业" },
],
},
}),
chunk("tool-call", { toolName: "rectification-set-focus", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-set-focus" }),
chunk("text-delta", {
text: "记下了:2016 年 9 月入学、2020 年 6 月毕业。这对校正很有帮助。接下来我们继续。",
}),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.match(result.answerText, /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/);
assert.doesNotMatch(result.answerText, /很有帮助|很有价值|很有分量|特别有用/);
assert.doesNotMatch(result.answerText, /接下来我们继续/);
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.match(String(finalize?.args.p_assistant_message ?? ""), /^记下了:2016 年 9 月入学、2020 年 6 月毕业。/);
assert.doesNotMatch(String(finalize?.args.p_assistant_message ?? ""), /很有帮助|特别有用/);
});
test("delivery persist keeps three sentences when the model writes four", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
conversationSummary: conversationSummaryFixture({
declinedSkippedTopics: [
"family",
"education",
"finance",
"relocation",
"health_pressure",
"career",
"relationship",
"occupation",
].map((domain) => ({
target_domain: domain,
status: "declined",
intent: "collect_method_evidence",
})),
}),
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "05:02",
decisionReceipt: {
session_outcome: "adopt_representative",
next_action: "ready_to_adopt",
stop_reason: "probe_pool_exhausted",
can_adopt: true,
// 原值: 无 event_fit_rate。新值: 8/10、80%。原因: BUG-1055 / P3 数字白名单只放行当轮
// 服务端事实;交付句里的「吻合率 80%」需要回执里真有这个吻合率才落库。
event_fit_rate: { matched: 8, total: 10, percent: 80, band: "high" },
},
}),
evidence: [
{
id: "e-career",
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: "e-edu",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "education_start",
domain: "education",
occurred_from: "2014-09-01",
occurred_to: null,
date_precision: "month",
summary: "2014年9月入学",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:06.000Z",
},
{
id: "e-move",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "home_change",
domain: "relocation",
occurred_from: "2020-06-01",
occurred_to: null,
date_precision: "month",
summary: "2020年6月搬家",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-12T10:00:06.000Z",
},
],
}),
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
// 原值: 首句「目前范围 04:49–04:53。」。新值: 「代表分钟是 05:02。」。原因: BUG-1055 / P3,
// 本夹具的服务端范围与代表分钟都是 05:02,04:49–04:53 是手写数字,会整句不落库。
const four = [
"代表分钟是 05:02。",
"对照了 8 件经历,事件吻合率 80%。",
"这只是代表性候选,不是已确认的唯一出生分钟。",
"还可以再看一眼分盘。",
].join("");
const { options } = runOptions({
action: "evidence",
accounting: accounting.client,
buildAgent: async () => 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: four }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
const sentences = result.answerText.split(/(?<=。)/).filter(Boolean);
assert.equal(sentences.length, 3);
assert.doesNotMatch(result.answerText, /还可以再看一眼分盘/);
const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(String(finalize?.args.p_assistant_message ?? "").split(/(?<=。)/).filter(Boolean).length, 3);
});