fix(rectification): run adopt narration on the last discriminator click
Independent Staging Quality Gate / validate (push) Failing after 9m46s
Independent Staging Quality Gate / publish (push) Has been skipped

The early-exit reused the pre-click dossier, so the agent never ran on the common path. Use this-turn decision, log agent vs template outcome, and time out after 8s.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-04 14:59:15 +08:00
co-authored by Cursor
parent e18bd25b19
commit 13dded9fff
8 changed files with 353 additions and 45 deletions
@@ -8,8 +8,13 @@ import type { ConflictProbe, InferenceState } from "../src/lib/rectification-age
import {
adoptDeliveryFacts,
validateAdoptNarration,
type AdoptDeliveryFacts,
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
import { createAdoptNarrationWriter } from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts";
import {
ADOPT_NARRATION_INSTRUCTIONS,
createAdoptNarrationWriter,
deliverAdoptNarration,
} from "../src/lib/rectification-agentic/v9/adopt-narration-agent.ts";
import {
applyCollectFocusDenial,
persistNextInterviewAfterChoice,
@@ -426,11 +431,34 @@ function fourteenProbeState(): InferenceState {
};
}
function preClickFourteenProbeState(): InferenceState {
const last = ANSWERED[ANSWERED.length - 1]!;
const state = fourteenProbeState();
return {
...state,
revision: 5,
answered_probes: state.answered_probes.filter((item) => item.probe_id !== last.id),
candidates: state.candidates.map((item) => {
if (item.time === "05:00") {
return { ...item, prior_score: 23, posterior_score: 23 };
}
if (item.time === "05:06") {
return { ...item, prior_score: 16, posterior_score: 16 };
}
if (item.time === "04:53") {
return { ...item, prior_score: 7, posterior_score: 7 };
}
return item;
}),
};
}
function caseDossier(extra?: {
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
acceptedTime?: string | null;
state?: InferenceState;
}): DecisionDossier {
const state = fourteenProbeState();
const state = extra?.state ?? fourteenProbeState();
return {
evidence: EVIDENCE,
conversationSummary: {
@@ -700,6 +728,14 @@ test("adopt narration agent keeps in-fact copy and fail-closes the rest", async
})(facts, fallback);
assert.equal(unknownSupport, fallback);
assert.equal(validateAdoptNarration("相对支持度 99,先用 05:00。", facts).ok, false);
const deniedPromise = "这不是确认的分钟,先用 05:00。";
assert.equal(validateAdoptNarration(deniedPromise, facts).ok, false);
const denied = await createAdoptNarrationWriter({
generateText: async () => deniedPromise,
})(facts, fallback);
assert.equal(denied, fallback);
assert.match(ADOPT_NARRATION_INSTRUCTIONS, /不要出现/);
});
test("three adopt entry points call the model once on first ready_to_adopt and not after accept", async () => {
@@ -709,36 +745,79 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
assert.match(route, /applyCollectFocusDenial\([\s\S]*narrateAdopt/);
assert.match(route, /persistNextInterviewIfIdle\(\{[\s\S]*narrateAdopt/);
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const persistFn = source.slice(
source.indexOf("export async function persistNextInterviewAfterChoice"),
source.indexOf("async function persistFocusAfterChoice"),
);
assert.doesNotMatch(persistFn, /decideFromDossier\(/);
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const preClick = caseDossier({ state: preClickFourteenProbeState() });
const preClickDecision = decideFromDossier(preClick, { birthDate: "1997-08-08" });
assert.notEqual(preClickDecision.precisionStage, "ready_to_adopt");
assert.ok(preClickDecision.probe);
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
function withCounter() {
let calls = 0;
const seen: AdoptDeliveryFacts[] = [];
const narrateAdopt = createAdoptNarrationWriter({
generateText: async () => {
generateText: async (facts) => {
calls += 1;
seen.push(facts);
return kept;
},
});
return { narrateAdopt, count: () => calls };
return { narrateAdopt, count: () => calls, seen };
}
const afterChoice = withCounter();
const accountingA = adoptAccounting(dossier);
const accountingA = adoptAccounting(preClick);
await persistNextInterviewAfterChoice({
accounting: accountingA.client,
userId: USER_ID,
caseId: CASE_ID,
dossier,
dossier: preClick,
decisionState: fourteenProbeState(),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
narrateAdopt: afterChoice.narrateAdopt,
});
assert.equal(afterChoice.count(), 1);
assert.equal(afterChoice.seen[0]?.representative_minute, "05:00");
assert.equal(afterChoice.seen[0]?.precision_stage, "ready_to_adopt");
assert.deepEqual(
afterChoice.seen[0]?.active_candidates.slice(0, 3).map((item) => [item.time, item.relative_support]),
[["05:00", 21], ["05:06", 18], ["04:53", 9]],
);
assertNoFocusWrite(accountingA);
const invalidChoice = withCounter();
const invalidWriter = createAdoptNarrationWriter({
generateText: async (facts) => {
invalidChoice.seen.push(facts);
return "更像 04:58,不要再问了。";
},
});
const invalidAccounting = adoptAccounting(preClick);
const invalid = await persistNextInterviewAfterChoice({
accounting: invalidAccounting.client,
userId: USER_ID,
caseId: CASE_ID,
dossier: preClick,
decisionState: fourteenProbeState(),
nextAction: publicNextAction(decision),
decision,
birthDate: "1997-08-08",
narrateAdopt: invalidWriter,
});
assertAdoptTemplate(invalid.hostNarration);
assert.match(invalid.hostNarration, /05:00/);
assert.doesNotMatch(invalid.hostNarration, /04:58/);
const afterDenial = withCounter();
let loads = 0;
const accountingB = fakeAccounting({
@@ -807,6 +886,63 @@ test("three adopt entry points call the model once on first ready_to_adopt and n
assert.equal(accepted.count(), 0);
});
test("adopt narration diagnostics distinguish agent, validation, error, and not-ready", async () => {
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。";
const agent = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => kept,
});
assert.equal(agent.adopt_narration, "agent");
assert.match(agent.text, /05:00/);
const invalid = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => "更像 04:58,不要再问了。",
});
assert.equal(invalid.adopt_narration, "template:unknown_minute");
assert.equal(invalid.text, fallback);
const errored = await deliverAdoptNarration({
facts,
fallback,
generateText: async () => {
throw new Error("model down");
},
});
assert.equal(errored.adopt_narration, "template:model_error");
assert.equal(errored.text, fallback);
const notReady = await deliverAdoptNarration({
facts: { ...facts, already_accepted: true },
fallback,
generateText: async () => kept,
});
assert.equal(notReady.adopt_narration, "template:not_ready");
assert.equal(notReady.text, fallback);
});
test("adopt narration times out to the template without throwing", async () => {
const dossier = caseDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
const facts = adoptDeliveryFacts(decision, dossier);
const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。";
const timed = await deliverAdoptNarration({
facts,
fallback,
timeoutMs: 30,
generateText: async () => new Promise(() => {}),
});
assert.equal(timed.adopt_narration, "template:model_error");
assert.equal(timed.text, fallback);
});
test("applyCollectFocusDenial on the family collect uses the same adopt template", async () => {
const dossier = caseDossier();
let loads = 0;