import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import test from "node:test"; // Route + stateful persistence fake: not a real database or browser test. // The idle interview, midnight override, focus writer, runner and NDJSON mapping are real. test("opening persists and reloads the midnight-side question before rendering NDJSON", () => { const script = 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, dossierFixture, computeFixture, conversationSummaryFixture, receiptHandlers } from './tests/rectification-v9-test-support.ts'; import { parseAgentChoiceCopy } from './src/lib/rectification-agentic/v9/choice-card.ts'; import { loadV9CaseDossier } from './src/lib/rectification-agentic/v9/tool-service.ts'; import { safePublicEvent } from './src/lib/rectification-agentic/v9/stream-mapping.ts'; import { windowFromBlockChoice } from './src/lib/rectification-agentic/v9/block-scan.ts'; const range = { start_time: '23:00', end_time: '03:59', midnight_side_pending: true, candidate_intervals: [ { start_at: '2000-06-15T00:00', end_at: '2000-06-15T03:59' }, { start_at: '2000-06-15T23:00', end_at: '2000-06-15T23:59' } ] }; let focus = null; let reloadsAfterWrite = 0; const calls = []; const dossier = () => dossierFixture({ stage: 'block_scan', candidateRange: range, evidence: [], evidenceCount: 0, turns: [], turnCount: 0, latestResult: null, conversationSummary: conversationSummaryFixture({ activeFocus: focus }) }); const initial = dossier(); assert.equal(initial.case.stage, 'block_scan'); assert.equal(initial.case.candidate_range.midnight_side_pending, true); assert.deepEqual(initial.case.candidate_range.candidate_intervals, range.candidate_intervals); assert.equal(initial.latest_result, null); assert.equal(initial.conversation_summary.active_focus, null); assert.deepEqual(initial.evidence, []); const accounting = { rpc: async (fn, args = {}) => { calls.push({ fn, args }); let data; if (fn === 'get_agentic_rectification_case') data = dossier().case; else if (fn === 'get_agentic_rectification_case_dossier') { if (focus) reloadsAfterWrite++; data = dossier(); } else if (fn === 'get_agentic_rectification_case_compute') data = { ...computeFixture({ baselineBirthSnapshot: { birth_date: '2000-06-15', timezone_offset: 8, birth_time_source: 'period_only', birth_time_period: 'late_night', reported_birth_time: null } }), candidate_range: range, }; else if (fn === 'set_agentic_rectification_conversation_focus') { 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, asked_turn_id: args.p_asked_turn_id, status: 'active', asked_at: '2026-09-20T00:00:00Z', resolved_at: null, answer_option: null }; data = { focus, idempotent: false }; } else if (fn === 'append_agentic_rectification_turn') data = { turn_id: TURN_ID, idempotent: false }; else if (fn === 'finalize_agentic_rectification_turn') data = { turn_id: TURN_ID, status: args.p_status, idempotent: false }; else if (receiptHandlers[fn]) data = await receiptHandlers[fn](fn, args); else throw new Error('Unexpected persistence RPC: ' + fn); return { data: structuredClone(data), error: null }; } }; 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('@/mastra/agentic-rectification', { namedExports: { getRectificationV9Agent: async () => ({ getSkill: async () => ({ name: 'jyotish-birth-time-rectification', instructions: 'synthetic model boundary' }), stream: async () => ({ fullStream: (async function* () { yield { type: 'start' }; yield { type: 'tool-call', payload: { toolName: 'rectification-read-case', args: { caseId: CASE_ID } } }; const loaded = await loadV9CaseDossier(accounting, USER_ID, CASE_ID); assert.equal(loaded.conversationSummary.activeFocus?.questionId, 'probe:block_scan.midnight_side'); yield { type: 'tool-result', payload: { toolName: 'rectification-read-case', result: loaded } }; yield { type: 'text-delta', payload: { text: '请先选择午夜前后。' } }; yield { type: 'finish', payload: { finishReason: 'stop' } }; })(), totalUsage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }) }) }) } }); 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: 'opening' }) })); const text = await response.text(); // Consume the actual NDJSON stream through EOF. assert.equal(response.status, 200, text); const events = text.trim().split('\n').map(line => JSON.parse(line)); assert.ok(focus, JSON.stringify({ events, calls })); assert.equal(focus.intent, 'choose_birth_block'); assert.equal(focus.question_id, 'probe:block_scan.midnight_side'); assert.equal(focus.expected_answer_schema.probe_id, focus.question_id); assert.equal(focus.expected_answer_schema.semantic_key, 'block_scan.midnight_side'); assert.equal(focus.expected_answer_schema.midnight_side, true); assert.equal(focus.expected_answer_schema.scoring, false); assert.equal(focus.expected_answer_schema.choice_kind, 'block_choice'); assert.ok(reloadsAfterWrite > 0); const loaded = await loadV9CaseDossier(accounting, USER_ID, CASE_ID); const choice = parseAgentChoiceCopy(loaded.conversationSummary.activeFocus.expectedAnswerSchema); assert.ok(choice, JSON.stringify(loaded.conversationSummary.activeFocus)); assert.match(choice.prompt, /午夜前.*午夜后/); assert.deepEqual(choice.options.map(option => option.key), ['A', 'B', 'C', 'D']); const schema = focus.expected_answer_schema; const late = [{ start_at: '2000-06-15T23:00', end_at: '2000-06-15T23:59' }]; const early = [{ start_at: '2000-06-15T00:00', end_at: '2000-06-15T03:59' }]; assert.deepEqual(schema.block_periods.A.candidate_intervals, late); assert.deepEqual(schema.block_periods.B.candidate_intervals, early); assert.deepEqual(schema.block_periods.C.candidate_intervals, range.candidate_intervals); for (const [optionId, intervals, start, end] of [ ['A', late, '23:00', '23:59'], ['B', early, '00:00', '03:59'], ['C', range.candidate_intervals, '23:00', '03:59'], ['D', range.candidate_intervals, '23:00', '03:59'], ]) { const selected = windowFromBlockChoice({ schema, optionId }); assert.deepEqual(selected.candidate_intervals, intervals, optionId); assert.equal(selected.start_time, start, optionId); assert.equal(selected.end_time, end, optionId); assert.equal(selected.midnight_side_pending, false, optionId); } assert.ok(events.some(event => event.type === 'run.completed'), JSON.stringify({ events, calls })); assert.equal(events.some(event => ['run.failed', 'error', 'attempt.reset'].includes(event.type)), false, JSON.stringify(events)); assert.equal(calls.filter(call => call.fn === 'create_agentic_rectification_run_attempt').length, 1); assert.equal(calls.findLast(call => call.fn === 'finalize_agentic_rectification_turn')?.args.p_status, 'completed'); const writes = calls.filter(call => call.fn === 'set_agentic_rectification_conversation_focus'); assert.equal(writes.length, 2); // Initial creation, then linking the completed opening turn. assert.equal(writes[0].args.p_asked_turn_id, null); assert.equal(writes[1].args.p_asked_turn_id, TURN_ID); assert.deepEqual(writes[1].args.p_expected_answer_schema, writes[0].args.p_expected_answer_schema); assert.ok(calls.findIndex(call => call.fn === 'set_agentic_rectification_conversation_focus') < calls.findIndex(call => call.fn === 'create_agentic_rectification_run_attempt')); // The public mapper drops the route's internal done marker; run.completed + EOF is its public completion contract. assert.equal(safePublicEvent({ type: 'done', emitted: true }), null); assert.equal(events.at(-1)?.type, 'run.completed'); console.log(JSON.stringify({ focusQuestion: focus.question_id, reloadsAfterWrite, focusWrites: calls.filter(call => call.fn === 'set_agentic_rectification_conversation_focus').length, eventTypes: events.map(event => event.type) })); `; const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], { cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8", timeout: 60_000, }); assert.equal(result.status, 0, result.stderr + result.stdout); console.log(result.stdout.trim()); });