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
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import { USER_COLLECT_QUESTION, USER_COLLECT_QUESTION_RETRY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||
import {
|
||
persistNextInterviewIfIdle,
|
||
isOrphanOtherCollectFocus,
|
||
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||
import {
|
||
persistableFocusDomain,
|
||
parseCollectFocusQuestionId,
|
||
stableFollowupQuestionId,
|
||
} from "../src/lib/rectification-agentic/v9/server-focus.ts";
|
||
import {
|
||
buildMethodFollowupPlan,
|
||
spokenFollowupForUser,
|
||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
|
||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import {
|
||
CASE_ID,
|
||
FOCUS_ID,
|
||
TURN_ID,
|
||
USER_ID,
|
||
activeFocusFixture,
|
||
candidateSnapshotFixture,
|
||
computeFixture,
|
||
dossierFixture,
|
||
fakeAccounting,
|
||
receiptHandlers,
|
||
} from "./rectification-v9-test-support.ts";
|
||
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
|
||
import { rectificationAgentRunSurface } from "./rectification-agent-run-surface.ts";
|
||
|
||
type ExecutableTool<T = unknown> = {
|
||
execute(input: unknown): Promise<T>;
|
||
};
|
||
|
||
const BANNED_OTHER_COLLECT = "也可以再" + "说一件";
|
||
|
||
function dated(
|
||
domain: string,
|
||
year: string,
|
||
extra: { eventKind?: string } = {},
|
||
) {
|
||
const occupation = extra.eventKind === "occupation_note";
|
||
return {
|
||
status: "confirmed" as const,
|
||
domain,
|
||
datePrecision: occupation ? "unknown" as const : "month" as const,
|
||
occurredFrom: occupation ? null : `${year}-03-01`,
|
||
occurredTo: null,
|
||
eventKind: extra.eventKind,
|
||
};
|
||
}
|
||
|
||
const SEVEN_DATED = [
|
||
dated("education", "2016"),
|
||
dated("career", "2020"),
|
||
dated("relationship", "2018"),
|
||
dated("finance", "2024"),
|
||
dated("relocation", "2022"),
|
||
dated("health_pressure", "2021"),
|
||
];
|
||
|
||
const FAMILY_DECLINED = [{ target_domain: "family", status: "declined" }];
|
||
|
||
const OCCUPATION_FOCUS = {
|
||
id: FOCUS_ID,
|
||
questionId: "collect:occupation:collect_method_evidence",
|
||
intent: "collect_method_evidence",
|
||
targetDomain: "other",
|
||
targetKind: "occupation_note",
|
||
expectedAnswerSchema: {
|
||
collect: true,
|
||
prompt: USER_COLLECT_QUESTION.occupation,
|
||
},
|
||
};
|
||
|
||
function rpcEvidence(rows: readonly ReturnType<typeof dated>[]) {
|
||
return rows.map((item, index) => ({
|
||
id: `e-${item.domain}-${index}`,
|
||
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-08T00:00:00.000Z",
|
||
}));
|
||
}
|
||
|
||
test("occupation continuation keeps collect:occupation even when target_domain is other", () => {
|
||
assert.equal(persistableFocusDomain("occupation"), "other");
|
||
assert.deepEqual(parseCollectFocusQuestionId("collect:occupation:collect_method_evidence"), {
|
||
domain: "occupation",
|
||
});
|
||
const plan = buildMethodFollowupPlan({
|
||
evidence: SEVEN_DATED,
|
||
declinedTopics: FAMILY_DECLINED,
|
||
activeFocus: OCCUPATION_FOCUS,
|
||
sessionOutcome: "collect_evidence",
|
||
contrastPacket: { candidateSetVersion: "04:47-05:15", vargaDifferences: [], probes: [] },
|
||
});
|
||
const next = plan.next_followup;
|
||
assert.ok(next);
|
||
assert.equal(next?.domain, "occupation");
|
||
assert.equal(spokenFollowupForUser(next), USER_COLLECT_QUESTION.occupation);
|
||
assert.equal(stableFollowupQuestionId(next!), OCCUPATION_FOCUS.questionId);
|
||
});
|
||
|
||
test("set-focus does not persist collect:other after two invalid spoken prompts", async () => {
|
||
const writes: string[] = [];
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||
evidence: [],
|
||
evidenceCount: 0,
|
||
conversationSummary: {
|
||
confirmed_evidence_summary: [],
|
||
pending_revisions: [],
|
||
active_focus: null,
|
||
declined_skipped_topics: [],
|
||
candidate_divergence_summary: null,
|
||
missing_evidence_categories: [],
|
||
last_result_policy: null,
|
||
summary_version: 1,
|
||
updated_at: "2026-09-08T00:00:00.000Z",
|
||
},
|
||
}),
|
||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||
writes.push(String(args.p_question_id ?? ""));
|
||
throw new Error("must not persist collect:other fallback");
|
||
},
|
||
});
|
||
const execute = (createRectificationV9Tools({
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
turnId: TURN_ID,
|
||
accounting: accounting.client as never,
|
||
})["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute;
|
||
const invalid = {
|
||
caseId: CASE_ID,
|
||
questionId: "collect:other:collect_method_evidence",
|
||
intent: "collect_method_evidence",
|
||
spokenPrompt: "请选一个选项继续问经历吧",
|
||
targetDomain: "career",
|
||
};
|
||
const first = await execute(invalid);
|
||
const second = await execute(invalid);
|
||
assert.equal((first as { error?: string }).error, "invalid_spoken_prompt");
|
||
assert.equal((second as { error?: string }).error, "invalid_spoken_prompt");
|
||
assert.equal(writes.some((id) => id.startsWith("collect:other:")), false);
|
||
assert.equal(writes.length, 0);
|
||
});
|
||
|
||
test("second set-focus in the same turn is idempotent and keeps the occupation focus", async () => {
|
||
const store: { focus: ReturnType<typeof activeFocusFixture> | null } = {
|
||
focus: activeFocusFixture({
|
||
questionId: OCCUPATION_FOCUS.questionId,
|
||
intent: "collect_method_evidence",
|
||
targetDomain: "other",
|
||
targetKind: "occupation_note",
|
||
expectedAnswerSchema: OCCUPATION_FOCUS.expectedAnswerSchema,
|
||
askedTurnId: TURN_ID,
|
||
}),
|
||
};
|
||
const writes: string[] = [];
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||
evidence: rpcEvidence(SEVEN_DATED),
|
||
evidenceCount: SEVEN_DATED.length,
|
||
conversationSummary: {
|
||
confirmed_evidence_summary: [],
|
||
pending_revisions: [],
|
||
active_focus: store.focus,
|
||
declined_skipped_topics: FAMILY_DECLINED,
|
||
candidate_divergence_summary: null,
|
||
missing_evidence_categories: [],
|
||
last_result_policy: null,
|
||
summary_version: 1,
|
||
updated_at: "2026-09-08T00:00:00.000Z",
|
||
},
|
||
}),
|
||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||
writes.push(String(args.p_question_id ?? ""));
|
||
store.focus = activeFocusFixture({
|
||
questionId: String(args.p_question_id),
|
||
intent: String(args.p_intent),
|
||
targetDomain: typeof args.p_target_domain === "string" ? args.p_target_domain : "other",
|
||
targetKind: "occupation_note",
|
||
expectedAnswerSchema: (args.p_expected_answer_schema as Record<string, unknown>) ?? {},
|
||
askedTurnId: typeof args.p_asked_turn_id === "string" ? args.p_asked_turn_id : TURN_ID,
|
||
});
|
||
return {
|
||
focus: store.focus,
|
||
idempotent: writes.length > 1,
|
||
};
|
||
},
|
||
});
|
||
const execute = (createRectificationV9Tools({
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
turnId: TURN_ID,
|
||
accounting: accounting.client as never,
|
||
})["rectification-set-focus"] as unknown as ExecutableTool<Record<string, unknown>>).execute;
|
||
const first = await execute({
|
||
caseId: CASE_ID,
|
||
questionId: OCCUPATION_FOCUS.questionId,
|
||
intent: "collect_method_evidence",
|
||
spokenPrompt: USER_COLLECT_QUESTION.occupation,
|
||
targetDomain: "occupation",
|
||
}) as Record<string, unknown>;
|
||
const second = await execute({
|
||
caseId: CASE_ID,
|
||
questionId: "collect:other:collect_method_evidence",
|
||
intent: "collect_method_evidence",
|
||
spokenPrompt: "请选一个选项继续问经历吧",
|
||
targetDomain: "career",
|
||
}) as Record<string, unknown>;
|
||
assert.equal(first.focus_id, FOCUS_ID);
|
||
assert.equal(second.idempotent, true);
|
||
assert.equal(second.focus_id, first.focus_id);
|
||
assert.equal(second.question_id, OCCUPATION_FOCUS.questionId);
|
||
assert.equal(writes.some((id) => id.startsWith("collect:other:")), false);
|
||
});
|
||
|
||
test("orphan collect:other focus is skipped so idle persist can deliver", async () => {
|
||
const covered = [
|
||
...SEVEN_DATED,
|
||
dated("occupation", "2020", { eventKind: "occupation_note" }),
|
||
];
|
||
const rpcRows = rpcEvidence(covered);
|
||
const fingerprint = evidenceLedgerFingerprint(rpcRows.map((item) => ({
|
||
id: item.id,
|
||
sourceTurnId: item.source_turn_id,
|
||
subject: item.subject,
|
||
eventKind: item.event_kind,
|
||
domain: item.domain,
|
||
occurredFrom: item.occurred_from,
|
||
occurredTo: item.occurred_to,
|
||
datePrecision: item.date_precision,
|
||
summary: item.summary,
|
||
status: item.status,
|
||
supersedesEvidenceId: item.supersedes_evidence_id,
|
||
createdAt: item.created_at,
|
||
})) as never);
|
||
let skipped = false;
|
||
const base = dossierFixture({
|
||
evidence: rpcRows,
|
||
evidenceCount: covered.length,
|
||
latestResult: candidateSnapshotFixture({
|
||
selectionAllowed: true,
|
||
representativeTime: "04:51",
|
||
evidenceLedgerFingerprint: fingerprint,
|
||
decisionReceipt: {
|
||
accept_allowed: true,
|
||
acceptance_allowed: true,
|
||
propose_allowed: true,
|
||
selection_allowed: true,
|
||
confirmation_allowed: false,
|
||
},
|
||
}),
|
||
conversationSummary: {
|
||
confirmed_evidence_summary: [],
|
||
pending_revisions: [],
|
||
active_focus: activeFocusFixture({
|
||
questionId: "collect:other:collect_method_evidence",
|
||
intent: "collect_method_evidence",
|
||
targetDomain: "other",
|
||
expectedAnswerSchema: { collect: true, prompt: "placeholder" },
|
||
}),
|
||
declined_skipped_topics: FAMILY_DECLINED,
|
||
candidate_divergence_summary: null,
|
||
missing_evidence_categories: [],
|
||
last_result_policy: null,
|
||
summary_version: 1,
|
||
updated_at: "2026-09-08T00:00:00.000Z",
|
||
},
|
||
});
|
||
assert.equal(isOrphanOtherCollectFocus({
|
||
questionId: "collect:other:collect_method_evidence",
|
||
}, covered), true);
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => skipped
|
||
? {
|
||
...base,
|
||
conversation_summary: {
|
||
...base.conversation_summary,
|
||
active_focus: null,
|
||
},
|
||
}
|
||
: base,
|
||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||
resolve_agentic_rectification_conversation_focus: (_fn, args) => {
|
||
skipped = true;
|
||
return {
|
||
focus_id: args.p_focus_id,
|
||
status: "skipped",
|
||
evidence_id: null,
|
||
idempotent: false,
|
||
};
|
||
},
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||
throw new Error(`must not persist ${String(args.p_question_id)}`);
|
||
},
|
||
});
|
||
const idle = await persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
assert.equal(skipped, true);
|
||
assert.equal(idle.terminalNote, true);
|
||
assert.ok(idle.hostNarration);
|
||
assert.equal((idle.hostNarration ?? "").includes(BANNED_OTHER_COLLECT), false);
|
||
});
|
||
|
||
test("agent-run writes the exhaustion gate when idle persist returns terminalNote", () => {
|
||
// 原值: 读 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 agent = rectificationAgentRunSurface;
|
||
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
|
||
const chat = rectificationChatSurface;
|
||
assert.match(agent, /interviewIdle\.terminalNote && interviewIdle\.hostNarration/);
|
||
assert.match(agent, /persistExhaustionGateTurn/);
|
||
assert.match(chat, /canOfferCards = canShowRectificationSelectionCards/);
|
||
});
|
||
|
||
test("user-copy and rectification sources no longer contain the other-collect fallback", () => {
|
||
const files = [
|
||
"../src/lib/rectification-agentic/user-copy.ts",
|
||
"../src/lib/rectification-agentic/v9/method-followup.ts",
|
||
"../src/lib/rectification-agentic/v9/server-focus.ts",
|
||
"../src/lib/rectification-agentic/v9/answer-choice.ts",
|
||
"../src/mastra/rectification-v9-tools.ts",
|
||
];
|
||
for (const relative of files) {
|
||
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
|
||
assert.equal(source.includes(BANNED_OTHER_COLLECT), false, relative);
|
||
}
|
||
assert.equal(USER_COLLECT_QUESTION.other, undefined);
|
||
assert.equal(USER_COLLECT_QUESTION_RETRY.other, undefined);
|
||
});
|