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); });