Files
Jyotisha/frontend/tests/rectification-skipped-health-deadend-20260909.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

167 lines
7.5 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 from "node:test";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import {
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_REPAIR_FAILED_COPY,
} from "../src/lib/rectification-surface-state.ts";
import {
holdoutFollowupFor,
type MethodFollowupEvidence,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
import { rectificationAgentRunSurface } from "./rectification-agent-run-surface.ts";
const SEVEN_WITHOUT_HEALTH: MethodFollowupEvidence[] = [
{ id: "e-edu", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null, eventKind: "education_start" },
{ id: "e-career", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2018-04-01", occurredTo: null, eventKind: "career_entry" },
{ id: "e-rel", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2021-05-01", occurredTo: null, eventKind: "relationship_start" },
{ id: "e-fam", status: "confirmed", domain: "family", datePrecision: "month", occurredFrom: "2023-03-01", occurredTo: null, eventKind: "family_event" },
{ id: "e-fin", status: "confirmed", domain: "finance", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "income_change" },
{ id: "e-reloc", status: "confirmed", domain: "relocation", datePrecision: "month", occurredFrom: "2024-08-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
];
function rpcEvidence(item: MethodFollowupEvidence & { id: string }) {
return {
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-09T00:00:00.000Z",
};
}
function skippedHealthDossier() {
const evidence = SEVEN_WITHOUT_HEALTH.map((item) => rpcEvidence({ ...item, id: item.id ?? `e-${item.domain}` }));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "14:45",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(SEVEN_WITHOUT_HEALTH as never),
decisionReceipt: {
oos_blind_prompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: [
{ target_domain: "family", status: "declined", intent: "collect_method_evidence" },
{
target_domain: "health",
status: "skipped",
intent: "collect_method_evidence",
question_id: "collect:health_pressure:collect_method_evidence",
},
],
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-09T00:00:00.000Z",
},
});
}
function warnLines(run: () => Promise<unknown>) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
};
return Promise.resolve()
.then(run)
.then((result) => ({ result, lines }))
.finally(() => {
console.warn = original;
});
}
test("skipped health ledger domain keeps holdoutFollowupFor null", () => {
assert.equal(holdoutFollowupFor({
evidence: SEVEN_WITHOUT_HEALTH,
oosBlindPrompts: [{
domain: "health_pressure",
user_meaning: "身体这条线还没用过。",
used_for_scoring: false,
}],
}, new Set(["health"])), null);
});
test("persistNextInterviewIfIdle still narrates after duplicate_focus", async () => {
const raw = skippedHealthDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: () => {
throw new Error("focus_idempotency_conflict");
},
});
const { result, lines } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const idle = result as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.ok((idle.hostNarration ?? "").trim());
assert.notEqual(idle.hostNarration, "");
assert.ok(
idle.terminalNote === true || Boolean(idle.hostNarration),
"duplicate_focus must still leave a visible carrier",
);
assert.ok(lines.some((line) => line.includes("rectification_focus_duplicate") || line.includes("rectification_exhaustion_collect")));
});
test("unavailable gap button posts repair-exit and says 接着问", () => {
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
const surface = readFileSync(new URL("../src/lib/rectification-surface-state.ts", import.meta.url), "utf8");
const repair = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts", import.meta.url),
"utf8",
);
// 原值: 读 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.equal(RECTIFICATION_QUESTION_RELOAD_LABEL, "接着问");
assert.match(surface, /RECTIFICATION_QUESTION_RELOAD_LABEL = "接着问"/);
assert.match(surface, /暂时接不上,请新建一次校正/);
assert.equal(RECTIFICATION_QUESTION_REPAIR_FAILED_COPY, "暂时接不上,请新建一次校正。");
assert.match(chat, /\/api\/rectification\/cases\/\$\{encodeURIComponent\(caseId\)\}\/repair-exit/);
assert.match(chat, /RECTIFICATION_QUESTION_REPAIR_FAILED_COPY/);
assert.doesNotMatch(chat, /function reloadQuestion/);
assert.match(repair, /ensureNonTerminalTurnExit/);
assert.match(repair, /POST \/api\/rectification\/cases\/\[caseId\]\/repair-exit/);
const catchBlock = agentRun.slice(agentRun.indexOf("persist interview before collect attach failed"));
assert.match(catchBlock.slice(0, 500), /ensureNonTerminalTurnExit/);
});