Files
Jyotisha/frontend/tests/rectification-grounding-route-20260927.test.ts
T
Jesse_ChenandClaude Opus 5.5 5a8084494b 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 03:28:27 +08:00

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