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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0e0baaa74c
commit
5a8084494b
@@ -220,6 +220,7 @@ export function respondWithRectificationAgentTurn(
|
||||
expectedWrite: turnState.expectedWrite,
|
||||
collectIntent: turnState.collectIntent,
|
||||
classifierDiagnostic: turnState.classifierDiagnostic,
|
||||
...(turnState.rangeBeforeTurn !== undefined ? { rangeBeforeTurn: turnState.rangeBeforeTurn } : {}),
|
||||
buildAgent: async (turnId, skillPackage, attemptId) => {
|
||||
let decision;
|
||||
try {
|
||||
|
||||
@@ -114,4 +114,11 @@ export type RectificationAgentTurnState = {
|
||||
collectIntent: "classified" | "unclassified" | null;
|
||||
writeClassified: boolean;
|
||||
classifierDiagnostic: TurnIntentClassifierDiagnostic | null;
|
||||
/**
|
||||
* Set when the preflight already applied a typed choice answer and hands the
|
||||
* rest of the message (a dated event) to the agent: the credible range from
|
||||
* before that answer, so the agent turn's one server range sentence covers
|
||||
* the whole message (BUG-1058). Undefined otherwise.
|
||||
*/
|
||||
rangeBeforeTurn?: readonly [string, string] | null;
|
||||
};
|
||||
|
||||
@@ -167,6 +167,10 @@ export async function handleRectificationTypedMessage(
|
||||
if (!continueToAgent) {
|
||||
return completedMessageResponse(applied.narration, requestId, caseId, applied.turnId);
|
||||
}
|
||||
// BUG-1058: the deferred choice narration is not persisted and the
|
||||
// agent turn reads the post-answer range, so hand it the range from
|
||||
// before this answer; the agent turn then says the change once.
|
||||
turnState.rangeBeforeTurn = previous?.credible_range ?? null;
|
||||
}
|
||||
if (classified?.intent === "stop_rectification" || classified?.intent === "ask_about_result") {
|
||||
const previous = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user