fix(rectification): guarantee nonterminal turn exits
Independent Staging Quality Gate / validate (push) Successful in 13m25s
Independent Staging Quality Gate / publish (push) Successful in 9m26s

This commit is contained in:
Jesse_Chen
2026-09-01 13:35:51 +08:00
parent 15877069fc
commit e404b6f42b
13 changed files with 966 additions and 125 deletions
@@ -709,12 +709,15 @@ test("answering a discriminator persists the next dated card so GET still has a
assert.equal(schema.semantic_key, EDUCATION_2014_PROBE.semantic_key);
assert.match(schema.choice?.prompt ?? "", /2014/);
assert.doesNotMatch(schema.choice?.prompt ?? "", /2015/);
assert.equal(schema.choice?.prompt, "2014 年前后,有没有升学、转学或换学习环境?");
assert.equal(applied.nextInterviewPersisted, true);
assert.equal(applied.nextChoiceReady, true);
assert.equal(shouldContinueAfterStructuredChoice(applied.nextAction, applied), false);
assert.equal(applied.narration, "接下来请点选下面这一问。");
const persistedPrompt = schema.choice?.prompt;
assert.ok(persistedPrompt);
assert.equal(applied.narration, persistedPrompt);
const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(turn?.args.p_assistant_message, "接下来请点选下面这一问。");
assert.equal(turn?.args.p_assistant_message, persistedPrompt);
const refreshed = parseV9CaseDossier(twoProbeDossier());
assert.ok(refreshed);
@@ -1104,7 +1107,7 @@ test("structured choice narration never persists a fake loading state", () => {
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);
const end = route.indexOf("const resolvedModel", start);
assert.ok(start >= 0 && end > start);
const block = route.slice(start, end);
assert.match(block, /applyRectificationChoice\(accounting/);
@@ -1202,6 +1205,42 @@ test("choice identity SQL keys stale_question to inactive focus, not question_id
assert.match(route, /选择题请求缺少 actionId、focusId 或 expectedRevision/);
});
test("choice followup narration uses the server-owned prompt", () => {
const prompt = "2014 年前后,有没有升学、转学或换学习环境?";
const followup: MethodFollowup = {
method_id: "d5_education",
intent: "distinguish_candidates",
ask_theme: "education_style",
domain: "education",
kind_hint: null,
user_prompt_hint: "server-owned choice",
must_not_label: false,
choice_frame: {
question_id: "education.2014",
method_id: "d5_education",
period: "2014 年前后",
prompt,
varga: null,
why: "用于区分候选时间",
option_a_hint: "明确发生且时间吻合",
option_b_hint: "发生过但程度较弱",
neither_label: "明确没有发生",
unsure_label: "这段记不清楚",
option_a_answer_class: "yes",
option_b_answer_class: "weak_yes",
option_c_answer_class: "no",
option_d_answer_class: "unsure",
choice_mode: "A/B/C/D",
stop_label: "先这样,先看当前范围",
stop_message: "先这样",
scoring: true,
},
source: "event_probe",
};
assert.equal(spokenFollowupForUser(followup), prompt);
});
test("degraded spoken collect strips discriminator identity", () => {
const followup: MethodFollowup = {
method_id: "dasha_events",
@@ -639,7 +639,7 @@ test("occupation collect denial declines the focus and advances coverage to hora
test("message and opening turns persist the next followup so current_question is not null", async () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
assert.match(afterRun, /if \(action === "message" \|\| action === "opening"\)/);
assert.doesNotMatch(afterRun, /if \(action === "message" \|\| action === "opening"\)/);
assert.match(afterRun, /persistNextInterviewIfIdle/);
assert.match(afterRun, /ensureNonTerminalTurnExit/);
assert.ok(afterRun.indexOf("result.ok") < afterRun.indexOf("persistNextInterviewIfIdle"));
@@ -900,11 +900,13 @@ test("nonterminal turn exit deterministically restores a spoken question", async
caseId: string;
}) => Promise<{ hostNarration: string | null; persisted: boolean }>);
assert.equal(typeof ensureExit, "function");
let activeFocus: Record<string, unknown> | null = null;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier()),
get_agentic_rectification_case_dossier: () => rpcDossier(occupationDossier(), activeFocus),
set_agentic_rectification_conversation_focus: (_fn, args) => {
return { focus: createdFocusFromArgs(args), idempotent: false };
activeFocus = createdFocusFromArgs(args);
return { focus: activeFocus, idempotent: false };
},
});
const repaired = await ensureExit!({
@@ -8,11 +8,23 @@ import {
publicDecisionFields,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
import { inspectDiscriminatorProbes, selectDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
inspectDiscriminatorProbes,
selectDiscriminatorProbe,
type CandidateDiscriminatorProbe,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
applyChoiceWithoutEvidence,
nakshatraBoundaryProbe,
stampChoiceSchemaWithProbe,
withNakshatraBoundaryProbe,
} from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { buildMethodFollowupPlan, conversationalSessionOutcome } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { awaitTurnExitBeforeResponse, finalizeSuccessfulTurnExit } from "../src/lib/rectification-agentic/v9/turn-exit.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { projectCurrentQuestion, projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
evidenceLedgerFingerprint,
parseV9CaseDossier,
@@ -20,9 +32,14 @@ import {
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { safeCaseProjection } from "../src/mastra/rectification-v9-tools.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
} from "./rectification-v9-test-support.ts";
const SEPARATED = [
@@ -210,6 +227,391 @@ function readSource(relative: string) {
return readFileSync(new URL(relative, import.meta.url), "utf8");
}
const ADVANCING_RECTIFICATION_ACTIONS = [
"opening",
"message",
"answer_choice",
"stop_and_review",
] as const;
function routeExitContract(route: string, turnExit: string) {
const immediateStart = route.indexOf("if (immediateResponse)");
const immediateEnd = route.indexOf("const requestTime", immediateStart);
const immediateExit = immediateStart >= 0 && immediateEnd > immediateStart
? route.slice(immediateStart, immediateEnd)
: "";
const awaitIndex = immediateExit.indexOf("await awaitTurnExitBeforeResponse");
const returnIndex = immediateExit.indexOf("return response", awaitIndex);
const immediateAwaited = awaitIndex >= 0
&& returnIndex > awaitIndex
&& /finalizeSuccessfulTurnExit\s*\(/.test(immediateExit.slice(awaitIndex, returnIndex));
const readOnlyExplicitlyExcluded = /input\.action\s*===\s*["']read_only["']/.test(turnExit);
const resultStart = route.indexOf("if (!result.ok)");
const doneIndex = route.indexOf('send({ type: "done"', resultStart);
const streamedSuccess = resultStart >= 0 && doneIndex > resultStart
? route.slice(resultStart, doneIndex)
: "";
const streamingAwaited = /await\s+finalizeSuccessfulTurnExit\s*\(/.test(streamedSuccess);
const executionBody = /RECTIFICATION_ACTION_EXECUTION\s*=\s*\{([\s\S]*?)\}\s*as const/.exec(turnExit)?.[1] ?? "";
const executionActions = [...executionBody.matchAll(/^\s*([a-z_]+):/gm)].map((match) => match[1]);
return { executionActions, immediateAwaited, readOnlyExplicitlyExcluded, streamingAwaited };
}
test("nonterminal invariant 1: every advancing action exits through an awaited common gate", () => {
const route = readSource("../src/app/api/rectification/agent/route.ts");
const turnExit = readSource("../src/lib/rectification-agentic/v9/turn-exit.ts");
const declared = /action:\s*z\.enum\(\[([^\]]+)\]\)/.exec(route)?.[1]
?.match(/["']([^"']+)["']/g)
?.map((value) => value.slice(1, -1)) ?? [];
assert.deepEqual(
declared.filter((action) => action !== "read_only"),
[...ADVANCING_RECTIFICATION_ACTIONS],
"new advancing actions must enter this invariant instead of silently bypassing the exit gate",
);
const contract = routeExitContract(route, turnExit);
const failures: string[] = [];
if (contract.executionActions.join(",") !== declared.join(",")) {
failures.push("schema actions and execution declarations differ");
}
for (const action of ADVANCING_RECTIFICATION_ACTIONS) {
const immediate = action === "message" || action === "answer_choice" || action === "stop_and_review";
const streamed = action === "opening" || action === "message";
if (immediate && !contract.immediateAwaited) {
failures.push(`${action}: HTTP 200 response can return without awaiting the common exit gate`);
}
if (streamed && !contract.streamingAwaited) {
failures.push(`${action}: stream can emit done before awaiting the common exit gate`);
}
}
if (!contract.readOnlyExplicitlyExcluded) {
failures.push("read_only: common gate exclusion is not explicit at the shared exit");
}
assert.deepEqual(failures, []);
});
test("nonterminal invariant 2: exhausted probes take the next available server-owned exit", () => {
const nakshatraProbe: CandidateDiscriminatorProbe = {
probeId: "probe:nakshatra-boundary:incident",
candidateSetVersion: "incident",
question: "哪一组日常节奏更像你?",
informationGain: 0.01,
semanticKey: "nakshatra-boundary:incident",
candidateSplitHash: "nakshatra-boundary:incident:early|late",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:07"] },
{ outcomeId: "weak_yes", supportsCandidateIds: ["05:07"], conflictsCandidateIds: ["05:00"] },
{ outcomeId: "no", supportsCandidateIds: [], conflictsCandidateIds: [] },
{ outcomeId: "unsure", supportsCandidateIds: [], conflictsCandidateIds: [] },
],
sourceFeatures: [],
domain: "appearance",
year: null,
choiceKind: "varga_style",
styleOptions: [
{ label: "A 组:直接、外放", answerClass: "yes" },
{ label: "B 组:克制、内敛", answerClass: "weak_yes" },
{ label: "两组都不太像", answerClass: "no" },
{ label: "一时说不好", answerClass: "unsure" },
],
};
const cases = [
{
name: "holdout",
holdoutValidation: "not_started" as const,
datedMethodCollectOpen: false,
nakshatraBoundaryProbe: null,
nextAction: "ask_holdout_validation",
},
{
name: "dated collect",
holdoutValidation: "unavailable" as const,
datedMethodCollectOpen: true,
nakshatraBoundaryProbe: null,
nextAction: "ask_fact_collection",
},
{
name: "nakshatra boundary",
holdoutValidation: "unavailable" as const,
datedMethodCollectOpen: false,
nakshatraBoundaryProbe: nakshatraProbe,
nextAction: "ask_candidate_discriminator",
},
{
name: "explicit range exit",
holdoutValidation: "unavailable" as const,
datedMethodCollectOpen: false,
nakshatraBoundaryProbe: null,
nextAction: "offer_provisional_range",
},
];
for (const fixture of cases) {
const decision = decideRectification({
methodCoverageAll: true,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: [
{ time: "05:00", score: 34 },
{ time: "05:06", score: 33 },
{ time: "05:07", score: 33 },
],
discriminatorProbe: null,
holdoutValidation: fixture.holdoutValidation,
datedMethodCollectOpen: fixture.datedMethodCollectOpen,
nakshatraBoundaryProbe: fixture.nakshatraBoundaryProbe,
userStopped: false,
datedEventCount: 7,
datedDomainCount: 3,
engineCeiling: ENGINE_OPEN,
});
assert.equal(decision.separation.sufficient, false, fixture.name);
assert.equal(decision.canAdopt, false, fixture.name);
assert.equal(decision.nextAction, fixture.nextAction, fixture.name);
}
});
test("nakshatra boundary is a consumable four-answer probe and is not asked twice", () => {
const base = buildInferenceState({
range_start: "04:51",
range_end: "05:15",
candidates: [
{ id: "04:51", time: "04:51", relative_support: 34 },
{ id: "05:03", time: "05:03", relative_support: 33 },
{ id: "05:15", time: "05:15", relative_support: 32 },
],
events: Array.from({ length: 7 }, (_, index) => ({
id: `incident-event-${index + 1}`,
domain: ["career", "relationship", "education"][index % 3]!,
year: 2016 + index,
precision: "year" as const,
})),
probes: [],
});
const boundary = {
near_boundary: true,
user_meaning: "平时做决定时,哪一组节奏更像你?",
options: [
{ key: "A" as const, time_bias: "earlier" as const, traits: ["直接", "行动快"] },
{ key: "B" as const, time_bias: "later" as const, traits: ["克制", "先观察"] },
],
};
const probe = nakshatraBoundaryProbe(base, boundary);
assert.ok(probe);
assert.deepEqual(probe.style_options?.map((item) => item.answer_class), ["yes", "weak_yes", "no", "unsure"]);
assert.match(probe.style_options?.[0]?.label ?? "", /直接.*行动快/);
assert.match(probe.style_options?.[1]?.label ?? "", /克制.*先观察/);
const withProbe = withNakshatraBoundaryProbe(base, boundary);
assert.ok(withProbe);
const schema = stampChoiceSchemaWithProbe({
choice: {
options: [
{ key: "A", answer_class: "yes" },
{ key: "B", answer_class: "weak_yes" },
{ key: "C", answer_class: "no" },
{ key: "D", answer_class: "unsure" },
],
},
}, withProbe, "nakshatra-boundary:incident", {
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
});
const applied = applyChoiceWithoutEvidence(withProbe, {
choiceKey: "A",
schema,
questionId: "nakshatra-boundary:incident",
domain: "appearance",
classifiedFrom: "choice",
});
assert.equal(applied.applied, true);
assert.equal(applied.reason, "applied");
assert.equal(applied.state.answered_probes.at(-1)?.probe_id, probe.id);
assert.equal(applied.state.answered_probes.at(-1)?.answer_class, "yes");
const deduplicated = withNakshatraBoundaryProbe(applied.state, boundary);
assert.ok(deduplicated);
assert.equal(deduplicated.probes.some((item) => item.source === "nakshatra_boundary"), false);
});
test("nonterminal invariant 3: answer_choice response waits until incident fallback focus is persisted", async () => {
const evidence = Array.from({ length: 7 }, (_, index) => ({
id: `44444444-4444-4444-8444-${String(index + 10).padStart(12, "0")}`,
source_turn_id: "33333333-3333-4333-8333-333333333333",
subject: "self",
event_kind: "dated_event",
domain: ["career", "relationship", "education"][index % 3]!,
occurred_from: `${2016 + index}-01-01`,
occurred_to: null,
date_precision: "year",
summary: `incident evidence ${index + 1}`,
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-01T00:00:00.000Z",
}));
const answeredProbes = Array.from({ length: 6 }, (_, index) => ({
id: `incident-answered-${index + 1}`,
semantic_key: `incident.answered.${index + 1}`,
candidate_split_hash: `incident-answered-split-${index + 1}`,
domain: ["career", "relationship", "education"][index % 3]!,
year: 2016 + index,
question: `incident answered question ${index + 1}`,
candidate_ids: ["04:51", "05:03", "05:15"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:51"], conflicts: ["05:15"] },
{ answer_class: "no" as const, supports: ["05:15"], conflicts: ["04:51"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
information_gain: 0.5,
source: "dasha_boundary",
}));
const droppedProbes = Array.from({ length: 5 }, (_, index) => ({
id: `incident-dropped-${index + 1}`,
semantic_key: `varga.d${index + 2}.incident`,
candidate_split_hash: `incident-dropped-split-${index + 1}`,
domain: "career",
year: 0,
question: `yearless varga contrast ${index + 1}`,
candidate_ids: ["04:51", "05:03", "05:15"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:51"], conflicts: ["05:15"] },
{ answer_class: "no" as const, supports: ["05:15"], conflicts: ["04:51"] },
],
information_gain: 1 + index / 10,
source: "varga_contrast",
}));
const incidentInference = buildInferenceState({
range_start: "04:51",
range_end: "05:15",
candidates: [
{ id: "04:51", time: "04:51", relative_support: 34 },
{ id: "05:03", time: "05:03", relative_support: 33 },
{ id: "05:15", time: "05:15", relative_support: 32 },
],
events: evidence.map((item) => ({
id: item.id,
domain: item.domain,
year: Number(item.occurred_from.slice(0, 4)),
precision: "year" as const,
})),
probes: [...answeredProbes, ...droppedProbes],
answered_probes: answeredProbes.map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: "unsure" as const,
classified_from: "choice" as const,
})),
});
const raw = dossierFixture({
evidence,
evidenceCount: evidence.length,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
decisionReceipt: {
inference_state: incidentInference,
diagnostic_quality: { passed: false, margin_percent: 4.476 },
date_sensitivity_retention_rate: 0.2857,
oos_blind_prompts: [
{ domain: "family", user_meaning: "家里有没有结婚、添丁或住院这类记得住时间的事?", used_for_scoring: false },
{ domain: "health_pressure", user_meaning: "有没有记得住时间的健康压力事件?", used_for_scoring: false },
],
nakshatra_boundary: {
near_boundary: true,
user_meaning: "平时做决定时,哪一组节奏更像你?",
options: [
{ key: "A", time_bias: "earlier", traits: ["直接", "行动快"] },
{ key: "B", time_bias: "later", traits: ["克制", "先观察"] },
],
},
},
candidates: [
{ candidate_id: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:51", relative_support: 34, tied_minute_count: 1 },
{ candidate_id: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:03", relative_support: 33, tied_minute_count: 1 },
{ candidate_id: "88888888-8888-4888-8888-888888888883", rank: 3, time: "05:15", relative_support: 32, tied_minute_count: 1 },
],
}),
});
const parsedIncident = parseV9CaseDossier(raw);
assert.ok(parsedIncident);
const decision = decideFromDossier(parsedIncident);
assert.equal(decision.canAdopt, false);
assert.notEqual(decision.nextAction, "offer_provisional_range");
let activeFocus: Record<string, unknown> | null = null;
let releaseWrite!: () => void;
const writeGate = new Promise<void>((resolve) => { releaseWrite = resolve; });
let markWriteStarted!: () => void;
const writeStarted = new Promise<void>((resolve) => { markWriteStarted = resolve; });
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => ({
...raw,
conversation_summary: {
...raw.conversation_summary,
active_focus: activeFocus,
},
}),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: async (_fn, args) => {
markWriteStarted();
await writeGate;
activeFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-09-01T00:00:00.000Z",
resolved_at: null,
};
return { focus: activeFocus, idempotent: false };
},
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
let responseVisible = false;
const responsePromise = awaitTurnExitBeforeResponse(
new Response(null, { status: 200 }),
() => finalizeSuccessfulTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
action: "answer_choice",
}),
);
void responsePromise.then(() => { responseVisible = true; });
await writeStarted;
await Promise.resolve();
assert.equal(responseVisible, false, "answer_choice response resolved before focus persistence completed");
releaseWrite();
const response = await responsePromise;
const refreshed = parseV9CaseDossier({
...raw,
conversation_summary: {
...raw.conversation_summary,
active_focus: activeFocus,
},
});
assert.ok(refreshed);
const currentQuestion = projectCurrentQuestion(refreshed.conversationSummary.activeFocus);
assert.equal(response.status, 200);
assert.equal(decision.canAdopt, false);
assert.ok(currentQuestion?.prompt);
assert.equal(currentQuestion.prompt, "家里有没有结婚、添丁或住院这类记得住时间的事?不记得具体日子也可以先说有没有。");
const historyTurn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn");
assert.equal(historyTurn?.args.p_request_id, FOCUS_ID);
assert.equal(historyTurn?.args.p_user_message, null);
assert.equal(historyTurn?.args.p_assistant_message, currentQuestion.prompt);
});
test("public decision fields are derived from decideRectification", () => {
const fixtures = [
{
@@ -186,8 +186,7 @@ test("openQuestionFromPersistedFocus returns collect_spoken without making colle
answerChoice.indexOf("async function persistFocusAfterChoice"),
);
assert.match(nextInterview, /isRenderableChoiceOpenQuestion/);
assert.match(nextInterview, /接下来请点选下面这一问/);
assert.ok(nextInterview.indexOf("isRenderableChoiceOpenQuestion") < nextInterview.indexOf("接下来请点选下面这一问"));
assert.match(nextInterview, /hostNarration: open\.prompt/);
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
assert.match(route, /isRenderableChoiceOpenQuestion/);
@@ -325,8 +324,10 @@ function collectFocus() {
test("agent route keeps question ownership in the server Case projection", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const turnExit = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-exit.ts", import.meta.url), "utf8");
const afterRun = route.slice(route.indexOf("const result = await runV9AgentTurn"));
assert.match(afterRun, /persistNextInterviewIfIdle/);
assert.match(afterRun, /await finalizeSuccessfulTurnExit/);
assert.match(turnExit, /persistNextInterviewIfIdle/);
assert.doesNotMatch(afterRun, /persistCollectSpokenAssistantIfNew|persistEmptyCollectSpokenAssistant/);
assert.doesNotMatch(afterRun, /answerText\.(?:includes|match|search)\(/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");