ClaudeAdapter.test.ts 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996
  1. /**
  2. * Unit tests for ClaudeAdapter — the `plugins/claude-code/` emitter.
  3. *
  4. * ## What changed, and why the old suite could not simply be edited
  5. *
  6. * This adapter used to emit `.claude/config.json` + `.claude/agents/*.md`. The `config.json`
  7. * half was fabricated (Claude Code has no such agent-config file) and the real target is the
  8. * plugin tree committed at `plugins/claude-code/`. Roughly half the old suite asserted the
  9. * shape of a file that should never have existed, so those tests are gone rather than
  10. * retargeted — keeping them would pin a format nothing reads.
  11. *
  12. * ## The one rule these tests exist to defend
  13. *
  14. * Claude Code's frontmatter has two flat lists and no scoping: no ordered globs, no `ask`,
  15. * no last-match-wins. Canonical agents depend on all three. Emitting MORE permission than
  16. * canonical specifies is the single unacceptable outcome, so every projection here is
  17. * checked to fail CLOSED and to say out loud what it dropped. A silent widening is the bug
  18. * class this file is aimed at — `PermissionMapper`'s permissive default (`hasAllow ||
  19. * !hasDeny`) answers `bash: true` for a deny-all-then-allowlist block, which is exactly why
  20. * the adapter routes through `core/Capabilities.ts` instead.
  21. */
  22. import { describe, it, expect, beforeEach } from "vitest";
  23. import { readFileSync } from "node:fs";
  24. import { ClaudeAdapter } from "../../../src/adapters/ClaudeAdapter";
  25. import { packagePath } from "../../support/pending.js";
  26. import type { OpenAgent, AgentFrontmatter, HookDefinition } from "../../../src/types";
  27. /** Claude Code's tools in the adapter's emit order. The union of both lists is always this. */
  28. const CLAUDE_TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "Task"] as const;
  29. /** The same list, rendered for a YAML `tools: [...]` override. */
  30. const CLAUDE_TOOLS_LITERAL = CLAUDE_TOOLS.join(", ");
  31. const FIXTURE_REVIEWER = packagePath("tests/golden/fixtures/fixture-reviewer.md");
  32. const FIXTURE_PLANNER = packagePath("tests/golden/fixtures/fixture-planner.md");
  33. function fixture(path: string): string {
  34. return readFileSync(path, "utf-8");
  35. }
  36. /**
  37. * A canonical agent file built around one permission block.
  38. *
  39. * The override defaults to a valid one: every agent targeting claude-code MUST author
  40. * `tools`, so an agent without one is an error, not a baseline. Tests that care about the
  41. * tool list pass their own via {@link override}; the "refuses without tools" case passes "".
  42. */
  43. function canonical(permission: string, extra = "", override = OVERRIDE_DEFAULT): string {
  44. return `---
  45. name: ProbeAgent
  46. description: A probe agent.
  47. mode: subagent
  48. ${extra}permission:
  49. ${permission}
  50. oac:
  51. id: probe-agent
  52. name: ProbeAgent
  53. category: subagents/test
  54. type: subagent
  55. targets:
  56. - claude-code
  57. ${override}---
  58. # ProbeAgent
  59. Body.
  60. `;
  61. }
  62. /** An `oac.overrides.claude-code` block, indented to sit inside {@link canonical}'s oac block. */
  63. function override(body: string): string {
  64. return ` overrides:\n claude-code:\n${body}`;
  65. }
  66. /** The default override for probes that are not about the tool list itself. */
  67. const OVERRIDE_DEFAULT = override(` tools: [Read]\n`);
  68. /**
  69. * An in-memory OpenAgent carrying an authored permission map — the `fromOAC` (legacy
  70. * `oac-compat convert`) path.
  71. *
  72. * The degradation warnings live here rather than on the canonical path, and that is by design:
  73. * `fromCanonical` refuses to degrade a scoped rule set at all (it demands an authored
  74. * override), whereas `fromOAC` converts an agent from a format with no `oac:` block, so there
  75. * is nowhere to record a decision and nobody to ask. Fail-closed-and-warn is the best it can
  76. * do, so it is still exactly what these assert.
  77. */
  78. function legacy(permission: Record<string, unknown>): OpenAgent {
  79. return {
  80. frontmatter: {
  81. name: "probe-agent",
  82. description: "A probe agent.",
  83. mode: "subagent",
  84. permission,
  85. } as AgentFrontmatter,
  86. metadata: { name: "probe-agent", category: "core", type: "subagent" },
  87. systemPrompt: "Body.",
  88. contexts: [],
  89. };
  90. }
  91. describe("ClaudeAdapter", () => {
  92. let adapter: ClaudeAdapter;
  93. beforeEach(() => {
  94. adapter = new ClaudeAdapter();
  95. });
  96. // ============================================================================
  97. // ADAPTER IDENTITY
  98. // ============================================================================
  99. describe("adapter identity", () => {
  100. it("has correct name", () => {
  101. expect(adapter.name).toBe("claude");
  102. });
  103. it("has correct displayName", () => {
  104. expect(adapter.displayName).toBe("Claude Code");
  105. });
  106. it("returns the plugin tree as its config path, not .claude/", () => {
  107. expect(adapter.getConfigPath()).toBe("plugins/claude-code/");
  108. });
  109. });
  110. // ============================================================================
  111. // OUTPUT LAYOUT
  112. // ============================================================================
  113. describe("output layout", () => {
  114. it("emits agents under plugins/claude-code/agents/", async () => {
  115. const { path } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  116. expect(path).toBe("plugins/claude-code/agents/fixture-reviewer.md");
  117. });
  118. it("keys the emitted path on oac.id, not the authored display name", async () => {
  119. // The canonical ids and the Claude Code filenames genuinely differ across the corpus
  120. // (`contextscout` -> `context-scout.md`, `reviewer` -> `code-reviewer.md`). Only the
  121. // id is stable identity, so resolving by `name:` would emit the wrong filename.
  122. const { path, content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  123. expect(path).toContain("fixture-reviewer.md"); // oac.id
  124. expect(path).not.toContain("FixtureReviewer"); // frontmatter name
  125. expect(content).toMatch(/^name: fixture-reviewer$/m);
  126. });
  127. it("emits no .claude/ path from any conversion", async () => {
  128. const canonicalResult = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
  129. const oacResult = await adapter.fromOAC({
  130. frontmatter: { name: "Agent", description: "Test", mode: "primary" },
  131. metadata: { name: "Agent", category: "core", type: "agent" },
  132. systemPrompt: "Prompt",
  133. contexts: [{ path: "context/a.md", description: "A" }],
  134. });
  135. expect(canonicalResult.path).not.toContain(".claude/");
  136. for (const config of oacResult.configs) {
  137. expect(config.fileName, `${config.fileName} still targets the old layout`).not.toContain(
  138. ".claude/"
  139. );
  140. expect(config.fileName).toMatch(/^plugins\/claude-code\//);
  141. }
  142. });
  143. it("never emits a config.json", async () => {
  144. const result = await adapter.fromOAC({
  145. frontmatter: { name: "Agent", description: "Test", mode: "primary" },
  146. metadata: { name: "Agent", category: "core", type: "agent" },
  147. systemPrompt: "Prompt",
  148. contexts: [],
  149. });
  150. expect(result.configs.map((c) => c.fileName)).toEqual([
  151. "plugins/claude-code/agents/Agent.md",
  152. ]);
  153. });
  154. it("emits one agent file for a primary agent, same as a subagent", async () => {
  155. // The old primary/subagent split existed only to choose between config.json and an
  156. // agent file. With config.json gone there is exactly one shape.
  157. const primary = await adapter.fromCanonical(fixture(FIXTURE_PLANNER)); // mode: primary
  158. expect(primary.path).toBe("plugins/claude-code/agents/fixture-planner.md");
  159. expect(primary.content).toMatch(/^---\nname: fixture-planner$/m);
  160. });
  161. });
  162. // ============================================================================
  163. // FRONTMATTER SHAPE
  164. // ============================================================================
  165. describe("frontmatter shape", () => {
  166. it("emits keys in the committed order: name, description, tools, disallowedTools, model", async () => {
  167. const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  168. const keys = content
  169. .split("---")[1]!
  170. .trim()
  171. .split("\n")
  172. .map((line) => line.split(":")[0]);
  173. expect(keys).toEqual(["name", "description", "tools", "disallowedTools", "model"]);
  174. });
  175. it("reproduces the committed frontmatter shape byte-for-byte", async () => {
  176. const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  177. expect(content.split("---\n\n")[0]).toBe(
  178. `---\nname: fixture-reviewer\n` +
  179. `description: Reviews code for correctness. A golden-file fixture, not a shipped agent.\n` +
  180. `tools: Read, Glob, Grep\n` +
  181. `disallowedTools: Write, Edit, Bash, WebFetch, Task\n` +
  182. `model: haiku\n`
  183. );
  184. });
  185. it("passes the model through unmapped", async () => {
  186. // The committed corpus uses Claude Code's own aliases (`sonnet`, `haiku`). Expanding
  187. // them to dated ids (`claude-sonnet-4-20250514`) would break every committed agent.
  188. const { content } = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
  189. expect(content).toMatch(/^model: sonnet$/m);
  190. });
  191. it("omits model when the source declares none", async () => {
  192. const { content } = await adapter.fromCanonical(canonical(` read:\n "*": "allow"\n`));
  193. expect(content).not.toMatch(/^model:/m);
  194. });
  195. it("omits an empty tools list rather than emitting a bare key", async () => {
  196. // `tools:` with no value means something different to Claude Code than an absent key.
  197. const { content } = await adapter.fromCanonical(
  198. canonical(` bash:\n "*": "deny"\n`, "", override(` tools: []\n`))
  199. );
  200. expect(content).not.toMatch(/^tools:\s*$/m);
  201. expect(content).toMatch(/^disallowedTools: Read, Write/m);
  202. });
  203. it("omits an empty disallowedTools list", async () => {
  204. // Only reachable when every bound tool is granted — an authored override is exhaustive,
  205. // so this is the one shape that leaves the deny list empty.
  206. const { content } = await adapter.fromCanonical(
  207. canonical(
  208. ` read:\n "*": "allow"\n`,
  209. "",
  210. override(` tools: [${CLAUDE_TOOLS_LITERAL}]\n`)
  211. )
  212. );
  213. expect(content).toMatch(/^tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task$/m);
  214. expect(content).not.toMatch(/^disallowedTools:/m);
  215. });
  216. it("preserves the body verbatim after the frontmatter", async () => {
  217. const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  218. expect(content).toContain("# FixtureReviewer");
  219. expect(content).toContain("- Report findings, do not fix them.");
  220. expect(content.endsWith("- Report findings, do not fix them.\n")).toBe(true);
  221. });
  222. it("renders a multi-line description as a YAML block scalar", async () => {
  223. // The committed agents carry multi-line `description: |` blocks with <example> tags.
  224. // A naive `key: "value"` would emit a broken single line.
  225. const source = canonical(` read:\n "*": "allow"\n`).replace(
  226. "description: A probe agent.",
  227. 'description: |\n First line.\n user: "quoted colon"\n'
  228. );
  229. const { content } = await adapter.fromCanonical(source);
  230. expect(content).toContain('description: |\n First line.\n user: "quoted colon"\n');
  231. });
  232. it("quotes a description that would otherwise be ambiguous YAML", async () => {
  233. const source = canonical(` read:\n "*": "allow"\n`).replace(
  234. "description: A probe agent.",
  235. 'description: "*starts with a star"'
  236. );
  237. const { content } = await adapter.fromCanonical(source);
  238. expect(content).toMatch(/^description: '\*starts with a star'$/m);
  239. });
  240. });
  241. // ============================================================================
  242. // TOOL ORDERING
  243. // ============================================================================
  244. describe("tool ordering", () => {
  245. it("emits tools in the canonical Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task order", async () => {
  246. // Recovered from the 7 committed agents — the only total order all 10 of their lists
  247. // agree with. Emitting any other order fails to reproduce every shipped file.
  248. const { content } = await adapter.fromCanonical(
  249. canonical(
  250. ` read:\n "*": "allow"\n`,
  251. "",
  252. override(` tools: [Task, Bash, Read, Grep, Write, Glob, Edit, WebFetch]\n`)
  253. )
  254. );
  255. expect(/^tools: (.*)$/m.exec(content)?.[1]).toBe(
  256. "Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task"
  257. );
  258. });
  259. it("orders disallowedTools by the same rule", async () => {
  260. const { content } = await adapter.fromCanonical(
  261. canonical(` read:\n "*": "allow"\n`, "", override(` tools: [Read]\n`))
  262. );
  263. expect(/^disallowedTools: (.*)$/m.exec(content)?.[1]).toBe(
  264. "Write, Edit, Glob, Grep, Bash, WebFetch, Task"
  265. );
  266. });
  267. it("puts every bound tool in exactly one list, leaving nothing to a default", async () => {
  268. // An omitted tool reads as "Claude Code's default" to Claude Code, which is the
  269. // ambiguity an authored override exists to end. Union == every bound tool, always.
  270. const { content } = await adapter.fromCanonical(
  271. canonical(` read:\n "*": "allow"\n`, "", override(` tools: [Read, Glob]\n`))
  272. );
  273. const tools = /^tools: (.*)$/m.exec(content)?.[1]?.split(", ") ?? [];
  274. const denied = /^disallowedTools: (.*)$/m.exec(content)?.[1]?.split(", ") ?? [];
  275. expect([...tools, ...denied].sort()).toEqual([...CLAUDE_TOOLS].sort());
  276. });
  277. });
  278. // ============================================================================
  279. // AUTHORED TOOLS — the whole contract
  280. // ============================================================================
  281. describe("authored tools", () => {
  282. // Claude Code cannot enforce a per-agent permission scope by any route, so an agent's
  283. // tools there are NOT derivable from its canonical permission: block. Deriving them was
  284. // tried and was wrong in both directions — fail-closed crippled `externalscout` (it scopes
  285. // `read`, so it lost Read outright), and widening is how the shipped agents leaked. So the
  286. // list is authored, taken at its word, and its absence is an error rather than a cue to
  287. // guess. These tests pin exactly that.
  288. it("refuses an agent that targets claude-code without authoring tools", async () => {
  289. await expect(
  290. adapter.fromCanonical(canonical(` read:\n "*": "allow"\n`, "", ""))
  291. ).rejects.toThrow(/declares no oac\.overrides\.claude-code\.tools/);
  292. });
  293. it("refuses even when the permission block would have projected cleanly", async () => {
  294. // The tempting case: `read: {"*": allow}` is unambiguous, so a projection WOULD be
  295. // exact. It is still refused — "we could have guessed correctly this time" is how a
  296. // derivation creeps back in, and the next agent is the one that scopes something.
  297. await expect(
  298. adapter.fromCanonical(canonical(` read:\n "*": "allow"\n`, "", ""))
  299. ).rejects.toThrow(/declares no oac\.overrides\.claude-code\.tools/);
  300. });
  301. it("says how to fix it, and that dropping the target is a valid answer", async () => {
  302. await expect(
  303. adapter.fromCanonical(canonical(` read:\n "*": "allow"\n`, "", ""))
  304. ).rejects.toThrow(/Or drop "claude-code" from targets/);
  305. });
  306. it("grants a tool the canonical permission block scopes, when authored to", async () => {
  307. // The widening case, stated out loud: canonical scopes bash to `git log*`; Claude Code
  308. // cannot express that; the author grants Bash anyway. No warning, no refusal — a human
  309. // decided, and the adapter does as it is told.
  310. const { content } = await adapter.fromCanonical(
  311. canonical(
  312. ` bash:\n "*": "deny"\n "git log*": "allow"\n`,
  313. "",
  314. override(` tools: [Read, Bash]\n`)
  315. )
  316. );
  317. expect(/^tools: (.*)$/m.exec(content)?.[1]).toBe("Read, Bash");
  318. });
  319. it("denies a tool the canonical permission block allows, when authored to", async () => {
  320. // The reverse: canonical allows bash outright, the author withholds it on this target.
  321. const { content } = await adapter.fromCanonical(
  322. canonical(` bash:\n "*": "allow"\n`, "", override(` tools: [Read]\n`))
  323. );
  324. expect(/^tools: (.*)$/m.exec(content)?.[1]).toBe("Read");
  325. expect(content).toMatch(/^disallowedTools:.*\bBash\b/m);
  326. });
  327. it("refuses a tool name Claude Code does not have", async () => {
  328. // A typo fails OPEN — `Reed` would silently just not be granted, quietly shipping an
  329. // agent with less access than intended and no error anywhere.
  330. await expect(
  331. adapter.fromCanonical(
  332. canonical(` read:\n "*": "allow"\n`, "", override(` tools: [Reed]\n`))
  333. )
  334. ).rejects.toThrow(/Claude Code has no such tool for/);
  335. });
  336. it("accepts an empty grant: an agent may be authored to have no tools at all", async () => {
  337. const { content } = await adapter.fromCanonical(
  338. canonical(` read:\n "*": "allow"\n`, "", override(` tools: []\n`))
  339. );
  340. expect(content).not.toMatch(/^tools:/m);
  341. expect(/^disallowedTools: (.*)$/m.exec(content)?.[1]).toBe(CLAUDE_TOOLS.join(", "));
  342. });
  343. it("emits the override name, and keys the path on it", async () => {
  344. const { path, content } = await adapter.fromCanonical(
  345. canonical(
  346. ` read:\n "*": "allow"\n`,
  347. "",
  348. override(` name: probe-scout\n tools: [Read]\n`)
  349. )
  350. );
  351. expect(path).toBe("plugins/claude-code/agents/probe-scout.md");
  352. expect(content).toMatch(/^name: probe-scout$/m);
  353. });
  354. it("prefers the override model over the canonical one", async () => {
  355. // Claude Code names models in its own vocabulary (`haiku`); canonical `model:` is
  356. // OpenCode's.
  357. const { content } = await adapter.fromCanonical(
  358. canonical(
  359. ` read:\n "*": "allow"\n`,
  360. "model: claude-sonnet-4\n",
  361. override(` model: haiku\n tools: [Read]\n`)
  362. )
  363. );
  364. expect(content).toMatch(/^model: haiku$/m);
  365. });
  366. });
  367. // ============================================================================
  368. // WARNINGS — one per lossy projection
  369. // ============================================================================
  370. describe("warnings", () => {
  371. // The degradation warnings below are asserted through `fromOAC`, not `fromCanonical`.
  372. // That is not a workaround — after the override work, `fromCanonical` never degrades a
  373. // scoped rule set at all (it refuses; see "refuses rather than guessing"), so there is no
  374. // canonical-path behaviour left to assert. `fromOAC` still degrades, because it converts
  375. // an agent from a format with no `oac:` block: no override can be authored and no human
  376. // can be asked, so fail-closed-and-warn remains the best available answer there.
  377. it("emits exactly one warning for a single unrepresentable capability", async () => {
  378. const { warnings } = await adapter.fromOAC(
  379. legacy({ bash: { "*": "deny", "git log*": "allow" } })
  380. );
  381. expect(warnings.filter((w) => /has no equivalent/.test(w))).toHaveLength(1);
  382. expect(warnings.some((w) => /'bash'/.test(w) && /fail-closed/.test(w))).toBe(true);
  383. });
  384. it("counts one warning per lossy capability, and none for the lossless ones", async () => {
  385. // read/glob are exact; bash and edit are not. Two losses, two warnings.
  386. const { warnings } = await adapter.fromOAC(
  387. legacy({
  388. read: { "*": "allow" },
  389. glob: { "*": "allow" },
  390. bash: { "*": "deny", "git log*": "allow" },
  391. edit: { "*": "allow", "**/*.key": "deny" },
  392. })
  393. );
  394. const lossy = warnings.filter((w) => /has no equivalent/.test(w));
  395. expect(lossy).toHaveLength(2);
  396. expect(lossy.filter((w) => /'bash'/.test(w))).toHaveLength(1);
  397. expect(lossy.filter((w) => /'edit'/.test(w))).toHaveLength(1);
  398. });
  399. it("adds a second warning naming 'ask' when a mixed list contains one", async () => {
  400. // test-engineer's real block: a test-runner allowlist plus `rm -rf *: ask`.
  401. const { warnings } = await adapter.fromOAC(
  402. legacy({ bash: { "npx vitest *": "allow", "rm -rf *": "ask", "*": "deny" } })
  403. );
  404. expect(warnings.some((w) => /cannot express/.test(w) && /ask/.test(w))).toBe(true);
  405. });
  406. it("warns when a rule list has no recoverable default", async () => {
  407. // context-manager's real `write` block: allow + deny with no "*" rule.
  408. const { warnings } = await adapter.fromOAC(
  409. legacy({ write: { ".opencode/context/**/*.md": "allow", "**/*.env*": "deny" } })
  410. );
  411. expect(warnings.some((w) => /ambiguous/.test(w))).toBe(true);
  412. });
  413. it("warns when a capability has no Claude Code tool at all", async () => {
  414. // externalscout's real `skill` block restricts which skills it may invoke. Claude Code
  415. // cannot express that; dropping it silently is the widening this suite guards against.
  416. const { warnings } = await adapter.fromCanonical(
  417. canonical(` skill:\n "*": "deny"\n "*context7*": "allow"\n`)
  418. );
  419. expect(warnings).toHaveLength(1);
  420. expect(warnings[0]).toMatch(/'skill' has no Claude Code tool/);
  421. });
  422. it("warns that temperature and maxSteps cannot be carried", async () => {
  423. const { warnings } = await adapter.fromCanonical(
  424. canonical(` read:\n "*": "allow"\n`, "temperature: 0.1\nmaxSteps: 10\n")
  425. );
  426. expect(warnings).toHaveLength(2);
  427. expect(warnings.some((w) => w.includes("temperature"))).toBe(true);
  428. expect(warnings.some((w) => w.includes("maxSteps"))).toBe(true);
  429. });
  430. it("reports no permission loss for an agent whose every capability projects exactly", async () => {
  431. // fixture-reviewer's block is uniform-per-capability, so nothing about its permissions
  432. // is lost. Its `temperature: 0.1` still is — and that one warning is the whole list.
  433. const { warnings } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  434. expect(warnings).toHaveLength(1);
  435. expect(warnings[0]).toContain("temperature");
  436. });
  437. });
  438. // ============================================================================
  439. // DETERMINISM
  440. // ============================================================================
  441. describe("determinism", () => {
  442. it("emits identical bytes regardless of the source's key order", async () => {
  443. const rules = {
  444. read: ` read:\n "*": "allow"\n`,
  445. bash: ` bash:\n "*": "deny"\n`,
  446. write: ` write:\n "*": "deny"\n`,
  447. };
  448. const forward = await adapter.fromCanonical(
  449. canonical(rules.read + rules.bash + rules.write)
  450. );
  451. const reversed = await adapter.fromCanonical(
  452. canonical(rules.write + rules.bash + rules.read)
  453. );
  454. expect(reversed.content).toBe(forward.content);
  455. });
  456. it("emits identical bytes across separate adapter instances", async () => {
  457. const source = fixture(FIXTURE_REVIEWER);
  458. expect((await new ClaudeAdapter().fromCanonical(source)).content).toBe(
  459. (await new ClaudeAdapter().fromCanonical(source)).content
  460. );
  461. });
  462. });
  463. // ============================================================================
  464. // INPUT VALIDATION
  465. // ============================================================================
  466. describe("input validation", () => {
  467. it("throws a named error when the source lacks an oac: block", async () => {
  468. const source = `---\nname: X\ndescription: Y\nmode: subagent\n---\n\nBody\n`;
  469. await expect(adapter.fromCanonical(source)).rejects.toThrow(/not a canonical agent file/);
  470. });
  471. it("names the offending field when the oac: block is malformed", async () => {
  472. const source = canonical(` read:\n "*": "allow"\n`).replace(
  473. "id: probe-agent",
  474. "id: Probe_Agent"
  475. );
  476. await expect(adapter.fromCanonical(source)).rejects.toThrow(/oac\.id/);
  477. });
  478. });
  479. // ============================================================================
  480. // CAPABILITIES
  481. // ============================================================================
  482. describe("getCapabilities()", () => {
  483. it("returns correct capabilities object", () => {
  484. const capabilities = adapter.getCapabilities();
  485. expect(capabilities.name).toBe("claude");
  486. expect(capabilities.displayName).toBe("Claude Code");
  487. expect(capabilities.supportsMultipleAgents).toBe(true);
  488. expect(capabilities.supportsSkills).toBe(true);
  489. expect(capabilities.supportsHooks).toBe(true);
  490. expect(capabilities.supportsGranularPermissions).toBe(false);
  491. expect(capabilities.supportsContexts).toBe(true);
  492. expect(capabilities.supportsCustomModels).toBe(true);
  493. expect(capabilities.supportsTemperature).toBe(false);
  494. expect(capabilities.supportsMaxSteps).toBe(false);
  495. expect(capabilities.configFormat).toBe("markdown");
  496. expect(capabilities.outputStructure).toBe("directory");
  497. });
  498. it("agrees with the CapabilityMatrix rather than restating it", async () => {
  499. // These two disagreed before: the matrix called Claude `json`, the adapter `markdown`.
  500. // A platform cannot have two answers about itself.
  501. const { getToolCapabilities } = await import("../../../src/core/CapabilityMatrix.js");
  502. expect(adapter.getCapabilities().configFormat).toBe(
  503. getToolCapabilities("claude").configFormat
  504. );
  505. });
  506. it("includes appropriate notes", () => {
  507. const capabilities = adapter.getCapabilities();
  508. expect(capabilities.notes?.length).toBeGreaterThan(0);
  509. expect(capabilities.notes?.some((n) => /permission/i.test(n))).toBe(true);
  510. });
  511. });
  512. // ============================================================================
  513. // toOAC() — the IMPORT direction (still accepts legacy .claude/ shapes)
  514. // ============================================================================
  515. describe("toOAC() - parsing config.json", () => {
  516. it("parses minimal config.json", async () => {
  517. const result = await adapter.toOAC(
  518. JSON.stringify({
  519. name: "TestAgent",
  520. description: "Test description",
  521. systemPrompt: "You are helpful",
  522. })
  523. );
  524. expect(result.frontmatter.name).toBe("TestAgent");
  525. expect(result.frontmatter.description).toBe("Test description");
  526. expect(result.systemPrompt).toBe("You are helpful");
  527. expect(result.frontmatter.mode).toBe("primary");
  528. });
  529. it("parses config with tools array", async () => {
  530. const result = await adapter.toOAC(
  531. JSON.stringify({ name: "Agent", description: "Test", tools: ["Read", "Write", "Bash"] })
  532. );
  533. expect(result.frontmatter.tools).toEqual({ read: true, write: true, bash: true });
  534. });
  535. it("parses config with tools string", async () => {
  536. const result = await adapter.toOAC(
  537. JSON.stringify({ name: "Agent", description: "Test", tools: "Read, Write, Edit" })
  538. );
  539. expect(result.frontmatter.tools).toEqual({ read: true, write: true, edit: true });
  540. });
  541. it("parses config with skills", async () => {
  542. const result = await adapter.toOAC(
  543. JSON.stringify({ name: "Agent", description: "Test", skills: ["skill1", "skill2"] })
  544. );
  545. expect(result.frontmatter.skills).toEqual(["skill1", "skill2"]);
  546. });
  547. it("parses config with hooks", async () => {
  548. const result = await adapter.toOAC(
  549. JSON.stringify({
  550. name: "Agent",
  551. description: "Test",
  552. hooks: {
  553. PreToolUse: [{ matcher: "*.txt", hooks: [{ type: "command", command: "validate" }] }],
  554. },
  555. })
  556. );
  557. expect(result.frontmatter.hooks?.length).toBe(1);
  558. expect(result.frontmatter.hooks?.[0].event).toBe("PreToolUse");
  559. expect(result.frontmatter.hooks?.[0].matchers).toEqual(["*.txt"]);
  560. });
  561. it("handles missing optional fields gracefully", async () => {
  562. const result = await adapter.toOAC(
  563. JSON.stringify({ name: "MinimalAgent", description: "Minimal" })
  564. );
  565. expect(result.frontmatter.name).toBe("MinimalAgent");
  566. expect(result.systemPrompt).toBe("");
  567. expect(result.frontmatter.tools).toBeUndefined();
  568. expect(result.frontmatter.skills).toBeUndefined();
  569. });
  570. it("parses invalid JSON as markdown (subagent fallback)", async () => {
  571. const result = await adapter.toOAC("not valid json");
  572. expect(result.frontmatter.mode).toBe("subagent");
  573. expect(result.systemPrompt).toBe("not valid json");
  574. });
  575. it("handles null system prompt", async () => {
  576. const result = await adapter.toOAC(
  577. JSON.stringify({ name: "Agent", description: "Test", systemPrompt: null })
  578. );
  579. expect(result.systemPrompt).toBe("");
  580. });
  581. });
  582. describe("toOAC() - parsing agent.md with YAML frontmatter", () => {
  583. it("parses agent.md with minimal frontmatter", async () => {
  584. const result = await adapter.toOAC(
  585. `---\nname: SubAgent\ndescription: A subagent\n---\n\nThis is the system prompt.`
  586. );
  587. expect(result.frontmatter.name).toBe("SubAgent");
  588. expect(result.frontmatter.description).toBe("A subagent");
  589. expect(result.frontmatter.mode).toBe("subagent");
  590. expect(result.systemPrompt).toBe("This is the system prompt.");
  591. });
  592. it("parses a committed plugin agent's flat tools list", async () => {
  593. const result = await adapter.toOAC(
  594. `---\nname: code-reviewer\ndescription: Reviews\ntools: Read, Glob, Grep\nmodel: sonnet\n---\n\nPrompt`
  595. );
  596. expect(result.frontmatter.tools).toEqual({ read: true, glob: true, grep: true });
  597. expect(result.frontmatter.model).toBe("claude-sonnet-4");
  598. });
  599. it("handles agent.md without frontmatter as markdown content", async () => {
  600. const result = await adapter.toOAC("No frontmatter here, just markdown content");
  601. expect(result.systemPrompt).toBe("No frontmatter here, just markdown content");
  602. expect(result.frontmatter.mode).toBe("subagent");
  603. });
  604. it("preserves multiline system prompt", async () => {
  605. const result = await adapter.toOAC(
  606. `---\nname: Agent\ndescription: Test\n---\n\nLine one.\nLine two.\nLine three.`
  607. );
  608. expect(result.systemPrompt).toContain("Line one.");
  609. expect(result.systemPrompt).toContain("Line three.");
  610. });
  611. });
  612. describe("model mapping (Claude to OAC)", () => {
  613. it("maps dated sonnet id to claude-sonnet-4", async () => {
  614. const result = await adapter.toOAC(
  615. JSON.stringify({ name: "A", description: "T", model: "claude-sonnet-4-20250514" })
  616. );
  617. expect(result.frontmatter.model).toBe("claude-sonnet-4");
  618. });
  619. it("maps short model aliases", async () => {
  620. const result = await adapter.toOAC(
  621. JSON.stringify({ name: "A", description: "T", model: "opus" })
  622. );
  623. expect(result.frontmatter.model).toBe("claude-opus-4");
  624. });
  625. it("preserves unknown models", async () => {
  626. const result = await adapter.toOAC(
  627. JSON.stringify({ name: "A", description: "T", model: "claude-custom-model" })
  628. );
  629. expect(result.frontmatter.model).toBe("claude-custom-model");
  630. });
  631. it("handles missing model gracefully", async () => {
  632. const result = await adapter.toOAC(JSON.stringify({ name: "A", description: "T" }));
  633. expect(result.frontmatter.model).toBeUndefined();
  634. });
  635. });
  636. // ============================================================================
  637. // fromOAC() — legacy in-memory interface, retargeted to the plugin layout
  638. // ============================================================================
  639. describe("fromOAC()", () => {
  640. const createAgent = (overrides?: Partial<AgentFrontmatter>): OpenAgent => ({
  641. frontmatter: {
  642. name: "CodeAnalyzer",
  643. description: "Analyzes code",
  644. mode: "subagent",
  645. ...overrides,
  646. },
  647. metadata: { name: "CodeAnalyzer", category: "specialist", type: "subagent" },
  648. systemPrompt: "Analyze code quality",
  649. contexts: [],
  650. });
  651. it("emits a single agent markdown file", async () => {
  652. const result = await adapter.fromOAC(createAgent());
  653. expect(result.success).toBe(true);
  654. expect(result.configs).toHaveLength(1);
  655. expect(result.configs[0].fileName).toBe("plugins/claude-code/agents/CodeAnalyzer.md");
  656. expect(result.configs[0].encoding).toBe("utf-8");
  657. });
  658. it("generates flat frontmatter and includes the system prompt", async () => {
  659. const result = await adapter.fromOAC(createAgent());
  660. const content = result.configs[0].content;
  661. expect(content).toMatch(/^---\nname: CodeAnalyzer\ndescription: Analyzes code\n/);
  662. expect(content).toContain("---\n\n");
  663. expect(content).toContain("Analyze code quality");
  664. });
  665. it("maps an authored tools map through the canonical ordering", async () => {
  666. const result = await adapter.fromOAC(
  667. createAgent({ tools: { bash: true, read: true, write: false } })
  668. );
  669. expect(result.configs[0].content).toMatch(/^tools: Read, Bash$/m);
  670. });
  671. it("projects an authored permission map fail-closed", async () => {
  672. const result = await adapter.fromOAC(
  673. createAgent({ permission: { bash: { "*": "deny", "git log*": "allow" } } })
  674. );
  675. expect(result.configs[0].content).toMatch(/^disallowedTools: Bash$/m);
  676. expect(result.warnings.some((w) => /bash/i.test(w))).toBe(true);
  677. });
  678. it("does not emit a permissionMode — Claude Code has no such agent field", async () => {
  679. const result = await adapter.fromOAC(
  680. createAgent({ permission: { read: "allow", write: "allow" } })
  681. );
  682. expect(result.configs[0].content).not.toContain("permissionMode");
  683. expect(result.configs[0].content).not.toContain("bypassPermissions");
  684. });
  685. it("warns when temperature is set (unsupported)", async () => {
  686. const result = await adapter.fromOAC(createAgent({ temperature: 0.7 }));
  687. expect(result.warnings.some((w) => w.includes("temperature"))).toBe(true);
  688. });
  689. it("warns when maxSteps is set (unsupported)", async () => {
  690. const result = await adapter.fromOAC(createAgent({ maxSteps: 10 }));
  691. expect(result.warnings.some((w) => w.includes("maxSteps"))).toBe(true);
  692. });
  693. it("includes validation warnings for a nameless agent", async () => {
  694. const result = await adapter.fromOAC(createAgent({ name: "", description: "" }));
  695. expect(result.warnings.length).toBeGreaterThan(0);
  696. });
  697. it("includes capabilities in the result", async () => {
  698. const result = await adapter.fromOAC(createAgent());
  699. expect(result.capabilities?.name).toBe("claude");
  700. });
  701. it("handles an empty system prompt", async () => {
  702. const result = await adapter.fromOAC({ ...createAgent(), systemPrompt: "" });
  703. expect(result.success).toBe(true);
  704. expect(result.configs[0].content).toBe(
  705. "---\nname: CodeAnalyzer\ndescription: Analyzes code\n---\n\n\n"
  706. );
  707. });
  708. it("carries hooks nowhere in agent frontmatter", async () => {
  709. // Claude Code agent frontmatter accepts name/description/tools/disallowedTools/model
  710. // and nothing else. The old adapter wrote a `hooks:` key that Claude Code silently
  711. // ignores, which reads as support that does not exist.
  712. const hook: HookDefinition = {
  713. event: "PreToolUse",
  714. matchers: ["*.txt"],
  715. commands: [{ type: "command", command: "validate" }],
  716. };
  717. const result = await adapter.fromOAC(createAgent({ hooks: [hook] }));
  718. expect(result.configs[0].content).not.toContain("hooks");
  719. });
  720. });
  721. // ============================================================================
  722. // SKILLS GENERATION FROM CONTEXTS
  723. // ============================================================================
  724. describe("fromOAC() - generating skills from contexts", () => {
  725. const withContexts = (
  726. contexts: Array<{ path: string; priority?: string; description?: string }>
  727. ): OpenAgent => ({
  728. frontmatter: { name: "Agent", description: "Test", mode: "primary" },
  729. metadata: { name: "Agent", category: "core", type: "agent" },
  730. systemPrompt: "Prompt",
  731. contexts,
  732. });
  733. it("generates skill files under the plugin tree", async () => {
  734. const result = await adapter.fromOAC(
  735. withContexts([
  736. { path: ".opencode/context/skills/python.md", description: "Python standards" },
  737. ])
  738. );
  739. const skill = result.configs.find((c) => c.fileName.includes("/skills/"));
  740. expect(skill?.fileName).toBe("plugins/claude-code/skills/python/SKILL.md");
  741. });
  742. it("generates a slugified skill name from the context path", async () => {
  743. const result = await adapter.fromOAC(
  744. withContexts([{ path: "docs/React Hooks Guide.md", description: "React docs" }])
  745. );
  746. expect(result.configs.find((c) => c.fileName.includes("/skills/"))?.fileName).toMatch(
  747. /react-hooks-guide/
  748. );
  749. });
  750. it("includes context priority in skill content", async () => {
  751. const result = await adapter.fromOAC(
  752. withContexts([{ path: "context/important.md", priority: "high", description: "Ctx" }])
  753. );
  754. expect(result.configs.find((c) => c.fileName.includes("/skills/"))?.content).toContain(
  755. "Priority: high"
  756. );
  757. });
  758. it("generates one skill per context", async () => {
  759. const result = await adapter.fromOAC(
  760. withContexts([{ path: "a.md" }, { path: "b.md" }, { path: "c.md" }])
  761. );
  762. expect(result.configs.filter((c) => c.fileName.includes("/skills/"))).toHaveLength(3);
  763. });
  764. it("falls back to a generated description when the context lacks one", async () => {
  765. const result = await adapter.fromOAC(withContexts([{ path: ".opencode/context/styles.md" }]));
  766. const skill = result.configs.find((c) => c.fileName.includes("/skills/"));
  767. expect(skill?.content).toContain("Context from");
  768. expect(skill?.content).toContain("styles.md");
  769. });
  770. });
  771. // ============================================================================
  772. // VALIDATION
  773. // ============================================================================
  774. describe("validateConversion()", () => {
  775. const createAgent = (overrides?: Partial<AgentFrontmatter>): OpenAgent => ({
  776. frontmatter: { name: "Agent", description: "Test", mode: "primary", ...overrides },
  777. metadata: { name: "Agent", category: "core", type: "agent" },
  778. systemPrompt: "Prompt",
  779. contexts: [],
  780. });
  781. it("returns no warnings for valid agent", () => {
  782. expect(adapter.validateConversion(createAgent())).toHaveLength(0);
  783. });
  784. it("warns when name is missing", () => {
  785. expect(adapter.validateConversion(createAgent({ name: "" })).some((w) => w.includes("name"))).toBe(
  786. true
  787. );
  788. });
  789. it("warns when description is missing", () => {
  790. expect(
  791. adapter
  792. .validateConversion(createAgent({ description: "" }))
  793. .some((w) => w.includes("description"))
  794. ).toBe(true);
  795. });
  796. it("warns about granular permission degradation", () => {
  797. const warnings = adapter.validateConversion(
  798. createAgent({ permission: { read: { "file1.txt": "allow", "file2.txt": "deny" } } })
  799. );
  800. expect(warnings.some((w) => w.includes("granular permissions"))).toBe(true);
  801. });
  802. it("does not warn about simple permission rules", () => {
  803. const warnings = adapter.validateConversion(
  804. createAgent({ permission: { read: "allow", write: "allow" } })
  805. );
  806. expect(warnings.some((w) => w.includes("granular permissions"))).toBe(false);
  807. });
  808. });
  809. });