docs(kb-dsh): 优化知识服务清单注入与错误处理提示

- 补查型工具(service_find)确认不暴露服务清单,避免与catalog冲突
- 清单内容策略扩展,0服务时注入明确禁止猜测id的提示
- 工具描述保持静态,上下文消息注入带source的UserMessage实现动态清单
- bl命令及安装提示仅在动态文本中出现,避免静态描述频繁消耗token
- 错误处理中4xx刷新并追加服务清单,0服务状态下明确提示不重试须创建部署
- service-catalog新增无服务提示及刷新服务列表构建函数
- service-context调整使用新清单构建逻辑,缓存空时注入无服务通知
- tools调整描述文案,提示来自上下文消息且拒绝猜测
- README补充bl CLI安装使用说明
- 测试补充无服务情况注入提示及刷新列表文本内容校验
This commit is contained in:
zeyu.fz
2026-08-24 19:34:15 +08:00
parent a28d95a763
commit e023af5be3
9 changed files with 262 additions and 63 deletions
+23 -7
View File
@@ -67,6 +67,8 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两
模型要判断"该不该检索",靠的是看到本 workspace 部署了哪些检索服务。插件内部经 `/api/v1/indices/rag/app/list` 拉取该清单并缓存,**不对模型暴露服务发现工具**`kb_service_list` 不会回归:它会把"先 list 再 search"的额外一轮重新引入);管理面仍用 bl。
补查型工具("只按关键词查、不列全部"的 `service_find`)也评估过,同样不做。理由不是成本而是**收益已被占掉**:唯一能支撑它的论据是"兜底走 bash + bl 不一定可用",而管理面本来就以 bl 为前提catalog 那条通道是零轮次、无条件注入、且带截断告知与默认收敛策略,再开第三个入口只会跟它抢事实源。真正要补的是兜底路径本身——见下面两小节。
### 载体:上下文消息,不是工具描述
两个工具的 **description 保持静态**(不含任何服务 id。清单经 `agent/pre-step` 注入为一条带 source 的 `UserMessage``{ kind: 'plugin', plugin: 'tool-bailian-kb/services', form: 'catalog' }`),而不是烘进 tool description。两个原因
@@ -78,15 +80,29 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两
### 清单内容策略
| 情形 | 注入内容 |
| ------------------------ | ----------------------------------------------------- |
| 配了默认服务 | 只列该服务 + "另有 N 个" 提示 |
| 未配默认deployed ≤ 10 | 全量 `agent_id` + 名称 |
| 未配默认deployed > 10 | 按 `modify_time` 倒序取 10 条,**显式标明截断**与总数 |
| 0 个 / 拉取失败 / 无缓存 | 不注入(工具仍可用) |
| 情形 | 注入内容 |
| ------------------------ | ------------------------------------------------------------------------------------ |
| 配了默认服务 | 只列该服务 + "另有 N 个" 提示 |
| 未配默认deployed ≤ 10 | 全量 `agent_id` + 名称 |
| 未配默认deployed > 10 | 按 `modify_time` 倒序取 10 条,**显式标明截断**与总数 |
| 缓存里 0 个服务 | 注入 `buildNoServiceNotice()`:禁止编 id并给出建服务/部署服务的路径(控制台或 bl |
| 无缓存文档(含拉取失败) | 不注入(工具仍可用) |
英文框架 + 服务名原样保留;空 scene 整节省略;截断必须告知(静默截断会让模型把清单当全集,进而断言"没有对应知识库")。
最后两行的差别是**能不能下断言**:缓存里有文档但 0 条,是权威的"这个 workspace 没有可调服务",说出来比沉默好——`agent_id` 恒必填,沉默只会让模型编一个 id换来服务端裸报错或静默放弃检索两者在用户看来都像插件坏了。没有文档则意味着首拉还没落地或一直失败此时任何断言都是猜交给后台刷新在后续 step 自愈。
注意这与"空 scene 整节省略"不冲突:空的**节**是噪音(另一节仍在给 id空的**清单**是模型手里一个 id 都没有。
### bl 引导只出现在动态载体上
工具描述是静态的,每次请求都付;而"怎么进一步查"取决于当下部署了什么。所以 `agent_id` 的参数描述**只指向上下文清单**,不写 `bl` 命令;`bl` 出现在两处动态文本里,且**每次出现都带安装方式**`npm install -g bailian-cli`
- catalog 中真正推荐了命令的分支(截断、收敛到默认服务);
- 4xx 之后追加的服务清单(`buildRefreshedSceneList`)。
带安装行是必需的:插件走 API 直连、从不 shell out所以一个凭据齐全的部署完全可能没装 `bl`。而安装引导原本只写在 `skills/bailian-kb/SKILL.md` 的前置检查里,那是二阶决策——要模型先决定加载 skill 才读到,恰好漏掉走工具描述兜底的那个模型。
### 缓存与刷新
落点:`${DSH_HOME:-~/.dsh}/cache/bailian-kb/services-<workspaceId>.json`(临时文件 + `rename()` 原子发布,目录 `0o700`)。按 workspace 分文件是必需的api key 只能访问自己的 workspace而"自动获取"按钮就是为了切账号。
@@ -108,7 +124,7 @@ settings 注册是**手写**的,没有用 `installSettingsSection`:需要两
## 错误语义
- HTTP 错误4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 id 已失效5xx 与刷新本身失败则原错误透传;
- HTTP 错误4xx 时刷新服务缓存并把当前可用服务追加进错误消息(这两个接口上 `agent_id` 是唯一的调用方标识符,所以 4xx 大多是 id 已失效);刷新后该 scene **一个服务都没有**时也照样追加说明(明确"别换 id 重试"+ 建服务路径),而不是放裸错误过去——裸的 `invalid agent_id` 读起来就是"再试一个"5xx 与刷新本身失败则原错误透传;
- 凭证缺失:指向 `~/.dsh/.env` / `.credentials.yaml` 配置方式与控制台取 key 页面;
- chat 超时:说明服务端多轮检索特性,建议重试或改用 `kb_search`
- 服务端错误体截断至 500 字符进入错误信息(优先 `code: message`)。
+1
View File
@@ -32,6 +32,7 @@ Retrieval happens through **retrieval services** you deploy on Bailian: a servic
- DeepSeek Harness with its plugin runtime (`@deepseek-ai/dsh-*`), Node.js >= 22.12
- An Aliyun Model Studio account: a **workspace id** and a **DashScope API key** ([get one](https://bailian.console.aliyun.com/?tab=app#/api-key))
- At least one **deployed** retrieval or Q&A service in that workspace — create one in the [console](https://bailian.console.aliyun.com/) or with `bl knowledge service create` / `bl knowledge service deploy`
- The [`bl` CLI](https://www.npmjs.com/package/bailian-cli) (`npm install -g bailian-cli`) for anything on the management side — creating knowledge bases, ingesting documents, deploying services. Retrieval itself calls the API directly and never shells out, so `kb_search` / `kb_chat` work without it
## Installation
+1
View File
@@ -32,6 +32,7 @@
- DeepSeek Harness 及其插件运行时(`@deepseek-ai/dsh-*`Node.js >= 22.12
- 阿里云百炼账号:一个**工作空间 ID** 和一个 **DashScope API 密钥**[去获取](https://bailian.console.aliyun.com/?tab=app#/api-key)
- 该工作空间下至少有一个**已部署**的检索或问答服务 —— 可在[控制台](https://bailian.console.aliyun.com/)创建,或用 `bl knowledge service create` / `bl knowledge service deploy`
- 管理面的事(建库、导入文档、部署服务)需要 [`bl` CLI](https://www.npmjs.com/package/bailian-cli)`npm install -g bailian-cli`)。检索本身直连 API、从不起子进程所以 `kb_search` / `kb_chat` 不装也能用
## 安装
+6 -19
View File
@@ -19,7 +19,7 @@ import { consoleLoginState, startConsoleLogin } from "./console-login.js";
import { KbClient } from "./client.js";
import { registerSkill } from "./skill.js";
import { ServiceCache } from "./service-cache.js";
import { CATALOG_ENTRY_LIMIT } from "./service-catalog.js";
import { buildRefreshedSceneList } from "./service-catalog.js";
import { installServiceContext } from "./service-context.js";
import type { ServiceScene } from "./api-types.js";
import { createKbTools } from "./tools.js";
@@ -333,28 +333,15 @@ export function apply(ctx: Context, config: Config): void {
resolveDefaultRetrieveAgentId: async () => await resolveDefaultAgentId("search"),
resolveDefaultChatAgentId: async () => await resolveDefaultAgentId("chat"),
// Self-heal for a cached id the server has since rejected: refresh once and
// put the current list in the error, which reaches the model this step.
// put the current list in the error, which reaches the model this step. An
// empty result is reported too rather than dropped — a bare "invalid
// agent_id" reads as "try another one", and when nothing is deployed no id
// can work.
describeServicesAfterRefresh: async (scene) => {
await serviceCache.refresh();
const workspaceId = await resolveWorkspaceIdOrUndefined();
if (workspaceId === undefined) return undefined;
const forScene =
serviceCache.peek(workspaceId)?.entries.filter((entry) => entry.scene === scene) ?? [];
if (forScene.length === 0) return undefined;
const lines = forScene
.slice(0, CATALOG_ENTRY_LIMIT)
.map(
(entry) =>
`- ${entry.agent_id}${entry.agent_name === "" ? "(unnamed)" : entry.agent_name}`,
);
const more = forScene.length - lines.length;
return [
`Deployed ${scene} services in this workspace, re-read just now:`,
...lines,
...(more > 0
? [`(and ${more} more — \`bl knowledge service list --scene ${scene}\`)`]
: []),
].join("\n");
return buildRefreshedSceneList(scene, serviceCache.entriesFor(workspaceId, scene));
},
get chatTimeoutMs() {
return current().chatTimeoutMs;
+106 -9
View File
@@ -1,8 +1,8 @@
/**
* Renders the deployed-service catalog the model reads before deciding whether
* to retrieve.
* to retrieve, plus the notice for a workspace that deploys nothing at all.
*
* This is a pure function on purpose: the four selection branches below are the
* These are pure functions on purpose: the selection branches below are the
* whole routing policy, and they are far easier to pin down in tests than
* through a live pre-step.
*
@@ -12,7 +12,11 @@
* - Truncation is always stated. Silently cutting the list makes the model treat
* it as complete and flatly answer "there is no such knowledge base".
* - An empty scene omits its whole section. `no chat services` is pure noise and
* invites the model to handle a case that does not exist.
* invites the model to handle a case that does not exist. An empty CATALOG is
* the opposite case and does get a message: see {@link buildNoServiceNotice}.
* - `bl` is only ever recommended together with how to get it. This plugin talks
* to the API directly and never shells out, so a fully configured deployment
* can have no `bl` on PATH at all.
*/
import type { ServiceScene } from "./api-types.js";
@@ -24,6 +28,17 @@ export const CATALOG_ENTRY_LIMIT = 10;
/** Truncation applied to a service description once the backend returns one. */
const DESCRIPTION_LIMIT = 200;
/**
* Appended once whenever the text tells the model to look further with `bl`.
*
* The install line belongs next to the recommendation, not in the tool
* descriptions: those are static and every token there is spent on every
* request, while this is needed only in the branches that actually name the
* command. A single-service catalog never carries it.
*/
const BL_AVAILABILITY_NOTE =
"(`bl` is the Bailian CLI — install it with `npm install -g bailian-cli` if the command is not found.)";
export interface CatalogInput {
entries: readonly ServiceEntry[];
/** Server-reported total, which may exceed `entries` when the fetch itself was capped. */
@@ -74,7 +89,7 @@ function renderScene(
scene: ServiceScene,
defaultAgentId: string | undefined,
truncatedFetch: boolean,
): string[] | undefined {
): { lines: string[]; usedLookupHint: boolean } | undefined {
const forScene = entries.filter((entry) => entry.scene === scene);
if (forScene.length === 0) return undefined;
const lines = [`${SCENE_LABEL[scene]}:`];
@@ -93,13 +108,14 @@ function renderScene(
` (default service; ${others} other${others === 1 ? "" : "s"} exist — ` +
`run \`bl knowledge service list --scene ${scene}\` to see them)`,
);
return { lines, usedLookupHint: true };
}
return lines;
return { lines, usedLookupHint: false };
}
if (forScene.length <= CATALOG_ENTRY_LIMIT && !truncatedFetch) {
lines.push(...forScene.map(renderEntry));
return lines;
return { lines, usedLookupHint: false };
}
const shown = [...forScene].sort(byRecency).slice(0, CATALOG_ENTRY_LIMIT);
@@ -112,7 +128,86 @@ function renderScene(
`${knownTotal} deployed ${scene} services — run \`bl knowledge service list --scene ${scene} ` +
"--name <keyword>` to look for others)",
);
return lines;
return { lines, usedLookupHint: true };
}
/**
* The notice injected when the workspace is reachable but deploys nothing the
* tools can call.
*
* This deliberately contradicts the "omit what does not exist" rule above, and
* the cases are not symmetric: an empty SECTION is noise because the other
* section still hands the model ids, whereas an empty CATALOG leaves it with no
* id at all while `agent_id` stays required. With nothing said, the model either
* invents an id earning a raw server rejection or silently drops the
* retrieval; both read to the user as a broken plugin when the real answer is
* "deploy a service". Naming the fix is the only way out that does not require
* the model to already have loaded the management skill.
* @returns the model-facing notice text.
*/
export function buildNoServiceNotice(): string {
return [
"<system-reminder>",
"The Bailian knowledge tools (kb_search / kb_chat) are configured, but this workspace has no " +
"deployed knowledge service, so neither tool can be called: both require an `agent_id` and there " +
"is none to use. Do not call them, and do not invent an id.",
"",
"If the user asks for something that should come from their knowledge base, tell them a service has " +
"to be created and deployed first — in the Bailian console (https://bailian.console.aliyun.com/), " +
"or with `bl knowledge service create` followed by `bl knowledge service deploy`. " +
BL_AVAILABILITY_NOTE,
"</system-reminder>",
].join("\n");
}
/**
* The service list appended to a call the server rejected, rendered from a
* just-refreshed cache.
*
* Lives here rather than at the call site so every model-facing rendering obeys
* the one set of conventions documented at the top of this file in particular
* that a shortfall is stated and that `bl` never appears without its install
* line.
* @param scene - the scene the failed call targeted.
* @param entries - that scene's entries as of the refresh, newest first.
* @returns the text to append to the failure.
*/
export function buildRefreshedSceneList(
scene: ServiceScene,
entries: readonly ServiceEntry[],
): string {
if (entries.length === 0) return buildNoSceneServiceNotice(scene);
const shown = entries.slice(0, CATALOG_ENTRY_LIMIT);
const more = entries.length - shown.length;
return [
`Deployed ${scene} services in this workspace, re-read just now:`,
...shown.map(renderEntry),
...(more > 0
? [
`(and ${more} more — run \`bl knowledge service list --scene ${scene}\` to see them) ` +
BL_AVAILABILITY_NOTE,
]
: []),
].join("\n");
}
/**
* What a rejected call is told when the refreshed list holds no service for that
* scene at all.
*
* The tool has already failed by this point, so this text is the model's only
* instruction. Without it the bare server rejection ("invalid agent_id") invites
* a retry with another guess, and no guess can succeed.
* @param scene - the scene whose services came back empty.
* @returns the text to append to the failure.
*/
function buildNoSceneServiceNotice(scene: ServiceScene): string {
return (
`This workspace has no deployed ${scene} services at all (re-checked just now), so no agent_id can ` +
"work — do not retry with a different id. Tell the user one has to be created and deployed, in the " +
"Bailian console (https://bailian.console.aliyun.com/) or with `bl knowledge service create` " +
`followed by \`bl knowledge service deploy\`. ${BL_AVAILABILITY_NOTE}`
);
}
/**
@@ -129,6 +224,7 @@ export function buildServiceCatalog(input: CatalogInput): string | undefined {
);
const chat = renderScene(input.entries, "chat", input.defaultChatAgentId, input.truncated);
if (search === undefined && chat === undefined) return undefined;
const usedLookupHint = search?.usedLookupHint === true || chat?.usedLookupHint === true;
return [
"<system-reminder>",
// The header must not name the tools: a scene with no services omits its
@@ -138,12 +234,13 @@ export function buildServiceCatalog(input: CatalogInput): string | undefined {
"Pass an id from the matching section as that tool's `agent_id` argument — it is required and " +
"cannot be guessed.",
"",
...(search ?? []),
...(search?.lines ?? []),
...(search !== undefined && chat !== undefined ? [""] : []),
...(chat ?? []),
...(chat?.lines ?? []),
"",
"If none of these services covers what the user is asking about, say so plainly rather than " +
"trying the closest-looking id — an unrelated retrieval result is worse than none.",
...(usedLookupHint ? [BL_AVAILABILITY_NOTE] : []),
"</system-reminder>",
].join("\n");
}
+15 -9
View File
@@ -25,7 +25,7 @@ import type { Context } from "@deepseek-ai/cordis";
import type { Agent, PreStepDecision } from "@deepseek-ai/dsh-agent";
import { createUserMessage } from "@deepseek-ai/dsh-llm";
import type { UserMessage } from "@deepseek-ai/dsh-session";
import { buildServiceCatalog } from "./service-catalog.js";
import { buildNoServiceNotice, buildServiceCatalog } from "./service-catalog.js";
import type { ServiceCache } from "./service-cache.js";
/** Marks this plugin's own injections in the durable log. */
@@ -102,20 +102,26 @@ export function installServiceContext(ctx: Context, opts: ServiceContextOptions)
if (opts.cache.isStale(workspaceId)) void opts.cache.refresh();
const document = opts.cache.peek(workspaceId);
// No document at all means the first fetch has not landed (or is
// failing): we know nothing, so claiming anything would be a guess. The
// refresh above self-corrects this on a later step.
if (document === undefined) return decision;
const [defaultRetrieveAgentId, defaultChatAgentId] = await Promise.all([
opts.resolveDefaultRetrieveAgentId(),
opts.resolveDefaultChatAgentId(),
]);
if (signal.aborted) return decision;
const text = buildServiceCatalog({
entries: document.entries,
total: document.total,
truncated: document.truncated,
...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}),
...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}),
});
if (text === undefined) return decision;
// A document with zero entries is the opposite: an authoritative "this
// workspace deploys nothing callable". Saying so beats silence, which
// leaves the model to invent a required agent_id.
const text =
buildServiceCatalog({
entries: document.entries,
total: document.total,
truncated: document.truncated,
...(defaultRetrieveAgentId !== undefined ? { defaultRetrieveAgentId } : {}),
...(defaultChatAgentId !== undefined ? { defaultChatAgentId } : {}),
}) ?? buildNoServiceNotice();
// Identical to what the model already sees: stay out of the way. This is
// the branch that runs on nearly every step.
+11 -6
View File
@@ -12,6 +12,12 @@
* changed schema token. The live catalog therefore rides an `agent/pre-step`
* context message instead (see `service-context.ts`), leaving these schemas
* byte-stable for the life of the process.
*
* Being static is also why recovery instructions do NOT live here: how to look
* further depends on what is actually deployed, and a token spent in a
* description is spent on every request. The dynamic carriers own that the
* catalog message states a shortfall where one exists, and a rejected call comes
* back with the services that do exist (`service-catalog.ts`).
*/
import { defineTool } from "@deepseek-ai/dsh-tools";
@@ -83,12 +89,11 @@ export function createKbTools(deps: KbToolDeps) {
required: true as const,
description:
"Retrieval/Q&A service id. REQUIRED: the schema cannot know whether this deployment " +
"configures a default service, so always pass one. The deployed services of this workspace, " +
"with their ids, are listed in a context message in this conversation; take the id from the " +
"section matching the tool you are calling. If that list is absent or none of its services " +
"covers the question, run `bl knowledge service list --scene search --name <keyword>` to look " +
"(workspaceId resolves automatically from DSH settings: bailian-kb.workspaceId in " +
"~/.dsh/settings.yaml).",
"configures a default service, so always pass one. This conversation carries a context message " +
"listing the services deployed in the workspace — take the id from the section matching the tool " +
"you are calling. When that list is absent, or none of its services covers the question, say so " +
"rather than guessing: a rejected id is answered with the services that do exist, so a guess buys " +
"nothing.",
};
const resolveRetrieveAgentId = async (supplied: string | undefined): Promise<string> => {
if (supplied !== undefined) return supplied;
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vite-plus/test";
import { CATALOG_ENTRY_LIMIT, buildServiceCatalog } from "../src/service-catalog.js";
import {
CATALOG_ENTRY_LIMIT,
buildNoServiceNotice,
buildRefreshedSceneList,
buildServiceCatalog,
} from "../src/service-catalog.js";
import type { ServiceEntry } from "../src/services.js";
function entry(overrides: Partial<ServiceEntry> & { agent_id: string }): ServiceEntry {
@@ -19,8 +24,9 @@ function catalog(
}
describe("buildServiceCatalog", () => {
it("returns undefined when there is nothing worth injecting", () => {
// No services at all: the tool descriptions alone keep the tools usable.
it("returns undefined when there is no service to render", () => {
// Nothing to route to; the caller substitutes buildNoServiceNotice(), which
// is the branch that tells the model what to do about it.
expect(catalog([])).toBeUndefined();
});
@@ -119,4 +125,69 @@ describe("buildServiceCatalog", () => {
expect(text).not.toContain("x".repeat(201));
expect(text).toContain("…");
});
it("explains how to get bl only where it recommends running it", () => {
// The plugin never shells out, so a fully configured deployment can have no
// bl on PATH. Naming the command without the install line is a dead end.
const complete = catalog([entry({ agent_id: "aid-1" })]) ?? "";
expect(complete).not.toContain("bl knowledge service list");
expect(complete).not.toContain("npm install -g bailian-cli");
const capped =
catalog(Array.from({ length: 12 }, (_x, index) => entry({ agent_id: `aid-${index}` }))) ?? "";
expect(capped).toContain("bl knowledge service list");
expect(capped).toContain("npm install -g bailian-cli");
// Same rule on the collapsed-to-default branch, which also names the command.
const collapsed =
catalog([entry({ agent_id: "aid-1" }), entry({ agent_id: "aid-2" })], {
defaultRetrieveAgentId: "aid-1",
}) ?? "";
expect(collapsed).toContain("bl knowledge service list");
expect(collapsed).toContain("npm install -g bailian-cli");
});
});
describe("buildNoServiceNotice", () => {
it("names the way out instead of leaving the model to invent an agent_id", () => {
const text = buildNoServiceNotice();
// agent_id is required, so silence here means a guessed id or a silently
// dropped retrieval — both look like a broken plugin to the user.
expect(text).toMatch(/do not invent an id/i);
expect(text).toContain("bl knowledge service create");
expect(text).toContain("bl knowledge service deploy");
// The management skill is a second-order path: a model that never loads it
// must still learn how to get bl.
expect(text).toContain("npm install -g bailian-cli");
expect(text).toContain("https://bailian.console.aliyun.com/");
});
});
describe("buildRefreshedSceneList", () => {
it("lists the scene's services for a rejected call", () => {
const text = buildRefreshedSceneList("search", [
entry({ agent_id: "aid-1", agent_name: "产品文档" }),
]);
expect(text).toContain("aid-1 — 产品文档");
expect(text).toMatch(/re-read just now/i);
expect(text).not.toContain("npm install -g bailian-cli");
});
it("states the shortfall and how to get bl when the list is capped", () => {
const entries = Array.from({ length: CATALOG_ENTRY_LIMIT + 3 }, (_x, index) =>
entry({ agent_id: `aid-${index}` }),
);
const text = buildRefreshedSceneList("search", entries);
expect(text).toContain("and 3 more");
expect(text).toContain("npm install -g bailian-cli");
});
it("tells a rejected call to stop retrying when the scene has no services", () => {
// Returning nothing here used to let the bare "invalid agent_id" through,
// which reads as an invitation to try a different id.
const text = buildRefreshedSceneList("chat", []);
expect(text).toMatch(/no deployed chat services/i);
expect(text).toMatch(/do not retry with a different id/i);
expect(text).toContain("bl knowledge service deploy");
});
});
@@ -17,6 +17,8 @@ interface HarnessOptions {
entries?: { agent_id: string; agent_name: string; scene: "search" | "chat"; status: string }[];
cacheOverride?: ServiceCache;
postJson?: () => Promise<unknown>;
/** Skip seeding the cache file, i.e. the very first step of a fresh install. */
withoutCacheFile?: boolean;
}
/** Drives the installed `agent/pre-step` listener against a fake session. */
@@ -25,15 +27,17 @@ function harness(options: HarnessOptions = {}) {
const entries = options.entries ?? [
{ agent_id: "aid-1", agent_name: "svc-one", scene: "search" as const, status: "deployed" },
];
writeServiceCache(serviceCachePath(WS, home), {
version: 1,
fetchedAt: Date.now(),
workspaceId: WS,
endpointHost: HOST,
entries,
total: entries.length,
truncated: false,
});
if (options.withoutCacheFile !== true) {
writeServiceCache(serviceCachePath(WS, home), {
version: 1,
fetchedAt: Date.now(),
workspaceId: WS,
endpointHost: HOST,
entries,
total: entries.length,
truncated: false,
});
}
const postJson = vi.fn(
options.postJson ?? (async () => ({ code: "Success", data: { total_count: 0, rows: [] } })),
);
@@ -145,8 +149,19 @@ describe("installServiceContext", () => {
expect(h.injected(second)).toHaveLength(1);
});
it("injects nothing when the cache holds no services", async () => {
it("injects the no-service notice when the cache holds no services", async () => {
// A cached empty list is authoritative, not a missing fetch: staying silent
// would leave the model to invent the required agent_id.
const h = harness({ entries: [] });
const own = h.injected(await h.step());
expect(own).toHaveLength(1);
expect(own[0]?.content[0]?.text).toMatch(/no\s+deployed knowledge service/i);
});
it("injects nothing while no cache document exists yet", async () => {
// Nothing has been fetched, so any claim about the workspace would be a
// guess; the background refresh corrects this on a later step.
const h = harness({ withoutCacheFile: true });
expect(h.injected(await h.step())).toHaveLength(0);
});