Files
Jyotisha/frontend/tests/rectification-superseded-focus.test.ts
T
Jesse_ChenandClaude Opus 5.5 eb773f1299 refactor(rectification): split POST /api/rectification/agent into branch handlers
Move-only (TASK-rectification-code-split-20260926 T2/T4). No behavior, API,
copy, DB, Skill, billing or scoring change.

- route.ts: 1077 -> 152 lines; POST 927 -> 114. It builds the Supabase
  clients (so the setup-failure mapping stays here) and assembles:
  agent-route-request.ts (auth / product / schema / flag / Case-Session
  binding), agent-route-typed-message.ts (declared-window reply, typed-answer
  preflight, unfocused classification), agent-route-structured-choice.ts,
  agent-route-agent-turn.ts (opening / read-only / typed agent stream and its
  exit gate), agent-route-billing.ts, agent-route-support.ts (schema, one-shot
  NDJSON reply, context types).
- The four mutable preflight lets (expectedWrite, collectIntent,
  writeClassified, classifierDiagnostic) that crossed branches are one
  turnState object; values and flow unchanged.
- Tests: whole-source contracts read tests/rectification-agent-route-surface.ts;
  the typed fast-path slice is rebuilt from the new files; billing,
  declared-window and structured-choice slices now call the handlers
  (billing in a child process because feature-pricing imports server-only),
  each with 原值/新值/原因. Route 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:40:07 +08:00

316 lines
12 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 {
deliveryNarrationAllowed,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
USER_COLLECT_QUESTION,
USER_COLLECT_QUESTION_RETRY,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { exhaustionSpokenCollectFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
COLLECT_FOCUS_RETRY_SUFFIXES,
collectFocusRetryQuestionIds,
persistServerOwnedFocus,
stableFollowupQuestionId,
} from "../src/lib/rectification-agentic/v9/server-focus.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 type { MethodFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { rectificationAgentRouteSurface } from "./rectification-agent-route-surface.ts";
const BASE_QUESTION = "collect:relationship:collect_method_evidence";
function collectFollowup(overrides: Partial<MethodFollowup> = {}): MethodFollowup {
return {
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: "relationship",
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
...overrides,
};
}
function focusRowFromArgs(args: Record<string, unknown>) {
return {
id: "abababab-abab-4bab-8bab-abababababab",
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-10T00:00:00.000Z",
resolved_at: null,
idempotent: false,
};
}
function datedEvidence() {
return [
{ 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-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: "2022-06-01", occurredTo: null, eventKind: "relocation" },
{ id: "e-health", status: "confirmed", domain: "health_pressure", datePrecision: "month", occurredFrom: "2021-11-01", occurredTo: null, eventKind: "health_episode" },
{ id: "e-occ", status: "confirmed", domain: "occupation", datePrecision: "unknown", occurredFrom: null, occurredTo: null, eventKind: "occupation_note" },
] as const;
}
function rpcEvidence(item: ReturnType<typeof datedEvidence>[number]) {
return {
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind,
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-10T00:00:00.000Z",
};
}
function relationshipOpenDossier() {
const ledger = datedEvidence();
const evidence = ledger.map((item) => rpcEvidence(item));
return dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(ledger as never),
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
},
}),
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-10T00:00:00.000Z",
},
});
}
test("collect focus retries next then next2 then next3 before duplicate_focus", async () => {
assert.deepEqual(COLLECT_FOCUS_RETRY_SUFFIXES, ["next", "next2", "next3"]);
assert.deepEqual(
collectFocusRetryQuestionIds(BASE_QUESTION),
[`${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`, `${BASE_QUESTION}:next3`],
);
const blocked = new Set([BASE_QUESTION, `${BASE_QUESTION}:next`, `${BASE_QUESTION}:next2`]);
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
writes += 1;
const id = String(args.p_question_id);
if (blocked.has(id)) throw new Error("agentic_rectification_focus_idempotency_conflict");
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup(),
});
assert.equal(persisted.status, "created");
assert.equal(writes, 4);
assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION.relationship);
});
test("collect focus four collisions return duplicate_focus", async () => {
let writes = 0;
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: () => {
writes += 1;
throw new Error("agentic_rectification_focus_idempotency_conflict");
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup(),
});
assert.equal(persisted.status, "duplicate_focus");
assert.equal(writes, 4);
assert.equal(persisted.questionId, `${BASE_QUESTION}:next3`);
});
test("collect_retry only changes copy, not whether a suffix is tried", async () => {
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => {
if (args.p_question_id === BASE_QUESTION) {
throw new Error("agentic_rectification_focus_idempotency_conflict");
}
return focusRowFromArgs(args);
},
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({ collect_retry: true }),
});
assert.equal(persisted.status, "created");
assert.equal(persisted.questionId, `${BASE_QUESTION}:next`);
assert.equal(persisted.prompt, USER_COLLECT_QUESTION_RETRY.relationship);
});
test("probe question ids do not receive collect suffixes", () => {
const followup: MethodFollowup = {
method_id: "dasha_events",
intent: "distinguish_candidates",
ask_theme: "dated_event",
domain: "education",
kind_hint: null,
user_prompt_hint: "ask",
must_not_label: false,
choice_frame: null,
source: "event_probe",
semantic_key: "education:2016",
candidate_split_hash: "education:2016",
};
const questionId = stableFollowupQuestionId(followup);
assert.match(questionId, /^probe:/);
assert.equal(questionId.includes(":next"), false);
});
test("deliveryNarrationAllowed requires public can_adopt or a selectable range offer", () => {
assert.equal(deliveryNarrationAllowed({
canAdopt: true,
sessionOutcome: "collect_evidence",
nextAction: "ask_fact_collection",
selectionAllowed: true,
}), false);
assert.equal(deliveryNarrationAllowed({
canAdopt: true,
sessionOutcome: "adopt_representative",
nextAction: "ready_to_adopt",
selectionAllowed: true,
}), true);
assert.equal(deliveryNarrationAllowed({
canAdopt: false,
sessionOutcome: "provisional_range",
nextAction: "offer_provisional_range",
selectionAllowed: true,
}), true);
assert.equal(deliveryNarrationAllowed({
canAdopt: false,
sessionOutcome: "collect_evidence",
nextAction: "offer_provisional_range",
selectionAllowed: false,
}), false);
// 原值: completed_with_range 且 canAdopt=false 不能播交付
// 新值: selectionAllowed 且 outcome 在 DELIVERY_OUTCOMES 即可播
// 原因: BUG-681 能说就能画,completed_with_range / provisional_range 与卡片同真同假
assert.equal(deliveryNarrationAllowed({
canAdopt: false,
sessionOutcome: "completed_with_range",
nextAction: "complete_with_range",
selectionAllowed: true,
}), true);
});
test("duplicate_focus on the last collect ask does not emit delivery copy", async () => {
const raw = relationshipOpenDossier();
const remaining = exhaustionSpokenCollectFollowup({
evidence: datedEvidence(),
declinedTopics: [],
});
// 原值: leftover 感情采集
// 新值: 训练门开后 leftover dated collect 为 null
// 原因: BUG-648
assert.equal(remaining, null);
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: (_fn, args) => ({
focus: {
id: "11111111-1111-4111-8111-111111111111",
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-10T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.doesNotMatch(idle.hostNarration ?? "", /领域不限|做不了|材料不够/);
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
// 原值: p_target_domain 不得是 relationship / finance
// 新值: 写的是定向感情线(本夹具就叫 relationshipOpenDossier,这条线是开着的)
// 原因: D5——七条线全部轮到,感情线仍未覆盖就该被问;本题真正的断言是
// 上一行「不得播交付文案」(BUG-751)
assert.match(String(setFocus?.args.p_question_id ?? ""), /^collect:(targeted|guided):/);
assert.notEqual(setFocus?.args.p_target_domain, "finance");
});
test("delivery copy is gated at the three persist sites", () => {
const answer = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
const decision = readFileSync(
new URL("../src/lib/rectification-agentic/core/rectification-decision.ts", import.meta.url),
"utf8",
);
assert.match(decision, /export function deliveryNarrationAllowed/);
assert.match(answer, /deliveryNarrationAllowed\(decision, nextAction\.type\)/);
assert.match(answer, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
assert.doesNotMatch(answer, /this ask is already closed; fall through to adopt/);
assert.match(route, /deliveryNarrationAllowed\(decision, decision\.nextAction\)/);
});