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
167 lines
7.5 KiB
TypeScript
167 lines
7.5 KiB
TypeScript
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/);
|
||
});
|