Move-only (TASK-rectification-code-split-20260926 T2/T4). No behavior, API, copy, DB, Skill, billing or scoring change. - route.ts: 1077 -> 152 lines; POST 927 -> 114. It builds the Supabase clients (so the setup-failure mapping stays here) and assembles: agent-route-request.ts (auth / product / schema / flag / Case-Session binding), agent-route-typed-message.ts (declared-window reply, typed-answer preflight, unfocused classification), agent-route-structured-choice.ts, agent-route-agent-turn.ts (opening / read-only / typed agent stream and its exit gate), agent-route-billing.ts, agent-route-support.ts (schema, one-shot NDJSON reply, context types). - The four mutable preflight lets (expectedWrite, collectIntent, writeClassified, classifierDiagnostic) that crossed branches are one turnState object; values and flow unchanged. - Tests: whole-source contracts read tests/rectification-agent-route-surface.ts; the typed fast-path slice is rebuilt from the new files; billing, declared-window and structured-choice slices now call the handlers (billing in a child process because feature-pricing imports server-only), each with 原值/新值/原因. Route growth caps added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
228 lines
14 KiB
TypeScript
228 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { spawnSync } from "node:child_process";
|
||
import { readFileSync } from "node:fs";
|
||
import { fileURLToPath } from "node:url";
|
||
import test from "node:test";
|
||
import { completeUsage } from "../src/lib/consultation-billing.ts";
|
||
import { rectificationAgentRouteSurface } from "./rectification-agent-route-surface.ts";
|
||
|
||
const root = new URL("../", import.meta.url);
|
||
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
|
||
const rectificationRoute = rectificationAgentRouteSurface;
|
||
const consultRoute = readFileSync(new URL("src/app/api/consult/route.ts", root), "utf8");
|
||
const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8");
|
||
|
||
function sourceBetween(source: string, start: string, end: string): string {
|
||
const startIndex = source.indexOf(start);
|
||
const endIndex = source.indexOf(end, startIndex + start.length);
|
||
assert.ok(startIndex >= 0, `missing start marker: ${start}`);
|
||
assert.ok(endIndex > startIndex, `missing end marker: ${end}`);
|
||
return source.slice(startIndex, endIndex);
|
||
}
|
||
|
||
test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => {
|
||
assert.match(rectificationRoute, /import \{ createServerSupabaseClient \} from "@\/lib\/supabase\/server"/);
|
||
assert.match(rectificationRoute, /supabase = await createServerSupabaseClient\(\)/);
|
||
// Former value: select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id")
|
||
// Messages were unused on this route and inflated every rectification turn.
|
||
assert.match(rectificationRoute, /select\("id,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
|
||
assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
|
||
assert.match(rectificationRoute, /modelConfigVersion: selectedModel\.configVersion/);
|
||
assert.doesNotMatch(rectificationRoute, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
|
||
assert.match(rectificationRoute, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/);
|
||
assert.match(rectificationRoute, /from\("usage_reservations"\)[\s\S]*\.eq\("feature_key", "rectification"\)[\s\S]*\.like\("request_id", `\$\{billingRequestPrefix\}%`\)/);
|
||
assert.match(rectificationRoute, /return reservations.length === 0[\s\S]*`\$\{billingRequestPrefix\}:retry:\$\{reservations.length\}`/);
|
||
assert.match(rectificationRoute, /authorizeUsage\(accounting, \{[\s\S]*requestId: billingRequestId/);
|
||
assert.match(rectificationRoute, /completeUsage\(accounting, userId, billingRequestId,/);
|
||
assert.match(rectificationRoute, /releaseUsage\(accounting, userId, billingRequestId,/);
|
||
});
|
||
|
||
test("Agentic rectification preserves pricing failure codes for logs and maps them to one safe public error", () => {
|
||
const reserveBilling = sourceBetween(rectificationRoute, "async reserve() {", "async complete(usage) {");
|
||
const errorMapping = sourceBetween(rectificationRoute, "const code = error instanceof RectificationToolServiceError", " } finally {");
|
||
|
||
assert.match(rectificationRoute, /import \{ FeaturePricingError, resolveFeaturePricing \} from "@\/lib\/feature-pricing"/);
|
||
assert.match(reserveBilling, /error instanceof FeaturePricingError \? error\.code : "billing_unavailable"/);
|
||
assert.match(reserveBilling, /reserve failed case=\$\{caseId\} code=\$\{reason\}/);
|
||
assert.match(reserveBilling, /return \{ success: false, reason, status: 503 \}/);
|
||
assert.match(errorMapping, /code\.startsWith\("feature_pricing_"\) \|\| code === "billing_unavailable"/);
|
||
assert.match(errorMapping, /code: "billing_unavailable", message: "当前服务的计费配置不可用,请联系支持人员。"/);
|
||
assert.ok(errorMapping.indexOf('code: "billing_unavailable"') < errorMapping.lastIndexOf('code: "run_failed"'));
|
||
assert.doesNotMatch(reserveBilling + errorMapping, /error\.message/);
|
||
});
|
||
|
||
test("free Agentic rectification turns bypass reservation, completion, and cancellation settlement", async () => {
|
||
|
||
// 旧:enum 到 stop_and_review
|
||
// 新:增加 skip_probe;非 message 仍不计费
|
||
// 原因:决策 4,核对跳过走即时结构化动作
|
||
assert.match(
|
||
rectificationRoute,
|
||
/action: z\.enum\(\["opening", "message", "read_only", "answer_choice", "stop_and_review", "skip_probe"\]\)/,
|
||
);
|
||
assert.equal(rectificationRoute.match(/if \(action !== "message"\)/g)?.length, 3);
|
||
// 原值: 从 route.ts 切 reserve / complete / release 三段(release 以 ` };\n\n try {` 收尾),
|
||
// 各匹配 `if (action !== "message")` 早退与 authorizeUsage / completeUsage / releaseUsage 调用。
|
||
// 新值: 调用 createRectificationRunBilling:非 message 动作三步都直接成功、一次也不碰账务库;
|
||
// message 动作三步都真的去读账务库(库不可用时 reserve 回 503 billing_unavailable,complete / release 回 false)。
|
||
// 原因: TASK-rectification-code-split-20260926 billing 对象原样搬进 agent-route-billing.ts,原 release 段的
|
||
// 收尾锚点不复存在;直接调用比按锚点切源码更强。
|
||
// feature-pricing.ts imports "server-only", so the adapter runs in a child
|
||
// process with that module mocked (same harness as the other route tests).
|
||
const script = String.raw`
|
||
import { mock } from "node:test";
|
||
mock.module("server-only", { namedExports: {} });
|
||
const { createRectificationRunBilling } = await import("./src/lib/rectification-agentic/v9/agent-route-billing.ts");
|
||
const touched = [];
|
||
const unreachableAccounting = new Proxy({}, {
|
||
get(_target, property) {
|
||
return () => {
|
||
touched.push(String(property));
|
||
throw new Error("billing store unavailable");
|
||
};
|
||
},
|
||
});
|
||
const model = { id: "model-a", configVersion: 1, inputCostMicrousdPerMillion: 0, outputCostMicrousdPerMillion: 0 };
|
||
const billingFor = (action) => createRectificationRunBilling({
|
||
action,
|
||
accounting: unreachableAccounting,
|
||
userId: "user-1",
|
||
caseId: "case-1",
|
||
requestId: "request-1",
|
||
selectedModel: model,
|
||
});
|
||
console.error = () => {};
|
||
console.warn = () => {};
|
||
const free = [];
|
||
for (const action of ["opening", "read_only", "answer_choice", "stop_and_review", "skip_probe"]) {
|
||
const billing = billingFor(action);
|
||
free.push({
|
||
action,
|
||
reserve: await billing.reserve(),
|
||
complete: await billing.complete({ inputTokens: 10, outputTokens: 5, durationMs: 1 }),
|
||
release: await billing.release(),
|
||
});
|
||
}
|
||
const touchedByFree = [...touched];
|
||
const paid = billingFor("message");
|
||
const paidResult = {
|
||
reserve: await paid.reserve(),
|
||
complete: await paid.complete({ inputTokens: 10, outputTokens: 5, durationMs: 1 }),
|
||
release: await paid.release(),
|
||
};
|
||
process.stdout.write("AYANAM_RESULT " + JSON.stringify({ free, touchedByFree, paid: paidResult, touched }) + "\n");
|
||
`;
|
||
const result = spawnSync(process.execPath, [
|
||
"--experimental-test-module-mocks",
|
||
"--import",
|
||
"tsx",
|
||
"--input-type=module",
|
||
"--eval",
|
||
script,
|
||
], {
|
||
cwd: fileURLToPath(root),
|
||
encoding: "utf8",
|
||
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
||
});
|
||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||
const line = result.stdout.split("\n").find((row) => row.startsWith("AYANAM_RESULT "));
|
||
assert.ok(line, result.stdout);
|
||
const probe = JSON.parse(line.slice("AYANAM_RESULT ".length)) as {
|
||
free: Array<{ action: string; reserve: unknown; complete: boolean; release: boolean }>;
|
||
touchedByFree: string[];
|
||
paid: { reserve: unknown; complete: boolean; release: boolean };
|
||
touched: string[];
|
||
};
|
||
for (const turn of probe.free) {
|
||
assert.deepEqual(turn.reserve, { success: true, status: 200 }, turn.action);
|
||
assert.equal(turn.complete, true, turn.action);
|
||
assert.equal(turn.release, true, turn.action);
|
||
}
|
||
assert.deepEqual(probe.touchedByFree, [], "free turns never read or write the billing store");
|
||
assert.deepEqual(probe.paid.reserve, { success: false, reason: "billing_unavailable", status: 503 });
|
||
assert.equal(probe.paid.complete, false);
|
||
assert.equal(probe.paid.release, false);
|
||
assert.deepEqual(probe.touched, ["from", "from", "from"], "each paid step looks up the case-bound reservation first");
|
||
assert.match(rectificationRoute, /authorizeUsage\(/);
|
||
assert.match(rectificationRoute, /completeUsage\(/);
|
||
assert.match(rectificationRoute, /releaseUsage\(/);
|
||
});
|
||
|
||
|
||
test("standard consultation resolves and settles the session-pinned model version", () => {
|
||
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
|
||
// Former value: select("id,model_id,model_config_version,session_type,messages").
|
||
// First-round session titles need the current title, theme, and chart role.
|
||
// 原值: select includes chart_profile_role but not chart_profile_id or chart_profile_name.
|
||
// 新值: the same select also reads chart_profile_id and chart_profile_name.
|
||
// 原因: ordinary chat resolves the subject from the stored binding, not from chart_profile_role alone.
|
||
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages,title,theme,chart_profile_id,chart_profile_name,chart_profile_role,context_summary"\)/);
|
||
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
|
||
assert.match(consultRoute, /actualModelId: selectedModel\.id/);
|
||
assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/);
|
||
// 原值: 守卫 .eq("title", expectedTitle) 用 RPC 前快照,append_consultation_question 已改写标题,更新恒 0 行
|
||
// 新值: shouldGenerate 仍用 RPC 前标题;守卫比较 RPC 后再 select 到的 title
|
||
// 原因: BUG-557,模型标题必须能在刷新前落库
|
||
assert.match(consultRoute, /const expectedTitle = typeof chatSession\.title === "string" \? chatSession\.title : ""/);
|
||
assert.match(consultRoute, /shouldGenerateSessionTitle\(\{\s*title: expectedTitle,/);
|
||
assert.match(consultRoute, /expectedTitle: titleAfterRpc/);
|
||
assert.match(consultRoute, /persistGuardedSessionTitle/);
|
||
});
|
||
|
||
test("standard consultation awaits real usage before durable response settlement", () => {
|
||
assert.doesNotMatch(consultRoute, /recordActualUsage|void usage\.then/);
|
||
assert.doesNotMatch(consultRoute, /inputTokens: 0,[\s\S]*outputTokens: 0,[\s\S]*costMicrousd: 0/);
|
||
assert.match(consultRoute, /async function usagePayload\(usage: Promise<\{ inputTokens\?: number; outputTokens\?: number \}>\)/);
|
||
// 原值 await usage;新值等待咨询与分类 usage 合并;原因:分类成本也必须结算。
|
||
assert.match(consultRoute, /const resolved = await mergeUsage\(\[usage, Promise\.resolve\(classificationUsage\)\]\);/);
|
||
assert.match(consultRoute, /const actualUsage = await usagePayload\(usage\);[\s\S]*p_actual_usage: actualUsage/);
|
||
assert.match(consultRoute, /function mergeUsage\(usages: Promise<Usage>\[\]\): Promise<Usage> \{[\s\S]*Promise\.all\(usages\)/);
|
||
// Former: three agentic first streams each had `usages.push(result.totalUsage)`.
|
||
// New: the three first streams share streamWithOverflowRetry, which pushes the
|
||
// successful first attempt (and the overflow retry under a different name).
|
||
// Reason: context overflow must retry once on the same request.
|
||
assert.equal(consultRoute.match(/usages\.push\(result\.totalUsage\)/g)?.length, 1);
|
||
assert.match(consultRoute, /streamWithOverflowRetry/);
|
||
assert.match(consultRoute, /isContextOverflowError/);
|
||
// Three agentic first streams: public/general, declared-window, and natal.
|
||
// Window and natal each have a contract retry plus an answer retry; general
|
||
// has only an answer retry. Every one of those model calls spends tokens.
|
||
assert.equal(consultRoute.match(/usages\.push\(retried\.totalUsage\)/g)?.length, 5);
|
||
assert.equal(consultRoute.match(/const retryForAnswer = async \(\) => \{/g)?.length, 3);
|
||
});
|
||
|
||
test("standard consultation forwards its stable reservation request as the usage event key", async () => {
|
||
const eventKey = "00000000-0000-4000-8000-000000000002";
|
||
const calls: Record<string, unknown>[] = [];
|
||
const accounting = {
|
||
async rpc(_rpcName: string, args: Record<string, unknown>) {
|
||
calls.push(args);
|
||
return {
|
||
data: { success: true, reservation_id: "00000000-0000-4000-8000-000000000003", credits: 9, error_code: null },
|
||
error: null,
|
||
};
|
||
},
|
||
};
|
||
const usage = { eventKey, actualModelId: "model", inputTokens: 1, outputTokens: 2, costMicrousd: 3, durationMs: 4 };
|
||
|
||
await completeUsage(accounting, "00000000-0000-4000-8000-000000000001", eventKey, usage);
|
||
await completeUsage(accounting, "00000000-0000-4000-8000-000000000001", eventKey, usage);
|
||
|
||
assert.deepEqual(calls.map((call) => (call.p_actual_usage as { eventKey: string }).eventKey), [eventKey, eventKey]);
|
||
assert.match(consultRoute, /eventKey: requestId,/);
|
||
assert.match(consultRoute, /const actualUsage = await usagePayload\(usage\);[\s\S]*p_actual_usage: actualUsage/);
|
||
assert.doesNotMatch(consultRoute, /eventKey:\s*(?:globalThis\.)?crypto\.randomUUID\(\)/);
|
||
});
|
||
|
||
test("legacy admin packages cannot mutate detached payment_packages", () => {
|
||
assert.doesNotMatch(packagesRoute, /payment_packages|queryAdminRows|insert into|update public/);
|
||
assert.match(packagesRoute, /export function GET\(request: Request\)/);
|
||
assert.match(packagesRoute, /const replacement = "\/api\/admin\/products"/);
|
||
assert.match(packagesRoute, /NextResponse\.redirect\(new URL\(replacement, request\.url\), 308\)/);
|
||
for (const method of ["POST", "PATCH", "DELETE"]) {
|
||
assert.match(packagesRoute, new RegExp(`export function ${method}\\(\\)`));
|
||
}
|
||
assert.match(packagesRoute, /status: 410/);
|
||
assert.match(packagesRoute, /error: "旧套餐写接口已停用,请使用统一商品管理。",[\s\S]*replacement,/);
|
||
});
|