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
parent e18bd25b19
commit 13dded9fff
8 changed files with 353 additions and 45 deletions
@@ -17,6 +17,46 @@ import {
type AdoptNarrationWriter,
} from "./adopt-narration.ts";
export const ADOPT_NARRATION_TIMEOUT_MS = 8_000;
export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`;
export type AdoptNarrationOutcome =
| "agent"
| `template:${string}`;
export type AdoptNarrationDelivery = Readonly<{
text: string;
adopt_narration: AdoptNarrationOutcome;
}>;
function composedAbortSignal(
signal: AbortSignal | undefined,
timeoutMs: number,
): AbortSignal {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
function whenAborted(signal: AbortSignal): Promise<never> {
return new Promise((_, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new Error("aborted"));
return;
}
signal.addEventListener("abort", () => {
reject(signal.reason ?? new Error("aborted"));
}, { once: true });
});
}
export function logAdoptNarrationOutcome(outcome: AdoptNarrationOutcome): void {
console.info(`[rectification-v9] adopt_narration=${outcome}`);
}
export async function generateAdoptNarrationText(
model: ResolvedLanguageModel,
facts: AdoptDeliveryFacts,
@@ -26,10 +66,7 @@ export async function generateAdoptNarrationText(
id: `rectification-adopt-narration-${model.id}`,
name: "Rectification Adopt Narration",
model: model.model,
instructions: `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。
用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些事核对。
只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。
不得承诺“确认”或“精确”,不得再提问,不要写“可以从下面选一个先用着”。`,
instructions: ADOPT_NARRATION_INSTRUCTIONS,
});
const result = await agent.generate([{
role: "user",
@@ -59,48 +96,83 @@ export async function deliverAdoptNarration(input: {
fallback: string;
model?: ResolvedLanguageModel | null;
signal?: AbortSignal;
timeoutMs?: number;
generateText?: (
facts: AdoptDeliveryFacts,
signal?: AbortSignal,
) => Promise<string>;
}): Promise<string> {
if (!shouldWriteAdoptNarration(input.facts)) return input.fallback;
}): Promise<AdoptNarrationDelivery> {
if (!shouldWriteAdoptNarration(input.facts)) {
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
}
const generate = input.generateText ?? (input.model
? (facts: AdoptDeliveryFacts, signal?: AbortSignal) => (
generateAdoptNarrationText(input.model as ResolvedLanguageModel, facts, signal)
)
: null);
if (!generate) return input.fallback;
if (!generate) {
const delivery = { text: input.fallback, adopt_narration: "template:not_ready" as const };
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
}
const signal = composedAbortSignal(
input.signal,
input.timeoutMs ?? ADOPT_NARRATION_TIMEOUT_MS,
);
try {
const text = await generate(input.facts, input.signal);
const text = await Promise.race([
generate(input.facts, signal),
whenAborted(signal),
]);
const checked = validateAdoptNarration(text, input.facts);
if (!checked.ok) return input.fallback;
return appendAdoptCue(checked.text);
if (!checked.ok) {
const delivery = {
text: input.fallback,
adopt_narration: `template:${checked.reason}` as const,
};
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
}
const delivery = {
text: appendAdoptCue(checked.text),
adopt_narration: "agent" as const,
};
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
} catch {
return input.fallback;
const delivery = { text: input.fallback, adopt_narration: "template:model_error" as const };
logAdoptNarrationOutcome(delivery.adopt_narration);
return delivery;
}
}
export function createAdoptNarrationWriter(input: {
model?: ResolvedLanguageModel | null;
signal?: AbortSignal;
timeoutMs?: number;
resolveModel?: () => Promise<ResolvedLanguageModel | null>;
generateText?: (
facts: AdoptDeliveryFacts,
signal?: AbortSignal,
) => Promise<string>;
}): AdoptNarrationWriter {
return async (facts, fallback) => deliverAdoptNarration({
facts,
fallback,
model: input.model,
signal: input.signal,
generateText: input.generateText ?? (input.resolveModel
? async (nextFacts, signal) => {
const model = input.model ?? await input.resolveModel!();
if (!model) throw new Error("adopt_narration_model_unavailable");
return generateAdoptNarrationText(model, nextFacts, signal);
}
: undefined),
});
return async (facts, fallback) => {
const delivered = await deliverAdoptNarration({
facts,
fallback,
model: input.model,
signal: input.signal,
timeoutMs: input.timeoutMs,
generateText: input.generateText ?? (input.resolveModel
? async (nextFacts, signal) => {
const model = input.model ?? await input.resolveModel!();
if (!model) throw new Error("adopt_narration_model_unavailable");
return generateAdoptNarrationText(model, nextFacts, signal);
}
: undefined),
});
return delivered.text;
};
}
@@ -141,6 +141,23 @@ function adoptHostNarration(input: {
}), input.receipt);
}
function dossierWithCurrentInference(
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"],
state: InferenceState | null,
): Parameters<typeof decideAfterInferenceChange>[0]["dossier"] {
if (!state) return dossier;
return {
...dossier,
latestResult: {
...(dossier.latestResult ?? {}),
decisionReceipt: {
...(dossier.latestResult?.decisionReceipt ?? {}),
inference_state: state,
},
},
};
}
export type ApplyChoiceCommand = Readonly<{
userId: string;
caseId: string;
@@ -390,6 +407,7 @@ export async function persistNextInterviewAfterChoice(input: {
dossier: Parameters<typeof decideAfterInferenceChange>[0]["dossier"];
decisionState: InferenceState | null;
nextAction: ReturnType<typeof publicNextAction>;
decision?: ReturnType<typeof decideAfterInferenceChange>;
birthDate?: string | null;
askedTurnId?: string | null;
narrateAdopt?: AdoptNarrationWriter;
@@ -412,6 +430,13 @@ export async function persistNextInterviewAfterChoice(input: {
decisionReceipt: input.decisionState ? { inference_state: input.decisionState } : null,
};
const birthDate = input.birthDate ?? null;
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
const decision = input.decision ?? decideAfterInferenceChange({
dossier: input.dossier,
state: input.decisionState,
userStopped: false,
birthDate,
});
const catalog = rectificationFollowupCatalog(latest, input.dossier.evidence);
const sessionOutcome = typeof input.nextAction.session_outcome === "string"
? input.nextAction.session_outcome as SessionOutcomeKind
@@ -435,12 +460,11 @@ export async function persistNextInterviewAfterChoice(input: {
followup,
methods: plan.methods,
})) {
const decision = decideFromDossier(input.dossier, { birthDate });
const facts = adoptDeliveryFacts(decision, input.dossier);
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
dossier: input.dossier,
dossier: liveDossier,
decision,
receipt: input.dossier.latestResult?.decisionReceipt,
receipt: liveDossier.latestResult?.decisionReceipt,
});
return {
hostNarration: input.narrateAdopt
@@ -660,7 +684,8 @@ export async function applyCollectFocusDenial(
},
},
}, "declined");
const nextAction = publicNextAction(decideFromDossier(withDeclined, { birthDate }));
const decision = decideFromDossier(withDeclined, { birthDate });
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
accounting,
userId: input.userId,
@@ -668,6 +693,7 @@ export async function applyCollectFocusDenial(
dossier: withDeclined,
decisionState: previousInferenceFromReceipt(withDeclined.latestResult?.decisionReceipt ?? null),
nextAction,
decision,
birthDate,
narrateAdopt: input.narrateAdopt,
});
@@ -793,6 +819,7 @@ export async function persistNextInterviewIfIdle(input: {
dossier,
decisionState: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
nextAction,
decision,
birthDate,
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
@@ -931,6 +958,7 @@ async function persistApplied(
dossier: dossierWithClosedFocus(input.dossier, input.focusStatus),
decisionState: input.decisionState ?? null,
nextAction,
decision: nextDecision,
birthDate,
narrateAdopt: command.narrateAdopt,
});
@@ -946,8 +974,9 @@ ${nextInterview.hostNarration}`;
nextInterviewPersisted = true;
}
}
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState ?? null);
const adoptionFacts = nextAction.can_adopt
? adoptDeliveryFacts(nextDecision, input.dossier)
? adoptDeliveryFacts(nextDecision, liveDossier)
: null;
const adoptionNarration = nextAction.can_adopt
? withProspectiveWindows(deliveryAdoptNarration({
@@ -956,7 +985,7 @@ ${nextInterview.hostNarration}`;
openingRange: openingRangeFromDossier(input.dossier),
stopReason: nextAction.stop_reason,
stopExplain: adoptionFacts ? templateStopExplain(adoptionFacts) : null,
}), input.dossier.latestResult?.decisionReceipt)
}), liveDossier.latestResult?.decisionReceipt)
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
const completedRangeNarration = nextAction.type === "complete_with_range"