Files
Jyotisha/frontend/tests/application-billing-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 eb773f1299 refactor(rectification): split POST /api/rectification/agent into branch handlers
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
2026-09-26 19:40:07 +08:00

228 lines
14 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 { 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,/);
});