ClaudeAdapter.test.ts 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908
  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. const FIXTURE_REVIEWER = packagePath("tests/golden/fixtures/fixture-reviewer.md");
  28. const FIXTURE_PLANNER = packagePath("tests/golden/fixtures/fixture-planner.md");
  29. function fixture(path: string): string {
  30. return readFileSync(path, "utf-8");
  31. }
  32. /** A canonical agent file built around one permission block, for targeted projection tests. */
  33. function canonical(permission: string, extra = ""): string {
  34. return `---
  35. name: ProbeAgent
  36. description: A probe agent.
  37. mode: subagent
  38. ${extra}permission:
  39. ${permission}
  40. oac:
  41. id: probe-agent
  42. name: ProbeAgent
  43. category: subagents/test
  44. type: subagent
  45. targets:
  46. - claude-code
  47. ---
  48. # ProbeAgent
  49. Body.
  50. `;
  51. }
  52. describe("ClaudeAdapter", () => {
  53. let adapter: ClaudeAdapter;
  54. beforeEach(() => {
  55. adapter = new ClaudeAdapter();
  56. });
  57. // ============================================================================
  58. // ADAPTER IDENTITY
  59. // ============================================================================
  60. describe("adapter identity", () => {
  61. it("has correct name", () => {
  62. expect(adapter.name).toBe("claude");
  63. });
  64. it("has correct displayName", () => {
  65. expect(adapter.displayName).toBe("Claude Code");
  66. });
  67. it("returns the plugin tree as its config path, not .claude/", () => {
  68. expect(adapter.getConfigPath()).toBe("plugins/claude-code/");
  69. });
  70. });
  71. // ============================================================================
  72. // OUTPUT LAYOUT
  73. // ============================================================================
  74. describe("output layout", () => {
  75. it("emits agents under plugins/claude-code/agents/", async () => {
  76. const { path } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  77. expect(path).toBe("plugins/claude-code/agents/fixture-reviewer.md");
  78. });
  79. it("keys the emitted path on oac.id, not the authored display name", async () => {
  80. // The canonical ids and the Claude Code filenames genuinely differ across the corpus
  81. // (`contextscout` -> `context-scout.md`, `reviewer` -> `code-reviewer.md`). Only the
  82. // id is stable identity, so resolving by `name:` would emit the wrong filename.
  83. const { path, content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  84. expect(path).toContain("fixture-reviewer.md"); // oac.id
  85. expect(path).not.toContain("FixtureReviewer"); // frontmatter name
  86. expect(content).toMatch(/^name: fixture-reviewer$/m);
  87. });
  88. it("emits no .claude/ path from any conversion", async () => {
  89. const canonicalResult = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
  90. const oacResult = await adapter.fromOAC({
  91. frontmatter: { name: "Agent", description: "Test", mode: "primary" },
  92. metadata: { name: "Agent", category: "core", type: "agent" },
  93. systemPrompt: "Prompt",
  94. contexts: [{ path: "context/a.md", description: "A" }],
  95. });
  96. expect(canonicalResult.path).not.toContain(".claude/");
  97. for (const config of oacResult.configs) {
  98. expect(config.fileName, `${config.fileName} still targets the old layout`).not.toContain(
  99. ".claude/"
  100. );
  101. expect(config.fileName).toMatch(/^plugins\/claude-code\//);
  102. }
  103. });
  104. it("never emits a config.json", async () => {
  105. const result = await adapter.fromOAC({
  106. frontmatter: { name: "Agent", description: "Test", mode: "primary" },
  107. metadata: { name: "Agent", category: "core", type: "agent" },
  108. systemPrompt: "Prompt",
  109. contexts: [],
  110. });
  111. expect(result.configs.map((c) => c.fileName)).toEqual([
  112. "plugins/claude-code/agents/Agent.md",
  113. ]);
  114. });
  115. it("emits one agent file for a primary agent, same as a subagent", async () => {
  116. // The old primary/subagent split existed only to choose between config.json and an
  117. // agent file. With config.json gone there is exactly one shape.
  118. const primary = await adapter.fromCanonical(fixture(FIXTURE_PLANNER)); // mode: primary
  119. expect(primary.path).toBe("plugins/claude-code/agents/fixture-planner.md");
  120. expect(primary.content).toMatch(/^---\nname: fixture-planner$/m);
  121. });
  122. });
  123. // ============================================================================
  124. // FRONTMATTER SHAPE
  125. // ============================================================================
  126. describe("frontmatter shape", () => {
  127. it("emits keys in the committed order: name, description, tools, disallowedTools, model", async () => {
  128. const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  129. const keys = content
  130. .split("---")[1]!
  131. .trim()
  132. .split("\n")
  133. .map((line) => line.split(":")[0]);
  134. expect(keys).toEqual(["name", "description", "tools", "disallowedTools", "model"]);
  135. });
  136. it("reproduces the committed frontmatter shape byte-for-byte", async () => {
  137. const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  138. expect(content.split("---\n\n")[0]).toBe(
  139. `---\nname: fixture-reviewer\n` +
  140. `description: Reviews code for correctness. A golden-file fixture, not a shipped agent.\n` +
  141. `tools: Read, Glob, Grep\n` +
  142. `disallowedTools: Write, Edit, Bash, Task\n` +
  143. `model: haiku\n`
  144. );
  145. });
  146. it("passes the model through unmapped", async () => {
  147. // The committed corpus uses Claude Code's own aliases (`sonnet`, `haiku`). Expanding
  148. // them to dated ids (`claude-sonnet-4-20250514`) would break every committed agent.
  149. const { content } = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
  150. expect(content).toMatch(/^model: sonnet$/m);
  151. });
  152. it("omits model when the source declares none", async () => {
  153. const { content } = await adapter.fromCanonical(canonical(` read:\n "*": "allow"\n`));
  154. expect(content).not.toMatch(/^model:/m);
  155. });
  156. it("omits an empty tools list rather than emitting a bare key", async () => {
  157. // `tools:` with no value means something different to Claude Code than an absent key.
  158. const { content } = await adapter.fromCanonical(canonical(` bash:\n "*": "deny"\n`));
  159. expect(content).not.toMatch(/^tools:\s*$/m);
  160. expect(content).toMatch(/^disallowedTools: Bash$/m);
  161. });
  162. it("omits an empty disallowedTools list", async () => {
  163. const { content } = await adapter.fromCanonical(canonical(` read:\n "*": "allow"\n`));
  164. expect(content).toMatch(/^tools: Read$/m);
  165. expect(content).not.toMatch(/^disallowedTools:/m);
  166. });
  167. it("preserves the body verbatim after the frontmatter", async () => {
  168. const { content } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  169. expect(content).toContain("# FixtureReviewer");
  170. expect(content).toContain("- Report findings, do not fix them.");
  171. expect(content.endsWith("- Report findings, do not fix them.\n")).toBe(true);
  172. });
  173. it("renders a multi-line description as a YAML block scalar", async () => {
  174. // The committed agents carry multi-line `description: |` blocks with <example> tags.
  175. // A naive `key: "value"` would emit a broken single line.
  176. const source = canonical(` read:\n "*": "allow"\n`).replace(
  177. "description: A probe agent.",
  178. 'description: |\n First line.\n user: "quoted colon"\n'
  179. );
  180. const { content } = await adapter.fromCanonical(source);
  181. expect(content).toContain('description: |\n First line.\n user: "quoted colon"\n');
  182. });
  183. it("quotes a description that would otherwise be ambiguous YAML", async () => {
  184. const source = canonical(` read:\n "*": "allow"\n`).replace(
  185. "description: A probe agent.",
  186. 'description: "*starts with a star"'
  187. );
  188. const { content } = await adapter.fromCanonical(source);
  189. expect(content).toMatch(/^description: '\*starts with a star'$/m);
  190. });
  191. });
  192. // ============================================================================
  193. // TOOL ORDERING
  194. // ============================================================================
  195. describe("tool ordering", () => {
  196. it("emits tools in the canonical Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task order", async () => {
  197. // Recovered from the 7 committed agents: all 10 of their lists fit this order and it
  198. // is the only total order that does. Alphabetical is refuted by context-manager.md
  199. // (`Read, Write, Glob, Grep, Bash`); so is ToolAccessSchema field order.
  200. const { content } = await adapter.fromCanonical(
  201. canonical(
  202. ` task:\n "*": "allow"\n` +
  203. ` bash:\n "*": "allow"\n` +
  204. ` grep:\n "*": "allow"\n` +
  205. ` glob:\n "*": "allow"\n` +
  206. ` edit:\n "*": "allow"\n` +
  207. ` write:\n "*": "allow"\n` +
  208. ` read:\n "*": "allow"\n` +
  209. ` webfetch:\n "*": "allow"\n`
  210. )
  211. );
  212. expect(content).toMatch(
  213. /^tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, Task$/m
  214. );
  215. });
  216. it("orders disallowedTools by the same rule", async () => {
  217. const { content } = await adapter.fromCanonical(
  218. canonical(
  219. ` task:\n "*": "deny"\n` +
  220. ` bash:\n "*": "deny"\n` +
  221. ` edit:\n "*": "deny"\n` +
  222. ` write:\n "*": "deny"\n`
  223. )
  224. );
  225. expect(content).toMatch(/^disallowedTools: Write, Edit, Bash, Task$/m);
  226. });
  227. it("does not emit a tool for a capability the source never mentions", async () => {
  228. // Ratified rule (02 §1.2.5 case 1): an absent capability means "the target's own
  229. // default", so naming it in either list would invent an intent the author never had.
  230. const { content } = await adapter.fromCanonical(canonical(` read:\n "*": "allow"\n`));
  231. for (const tool of ["Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "Task"]) {
  232. expect(content, `${tool} was invented from silence`).not.toContain(tool);
  233. }
  234. });
  235. });
  236. // ============================================================================
  237. // PERMISSION PROJECTION — fails closed
  238. // ============================================================================
  239. describe("permission projection", () => {
  240. it("fails closed on a deny-all-then-allowlist bash block", async () => {
  241. // The live shape: `bash: {"*": deny, "git log*": allow}`. Claude Code has no ordered
  242. // -glob equivalent. Answering `tools: Bash` because "an allow rule exists" would hand
  243. // it unrestricted shell — the precise failure PermissionMapper's permissive default
  244. // produces, and the reason this adapter does not use it.
  245. const { content } = await adapter.fromCanonical(fixture(FIXTURE_PLANNER));
  246. expect(content).toMatch(/^disallowedTools:.*\bBash\b/m);
  247. expect(content).not.toMatch(/^tools:.*\bBash\b/m);
  248. });
  249. it("degrades 'ask' to deny, never to allow", async () => {
  250. const { content } = await adapter.fromCanonical(canonical(` bash:\n "*": "ask"\n`));
  251. expect(content).toMatch(/^disallowedTools: Bash$/m);
  252. expect(content).not.toMatch(/^tools:.*Bash/m);
  253. });
  254. it("does not treat an allow-with-exceptions as a plain allow", async () => {
  255. const { content } = await adapter.fromCanonical(
  256. canonical(` edit:\n "*": "allow"\n "**/*.env*": "deny"\n`)
  257. );
  258. expect(content).toMatch(/^disallowedTools: Edit$/m);
  259. });
  260. it("carries a provably uniform allow through as a grant", async () => {
  261. const { content, warnings } = await adapter.fromCanonical(
  262. canonical(` read:\n "*": "allow"\n`)
  263. );
  264. expect(content).toMatch(/^tools: Read$/m);
  265. expect(warnings).toEqual([]);
  266. });
  267. it("carries a provably uniform deny through as a denial, silently", async () => {
  268. // An exact projection loses nothing, so it must not warn — warnings mean loss, and
  269. // noise here would train readers to ignore the real ones.
  270. const { content, warnings } = await adapter.fromCanonical(
  271. canonical(` bash:\n "*": "deny"\n`)
  272. );
  273. expect(content).toMatch(/^disallowedTools: Bash$/m);
  274. expect(warnings).toEqual([]);
  275. });
  276. it("never grants a tool whose rules contain any deny", async () => {
  277. // Property check over every mixed shape in the live corpus.
  278. const shapes = [
  279. ` bash:\n "*": "deny"\n "git log*": "allow"\n`,
  280. ` bash:\n "git log*": "allow"\n "*": "deny"\n`,
  281. ` edit:\n "**/*.env*": "deny"\n "**/*.key": "deny"\n`,
  282. ` read:\n "**/*": "deny"\n ".tmp/**": "allow"\n`,
  283. ];
  284. for (const shape of shapes) {
  285. const { content } = await adapter.fromCanonical(canonical(shape));
  286. const tools = /^tools: (.*)$/m.exec(content)?.[1] ?? "";
  287. expect(tools, `${shape} leaked a grant`).toBe("");
  288. }
  289. });
  290. });
  291. // ============================================================================
  292. // WARNINGS — one per lossy projection
  293. // ============================================================================
  294. describe("warnings", () => {
  295. it("emits exactly one warning for a single unrepresentable capability", async () => {
  296. const { warnings } = await adapter.fromCanonical(
  297. canonical(` bash:\n "*": "deny"\n "git log*": "allow"\n`)
  298. );
  299. expect(warnings).toHaveLength(1);
  300. expect(warnings[0]).toMatch(/bash/i);
  301. expect(warnings[0]).toMatch(/fail-closed/);
  302. });
  303. it("counts one warning per lossy capability, and none for the lossless ones", async () => {
  304. // read/glob are exact; bash and edit are not. Two losses, two warnings.
  305. const { warnings } = await adapter.fromCanonical(
  306. canonical(
  307. ` read:\n "*": "allow"\n` +
  308. ` glob:\n "*": "allow"\n` +
  309. ` bash:\n "*": "deny"\n "git log*": "allow"\n` +
  310. ` edit:\n "*": "allow"\n "**/*.key": "deny"\n`
  311. )
  312. );
  313. expect(warnings).toHaveLength(2);
  314. expect(warnings.filter((w) => /'bash'/.test(w))).toHaveLength(1);
  315. expect(warnings.filter((w) => /'edit'/.test(w))).toHaveLength(1);
  316. });
  317. it("adds a second warning naming 'ask' when a mixed list contains one", async () => {
  318. // test-engineer's real block: a test-runner allowlist plus `rm -rf *: ask`.
  319. const { warnings } = await adapter.fromCanonical(
  320. canonical(` bash:\n "npx vitest *": "allow"\n "rm -rf *": "ask"\n "*": "deny"\n`)
  321. );
  322. expect(warnings).toHaveLength(2);
  323. expect(warnings.some((w) => /cannot express/.test(w) && /ask/.test(w))).toBe(true);
  324. });
  325. it("warns when a rule list has no recoverable default", async () => {
  326. // context-manager's real `write` block: allow + deny with no "*" rule.
  327. const { warnings } = await adapter.fromCanonical(
  328. canonical(
  329. ` write:\n ".opencode/context/**/*.md": "allow"\n "**/*.env*": "deny"\n`
  330. )
  331. );
  332. expect(warnings).toHaveLength(2);
  333. expect(warnings.some((w) => /ambiguous/.test(w))).toBe(true);
  334. });
  335. it("warns when a capability has no Claude Code tool at all", async () => {
  336. // externalscout's real `skill` block restricts which skills it may invoke. Claude Code
  337. // cannot express that; dropping it silently is the widening this suite guards against.
  338. const { warnings } = await adapter.fromCanonical(
  339. canonical(` skill:\n "*": "deny"\n "*context7*": "allow"\n`)
  340. );
  341. expect(warnings).toHaveLength(1);
  342. expect(warnings[0]).toMatch(/'skill' has no Claude Code tool/);
  343. });
  344. it("warns that temperature and maxSteps cannot be carried", async () => {
  345. const { warnings } = await adapter.fromCanonical(
  346. canonical(` read:\n "*": "allow"\n`, "temperature: 0.1\nmaxSteps: 10\n")
  347. );
  348. expect(warnings).toHaveLength(2);
  349. expect(warnings.some((w) => w.includes("temperature"))).toBe(true);
  350. expect(warnings.some((w) => w.includes("maxSteps"))).toBe(true);
  351. });
  352. it("reports no permission loss for an agent whose every capability projects exactly", async () => {
  353. // fixture-reviewer's block is uniform-per-capability, so nothing about its permissions
  354. // is lost. Its `temperature: 0.1` still is — and that one warning is the whole list.
  355. const { warnings } = await adapter.fromCanonical(fixture(FIXTURE_REVIEWER));
  356. expect(warnings).toHaveLength(1);
  357. expect(warnings[0]).toContain("temperature");
  358. });
  359. });
  360. // ============================================================================
  361. // DETERMINISM
  362. // ============================================================================
  363. describe("determinism", () => {
  364. it("emits identical bytes regardless of the source's key order", async () => {
  365. const rules = {
  366. read: ` read:\n "*": "allow"\n`,
  367. bash: ` bash:\n "*": "deny"\n`,
  368. write: ` write:\n "*": "deny"\n`,
  369. };
  370. const forward = await adapter.fromCanonical(
  371. canonical(rules.read + rules.bash + rules.write)
  372. );
  373. const reversed = await adapter.fromCanonical(
  374. canonical(rules.write + rules.bash + rules.read)
  375. );
  376. expect(reversed.content).toBe(forward.content);
  377. });
  378. it("emits identical bytes across separate adapter instances", async () => {
  379. const source = fixture(FIXTURE_REVIEWER);
  380. expect((await new ClaudeAdapter().fromCanonical(source)).content).toBe(
  381. (await new ClaudeAdapter().fromCanonical(source)).content
  382. );
  383. });
  384. });
  385. // ============================================================================
  386. // INPUT VALIDATION
  387. // ============================================================================
  388. describe("input validation", () => {
  389. it("throws a named error when the source lacks an oac: block", async () => {
  390. const source = `---\nname: X\ndescription: Y\nmode: subagent\n---\n\nBody\n`;
  391. await expect(adapter.fromCanonical(source)).rejects.toThrow(/not a canonical agent file/);
  392. });
  393. it("names the offending field when the oac: block is malformed", async () => {
  394. const source = canonical(` read:\n "*": "allow"\n`).replace(
  395. "id: probe-agent",
  396. "id: Probe_Agent"
  397. );
  398. await expect(adapter.fromCanonical(source)).rejects.toThrow(/oac\.id/);
  399. });
  400. });
  401. // ============================================================================
  402. // CAPABILITIES
  403. // ============================================================================
  404. describe("getCapabilities()", () => {
  405. it("returns correct capabilities object", () => {
  406. const capabilities = adapter.getCapabilities();
  407. expect(capabilities.name).toBe("claude");
  408. expect(capabilities.displayName).toBe("Claude Code");
  409. expect(capabilities.supportsMultipleAgents).toBe(true);
  410. expect(capabilities.supportsSkills).toBe(true);
  411. expect(capabilities.supportsHooks).toBe(true);
  412. expect(capabilities.supportsGranularPermissions).toBe(false);
  413. expect(capabilities.supportsContexts).toBe(true);
  414. expect(capabilities.supportsCustomModels).toBe(true);
  415. expect(capabilities.supportsTemperature).toBe(false);
  416. expect(capabilities.supportsMaxSteps).toBe(false);
  417. expect(capabilities.configFormat).toBe("markdown");
  418. expect(capabilities.outputStructure).toBe("directory");
  419. });
  420. it("agrees with the CapabilityMatrix rather than restating it", async () => {
  421. // These two disagreed before: the matrix called Claude `json`, the adapter `markdown`.
  422. // A platform cannot have two answers about itself.
  423. const { getToolCapabilities } = await import("../../../src/core/CapabilityMatrix.js");
  424. expect(adapter.getCapabilities().configFormat).toBe(
  425. getToolCapabilities("claude").configFormat
  426. );
  427. });
  428. it("includes appropriate notes", () => {
  429. const capabilities = adapter.getCapabilities();
  430. expect(capabilities.notes?.length).toBeGreaterThan(0);
  431. expect(capabilities.notes?.some((n) => /permission/i.test(n))).toBe(true);
  432. });
  433. });
  434. // ============================================================================
  435. // toOAC() — the IMPORT direction (still accepts legacy .claude/ shapes)
  436. // ============================================================================
  437. describe("toOAC() - parsing config.json", () => {
  438. it("parses minimal config.json", async () => {
  439. const result = await adapter.toOAC(
  440. JSON.stringify({
  441. name: "TestAgent",
  442. description: "Test description",
  443. systemPrompt: "You are helpful",
  444. })
  445. );
  446. expect(result.frontmatter.name).toBe("TestAgent");
  447. expect(result.frontmatter.description).toBe("Test description");
  448. expect(result.systemPrompt).toBe("You are helpful");
  449. expect(result.frontmatter.mode).toBe("primary");
  450. });
  451. it("parses config with tools array", async () => {
  452. const result = await adapter.toOAC(
  453. JSON.stringify({ name: "Agent", description: "Test", tools: ["Read", "Write", "Bash"] })
  454. );
  455. expect(result.frontmatter.tools).toEqual({ read: true, write: true, bash: true });
  456. });
  457. it("parses config with tools string", async () => {
  458. const result = await adapter.toOAC(
  459. JSON.stringify({ name: "Agent", description: "Test", tools: "Read, Write, Edit" })
  460. );
  461. expect(result.frontmatter.tools).toEqual({ read: true, write: true, edit: true });
  462. });
  463. it("parses config with skills", async () => {
  464. const result = await adapter.toOAC(
  465. JSON.stringify({ name: "Agent", description: "Test", skills: ["skill1", "skill2"] })
  466. );
  467. expect(result.frontmatter.skills).toEqual(["skill1", "skill2"]);
  468. });
  469. it("parses config with hooks", async () => {
  470. const result = await adapter.toOAC(
  471. JSON.stringify({
  472. name: "Agent",
  473. description: "Test",
  474. hooks: {
  475. PreToolUse: [{ matcher: "*.txt", hooks: [{ type: "command", command: "validate" }] }],
  476. },
  477. })
  478. );
  479. expect(result.frontmatter.hooks?.length).toBe(1);
  480. expect(result.frontmatter.hooks?.[0].event).toBe("PreToolUse");
  481. expect(result.frontmatter.hooks?.[0].matchers).toEqual(["*.txt"]);
  482. });
  483. it("handles missing optional fields gracefully", async () => {
  484. const result = await adapter.toOAC(
  485. JSON.stringify({ name: "MinimalAgent", description: "Minimal" })
  486. );
  487. expect(result.frontmatter.name).toBe("MinimalAgent");
  488. expect(result.systemPrompt).toBe("");
  489. expect(result.frontmatter.tools).toBeUndefined();
  490. expect(result.frontmatter.skills).toBeUndefined();
  491. });
  492. it("parses invalid JSON as markdown (subagent fallback)", async () => {
  493. const result = await adapter.toOAC("not valid json");
  494. expect(result.frontmatter.mode).toBe("subagent");
  495. expect(result.systemPrompt).toBe("not valid json");
  496. });
  497. it("handles null system prompt", async () => {
  498. const result = await adapter.toOAC(
  499. JSON.stringify({ name: "Agent", description: "Test", systemPrompt: null })
  500. );
  501. expect(result.systemPrompt).toBe("");
  502. });
  503. });
  504. describe("toOAC() - parsing agent.md with YAML frontmatter", () => {
  505. it("parses agent.md with minimal frontmatter", async () => {
  506. const result = await adapter.toOAC(
  507. `---\nname: SubAgent\ndescription: A subagent\n---\n\nThis is the system prompt.`
  508. );
  509. expect(result.frontmatter.name).toBe("SubAgent");
  510. expect(result.frontmatter.description).toBe("A subagent");
  511. expect(result.frontmatter.mode).toBe("subagent");
  512. expect(result.systemPrompt).toBe("This is the system prompt.");
  513. });
  514. it("parses a committed plugin agent's flat tools list", async () => {
  515. const result = await adapter.toOAC(
  516. `---\nname: code-reviewer\ndescription: Reviews\ntools: Read, Glob, Grep\nmodel: sonnet\n---\n\nPrompt`
  517. );
  518. expect(result.frontmatter.tools).toEqual({ read: true, glob: true, grep: true });
  519. expect(result.frontmatter.model).toBe("claude-sonnet-4");
  520. });
  521. it("handles agent.md without frontmatter as markdown content", async () => {
  522. const result = await adapter.toOAC("No frontmatter here, just markdown content");
  523. expect(result.systemPrompt).toBe("No frontmatter here, just markdown content");
  524. expect(result.frontmatter.mode).toBe("subagent");
  525. });
  526. it("preserves multiline system prompt", async () => {
  527. const result = await adapter.toOAC(
  528. `---\nname: Agent\ndescription: Test\n---\n\nLine one.\nLine two.\nLine three.`
  529. );
  530. expect(result.systemPrompt).toContain("Line one.");
  531. expect(result.systemPrompt).toContain("Line three.");
  532. });
  533. });
  534. describe("model mapping (Claude to OAC)", () => {
  535. it("maps dated sonnet id to claude-sonnet-4", async () => {
  536. const result = await adapter.toOAC(
  537. JSON.stringify({ name: "A", description: "T", model: "claude-sonnet-4-20250514" })
  538. );
  539. expect(result.frontmatter.model).toBe("claude-sonnet-4");
  540. });
  541. it("maps short model aliases", async () => {
  542. const result = await adapter.toOAC(
  543. JSON.stringify({ name: "A", description: "T", model: "opus" })
  544. );
  545. expect(result.frontmatter.model).toBe("claude-opus-4");
  546. });
  547. it("preserves unknown models", async () => {
  548. const result = await adapter.toOAC(
  549. JSON.stringify({ name: "A", description: "T", model: "claude-custom-model" })
  550. );
  551. expect(result.frontmatter.model).toBe("claude-custom-model");
  552. });
  553. it("handles missing model gracefully", async () => {
  554. const result = await adapter.toOAC(JSON.stringify({ name: "A", description: "T" }));
  555. expect(result.frontmatter.model).toBeUndefined();
  556. });
  557. });
  558. // ============================================================================
  559. // fromOAC() — legacy in-memory interface, retargeted to the plugin layout
  560. // ============================================================================
  561. describe("fromOAC()", () => {
  562. const createAgent = (overrides?: Partial<AgentFrontmatter>): OpenAgent => ({
  563. frontmatter: {
  564. name: "CodeAnalyzer",
  565. description: "Analyzes code",
  566. mode: "subagent",
  567. ...overrides,
  568. },
  569. metadata: { name: "CodeAnalyzer", category: "specialist", type: "subagent" },
  570. systemPrompt: "Analyze code quality",
  571. contexts: [],
  572. });
  573. it("emits a single agent markdown file", async () => {
  574. const result = await adapter.fromOAC(createAgent());
  575. expect(result.success).toBe(true);
  576. expect(result.configs).toHaveLength(1);
  577. expect(result.configs[0].fileName).toBe("plugins/claude-code/agents/CodeAnalyzer.md");
  578. expect(result.configs[0].encoding).toBe("utf-8");
  579. });
  580. it("generates flat frontmatter and includes the system prompt", async () => {
  581. const result = await adapter.fromOAC(createAgent());
  582. const content = result.configs[0].content;
  583. expect(content).toMatch(/^---\nname: CodeAnalyzer\ndescription: Analyzes code\n/);
  584. expect(content).toContain("---\n\n");
  585. expect(content).toContain("Analyze code quality");
  586. });
  587. it("maps an authored tools map through the canonical ordering", async () => {
  588. const result = await adapter.fromOAC(
  589. createAgent({ tools: { bash: true, read: true, write: false } })
  590. );
  591. expect(result.configs[0].content).toMatch(/^tools: Read, Bash$/m);
  592. });
  593. it("projects an authored permission map fail-closed", async () => {
  594. const result = await adapter.fromOAC(
  595. createAgent({ permission: { bash: { "*": "deny", "git log*": "allow" } } })
  596. );
  597. expect(result.configs[0].content).toMatch(/^disallowedTools: Bash$/m);
  598. expect(result.warnings.some((w) => /bash/i.test(w))).toBe(true);
  599. });
  600. it("does not emit a permissionMode — Claude Code has no such agent field", async () => {
  601. const result = await adapter.fromOAC(
  602. createAgent({ permission: { read: "allow", write: "allow" } })
  603. );
  604. expect(result.configs[0].content).not.toContain("permissionMode");
  605. expect(result.configs[0].content).not.toContain("bypassPermissions");
  606. });
  607. it("warns when temperature is set (unsupported)", async () => {
  608. const result = await adapter.fromOAC(createAgent({ temperature: 0.7 }));
  609. expect(result.warnings.some((w) => w.includes("temperature"))).toBe(true);
  610. });
  611. it("warns when maxSteps is set (unsupported)", async () => {
  612. const result = await adapter.fromOAC(createAgent({ maxSteps: 10 }));
  613. expect(result.warnings.some((w) => w.includes("maxSteps"))).toBe(true);
  614. });
  615. it("includes validation warnings for a nameless agent", async () => {
  616. const result = await adapter.fromOAC(createAgent({ name: "", description: "" }));
  617. expect(result.warnings.length).toBeGreaterThan(0);
  618. });
  619. it("includes capabilities in the result", async () => {
  620. const result = await adapter.fromOAC(createAgent());
  621. expect(result.capabilities?.name).toBe("claude");
  622. });
  623. it("handles an empty system prompt", async () => {
  624. const result = await adapter.fromOAC({ ...createAgent(), systemPrompt: "" });
  625. expect(result.success).toBe(true);
  626. expect(result.configs[0].content).toBe(
  627. "---\nname: CodeAnalyzer\ndescription: Analyzes code\n---\n\n\n"
  628. );
  629. });
  630. it("carries hooks nowhere in agent frontmatter", async () => {
  631. // Claude Code agent frontmatter accepts name/description/tools/disallowedTools/model
  632. // and nothing else. The old adapter wrote a `hooks:` key that Claude Code silently
  633. // ignores, which reads as support that does not exist.
  634. const hook: HookDefinition = {
  635. event: "PreToolUse",
  636. matchers: ["*.txt"],
  637. commands: [{ type: "command", command: "validate" }],
  638. };
  639. const result = await adapter.fromOAC(createAgent({ hooks: [hook] }));
  640. expect(result.configs[0].content).not.toContain("hooks");
  641. });
  642. });
  643. // ============================================================================
  644. // SKILLS GENERATION FROM CONTEXTS
  645. // ============================================================================
  646. describe("fromOAC() - generating skills from contexts", () => {
  647. const withContexts = (
  648. contexts: Array<{ path: string; priority?: string; description?: string }>
  649. ): OpenAgent => ({
  650. frontmatter: { name: "Agent", description: "Test", mode: "primary" },
  651. metadata: { name: "Agent", category: "core", type: "agent" },
  652. systemPrompt: "Prompt",
  653. contexts,
  654. });
  655. it("generates skill files under the plugin tree", async () => {
  656. const result = await adapter.fromOAC(
  657. withContexts([
  658. { path: ".opencode/context/skills/python.md", description: "Python standards" },
  659. ])
  660. );
  661. const skill = result.configs.find((c) => c.fileName.includes("/skills/"));
  662. expect(skill?.fileName).toBe("plugins/claude-code/skills/python/SKILL.md");
  663. });
  664. it("generates a slugified skill name from the context path", async () => {
  665. const result = await adapter.fromOAC(
  666. withContexts([{ path: "docs/React Hooks Guide.md", description: "React docs" }])
  667. );
  668. expect(result.configs.find((c) => c.fileName.includes("/skills/"))?.fileName).toMatch(
  669. /react-hooks-guide/
  670. );
  671. });
  672. it("includes context priority in skill content", async () => {
  673. const result = await adapter.fromOAC(
  674. withContexts([{ path: "context/important.md", priority: "high", description: "Ctx" }])
  675. );
  676. expect(result.configs.find((c) => c.fileName.includes("/skills/"))?.content).toContain(
  677. "Priority: high"
  678. );
  679. });
  680. it("generates one skill per context", async () => {
  681. const result = await adapter.fromOAC(
  682. withContexts([{ path: "a.md" }, { path: "b.md" }, { path: "c.md" }])
  683. );
  684. expect(result.configs.filter((c) => c.fileName.includes("/skills/"))).toHaveLength(3);
  685. });
  686. it("falls back to a generated description when the context lacks one", async () => {
  687. const result = await adapter.fromOAC(withContexts([{ path: ".opencode/context/styles.md" }]));
  688. const skill = result.configs.find((c) => c.fileName.includes("/skills/"));
  689. expect(skill?.content).toContain("Context from");
  690. expect(skill?.content).toContain("styles.md");
  691. });
  692. });
  693. // ============================================================================
  694. // VALIDATION
  695. // ============================================================================
  696. describe("validateConversion()", () => {
  697. const createAgent = (overrides?: Partial<AgentFrontmatter>): OpenAgent => ({
  698. frontmatter: { name: "Agent", description: "Test", mode: "primary", ...overrides },
  699. metadata: { name: "Agent", category: "core", type: "agent" },
  700. systemPrompt: "Prompt",
  701. contexts: [],
  702. });
  703. it("returns no warnings for valid agent", () => {
  704. expect(adapter.validateConversion(createAgent())).toHaveLength(0);
  705. });
  706. it("warns when name is missing", () => {
  707. expect(adapter.validateConversion(createAgent({ name: "" })).some((w) => w.includes("name"))).toBe(
  708. true
  709. );
  710. });
  711. it("warns when description is missing", () => {
  712. expect(
  713. adapter
  714. .validateConversion(createAgent({ description: "" }))
  715. .some((w) => w.includes("description"))
  716. ).toBe(true);
  717. });
  718. it("warns about granular permission degradation", () => {
  719. const warnings = adapter.validateConversion(
  720. createAgent({ permission: { read: { "file1.txt": "allow", "file2.txt": "deny" } } })
  721. );
  722. expect(warnings.some((w) => w.includes("granular permissions"))).toBe(true);
  723. });
  724. it("does not warn about simple permission rules", () => {
  725. const warnings = adapter.validateConversion(
  726. createAgent({ permission: { read: "allow", write: "allow" } })
  727. );
  728. expect(warnings.some((w) => w.includes("granular permissions"))).toBe(false);
  729. });
  730. });
  731. });