Files
Jyotisha/frontend/tests/rectification-other-collect-fallback-20260908.test.ts
T
Jesse_ChenandCursor 9aec502961
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(rectification): stop other-collect fallback from blocking delivery (BUG-586)
After the dated domains are asked or declined, keep the occupation question or the range card instead of hanging on a leftover other-collect prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 09:02:20 +08:00

349 lines
12 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";
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", () => {
const agent = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
assert.match(agent, /idle\.terminalNote && idle\.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);
});