Files
Jyotisha/frontend/tests/rectification-adopt-flow-20260902.test.ts
T
Jesse_Chen e8c98c37cf
Independent Staging Quality Gate / validate (push) Successful in 8m29s
Independent Staging Quality Gate / publish (push) Successful in 1m45s
fix(rectification): restore collect-phase exit and drop the consult handoff
Public can_adopt no longer starves the collect fallback, 改选 scrolls to
the offer, opening collect uses other, and probe_year stays on the
schema. The status bar no longer starts an empty consultation chat.

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

533 lines
20 KiB
TypeScript

import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
publicCanAdopt,
publicDecisionFields,
decideRectification,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
canShowRectificationReadonlyRange,
canShowRectificationSelectionCards,
parseRectificationCandidateResult,
} from "../src/lib/rectification-candidate-result.ts";
import {
attachOfferResultToTurns,
attachQuestionsToTurns,
parseTurnQuestion,
} from "../src/lib/rectification-agentic/v9/turn-question.ts";
import {
buildMethodFollowupPlan,
collectQuestionDomain,
OPENING_COLLECT_DOMAIN,
parsePersistedFollowupQuestionId,
projectRectificationChoiceCard,
spokenCollectFallbackFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
CHOICE_SKIP_QUESTION_LABEL,
CHOICE_STOP_LABEL,
parseRectificationChoiceCard,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { stableFollowupQuestionId } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { hasPersistedVedastroValidation } from "../src/lib/rectification-agentic/v9/confirmation-gate.ts";
import {
parseToolActivityDetail,
loadV9TurnReceipt,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS,
RECTIFICATION_SLOW_STEP_MS,
RECTIFICATION_TIMEOUT_WARN_BEFORE_MS,
rectificationLiveProgressLabel,
} from "../src/lib/rectification-activity-labels.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING, CASE_ID, FOCUS_ID, TURN_ID, USER_ID, fakeAccounting } from "./rectification-v9-test-support.ts";
const preAdopt = JSON.parse(readFileSync(
new URL("./fixtures/rectification-case-bf7a8de6-pre-adopt.json", import.meta.url),
"utf8",
)) as Record<string, unknown>;
const postAdopt = JSON.parse(readFileSync(
new URL("./fixtures/rectification-case-bf7a8de6-post-adopt.json", import.meta.url),
"utf8",
)) as Record<string, unknown>;
const STYLE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const EDUCATION_VERIFY = {
year: 2016,
year_label: "2016 年前后",
domain: "education" as const,
event_family: "升学结果或学习环境出现明显变化",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "2016 年前后升学结果或学习环境出现明显变化",
role: "reverse_verify" as const,
information_gain: 0.4,
semantic_key: "education.2016",
style_options: STYLE_OPTIONS,
};
const VERIFY_COPY = {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: STYLE_OPTIONS[0].label,
option_b: STYLE_OPTIONS[1].label,
option_c: STYLE_OPTIONS[2].label,
option_d: STYLE_OPTIONS[3].label,
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
} as const;
function reverseVerifySchema() {
return {
prompt: VERIFY_COPY.prompt,
probe_year: 2016,
choice: VERIFY_COPY,
};
}
function collectWithInternalAdopt() {
return decideRectification({
methodCoverageAll: false,
datedMethodCollectOpen: true,
datedEventCount: 4,
datedDomainCount: 3,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: [
{ time: "05:06", score: 14 },
{ time: "04:53", score: 13 },
{ time: "05:03", score: 13 },
],
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
confirmationAllowed: false,
inferenceCredibleRange: ["04:53", "05:06"],
});
}
test("public can_adopt is closed during collect even when internal capability is open", () => {
const decision = collectWithInternalAdopt();
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.canAdopt, true);
const fields = publicDecisionFields(decision);
assert.equal(fields.can_adopt, false);
assert.equal(fields.selection_allowed, decision.selectionAllowed);
assert.equal(publicCanAdopt(decision), false);
});
test("public can_adopt stays open only for adopt outcomes", () => {
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "adopt_representative" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "awaiting_confirmation" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "validated_range" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "discriminate_candidates" }), false);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "validate_holdout" }), false);
assert.equal(publicCanAdopt({ canAdopt: false, sessionOutcome: "adopt_representative" }), false);
});
test("accept route 409s before the adopt RPC when public can_adopt is false", () => {
const accept = readFileSync(
new URL("../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", import.meta.url),
"utf8",
);
const gate = accept.indexOf("overlaid.can_adopt !== true");
const rpc = accept.indexOf("accept_agentic_rectification_candidate_for_case_v2");
assert.ok(gate >= 0, "accept must gate on overlaid.can_adopt");
assert.ok(rpc > gate, "RPC must not run before the session_outcome gate");
assert.match(accept, /adoption_not_allowed/);
assert.match(accept, /session_outcome: fields\.session_outcome/);
});
test("pre-adopt snapshot does not show adopt cards and does show a readonly range", () => {
const result = parseRectificationCandidateResult(preAdopt);
assert.ok(result);
assert.equal(result.sessionOutcome, "collect_evidence");
assert.equal(result.canAdopt, true);
assert.equal(canShowRectificationSelectionCards(result), false);
assert.equal(canShowRectificationReadonlyRange(result), true);
assert.deepEqual(result.credibleRange, ["04:53", "05:06"]);
});
test("post-adopt snapshot keeps the offer on the pre-verify message", () => {
const result = parseRectificationCandidateResult(postAdopt);
assert.ok(result);
assert.equal(result.selectedTime, "05:06");
const turns = Array.isArray(postAdopt.turns) ? postAdopt.turns as Array<{
id: string;
role: string;
question?: unknown;
}> : [];
const withQuestions = turns.map((turn) => ({
...turn,
text: null,
question: parseTurnQuestion(turn.question),
}));
const attached = attachOfferResultToTurns(withQuestions, {
resultId: result.resultId,
canAdopt: true,
acceptedTime: "05:06",
});
assert.equal(attached[0]?.offer_result_id, result.resultId);
assert.equal(attached[1]?.offer_result_id, null);
assert.equal(attached[1]?.question?.kind, "reverse_verify");
});
test("accepted reverse_verify keep reuses the persisted question id", () => {
const questionId = "reverse_verify:education_style:score";
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
const followup = plan.next_followup;
assert.equal(followup?.intent, "reverse_verify");
assert.equal(followup?.method_id, "reverse_verify");
assert.equal(followup?.ask_theme, "education_style");
assert.equal(stableFollowupQuestionId(followup!), questionId);
assert.match(followup?.user_prompt_hint ?? "", /当前排盘已采用该时间/);
const parsed = parsePersistedFollowupQuestionId(questionId);
assert.deepEqual(parsed, { method_id: "reverse_verify", ask_theme: "education_style", scoring: true });
const card = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.ok(card);
assert.equal(card.focus_id, FOCUS_ID);
assert.equal(card.question_id, questionId);
assert.equal(card.stop_label, CHOICE_SKIP_QUESTION_LABEL);
assert.ok(parseRectificationChoiceCard(card));
const again = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.equal(again?.question_id, card.question_id);
assert.equal(again?.focus_id, card.focus_id);
});
test("distinguish keep still uses probe: semantic_key ids", () => {
const questionId = "probe:education.2016";
const plan = buildMethodFollowupPlan({
evidence: [{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2020-01-01",
occurredTo: null,
}],
sessionOutcome: "discriminate_candidates",
eventProbes: [{
...EDUCATION_VERIFY,
role: "distinguish",
semantic_key: "education.2016",
candidate_split_hash: "education:2016:05:06|04:53",
candidate_ids: ["05:06", "04:53"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:06"], conflicts: ["04:53"] },
{ answer_class: "no", supports: ["04:53"], conflicts: ["05:06"] },
],
}],
activeFocus: {
id: FOCUS_ID,
questionId,
intent: "distinguish_candidates",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: {
semantic_key: "education.2016",
candidate_split_hash: "education:2016:05:06|04:53",
probe_id: "education.2016",
choice: {
prompt: "2016 年前后,升学结果或学习环境有没有明显变化?",
option_a: STYLE_OPTIONS[0].label,
option_b: STYLE_OPTIONS[1].label,
option_c: STYLE_OPTIONS[2].label,
option_d: STYLE_OPTIONS[3].label,
},
},
},
});
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
assert.equal(plan.next_followup?.semantic_key, "education.2016");
assert.equal(stableFollowupQuestionId(plan.next_followup!), questionId);
assert.equal(parsePersistedFollowupQuestionId(questionId), null);
});
test("post-adopt live question identity matches the last reverse_verify turn", () => {
const last = Array.isArray(postAdopt.turns)
? postAdopt.turns[postAdopt.turns.length - 1] as { question?: unknown }
: null;
const question = parseTurnQuestion(last?.question);
assert.equal(question?.kind, "reverse_verify");
assert.equal(question?.options?.length, 4);
const card = projectRectificationChoiceCard({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
}],
accepted: true,
sessionOutcome: "adopt_representative",
eventProbes: [EDUCATION_VERIFY],
activeFocus: {
id: question?.focus_id,
questionId: question?.question_id,
intent: "reverse_verify",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: reverseVerifySchema(),
},
});
assert.equal(card?.focus_id, question?.focus_id);
assert.equal(card?.question_id, question?.question_id);
});
test("opening collect domain is other rather than unknown", () => {
// 旧 education → 新 other → 保留 不得为 unknown、stable id 不含 unknown
assert.equal(OPENING_COLLECT_DOMAIN, "other");
assert.equal(collectQuestionDomain(null), "other");
assert.equal(collectQuestionDomain("unknown"), "other");
const fallback = spokenCollectFallbackFollowup({
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain: null,
kind_hint: null,
user_prompt_hint: "collect",
must_not_label: false,
choice_frame: null,
source: "method_coverage",
});
assert.equal(fallback.domain, "other");
assert.equal(stableFollowupQuestionId(fallback), "collect:other:collect_method_evidence");
assert.doesNotMatch(stableFollowupQuestionId(fallback), /unknown/);
});
test("skipped collect questions stay attached to their asked turn", () => {
const attached = attachQuestionsToTurns(
[{ id: TURN_ID, role: "assistant", text: "记下了。", status: "completed" }],
[{
id: FOCUS_ID,
caseId: CASE_ID,
questionId: "collect:family:collect_method_evidence",
intent: "collect_method_evidence",
targetEvidenceId: null,
targetDomain: "family",
targetKind: null,
expectedAnswerSchema: {
collect: true,
prompt: "2021 年前后,家里有没有人住院、搬家或职责明显变化?",
},
status: "skipped",
askedAt: "2026-09-02T00:00:00.000Z",
resolvedAt: "2026-09-02T00:03:00.000Z",
askedTurnId: TURN_ID,
answerOption: "stop",
}],
);
assert.equal(attached[0]?.question?.status, "skipped");
assert.equal(attached[0]?.question?.kind, "collect_spoken");
});
test("chat owns adopt cards on the offering message and drops the list-end handoff", () => {
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(chat, /canShowRectificationSelectionCards/);
assert.match(chat, /canShowRectificationReadonlyRange/);
assert.match(chat, /candidateOffer/);
assert.match(chat, /!candidateResult\?\.selectedTime/);
assert.match(chat, /rectification-adopt-status/);
// 旧 状态条含「用这个时间看盘」→ 新 产品删按钮 → 保留 状态条仍在且含「改选」,不得再渲染 consult-handoff
assert.doesNotMatch(chat, /用这个时间看盘/);
assert.match(chat, /改选/);
assert.match(chat, /CHOICE_STOP_LABEL/);
assert.match(chat, /已跳过(已采用/);
assert.match(chat, /rectificationLiveProgressLabel/);
assert.doesNotMatch(chat, /rectification-consult-handoff/);
assert.match(chat, /kind === "reverse_verify"/);
assert.match(chat, /scoring: question\.kind !== "reverse_verify"/);
});
test("choice cards use 这题跳过 for reverse_verify and 先这样 for collect", () => {
assert.equal(CHOICE_SKIP_QUESTION_LABEL, "这题跳过");
assert.equal(CHOICE_STOP_LABEL, "先这样,先看当前范围");
const choiceCard = readFileSync(
new URL("../src/lib/rectification-agentic/v9/choice-card.ts", import.meta.url),
"utf8",
);
assert.match(choiceCard, /CHOICE_SKIP_QUESTION_LABEL/);
const prompt = readFileSync(
new URL("../src/mastra/agentic-rectification.ts", import.meta.url),
"utf8",
);
assert.match(prompt, /case\.accepted_time 非空时,正文第一句要说明已按该时间采用/);
assert.match(prompt, /必须先用一句话承接用户本轮给出的事实(年份\+事件)/);
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
assert.match(voice, /不要预告选项/);
});
test("set-focus failures persist the spoken-prompt reason on the receipt", () => {
const tools = readFileSync(
new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url),
"utf8",
);
assert.match(tools, /resultFingerprint: JSON\.stringify\(\{ reason: spoken\.reason \}\)/);
assert.match(tools, /readVedastroMinuteSensitiveStatus\(decisionReceipt\) !== "passed"/);
const detail = parseToolActivityDetail({
error: "invalid_spoken_prompt",
result_fingerprint: JSON.stringify({ reason: "probe_mismatch" }),
});
assert.equal(detail?.reason, "probe_mismatch");
assert.equal(detail?.error, "invalid_spoken_prompt");
});
test("elapsed_ms serializes from the turn receipt and 45s copy follows the live tool", () => {
const migration = readFileSync(
new URL("../supabase/migrations/20260902020000_rectification_tool_activity_timing.sql", import.meta.url),
"utf8",
);
assert.match(migration, /'elapsed_ms'/);
assert.match(migration, /'started_at'/);
assert.equal(existsSync(fileURLToPath(new URL("../db/migrations/20260902020000_rectification_tool_activity_timing.sql", import.meta.url))), false);
assert.equal(RECTIFICATION_SLOW_STEP_MS, 45_000);
assert.equal(
rectificationLiveProgressLabel({
tool: "rectification-compare-candidates",
baseLabel: "正在比较候选时间…",
stepStartedAt: 0,
runStartedAt: 0,
now: RECTIFICATION_SLOW_STEP_MS,
}),
"引擎在比较候选,可能需要一两分钟",
);
assert.equal(
rectificationLiveProgressLabel({
tool: "rectification-read-case",
baseLabel: "正在读取校正记录…",
stepStartedAt: 0,
runStartedAt: 0,
now: RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS - RECTIFICATION_TIMEOUT_WARN_BEFORE_MS,
}),
"即将超时,会自动重试或提示",
);
});
test("read_only compare reuses a passed vedastro validation and still names the helper", () => {
assert.equal(hasPersistedVedastroValidation({
vedastro_event_validation: { status: "failed" },
}), true);
assert.equal(hasPersistedVedastroValidation({
gates: { exact_confirmation: { vedastro_event_validation: { status: "passed" } } },
}), true);
assert.equal(hasPersistedVedastroValidation({}), false);
});
test("GET turn receipts expose elapsed_ms and set-focus failure detail", async () => {
const accounting = fakeAccounting({
get_agentic_rectification_turn_receipt: () => ({
turn_id: TURN_ID,
attempt_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
status: "completed",
skill_name: "jyotish-birth-time-rectification",
skill_version: "9.0.0",
engine_version: null,
phases: [],
tool_activities: [
{
tool: "rectification-compare-candidates",
status: "completed",
methods: ["d1-rashi"],
started_at: "2026-09-02T13:27:10.000Z",
elapsed_ms: 120000,
result_fingerprint: JSON.stringify({
hash: "abc",
engine_compare_ms: 80000,
vedastro_validate_ms: 30000,
persist_ms: 10000,
}),
},
{
tool: "rectification-set-focus",
status: "failed",
methods: [],
started_at: "2026-09-02T13:29:50.000Z",
elapsed_ms: 400,
error: "invalid_spoken_prompt",
result_fingerprint: JSON.stringify({ reason: "probe_mismatch" }),
},
],
tools: ["rectification-compare-candidates"],
methods: ["d1-rashi"],
started_at: "2026-09-02T13:27:05.000Z",
completed_at: "2026-09-02T13:30:03.000Z",
}),
});
const receipt = await loadV9TurnReceipt(accounting.client, USER_ID, CASE_ID, TURN_ID);
assert.equal(receipt?.toolActivities[0]?.elapsedMs, 120000);
assert.equal(receipt?.toolActivities[0]?.detail?.engine_compare_ms, 80000);
assert.equal(receipt?.toolActivities[1]?.detail?.reason, "probe_mismatch");
});