diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6f37ecd5..0fa8bb83 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -11285,3 +11285,19 @@ - 相关记录:BUG-473、BUG-249、BUG-250、BUG-260、BUG-617 - 复发自:BUG-473(影响面含本文件,但两条防复发只约束正在流的那一行) - 修复版本:待发布 + +## BUG-726 | 一轮校正把同一份 Case 档案从数据库取多次 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-16 +- 影响面:`loadV9CaseDossier` / `loadV9CaseCompute`、`POST /api/rectification/agent`、regenerate、Case GET/POST +- 用户现象:同一轮对话里档案被重复取回。用户看不到这条,但 2 vCPU 主机上这份 jsonb 投影与 Next.js、Python 引擎抢同一批核。 +- 触发条件:一次 HTTP 请求内多处调用 `get_agentic_rectification_case_dossier` 或 `get_agentic_rectification_case_compute`,中间没有写操作。 +- 根因:只读投影没有任何请求作用域缓存。每个调用点独立打 Postgres RPC。这不是回归,是 V9 运行时引入以来的分层遗漏。 +- 修复:新增 `withRectificationRequestCache`,挂在客户端对象上。两个只读投影按 `(fn, p_user_id, p_case_id)` 缓存 in-flight promise;其它 RPC 与 `.from(...)` 一律先清空再转发。生命周期等于一次请求。路由入口各包一处,零调用点改动,不跨请求缓存。 +- 验证:`frontend/tests/rectification-request-dossier-cache.test.ts`:连续两次读只打一次底层;中间其它 RPC 后必须重读;不同 caseId 互不命中;并发读合并;`.from` 透传后缓存清空;五条路由源码合同。既有 stream 50 / answer-choice 34 一条不改仍全绿。路由预取 + 证据轮 `case_dossier` 5→4。 +- 防复发:Case 只读投影必须经请求作用域缓存读取;新增只读投影要么进缓存白名单,要么在记录里写明为什么不能缓存。不得把档案挂在模块作用域或 `globalThis`。 +- 相关记录:BUG-176 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-request-dossier-cache-20260915.md b/docs/tasks/PROGRESS-rectification-request-dossier-cache-20260915.md new file mode 100644 index 00000000..d53b38e6 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-request-dossier-cache-20260915.md @@ -0,0 +1,73 @@ +# PROGRESS · 一轮对话把整份 Case 档案从数据库取 3.4 次(2026-09-15 / 2026-09-16) + +工作树:`.worktrees/rectification-request-dossier-cache-20260915` +分支:`codex/rectification-request-dossier-cache-20260915` +任务书基线:`6b3248bf`;代码基线 `origin/staging` @ `e4788dfc`(含 failure-attribution `8b982baf`、engine-memoization BUG-721、settled-render BUG-725) +本机 Windows。 + +Skill **未 bump**。未改 `frontend/src/app/page.tsx`、SQL、轮询间隔、GET 投影形状、计费口径、`docs/tasks/README.md` 状态列。未 push `HEAD:staging`。 + +## 开工基线 + +| 项 | 数字 | +| --- | --- | +| `frontend/src/app/page.tsx` | **1879** 行(未改) | +| 最大 BUG 号 | **BUG-725**(本单占用 **BUG-726**;任务书预占 726,开工时 722–724 已落库,随后 721/725 也合入 staging) | +| `tsc --noEmit` | 收尾 **0 错** | +| `npm run lint` | **0 error**(全仓既有 warning;本单文件 0 warning) | + +`frontend/node_modules` 已是指向主仓的 junction,未新建。 + +## 任务状态 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 5.1 请求作用域缓存包装器 | 完成 | `withRectificationRequestCache`;写即失效;缓存 in-flight promise | +| 5.2 路由入口接线 | 完成 | agent / regenerate / Case GET / repair-exit POST / accept POST 各一处 | +| 5.3 前后计数 | 完成 | 见下表;预算断言钉在路由预取 + 证据轮 `≤ 4` | +| 5.4 Bug 历史 | 完成 | BUG-726 | + +## 实现要点 + +- 新模块 `frontend/src/lib/rectification-agentic/v9/request-cache.ts`。Map 在每次 `withRectificationRequestCache` 的闭包里,不挂模块作用域、不挂 `globalThis`。 +- 白名单:`get_agentic_rectification_case_dossier`、`get_agentic_rectification_case_compute`。键是 `(fn, p_user_id, p_case_id)`。 +- 其它 `rpc` 先 `clear` 再转发。Proxy 透传 `.from(...)` 等属性,访问时同样清空。类型用 `as T` 保留 Supabase 客户端形状,**没有为过类型去改业务代码**,也没有收缩成只包 `rpc`。 +- 调用点零改动。`fakeAccounting` 默认不包缓存(`requestCache` / `RECTIFICATION_REQUEST_CACHE=1` 显式打开);计数导出默认关闭(`countRpc` / `RECTIFICATION_RPC_COUNT=1`)。 + +## 改前 / 改后 dossier RPC 计数 + +测法:`fakeAccounting` 按实例聚合,只计打过 dossier 或 compute 的场景。改后用 `RECTIFICATION_REQUEST_CACHE=1` 包上同一批测试客户端。既有断言一条未改。 + +| 路径 | 场景数 | 改前 dossier(均值) | 改后 dossier(均值) | 改前 compute(均值) | 改后 compute(均值) | +| --- | ---: | ---: | ---: | ---: | ---: | +| Agent 轮(`rectification-v9-stream.test.ts`) | 41 | 143(**3.49**) | 143(**3.49**) | 27(0.66) | 27(0.66) | +| 点选题(`rectification-answer-choice.test.ts`) | 15 | 31(**2.07**) | 30(**2.00**) | 17(1.13) | 15(1.00) | +| 路由预取 + 证据轮(新预算断言) | 1 | 5 | **4** | — | — | + +Agent 轮套件数字没降:这些测试的 fake stream 不执行真实工具,每次 `loadV9CaseDossier` 之间都夹着 skill identity / append / run phase 等非白名单 RPC,按「写即失效」必须重读。生产路径上路由先读档案、再进 `runV9AgentTurn` 再读一次,中间无写,预算断言覆盖这一跳(5→4)。点选题套件 dossier 31→30、compute 17→15。 + +## 既有断言改动 + +无。stream 50 / answer-choice 34 一条未改仍全绿。 + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `tsc --noEmit` | **0 错** | +| 本单文件 eslint | **0 error / 0 warning** | +| `npm run lint` | **0 error**(全仓既有 warning 同量级) | +| `tests/rectification-request-dossier-cache.test.ts` | **8 / 8 绿** | +| `rectification-v9-stream.test.ts` | **50 / 50 绿** | +| `rectification-answer-choice.test.ts` | **34 / 34 绿** | +| `tests/rectification-*.test.ts` + `agentic-rectification-*.test.ts` | **1263 pass / 1 fail / 9 skipped**。唯一失败是既有 `rectification-v9-agent.test.ts` Windows `symlinkSync` EPERM,与本单无关 | +| `page.tsx` | 仍 1879 行,未改 | +| 全量 `npm test` | `# tests 3197 / # pass 3114 / # fail 69 / # skipped 14`。比 failure-attribution 进度记录的 3189/3104/71/14 多本单 8 条绿;失败仍是无 Docker 的 database-*、部署/Caddy/shell、Windows symlink EPERM。不含本单定向套件 | +| `npx next build --webpack` | **Compiled successfully** + TypeScript 37s 过。Collecting page data 死在既有 `/api/consult` skill runtime symlink EPERM。不伪装成 `/` ○ Static 已核过 | + +## 环境缺口 + +- 无 Docker:数据库套件红,不伪装成通过。 +- 无登录态、无 Chrome:浏览器级验收不在本单。 +- Windows 无 symlink 权限:`rectification-v9-agent.test.ts` 与部分 skill runtime 路径 EPERM。本机 Node 只能建 junction。 +- `next build`:webpack 编译在本机可过;收集页面数据时 skill runtime symlink 仍可能 EPERM(既有缺口)。不伪装成 `/` ○ Static 已核过。 diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index 43902b2e..1f2fd4f4 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -30,6 +30,7 @@ import { isProductEnabled } from "@/lib/product-access"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { defaultMessageOrigin, isRectificationMessageOrigin } from "@/lib/rectification-agentic/v9/message-origin"; import { previousInferenceFromReceipt } from "@/lib/rectification-agentic/v9/inference-adapter"; @@ -150,7 +151,7 @@ export async function POST(request: Request) { let accounting; try { supabase = await createServerSupabaseClient(); - accounting = createAdminSupabaseClient(); + accounting = withRectificationRequestCache(createAdminSupabaseClient()); } catch (error) { return jsonForSupabaseSetupFailure(error, "POST /api/rectification/agent"); } diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts index cccb5e99..0b338362 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache"; import { isProductEnabled } from "@/lib/product-access"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { RectificationToolServiceError } from "@/lib/rectification-agentic/v9/tool-service"; @@ -59,7 +60,7 @@ export async function POST(request: Request, context: RouteContext) { let accounting; try { supabase = await createServerSupabaseClient(); - accounting = createAdminSupabaseClient(); + accounting = withRectificationRequestCache(createAdminSupabaseClient()); } catch (error) { return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/candidates/accept"); } diff --git a/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts index a7fddac3..62e900d8 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { loadV9CaseDossier, @@ -32,7 +33,7 @@ export async function POST(request: Request, context: RouteContext) { let accounting; try { supabase = await createServerSupabaseClient(); - accounting = createAdminSupabaseClient(); + accounting = withRectificationRequestCache(createAdminSupabaseClient()); } catch (error) { return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/repair-exit"); } diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index 36bf7711..e9215469 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { loadV9CaseDossier, @@ -29,7 +30,7 @@ export async function GET(request: Request, context: RouteContext) { let accounting; try { supabase = await createServerSupabaseClient(); - accounting = createAdminSupabaseClient(); + accounting = withRectificationRequestCache(createAdminSupabaseClient()); } catch (error) { return jsonForSupabaseSetupFailure(error, "GET /api/rectification/cases/[caseId]"); } diff --git a/frontend/src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts index 32831128..f84e0807 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts @@ -9,6 +9,7 @@ import { resolveExactSkillPackage } from "@/lib/skill-package-registry"; import { resolveSessionLanguageModel } from "@/lib/model-catalog"; import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { withRectificationRequestCache } from "@/lib/rectification-agentic/v9/request-cache"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getRectificationV9RegenerationAgent } from "@/mastra/agentic-rectification"; import { logRectificationDeliveryTurn } from "@/lib/rectification-agentic/v9/delivery-turn-guard"; @@ -64,7 +65,7 @@ export async function POST(request: Request, context: RouteContext) { let accounting; try { supabase = await createServerSupabaseClient(); - accounting = createAdminSupabaseClient(); + accounting = withRectificationRequestCache(createAdminSupabaseClient()); } catch (error) { return jsonForSupabaseSetupFailure(error, "POST /api/rectification/cases/[caseId]/turns/[turnId]/regenerate"); } diff --git a/frontend/src/lib/rectification-agentic/v9/request-cache.ts b/frontend/src/lib/rectification-agentic/v9/request-cache.ts new file mode 100644 index 00000000..f02680ee --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/request-cache.ts @@ -0,0 +1,76 @@ +/** + * Request-scoped cache for Case read projections. + * + * `get_agentic_rectification_case_dossier` and + * `get_agentic_rectification_case_compute` are unchanged for the life of a + * request until this client performs any other RPC or table access. Call sites + * keep calling `loadV9CaseDossier` as before; the wrapper sits on the client + * object so direct `accounting.rpc(...)` paths are covered too. + * + * The cache is a closure over a Map created by each `withRectificationRequestCache` + * call. It is not stored on `globalThis` or at module scope. + * + * Other properties are forwarded through a Proxy so `.from(table).update(...)` + * (used via `as never` in a few call sites) still works, and accessing them + * invalidates the same way a non-projection RPC does. + */ +import type { RectificationRpcClient } from "./tool-service"; + +export const RECTIFICATION_CACHED_PROJECTION_RPCS = [ + "get_agentic_rectification_case_dossier", + "get_agentic_rectification_case_compute", +] as const; + +type CachedProjectionRpc = (typeof RECTIFICATION_CACHED_PROJECTION_RPCS)[number]; + +const cachedProjectionRpcSet = new Set(RECTIFICATION_CACHED_PROJECTION_RPCS); + +function isCachedProjectionRpc(fn: string): fn is CachedProjectionRpc { + return cachedProjectionRpcSet.has(fn); +} + +function projectionCacheKey(fn: string, args: Record): string | null { + const userId = args.p_user_id; + const caseId = args.p_case_id; + if (typeof userId !== "string" || typeof caseId !== "string" || !userId || !caseId) { + return null; + } + return `${fn}\0${userId}\0${caseId}`; +} + +type RpcResult = Awaited>; + +export function withRectificationRequestCache(accounting: T): T { + const cache = new Map>(); + const originalRpc = accounting.rpc.bind(accounting); + + function invalidate(): void { + cache.clear(); + } + + function cachedRpc(fn: string, args: Record): PromiseLike { + if (!isCachedProjectionRpc(fn)) { + invalidate(); + return originalRpc(fn, args); + } + const key = projectionCacheKey(fn, args); + if (!key) return originalRpc(fn, args); + const hit = cache.get(key); + if (hit) return hit; + const pending = originalRpc(fn, args); + cache.set(key, pending); + return pending; + } + + return new Proxy(accounting, { + get(target, property) { + if (property === "rpc") return cachedRpc; + invalidate(); + const value = Reflect.get(target, property); + if (typeof value === "function") { + return value.bind(target); + } + return value; + }, + }) as T; +} diff --git a/frontend/tests/rectification-request-dossier-cache.test.ts b/frontend/tests/rectification-request-dossier-cache.test.ts new file mode 100644 index 00000000..38a6d816 --- /dev/null +++ b/frontend/tests/rectification-request-dossier-cache.test.ts @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts"; +import { loadV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts"; +import { RECTIFICATION_SKILL_NAME } from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { + CASE_COMPUTE_RPC, + CASE_DOSSIER_RPC, + CASE_ID, + SESSION_ID, + TURN_ID, + USER_ID, + countCachedProjectionRpcs, + dossierFixture, + fakeAccounting, + receiptHandlers, +} from "./rectification-v9-test-support.ts"; + +/** Measured after wrapping: route-style prefetch + one evidence turn. */ +const CACHED_AGENT_TURN_DOSSIER_BUDGET = 4; + +const WRAPPED_ADMIN_CLIENT = /accounting = withRectificationRequestCache\(createAdminSupabaseClient\(\)\)/; +const BARE_ADMIN_CLIENT = /accounting = createAdminSupabaseClient\(\)/; + +const CACHED_ROUTES = [ + "../src/app/api/rectification/agent/route.ts", + "../src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", + "../src/app/api/rectification/cases/[caseId]/route.ts", + "../src/app/api/rectification/cases/[caseId]/repair-exit/route.ts", + "../src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", +] as const; + +function projectionArgs(caseId = CASE_ID) { + return { p_user_id: USER_ID, p_case_id: caseId }; +} + +function delayedClient(innerCalls: string[], delayMs = 20) { + return { + rpc(fn: string, args: Record) { + void args; + innerCalls.push(fn); + return new Promise<{ data: unknown; error: null }>((resolve) => { + setTimeout(() => resolve({ data: { ok: fn }, error: null }), delayMs); + }); + }, + }; +} + +test("repeated case_dossier reads of the same user and case hit the in-flight cache once", async () => { + const accounting = fakeAccounting({ + [CASE_DOSSIER_RPC]: () => dossierFixture(), + }, { requestCache: true }); + const first = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID); + const second = await loadV9CaseDossier(accounting.client, USER_ID, CASE_ID); + assert.equal(first.case.caseId, CASE_ID); + assert.equal(second.case.caseId, CASE_ID); + assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 0 }); +}); + +test("a non-projection rpc invalidates so the next dossier read hits the backing client", async () => { + const accounting = fakeAccounting({ + [CASE_DOSSIER_RPC]: () => dossierFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + }, { requestCache: true }); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs()); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs()); + await accounting.client.rpc("append_agentic_rectification_turn", projectionArgs()); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs()); + assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2); +}); + +test("two caseIds do not share a cached projection", async () => { + const otherCaseId = "99999999-9999-4999-8999-999999999999"; + const accounting = fakeAccounting({ + [CASE_DOSSIER_RPC]: () => dossierFixture(), + }, { requestCache: true }); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID)); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(otherCaseId)); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs(CASE_ID)); + assert.equal( + accounting.calls.filter((call) => call.fn === CASE_DOSSIER_RPC).length, + 2, + ); +}); + +test("concurrent duplicate reads share one backing rpc", async () => { + const innerCalls: string[] = []; + const wrapped = withRectificationRequestCache(delayedClient(innerCalls)); + const args = projectionArgs(); + const [first, second] = await Promise.all([ + wrapped.rpc(CASE_DOSSIER_RPC, args), + wrapped.rpc(CASE_DOSSIER_RPC, args), + ]); + assert.deepEqual(first, { data: { ok: CASE_DOSSIER_RPC }, error: null }); + assert.deepEqual(second, { data: { ok: CASE_DOSSIER_RPC }, error: null }); + assert.deepEqual(innerCalls, [CASE_DOSSIER_RPC]); +}); + +test("dossier and compute projections cache independently", async () => { + const accounting = fakeAccounting({ + [CASE_DOSSIER_RPC]: () => dossierFixture(), + [CASE_COMPUTE_RPC]: () => ({ case_id: CASE_ID }), + }, { requestCache: true }); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs()); + await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs()); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs()); + await accounting.client.rpc(CASE_COMPUTE_RPC, projectionArgs()); + assert.deepEqual(countCachedProjectionRpcs(accounting.calls), { dossier: 1, compute: 1 }); +}); + +test(".from passthrough invalidates the projection cache", async () => { + const accounting = fakeAccounting({ + [CASE_DOSSIER_RPC]: () => dossierFixture(), + }, { requestCache: true }); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs()); + await accounting.client.from("profiles").update({ birth_time_source: "approximate" }).eq("id", USER_ID); + await accounting.client.rpc(CASE_DOSSIER_RPC, projectionArgs()); + assert.equal(countCachedProjectionRpcs(accounting.calls).dossier, 2); + assert.equal(accounting.profilePatches.length, 1); +}); + +test("rectification routes wrap createAdminSupabaseClient once and never use the bare client", () => { + for (const relative of CACHED_ROUTES) { + const source = readFileSync(new URL(relative, import.meta.url), "utf8"); + const wraps = source.match(new RegExp(WRAPPED_ADMIN_CLIENT.source, "g")) ?? []; + assert.equal(wraps.length, 1, relative); + assert.equal(BARE_ADMIN_CLIENT.test(source), false, relative); + } +}); + +test("a cached evidence turn stays within the measured case_dossier budget", async () => { + const handlers = { + ...receiptHandlers, + [CASE_DOSSIER_RPC]: () => dossierFixture(), + append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }), + finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }), + }; + const unwrapped = fakeAccounting(handlers); + const wrapped = fakeAccounting(handlers, { requestCache: true }); + const stream = { + stream: async () => ({ + fullStream: (async function* () { + yield { type: "start" }; + yield { type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } }; + yield { type: "tool-result", payload: { toolName: "skill" } }; + yield { type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } }; + yield { type: "tool-result", payload: { toolName: "rectification-read-case" } }; + yield { type: "text-delta", payload: { text: "记下了。" } }; + yield { type: "finish" }; + })(), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + }), + getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }), + }; + const base = { + userId: USER_ID, + caseId: CASE_ID, + sessionId: SESSION_ID, + requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + action: "evidence" as const, + message: "2016年9月离开家去北京工作", + modelName: "gpt-4o-mini", + billing: { + reserve: async () => ({ success: true, status: 200 }), + complete: async () => true, + release: async () => true, + }, + emit: () => {}, + buildAgent: async () => stream as never, + }; + await loadV9CaseDossier(unwrapped.client, USER_ID, CASE_ID); + await runV9AgentTurn({ ...base, accounting: unwrapped.client }); + await loadV9CaseDossier(wrapped.client, USER_ID, CASE_ID); + await runV9AgentTurn({ ...base, accounting: wrapped.client }); + const before = countCachedProjectionRpcs(unwrapped.calls); + const after = countCachedProjectionRpcs(wrapped.calls); + assert.ok(after.dossier < before.dossier); + assert.ok( + after.dossier <= CACHED_AGENT_TURN_DOSSIER_BUDGET, + `cached dossier rpc ${after.dossier} exceeded budget ${CACHED_AGENT_TURN_DOSSIER_BUDGET} (uncached ${before.dossier})`, + ); +}); diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index 32f8fa3a..fcd7659b 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -1,4 +1,8 @@ import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts"; + +export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier"; +export const CASE_COMPUTE_RPC = "get_agentic_rectification_case_compute"; export type FakeRpcHandler = ( fn: string, @@ -17,13 +21,81 @@ export type FakeAccounting = { }; }; +export type FakeAccountingOptions = { + fallback?: FakeRpcHandler; + /** + * Default off. When true, wrap `client` with the request-scoped projection + * cache. Also enabled by RECTIFICATION_REQUEST_CACHE=1 for before/after counts. + */ + requestCache?: boolean; + /** + * Default off. When true, this instance is included in takeFakeAccountingRpcCounts(). + * Also enabled by RECTIFICATION_RPC_COUNT=1. + */ + countRpc?: boolean; +}; + +export type CachedProjectionRpcCounts = { + dossier: number; + compute: number; +}; + +export type FakeAccountingRpcCountSummary = { + scenes: number; + dossier: number; + compute: number; + dossierMean: number; + computeMean: number; +}; + +const countedAccounting: FakeAccounting[] = []; +let rpcCountEnabled = process.env.RECTIFICATION_RPC_COUNT === "1"; + +export function countCachedProjectionRpcs( + calls: Array<{ fn: string }>, +): CachedProjectionRpcCounts { + let dossier = 0; + let compute = 0; + for (const call of calls) { + if (call.fn === CASE_DOSSIER_RPC) dossier += 1; + else if (call.fn === CASE_COMPUTE_RPC) compute += 1; + } + return { dossier, compute }; +} + +export function enableFakeAccountingRpcCount(enabled = true): void { + rpcCountEnabled = enabled; + if (!enabled) countedAccounting.length = 0; +} + +export function takeFakeAccountingRpcCounts(): FakeAccountingRpcCountSummary { + const scenes = countedAccounting + .map((item) => countCachedProjectionRpcs(item.calls)) + .filter((row) => row.dossier > 0 || row.compute > 0); + const dossier = scenes.reduce((sum, row) => sum + row.dossier, 0); + const compute = scenes.reduce((sum, row) => sum + row.compute, 0); + return { + scenes: scenes.length, + dossier, + compute, + dossierMean: scenes.length === 0 ? 0 : dossier / scenes.length, + computeMean: scenes.length === 0 ? 0 : compute / scenes.length, + }; +} + +if (process.env.RECTIFICATION_RPC_COUNT === "1") { + process.on("beforeExit", () => { + process.stderr.write(`RECTIFICATION_RPC_COUNT ${JSON.stringify(takeFakeAccountingRpcCounts())}\n`); + }); +} + export function fakeAccounting( handlers: Partial>, - options: { fallback?: FakeRpcHandler } = {}, + options: FakeAccountingOptions = {}, ): FakeAccounting { const calls: Array<{ fn: string; args: Record }> = []; const profilePatches: Array> = []; - const client: FakeAccounting["client"] = { + const rawClient: FakeAccounting["client"] = { rpc(fn, args) { calls.push({ fn, args }); const handler = handlers[fn] ?? options.fallback; @@ -57,7 +129,12 @@ export function fakeAccounting( }; }, }; - return { calls, profilePatches, client }; + const requestCache = options.requestCache === true + || process.env.RECTIFICATION_REQUEST_CACHE === "1"; + const client = requestCache ? withRectificationRequestCache(rawClient) : rawClient; + const accounting: FakeAccounting = { calls, profilePatches, client }; + if (options.countRpc === true || rpcCountEnabled) countedAccounting.push(accounting); + return accounting; } export const CASE_ID = "11111111-1111-4111-8111-111111111111";