完成回答、直达校正、输出审核和开流前加载仍留在路由里,使 diff 停在 1500 行以内。 Co-Authored-By: Grok (agent loop)
170 lines
7.1 KiB
TypeScript
170 lines
7.1 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import { register } from "node:module";
|
||
import test from "node:test";
|
||
|
||
import { agentObservabilityEventSchema } from "../src/lib/agent-observability.ts";
|
||
import { ConsultationInputBlockedError, consultPrepObservabilityEvent } from "../src/lib/consult/prep-log.ts";
|
||
import { inputBlockedCopy, MODERATION_SELF_HARM_COPY } from "../src/lib/moderation/index.ts";
|
||
import type { ModerationResult } from "../src/lib/moderation/types.ts";
|
||
import { streamFirstResponse } from "../src/lib/stream-first-response.ts";
|
||
|
||
const blocked: ModerationResult = {
|
||
verdict: "block",
|
||
categories: ["self_harm"],
|
||
ruleIds: ["lexicon"],
|
||
providerId: "local",
|
||
};
|
||
|
||
test("a blocked question records once and never reaches reserve", async () => {
|
||
let reserves = 0;
|
||
let records = 0;
|
||
const pending = Promise.resolve(blocked);
|
||
async function beforeReserve() {
|
||
const result = await pending;
|
||
if (result.verdict !== "pass") records += 1;
|
||
if (result.verdict === "block") throw new ConsultationInputBlockedError(result);
|
||
reserves += 1;
|
||
}
|
||
await assert.rejects(beforeReserve, (error: unknown) => {
|
||
assert.ok(error instanceof ConsultationInputBlockedError);
|
||
assert.equal(inputBlockedCopy(error.result), MODERATION_SELF_HARM_COPY);
|
||
return true;
|
||
});
|
||
assert.equal(reserves, 0);
|
||
assert.equal(records, 1);
|
||
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||
const blockedAt = route.indexOf('blocked?.verdict === "block"');
|
||
const profileAt = route.indexOf("error instanceof ConsultationProfileTruthError");
|
||
assert.ok(blockedAt > 0 && blockedAt < profileAt);
|
||
});
|
||
|
||
test("moderation that takes 1.5s does not hold the first frame", async () => {
|
||
let release: (result: ModerationResult) => void = () => {};
|
||
const pending = new Promise<ModerationResult>((resolve) => {
|
||
release = resolve;
|
||
});
|
||
const started = Date.now();
|
||
const response = streamFirstResponse({
|
||
requestId: "prep-frame",
|
||
run: async () => {
|
||
const result = await pending;
|
||
return Response.json(
|
||
{ error: "无法处理该请求", code: "content_blocked", message: inputBlockedCopy(result) },
|
||
{ status: 400 },
|
||
);
|
||
},
|
||
});
|
||
const reader = response.body!.getReader();
|
||
const first = await reader.read();
|
||
assert.ok(Date.now() - started < 400, "first frame waited for moderation");
|
||
assert.ok(first.value && first.value.byteLength > 0);
|
||
setTimeout(() => release(blocked), 1_500);
|
||
const chunks: string[] = [];
|
||
const decoder = new TextDecoder();
|
||
while (true) {
|
||
const next = await reader.read();
|
||
if (next.value) chunks.push(decoder.decode(next.value));
|
||
if (next.done) break;
|
||
}
|
||
const events = chunks.join("").trim().split("\n").map((line) => JSON.parse(line));
|
||
const rejected = events.find((event) => event.type === "run.failed");
|
||
assert.equal(rejected.message, MODERATION_SELF_HARM_COPY);
|
||
assert.equal(rejected.reason, "content_blocked");
|
||
assert.equal(rejected.status, 400);
|
||
});
|
||
|
||
test("prep observability accepts the closed schema and drops the question", () => {
|
||
const question = "这句问题原文不该出现在日志里";
|
||
const event = consultPrepObservabilityEvent({
|
||
requestId: "req-prep",
|
||
sessionId: "sess-prep",
|
||
phases: [
|
||
{ phase: "prep.model", durationMs: 3, status: "completed" },
|
||
{ phase: "prep.moderation", durationMs: 12, status: "completed" },
|
||
],
|
||
});
|
||
assert.deepEqual(agentObservabilityEventSchema.parse(event), event);
|
||
assert.equal(JSON.stringify(event).includes(question), false);
|
||
assert.equal(event.agentVersion, "consultation-prep-v1");
|
||
});
|
||
|
||
test("readVersion cache skips a second database and DNS lookup inside the ttl", async () => {
|
||
await register(new URL("./support/stub-server-only.mjs", import.meta.url).href, import.meta.url);
|
||
let queries = 0;
|
||
let dns = 0;
|
||
const row = {
|
||
model_id: "model-a",
|
||
version: 4,
|
||
label: "Model A",
|
||
description: "fixture",
|
||
provider_model: "demo",
|
||
credit_cost: 1,
|
||
is_default: false,
|
||
provider_code: "demo",
|
||
provider_type: "openai-compatible",
|
||
base_url: "https://example.com/v1",
|
||
encrypted_api_key: "enc",
|
||
input_cost: 1,
|
||
output_cost: 1,
|
||
context_window: 8000,
|
||
};
|
||
const catalog = await import("../src/lib/model-catalog.ts");
|
||
const flags = await import("../src/lib/consult/prep-flags.ts");
|
||
catalog.setModelCatalogIoForTests({
|
||
queryAdminRows: (async () => {
|
||
queries += 1;
|
||
if (queries === 3) throw new Error("database down");
|
||
if (queries >= 4) return [];
|
||
return [row];
|
||
}) as typeof import("../src/lib/admin/database.ts").queryAdminRows,
|
||
assertAllowedModelProviderUrl: (async (value: string) => {
|
||
dns += 1;
|
||
return { url: new URL(value), addresses: [] };
|
||
}) as typeof import("../src/lib/epay/gateway-policy.ts").assertAllowedModelProviderUrl,
|
||
decryptModelProviderApiKey: (() => "test-key") as typeof import("../src/lib/model-provider-policy.ts").decryptModelProviderApiKey,
|
||
});
|
||
process.env.MODEL_VERSION_CACHE_TTL_MS = "15000";
|
||
try {
|
||
catalog.invalidateLanguageModelCatalog();
|
||
const first = await catalog.resolveSessionLanguageModel("model-a", 4);
|
||
const second = await catalog.resolveSessionLanguageModel("model-a", 4);
|
||
assert.equal(first?.id, "model-a");
|
||
assert.equal(second, first);
|
||
assert.equal(queries, 1);
|
||
assert.equal(dns, 1);
|
||
catalog.invalidateLanguageModelCatalog();
|
||
await catalog.resolveSessionLanguageModel("model-a", 4);
|
||
assert.equal(queries, 2);
|
||
process.env.MODEL_VERSION_CACHE_TTL_MS = "0";
|
||
await catalog.resolveSessionLanguageModel("model-a", 4);
|
||
assert.ok(queries >= 3);
|
||
const failed = await catalog.resolveSessionLanguageModel("model-a", 4);
|
||
const empty = await catalog.resolveSessionLanguageModel("model-a", 4);
|
||
assert.equal(failed, null);
|
||
assert.equal(empty, null);
|
||
const queriesAfterMiss = queries;
|
||
await catalog.resolveSessionLanguageModel("model-a", 4);
|
||
assert.ok(queries > queriesAfterMiss);
|
||
} finally {
|
||
flags.resetConsultPrepFlagWarningsForTests();
|
||
delete process.env.MODEL_VERSION_CACHE_TTL_MS;
|
||
catalog.invalidateLanguageModelCatalog();
|
||
catalog.setModelCatalogIoForTests(null);
|
||
}
|
||
});
|
||
|
||
test("the title read starts after the question is saved and is awaited at the old use site", () => {
|
||
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||
const settlement = readFileSync(new URL("../src/lib/consult/settlement.ts", import.meta.url), "utf8");
|
||
// 原值: route.ts 里 append_consultation_question 早于 .select("title")
|
||
// 新值: route.ts 里 appendQuestion() 早于 .select("title");RPC 名在 settlement.ts
|
||
// 原因: 追加问题搬进结算工厂,标题读取仍在保存成功之后
|
||
const append = route.indexOf("consultSettlement.appendQuestion()");
|
||
assert.match(settlement, /accounting\.rpc\("append_consultation_question"/);
|
||
const title = route.indexOf('.select("title")');
|
||
const classify = route.indexOf("await classifyConsultationTurn({");
|
||
const awaited = route.indexOf("await titleRead");
|
||
assert.ok(append > 0 && title > append && classify > title && awaited > classify);
|
||
});
|