a0ce55f066
Showing a choice card is no longer treated as completion. Distinguish probes require real candidate groups, holdout stays out of scoring, and ordinary sessions can finish with a credible range instead of an exact-minute gate. Co-authored-by: Cursor <cursoragent@cursor.com>
409 lines
16 KiB
TypeScript
409 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
import { applyRectificationChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
|
import {
|
|
CHOICE_ACTION,
|
|
STOP_ACTION,
|
|
composeChoiceNarration,
|
|
quoteIsFromAssistantQuestion,
|
|
userVisibleChoiceLine,
|
|
} from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
|
import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts";
|
|
import { projectTurnDecision, TURN_DECISION_MAX_BYTES, turnDecisionByteLength } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
|
import {
|
|
mapModelFinishToErrorCode,
|
|
userFacingRunFailure,
|
|
isIncompleteRunBanner,
|
|
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
|
|
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
|
|
import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
|
import {
|
|
CASE_ID,
|
|
FOCUS_ID,
|
|
SESSION_ID,
|
|
TURN_ID,
|
|
USER_ID,
|
|
activeFocusFixture,
|
|
candidateSnapshotFixture,
|
|
conversationSummaryFixture,
|
|
dossierFixture,
|
|
fakeAccounting,
|
|
receiptHandlers,
|
|
} from "./rectification-v9-test-support.ts";
|
|
|
|
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
|
const QUESTION_ID = "question-1";
|
|
|
|
function inferenceState() {
|
|
return buildInferenceState({
|
|
range_start: "04:50",
|
|
range_end: "05:10",
|
|
candidates: [
|
|
{ id: "05:00", time: "05:00", relative_support: 10 },
|
|
{ id: "05:10", time: "05:10", relative_support: 10 },
|
|
],
|
|
events: [
|
|
{ id: "e1", domain: "education", year: 2016, precision: "month" },
|
|
{ id: "e2", domain: "career", year: 2018, precision: "year" },
|
|
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
|
|
{ id: "e4", domain: "family", year: 2023, precision: "year" },
|
|
],
|
|
probes: [{
|
|
id: "p-cd",
|
|
semantic_key: "career.2015",
|
|
candidate_split_hash: "05:00|05:10",
|
|
domain: "career",
|
|
year: 2015,
|
|
question: "2016 年前后有没有高考或重要考试发挥失常?",
|
|
candidate_ids: ["05:00", "05:10"],
|
|
expected_outcomes: [
|
|
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
|
|
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
|
|
{ answer_class: "unsure", supports: [], conflicts: [] },
|
|
],
|
|
information_gain: 0.4,
|
|
source: "dasha_boundary",
|
|
}],
|
|
});
|
|
}
|
|
|
|
function choiceDossier() {
|
|
const inference = inferenceState();
|
|
const snapshot = candidateSnapshotFixture();
|
|
Object.assign(snapshot.decision_receipt, { inference_state: inference });
|
|
return dossierFixture({
|
|
latestResult: snapshot,
|
|
conversationSummary: conversationSummaryFixture({
|
|
activeFocus: activeFocusFixture({
|
|
expectedAnswerSchema: {
|
|
choice: {
|
|
prompt: "2016 年前后,有没有明显高考或重要考试发挥失常?",
|
|
option_a: "是,大概就在那段时间",
|
|
option_b: "有类似,但年份不对或不够重大",
|
|
option_c: "没有明显发生",
|
|
option_d: "不记得 / 不确定",
|
|
},
|
|
probe_id: "p-cd",
|
|
semantic_key: "career.2015",
|
|
},
|
|
}),
|
|
}),
|
|
});
|
|
}
|
|
|
|
function choiceAccounting(overrides: Parameters<typeof fakeAccounting>[0] = {}) {
|
|
const receipts = new Map<string, Record<string, unknown>>();
|
|
return fakeAccounting({
|
|
...receiptHandlers,
|
|
get_agentic_rectification_case_dossier: () => choiceDossier(),
|
|
apply_agentic_rectification_choice_action: (_fn, args) => {
|
|
const existing = receipts.get(String(args.p_action_id));
|
|
if (existing) return { ...existing, idempotent: true };
|
|
const receipt = {
|
|
action_id: args.p_action_id,
|
|
status: "applied",
|
|
idempotent: false,
|
|
question_id: args.p_question_id,
|
|
option_id: args.p_option_id,
|
|
probe_id: args.p_inference && typeof args.p_inference === "object"
|
|
? (args.p_inference as { probe_id?: string }).probe_id ?? "p-cd"
|
|
: "p-cd",
|
|
revision: Number(args.p_expected_revision) + 1,
|
|
source_quote: args.p_source_quote,
|
|
derived_context: args.p_derived_context,
|
|
narration: args.p_narration,
|
|
focus_status: args.p_focus_status,
|
|
};
|
|
receipts.set(String(args.p_action_id), receipt);
|
|
return receipt;
|
|
},
|
|
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
test("does not force-follow when the reader has scrolled upward", () => {
|
|
assert.equal(isNearBottom(1_000, 100, 400), false);
|
|
assert.equal(shouldFollowLatest(false), false);
|
|
assert.equal(isNearBottom(1_000, 520, 400), true);
|
|
assert.equal(shouldFollowLatest(true), true);
|
|
});
|
|
|
|
test("jump-to-latest stays hidden while a choice card still sits in the overlay band", () => {
|
|
assert.equal(shouldShowJumpToLatest(1_000, 100, 400), true);
|
|
assert.equal(shouldShowJumpToLatest(1_000, 520, 400), false);
|
|
assert.equal(shouldShowJumpToLatest(1_000, 500, 400, true), false);
|
|
assert.equal(shouldShowJumpToLatest(1_000, 200, 400, true), true);
|
|
});
|
|
|
|
test("choice quotes come from the option label, not the assistant question year", () => {
|
|
const question = "2016 年前后,有没有明显高考或重要考试发挥失常?";
|
|
assert.equal(quoteIsFromAssistantQuestion("2016年", question), true);
|
|
assert.equal(quoteIsFromAssistantQuestion("是,大概就在那段时间", question), false);
|
|
const card = {
|
|
question_id: QUESTION_ID,
|
|
method_id: "d5",
|
|
prompt: question,
|
|
why: "",
|
|
varga: null,
|
|
choice_mode: "A/B/C/D" as const,
|
|
options: [
|
|
{ key: "A" as const, label: "是,大概就在那段时间", role: "primary" as const },
|
|
{ key: "B" as const, label: "有类似,但年份不对或不够重大", role: "primary" as const },
|
|
{ key: "C" as const, label: "没有明显发生", role: "primary" as const },
|
|
{ key: "D" as const, label: "不记得 / 不确定", role: "secondary" as const },
|
|
],
|
|
stop_label: "先这样,先看当前范围",
|
|
stop_message: "先这样",
|
|
scoring: true,
|
|
probe_id: "p-cd",
|
|
case_revision: 1,
|
|
focus_id: FOCUS_ID,
|
|
};
|
|
assert.equal(userVisibleChoiceLine(card, "A"), "A. 是,大概就在那段时间");
|
|
});
|
|
|
|
test("clicking A applies the choice without invoking a language model", async () => {
|
|
const accounting = choiceAccounting();
|
|
const applied = await applyRectificationChoice(accounting.client, {
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
actionId: ACTION_ID,
|
|
action: CHOICE_ACTION,
|
|
focusId: FOCUS_ID,
|
|
questionId: QUESTION_ID,
|
|
probeId: "p-cd",
|
|
optionId: "A",
|
|
expectedRevision: inferenceState().revision,
|
|
});
|
|
assert.equal(applied.applied, true);
|
|
assert.equal(applied.focusId, FOCUS_ID);
|
|
assert.equal(applied.optionId, "A");
|
|
assert.equal(applied.sourceQuote, "是,大概就在那段时间");
|
|
assert.equal(applied.derivedContext.sourceType, "structured_probe_answer");
|
|
assert.deepEqual(applied.derivedContext.referencedDateRange, {
|
|
start: "2015-01-01",
|
|
end: "2015-12-31",
|
|
});
|
|
assert.equal(applied.narration, composeChoiceNarration({
|
|
optionId: "A",
|
|
scoring: true,
|
|
appliedInference: true,
|
|
}));
|
|
const fns = accounting.calls.map((call) => call.fn);
|
|
assert.ok(fns.includes("apply_agentic_rectification_choice_action"));
|
|
assert.ok(fns.includes("append_agentic_rectification_turn"));
|
|
assert.equal(fns.includes("record_agentic_rectification_evidence_batch"), false);
|
|
assert.equal(fns.some((fn) => fn === "create_agentic_rectification_run_attempt" || fn.includes("stream")), false);
|
|
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
|
|
assert.equal(persist?.args.p_focus_id, FOCUS_ID);
|
|
assert.equal(persist?.args.p_focus_status, "resolved");
|
|
assert.equal(persist?.args.p_source_quote, "是,大概就在那段时间");
|
|
assert.notEqual(persist?.args.p_source_quote, "2016年");
|
|
assert.ok(persist?.args.p_inference);
|
|
assert.equal(applied.nextAction.type === "ask_candidate_discriminator"
|
|
|| applied.nextAction.type === "ask_holdout_validation"
|
|
|| applied.nextAction.type === "offer_provisional_range"
|
|
|| applied.nextAction.type === "complete_with_range"
|
|
|| applied.nextAction.type === "ready_to_adopt"
|
|
|| applied.nextAction.type === "ask_fact_collection", true);
|
|
});
|
|
|
|
test("replaying the same actionId does not duplicate the applied receipt", async () => {
|
|
const accounting = choiceAccounting();
|
|
const command = {
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
actionId: ACTION_ID,
|
|
action: CHOICE_ACTION,
|
|
focusId: FOCUS_ID,
|
|
questionId: QUESTION_ID,
|
|
probeId: "p-cd",
|
|
optionId: "A" as const,
|
|
expectedRevision: inferenceState().revision,
|
|
};
|
|
const first = await applyRectificationChoice(accounting.client, command);
|
|
const second = await applyRectificationChoice(accounting.client, command);
|
|
assert.equal(first.idempotent, false);
|
|
assert.equal(second.idempotent, true);
|
|
assert.equal(
|
|
accounting.calls.filter((call) => call.fn === "apply_agentic_rectification_choice_action").length,
|
|
2,
|
|
);
|
|
const secondCall = accounting.calls.filter((call) => call.fn === "apply_agentic_rectification_choice_action").at(-1);
|
|
assert.equal(secondCall?.args.p_action_id, ACTION_ID);
|
|
});
|
|
|
|
test("keeps the applied answer when narration persistence fails", async () => {
|
|
const accounting = choiceAccounting({
|
|
append_agentic_rectification_turn: () => {
|
|
throw new Error("turn_write_failed");
|
|
},
|
|
});
|
|
const applied = await applyRectificationChoice(accounting.client, {
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
actionId: ACTION_ID,
|
|
action: CHOICE_ACTION,
|
|
focusId: FOCUS_ID,
|
|
questionId: QUESTION_ID,
|
|
probeId: "p-cd",
|
|
optionId: "A",
|
|
expectedRevision: inferenceState().revision,
|
|
});
|
|
assert.equal(applied.applied, true);
|
|
assert.equal(applied.status, "applied");
|
|
assert.equal(applied.narrationPersisted, false);
|
|
assert.match(applied.narration, /已记录你的选择/);
|
|
});
|
|
|
|
test("stop_and_review does not write an inference transition", async () => {
|
|
const accounting = choiceAccounting();
|
|
const applied = await applyRectificationChoice(accounting.client, {
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
actionId: ACTION_ID,
|
|
action: STOP_ACTION,
|
|
focusId: FOCUS_ID,
|
|
questionId: QUESTION_ID,
|
|
optionId: "stop",
|
|
expectedRevision: inferenceState().revision,
|
|
});
|
|
assert.equal(applied.optionId, "stop");
|
|
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
|
|
assert.equal(persist?.args.p_inference, null);
|
|
assert.equal(persist?.args.p_focus_status, "skipped");
|
|
});
|
|
|
|
test("turn_decision stays inside the configured byte budget", () => {
|
|
const dossier = parseV9CaseDossier(choiceDossier());
|
|
assert.ok(dossier);
|
|
const projection = projectTurnDecision(dossier);
|
|
assert.equal(projection.projection, "turn_decision");
|
|
const currentQuestion = projection.current_question as { prompt?: string } | null;
|
|
assert.equal(currentQuestion?.prompt, "2016 年前后,有没有明显高考或重要考试发挥失常?");
|
|
assert.ok(turnDecisionByteLength(projection) <= TURN_DECISION_MAX_BYTES);
|
|
assert.ok(!("birth_context" in projection));
|
|
assert.ok(!("baseline_birth_snapshot" in projection));
|
|
});
|
|
|
|
test("truncated and timed-out runs return concrete finish reasons", () => {
|
|
assert.equal(mapModelFinishToErrorCode({
|
|
finishReason: "length",
|
|
aborted: false,
|
|
timedOut: false,
|
|
answerText: "部分回答",
|
|
stepCount: 1,
|
|
maxSteps: 8,
|
|
}), "answer_truncated");
|
|
assert.equal(mapModelFinishToErrorCode({
|
|
finishReason: "stop",
|
|
aborted: true,
|
|
timedOut: true,
|
|
answerText: "",
|
|
stepCount: 1,
|
|
maxSteps: 8,
|
|
}), "run_timeout");
|
|
assert.equal(userFacingRunFailure("answer_truncated"), "模型输出达到上限,状态已记录。");
|
|
assert.equal(userFacingRunFailure("run_timeout"), "服务端运行超时,状态已记录。");
|
|
assert.equal(isIncompleteRunBanner("本轮处理未完成,请稍后再试"), true);
|
|
assert.equal(isIncompleteRunBanner("已记录你的选择,并更新了候选比较。"), false);
|
|
});
|
|
|
|
test("the public agent route treats structured choice as a non-model command", () => {
|
|
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
|
const start = route.indexOf("if (isStructuredChoice)");
|
|
const end = route.indexOf("const selectedModel", start);
|
|
assert.ok(start >= 0 && end > start);
|
|
const block = route.slice(start, end);
|
|
assert.match(block, /applyRectificationChoice\(accounting/);
|
|
assert.doesNotMatch(block, /runV9AgentTurn/);
|
|
assert.doesNotMatch(block, /getRectificationV9Agent/);
|
|
assert.doesNotMatch(block, /authorizeUsage/);
|
|
assert.match(route, /"answer_choice"/);
|
|
assert.match(route, /"stop_and_review"/);
|
|
assert.match(route, /export const maxDuration = 240/);
|
|
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
|
assert.match(chat, /isPersistedFocusId\(focusId\)/);
|
|
assert.match(chat, /focusId,/);
|
|
assert.match(chat, /回到最新/);
|
|
assert.match(chat, /followTailRef\.current/);
|
|
});
|
|
|
|
test("rectification attempt timeout stays under the agent route budget", () => {
|
|
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
|
|
const regenerate = readFileSync(
|
|
new URL("../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(agentRun, /RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000/);
|
|
assert.match(agentRun, /RECTIFICATION_AGENT_ROUTE_MAX_DURATION_S = 240/);
|
|
assert.match(regenerate, /export const maxDuration = 240/);
|
|
assert.ok(210_000 < 240_000);
|
|
assert.match(agentRun, /RETRYABLE_ERROR_CODES = new Set\(\[/);
|
|
assert.doesNotMatch(agentRun, /stale_question/);
|
|
assert.doesNotMatch(agentRun, /revision_conflict/);
|
|
});
|
|
|
|
test("derived questionId is an audit label, not the choice identity", async () => {
|
|
const accounting = choiceAccounting();
|
|
const applied = await applyRectificationChoice(accounting.client, {
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
actionId: ACTION_ID,
|
|
action: CHOICE_ACTION,
|
|
focusId: FOCUS_ID,
|
|
questionId: "derived-from-stem:not-the-db-row",
|
|
probeId: "p-cd",
|
|
optionId: "A",
|
|
expectedRevision: inferenceState().revision,
|
|
});
|
|
assert.equal(applied.applied, true);
|
|
assert.equal(applied.focusId, FOCUS_ID);
|
|
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
|
|
assert.equal(persist?.args.p_focus_id, FOCUS_ID);
|
|
assert.equal(persist?.args.p_question_id, QUESTION_ID);
|
|
});
|
|
|
|
test("a mismatched focusId is stale_question even when questionId matches", async () => {
|
|
await assert.rejects(
|
|
() => applyRectificationChoice(choiceAccounting().client, {
|
|
userId: USER_ID,
|
|
caseId: CASE_ID,
|
|
sessionId: SESSION_ID,
|
|
actionId: ACTION_ID,
|
|
action: CHOICE_ACTION,
|
|
focusId: "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd",
|
|
questionId: QUESTION_ID,
|
|
probeId: "p-cd",
|
|
optionId: "A",
|
|
expectedRevision: inferenceState().revision,
|
|
}),
|
|
(error: unknown) => {
|
|
assert.ok(error instanceof RectificationToolServiceError);
|
|
assert.match(error.message, /stale_question/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("choice identity SQL keys stale_question to inactive focus, not question_id", () => {
|
|
const migration = readFileSync(
|
|
new URL("../supabase/migrations/20260826020000_rectification_choice_focus_identity.sql", import.meta.url),
|
|
"utf8",
|
|
);
|
|
assert.match(migration, /v_focus\.status is distinct from 'active'/);
|
|
assert.doesNotMatch(migration, /v_focus\.question_id is distinct from/);
|
|
assert.doesNotMatch(migration, /p_question_id is distinct from v_focus\.question_id/);
|
|
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
|
assert.match(route, /focusId: z\.string\(\)\.uuid\(\)/);
|
|
assert.match(route, /选择题请求缺少 actionId、focusId 或 expectedRevision/);
|
|
});
|