fix(rectification): render adoption carrier on deterministic choice path
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

This commit is contained in:
Jesse_Chen
2026-08-31 02:15:12 +08:00
parent 79bfa73da3
commit ff5998f184
12 changed files with 298 additions and 17 deletions
@@ -531,7 +531,7 @@ test("conversation and house board reuse the quiet overlay scrollbar", () => {
assert.doesNotMatch(styles, /\.conversation:not\(\.is-empty\):not\(\.is-rectification\) \{[^}]*scrollbar-gutter/);
});
test("time-selection cards appear under the latest settled agent bubble only after offer-candidates", () => {
test("time-selection cards use server adoption state and stay mutually exclusive with choice cards", () => {
const messageLoop = chat.slice(
chat.indexOf("{messages.map((message) => {"),
chat.indexOf("{savedTime &&"),
@@ -543,7 +543,11 @@ test("time-selection cards appear under the latest settled agent bubble only aft
assert.match(chat, /turnOfferedSelection/);
assert.match(chat, /rectification-offer-candidates/);
assert.match(chat, /showLiveChoiceCard = Boolean\(\s*choiceCard[\s\S]*answeredQuestionIds[\s\S]*!busy/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*offeredSelectionOnce[\s\S]*!showLiveChoiceCard[\s\S]*!busy/);
assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\?\.selectionAllowed[\s\S]*candidateResult\?\.canAdopt[\s\S]*!showLiveChoiceCard[\s\S]*!busy[\s\S]*!readonly/);
assert.doesNotMatch(
chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const selectionCardMessageKey")),
/offeredSelectionOnce/,
);
assert.match(
chat,
/selectionCardMessageKey = showSelectionCards && latestSettledAssistant\s*\? latestSettledAssistant\.renderKey\s*: undefined/,
@@ -423,6 +423,74 @@ function familyCollectDossier() {
});
}
function adoptionInference() {
return buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 16 },
{ id: "05:10", time: "05:10", relative_support: 14 },
],
events: [
{ id: "e1", domain: "career", year: 2020, precision: "month" },
{ id: "e2", domain: "career", year: 2024, precision: "day" },
{ id: "e3", domain: "relationship", year: 2024, precision: "month" },
{ id: "e4", domain: "relationship", year: 2024, precision: "day" },
],
probes: [RELOCATION_2015_PROBE],
});
}
function adoptionDossier() {
const inference = adoptionInference();
return dossierFixture({
evidenceCount: 6,
evidence: [
...fourEventRows(),
{
id: "occupation-note",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "occupation_note",
domain: "occupation",
occurred_from: null,
occurred_to: null,
date_precision: "unknown",
summary: "长期从事技术工作",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-08-28T07:38:00.000Z",
},
],
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
representativeTime: "05:00",
decisionReceipt: { inference_state: inference },
}),
conversationSummary: conversationSummaryFixture({
declinedSkippedTopics: [{ target_domain: "family", status: "skipped" }],
activeFocus: activeFocusFixture({
intent: "distinguish_candidates",
targetDomain: "relocation",
targetKind: "home_change",
expectedAnswerSchema: {
choice: {
prompt: RELOCATION_2015_PROBE.question,
options: STYLE_OPTIONS.map((option, index) => ({
key: (["A", "B", "C", "D"] as const)[index]!,
label: option.label,
answer_class: option.answer_class,
})),
},
probe_id: RELOCATION_2015_PROBE.id,
semantic_key: RELOCATION_2015_PROBE.semantic_key,
candidate_split_hash: RELOCATION_2015_PROBE.candidate_split_hash,
},
}),
}),
});
}
function persistChoiceAccounting(
dossier: ReturnType<typeof twoProbeDossier>,
extra: Parameters<typeof fakeAccounting>[0] = {},
@@ -515,6 +583,30 @@ test("synthetic choice taps are not kept as chat user lines", () => {
assert.equal(isStructuredChoiceUserText("没有,那年很顺利"), false);
});
test("last structured choice emits the adoption range and persists the same narration", async () => {
const accounting = persistChoiceAccounting(adoptionDossier());
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: RELOCATION_2015_PROBE.id,
optionId: "A",
expectedRevision: adoptionInference().revision,
});
assert.equal(applied.nextAction.type, "offer_provisional_range");
assert.equal(applied.nextAction.can_adopt, true);
assert.match(applied.narration, /当前可信区间是/);
assert.match(applied.narration, /代表分钟/);
assert.match(applied.narration, /代表分钟只是代表性候选,不是已确认的唯一出生分钟。/);
assert.match(applied.narration, /可以从下面的时间里选一个采用/);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.match(String(turn?.args.p_assistant_message ?? ""), /可以从下面的时间里选一个采用/);
});
test("clicking A applies the choice without invoking a language model", async () => {
const accounting = choiceAccounting();
const applied = await applyRectificationChoice(accounting.client, {
@@ -19,6 +19,7 @@ const camelCaseSnapshot = {
],
overallConfidence: "low",
selectionAllowed: true,
canAdopt: true,
confirmationAllowed: false,
representativeTime: null,
selectedTime: null,
@@ -34,9 +35,24 @@ test("parses the camelCase Candidate Snapshot returned by the Case API", () => {
assert.equal(result.candidates[0]?.candidateId, CANDIDATE_ID);
assert.equal(result.candidates[0]?.relativeSupport, 34);
assert.equal(result.selectionAllowed, true);
assert.equal(result.canAdopt, true);
assert.equal(result.confirmationAllowed, false);
});
test("adoption rendering state parses both API spellings and fails closed when absent", () => {
const snake = parseRectificationCandidateResult({
...camelCaseSnapshot,
can_adopt: true,
});
assert.equal(snake?.canAdopt, true);
const absent = parseRectificationCandidateResult({
...camelCaseSnapshot,
canAdopt: undefined,
});
assert.equal(absent?.canAdopt, false);
});
test("low-confidence near ties never receive a recommendation badge", () => {
const result = parseRectificationCandidateResult(camelCaseSnapshot);
@@ -0,0 +1,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { slimDecisionReceipt } from "../src/lib/rectification-agentic/v9/case-receipt-projection.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
activeFocusFixture,
candidateSnapshotFixture,
conversationSummaryFixture,
dossierFixture,
} from "./rectification-v9-test-support.ts";
test("default Case receipt projection keeps decision and inference state while slimming render-only payloads", () => {
const inference = buildInferenceState({
range_start: "04:53",
range_end: "05:07",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 50 },
{ id: "05:07", time: "05:07", relative_support: 40 },
{ id: "04:00", time: "04:00", relative_support: 10 },
],
events: [],
probes: [],
});
const fullInference = {
...inference,
candidates: inference.candidates.map((candidate) => (
candidate.time === "04:00" ? { ...candidate, status: "eliminated" as const } : candidate
)),
};
const dossier = parseV9CaseDossier(dossierFixture({
latestResult: candidateSnapshotFixture({
representativeTime: "05:00",
decisionReceipt: { inference_state: fullInference },
}),
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
expectedAnswerSchema: {
collect: true,
probe_id: "probe-current",
semantic_key: "current.semantic",
candidate_split_hash: "current-split",
},
}),
}),
}));
assert.ok(dossier);
const decision = { status: "nonterminal", next: "ask" };
const receipt = {
decision,
gates: { can_adopt: false, selection_allowed: false },
inference_state: fullInference,
house_tables_by_time: {
"04:00": { time: "04:00", houses: Array(12).fill({ sign: "x" }) },
"05:00": { time: "05:00", houses: Array(12).fill({ sign: "x" }) },
"05:07": { time: "05:07", houses: Array(12).fill({ sign: "x" }) },
},
house_table: { time: "05:00", houses: Array(12).fill({ sign: "x" }) },
discriminating_event_probes: [
{ id: "probe-current", question: "当前" },
{ id: "probe-old", question: "旧" },
],
evidence_collection_probes: [
{ semantic_key: "current.semantic", question: "当前" },
{ semantic_key: "old.semantic", question: "旧" },
],
};
const slim = slimDecisionReceipt(receipt, dossier!);
assert.deepEqual(slim.decision, decision);
assert.deepEqual(slim.gates, receipt.gates);
assert.deepEqual(slim.inference_state, fullInference);
assert.deepEqual(Object.keys(slim.house_tables_by_time as Record<string, unknown>).sort(), ["05:00", "05:07"]);
assert.deepEqual((slim.discriminating_event_probes as Array<Record<string, unknown>>).map((row) => row.id), ["probe-current"]);
assert.deepEqual((slim.evidence_collection_probes as Array<Record<string, unknown>>).map((row) => row.semantic_key), ["current.semantic"]);
});
@@ -894,7 +894,6 @@ test("nonterminal turn exit deterministically restores a spoken question", async
accounting: ReturnType<typeof fakeAccounting>["client"];
userId: string;
caseId: string;
adoptCarrierReady: boolean;
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
assert.equal(typeof ensureExit, "function");
const accounting = fakeAccounting({
@@ -908,7 +907,6 @@ test("nonterminal turn exit deterministically restores a spoken question", async
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
adoptCarrierReady: false,
});
assert.equal(repaired.persisted, true);
assert.match(repaired.hostNarration ?? "", /当前可信区间/);