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
223 lines
8.4 KiB
TypeScript
223 lines
8.4 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { spawnSync } from "node:child_process";
|
||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import test from "node:test";
|
||
|
||
const frontendRoot = fileURLToPath(new URL("../", import.meta.url));
|
||
const srcRoot = join(frontendRoot, "src");
|
||
|
||
const NOT_CONFIGURED = "服务尚未配置";
|
||
|
||
const ROUTES_USING_HELPER = [
|
||
"src/app/api/rectification/agent/route.ts",
|
||
"src/app/api/rectification/cases/open/route.ts",
|
||
"src/app/api/rectification/cases/entry-summary/route.ts",
|
||
"src/app/api/rectification/cases/[caseId]/route.ts",
|
||
"src/app/api/rectification/cases/[caseId]/close/route.ts",
|
||
"src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts",
|
||
"src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts",
|
||
"src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts",
|
||
// 原值: 清单含 cases/[caseId]/turns/[turnId]/regenerate/route.ts。新值: 移出清单。
|
||
// 原因: BUG-1056(TASK-rectification-grounding-20260927 P1)删除生时校正的重新生成接口与按钮,该路由文件已不存在。
|
||
"src/app/api/consult/route.ts",
|
||
"src/app/api/consult/status/route.ts",
|
||
"src/app/api/consult/cancel/route.ts",
|
||
"src/app/api/models/route.ts",
|
||
"src/app/api/birth-time-journey/route.ts",
|
||
"src/app/api/birth-time-guide/route.ts",
|
||
] as const;
|
||
|
||
function walk(directory: string): string[] {
|
||
const files: string[] = [];
|
||
for (const name of readdirSync(directory)) {
|
||
const path = join(directory, name);
|
||
if (statSync(path).isDirectory()) {
|
||
files.push(...walk(path));
|
||
continue;
|
||
}
|
||
if (path.endsWith(".ts") || path.endsWith(".tsx")) files.push(path);
|
||
}
|
||
return files;
|
||
}
|
||
|
||
type RouteProbe = {
|
||
configStatus: number;
|
||
configBody: Record<string, unknown>;
|
||
unavailableStatus: number;
|
||
unavailableBody: Record<string, unknown>;
|
||
unavailableLogs: string[][];
|
||
};
|
||
|
||
function executeRouteProbe(kind: "agent" | "cases"): RouteProbe {
|
||
const script = String.raw`
|
||
import { mock } from "node:test";
|
||
import { pathToFileURL } from "node:url";
|
||
import { SupabaseConfigurationError } from "@/lib/supabase/config";
|
||
|
||
const kind = ${JSON.stringify(kind)};
|
||
const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href;
|
||
mock.module("server-only", { namedExports: {} });
|
||
|
||
let calls = 0;
|
||
mock.module("@/lib/supabase/server", { namedExports: {
|
||
createServerSupabaseClient: async () => {
|
||
calls += 1;
|
||
if (calls === 1) throw new SupabaseConfigurationError(["APP_DATABASE_URL"]);
|
||
throw new Error("connection refused");
|
||
},
|
||
}});
|
||
mock.module("@/lib/supabase/admin", { namedExports: {
|
||
createAdminSupabaseClient: () => { throw new Error("admin unused in this unit test"); },
|
||
}});
|
||
|
||
const unavailableLogs = [];
|
||
const originalError = console.error;
|
||
console.error = (...args) => {
|
||
unavailableLogs.push(args.map((value) => {
|
||
if (typeof value === "string") return value;
|
||
if (value instanceof Error) return value.stack ?? value.message;
|
||
return String(value);
|
||
}));
|
||
};
|
||
|
||
async function invoke() {
|
||
if (kind === "agent") {
|
||
const { POST } = await import(moduleUrl("src/app/api/rectification/agent/route.ts"));
|
||
return POST(new Request("https://staging.jyotisha.chat/api/rectification/agent", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: "{}",
|
||
}));
|
||
}
|
||
const { GET } = await import(moduleUrl("src/app/api/rectification/cases/[caseId]/route.ts"));
|
||
return GET(
|
||
new Request("https://staging.jyotisha.chat/api/rectification/cases/123e4567-e89b-12d3-a456-426614174000?sessionId=123e4567-e89b-12d3-a456-426614174001"),
|
||
{ params: Promise.resolve({ caseId: "123e4567-e89b-12d3-a456-426614174000" }) },
|
||
);
|
||
}
|
||
|
||
const configResponse = await invoke();
|
||
const configBody = await configResponse.json();
|
||
const unavailableResponse = await invoke();
|
||
const unavailableBody = await unavailableResponse.json();
|
||
console.error = originalError;
|
||
console.log("AYANAM_RESULT " + JSON.stringify({
|
||
configStatus: configResponse.status,
|
||
configBody,
|
||
unavailableStatus: unavailableResponse.status,
|
||
unavailableBody,
|
||
unavailableLogs,
|
||
}));
|
||
`;
|
||
const result = spawnSync(process.execPath, [
|
||
"--experimental-test-module-mocks",
|
||
"--import",
|
||
"tsx",
|
||
"--input-type=module",
|
||
"--eval",
|
||
script,
|
||
], {
|
||
cwd: frontendRoot,
|
||
encoding: "utf8",
|
||
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
||
});
|
||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||
const line = result.stdout
|
||
.split("\n")
|
||
.map((row) => row.trim())
|
||
.find((row) => row.startsWith("AYANAM_RESULT "));
|
||
assert.ok(line, result.stdout);
|
||
return JSON.parse(line.slice("AYANAM_RESULT ".length)) as RouteProbe;
|
||
}
|
||
|
||
function assertConfigMislabel(body: Record<string, unknown>) {
|
||
assert.equal(body.error, NOT_CONFIGURED);
|
||
assert.equal(body.code, undefined);
|
||
}
|
||
|
||
function assertUnavailable(body: Record<string, unknown>, logs: string[][]) {
|
||
assert.equal(body.error, "服务暂时不可用,请稍后重试");
|
||
assert.equal(body.code, "service_unavailable");
|
||
assert.equal(JSON.stringify(body).includes("尚未配置"), false);
|
||
assert.ok(logs.some((entry) => entry.includes("Error")));
|
||
assert.equal(JSON.stringify(logs).includes("connection refused"), false);
|
||
assert.equal(JSON.stringify(logs).includes("APP_DATABASE_URL"), false);
|
||
}
|
||
|
||
test("shared helper classifies configuration errors and connection failures", async () => {
|
||
const { jsonForSupabaseSetupFailure } = await import("../src/lib/api/service-unavailable.ts");
|
||
const { SupabaseConfigurationError } = await import("../src/lib/supabase/config.ts");
|
||
const logged: unknown[][] = [];
|
||
const originalError = console.error;
|
||
console.error = (...args: unknown[]) => {
|
||
logged.push(args);
|
||
};
|
||
try {
|
||
const missing = await jsonForSupabaseSetupFailure(
|
||
new SupabaseConfigurationError(["APP_DATABASE_URL"]),
|
||
"POST /api/rectification/agent",
|
||
);
|
||
assert.equal(missing.status, 503);
|
||
assert.deepEqual(await missing.json(), {
|
||
error: NOT_CONFIGURED,
|
||
message: "请先配置数据库环境变量。",
|
||
});
|
||
assert.deepEqual(logged, []);
|
||
|
||
const unavailable = await jsonForSupabaseSetupFailure(
|
||
new Error("connection refused"),
|
||
"GET /api/rectification/cases/[caseId]",
|
||
);
|
||
assert.equal(unavailable.status, 503);
|
||
assert.deepEqual(await unavailable.json(), {
|
||
error: "服务暂时不可用,请稍后重试",
|
||
code: "service_unavailable",
|
||
});
|
||
assert.deepEqual(logged, [["GET /api/rectification/cases/[caseId]", "Error"]]);
|
||
} finally {
|
||
console.error = originalError;
|
||
}
|
||
});
|
||
|
||
test("rectification agent maps setup failures without calling the rest of the handler", () => {
|
||
const probe = executeRouteProbe("agent");
|
||
assert.equal(probe.configStatus, 503);
|
||
assertConfigMislabel(probe.configBody);
|
||
assert.equal(probe.unavailableStatus, 503);
|
||
assertUnavailable(probe.unavailableBody, probe.unavailableLogs);
|
||
assert.ok(probe.unavailableLogs.some((entry) =>
|
||
entry.some((value) => value.includes("/api/rectification/agent")),
|
||
));
|
||
});
|
||
|
||
test("rectification case refresh maps setup failures without calling the rest of the handler", () => {
|
||
const probe = executeRouteProbe("cases");
|
||
assert.equal(probe.configStatus, 503);
|
||
assertConfigMislabel(probe.configBody);
|
||
assert.equal(probe.unavailableStatus, 503);
|
||
assertUnavailable(probe.unavailableBody, probe.unavailableLogs);
|
||
assert.ok(probe.unavailableLogs.some((entry) =>
|
||
entry.some((value) => value.includes("/api/rectification/cases/")),
|
||
));
|
||
});
|
||
|
||
test("not-configured copy lives only in the shared helper, and the listed routes call it", () => {
|
||
// 原值: 测试名写 16 条,清单含 src/lib/onboarding-post.ts。
|
||
// 新值: 15 条,建议问题接口删除。
|
||
// 原因: /api/onboarding 下线。
|
||
const hits = walk(srcRoot)
|
||
.filter((path) => readFileSync(path, "utf8").includes(NOT_CONFIGURED))
|
||
.map((path) => path.slice(srcRoot.length + 1).split("\\").join("/"));
|
||
assert.deepEqual(hits, ["lib/api/service-unavailable.ts"]);
|
||
for (const relative of ROUTES_USING_HELPER) {
|
||
const source = readFileSync(join(frontendRoot, relative), "utf8");
|
||
assert.match(
|
||
source,
|
||
/jsonForSupabaseSetupFailure/,
|
||
`${relative} must use jsonForSupabaseSetupFailure`,
|
||
);
|
||
}
|
||
});
|