Files
Jyotisha/frontend/tests/application-billing-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 eef0cb7486 fix(consult): write the answer in the step that saw the chart (BUG-1053)
The natal loop's step after run-jyotish-consultation saw the evidence, but
its text was drained and a second, blind compose stream (history + question
only, empty findings) wrote the user-visible answer. Remove compose,
interpret and the drain; keep the loop's own final-step text.

- stepScopedAnswer: per-step holding; text of a step that calls a tool is
  dropped, so narration around tool calls never reaches the answer
- writing shape (opener + four headings) moves into the user turn
- length continuation receives the calculation result; Pass 4 whole-answer
  reject retries through retryForAnswer with the rewrite hint
- createConsultationRunClock: tools keep the 110s tool phase; the loop is
  handed to the 70s answer clock when the calculation result arrives
- settlement judges the step that wrote the answer (BUG-1051 kept)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 01:01:38 +08:00

231 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);
// 原值: /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<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,/);
});