Files
Jyotisha/frontend/tests/rectification-grounding-route-20260927.test.ts
T
jesse-uxandJesse_Chen 6e16dc5e16 fix(rectification): keep one delivery and adopt with the birth date
Accept and GET now decide with the same birth snapshot as the answer, so an under-age probe cannot flip can_adopt. The exit skips a second write when this reply already delivered. The card stays without an adopt button when adoption is not allowed, and a refusal shows the Chinese sentence.
2026-10-06 16:03:57 +08:00

184 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' };
// 原值: 未知 RPC 返回 null,答题轮不读出生快照。
// 新值: get_agentic_rectification_case_compute 返回虚构 1900-06-15。
// 原因: 轮次收尾现在必须读到出生日期才写范围句;缺快照会中断,助手正文变成空字符串。断言原文不改。
else if (fn === 'get_agentic_rectification_case_compute') data = {
case_id: CASE_ID, skill_version: '9.0.0', baseline_profile_fingerprint: '${"a".repeat(64)}',
baseline_birth_snapshot: { birth_date: '1900-06-15' },
candidate_range: { start_time: '04:00', end_time: '06:00' },
};
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);
});