Carry explicit local date intervals instead of inferring the day from clock order. Cluster width, delivery, adoption, and reports keep the actual civil date; adopted date is stored separately from the reported birth_date. Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation thresholds, and Skill version are unchanged. Isolated Linux final-3 gates passed; four pre-existing Python failures remain. This is not a production release.
152 lines
10 KiB
TypeScript
152 lines
10 KiB
TypeScript
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());
|
|
});
|