OpenCodeAdapter.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460
  1. /**
  2. * OpenCodeAdapter — emitting `.opencode/agent/**` from `content/agents/**`.
  3. *
  4. * The adapter's contract is narrow enough to state in one line: the emitted file is the
  5. * canonical file minus its `oac:` block. So the tests here are mostly about proving that
  6. * "minus its `oac:` block" is *all* that happens — that nothing else is quietly reformatted,
  7. * reordered or dropped along the way.
  8. *
  9. * The strongest gate in this file is `round-trip against the live corpus`: no expectation was
  10. * authored for it. It rebuilds the 33 agent files OpenCode is loading today and demands the
  11. * bytes already on disk. Nothing in it can be wrong-by-guess, and it is what makes subtask 09's
  12. * seeding reviewable as a no-op diff.
  13. */
  14. import { describe, it, expect } from "vitest";
  15. import { readFileSync } from "node:fs";
  16. import { relative, sep } from "node:path";
  17. import * as yaml from "js-yaml";
  18. import { OpenCodeAdapter, OpenCodeEmitError } from "../../../src/adapters/OpenCodeAdapter.js";
  19. import { desugarPermission } from "../../../src/types.js";
  20. import { listFiles, repoPath } from "../../support/pending.js";
  21. const CONTENT_ROOT = repoPath("content/agents");
  22. const OPENCODE_ROOT = repoPath(".opencode/agent");
  23. /** POSIX path relative to the canonical content root. */
  24. function contentRelative(absolute: string): string {
  25. return relative(CONTENT_ROOT, absolute).split(sep).join("/");
  26. }
  27. /** Parse a frontmatter block out of an emitted file. */
  28. function frontmatterOf(content: string): Record<string, unknown> {
  29. const match = /^---\n([\s\S]*?)\n---\n/.exec(content);
  30. expect(match, "emitted file has no parseable frontmatter").not.toBeNull();
  31. return yaml.load(match![1]!) as Record<string, unknown>;
  32. }
  33. /** The top-level frontmatter keys, in emitted order. */
  34. function keysOf(content: string): string[] {
  35. return Object.keys(frontmatterOf(content));
  36. }
  37. const adapter = (): OpenCodeAdapter => new OpenCodeAdapter();
  38. const MINIMAL = [
  39. "---",
  40. "name: Probe",
  41. "description: A probe agent.",
  42. "mode: subagent",
  43. "oac:",
  44. " id: probe",
  45. " name: Probe",
  46. " category: core",
  47. " type: subagent",
  48. "---",
  49. "",
  50. "# Probe",
  51. "",
  52. ].join("\n");
  53. // ============================================================================
  54. // Identity
  55. // ============================================================================
  56. describe("OpenCodeAdapter identity", () => {
  57. it("reports its name and display name", () => {
  58. expect(adapter().name).toBe("opencode");
  59. expect(adapter().displayName).toBe("OpenCode");
  60. });
  61. it("declares granular permission support — OpenCode is the one target that loses nothing", () => {
  62. const capabilities = adapter().getCapabilities();
  63. expect(capabilities.supportsGranularPermissions).toBe(true);
  64. expect(capabilities.configFormat).toBe("markdown");
  65. expect(capabilities.outputStructure).toBe("directory");
  66. });
  67. it("writes agents under .opencode/agent/", () => {
  68. expect(adapter().getConfigPath()).toBe(".opencode/agent/");
  69. });
  70. });
  71. // ============================================================================
  72. // Stripping the oac: block
  73. // ============================================================================
  74. describe("OpenCodeAdapter oac stripping", () => {
  75. it("emits no oac key — asserted by parsing the YAML, not by searching the text", async () => {
  76. const { content } = await adapter().fromCanonical(MINIMAL);
  77. // A string search for "oac:" would be satisfied by a body that merely mentions it, and
  78. // would false-positive on a prompt discussing the oac block. Parse instead.
  79. expect(frontmatterOf(content)).not.toHaveProperty("oac");
  80. });
  81. it("keeps every OpenCode-legal field, in authored order", async () => {
  82. const { content } = await adapter().fromCanonical(MINIMAL);
  83. expect(keysOf(content)).toEqual(["name", "description", "mode"]);
  84. });
  85. it("leaves the body untouched, including its --- horizontal rules", async () => {
  86. const source = [
  87. "---",
  88. "name: Probe",
  89. "description: A probe agent.",
  90. "mode: subagent",
  91. "oac:",
  92. " id: probe",
  93. " name: Probe",
  94. " category: core",
  95. " type: subagent",
  96. "---",
  97. "",
  98. "# Probe",
  99. "",
  100. "---",
  101. "",
  102. "A section after a horizontal rule.",
  103. "",
  104. ].join("\n");
  105. const { content } = await adapter().fromCanonical(source);
  106. // The body's own `---` must not be mistaken for the frontmatter terminator.
  107. expect(content).toContain("A section after a horizontal rule.");
  108. expect(content.endsWith("A section after a horizontal rule.\n")).toBe(true);
  109. });
  110. it("strips an oac: block that is not the last key, keeping the key after it", async () => {
  111. const source = [
  112. "---",
  113. "name: Probe",
  114. "oac:",
  115. " id: probe",
  116. " name: Probe",
  117. " category: core",
  118. " type: subagent",
  119. "description: A probe agent.",
  120. "mode: subagent",
  121. "---",
  122. "body",
  123. "",
  124. ].join("\n");
  125. const { content } = await adapter().fromCanonical(source);
  126. expect(keysOf(content)).toEqual(["name", "description", "mode"]);
  127. });
  128. it("preserves a blank separator line that follows the oac: block", async () => {
  129. const source = [
  130. "---",
  131. "name: Probe",
  132. "oac:",
  133. " id: probe",
  134. " name: Probe",
  135. " category: core",
  136. " type: subagent",
  137. "",
  138. "description: A probe agent.",
  139. "mode: subagent",
  140. "---",
  141. "body",
  142. "",
  143. ].join("\n");
  144. const { content } = await adapter().fromCanonical(source);
  145. // The blank line separates the next key; swallowing it would reformat the author's file.
  146. expect(content).toContain("name: Probe\n\ndescription: A probe agent.");
  147. });
  148. it("preserves YAML comments, which a dump-based emitter would silently discard", async () => {
  149. const source = [
  150. "---",
  151. "# A comment the author wrote.",
  152. "name: Probe",
  153. "description: A probe agent.",
  154. "mode: subagent",
  155. "oac:",
  156. " id: probe",
  157. " name: Probe",
  158. " category: core",
  159. " type: subagent",
  160. "---",
  161. "body",
  162. "",
  163. ].join("\n");
  164. const { content } = await adapter().fromCanonical(source);
  165. expect(content).toContain("# A comment the author wrote.");
  166. });
  167. });
  168. // ============================================================================
  169. // Permissions
  170. // ============================================================================
  171. describe("OpenCodeAdapter permissions", () => {
  172. const planner = readFileSync(
  173. repoPath("packages/compatibility-layer/tests/golden/fixtures/fixture-planner.md"),
  174. "utf-8"
  175. );
  176. it("serializes ordered rules back to a YAML mapping in original author order", async () => {
  177. const { content } = await adapter().fromCanonical(planner);
  178. const permission = frontmatterOf(content)["permission"] as Record<
  179. string,
  180. Record<string, string>
  181. >;
  182. // Capability order, then rule order within `bash`. Both are semantic: OpenCode flattens
  183. // the map and resolves last-match-wins, so a reordering here silently changes what the
  184. // agent may run.
  185. expect(Object.keys(permission)).toEqual(["read", "bash", "edit", "write"]);
  186. expect(Object.keys(permission["bash"]!)).toEqual(["*", "git status", "git log*"]);
  187. });
  188. it("keeps a deny-all-then-allowlist resolving exactly as authored", async () => {
  189. const { content } = await adapter().fromCanonical(planner);
  190. const rules = desugarPermission(frontmatterOf(content)["permission"]);
  191. const bash = rules.find((entry) => entry.capability === "bash");
  192. // The catch-all deny comes FIRST and the allows come after it. Reverse them and
  193. // `git status` resolves to deny; drop the allows and the agent is bricked.
  194. expect(bash?.rules).toEqual([
  195. { pattern: "*", action: "deny" },
  196. { pattern: "git status", action: "allow" },
  197. { pattern: "git log*", action: "allow" },
  198. ]);
  199. });
  200. it("never widens a deny-all bash block to `bash: true`", async () => {
  201. // Guards the specific live hazard: `PermissionMapper.mapPermissionsFromOAC` defaults to a
  202. // "permissive" strategy whose record branch returns `hasAllow || !hasDeny`, which answers
  203. // `true` for CoderAgent's deny-all-then-allowlist. If that mapper ever reaches this build
  204. // path, this test is what catches it.
  205. const { content } = await adapter().fromCanonical(
  206. readFileSync(repoPath("content/agents/subagents/code/coder-agent.md"), "utf-8")
  207. );
  208. const permission = frontmatterOf(content)["permission"] as Record<string, unknown>;
  209. expect(permission["bash"]).not.toBe(true);
  210. expect((permission["bash"] as Record<string, string>)["*"]).toBe("deny");
  211. });
  212. it("preserves the `skill` capability, which a closed vocabulary would drop", async () => {
  213. // ExternalScout denies all skills then re-allows context7. `skill` is absent from doc 02
  214. // §1.2.7's closed vocabulary but IS a real OpenCode permission key (config.ts:575), so a
  215. // parser built to that enum would drop this block and silently grant every skill.
  216. const { content } = await adapter().fromCanonical(
  217. readFileSync(repoPath("content/agents/subagents/core/externalscout.md"), "utf-8")
  218. );
  219. const permission = frontmatterOf(content)["permission"] as Record<
  220. string,
  221. Record<string, string>
  222. >;
  223. expect(permission).toHaveProperty("skill");
  224. expect(Object.keys(permission["skill"]!)).toEqual(["*", "*context7*"]);
  225. expect(permission["skill"]!["*"]).toBe("deny");
  226. });
  227. });
  228. // ============================================================================
  229. // Round-trip against the live corpus
  230. // ============================================================================
  231. /**
  232. * The obsolete header that 23 committed `.opencode/agent` files still carry — a comment
  233. * pointing at the very sidecar this refactor dissolves.
  234. *
  235. * The seeding commit (`cf97d98`) dropped it from 9 of those 23 and kept it in the other 14.
  236. * That inconsistency is a seeding artifact, not an adapter behaviour: the comment is absent
  237. * from those 9 canonical sources, so no emitter can reproduce it.
  238. *
  239. * It is pinned here rather than papered over. The adapter is NOT special-cased to re-insert
  240. * it — doing so would be forging bytes the source does not contain. Instead the 9 are named,
  241. * so the drift is visible, cannot grow silently, and stays owned by whoever re-seeds
  242. * `content/agents` (subtask 09). Resolving it means either restoring the comment in those 9
  243. * sources or removing it from all 23 — a content decision, made once, deliberately.
  244. */
  245. const SIDECAR_COMMENT =
  246. [
  247. "# OpenCode Agent Configuration",
  248. "# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:",
  249. "# .opencode/config/agent-metadata.json",
  250. "",
  251. ].join("\n") + "\n";
  252. /** The 9 sources whose committed output still carries {@link SIDECAR_COMMENT}. */
  253. const DRIFTED: ReadonlySet<string> = new Set([
  254. "content/copywriter.md",
  255. "content/technical-writer.md",
  256. "meta/system-builder.md",
  257. "subagents/planning/story-mapper.md",
  258. "subagents/system-builder/agent-generator.md",
  259. "subagents/system-builder/command-creator.md",
  260. "subagents/system-builder/domain-analyzer.md",
  261. "subagents/test/simple-responder.md",
  262. "subagents/utils/image-specialist.md",
  263. ]);
  264. describe("OpenCodeAdapter round-trip", () => {
  265. const sources = listFiles(CONTENT_ROOT);
  266. it("finds the canonical corpus", () => {
  267. expect(sources.length).toBeGreaterThan(0);
  268. });
  269. it.each(sources.map((file) => [contentRelative(file), file] as const))(
  270. "reproduces .opencode/agent/%s byte-for-byte",
  271. async (rel, file) => {
  272. const { content } = await adapter().fromCanonical(readFileSync(file, "utf-8"), {
  273. filePath: file,
  274. });
  275. const committed = readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8");
  276. // The drifted 9 must differ ONLY by the obsolete comment — re-inserting it recovers the
  277. // committed bytes exactly. Any other difference in them still fails here.
  278. const expected = DRIFTED.has(rel)
  279. ? committed.replace(`---\n${SIDECAR_COMMENT}`, "---\n")
  280. : committed;
  281. expect(content).toBe(expected);
  282. }
  283. );
  284. it("pins the drifted set, so it cannot grow unnoticed", async () => {
  285. const drifted: string[] = [];
  286. for (const file of sources) {
  287. const rel = contentRelative(file);
  288. const { content } = await adapter().fromCanonical(readFileSync(file, "utf-8"));
  289. if (content !== readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8")) drifted.push(rel);
  290. }
  291. expect(drifted.sort()).toEqual([...DRIFTED].sort());
  292. });
  293. it("maps each source onto its committed output path", () => {
  294. for (const file of sources) {
  295. const rel = contentRelative(file);
  296. expect(adapter().outputPath(rel)).toBe(`.opencode/agent/${rel}`);
  297. }
  298. });
  299. it("derives the output path from the file, not from oac.category + oac.id", () => {
  300. // `content/agents/subagents/code/test-engineer.md` declares `id: tester`. Composing the
  301. // path from the id would emit `subagents/code/tester.md` and orphan the `test-engineer.md`
  302. // OpenCode actually loads.
  303. expect(adapter().outputPath("subagents/code/test-engineer.md")).toBe(
  304. ".opencode/agent/subagents/code/test-engineer.md"
  305. );
  306. });
  307. });
  308. // ============================================================================
  309. // Determinism
  310. // ============================================================================
  311. describe("OpenCodeAdapter determinism", () => {
  312. it("emits byte-identical output across runs and instances", async () => {
  313. const source = readFileSync(repoPath("content/agents/core/openagent.md"), "utf-8");
  314. const first = await adapter().fromCanonical(source);
  315. const second = await adapter().fromCanonical(source);
  316. expect(second.content).toBe(first.content);
  317. });
  318. it("emits no timestamp or absolute host path", async () => {
  319. const { content } = await adapter().fromCanonical(
  320. readFileSync(repoPath("content/agents/core/openagent.md"), "utf-8"),
  321. { filePath: "/Users/someone/content/agents/core/openagent.md" }
  322. );
  323. // filePath is a diagnostic only — it must never leak into emitted bytes, or the output
  324. // would depend on where the repo is checked out.
  325. expect(content).not.toMatch(/\/Users\//);
  326. expect(content).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
  327. });
  328. });
  329. // ============================================================================
  330. // Rejection
  331. // ============================================================================
  332. describe("OpenCodeAdapter rejection", () => {
  333. it("rejects a file with no oac: block", async () => {
  334. const source = ["---", "name: Probe", "description: d", "mode: subagent", "---", "b", ""].join(
  335. "\n"
  336. );
  337. await expect(adapter().fromCanonical(source)).rejects.toThrow(OpenCodeEmitError);
  338. await expect(adapter().fromCanonical(source)).rejects.toThrow(/oac:/);
  339. });
  340. it("rejects a file with no frontmatter", async () => {
  341. await expect(adapter().fromCanonical("# Just a document\n")).rejects.toThrow(
  342. /frontmatter/
  343. );
  344. });
  345. it("rejects unterminated frontmatter", async () => {
  346. await expect(adapter().fromCanonical("---\nname: Probe\n")).rejects.toThrow(
  347. /unterminated/i
  348. );
  349. });
  350. it("rejects an integer-like permission scope that ECMAScript would reorder", async () => {
  351. const source = [
  352. "---",
  353. "name: Probe",
  354. "description: A probe agent.",
  355. "mode: subagent",
  356. "permission:",
  357. " bash:",
  358. ' "*": "deny"',
  359. ' "8080": "allow"',
  360. "oac:",
  361. " id: probe",
  362. " name: Probe",
  363. " category: core",
  364. " type: subagent",
  365. "---",
  366. "body",
  367. "",
  368. ].join("\n");
  369. // An integer-like key is hoisted to the front of the object by ECMAScript, which would
  370. // silently invert last-match-wins precedence. Rejecting beats emitting a reordered file.
  371. await expect(adapter().fromCanonical(source)).rejects.toThrow(/integer-like/);
  372. });
  373. it("names the file it rejected", async () => {
  374. await expect(
  375. adapter().fromCanonical("# no frontmatter\n", { filePath: "content/agents/broken.md" })
  376. ).rejects.toThrow(/content\/agents\/broken\.md/);
  377. });
  378. it("refuses fromOAC() rather than reordering an unordered permission map", async () => {
  379. const result = await adapter().fromOAC({
  380. frontmatter: { name: "Probe", description: "d", mode: "subagent" },
  381. metadata: { tags: [], dependencies: [] },
  382. systemPrompt: "body",
  383. contexts: [],
  384. });
  385. expect(result.success).toBe(false);
  386. expect(result.errors?.[0]).toMatch(/fromCanonical/);
  387. });
  388. });