From 5a8084494b775cff516616a92404b0ac9a1cb3a8 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sun, 27 Sep 2026 02:47:35 +0800 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- .../v9/agent-route-agent-turn.ts | 1 + .../v9/agent-route-support.ts | 7 + .../v9/agent-route-typed-message.ts | 4 + ...ification-grounding-route-20260927.test.ts | 175 ++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 frontend/tests/rectification-grounding-route-20260927.test.ts diff --git a/frontend/src/lib/rectification-agentic/v9/agent-route-agent-turn.ts b/frontend/src/lib/rectification-agentic/v9/agent-route-agent-turn.ts index fed93ecb..b7707eb3 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-route-agent-turn.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-route-agent-turn.ts @@ -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 { diff --git a/frontend/src/lib/rectification-agentic/v9/agent-route-support.ts b/frontend/src/lib/rectification-agentic/v9/agent-route-support.ts index fb83a723..5c259fd4 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-route-support.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-route-support.ts @@ -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; }; diff --git a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts index e97c1c44..7173d901 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-route-typed-message.ts @@ -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); diff --git a/frontend/tests/rectification-grounding-route-20260927.test.ts b/frontend/tests/rectification-grounding-route-20260927.test.ts new file mode 100644 index 00000000..7c8768f7 --- /dev/null +++ b/frontend/tests/rectification-grounding-route-20260927.test.ts @@ -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); +});