开流前的注销检查可以和读会话、解析模型同时进行;审核改到扣点前等待,不再挡住第一行;模型版本可以短缓存;两个领域可以一起算。本机没有数据库中位数,也没有带星盘网络库的镜像,所以默认仍是关闭、缓存 0、领域逐个算。 自测:tsc 0 错;lint 0 error;相关测试失败 0、取消 0。test:db 未跑。 Co-Authored-By: Grok (agent loop)
165 lines
6.7 KiB
TypeScript
165 lines
6.7 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 append = route.indexOf('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);
|
|
});
|