Files
zeyu.fz d24104f7dc feat(kb-dsh): 接入仓库工程约定并改名为公开包 bailian-kb-dsh
包名 @ali/bailian-kb-dsh → bailian-kb-dsh(公开 npm):package.json name +
cordis.patch.yml insert.name + tsdown PLUGIN_ID 三处同步(漏一处即 dsh 运行时崩)。

产物 lib/ → dist/(本仓 .gitignore 忽略 dist 不忽略 lib),连带 main/types/
exports/files/tsdown outDir 同步;.gitignore 补 *.tsbuildinfo。

依赖接 catalog(yaml/typescript/@types/node/vite-plus);测试导入 vitest →
vite-plus/test(全仓统一约定,消掉唯一的 vitest 依赖漂移)。

tsconfig 拆三件套:tsconfig.json 纯类型检查覆盖 src+tests(供 oxlint 自动发现,
含 jsx/DOM),tsconfig.build.json 产出 node 半,tsconfig.web.json 隔离检查 web 半。
补齐 tests 从未被类型检查暴露的一处 partial 输入类型错误。

根 vite.config.ts 新增两条 override:web 半 no-restricted-imports 把 tsdown 构建期
的 bundle purity gate 提前到 lint 期;全包放开 _ 前缀的 no-unused-vars。

文档:新增 docs/agents/dsh-plugin.md,AGENTS.md 项目地图/版本锁步例外/分层边界/
场景索引同步,packages.mjs 注释说明故意不进发布白名单。

格式化(单引号无分号 → 双引号加分号)由 pre-commit 的 vp check --fix 自动完成,
无法单独成 commit,一并纳入。

全仓 vp check 0 error;插件 13 文件 85 测试全绿;build + typecheck 通过。
2026-08-24 11:02:44 +08:00

206 lines
9.2 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import { KbApiError, KbClient } from "../src/client.js";
import { createKbTools } from "../src/tools.js";
const EXEC = {} as never;
function toolsWith(
postJson: unknown,
postSse?: unknown,
resolveDefaultRetrieveAgentId?: () => Promise<string | undefined>,
resolveDefaultChatAgentId?: () => Promise<string | undefined>,
describeServicesAfterRefresh?: (scene: "search" | "chat") => Promise<string | undefined>,
) {
const client = { postJson, postSse, agentVersion: undefined } as unknown as KbClient;
const list = createKbTools({
client,
...(resolveDefaultRetrieveAgentId ? { resolveDefaultRetrieveAgentId } : {}),
...(resolveDefaultChatAgentId ? { resolveDefaultChatAgentId } : {}),
...(describeServicesAfterRefresh ? { describeServicesAfterRefresh } : {}),
chatTimeoutMs: 1000,
});
const byName = Object.fromEntries(list.map((t) => [t.name, t]));
return { byName, list };
}
const searchResponse = {
request_id: "r1",
data: {
total: 3,
nodes: [
{ score: 0.9, text: "A", metadata: { doc_name: "d1" } },
{ score: 0.8, text: "B", metadata: {} },
{ score: 0.7, text: "C", metadata: {} },
],
},
};
describe("createKbTools", () => {
it("registers kb_search and kb_chat", () => {
const { list } = toolsWith(vi.fn());
expect(list.map((t) => t.name).sort()).toEqual(["kb_chat", "kb_search"]);
});
it("keeps both descriptions free of service ids so the schemas stay prefix-stable", () => {
// The live service list rides an `agent/pre-step` context message precisely
// because re-registering a tool to refresh its description would void the
// prompt prefix cache. Any id leaking in here means that decision regressed.
const { byName } = toolsWith(vi.fn());
for (const tool of [byName.kb_search!, byName.kb_chat!]) {
const text = `${tool.description} ${JSON.stringify(tool.parameters)}`;
expect(text).not.toMatch(/aid-[0-9a-f]/);
}
});
it("kb_search truncates nodes client-side to top_k and never sends top_k to the server", async () => {
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse);
const { byName } = toolsWith(postJson);
const out = (await byName.kb_search!.execute(
{ query: "q", agent_id: "aid-1", top_k: 2 },
EXEC,
)) as { chunks: unknown[] };
expect(out.chunks).toHaveLength(2);
const body = postJson.mock.calls[0]![1] as Record<string, unknown>;
expect(body).not.toHaveProperty("top_k");
expect(body.agent_id).toBe("aid-1");
});
it("agent_id is required in the schema for both tools", () => {
const { byName } = toolsWith(vi.fn());
// defineTool compiles the spec into JSON Schema: requiredness lives in the top-level `required` array.
const requiredList = (tool: { parameters: Record<string, unknown> }) =>
(tool.parameters.required ?? []) as string[];
expect(requiredList(byName.kb_search!)).toContain("agent_id");
expect(requiredList(byName.kb_chat!)).toContain("agent_id");
});
it("a missing agent_id is rejected by schema validation before execute (even with a default resolver)", async () => {
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse);
const resolveDefaultRetrieveAgentId = vi.fn(async () => "aid-fixed");
const { byName } = toolsWith(postJson, undefined, resolveDefaultRetrieveAgentId);
// defineTool validates args against the compiled schema before execute runs,
// so with agent_id required the per-call default fallback is never consulted
// through this entry point; it stays as defense-in-depth only.
const err = await byName.kb_search!.execute({ query: "q" }, EXEC).catch((e: unknown) => e);
expect((err as Error).message).toContain("agent_id");
expect(resolveDefaultRetrieveAgentId).not.toHaveBeenCalled();
expect(postJson).not.toHaveBeenCalled();
});
it("a missing agent_id without any default is also a schema rejection, not the runtime guidance error", async () => {
const { byName } = toolsWith(vi.fn());
const err = await byName.kb_search!.execute({ query: "q" }, EXEC).catch((e: unknown) => e);
expect((err as Error).message).toContain("missing required property");
});
it("a supplied agent_id bypasses the default resolver entirely", async () => {
const postJson = vi.fn(async (_path: string, _body: unknown) => searchResponse);
const resolveDefaultRetrieveAgentId = vi.fn(async () => "aid-default");
const { byName } = toolsWith(postJson, undefined, resolveDefaultRetrieveAgentId);
await byName.kb_search!.execute({ query: "q", agent_id: "aid-explicit" }, EXEC);
expect(resolveDefaultRetrieveAgentId).not.toHaveBeenCalled();
expect((postJson.mock.calls[0]![1] as Record<string, unknown>).agent_id).toBe("aid-explicit");
});
it("a 4xx failure passes the original error through unchanged when no refresh hook is wired", async () => {
const postJson = vi.fn(async (_path: string) => {
throw new KbApiError("agent not found", 400);
});
const { byName } = toolsWith(postJson);
const err = await byName
.kb_search!.execute({ query: "q", agent_id: "bad" }, EXEC)
.catch((e: unknown) => e);
expect((err as Error).message).toBe("agent not found");
});
it("a 4xx failure appends the refreshed service list for the calling scene", async () => {
// agent_id is the only caller-supplied identifier on these endpoints, so a
// rejected request most often means the cached id is gone. The recovery the
// model needs is the current list, and an error message carries it without
// disturbing the request prefix.
const postJson = vi.fn(async (_path: string) => {
throw new KbApiError("agent not found", 400);
});
const describe = vi.fn(async (scene: "search" | "chat") => `services for ${scene}: aid-new`);
const { byName } = toolsWith(postJson, undefined, undefined, undefined, describe);
const err = await byName
.kb_search!.execute({ query: "q", agent_id: "stale" }, EXEC)
.catch((e: unknown) => e);
expect(describe).toHaveBeenCalledWith("search");
expect((err as Error).message).toContain("agent not found");
expect((err as Error).message).toContain("services for search: aid-new");
});
it("leaves a 5xx failure and a failing refresh alone", async () => {
// A server-side fault is not an id problem, and a refresh that itself fails
// must not replace the real error with its own.
const serverError = vi.fn(async (_path: string) => {
throw new KbApiError("upstream exploded", 502);
});
const describe = vi.fn(async () => "never used");
const { byName } = toolsWith(serverError, undefined, undefined, undefined, describe);
const err = await byName
.kb_search!.execute({ query: "q", agent_id: "aid-1" }, EXEC)
.catch((e: unknown) => e);
expect(describe).not.toHaveBeenCalled();
expect((err as Error).message).toBe("upstream exploded");
const badRequest = vi.fn(async (_path: string) => {
throw new KbApiError("agent not found", 400);
});
const failing = vi.fn(async () => {
throw new Error("refresh also down");
});
const { byName: byName2 } = toolsWith(badRequest, undefined, undefined, undefined, failing);
const err2 = await byName2
.kb_search!.execute({ query: "q", agent_id: "bad" }, EXEC)
.catch((e: unknown) => e);
expect((err2 as Error).message).toBe("agent not found");
});
it("kb_chat buffers the SSE stream into one answer", async () => {
const sse =
'data: {"output":{"choices":[{"message":{"content":"hi"},"finish_reason":"stop"}]},"request_id":"r2"}\n\ndata: [DONE]\n\n';
const postSse = vi.fn(async () => new Response(sse, { status: 200 }));
const { byName } = toolsWith(vi.fn(), postSse);
const out = (await byName.kb_chat!.execute({ message: "q", agent_id: "aid-1" }, EXEC)) as {
answer: string;
};
expect(out.answer).toBe("hi");
});
it("kb_chat translates a timeout into retry-or-search guidance", async () => {
const timeout = Object.assign(new Error("operation timed out"), { name: "TimeoutError" });
const postSse = vi.fn(async () => {
throw timeout;
});
const { byName } = toolsWith(vi.fn(), postSse);
const err = await byName
.kb_chat!.execute({ message: "q", agent_id: "aid-1" }, EXEC)
.catch((e: unknown) => e);
expect((err as Error).message).toMatch(/timed out.*kb_search/s);
});
it("kb_chat reads chatTimeoutMs off deps per call (live-settings getter stays live)", async () => {
const timeout = Object.assign(new Error("operation timed out"), { name: "TimeoutError" });
const postSse = vi.fn(async () => {
throw timeout;
});
const client = { postJson: vi.fn(), postSse, agentVersion: undefined } as unknown as KbClient;
// Mirrors the host apply: a getter over the mutable settings source.
let timeoutMs = 1000;
const list = createKbTools({
client,
get chatTimeoutMs() {
return timeoutMs;
},
});
const chat = list.find((t) => t.name === "kb_chat")!;
timeoutMs = 2222;
const err = await chat
.execute({ message: "q", agent_id: "aid-1" }, EXEC)
.catch((e: unknown) => e);
expect((err as Error).message).toContain("2222ms");
});
});