Files
Jyotisha/frontend/tests/rectification-other-collect-fallback-20260908.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

353 lines
13 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 { 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);
});