fix(rectification): persist refresh attempts and map collect focus kinds (BUG-656/657/658)
Independent Staging Quality Gate / validate (push) Failing after 13m28s
Independent Staging Quality Gate / publish (push) Skipped

Empty engine refresh now records an already_answered attempt so GET can leave wait-to-narrow; collect kinds map onto the table CHECK; remaining candidates drive probe refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-12 06:09:20 +08:00
co-authored by Cursor
parent 2b6d28e7a7
commit eae049d0ea
17 changed files with 747 additions and 62 deletions
@@ -33,7 +33,7 @@ import {
setRefreshDiscriminatorProbesForTests,
} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { persistServerOwnedFocus, FOCUS_TARGET_KIND_CHECK } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import {
@@ -374,7 +374,18 @@ function accidentDossier(answeredCount: number, extra: {
} = {}): DecisionDossier {
const loaded = liveState(answeredCount);
const state = extra.refreshCount != null
? { ...loaded, refresh_count: extra.refreshCount }
? {
...loaded,
refresh_count: extra.refreshCount,
refresh_attempts: extra.refreshCount >= 1
? [{
candidate_set_id: loaded.candidate_set_id,
answer_count: loaded.answered_probes.length,
result: "no_new_probes" as const,
at: "2026-09-11T00:00:00.000Z",
}]
: loaded.refresh_attempts,
}
: loaded;
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
return {
@@ -477,7 +488,26 @@ function idleHandlers(decision: DecisionDossier, extra: {
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra),
get_agentic_rectification_case_dossier: () => {
const base = rpcDossier(decision, extra);
const last = extra.transitions?.at(-1);
const inference = last?.p_inference_state;
const latest = base.latest_result as {
decision_receipt?: Record<string, unknown>;
} | null | undefined;
if (!inference || typeof inference !== "object" || !latest) return base;
return {
...base,
latest_result: {
...latest,
decision_receipt: {
...(latest.decision_receipt ?? {}),
inference_state: inference,
refresh_attempts: (inference as { refresh_attempts?: unknown }).refresh_attempts,
},
},
};
},
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
apply_agentic_rectification_choice_action: (_fn, args) => ({
@@ -598,6 +628,13 @@ test("T0: sixth dated answer must refresh or targeted-collect, not deliver a car
assert.match(host, /家里|收入|搬家|感情|还能再收窄|添丁|住院/);
assert.doesNotMatch(host, /这次给出|最终|做不了|才会变|没有拿到下一个问题/);
assert.equal(persisted.choiceReady, false);
const focusWrite = idleAccounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(focusWrite, "targeted collect must become the active focus");
assert.match(String(focusWrite.args.p_question_id ?? ""), /collect:targeted:/);
assert.ok(
(FOCUS_TARGET_KIND_CHECK as readonly string[]).includes(String(focusWrite.args.p_target_kind ?? "")),
String(focusWrite.args.p_target_kind),
);
for (const phrase of COLLECT_FLOW_BANNED_PHRASES) {
if (phrase === "领域") continue;
assert.equal(host.includes(phrase), false, phrase);
@@ -676,6 +713,8 @@ test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers t
}));
const next = result as Awaited<ReturnType<typeof persistNextInterviewAfterChoice>>;
assert.ok((next.hostNarration ?? "").trim(), "BUG-652: never silent empty carrier");
assert.match(next.hostNarration, /目前范围/);
assert.doesNotMatch(next.hostNarration, /没有拿到下一个问题/);
const skippedDirect = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
@@ -908,24 +947,76 @@ test("T0: last inference row and GET probe key vs persist status after a real re
resetRefreshDiscriminatorProbesForTests();
});
test("T3: refresh without new engine probes does not write inference", async () => {
test("T3: refresh without new engine probes persists an already_answered attempt", async () => {
resetDeliveryTurnGuardForTests();
resetRefreshDiscriminatorProbesForTests();
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
}));
let engineCalls = 0;
setRefreshDiscriminatorProbesForTests(async ({ state }) => {
engineCalls += 1;
return {
state: { ...state, refresh_count: (state.refresh_count ?? 0) + 1 },
eventProbes: [],
candidateSetId: state.candidate_set_id,
refreshCount: (state.refresh_count ?? 0) + 1,
};
});
const dossier = accidentDossier(6);
const transitions: Record<string, unknown>[] = [];
const accounting = idleHandlers(dossier, { transitions });
const idle = await persistNextInterviewIfIdle({
accounting: idleHandlers(dossier, { transitions }).client,
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(transitions.length, 0, JSON.stringify(transitions.at(-1) ?? {}));
const last = transitions.at(-1);
const inference = last?.p_inference_state as {
refresh_attempts?: ReadonlyArray<{
candidate_set_id?: string;
answer_count?: number;
result?: string;
}>;
probes?: unknown[];
candidate_set_id?: string;
} | undefined;
assert.equal(last?.p_reason, "already_answered", JSON.stringify(last ?? {}));
assert.equal(last?.p_raw_answer, "refresh_attempt");
assert.equal(inference?.refresh_attempts?.at(-1)?.result, "no_new_probes");
assert.equal(inference?.refresh_attempts?.at(-1)?.answer_count, liveState(6).answered_probes.length);
assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id);
assert.equal((inference as { refresh_count?: number } | undefined)?.refresh_count ?? 0, 0);
assert.equal(
(inference?.probes ?? []).some((item) => (
Boolean(item)
&& typeof item === "object"
&& (item as { semantic_key?: string }).semantic_key === FAMILY_REFRESH.semantic_key
)),
false,
);
assert.ok((idle.hostNarration ?? "").trim());
const overlayed = {
...dossier,
latestResult: {
...dossier.latestResult!,
decisionReceipt: {
...(dossier.latestResult?.decisionReceipt ?? {}),
inference_state: inference,
refresh_attempts: inference?.refresh_attempts,
},
},
};
const getDecision = decideFromDossier(overlayed, { birthDate: "1997-08-08" });
assert.equal(getDecision.nextAction, "ask_fact_collection", getDecision.nextAction);
assert.match(
followupPlan(overlayed, getDecision.sessionOutcome).next_followup?.collection_key ?? "",
/collect:targeted:/,
);
const second = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(engineCalls, 1, `second refresh called the engine ${engineCalls} times`);
assert.ok((second.hostNarration ?? idle.hostNarration ?? "").trim());
resetRefreshDiscriminatorProbesForTests();
});
@@ -949,7 +1040,13 @@ test("T3: changed candidate set is not persisted even when engine returns a prob
nextAction: publicNextAction(decideFromDossier(dossier, { birthDate: "1997-08-08" })),
birthDate: "1997-08-08",
});
assert.equal(transitions.length, 0);
const last = transitions.at(-1);
assert.equal(last?.p_reason, "already_answered");
assert.notEqual(last?.p_reason, "supersede");
assert.equal(last?.p_candidate_set_id, liveState(6).candidate_set_id);
const inference = last?.p_inference_state as { candidate_set_id?: string; candidates?: unknown[] } | undefined;
assert.equal(inference?.candidate_set_id, liveState(6).candidate_set_id);
assert.equal(inference?.candidates?.length, TIMES.length);
resetRefreshDiscriminatorProbesForTests();
});
@@ -973,7 +1070,12 @@ test("T3: empty candidate list is not persisted even when engine returns a probe
hasDatedProbe: false,
});
assert.equal(refreshed.refreshed, false);
assert.equal(transitions.length, 0);
assert.equal(refreshed.attemptRecorded, true);
const last = transitions.at(-1);
assert.equal(last?.p_reason, "already_answered");
assert.notEqual(last?.p_reason, "supersede");
const inference = last?.p_inference_state as { candidates?: unknown[] } | undefined;
assert.equal(inference?.candidates?.length, TIMES.length);
resetRefreshDiscriminatorProbesForTests();
});
@@ -5,6 +5,8 @@ import test from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
COLLECT_FOCUS_RETRY_SUFFIX,
FOCUS_TARGET_KIND_CHECK,
collectFocusTargetKind,
openQuestionFromPersistedFocus,
persistableFocusDomain,
persistServerOwnedFocus,
@@ -924,3 +926,55 @@ test("frameless precision-stage distinguish is skipped instead of opening GENERI
GENERIC_COLLECT_QUESTION,
);
});
function targetKindCheckFromSql(): string[] {
const sql = readFileSync(new URL(
"../supabase/migrations/20260814020000_rectification_v10_runtime.sql",
import.meta.url,
), "utf8");
const match = /target_kind text check \(\s*target_kind is null or target_kind in \(\s*([\s\S]*?)\)\s*\)/.exec(sql);
assert.ok(match?.[1], "target_kind CHECK missing");
return [...match[1].matchAll(/'([a-z_]+)'/g)].map((item) => item[1]);
}
test("T1: collect focus kinds map to the table CHECK enum", async () => {
const allowed = targetKindCheckFromSql();
assert.deepEqual([...FOCUS_TARGET_KIND_CHECK], allowed);
const cases: ReadonlyArray<{
kind_hint: string;
domain: string;
collectKind: string;
}> = [
{ kind_hint: "targeted:family", domain: "family", collectKind: "targeted:family" },
{ kind_hint: "anchor:education_start:2016", domain: "education", collectKind: "anchor:education_start:2016" },
{ kind_hint: "generic:relationship", domain: "relationship", collectKind: "generic:relationship" },
];
for (const item of cases) {
const mapped = collectFocusTargetKind({ kind_hint: item.kind_hint, domain: item.domain });
assert.ok(mapped && allowed.includes(mapped), `${item.kind_hint} -> ${mapped}`);
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => focusRowFromArgs(args),
});
const persisted = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: null,
followup: collectFollowup({
domain: item.domain,
kind_hint: item.kind_hint,
collection_key: item.kind_hint.startsWith("targeted:")
? `collect:targeted:${item.domain}`
: undefined,
}),
});
assert.equal(persisted.status, "created", item.kind_hint);
assert.ok(
persisted.focus?.targetKind && allowed.includes(persisted.focus.targetKind),
`${item.kind_hint} wrote ${persisted.focus?.targetKind}`,
);
assert.equal(persisted.focus?.expectedAnswerSchema.collect_kind, item.collectKind);
assert.equal(accounting.calls[0]?.args.p_target_kind, persisted.focus?.targetKind);
}
});
@@ -72,6 +72,13 @@ test("question gap: collect waiting is not the unavailable repair path", () => {
}),
true,
);
assert.equal(
interviewCollectWaiting({
sessionOutcome: "collect_evidence",
questionMissing: true,
}),
true,
);
assert.equal(
rectificationQuestionGapState({
...gapBase,