| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460 |
- /**
- * OpenCodeAdapter — emitting `.opencode/agent/**` from `content/agents/**`.
- *
- * The adapter's contract is narrow enough to state in one line: the emitted file is the
- * canonical file minus its `oac:` block. So the tests here are mostly about proving that
- * "minus its `oac:` block" is *all* that happens — that nothing else is quietly reformatted,
- * reordered or dropped along the way.
- *
- * The strongest gate in this file is `round-trip against the live corpus`: no expectation was
- * authored for it. It rebuilds the 33 agent files OpenCode is loading today and demands the
- * bytes already on disk. Nothing in it can be wrong-by-guess, and it is what makes subtask 09's
- * seeding reviewable as a no-op diff.
- */
- import { describe, it, expect } from "vitest";
- import { readFileSync } from "node:fs";
- import { relative, sep } from "node:path";
- import * as yaml from "js-yaml";
- import { OpenCodeAdapter, OpenCodeEmitError } from "../../../src/adapters/OpenCodeAdapter.js";
- import { desugarPermission } from "../../../src/types.js";
- import { listFiles, repoPath } from "../../support/pending.js";
- const CONTENT_ROOT = repoPath("content/agents");
- const OPENCODE_ROOT = repoPath(".opencode/agent");
- /** POSIX path relative to the canonical content root. */
- function contentRelative(absolute: string): string {
- return relative(CONTENT_ROOT, absolute).split(sep).join("/");
- }
- /** Parse a frontmatter block out of an emitted file. */
- function frontmatterOf(content: string): Record<string, unknown> {
- const match = /^---\n([\s\S]*?)\n---\n/.exec(content);
- expect(match, "emitted file has no parseable frontmatter").not.toBeNull();
- return yaml.load(match![1]!) as Record<string, unknown>;
- }
- /** The top-level frontmatter keys, in emitted order. */
- function keysOf(content: string): string[] {
- return Object.keys(frontmatterOf(content));
- }
- const adapter = (): OpenCodeAdapter => new OpenCodeAdapter();
- const MINIMAL = [
- "---",
- "name: Probe",
- "description: A probe agent.",
- "mode: subagent",
- "oac:",
- " id: probe",
- " name: Probe",
- " category: core",
- " type: subagent",
- "---",
- "",
- "# Probe",
- "",
- ].join("\n");
- // ============================================================================
- // Identity
- // ============================================================================
- describe("OpenCodeAdapter identity", () => {
- it("reports its name and display name", () => {
- expect(adapter().name).toBe("opencode");
- expect(adapter().displayName).toBe("OpenCode");
- });
- it("declares granular permission support — OpenCode is the one target that loses nothing", () => {
- const capabilities = adapter().getCapabilities();
- expect(capabilities.supportsGranularPermissions).toBe(true);
- expect(capabilities.configFormat).toBe("markdown");
- expect(capabilities.outputStructure).toBe("directory");
- });
- it("writes agents under .opencode/agent/", () => {
- expect(adapter().getConfigPath()).toBe(".opencode/agent/");
- });
- });
- // ============================================================================
- // Stripping the oac: block
- // ============================================================================
- describe("OpenCodeAdapter oac stripping", () => {
- it("emits no oac key — asserted by parsing the YAML, not by searching the text", async () => {
- const { content } = await adapter().fromCanonical(MINIMAL);
- // A string search for "oac:" would be satisfied by a body that merely mentions it, and
- // would false-positive on a prompt discussing the oac block. Parse instead.
- expect(frontmatterOf(content)).not.toHaveProperty("oac");
- });
- it("keeps every OpenCode-legal field, in authored order", async () => {
- const { content } = await adapter().fromCanonical(MINIMAL);
- expect(keysOf(content)).toEqual(["name", "description", "mode"]);
- });
- it("leaves the body untouched, including its --- horizontal rules", async () => {
- const source = [
- "---",
- "name: Probe",
- "description: A probe agent.",
- "mode: subagent",
- "oac:",
- " id: probe",
- " name: Probe",
- " category: core",
- " type: subagent",
- "---",
- "",
- "# Probe",
- "",
- "---",
- "",
- "A section after a horizontal rule.",
- "",
- ].join("\n");
- const { content } = await adapter().fromCanonical(source);
- // The body's own `---` must not be mistaken for the frontmatter terminator.
- expect(content).toContain("A section after a horizontal rule.");
- expect(content.endsWith("A section after a horizontal rule.\n")).toBe(true);
- });
- it("strips an oac: block that is not the last key, keeping the key after it", async () => {
- const source = [
- "---",
- "name: Probe",
- "oac:",
- " id: probe",
- " name: Probe",
- " category: core",
- " type: subagent",
- "description: A probe agent.",
- "mode: subagent",
- "---",
- "body",
- "",
- ].join("\n");
- const { content } = await adapter().fromCanonical(source);
- expect(keysOf(content)).toEqual(["name", "description", "mode"]);
- });
- it("preserves a blank separator line that follows the oac: block", async () => {
- const source = [
- "---",
- "name: Probe",
- "oac:",
- " id: probe",
- " name: Probe",
- " category: core",
- " type: subagent",
- "",
- "description: A probe agent.",
- "mode: subagent",
- "---",
- "body",
- "",
- ].join("\n");
- const { content } = await adapter().fromCanonical(source);
- // The blank line separates the next key; swallowing it would reformat the author's file.
- expect(content).toContain("name: Probe\n\ndescription: A probe agent.");
- });
- it("preserves YAML comments, which a dump-based emitter would silently discard", async () => {
- const source = [
- "---",
- "# A comment the author wrote.",
- "name: Probe",
- "description: A probe agent.",
- "mode: subagent",
- "oac:",
- " id: probe",
- " name: Probe",
- " category: core",
- " type: subagent",
- "---",
- "body",
- "",
- ].join("\n");
- const { content } = await adapter().fromCanonical(source);
- expect(content).toContain("# A comment the author wrote.");
- });
- });
- // ============================================================================
- // Permissions
- // ============================================================================
- describe("OpenCodeAdapter permissions", () => {
- const planner = readFileSync(
- repoPath("packages/compatibility-layer/tests/golden/fixtures/fixture-planner.md"),
- "utf-8"
- );
- it("serializes ordered rules back to a YAML mapping in original author order", async () => {
- const { content } = await adapter().fromCanonical(planner);
- const permission = frontmatterOf(content)["permission"] as Record<
- string,
- Record<string, string>
- >;
- // Capability order, then rule order within `bash`. Both are semantic: OpenCode flattens
- // the map and resolves last-match-wins, so a reordering here silently changes what the
- // agent may run.
- expect(Object.keys(permission)).toEqual(["read", "bash", "edit", "write"]);
- expect(Object.keys(permission["bash"]!)).toEqual(["*", "git status", "git log*"]);
- });
- it("keeps a deny-all-then-allowlist resolving exactly as authored", async () => {
- const { content } = await adapter().fromCanonical(planner);
- const rules = desugarPermission(frontmatterOf(content)["permission"]);
- const bash = rules.find((entry) => entry.capability === "bash");
- // The catch-all deny comes FIRST and the allows come after it. Reverse them and
- // `git status` resolves to deny; drop the allows and the agent is bricked.
- expect(bash?.rules).toEqual([
- { pattern: "*", action: "deny" },
- { pattern: "git status", action: "allow" },
- { pattern: "git log*", action: "allow" },
- ]);
- });
- it("never widens a deny-all bash block to `bash: true`", async () => {
- // Guards the specific live hazard: `PermissionMapper.mapPermissionsFromOAC` defaults to a
- // "permissive" strategy whose record branch returns `hasAllow || !hasDeny`, which answers
- // `true` for CoderAgent's deny-all-then-allowlist. If that mapper ever reaches this build
- // path, this test is what catches it.
- const { content } = await adapter().fromCanonical(
- readFileSync(repoPath("content/agents/subagents/code/coder-agent.md"), "utf-8")
- );
- const permission = frontmatterOf(content)["permission"] as Record<string, unknown>;
- expect(permission["bash"]).not.toBe(true);
- expect((permission["bash"] as Record<string, string>)["*"]).toBe("deny");
- });
- it("preserves the `skill` capability, which a closed vocabulary would drop", async () => {
- // ExternalScout denies all skills then re-allows context7. `skill` is absent from doc 02
- // §1.2.7's closed vocabulary but IS a real OpenCode permission key (config.ts:575), so a
- // parser built to that enum would drop this block and silently grant every skill.
- const { content } = await adapter().fromCanonical(
- readFileSync(repoPath("content/agents/subagents/core/externalscout.md"), "utf-8")
- );
- const permission = frontmatterOf(content)["permission"] as Record<
- string,
- Record<string, string>
- >;
- expect(permission).toHaveProperty("skill");
- expect(Object.keys(permission["skill"]!)).toEqual(["*", "*context7*"]);
- expect(permission["skill"]!["*"]).toBe("deny");
- });
- });
- // ============================================================================
- // Round-trip against the live corpus
- // ============================================================================
- /**
- * The obsolete header that 23 committed `.opencode/agent` files still carry — a comment
- * pointing at the very sidecar this refactor dissolves.
- *
- * The seeding commit (`cf97d98`) dropped it from 9 of those 23 and kept it in the other 14.
- * That inconsistency is a seeding artifact, not an adapter behaviour: the comment is absent
- * from those 9 canonical sources, so no emitter can reproduce it.
- *
- * It is pinned here rather than papered over. The adapter is NOT special-cased to re-insert
- * it — doing so would be forging bytes the source does not contain. Instead the 9 are named,
- * so the drift is visible, cannot grow silently, and stays owned by whoever re-seeds
- * `content/agents` (subtask 09). Resolving it means either restoring the comment in those 9
- * sources or removing it from all 23 — a content decision, made once, deliberately.
- */
- const SIDECAR_COMMENT =
- [
- "# OpenCode Agent Configuration",
- "# Metadata (id, name, category, type, version, author, tags, dependencies) is stored in:",
- "# .opencode/config/agent-metadata.json",
- "",
- ].join("\n") + "\n";
- /** The 9 sources whose committed output still carries {@link SIDECAR_COMMENT}. */
- const DRIFTED: ReadonlySet<string> = new Set([
- "content/copywriter.md",
- "content/technical-writer.md",
- "meta/system-builder.md",
- "subagents/planning/story-mapper.md",
- "subagents/system-builder/agent-generator.md",
- "subagents/system-builder/command-creator.md",
- "subagents/system-builder/domain-analyzer.md",
- "subagents/test/simple-responder.md",
- "subagents/utils/image-specialist.md",
- ]);
- describe("OpenCodeAdapter round-trip", () => {
- const sources = listFiles(CONTENT_ROOT);
- it("finds the canonical corpus", () => {
- expect(sources.length).toBeGreaterThan(0);
- });
- it.each(sources.map((file) => [contentRelative(file), file] as const))(
- "reproduces .opencode/agent/%s byte-for-byte",
- async (rel, file) => {
- const { content } = await adapter().fromCanonical(readFileSync(file, "utf-8"), {
- filePath: file,
- });
- const committed = readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8");
- // The drifted 9 must differ ONLY by the obsolete comment — re-inserting it recovers the
- // committed bytes exactly. Any other difference in them still fails here.
- const expected = DRIFTED.has(rel)
- ? committed.replace(`---\n${SIDECAR_COMMENT}`, "---\n")
- : committed;
- expect(content).toBe(expected);
- }
- );
- it("pins the drifted set, so it cannot grow unnoticed", async () => {
- const drifted: string[] = [];
- for (const file of sources) {
- const rel = contentRelative(file);
- const { content } = await adapter().fromCanonical(readFileSync(file, "utf-8"));
- if (content !== readFileSync(`${OPENCODE_ROOT}/${rel}`, "utf-8")) drifted.push(rel);
- }
- expect(drifted.sort()).toEqual([...DRIFTED].sort());
- });
- it("maps each source onto its committed output path", () => {
- for (const file of sources) {
- const rel = contentRelative(file);
- expect(adapter().outputPath(rel)).toBe(`.opencode/agent/${rel}`);
- }
- });
- it("derives the output path from the file, not from oac.category + oac.id", () => {
- // `content/agents/subagents/code/test-engineer.md` declares `id: tester`. Composing the
- // path from the id would emit `subagents/code/tester.md` and orphan the `test-engineer.md`
- // OpenCode actually loads.
- expect(adapter().outputPath("subagents/code/test-engineer.md")).toBe(
- ".opencode/agent/subagents/code/test-engineer.md"
- );
- });
- });
- // ============================================================================
- // Determinism
- // ============================================================================
- describe("OpenCodeAdapter determinism", () => {
- it("emits byte-identical output across runs and instances", async () => {
- const source = readFileSync(repoPath("content/agents/core/openagent.md"), "utf-8");
- const first = await adapter().fromCanonical(source);
- const second = await adapter().fromCanonical(source);
- expect(second.content).toBe(first.content);
- });
- it("emits no timestamp or absolute host path", async () => {
- const { content } = await adapter().fromCanonical(
- readFileSync(repoPath("content/agents/core/openagent.md"), "utf-8"),
- { filePath: "/Users/someone/content/agents/core/openagent.md" }
- );
- // filePath is a diagnostic only — it must never leak into emitted bytes, or the output
- // would depend on where the repo is checked out.
- expect(content).not.toMatch(/\/Users\//);
- expect(content).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
- });
- });
- // ============================================================================
- // Rejection
- // ============================================================================
- describe("OpenCodeAdapter rejection", () => {
- it("rejects a file with no oac: block", async () => {
- const source = ["---", "name: Probe", "description: d", "mode: subagent", "---", "b", ""].join(
- "\n"
- );
- await expect(adapter().fromCanonical(source)).rejects.toThrow(OpenCodeEmitError);
- await expect(adapter().fromCanonical(source)).rejects.toThrow(/oac:/);
- });
- it("rejects a file with no frontmatter", async () => {
- await expect(adapter().fromCanonical("# Just a document\n")).rejects.toThrow(
- /frontmatter/
- );
- });
- it("rejects unterminated frontmatter", async () => {
- await expect(adapter().fromCanonical("---\nname: Probe\n")).rejects.toThrow(
- /unterminated/i
- );
- });
- it("rejects an integer-like permission scope that ECMAScript would reorder", async () => {
- const source = [
- "---",
- "name: Probe",
- "description: A probe agent.",
- "mode: subagent",
- "permission:",
- " bash:",
- ' "*": "deny"',
- ' "8080": "allow"',
- "oac:",
- " id: probe",
- " name: Probe",
- " category: core",
- " type: subagent",
- "---",
- "body",
- "",
- ].join("\n");
- // An integer-like key is hoisted to the front of the object by ECMAScript, which would
- // silently invert last-match-wins precedence. Rejecting beats emitting a reordered file.
- await expect(adapter().fromCanonical(source)).rejects.toThrow(/integer-like/);
- });
- it("names the file it rejected", async () => {
- await expect(
- adapter().fromCanonical("# no frontmatter\n", { filePath: "content/agents/broken.md" })
- ).rejects.toThrow(/content\/agents\/broken\.md/);
- });
- it("refuses fromOAC() rather than reordering an unordered permission map", async () => {
- const result = await adapter().fromOAC({
- frontmatter: { name: "Probe", description: "d", mode: "subagent" },
- metadata: { tags: [], dependencies: [] },
- systemPrompt: "body",
- contexts: [],
- });
- expect(result.success).toBe(false);
- expect(result.errors?.[0]).toMatch(/fromCanonical/);
- });
- });
|