fix(rectification): keep same-sentence dated events after structured answers
Independent Staging Quality Gate / validate (push) Successful in 13m7s
Independent Staging Quality Gate / publish (push) Successful in 29m19s

The choice and collect fast paths applied the answer then returned,
so a dated event in the same utterance never reached the evidence
ledger. Idle persist also prechecked follow-up with a hardcoded
collect_evidence outcome instead of the dossier decision.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-29 23:18:07 +08:00
co-authored by Cursor
parent 078c1cdc0d
commit 3a0319694e
8 changed files with 306 additions and 17 deletions
@@ -663,6 +663,38 @@ test("answering a discriminator persists the next dated card so GET still has a
assert.doesNotMatch(card.prompt, /2015/);
});
test("deferFollowup scores the choice without persisting the next interview or turn", async () => {
const accounting = persistChoiceAccounting(twoProbeDossier());
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: "C",
expectedRevision: twoProbeInference().revision,
deferFollowup: true,
});
assert.equal(applied.applied, true);
assert.equal(applied.answerClass, "no");
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
assert.equal(persist?.args.p_option_id, "C");
assert.ok(persist?.args.p_inference);
assert.equal(
accounting.calls.some((call) => call.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(
accounting.calls.some((call) => call.fn === "append_agentic_rectification_turn"),
false,
);
assert.equal(applied.nextInterviewPersisted, false);
assert.equal(applied.narrationPersisted, false);
});
test("answering the last discriminator persists a year-locked family collect focus, not a yearless D24 card", async () => {
const accounting = persistChoiceAccounting(familyCollectDossier(), {
set_agentic_rectification_conversation_focus: (_fn, args) => ({
@@ -6,6 +6,7 @@ import { applyAnswerToState, buildInferenceState } from "../src/lib/rectificatio
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
rectificationFollowupCatalog,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
@@ -15,6 +16,7 @@ import { serverOwnedChoiceCopy } from "../src/lib/rectification-agentic/v9/choic
import { persistServerOwnedFocus } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import {
shouldContinueAgentForDatedEvent,
shouldDeclineCollectFocus,
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import {
@@ -745,4 +747,191 @@ test("collect-focus classifier and resolve-focus copy do not treat explicit no a
assert.match(resolveTool, /declined/);
assert.match(resolveTool, /采集题|确实没有/);
assert.match(resolveTool, /resolved 只用于已落证据|已落证据/);
assert.match(resolveTool, /没有 active focus|没有当前.*focus/);
});
test("has_new_dated_event continues into the agent after applying the answer", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8");
const fastPath = route.slice(
route.indexOf('if (action === "message")'),
route.indexOf("const requestTime"),
);
assert.match(classifier, /has_new_dated_event/);
assert.match(classifier, /带大概时间的经历/);
assert.match(classifier, /intent 仍为 answer_current_focus/);
assert.match(classifier, /不要改成 provide_new_evidence/);
assert.doesNotMatch(classifier + fastPath, /USER_STOP_PATTERN|parseChoiceKeyFromUserMessage/);
assert.doesNotMatch(classifier + fastPath, /(?:userMessage|user_message|message)\.(?:match|search|includes|startsWith|endsWith)\(/);
assert.match(fastPath, /shouldContinueAgentForDatedEvent/);
assert.match(fastPath, /deferFollowup:\s*continueToAgent/);
const choiceApply = fastPath.slice(
fastPath.indexOf("if (classified.intent === \"answer_current_focus\")"),
fastPath.indexOf("if (classified.intent === \"stop_rectification\")"),
);
assert.ok(choiceApply.indexOf("applyRectificationChoice") < choiceApply.indexOf("if (!continueToAgent)"));
assert.match(choiceApply, /return completedMessageResponse\(applied\.narration/);
assert.ok(choiceApply.indexOf("if (!continueToAgent)") < choiceApply.indexOf("return completedMessageResponse(applied.narration"));
const collectApply = fastPath.slice(
fastPath.indexOf("if (shouldDeclineCollectFocus(classified))"),
fastPath.indexOf("} else {"),
);
assert.ok(collectApply.indexOf("applyCollectFocusDenial") < collectApply.indexOf("if (!continueToAgent)"));
assert.match(collectApply, /persistV9DeterministicTurn/);
assert.ok(collectApply.indexOf("if (!continueToAgent)") < collectApply.indexOf("persistV9DeterministicTurn"));
assert.ok(route.indexOf("if (action === \"message\")") < route.indexOf("runV9AgentTurn({"));
const answerChoice = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const persistApplied = answerChoice.slice(answerChoice.indexOf("async function persistApplied"));
assert.match(persistApplied, /command\.deferFollowup !== true/);
assert.equal(shouldContinueAgentForDatedEvent({
intent: "answer_current_focus",
answer_class: "no",
has_new_dated_event: true,
}), true);
assert.equal(shouldContinueAgentForDatedEvent({
intent: "answer_current_focus",
answer_class: "no",
}), false);
});
test("collect denial with a new dated event does not persist the next interview before the agent", async () => {
const occupationFocus = {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: "occupation:occupation",
intent: "collect_method_evidence",
target_evidence_id: null,
target_domain: "occupation",
target_kind: null,
expected_answer_schema: {
prompt: "你长期做什么工作?",
collect: true,
},
status: "active",
asked_at: "2026-08-29T00:00:00.000Z",
resolved_at: null,
};
let loads = 0;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => {
loads += 1;
if (loads === 1) {
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [{ target_domain: "family", status: "declined" }],
}), occupationFocus);
}
return rpcDossier(revision5Dossier(revision5State(), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
],
}));
},
get_agentic_rectification_case_compute: () => computeFixture(),
resolve_agentic_rectification_conversation_focus: (_fn, args) => ({
focus_id: args.p_focus_id,
status: args.p_status,
evidence_id: null,
idempotent: false,
}),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: "acacacac-acac-4cac-8cac-acacacacacac",
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-08-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const applied = await applyCollectFocusDenial(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
focusId: FOCUS_ID,
deferFollowup: true,
});
const resolve = accounting.calls.find((item) => item.fn === "resolve_agentic_rectification_conversation_focus");
assert.equal(resolve?.args.p_status, "declined");
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(applied.nextInterviewPersisted, false);
});
test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once", async () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const idle = source.slice(
source.indexOf("export async function persistNextInterviewIfIdle"),
source.indexOf("async function persistApplied"),
);
assert.equal(idle.split("decideFromDossier").length - 1, 1);
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
assert.match(idle, /publicNextAction\(decision\)/);
const covered = revision5Dossier(revision5State([DATED_RELOCATION_2016]), {
declinedTopics: [
{ target_domain: "family", status: "declined" },
{ target_domain: "occupation", status: "declined" },
{ target_domain: "horary", status: "declined" },
],
});
const decision = decideFromDossier(covered);
assert.notEqual(decision.sessionOutcome, "collect_evidence");
const catalog = rectificationFollowupCatalog(covered.latestResult, covered.evidence);
const collectPlan = buildMethodFollowupPlan({
evidence: covered.evidence,
declinedTopics: covered.conversationSummary.declinedSkippedTopics,
sessionOutcome: "collect_evidence",
...catalog,
candidatesSeparated: false,
});
const decisionPlan = buildMethodFollowupPlan({
evidence: covered.evidence,
declinedTopics: covered.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
...catalog,
candidatesSeparated: false,
});
assert.ok(decisionPlan.next_followup);
assert.notEqual(collectPlan.next_followup?.intent, decisionPlan.next_followup?.intent);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(covered),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
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-08-29T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const persisted = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(persisted.persisted, true);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence");
});
@@ -5,6 +5,8 @@ import test from "node:test";
import {
optionIdForAnswerClass,
parseRectificationTurnIntent,
shouldContinueAgentForDatedEvent,
shouldDeclineCollectFocus,
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import type { ConversationFocus } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { CASE_ID, FOCUS_ID } from "./rectification-v9-test-support.ts";
@@ -71,6 +73,36 @@ test("turn intent parser enforces answer_class only for current-focus answers",
}), null);
});
test("missing has_new_dated_event is treated as false and true is fail-closed optional", () => {
const legacy = parseRectificationTurnIntent({
intent: "answer_current_focus",
answer_class: "no",
});
assert.equal(legacy?.intent, "answer_current_focus");
assert.equal(legacy?.answer_class, "no");
assert.equal(legacy?.has_new_dated_event, undefined);
assert.equal(shouldContinueAgentForDatedEvent(legacy), false);
assert.equal(shouldContinueAgentForDatedEvent(null), false);
assert.equal(shouldContinueAgentForDatedEvent({
intent: "answer_current_focus",
answer_class: "no",
has_new_dated_event: false,
}), false);
const withEvent = parseRectificationTurnIntent({
intent: "answer_current_focus",
answer_class: "no",
has_new_dated_event: true,
});
assert.equal(withEvent?.has_new_dated_event, true);
assert.equal(shouldContinueAgentForDatedEvent(withEvent), true);
assert.equal(shouldDeclineCollectFocus(withEvent), true);
assert.equal(parseRectificationTurnIntent({
intent: "answer_current_focus",
answer_class: "no",
has_new_dated_event: "yes",
}), null);
});
test("answer classes resolve through each dynamic option instead of A/B/C/D position", () => {
const focus = focusWithOptions(SHUFFLED_CHOICE);
assert.equal(optionIdForAnswerClass(focus, "no"), "A");