Files
Jyotisha/frontend/tests/rectification-latency-20260926.test.ts
T
Jesse_ChenandClaude Opus 5.5 a00c40d4b8 refactor(rectification): split runV9AgentTurn into prepare / attempt / retry / finish
Move-only (TASK-rectification-code-split-20260926 T3/T4). No behavior,
receipt, billing, Skill or scoring change.

- agent-run.ts: 1400 -> 135 lines; runV9AgentTurn 1046 -> 25 lines, no
  nested functions. It keeps the public types, the budget constants and the
  phase order: agent-run-prepare.ts (reliability, session, year re-ask, Skill
  identity, delivered guard, reserve, turn row / replay),
  agent-run-retry.ts (attempt loop), agent-run-attempt.ts (the old nested
  streamAttempt), agent-run-finish.ts (billing settle, receipts, interview,
  finalize), agent-run-support.ts (outcome types, retry classification, turn
  receipt writers), agent-run-messages.ts (buildAgentMessages /
  buildOpeningBrief, re-exported).
- One token changed with the move: the attempt passes its own
  `previousErrorCode` argument to buildAgentMessages instead of reading the
  enclosing `lastAttemptError`; the loop passes that same value (clears the
  old unused-parameter warning).
- Tests: whole-source contracts read tests/rectification-agent-run-surface.ts;
  behavioral runV9AgentTurn tests unchanged. agent-run growth caps added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:52:51 +08:00

563 lines
28 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.
/**
* BUG-1047 (TASK-rectification-latency-20260926): a typed rectification answer
* waited 25–90 s behind an unbounded classifier and four thinking steps with
* no visible progress, and the run diagnostic could not say where the time
* went.
*
* Covered here (Node 20-runnable, no module mocks):
* - D2: each classifier attempt is capped at 10 s and a hang takes the
* existing retry → `classifier_unavailable` path; success is timed too.
* - D3: stage progress is monotonic, reaches the stream as `turn.progress`,
* passes the public allowlist, and never lands in the saved reply or phases.
* - D4: step start/end, provider reasoning tokens, classifier timing and
* engine call durations reach `RectificationRunDiagnostic` without text.
* - D5: `/v5/versions` memo, and the skipped second idle interview call.
* The route-level "first byte before the classifier" test needs module mocks
* and lives in `rectification-latency-route-20260926.test.ts`.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
CASE_ID,
RECTIFICATION_SKILL_SHA256,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
import {
RECTIFICATION_CLASSIFIER_ATTEMPT_TIMEOUT_MS,
classifyTurnIntentWithRetry,
} from "../src/lib/rectification-agentic/v9/turn-intent-classifier.ts";
import {
createTurnInstrumentation,
currentEngineCallTimings,
reportTurnProgress,
runWithTurnInstrumentation,
type RectificationTurnProgressStage,
} from "../src/lib/rectification-agentic/v9/turn-instrumentation.ts";
import {
RECTIFICATION_ENGINE_VERSIONS_CACHE_TTL_MS,
readV9EngineScoringIdentity,
} from "../src/lib/rectification-agentic/v9/engine-client.ts";
import { safePublicEvent, turnProgressForChunk } from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import {
diagnosticStepsFromTimings,
recordStepChunk,
type RectificationStepTiming,
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
import { runV9AgentTurn, type V9AgentRunOptions } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { finalizeSuccessfulTurnExit } from "../src/lib/rectification-agentic/v9/turn-exit.ts";
import { RECTIFICATION_TURN_PROGRESS_LABELS } from "../src/lib/rectification-activity-labels.ts";
import { rectificationInitialLiveLabel } from "../src/lib/rectification-surface-state.ts";
import { rectificationAgentRouteSurface } from "./rectification-agent-route-surface.ts";
import { rectificationAgentRunSurface } from "./rectification-agent-run-surface.ts";
const dummyModel = { id: "test-model" } as ResolvedLanguageModel;
const PROGRESS_LINES = Object.values(RECTIFICATION_TURN_PROGRESS_LABELS);
async function flush(times = 5) {
for (let i = 0; i < times; i += 1) await Promise.resolve();
}
// ---------------------------------------------------------------- D2 classifier
test("D2: a hanging classifier attempt ends at 10 s, retries once, then takes classifier_unavailable", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
assert.equal(RECTIFICATION_CLASSIFIER_ATTEMPT_TIMEOUT_MS, 10_000);
const signals: AbortSignal[] = [];
const warnings: unknown[][] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => { warnings.push(args); };
try {
let settled = false;
const pending = classifyTurnIntentWithRetry(
dummyModel,
{ userMessage: "2016 年 3 月入学", caseStatus: "collecting_evidence" },
(_model, input) => {
if (input.signal) signals.push(input.signal);
return new Promise(() => {}); // provider never answers
},
).then((result) => { settled = true; return result; });
await flush();
assert.equal(signals.length, 1);
t.mock.timers.tick(9_999);
await flush();
assert.equal(settled, false, "no answer before 10 s");
assert.equal(signals[0].aborted, false);
t.mock.timers.tick(1);
await flush();
assert.equal(signals[0].aborted, true, "the first attempt is aborted at 10 s");
assert.equal(signals.length, 2, "exactly one retry starts");
assert.equal(settled, false);
t.mock.timers.tick(10_000);
const result = await pending;
assert.equal(result.outcome, "classifier_unavailable");
assert.equal(result.classified, null);
assert.equal(result.expectedWrite, "unknown");
assert.equal(result.diagnostic?.attempts, 2);
assert.equal(result.diagnostic?.timedOutAttempts, 2);
assert.equal(signals[1].aborted, true);
const line = JSON.stringify(warnings[0]);
assert.match(line, /rectification_classifier_unavailable/);
assert.match(line, /timedOutAttempts/);
assert.doesNotMatch(line, /2016|入学/);
} finally {
console.warn = originalWarn;
}
});
test("D2: a first attempt that times out and a second that answers is classified (existing retry)", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let calls = 0;
const pending = classifyTurnIntentWithRetry(
dummyModel,
{ userMessage: "没有", caseStatus: "collecting_evidence" },
async () => {
calls += 1;
if (calls === 1) return new Promise(() => {});
return { intent: "answer_current_focus", answer_class: "no" } as const;
},
);
await flush();
t.mock.timers.tick(10_000);
const result = await pending;
assert.equal(calls, 2);
assert.equal(result.outcome, "classified");
assert.equal(result.diagnostic?.timedOutAttempts, 1);
});
test("D2/D4: a successful classification logs its timing without the user text", async () => {
const lines: string[] = [];
const originalInfo = console.info;
console.info = (...args: unknown[]) => { lines.push(args.map(String).join(" ")); };
try {
const result = await classifyTurnIntentWithRetry(
dummyModel,
{ userMessage: "2019 年换了工作", caseStatus: "collecting_evidence" },
async () => ({ intent: "provide_new_evidence", answer_class: null, has_new_dated_event: true }),
);
assert.equal(result.outcome, "classified");
assert.equal(result.diagnostic?.attempts, 1);
assert.equal(result.diagnostic?.timedOutAttempts, 0);
assert.equal(typeof result.diagnostic?.elapsedMs, "number");
} finally {
console.info = originalInfo;
}
const line = lines.find((item) => item.includes("RectificationClassifierDiagnostic"));
assert.ok(line, "success is logged too");
assert.doesNotMatch(line, /2019|换了工作|provide_new_evidence/);
});
test("D2 red line: no model swap, thinking untouched, route uses the default 10 s cap", () => {
const classifier = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-intent-classifier.ts", import.meta.url), "utf8");
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
// The Agent is still built on the session model with no thinking override.
assert.match(classifier, /model: model\.model,/);
assert.doesNotMatch(classifier, /thinking:|thinkingTokens|providerOptions|modelSettings|agentGenerationSettings/);
assert.doesNotMatch(route, /attemptTimeoutMs/);
assert.equal((route.match(/await classifyTurnIntentWithRetry\(/g) ?? []).length, 4);
});
// ---------------------------------------------------------------- D3 progress
test("D3: stages only move forward, reach the sink, and reset for a retried attempt", async () => {
const seen: RectificationTurnProgressStage[] = [];
await runWithTurnInstrumentation({ onProgress: (stage) => seen.push(stage) }, async (instrumentation) => {
instrumentation.advance("received");
reportTurnProgress("recording");
await Promise.resolve();
reportTurnProgress("received"); // lower: ignored
reportTurnProgress("rescoring");
reportTurnProgress("rescoring"); // same: ignored
instrumentation.resetStage();
reportTurnProgress("received");
reportTurnProgress("preparing_question");
});
assert.deepEqual(seen, ["received", "recording", "rescoring", "received", "preparing_question"]);
// Outside a turn scope the calls are silent no-ops.
reportTurnProgress("recording");
assert.deepEqual(currentEngineCallTimings(), []);
});
test("D3: the scope follows async work started inside run(), including a ReadableStream start", async () => {
const seen: string[] = [];
const instrumentation = createTurnInstrumentation();
const stream = instrumentation.run(() => new ReadableStream<string>({
async start(controller) {
instrumentation.setProgressSink((stage) => controller.enqueue(stage));
instrumentation.advance("received");
await new Promise((resolve) => setTimeout(resolve, 5));
reportTurnProgress("rescoring"); // e.g. from the engine client, deep in the turn
controller.close();
},
}));
const reader = stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
seen.push(value);
}
assert.deepEqual(seen, ["received", "rescoring"]);
});
test("D3: turn.progress and turn.rejected pass the public allowlist, nothing else rides along", () => {
assert.deepEqual(safePublicEvent({ type: "turn.progress", stage: "recording", text: "x" }), {
type: "turn.progress",
stage: "recording",
});
assert.equal(safePublicEvent({ type: "turn.progress", stage: "thinking" }), null);
assert.deepEqual(
safePublicEvent({ type: "turn.rejected", httpStatus: 409, code: "stale_question", message: "这道题已经过期", secret: 1 }),
{ type: "turn.rejected", httpStatus: 409, code: "stale_question", message: "这道题已经过期" },
);
assert.equal(safePublicEvent({ type: "turn.rejected", httpStatus: 200, message: "ok" }), null);
assert.deepEqual(
safePublicEvent({ type: "turn.rejected", httpStatus: 500, code: "Bad Code!", message: "m" }),
{ type: "turn.rejected", httpStatus: 500, message: "m" },
);
});
test("D3: tool events map to the four stages", () => {
const call = (toolName: string) => ({ type: "tool-call", payload: { toolName } }) as never;
const result = (toolName: string) => ({ type: "tool-result", payload: { toolName, result: { ok: true } } }) as never;
assert.equal(turnProgressForChunk(call("rectification-read-case")), null);
assert.equal(turnProgressForChunk(call("rectification-record-evidence-batch")), "recording");
assert.equal(turnProgressForChunk(result("rectification-record-evidence-batch")), "preparing_question");
assert.equal(turnProgressForChunk(call("rectification-compare-candidates")), "rescoring");
assert.equal(turnProgressForChunk(call("rectification-set-focus")), "preparing_question");
assert.equal(turnProgressForChunk(call("skill")), null);
assert.equal(turnProgressForChunk({ type: "text-delta", payload: { text: "x" } } as never), null);
});
test("D3 copy: the four lines, and a typed answer starts on the first one", () => {
assert.deepEqual(RECTIFICATION_TURN_PROGRESS_LABELS, {
received: "收到,正在对照你的档案…",
recording: "正在记下这件事…",
rescoring: "正在重新对照盘面…",
preparing_question: "正在准备下一个问题…",
});
assert.equal(rectificationInitialLiveLabel("message"), RECTIFICATION_TURN_PROGRESS_LABELS.received);
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
const design = readFileSync(new URL("../DESIGN.md", import.meta.url), "utf8");
for (const line of PROGRESS_LINES) {
assert.ok(voice.includes(line), `VOICE lists ${line}`);
assert.ok(design.includes(line), `DESIGN lists ${line}`);
}
});
// ---------------------------------------------------------------- D4 run diagnostic
test("D4: step timeline records start/end, public tools and provider usage per step", () => {
const steps: RectificationStepTiming[] = [];
const isPublic = (name: string) => name.startsWith("rectification-");
recordStepChunk(steps, { type: "step-start" }, 10, isPublic);
recordStepChunk(steps, { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } }, 900, isPublic);
recordStepChunk(steps, { type: "step-finish", payload: { output: { usage: { inputTokens: 1200, outputTokens: 40, reasoningTokens: 700 } } } }, 1_000, isPublic);
recordStepChunk(steps, { type: "step-start" }, 1_050, isPublic);
recordStepChunk(steps, { type: "tool-call", payload: { toolName: "skill" } }, 1_060, isPublic);
recordStepChunk(steps, { type: "step-finish", payload: { output: { usage: { inputTokens: 1300, outputTokens: 20 } } } }, 4_000, isPublic);
const summary = diagnosticStepsFromTimings(steps);
assert.equal(summary.stepCount, 2);
assert.deepEqual(summary.steps.map((step) => [step.startMs, step.endMs, step.tools]), [
[10, 1_000, ["rectification-read-case"]],
[1_050, 4_000, []],
]);
assert.equal(summary.reasoningTokens, 700);
assert.equal(summary.inputTokens, 2_500);
assert.equal(summary.toolCallCount, 1);
assert.equal(diagnosticStepsFromTimings([]).reasoningTokens, null, "unreported stays null, not 0");
});
function chunk(type: string, payload?: Record<string, unknown>) {
return { type, ...(payload ? { payload } : {}) };
}
function fakeAgent(chunks: Array<{ type: string; payload?: Record<string, unknown> }>) {
return {
stream: async () => ({
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
}),
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
test("D3/D4: an Agent turn reports stages, a full diagnostic, and never persists a progress line", async () => {
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
});
const emitted: Array<{ type: string }> = [];
const stages: RectificationTurnProgressStage[] = [];
const infos: string[] = [];
const originalInfo = console.info;
console.info = (...args: unknown[]) => { infos.push(args.map(String).join(" ")); };
const options: V9AgentRunOptions = {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "read_only",
message: null,
modelName: "test-model",
accounting: accounting.client,
billing: {
reserve: async () => ({ success: true, status: 200 }),
complete: async () => true,
release: async () => true,
},
emit: (event) => { emitted.push(event); },
classifierDiagnostic: { outcome: "classified", attempts: 1, timedOutAttempts: 0, elapsedMs: 4_200 },
buildAgent: async () => fakeAgent([
chunk("start"),
chunk("step-start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("step-finish", { output: { usage: { inputTokens: 900, outputTokens: 30, reasoningTokens: 512 } } }),
chunk("step-start"),
chunk("tool-call", { toolName: "rectification-record-evidence-batch", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-record-evidence-batch", result: { accepted: [] } }),
chunk("step-finish", { output: { usage: { inputTokens: 950, outputTokens: 40, reasoningTokens: 256 } } }),
chunk("step-start"),
chunk("text-delta", { text: "记下了。" }),
chunk("step-finish", { output: { usage: { inputTokens: 980, outputTokens: 12 } } }),
chunk("finish"),
]) as never,
};
let result;
try {
result = await runWithTurnInstrumentation(
{ onProgress: (stage) => stages.push(stage) },
() => runV9AgentTurn(options),
);
} finally {
console.info = originalInfo;
}
assert.equal(result.ok, true, JSON.stringify(result));
assert.deepEqual(stages, ["recording", "preparing_question"]);
// Progress never goes through the persisted phase/answer channel.
assert.equal(emitted.some((event) => event.type === "turn.progress"), false);
const persistedText = JSON.stringify(accounting.calls.map((call) => call.args));
for (const line of PROGRESS_LINES) assert.ok(!persistedText.includes(line), `not persisted: ${line}`);
assert.equal(persistedText.includes("turn.progress"), false);
const finalize = accounting.calls.findLast((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(finalize?.args.p_assistant_message, "记下了。");
const line = infos.find((item) => item.includes("RectificationRunDiagnostic"));
assert.ok(line);
const diagnostic = JSON.parse(line);
assert.equal(diagnostic.stepCount, 3);
assert.equal(diagnostic.reasoningTokens, 768);
assert.equal(diagnostic.inputTokens, 2_830);
assert.equal(diagnostic.toolCallCount, 2);
assert.equal(diagnostic.distinctToolCount, 2);
assert.deepEqual(diagnostic.steps.map((step: { tools: string[] }) => step.tools), [
["rectification-read-case"],
["rectification-record-evidence-batch"],
[],
]);
for (const step of diagnostic.steps) {
assert.equal(typeof step.startMs, "number");
assert.ok(step.endMs >= step.startMs);
}
assert.deepEqual(diagnostic.classifier, { outcome: "classified", attempts: 1, timedOutAttempts: 0, elapsedMs: 4_200 });
assert.deepEqual(diagnostic.engineCalls, []);
assert.equal(diagnostic.attemptNumber, 1);
assert.doesNotMatch(line, /记下了|RECTIFICATION_SKILL|北京|1990/);
assert.ok(!line.includes(RECTIFICATION_SKILL_SHA256));
});
// ---------------------------------------------------------------- D4 engine timings + D5 versions memo
function isolateIdentityEnv(t: { after(fn: () => void): void }) {
const saved = {
algorithm: process.env.RECTIFICATION_ALGORITHM_VERSION,
policy: process.env.RECTIFICATION_DECISION_POLICY_VERSION,
engine: process.env.RECTIFICATION_ENGINE_VERSION,
};
delete process.env.RECTIFICATION_ALGORITHM_VERSION;
delete process.env.RECTIFICATION_DECISION_POLICY_VERSION;
delete process.env.RECTIFICATION_ENGINE_VERSION;
t.after(() => {
for (const [key, value] of [
["RECTIFICATION_ALGORITHM_VERSION", saved.algorithm],
["RECTIFICATION_DECISION_POLICY_VERSION", saved.policy],
["RECTIFICATION_ENGINE_VERSION", saved.engine],
] as const) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
}
const VERSIONS = { algorithm_version: "rectification-v5-matrix-scoring-8", decision_policy_version: "rectification-candidate-policy-v3" };
test("D5: /v5/versions is read once per TTL when env does not pin the identity; engine timing is recorded", async (t) => {
isolateIdentityEnv(t);
let fetches = 0;
t.mock.method(globalThis, "fetch", async (url: unknown) => {
fetches += 1;
assert.ok(String(url).endsWith("/api/rectification/v5/versions"));
return Response.json(VERSIONS);
});
let now = 1_000_000;
t.mock.method(Date, "now", () => now);
const timings = await runWithTurnInstrumentation({}, async () => {
const first = await readV9EngineScoringIdentity();
const second = await readV9EngineScoringIdentity();
assert.deepEqual(first, { algorithmVersion: VERSIONS.algorithm_version, policyVersion: VERSIONS.decision_policy_version });
assert.deepEqual(second, first);
assert.notEqual(second, first, "callers get their own object");
return currentEngineCallTimings();
});
assert.equal(fetches, 1);
assert.deepEqual(timings.map((item) => [item.path, item.outcome]), [["/api/rectification/v5/versions", "ok"]]);
now += RECTIFICATION_ENGINE_VERSIONS_CACHE_TTL_MS - 1;
await readV9EngineScoringIdentity();
assert.equal(fetches, 1, "still inside the TTL");
now += 2;
await readV9EngineScoringIdentity();
assert.equal(fetches, 2, "TTL expired: read again");
});
test("D5: failures and incomplete version payloads are never memoised", async (t) => {
isolateIdentityEnv(t);
let fetches = 0;
t.mock.method(globalThis, "fetch", async () => {
fetches += 1;
return fetches <= 2 ? Response.json({ algorithm_version: VERSIONS.algorithm_version }) : Response.json(VERSIONS);
});
assert.equal((await readV9EngineScoringIdentity()).policyVersion, null);
assert.equal((await readV9EngineScoringIdentity()).policyVersion, null);
assert.equal((await readV9EngineScoringIdentity()).policyVersion, VERSIONS.decision_policy_version);
assert.equal(fetches, 3);
t.mock.method(globalThis, "fetch", async () => { throw new Error("engine down"); });
assert.deepEqual(await readV9EngineScoringIdentity(), { algorithmVersion: null, policyVersion: null },
"a different transport never sees another transport's memo");
});
// ---------------------------------------------------------------- D5 second idle call
function focusDossier() {
return dossierFixture({
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
intent: "distinguish_candidates",
questionId: "d9:relationship:2023",
targetDomain: "relationship",
expectedAnswerSchema: {
prompt: "2023 年前后,你有没有一段认真开始或结束的关系?",
choice: {
prompt: "2023 年前后,你有没有一段认真开始或结束的关系?",
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" },
],
},
},
}),
}),
});
}
function focusAccounting() {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => focusDossier(),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: { ...activeFocusFixture({ intent: "distinguish_candidates", questionId: String(args.p_question_id) }), asked_turn_id: args.p_asked_turn_id },
idempotent: true,
}),
});
}
test("D5: with the next focus already active, a second idle call is a pure repeat — so skipping it is output-identical", async () => {
const accounting = focusAccounting();
const first = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, askedTurnId: TURN_ID });
const writesAfterFirst = accounting.calls.filter((call) => call.fn === "set_agentic_rectification_conversation_focus").map((call) => call.args);
const second = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, askedTurnId: TURN_ID });
const writesAfterSecond = accounting.calls.filter((call) => call.fn === "set_agentic_rectification_conversation_focus").map((call) => call.args);
assert.equal(first.focusActive, true);
assert.deepEqual(second, first);
// The only write the second call makes is the identical idempotent re-link.
assert.equal(writesAfterSecond.length, writesAfterFirst.length * 2);
assert.deepEqual(writesAfterSecond.slice(writesAfterFirst.length), writesAfterFirst);
});
test("D5: the exit gate skips its idle re-read only when told the run settled the interview", async () => {
// Both arms first make the Agent run's own post-turn call, as agent-run.ts does.
const run = async (interviewSettled: boolean) => {
const accounting = focusAccounting();
const idle = await persistNextInterviewIfIdle({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, askedTurnId: TURN_ID });
assert.equal(idle.focusActive, true);
await finalizeSuccessfulTurnExit({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
action: "message",
askedTurnId: TURN_ID,
interviewSettled,
});
return accounting.calls;
};
const unsettled = await run(false);
const settled = await run(true);
const writes = (calls: typeof settled) => calls
.filter((call) => call.fn !== "get_agentic_rectification_case_dossier" && call.fn !== "get_agentic_rectification_case_compute")
.map((call) => JSON.stringify([call.fn, call.args]));
assert.ok(settled.length < unsettled.length, `${settled.length} < ${unsettled.length}`);
// Same distinct writes either way: the skipped call only re-linked the same focus.
assert.deepEqual([...new Set(writes(settled))].sort(), [...new Set(writes(unsettled))].sort());
});
test("D5 contract: the Agent run reports interviewSettled only from the focus-active exit; the route forwards it", () => {
// 原值: 读 lib/rectification-agentic/v9/agent-run.ts 单文件。新值: rectificationAgentRunSurface(agent-run.ts + 拆出的 agent-run-*.ts,见 rectification-agent-run-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const agentRun = rectificationAgentRunSurface;
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
assert.match(agentRun, /interviewSettled = interviewIdle\.focusActive === true;/);
assert.match(route, /interviewSettled: result\.interviewSettled === true/);
});
// ---------------------------------------------------------------- D3 route shape (source)
test("D3 contract: a typed message builds the stream first and classifies inside it", () => {
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
assert.match(route, /const deferToStream = action === "message";/);
assert.match(route, /const immediateResponse = deferToStream \? null : await computeImmediateResponse\(\);/);
const start = route.indexOf("async start(controller)");
const inStream = route.slice(start);
const advance = inStream.indexOf('instrumentation.advance("received")');
const preflight = inStream.indexOf("await computeImmediateResponse()");
const unfocused = inStream.indexOf("await classifyUnfocusedMessage()");
const agent = inStream.indexOf("const result = await runV9AgentTurn");
assert.ok(start > 0 && advance > 0, "first progress line is inside the stream");
assert.ok(advance < preflight && preflight < unfocused && unfocused < agent);
// Before the stream exists nothing runs the classifier for a message: the
// preflight is only invoked outside the stream when it is not deferred, and
// the unfocused classification is only invoked inside the stream.
assert.equal((route.match(/await computeImmediateResponse\(\)/g) ?? []).length, 2);
assert.equal((route.match(/await classifyUnfocusedMessage\(\)/g) ?? []).length, 1);
assert.equal((inStream.match(/await computeImmediateResponse\(\)/g) ?? []).length, 1);
// The instrumentation scope wraps the stream construction.
assert.match(route, /const body = instrumentation\.run\(\(\) => new ReadableStream<Uint8Array>\(\{/);
});