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>
349 lines
12 KiB
TypeScript
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);
|
|
});
|