| 1 | // Run: tsx src/__tests__/tool-subject.test.ts |
| 2 | |
| 3 | import { subjectOf } from "../lib/tools"; |
| 4 | import { toolGroupKind } from "../components/ToolGroup"; |
| 5 | import { historyMessagesToItems, isReadOnlyTool } from "../lib/useController"; |
| 6 | import type { HistoryMessage } from "../lib/types"; |
| 7 | |
| 8 | let passed = 0; |
| 9 | let failed = 0; |
| 10 | |
| 11 | function eq(a: unknown, b: unknown, label: string) { |
| 12 | if (a === b) { |
| 13 | process.stdout.write(` PASS ${label}\n`); |
| 14 | passed += 1; |
| 15 | } else { |
| 16 | process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`); |
| 17 | failed += 1; |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | console.log("\ntool subject contract"); |
| 22 | |
| 23 | eq(subjectOf("task", JSON.stringify({ description: "audit docs" })), "audit docs", "task subject uses description"); |
| 24 | eq(subjectOf("run_skill", JSON.stringify({ name: "code-reviewer", arguments: "review this branch" })), "code-reviewer", "run_skill subject uses skill name"); |
| 25 | eq( |
| 26 | subjectOf("use_capability", JSON.stringify({ action: "call", capability_id: "mcp-tool:github/search_issues" })), |
| 27 | "mcp-tool:github/search_issues", |
| 28 | "use_capability subject uses capability_id", |
| 29 | ); |
| 30 | eq(subjectOf("use_capability", JSON.stringify({ action: "list" })), "list", "use_capability list falls back to action"); |
| 31 | |
| 32 | const capabilityArgs = JSON.stringify({ action: "call", capability_id: "mcp-tool:db/write" }); |
| 33 | eq( |
| 34 | toolGroupKind({ kind: "tool", id: "reader", name: "use_capability", args: capabilityArgs, readOnly: true, status: "done" }), |
| 35 | "explore", |
| 36 | "resolved read-only MCP groups as research", |
| 37 | ); |
| 38 | eq( |
| 39 | toolGroupKind({ kind: "tool", id: "writer", name: "use_capability", args: capabilityArgs, readOnly: false, status: "done" }), |
| 40 | "modify", |
| 41 | "resolved writer MCP stays in the modify group", |
| 42 | ); |
| 43 | |
| 44 | const history = historyMessagesToItems([{ |
| 45 | role: "assistant", |
| 46 | content: "", |
| 47 | toolCalls: [ |
| 48 | { |
| 49 | id: "old", |
| 50 | name: "use_capability", |
| 51 | arguments: capabilityArgs, |
| 52 | }, |
| 53 | { |
| 54 | id: "reader", |
| 55 | name: "use_capability", |
| 56 | arguments: capabilityArgs, |
| 57 | resolvedName: "mcp__db__read", |
| 58 | capabilityId: "mcp-tool:db/read", |
| 59 | resolvedReadOnly: true, |
| 60 | }, |
| 61 | { |
| 62 | id: "writer", |
| 63 | name: "use_capability", |
| 64 | arguments: capabilityArgs, |
| 65 | resolvedName: "mcp__db__write", |
| 66 | capabilityId: "mcp-tool:db/write", |
| 67 | resolvedReadOnly: false, |
| 68 | }, |
| 69 | ], |
| 70 | }] as HistoryMessage[], "h-").items.filter((item) => item.kind === "tool"); |
| 71 | eq(history[0]?.kind === "tool" ? history[0].readOnly : true, false, "old proxy history fails closed as visible"); |
| 72 | eq(history[1]?.kind === "tool" ? history[1].readOnly : false, true, "resolved reader history restores read-only classification"); |
| 73 | eq(history[2]?.kind === "tool" ? history[2].readOnly : true, false, "resolved writer history restores writer classification"); |
| 74 | eq(history[2]?.kind === "tool" ? history[2].resolvedName : "", "mcp__db__write", "history keeps resolved MCP target"); |
| 75 | eq(isReadOnlyTool("read_file"), true, "legacy built-in reader remains read-only"); |
| 76 | eq(isReadOnlyTool("grep"), true, "legacy search reader remains read-only"); |
| 77 | eq(isReadOnlyTool("use_capability"), false, "legacy unresolved proxy remains fail-closed"); |
| 78 | eq(isReadOnlyTool("unknown_tool"), false, "unknown history tool remains fail-closed"); |
| 79 | |
| 80 | if (failed) { |
| 81 | process.stdout.write(`\n${failed} failed, ${passed} passed\n`); |
| 82 | process.exit(1); |
| 83 | } |
| 84 | process.stdout.write(`\n${passed} passed\n`); |
| 85 |