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\[\]\): Promise \{[\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); // 原值: /const retryForAnswer = async \(\) => \{/ 三处 // 新值: /const retryForAnswer = async \(retryHint\?: string\) => \{/ 三处 // 原因: BUG-1053 删除 compose 后,Pass 4 整篇被拒的重写提示改由 answer retry 携带;三处回答重试仍各计一次 usage assert.equal(consultRoute.match(/const retryForAnswer = async \(retryHint\?: string\) => \{/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[] = []; const accounting = { async rpc(_rpcName: string, args: Record) { 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,/); });