Files
Jyotisha/frontend/tests/rectification-superseded-focus.test.ts
T
Jesse_ChenandCursor a3a51c32c2 fix(rectification): re-ask superseded collect ids instead of fake delivery (BUG-644/645)
A superseded collect row still occupied the unique question id, so the last
"没有" skipped the unasked domain and spoke delivery copy while can_adopt stayed false.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 21:03:12 +08:00

341 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
deliveryNarrationAllowed,
publicCanAdopt,
} 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 { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.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";
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",
},
});
}
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("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);
});
test("duplicate_focus on the last collect ask does not emit delivery copy", async () => {
const raw = relationshipOpenDossier();
const remaining = exhaustionSpokenCollectFollowup({
evidence: datedEvidence(),
declinedTopics: [],
});
assert.equal(remaining?.domain, "relationship");
const mapped = {
case: {
caseId: CASE_ID,
sessionId: "22222222-2222-4222-8222-222222222222",
status: "collecting_evidence",
candidateRange: { start_time: "04:50", end_time: "05:10" },
stage: "minute",
acceptedTime: null,
confirmedTime: null,
reportedBirthTime: null,
birthTimeSource: null,
blockScan: null,
},
evidence: datedEvidence(),
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [],
},
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(datedEvidence() as never),
}),
};
const decision = decideFromDossier(mapped as never, { birthDate: "1998-03-15" });
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(publicCanAdopt(decision), false);
assert.equal(deliveryNarrationAllowed(decision, decision.nextAction), false);
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.match(idle.hostNarration ?? "", /感情/);
assert.doesNotMatch(idle.hostNarration ?? "", /这次给出的范围/);
assert.notEqual(idle.terminalNote, true);
const exhaustion = lines
.map((line) => {
try {
return JSON.parse(line) as Record<string, unknown>;
} catch {
return null;
}
})
.find((item) => item?.event === "rectification_exhaustion_collect");
assert.equal(exhaustion?.persist_status, "duplicate_focus");
assert.equal(typeof exhaustion?.question_id, "string");
});
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");
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
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, input\.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\)/);
});