Files
Jyotisha/frontend/tests/rectification-stale-compare-fix-20260907.test.ts
T
Jesse_ChenandClaude Opus 5.5 a00c40d4b8 refactor(rectification): split runV9AgentTurn into prepare / attempt / retry / finish
Move-only (TASK-rectification-code-split-20260926 T3/T4). No behavior,
receipt, billing, Skill or scoring change.

- agent-run.ts: 1400 -> 135 lines; runV9AgentTurn 1046 -> 25 lines, no
  nested functions. It keeps the public types, the budget constants and the
  phase order: agent-run-prepare.ts (reliability, session, year re-ask, Skill
  identity, delivered guard, reserve, turn row / replay),
  agent-run-retry.ts (attempt loop), agent-run-attempt.ts (the old nested
  streamAttempt), agent-run-finish.ts (billing settle, receipts, interview,
  finalize), agent-run-support.ts (outcome types, retry classification, turn
  receipt writers), agent-run-messages.ts (buildAgentMessages /
  buildOpeningBrief, re-exported).
- One token changed with the move: the attempt passes its own
  `previousErrorCode` argument to buildAgentMessages instead of reading the
  enclosing `lastAttemptError`; the loop passes that same value (clears the
  old unused-parameter warning).
- Tests: whole-source contracts read tests/rectification-agent-run-surface.ts;
  behavioral runV9AgentTurn tests unchanged. agent-run growth caps added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:52:51 +08:00

507 lines
19 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 } from "node:fs";
import test, { afterEach } from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
ADOPT_OUTCOMES,
RECTIFICATION_TERMINATION_COPY,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
RECTIFICATION_USER_COPY,
withCompareFailedRetryNotice,
withLastSuccessfulCompareNotice,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
applyRectificationChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { resetStaleMinuteRescoreAttemptsForTests, rescoreMinuteAfterWindowChange } from "../src/lib/rectification-agentic/v9/block-scan-answer.ts";
import { STOP_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { parseToolActivityDetail } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
CANDIDATE_ID,
CANDIDATE_RANGE,
FOCUS_ID,
RESULT_ID,
SECOND_CANDIDATE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import { rectificationAgentRunSurface } from "./rectification-agent-run-surface.ts";
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const QUESTION_ID = "question-1";
afterEach(() => {
resetStaleMinuteRescoreAttemptsForTests();
});
function scoreableEvidenceRows() {
return [
{
id: "44444444-4444-4444-8444-444444444441",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "education_start",
domain: "education",
occurred_from: "2016-09-01",
occurred_to: "2016-09-30",
date_precision: "month",
summary: "education start",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444442",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2018-07-01",
occurred_to: null,
date_precision: "month",
summary: "career entry",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444443",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_start",
domain: "relationship",
occurred_from: "2021-05-01",
occurred_to: null,
date_precision: "month",
summary: "relationship start",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444444",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "family_event",
domain: "family",
occurred_from: "2023-03-01",
occurred_to: null,
date_precision: "month",
summary: "family event",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
];
}
function staleDossier(extra: { status?: string; activeFocus?: ReturnType<typeof activeFocusFixture> | null } = {}) {
const evidence = scoreableEvidenceRows();
const inference = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:02", time: "05:02", relative_support: 58 },
{ id: "04:55", time: "04:55", relative_support: 42 },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "month" },
{ id: "e3", domain: "relationship", year: 2021, precision: "month" },
{ id: "e4", domain: "family", year: 2023, precision: "month" },
],
probes: [],
});
return dossierFixture({
status: extra.status ?? "collecting_evidence",
evidence,
latestResult: candidateSnapshotFixture({
evidenceLedgerFingerprint: "b".repeat(64),
representativeTime: "05:02",
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
inference_state: inference,
},
}),
conversationSummary: conversationSummaryFixture({
activeFocus: extra.activeFocus === undefined
? activeFocusFixture({
questionId: QUESTION_ID,
expectedAnswerSchema: {
choice: {
prompt: "平时相处更接近哪一种?",
option_a: "照顾对方感受",
option_b: "习惯自己拿主意",
option_c: "两种都有",
option_d: "说不好",
options: [
{ key: "A", label: "照顾对方感受", answer_class: "yes" },
{ key: "B", label: "习惯自己拿主意", answer_class: "weak_yes" },
{ key: "C", label: "两种都有", answer_class: "no" },
{ key: "D", label: "说不好", answer_class: "unsure" },
],
},
probe_id: "p-d9",
semantic_key: "varga.d9.style",
scoring: true,
},
})
: extra.activeFocus,
}),
});
}
function scoreEnginePayload() {
return {
success: true,
endpoint: "rectification_v5_score",
result_id: RESULT_ID,
algorithm_version: "rectification-event-contract-v2",
event_contract_version: "rectification-event-contract-v2",
decision_policy_version: "rectification-candidate-policy-v2",
execution_ledger_version: "rectification-execution-ledger-v2",
candidate_decisions: [
{ candidate_id: CANDIDATE_ID, time: "05:02", rank: 1, relative_support: 58, tied_minute_count: 1 },
{ candidate_id: SECOND_CANDIDATE_ID, time: "04:55", rank: 2, relative_support: 42, tied_minute_count: 1 },
],
decision_receipt: {
receipt_version: "candidate-decision-receipt-v2",
contract_version: "v2",
event_contract_version: "rectification-event-contract-v2",
policy_version: "rectification-candidate-policy-v2",
decision_policy_version: "rectification-candidate-policy-v2",
display_allowed: true,
selection_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
accept_allowed: true,
confirm_allowed: false,
representative_candidate_id: CANDIDATE_ID,
representative_time: "05:02",
overall_confidence: "high",
margin_percent: 16,
},
execution_ledger: [
{ ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d1-rashi", status: "executed", source: "python-engine" },
],
};
}
test("compare failure copy and receipt detail stay user-visible without PII", () => {
assert.equal(
withCompareFailedRetryNotice("这条记下了。"),
`这条记下了。\n\n${RECTIFICATION_USER_COPY.compareFailedRetry}`,
);
assert.equal(
withLastSuccessfulCompareNotice("目前范围 04:45–05:15。"),
`目前范围 04:45–05:15。\n\n${RECTIFICATION_USER_COPY.lastSuccessfulCompareRange}`,
);
const detail = parseToolActivityDetail({
result_fingerprint: JSON.stringify({
safe_error_code: "engine_request_failed",
engine_message: "asked_probe_keys[0] must be a non-empty string up to 120 characters",
}),
});
assert.equal(detail?.safe_error_code, "engine_request_failed");
assert.match(String(detail?.engine_message), /asked_probe_keys/);
// 原值: 读 lib/rectification-agentic/v9/agent-run.ts 单文件。新值: rectificationAgentRunSurface(agent-run.ts + 拆出的 agent-run-*.ts,见 rectification-agent-run-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const agentRun = rectificationAgentRunSurface;
assert.match(agentRun, /withCompareFailedRetryNotice/);
assert.match(agentRun, /rectification-compare-candidates/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
// 原值: /engine_message: engineMessageForReceipt/ 写在 compare catch 里
// 新值: failReceipt 统一写 engine_message,compare catch 调用它
// 原因: BUG-659 失败回执抽成共享 helper,源码锁仍要保住脱敏原文
assert.match(tools, /const message = engineMessageForReceipt\(error\)/);
assert.match(tools, /engine_message: message/);
assert.match(tools, /failReceipt\("rectification-compare-candidates"/);
});
test("idle persist on a stale snapshot calls candidate score once", async () => {
let scoreCalls = 0;
const previous = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/rectification/v5/score")) {
scoreCalls += 1;
return {
ok: true,
status: 200,
json: async () => scoreEnginePayload(),
};
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
try {
const raw = staleDossier({ activeFocus: null });
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
persist_agentic_rectification_candidate_v2: (_fn, args) => ({
result_id: RESULT_ID,
candidates: args.p_candidates,
overall_confidence: "medium",
selection_allowed: true,
confirmation_allowed: false,
representative_time: "05:02",
evidence_ledger_fingerprint: args.p_evidence_ledger_fingerprint,
candidate_range_fingerprint: args.p_candidate_range_fingerprint,
skill_version: args.p_skill_version,
algorithm_version: args.p_algorithm_version,
event_contract_version: args.p_event_contract_version,
decision_policy_version: args.p_decision_policy_version,
decision_receipt: args.p_decision_receipt,
execution_ledger: args.p_execution_ledger,
created_at: "2026-09-07T00:00:00.000Z",
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
askedTurnId: TURN_ID,
});
// 原值: 1 然后仍是 1
// 新值: 2 然后 3
// 原因: 过期快照重算 1 次 + 池空刷新探针 1 次(BUG-653)。本夹具没有已答题,刷新计数落不了库,第二次 idle 会再空刷一次。
assert.equal(scoreCalls, 2);
await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
askedTurnId: TURN_ID,
});
assert.equal(scoreCalls, 3);
} finally {
globalThis.fetch = previous;
}
});
function fiveAnsweredInference() {
const probes = Array.from({ length: 5 }, (_, index) => ({
id: `probe-${index + 1}`,
semantic_key: `varga.d9.style.${index + 1}`,
candidate_split_hash: `split-${index + 1}`,
domain: "relationship",
year: 2014 + index,
question: `style ${index + 1}`,
candidate_ids: ["05:02", "04:55"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["05:02"], conflicts: ["04:55"] },
{ answer_class: "no" as const, supports: ["04:55"], conflicts: ["05:02"] },
],
information_gain: 0.2,
source: "event_probe",
}));
return buildInferenceState({
range_start: CANDIDATE_RANGE.start_time,
range_end: CANDIDATE_RANGE.end_time,
candidates: [
{ id: "05:02", time: "05:02", relative_support: 58 },
{ id: "04:55", time: "04:55", relative_support: 42 },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "month" },
{ id: "e3", domain: "relationship", year: 2021, precision: "month" },
{ id: "e4", domain: "family", year: 2023, precision: "month" },
{ id: "e5", domain: "finance", year: 2017, precision: "month" },
],
probes,
answered_probes: probes.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: "yes" as const,
classified_from: "choice" as const,
})),
});
}
function persistCandidateEcho() {
return (_fn: string, args: Record<string, unknown>) => ({
result_id: RESULT_ID,
candidates: args.p_candidates,
overall_confidence: "medium",
selection_allowed: true,
confirmation_allowed: false,
representative_time: "05:02",
evidence_ledger_fingerprint: args.p_evidence_ledger_fingerprint,
candidate_range_fingerprint: args.p_candidate_range_fingerprint,
skill_version: args.p_skill_version,
algorithm_version: args.p_algorithm_version,
event_contract_version: args.p_event_contract_version,
decision_policy_version: args.p_decision_policy_version,
decision_receipt: args.p_decision_receipt,
execution_ledger: args.p_execution_ledger,
created_at: "2026-09-07T00:00:00.000Z",
});
}
function mockScoreFetch() {
return (async (target: RequestInfo | URL) => {
const url = String(target);
if (url.includes("/api/rectification/v5/score")) {
return { ok: true, status: 200, json: async () => scoreEnginePayload() };
}
if (url.includes("/api/rectification/v5/vedastro-validate")) {
return {
ok: true,
status: 200,
json: async () => ({ status: "not_evaluated", can_confirm_exact_minute: false }),
};
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
}
test("STOP on a stale snapshot rescores then delivers a range", async () => {
const previous = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error("engine down");
}) as typeof fetch;
try {
const raw = staleDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
probe_id: "p-d9",
revision: Number(args.p_expected_revision) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: STOP_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
optionId: "stop",
expectedRevision: 1,
});
assert.ok(ADOPT_OUTCOMES.has(applied.nextAction.session_outcome));
assert.equal(applied.nextAction.can_adopt, true);
assert.match(applied.narration, new RegExp(RECTIFICATION_USER_COPY.lastSuccessfulCompareRange));
assert.ok(applied.narration.includes(RECTIFICATION_TERMINATION_COPY) || applied.narration.includes("范围"));
} finally {
globalThis.fetch = previous;
}
});
function inferenceFromPersist(calls: Array<{ fn: string; args: Record<string, unknown> }>) {
const persist = [...calls].reverse().find((item) => item.fn === "persist_agentic_rectification_candidate_v2");
const receipt = persist?.args.p_decision_receipt;
assert.ok(receipt && typeof receipt === "object");
const inference = (receipt as { inference_state?: { answered_probes?: unknown[]; candidates?: Array<{ posterior_score?: number; time?: string }>; credible_range?: [string, string] } }).inference_state;
assert.ok(inference);
return inference;
}
test("STOP on a stale snapshot keeps five answered probes and posterior scores", async () => {
const previous = globalThis.fetch;
globalThis.fetch = mockScoreFetch();
try {
const before = fiveAnsweredInference();
const raw = staleDossier();
(raw.latest_result as { decision_receipt: Record<string, unknown> }).decision_receipt.inference_state = before;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
persist_agentic_rectification_candidate_v2: persistCandidateEcho(),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
probe_id: "p-d9",
revision: Number(args.p_expected_revision) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: STOP_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
optionId: "stop",
expectedRevision: 1,
});
const inference = inferenceFromPersist(accounting.calls);
const choiceAnswers = (inference.answered_probes ?? []).filter((item) => (
Boolean(item)
&& typeof item === "object"
&& (item as { classified_from?: string }).classified_from === "choice"
));
assert.equal(choiceAnswers.length, 5);
const winner = inference.candidates?.find((item) => item.time === "05:02");
assert.ok(winner);
assert.notEqual(winner.posterior_score, 58);
assert.ok(before.credible_range);
const [start, end] = inference.credible_range ?? ["", ""];
assert.ok(start >= before.credible_range[0]);
assert.ok(end <= before.credible_range[1]);
} finally {
globalThis.fetch = previous;
}
});
test("window change rescore writes inference_state with empty answers", async () => {
const previous = globalThis.fetch;
globalThis.fetch = mockScoreFetch();
try {
const raw = staleDossier();
(raw.latest_result as { decision_receipt: Record<string, unknown> }).decision_receipt.inference_state = fiveAnsweredInference();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
persist_agentic_rectification_candidate_v2: persistCandidateEcho(),
});
await rescoreMinuteAfterWindowChange(accounting.client, USER_ID, CASE_ID);
const inference = inferenceFromPersist(accounting.calls);
assert.equal((inference.answered_probes ?? []).length, 0);
} finally {
globalThis.fetch = previous;
}
});