Files
Arcrun/mcp/tests/unit/tools/skills-examples-honesty.test.ts
T
uncle6me-web c2897ba68b fix(mcp): AI 一連上就知道「這裡有主人的知識庫」+「讀不到」不再講成「不存在」
leo 2026-08-13:「它應該是**你的知識來源**⋯⋯**沒有它你是瞎的**,
從你對 Arcrun 的認知就知道你的內建 Memory 是沒用的。」

## 病灶一:指路的話被綁在一個會無聲消失的段落上

instructions 裡唯一提到「這裡有知識可查」的,是 buildLibraryMapInstructions
拼上去的【藏書地圖】——而那段是**選配的**(1500ms 逾時、catch 回 null、失敗無聲)。
必定出現的那段靜態文字(startHere)從頭到尾只講工作流、零件、recipe、邊的語法,
`kbdb_search`/`kbdb_get_map` 一個字都沒有。

實害:同一天,一條 portal 連線的 session 為了回答「Arcrun 是什麼」去讀了 682 行
原始碼——而 `kbdb_search(q="Arcrun 是什麼")` 當下回得出 33 筆真答案。

修法:
- 新增 `KNOWLEDGE_FIRST` 靜態段(與 startHere 同級,KBDB 掛了照樣出現),
  排在最前面:這條連線後面有主人的知識庫、第一個動作是 kbdb_search、
  工具怎麼呼叫、以及「沒查到/沒查/地圖沒取到」三件事不可以講成同一句。
- 地圖失敗**不再靜默**:拿不到就印【藏書地圖:這次沒取到】,
  stale token 另印身分版(要使用者重新連線)——「沒取到」與「這裡沒有知識」
  不可以長得一樣(Arcrun#109 同族)。鐵律不變:失敗仍然不擋連線。
- 抽出 `buildServerInstructions(env, identity)` 供測試印出完整字串。

## 病灶二:一個 401 被逐字翻譯成「不存在」

`arcrun_get_skill('write_intent_workflow')` 回「skill ... 不存在」,
但 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`
(entry_type: agent-skill、source: installer-seed)——**與本工具查的鍵逐字相符**。
真兇是 `kbdbGetByPageName` 的 `if (!resp.ok) return null;`。
而 401 的來源是該實例的 arcrun-mcp 沒有 KBDB_INTERNAL_TOKEN
(kbdb-client 沒 token 就匿名送出)=部署面的事,但這支 code 把它講成了假話。

修法:
- 非 2xx 一律拋 KbdbAccessError(帶真 status)→ kbdb_unauthorized/kbdb_unreachable,
  訊息明說「這是讀不到,不是不存在」,並交出還走得通的那條路(kbdb_search)。
- `not_found` 只留給「KBDB 正常回應但真的沒這張卡」,並說明是「這台實例沒 seed」。
- 五支工具吃 identity:stale → identity_missing;portal 撞 401 時額外說明
  「是這批工具還走服務憑據,不是你的帳號讀不到」。
- instructions 步驟 4 不再指名 `arcrun_get_skill('INDEX')`(leo21c 實測根本沒 seed
  這支)——改成先 `arcrun_list_skills()` 看這台實例真的有哪幾支。

## 驗

- `mcp` 測試 130 passed(新增 mcp-instructions 7 項+skills-examples-honesty 10 項),tsc exit 0。
- 三種情境(地圖抓得到/抓不到/舊 token)各印一份完整 instructions 實測,
  三份的開場都指向 kbdb_search。

⚠️ 未部署(部署是 leo 的手動閘)。上線後才會到用戶手上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:09:35 +08:00

187 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* skillexample 工具的**誠實性**測試(2026-08-13Arcrun#100#109 同一族)。
*
* 真實事故:`arcrun_get_skill('write_intent_workflow')` 回「skill ... **不存在**」,
* 但同一台實例的 `kbdb_search` 撈得到 `page_name: "skill-write_intent_workflow"`
*`entry_type: agent-skill``source: installer-seed`)——**查的鍵逐字相符**。
* 真兇是 `if (!resp.ok) return null;`:一個 HTTP 401 被翻譯成「不存在」。
*
* 這個謊有實害:instructions 叫 AI 第一步先讀 skill,它照做、收到「不存在」,
* 於是結論「這裡沒有 skill」⇒ 去猜/grep repo/上網。
*
* 判準:**讀不到(401/403/5xx/連不上)與不存在(KBDB 正常回應但沒這張卡)必須長得不一樣**,
* 且讀不到時要交出還走得通的下一步(`kbdb_search`)。
*/
import { describe, it, expect } from "vitest";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { Env } from "../../../src/types.js";
import {
registerGetSkill,
registerListSkills,
registerGetExample,
} from "../../../src/tools/arcrun_skills_examples.js";
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
const SERVICE: KnowledgeIdentity = { kind: "service" };
const PORTAL: KnowledgeIdentity = {
kind: "portal",
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["*"] },
};
const STALE: KnowledgeIdentity = { kind: "stale" };
type ToolHandler = (args: Record<string, unknown>) => Promise<{
content: { type: string; text: string }[];
isError?: boolean;
}>;
function fakeServer() {
const tools = new Map<string, ToolHandler>();
const server = {
tool: (name: string, _desc: string, _schema: unknown, handler: ToolHandler) => {
tools.set(name, handler);
},
} as unknown as McpServer;
return { server, tools };
}
function makeEnv(respond: (url: string) => Response): Env {
return {
KBDB_INTERNAL_TOKEN: "",
KBDB: { fetch: async (url: string) => respond(url) },
} as unknown as Env;
}
const parse = (res: { content: { text: string }[] }) => JSON.parse(res.content[0].text);
describe("arcrun_get_skill — 401 不准講成「不存在」", () => {
it("KBDB 回 401 → kbdb_unauthorized,訊息明說「讀不到不是不存在」並給 kbdb_search 這條路", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), SERVICE);
const res = await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" });
expect(res.isError).toBe(true);
const body = parse(res);
expect(body.error_code).toBe("kbdb_unauthorized");
// 🔴 這句是本次事故的核心:不可以再出現舊版那句「skill "x" 不存在」的結論
expect(body.human_message).not.toMatch(/skill "[^"]*" 不存在/);
expect(body.human_message).toContain("這是「讀不到」,不是「不存在」");
expect(body.human_message).toContain("讀不到");
expect(body.human_message).toContain("HTTP 401");
// 還走得通的那條路要交到 AI 手上(實測 kbdb_search 撈得到同一張卡)
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'skill-write_intent_workflow' })");
// 並明白禁止它改口
expect(body.next_actions.join("\n")).toContain("不准把這個錯誤回報成");
});
it("以帳密登入的連線收到 401 → additionally 說清楚「不是你的帳號讀不到」", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("unauthorized", { status: 401 })), PORTAL);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_recipe" }));
expect(body.human_message).toContain("服務內部憑據");
expect(body.human_message).toContain("不代表你的帳號讀不到");
});
it("KBDB 5xx → kbdb_unreachable(連不上,也不是不存在)", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("boom", { status: 503 })), SERVICE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "whatever" }));
expect(body.error_code).toBe("kbdb_unreachable");
expect(body.human_message).toContain("HTTP 503");
});
it("binding 直接爆炸 → 也回 kbdb_unreachable,不吞成 not_found", async () => {
const { server, tools } = fakeServer();
const env = {
KBDB: {
fetch: async () => {
throw new Error("kbdb down");
},
},
} as unknown as Env;
registerGetSkill(server, env, SERVICE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
expect(body.error_code).toBe("kbdb_unreachable");
expect(body.human_message).toContain("kbdb down");
});
it("KBDB 正常回應但真的沒這張卡 → not_found,且說明是「這台實例沒 seed」不是全世界沒有", async () => {
const { server, tools } = fakeServer();
registerGetSkill(
server,
makeEnv(() => new Response(JSON.stringify({ entries: [] }))),
SERVICE,
);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "INDEX" }));
expect(body.error_code).toBe("not_found");
expect(body.human_message).toContain("KBDB 正常回應");
expect(body.human_message).toContain("skill-INDEX");
expect(body.next_actions.join("\n")).toContain("arcrun_list_skills()");
});
it("卡片真的在 → 照舊回內容(沒改壞正路)", async () => {
const { server, tools } = fakeServer();
let seen = "";
registerGetSkill(
server,
makeEnv((url) => {
seen = url;
return new Response(
JSON.stringify({
entries: [{ id: "e1", page_name: "skill-write_intent_workflow", content: "# 意圖工作流", tags_json: '["skill:core"]' }],
}),
);
}),
SERVICE,
);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "write_intent_workflow" }));
expect(body.ok).toBe(true);
expect(body.data.content).toBe("# 意圖工作流");
expect(seen).toContain("page_name=skill-write_intent_workflow");
});
it("舊 tokenstale)→ identity_missing(誠實要求重新連線,不謊稱找不到)", async () => {
const { server, tools } = fakeServer();
registerGetSkill(server, makeEnv(() => new Response("{}")), STALE);
const body = parse(await tools.get("arcrun_get_skill")!({ slug: "x" }));
expect(body.error_code).toBe("identity_missing");
});
});
describe("arcrun_list_skills / arcrun_get_example — 同一條規則", () => {
it("list_skills 撞 401 → kbdb_unauthorized(舊版是 fetch_failed 一句技術話)", async () => {
const { server, tools } = fakeServer();
registerListSkills(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
const body = parse(await tools.get("arcrun_list_skills")!({}));
expect(body.error_code).toBe("kbdb_unauthorized");
expect(body.next_actions.join("\n")).toContain("kbdb_search");
});
it("list_skills 成功時提醒「只用清單裡真的有的 slug」(別再猜 INDEX", async () => {
const { server, tools } = fakeServer();
registerListSkills(
server,
makeEnv(() =>
new Response(
JSON.stringify({
entries: [{ id: "e1", page_name: "skill-write_recipe", content: "x", tags_json: "[]" }],
}),
),
),
SERVICE,
);
const body = parse(await tools.get("arcrun_list_skills")!({}));
expect(body.ok).toBe(true);
expect(body.data.count).toBe(1);
expect(body.hints.join("\n")).toContain("不要硬猜名字");
});
it("get_example 撞 401 → 同樣是讀不到,不是「example 不存在」", async () => {
const { server, tools } = fakeServer();
registerGetExample(server, makeEnv(() => new Response("nope", { status: 401 })), SERVICE);
const body = parse(await tools.get("arcrun_get_example")!({ slug: "rag-search-answer" }));
expect(body.error_code).toBe("kbdb_unauthorized");
expect(body.human_message).not.toMatch(/example "[^"]*" 不存在/);
expect(body.next_actions.join("\n")).toContain("kbdb_search({ q: 'example-rag-search-answer' })");
});
});