| 1 | // Run: tsx src/__tests__/tool-subject.test.ts |
| 2 | |
| 3 | import { subjectOf } from "../lib/tools"; |
| 4 | import { historyMessagesToItems, isBatchedReadOnlyTool, isReadOnlyTool } from "../lib/useController"; |
| 5 | import type { HistoryMessage } from "../lib/types"; |
| 6 | |
| 7 | let passed = 0; |
| 8 | let failed = 0; |
| 9 | |
| 10 | function eq(a: unknown, b: unknown, label: string) { |
| 11 | if (a === b) { |
| 12 | process.stdout.write(` PASS ${label}\n`); |
| 13 | passed += 1; |
| 14 | } else { |
| 15 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 16 | failed += 1; |
| 17 | } |
| 18 | } |
| 19 | |
| 20 | console.log("\ntool subject contract"); |
| 21 | |
| 22 | eq(subjectOf("task", JSON.stringify({ description: "audit docs" })), "audit docs", "task subject uses description"); |
| 23 | eq(subjectOf("run_skill", JSON.stringify({ name: "code-reviewer", arguments: "review this branch" })), "code-reviewer", "run_skill subject uses skill name"); |
| 24 | eq( |
| 25 | subjectOf("use_capability", JSON.stringify({ action: "call", capability_id: "mcp-tool:github/search_issues" })), |
| 26 | "mcp-tool:github/search_issues", |
| 27 | "use_capability subject uses capability_id", |
| 28 | ); |
| 29 | eq(subjectOf("use_capability", JSON.stringify({ action: "list" })), "list", "use_capability list falls back to action"); |
| 30 | |
| 31 | const capabilityArgs = JSON.stringify({ action: "call", capability_id: "mcp-tool:db/write" }); |
| 32 | |
| 33 | |
| 34 | |
| 35 | |
| 36 | const history = historyMessagesToItems([{ |
| 37 | role: "assistant", |
| 38 | content: "", |
| 39 | toolCalls: [ |
| 40 | { |
| 41 | id: "old", |
| 42 | name: "use_capability", |
| 43 | arguments: capabilityArgs, |
| 44 | }, |
| 45 | { |
| 46 | id: "reader", |
| 47 | name: "use_capability", |
| 48 | arguments: capabilityArgs, |
| 49 | resolvedName: "mcp__db__read", |
| 50 | capabilityId: "mcp-tool:db/read", |
| 51 | resolvedReadOnly: true, |
| 52 | }, |
| 53 | { |
| 54 | id: "writer", |
| 55 | name: "use_capability", |
| 56 | arguments: capabilityArgs, |
| 57 | resolvedName: "mcp__db__write", |
| 58 | capabilityId: "mcp-tool:db/write", |
| 59 | resolvedReadOnly: false, |
| 60 | }, |
| 61 | ], |
| 62 | }] as HistoryMessage[], "h-").items.filter((item) => item.kind === "tool"); |
| 63 | eq(history[0]?.kind === "tool" ? history[0].readOnly : true, false, "old proxy history fails closed as visible"); |
| 64 | eq(history[1]?.kind === "tool" ? history[1].readOnly : false, true, "resolved reader history restores read-only classification"); |
| 65 | eq(history[2]?.kind === "tool" ? history[2].readOnly : true, false, "resolved writer history restores writer classification"); |
| 66 | eq(history[2]?.kind === "tool" ? history[2].resolvedName : "", "mcp__db__write", "history keeps resolved MCP target"); |
| 67 | eq(isReadOnlyTool("read_file"), true, "legacy built-in reader remains read-only"); |
| 68 | eq(isReadOnlyTool("web_search"), true, "provider web search cards are read-only"); |
| 69 | eq(isBatchedReadOnlyTool("web_search", true), false, "provider web search stays a standalone card"); |
| 70 | eq(isBatchedReadOnlyTool("read_file", true), true, "ordinary readers still batch"); |
| 71 | eq(isReadOnlyTool("grep"), true, "legacy search reader remains read-only"); |
| 72 | eq(isReadOnlyTool("use_capability"), false, "legacy unresolved proxy remains fail-closed"); |
| 73 | eq(isReadOnlyTool("unknown_tool"), false, "unknown history tool remains fail-closed"); |
| 74 | |
| 75 | const searchHistory = historyMessagesToItems([{ |
| 76 | role: "assistant", |
| 77 | content: "answer only", |
| 78 | serverSearch: [{ |
| 79 | id: "s1", |
| 80 | query: "bitcoin", |
| 81 | results: [{ title: "新闻本文", url: "https://example.com/a" }], |
| 82 | }], |
| 83 | }] as HistoryMessage[], "h-").items; |
| 84 | eq(searchHistory[0]?.kind, "tool", "server search hydrates as a tool card before the answer"); |
| 85 | eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].name : "", "web_search", "server search card uses web_search"); |
| 86 | eq(searchHistory[0]?.kind === "tool" ? subjectOf("web_search", searchHistory[0].args) : "", "bitcoin", "search card subject is the query"); |
| 87 | eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].searchSources?.length : 0, 1, "search card keeps structured sources for display"); |
| 88 | eq(searchHistory[0]?.kind === "tool" ? searchHistory[0].output : "", "新闻本文\nhttps://example.com/a", "search card preserves raw output for replay/history"); |
| 89 | eq(searchHistory[1]?.kind, "assistant", "model text stays a separate assistant item"); |
| 90 | eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].text : "", "answer only", "answer text does not include search results"); |
| 91 | eq(searchHistory[1]?.kind === "assistant" ? searchHistory[1].searchSources?.[0]?.title : "", "新闻本文", "answer hydrates search footnotes"); |
| 92 | |
| 93 | if (failed) { |
| 94 | process.stdout.write(`\n${failed} failed, ${passed} passed\n`); |
| 95 | process.exit(1); |
| 96 | } |
| 97 | process.stdout.write(`\n${passed} passed\n`); |
| 98 |