fix(rectification): typed choice answer with a dated event keeps its range sentence (BUG-1058)

T4 of TASK-rectification-grounding-20260927 (BUG-588 / BUG-569 family).
The deferFollowup path persisted the choice before the agent turn, so the
agent turn's prepare read the post-answer range and the deferred choice
narration was never persisted: nobody said the range moved. The typed-message
preflight now hands the pre-answer credible range to the agent turn
(rangeBeforeTurn); the turn's one server range sentence covers the answer and
the dated event. Route-level regression with the real handler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-27 03:28:27 +08:00
co-authored by Claude Opus 5.5
parent 0e0baaa74c
commit 5a8084494b
4 changed files with 187 additions and 0 deletions
@@ -220,6 +220,7 @@ export function respondWithRectificationAgentTurn(
expectedWrite: turnState.expectedWrite,
collectIntent: turnState.collectIntent,
classifierDiagnostic: turnState.classifierDiagnostic,
...(turnState.rangeBeforeTurn !== undefined ? { rangeBeforeTurn: turnState.rangeBeforeTurn } : {}),
buildAgent: async (turnId, skillPackage, attemptId) => {
let decision;
try {
@@ -114,4 +114,11 @@ export type RectificationAgentTurnState = {
collectIntent: "classified" | "unclassified" | null;
writeClassified: boolean;
classifierDiagnostic: TurnIntentClassifierDiagnostic | null;
/**
* Set when the preflight already applied a typed choice answer and hands the
* rest of the message (a dated event) to the agent: the credible range from
* before that answer, so the agent turn's one server range sentence covers
* the whole message (BUG-1058). Undefined otherwise.
*/
rangeBeforeTurn?: readonly [string, string] | null;
};
@@ -167,6 +167,10 @@ export async function handleRectificationTypedMessage(
if (!continueToAgent) {
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
}
// BUG-1058: the deferred choice narration is not persisted and the
// agent turn reads the post-answer range, so hand it the range from
// before this answer; the agent turn then says the change once.
turnState.rangeBeforeTurn = previous?.credible_range ?? null;
}
if (classified?.intent === "stop_rectification" || classified?.intent === "ask_about_result") {
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
@@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
// BUG-1058 (TASK-rectification-grounding-20260927 T4), route level. The real
// POST /api/rectification/agent handler, typed-message preflight, agent-turn
// branch and runV9AgentTurn, with fake persistence. A typed answer to a
// choice card that also carries a dated event: the preflight applies the
// choice with deferFollowup (its narration is not persisted) and hands the
// rest to the agent. The choice itself moves the credible range; before the
// fix the agent turn compared post-answer with post-answer and nobody said
// the range changed. Now the persisted body carries exactly one server range
// sentence covering the whole message.
//
// Module doubles: the choice application (its only effect needed here is the
// stored range moving and deferFollowup being honoured), the classifier, the
// Mastra agent (a scripted stream), billing and the post-turn exit gate.
// Uses node:test module mocks (Node >= 22.3, same as the other route tests).
function routeScript(batchMovesRange: boolean) {
return String.raw`
import assert from 'node:assert/strict';
import { mock } from 'node:test';
import { pathToFileURL } from 'node:url';
import { CASE_ID, SESSION_ID, USER_ID, TURN_ID, FOCUS_ID, CANDIDATE_ID, SECOND_CANDIDATE_ID, dossierFixture,
conversationSummaryFixture, activeFocusFixture, candidateSnapshotFixture, receiptHandlers } from './tests/rectification-v9-test-support.ts';
import { buildInferenceState } from './src/lib/rectification-agentic/core/build-state.ts';
import * as realClassifier from './src/lib/rectification-agentic/v9/turn-intent-classifier.ts';
import * as realAnswerChoice from './src/lib/rectification-agentic/v9/answer-choice.ts';
import * as realTurnExit from './src/lib/rectification-agentic/v9/turn-exit.ts';
const BEFORE = ['04:50', '05:10'];
const MID = ['04:55', '05:05'];
const AFTER = ${batchMovesRange ? "['04:55', '05:02']" : "MID"};
let range = BEFORE;
const prompt = '2023 年前后,有没有换过工作或职责明显变化?';
const focus = activeFocusFixture({ intent: 'distinguish_candidates', questionId: 'd10:career:2023', askedTurnId: TURN_ID,
expectedAnswerSchema: { prompt, probe_id: 'probe:career:2023', choice: { prompt,
option_a: '明确发生且时间吻合', option_b: '发生过但程度较弱', option_c: '没有这回事', option_d: '这段记不清楚',
options: [
{ key: 'A', label: '明确发生且时间吻合', answer_class: 'yes' },
{ key: 'B', label: '发生过但程度较弱', answer_class: 'weak_yes' },
{ key: 'C', label: '没有这回事', answer_class: 'no' },
{ key: 'D', label: '这段记不清楚', answer_class: 'unsure' },
] } } });
const inference = (credible) => ({ ...buildInferenceState({ range_start: '04:50', range_end: '05:10',
candidates: [{ id: CANDIDATE_ID, time: '05:02', relative_support: 58 }, { id: SECOND_CANDIDATE_ID, time: '04:55', relative_support: 42 }],
events: [], probes: [] }), credible_range: credible });
const dossier = () => dossierFixture({
latestResult: candidateSnapshotFixture({ representativeTime: '05:02', decisionReceipt: { inference_state: inference(range) } }),
conversationSummary: conversationSummaryFixture({ activeFocus: focus }) });
const calls = [];
const accounting = { rpc: async (fn, args = {}) => {
calls.push({ fn, args });
let data;
if (fn === 'get_agentic_rectification_case') data = { ...dossierFixture().case, session_id: SESSION_ID, status: 'collecting_evidence' };
else if (fn === 'get_agentic_rectification_case_dossier') data = dossier();
else if (fn === 'append_agentic_rectification_turn') data = { turn_id: TURN_ID, should_execute: true, status: 'pending', idempotent: false };
else if (fn === 'finalize_agentic_rectification_turn') data = { turn_id: TURN_ID, status: 'completed', idempotent: false };
else if (receiptHandlers[fn]) data = await receiptHandlers[fn](fn, args);
else data = null;
return { data: structuredClone(data), error: null };
}, from: () => { throw new Error('no table access expected'); } };
const choiceCalls = [];
mock.module('server-only', { namedExports: {} });
mock.module('@/lib/supabase/server', { namedExports: { createServerSupabaseClient: async () => ({
auth: { getUser: async () => ({ data: { user: { id: USER_ID } }, error: null }) },
from: () => ({ select() { return this; }, eq() { return this; },
maybeSingle: async () => ({ data: { id: SESSION_ID, session_type: 'birth_time_rectification',
agentic_rectification_case_id: CASE_ID, model_id: 'synthetic', model_config_version: 1 }, error: null }) })
}) } });
mock.module('@/lib/supabase/admin', { namedExports: { createAdminSupabaseClient: () => accounting } });
mock.module('@/lib/product-access', { namedExports: { isProductEnabled: async () => true } });
mock.module('@/lib/feature-flags', { namedExports: { loadRuntimeFeatureFlags: async () => new Map([
['rectification_runtime_version', { enabled: true }]
]) } });
mock.module('@/lib/model-catalog', { namedExports: { resolveSessionLanguageModel: async () => ({
id: 'synthetic', configVersion: 1, model: {}
}) } });
mock.module('@/lib/rectification-agentic/v9/agent-route-billing', { namedExports: {
rectificationBillingRequestId: async () => 'rectification:case:' + CASE_ID,
createRectificationRunBilling: () => ({ reserve: async () => ({ success: true, status: 200 }), complete: async () => true, release: async () => true }),
} });
mock.module('@/lib/rectification-agentic/v9/turn-intent-classifier', { namedExports: {
...realClassifier,
classifyTurnIntentWithRetry: async () => ({
classified: { intent: 'answer_current_focus', answer_class: 'yes', has_new_dated_event: true },
expectedWrite: 'evidence', outcome: 'classified',
diagnostic: { outcome: 'classified', attempts: 1, timedOutAttempts: 0, elapsedMs: 5 },
}),
} });
mock.module('@/lib/rectification-agentic/v9/answer-choice', { namedExports: {
...realAnswerChoice,
applyRectificationChoice: async (choiceAccounting, command) => {
choiceCalls.push({ optionId: command.optionId, deferFollowup: command.deferFollowup });
// The real path persists the answered probe through this RPC, which
// also drops the request-scoped dossier cache.
await choiceAccounting.rpc('append_agentic_rectification_inference_transition', { p_user_id: USER_ID, p_case_id: CASE_ID });
range = MID;
return { narration: '已记录,范围收到 04:55–05:05。', turnId: null };
},
persistNextInterviewIfIdle: async () => ({ persisted: false, choiceReady: false, hostNarration: null, focusActive: true }),
} });
mock.module('@/lib/rectification-agentic/v9/turn-exit', { namedExports: {
...realTurnExit,
finalizeSuccessfulTurnExit: async () => {},
} });
const chunks = [
{ type: 'start' },
{ type: 'tool-call', payload: { toolName: 'rectification-read-case', args: { caseId: CASE_ID } } },
{ type: 'tool-result', payload: { toolName: 'rectification-read-case' } },
{ type: 'tool-call', payload: { toolName: 'rectification-record-evidence-batch', args: { caseId: CASE_ID } } },
{ type: 'tool-result', payload: { toolName: 'rectification-record-evidence-batch', result: {
items: [{ index: 0, outcome: 'accepted' }], accepted_count: 1, rescore: { status: 'completed' },
accepted_recaps: [{ display_date_label: '2023 年 3 月', event_phrase: '换了工作' }] } } },
{ type: 'text-delta', payload: { text: '记下了:2023 年 3 月换了工作。' } },
{ type: 'finish' },
];
mock.module('@/mastra/agentic-rectification', { namedExports: { getRectificationV9Agent: () => ({
getSkill: async (name) => ({ name, instructions: 'skill' }),
stream: async () => ({
fullStream: (async function* () {
for (const item of chunks) {
if (item.type === 'tool-result' && item.payload.toolName === 'rectification-record-evidence-batch') range = AFTER;
yield item;
}
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
}),
}) } });
const { POST } = await import(pathToFileURL(process.cwd() + '/src/app/api/rectification/agent/route.ts').href);
const response = await POST(new Request('https://example.invalid/api/rectification/agent', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ caseId: CASE_ID, sessionId: SESSION_ID, requestId: TURN_ID, action: 'message',
message: '有,2023 年 3 月换了工作' })
}));
assert.equal(response.status, 200);
const events = (await response.text()).trim().split('\n').filter(Boolean).map((line) => JSON.parse(line));
assert.deepEqual(choiceCalls, [{ optionId: 'A', deferFollowup: true }]);
const finalize = calls.filter((call) => call.fn === 'finalize_agentic_rectification_turn').at(-1);
const persisted = String(finalize?.args.p_assistant_message ?? '');
let shown = '';
for (const event of events) {
if (event.type !== 'answer.delta') continue;
shown = event.replace === true ? event.text : shown + event.text;
}
console.log(JSON.stringify({ persisted, shown, types: events.map((event) => event.type) }));
`;
}
function runRoute(batchMovesRange: boolean) {
const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", routeScript(batchMovesRange)], {
cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8", timeout: 60_000,
});
assert.equal(result.status, 0, result.stderr + result.stdout);
const line = result.stdout.trim().split("\n").filter((row) => row.startsWith("{\"persisted\"")).at(-1);
assert.ok(line, result.stdout);
return JSON.parse(line) as { persisted: string; shown: string; types: string[] };
}
test("BUG-1058: a typed choice answer with a dated event persists one server range sentence for the answer's range change", () => {
const { persisted, shown } = runRoute(false);
assert.equal(persisted, "记下了:2023 年 3 月换了工作。范围从 04:50–05:10 变为 04:55–05:05。");
assert.equal((persisted.match(/范围从/g) ?? []).length, 1);
assert.equal(shown, persisted);
});
test("BUG-1058: when the dated event moves the range again, the one sentence spans answer and event", () => {
const { persisted, shown } = runRoute(true);
assert.equal(persisted, "记下了:2023 年 3 月换了工作。范围从 04:50–05:10 变为 04:55–05:02。");
assert.equal((persisted.match(/范围/g) ?? []).length, 1);
assert.equal(shown, persisted);
});