T2 of TASK-rectification-grounding-20260927 (product decision P1). The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that persisted unchecked text (invented range / fit rate reproduced). Removed: the regenerate route, regenerate-turn.ts, the regeneration agent and its read-only tool set, the client regenerate action/state and the regenerating/canRegenerate props. ChatMessageActions renders the regenerate button only when onRegenerate is passed; ordinary consultation is unchanged. The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6, retire in a later round). DESIGN.md / VOICE.md updated; source-contract tests follow with 原值/新值/原因 notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
186 lines
8.1 KiB
TypeScript
186 lines
8.1 KiB
TypeScript
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",
|
||
// 原值: 含 cases/[caseId]/turns/[turnId]/regenerate/route.ts。新值: 移出。原因: BUG-1056(TASK-rectification-grounding-20260927 P1)删除生时校正的重新生成接口与按钮。
|
||
"../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<string, unknown>) {
|
||
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})`,
|
||
);
|
||
});
|