RegistryEmitter.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. /**
  2. * Registry emission — `registry.json` is GENERATED from `content/agents/**`, not hand-edited.
  3. *
  4. * ─── What is actually at stake ──────────────────────────────────────────────────────────
  5. *
  6. * `install.sh` reads `registry.json` and is the only shipping install path. Every assertion
  7. * here is really a statement about whether a real user can install this repo, which is why
  8. * this suite leans on the LIVE corpus rather than fixtures alone: a fixture tree cannot tell
  9. * us that the thing we ship is intact.
  10. *
  11. * Three properties matter, in this order:
  12. *
  13. * 1. Nothing that install.sh reads is lost (carry-through, aliases, eval-runner).
  14. * 2. The output is byte-stable and a fixed point over itself, or the subtask-11
  15. * `oac build && git diff --exit-code` gate is a coin flip.
  16. * 3. The generated entries actually come from the canonical files.
  17. *
  18. * ─── Two snapshots of known defects live here ───────────────────────────────────────────
  19. *
  20. * {@link MISSING_FROM_REGISTRY} and {@link REGISTRY_ONLY_DEPENDENCIES} pin bugs, not
  21. * invariants — same pattern as `KNOWN_DEAD` in `reference-resolution.test.ts`. When a subtask
  22. * repairs one, the test turns red and forces a deliberate edit here. That is the point: the
  23. * drift these record is exactly the kind that accumulated silently for months because the only
  24. * validator in the repo could not see it.
  25. */
  26. import { describe, it, expect } from "vitest";
  27. import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "node:fs";
  28. import { tmpdir } from "node:os";
  29. import { join } from "node:path";
  30. import {
  31. RegistryEmitter,
  32. emitRegistry,
  33. serializeRegistry,
  34. entryForAgent,
  35. type RegistryDocument,
  36. type RegistryEntry,
  37. } from "../../../src/core/RegistryEmitter.js";
  38. import { generatedPaths } from "../../../src/core/BuildManifest.js";
  39. import { CanonicalAgentLoader } from "../../../src/core/AgentLoader.js";
  40. import { repoPath } from "../../support/pending.js";
  41. const COMMITTED_JSON = readFileSync(repoPath("registry.json"), "utf-8");
  42. const COMMITTED = JSON.parse(COMMITTED_JSON) as RegistryDocument;
  43. /** Categories phase 1 generates. Everything else is carried through verbatim. */
  44. const GENERATED_CATEGORIES = ["agents", "subagents"] as const;
  45. /** Anything that would make two runs differ. */
  46. const NONDETERMINISM = [
  47. { name: "an ISO timestamp", pattern: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ },
  48. { name: "a 'generated at' stamp", pattern: /generated (at|on)[:\s]/i },
  49. { name: "an absolute home path", pattern: /\/(Users|home)\/[^/\s"]+/ },
  50. ];
  51. /**
  52. * Agents that ship on disk, are referenced, and were ABSENT from the registry until subtask 10
  53. * generated it.
  54. *
  55. * Verified on disk 2026-07-15. `batch-executor` is the load-bearing one: both `openagent` and
  56. * `opencoder` declare `subagent:batch-executor`, so the registry advertised a dependency on a
  57. * component it did not contain. The other six were in neither the registry nor
  58. * `.opencode/config/agent-metadata.json` — they were added to `.opencode/agent/` and nothing
  59. * ever noticed. `auto-detect-components.sh --dry-run` reported all 7 as "New Components".
  60. *
  61. * ─── Updated by subtask 10, deliberately ────────────────────────────────────────────────
  62. *
  63. * This list used to assert the committed registry OMITS these 7 — it pinned the bug. Subtask
  64. * 10 ran `oac build`, which emits `registry.json` in place, so all 7 are now registered and
  65. * that assertion inverted. Per this file's own contract ("when a subtask repairs one, the test
  66. * turns red and forces a deliberate edit here"), the edit is made rather than the test
  67. * relaxed: the list now guards that generation KEEPS registering them. It stops being a
  68. * snapshot of a loss and becomes a regression guard, exactly as
  69. * {@link REGISTRY_ONLY_DEPENDENCIES} did when subtask 09b backfilled the 13 edges.
  70. */
  71. const RECOVERED_BY_GENERATION = [
  72. "adr-manager",
  73. "architecture-analyzer",
  74. "batch-executor",
  75. "contract-manager",
  76. "prioritization-engine",
  77. "stage-orchestrator",
  78. "story-mapper",
  79. ] as const;
  80. /**
  81. * Dependency edges that were once authored ONLY in `registry.json` — no agent file and no
  82. * sidecar entry declared them, so generating from the canonical tree used to DROP them.
  83. *
  84. * That was a real regression, not a cleanup: `install.sh` resolves these transitively
  85. * (`resolve_dependencies`, install.sh:420), so installing `subagent:externalscout` pulls in
  86. * `skill:context7` and would have stopped doing so. They were hand-authored straight into
  87. * `registry.json` and never made it into any file, which is precisely the drift this refactor
  88. * exists to end.
  89. *
  90. * Subtask 09b backfilled all 13 into `oac.dependencies` in `content/agents/**`, in the exact
  91. * order `registry.json` declares them, so the emitter now reproduces every one. This list is
  92. * therefore no longer a snapshot of a loss — it is the guard that the backfill stays put. Each
  93. * edge was verified to resolve against the registry before being carried over; the sibling dead
  94. * ref `context:context-system/*` (profiles-only, expands to 0) was deliberately NOT introduced
  95. * here — see `tests/unit/build/reference-resolution.test.ts`.
  96. */
  97. const REGISTRY_ONLY_DEPENDENCIES: Readonly<Record<string, readonly string[]>> = {
  98. contextscout: [
  99. "command:check-context-deps",
  100. "context:registry-dependencies",
  101. "context:context-system",
  102. "context:mvi",
  103. "context:structure",
  104. "context:workflows",
  105. "subagent:externalscout",
  106. "context:root-navigation",
  107. "context:context-paths-config",
  108. ],
  109. externalscout: ["skill:context7", "context:context-system"],
  110. "image-specialist": ["tool:gemini"],
  111. "context-organizer": ["context:core/context-system/*"],
  112. };
  113. function entries(document: RegistryDocument, category: string): RegistryEntry[] {
  114. return document.components[category] ?? [];
  115. }
  116. function byId(document: RegistryDocument, category: string): Map<string, RegistryEntry> {
  117. return new Map(entries(document, category).map((entry) => [entry.id, entry]));
  118. }
  119. /** A throwaway repo root containing only a `registry.json`, for fixed-point testing. */
  120. function scratchRoot(registryJson: string): string {
  121. const root = mkdtempSync(join(tmpdir(), "oac-registry-"));
  122. writeFileSync(join(root, "registry.json"), registryJson);
  123. return root;
  124. }
  125. // ============================================================================
  126. // SERIALISATION — the diff must be semantic, never formatting
  127. // ============================================================================
  128. describe("serializeRegistry", () => {
  129. it("reproduces the committed registry.json byte-for-byte from its own parse", () => {
  130. // If this drifts, every generated-vs-committed diff drowns in whitespace and escaping
  131. // noise and the subtask-11 gate stops meaning anything. It also pins the non-ASCII
  132. // escaping: the committed file writes `—` and two emoji as escapes, which bare
  133. // `JSON.stringify` would emit as raw UTF-8.
  134. expect(serializeRegistry(COMMITTED)).toBe(COMMITTED_JSON);
  135. });
  136. it("escapes non-ASCII rather than emitting raw UTF-8", () => {
  137. const json = serializeRegistry({ components: {}, note: "em—dash ⛔" } as RegistryDocument);
  138. expect(json).toContain("em\\u2014dash \\u26d4");
  139. expect(json).not.toContain("—");
  140. });
  141. it("ends with exactly one trailing newline", () => {
  142. expect(serializeRegistry(COMMITTED).endsWith("}\n")).toBe(true);
  143. expect(serializeRegistry(COMMITTED).endsWith("}\n\n")).toBe(false);
  144. });
  145. });
  146. // ============================================================================
  147. // DETERMINISM
  148. // ============================================================================
  149. describe("determinism", () => {
  150. it("emits byte-identical output across two runs", async () => {
  151. expect(await emitRegistry(repoPath())).toBe(await emitRegistry(repoPath()));
  152. });
  153. it("emits nothing that varies between runs", async () => {
  154. const json = await emitRegistry(repoPath());
  155. for (const { name, pattern } of NONDETERMINISM) {
  156. expect(json, `generated registry.json contains ${name}`).not.toMatch(pattern);
  157. }
  158. });
  159. it("carries metadata.lastUpdated from the base instead of stamping the clock", async () => {
  160. // auto-detect-components.sh:872 did `now | strftime("%Y-%m-%d")`, which alone would make
  161. // every rebuild a diff and every `git diff --exit-code` gate fail on an unchanged tree.
  162. const document = await new RegistryEmitter(repoPath()).emit();
  163. expect(document.metadata).toEqual(COMMITTED.metadata);
  164. });
  165. it("sorts generated entries by id, not by filesystem or insertion order", async () => {
  166. const document = await new RegistryEmitter(repoPath()).emit();
  167. for (const category of GENERATED_CATEGORIES) {
  168. const ids = entries(document, category).map((entry) => entry.id);
  169. expect(ids, `${category} is not id-sorted`).toEqual([...ids].sort());
  170. }
  171. });
  172. it("is a fixed point over its own output", async () => {
  173. // Subtask 10 writes this output back to registry.json, and the emitter then reads that as
  174. // its own base. If emitting were not idempotent the tree would oscillate and the diff gate
  175. // would fail forever on a tree nobody touched.
  176. const first = await emitRegistry(repoPath());
  177. const root = scratchRoot(first);
  178. try {
  179. const second = await new RegistryEmitter(root, {
  180. contentRoot: repoPath("content/agents"),
  181. }).emitJson();
  182. expect(second).toBe(first);
  183. } finally {
  184. rmSync(root, { recursive: true, force: true });
  185. }
  186. });
  187. });
  188. // ============================================================================
  189. // CARRY-THROUGH — phase 1 owns agents only
  190. // ============================================================================
  191. describe("carry-through of everything the canonical tree does not own", () => {
  192. it("leaves non-agent component categories byte-identical", async () => {
  193. const document = await new RegistryEmitter(repoPath()).emit();
  194. const carried = Object.keys(COMMITTED.components).filter(
  195. (category) => !GENERATED_CATEGORIES.includes(category as (typeof GENERATED_CATEGORIES)[number])
  196. );
  197. expect(carried).toContain("contexts");
  198. for (const category of carried) {
  199. expect(entries(document, category), `${category} was modified`).toEqual(
  200. entries(COMMITTED, category)
  201. );
  202. }
  203. });
  204. it("leaves every non-components top-level key untouched, in order", async () => {
  205. const document = await new RegistryEmitter(repoPath()).emit();
  206. expect(Object.keys(document)).toEqual(Object.keys(COMMITTED));
  207. for (const key of Object.keys(COMMITTED).filter((k) => k !== "components")) {
  208. expect(document[key], `top-level "${key}" was modified`).toEqual(COMMITTED[key]);
  209. }
  210. });
  211. it("preserves the component category order", async () => {
  212. const document = await new RegistryEmitter(repoPath()).emit();
  213. expect(Object.keys(document.components)).toEqual(Object.keys(COMMITTED.components));
  214. });
  215. it("preserves context aliases", async () => {
  216. // install.sh resolve_dependencies matches `.id == $id or (.aliases | index($id))`
  217. // (install.sh:420). Dropping an alias silently uninstalls a component for anyone naming
  218. // it the aliased way.
  219. const document = await new RegistryEmitter(repoPath()).emit();
  220. const aliased = entries(document, "contexts").filter((entry) => entry.aliases !== undefined);
  221. expect(aliased.map((entry) => entry.id).sort()).toEqual([
  222. "component-planning",
  223. "feature-breakdown",
  224. "session-management",
  225. ]);
  226. });
  227. it("keeps registry profiles verbatim and never consults profile.json", async () => {
  228. // `.profiles` is what install.sh reads (get_profile_components, install.sh:292).
  229. // `.opencode/profiles/<name>/profile.json` has drifted from it on all 5 profiles and is
  230. // read only by check-dependencies.ts, which no install path invokes. Reconciling them is
  231. // a content decision; generating from the wrong one silently changes what users install.
  232. const document = await new RegistryEmitter(repoPath()).emit();
  233. expect(document.profiles).toEqual(COMMITTED.profiles);
  234. });
  235. it("leaves the dead advanced-profile wildcard exactly as authored", async () => {
  236. // `context:context-system/*` expands to zero registry paths. Repairing it to
  237. // `context:core/context-system/*` would change what `--profile advanced` installs, so it
  238. // stays a known-dead ref that ReferenceResolver reports rather than an emitter rewrite.
  239. const document = await new RegistryEmitter(repoPath()).emit();
  240. const advanced = document.profiles as Record<string, { components: string[] }>;
  241. expect(advanced.advanced?.components).toContain("context:context-system/*");
  242. });
  243. it("carries agents that have no canonical counterpart", async () => {
  244. // `agent:eval-runner` ships in `.opencode/agent/eval-runner.md` and is in the committed
  245. // registry, but `content/agents/` does not carry it yet. Generating agents purely from the
  246. // canonical tree would delete it and uninstall the eval harness.
  247. const document = await new RegistryEmitter(repoPath()).emit();
  248. expect(byId(document, "agents").get("eval-runner")).toEqual(
  249. byId(COMMITTED, "agents").get("eval-runner")
  250. );
  251. });
  252. it("never drops an id the committed registry publishes", async () => {
  253. const document = await new RegistryEmitter(repoPath()).emit();
  254. for (const category of Object.keys(COMMITTED.components)) {
  255. const generated = byId(document, category);
  256. const dropped = entries(COMMITTED, category)
  257. .map((entry) => entry.id)
  258. .filter((id) => !generated.has(id));
  259. expect(dropped, `${category} ids dropped by the emitter`).toEqual([]);
  260. }
  261. });
  262. });
  263. // ============================================================================
  264. // WITHDRAWAL — an entry we generated must not outlive its canonical source
  265. // ============================================================================
  266. /**
  267. * The other half of carry-through, and the harder half.
  268. *
  269. * Carrying every unclaimed base entry is what keeps `eval-runner` alive, but applied blindly it
  270. * recreates the defect this emitter exists to cure: delete `content/agents/x.md` and the build
  271. * removes `.opencode/agent/x.md` while leaving `x` in the registry forever. The output is a
  272. * stable fixed point, so `oac build && git diff --exit-code` stays green while being wrong —
  273. * the drift gate cannot see it.
  274. *
  275. * By id alone "never had a source" and "source was deleted" are identical. The discriminator is
  276. * `.oac/build-manifest.json`: an entry whose `path` the ledger claims was written by us.
  277. */
  278. describe("withdrawal of entries the build itself generated", () => {
  279. /** A path the ledger claims — i.e. one the build wrote on a previous run. */
  280. const GENERATED_PATH = ".opencode/agent/subagents/utils/demo-agent.md";
  281. /** `eval-runner`'s file: shipped, hand-authored, and never generated by us. */
  282. const EVAL_RUNNER_PATH = ".opencode/agent/eval-runner.md";
  283. /**
  284. * The committed registry plus an entry for an agent the build generated and whose canonical
  285. * source has since been deleted — the exact state a deletion build reads as its base.
  286. */
  287. function baseWithDeletedSource(): RegistryDocument {
  288. const document = JSON.parse(COMMITTED_JSON) as RegistryDocument;
  289. document.components.subagents = [
  290. ...entries(document, "subagents"),
  291. {
  292. id: "demo-agent",
  293. name: "Demo Agent",
  294. type: "subagent",
  295. path: GENERATED_PATH,
  296. version: "1.0.0",
  297. description: "Generated by a previous build; its canonical source is now gone.",
  298. tags: ["demo"],
  299. dependencies: [],
  300. category: "subagents/utils",
  301. },
  302. ];
  303. return document;
  304. }
  305. /** Emit against a scratch base and an explicit ledger, over the live canonical tree. */
  306. async function emitWith(
  307. base: RegistryDocument,
  308. ledger: ReadonlySet<string>
  309. ): Promise<RegistryDocument> {
  310. const root = scratchRoot(serializeRegistry(base));
  311. try {
  312. return await new RegistryEmitter(root, {
  313. contentRoot: repoPath("content/agents"),
  314. generatedPaths: ledger,
  315. }).emit();
  316. } finally {
  317. rmSync(root, { recursive: true, force: true });
  318. }
  319. }
  320. it("drops an entry whose path the ledger claims once its canonical source is gone", async () => {
  321. // The bug: `merge` carried this forever because `demo-agent` is simply "not in generated",
  322. // which is also true of eval-runner. The ledger is what tells them apart.
  323. const document = await emitWith(baseWithDeletedSource(), new Set([GENERATED_PATH]));
  324. expect(byId(document, "subagents").has("demo-agent")).toBe(false);
  325. });
  326. it("keeps eval-runner while withdrawing a generated entry in the same merge", async () => {
  327. // The regression that matters most: a fix that drops the deleted agent by also dropping
  328. // every sourceless entry uninstalls the eval harness. Both must hold at once.
  329. const document = await emitWith(baseWithDeletedSource(), new Set([GENERATED_PATH]));
  330. expect(byId(document, "subagents").has("demo-agent")).toBe(false);
  331. expect(byId(document, "agents").get("eval-runner")).toEqual(
  332. byId(COMMITTED, "agents").get("eval-runner")
  333. );
  334. });
  335. it("carries every entry when there is no ledger, so a first build deletes nothing", async () => {
  336. // Matches BuildPipeline's rule: no record of having written a file is not evidence that we
  337. // wrote it. A fresh clone has no manifest and must not withdraw anything.
  338. const document = await emitWith(baseWithDeletedSource(), new Set());
  339. expect(byId(document, "subagents").get("demo-agent")?.path).toBe(GENERATED_PATH);
  340. expect(byId(document, "agents").has("eval-runner")).toBe(true);
  341. });
  342. it("carries an entry the ledger does not claim, however the ledger is populated", async () => {
  343. // eval-runner survives against the REAL ledger, not just a hand-made one.
  344. const document = await emitWith(baseWithDeletedSource(), generatedPaths(repoPath()));
  345. expect(byId(document, "agents").get("eval-runner")).toEqual(
  346. byId(COMMITTED, "agents").get("eval-runner")
  347. );
  348. });
  349. it("has a ledger that claims every generated agent file and not eval-runner", async () => {
  350. // The discriminator is only real if the live ledger actually separates the two populations.
  351. // If a later subtask canonicalises eval-runner this turns red and forces a deliberate edit:
  352. // the entry would then be generated by id and no longer need carrying at all.
  353. const ledger = generatedPaths(repoPath());
  354. const claimed = (id: string): boolean =>
  355. ledger.has(byId(COMMITTED, "agents").get(id)?.path ?? "");
  356. expect(ledger.has(EVAL_RUNNER_PATH), "eval-runner is in the ledger").toBe(false);
  357. expect(claimed("openagent"), "a generated agent is missing from the ledger").toBe(true);
  358. for (const category of GENERATED_CATEGORIES) {
  359. const unclaimed = entries(COMMITTED, category)
  360. .filter((entry) => !ledger.has(entry.path ?? ""))
  361. .map((entry) => entry.id);
  362. expect(unclaimed, `${category} entries the ledger does not claim`).toEqual(
  363. category === "agents" ? ["eval-runner"] : []
  364. );
  365. }
  366. });
  367. });
  368. // ============================================================================
  369. // GENERATION — entries come from the canonical files
  370. // ============================================================================
  371. describe("generation from the canonical tree", () => {
  372. it("derives every field of an entry from the canonical file", async () => {
  373. const document = await new RegistryEmitter(repoPath()).emit();
  374. const agents = await new CanonicalAgentLoader(repoPath("content/agents")).loadFromDirectory();
  375. const tester = agents.find((agent) => agent.oac.id === "tester");
  376. expect(tester).toBeDefined();
  377. expect(byId(document, "subagents").get("tester")).toEqual({
  378. id: "tester",
  379. name: "TestEngineer",
  380. type: "subagent",
  381. // Identity is oac.id, never the filename: the file is `test-engineer.md` but the
  382. // registry, the profiles and every context doc reference `tester`.
  383. path: ".opencode/agent/subagents/code/test-engineer.md",
  384. version: "1.0.0",
  385. description: tester!.frontmatter.description,
  386. tags: ["testing", "tdd", "quality"],
  387. dependencies: ["context:standards-tests"],
  388. category: "subagents/code",
  389. });
  390. });
  391. it("emits entry keys in the committed field order", async () => {
  392. const document = await new RegistryEmitter(repoPath()).emit();
  393. for (const category of GENERATED_CATEGORIES) {
  394. for (const entry of entries(document, category)) {
  395. if (entry.id === "eval-runner") continue; // carried through, keeps its own shape
  396. expect(Object.keys(entry), `${entry.id} field order`).toEqual([
  397. "id",
  398. "name",
  399. "type",
  400. "path",
  401. "version",
  402. "description",
  403. "tags",
  404. "dependencies",
  405. "category",
  406. ]);
  407. }
  408. }
  409. });
  410. it("points path at the built install tree, not the canonical source", async () => {
  411. // install.sh copies `.path` verbatim. If it named `content/agents/**` the installer would
  412. // fetch files that do not exist in the published layout.
  413. const document = await new RegistryEmitter(repoPath()).emit();
  414. for (const category of GENERATED_CATEGORIES) {
  415. for (const entry of entries(document, category)) {
  416. expect(entry.path, `${entry.id} path`).toMatch(/^\.opencode\/agent\//);
  417. }
  418. }
  419. });
  420. it("registers every canonical agent", async () => {
  421. const document = await new RegistryEmitter(repoPath()).emit();
  422. const agents = await new CanonicalAgentLoader(repoPath("content/agents")).loadFromDirectory();
  423. const registered = new Set([
  424. ...byId(document, "agents").keys(),
  425. ...byId(document, "subagents").keys(),
  426. ]);
  427. const unregistered = agents.map((a) => a.oac.id).filter((id) => !registered.has(id));
  428. expect(unregistered, "canonical agents the emitter failed to register").toEqual([]);
  429. });
  430. it("files an agent under the components key its oac.type names", async () => {
  431. const document = await new RegistryEmitter(repoPath()).emit();
  432. for (const entry of entries(document, "agents")) expect(entry.type).toBe("agent");
  433. for (const entry of entries(document, "subagents")) expect(entry.type).toBe("subagent");
  434. });
  435. it("resolves a fixture tree independently of the live corpus", async () => {
  436. const agents = await new CanonicalAgentLoader(repoPath("content/agents")).loadFromDirectory();
  437. const openagent = agents.find((agent) => agent.oac.id === "openagent");
  438. expect(entryForAgent(openagent!, ".opencode/agent")).toMatchObject({
  439. id: "openagent",
  440. type: "agent",
  441. path: ".opencode/agent/core/openagent.md",
  442. category: "core",
  443. });
  444. expect(entryForAgent(openagent!, "custom/root").path).toBe("custom/root/core/openagent.md");
  445. });
  446. });
  447. // ============================================================================
  448. // DEFECTS THE EMITTER FIXES, AND ONE IT WOULD CAUSE
  449. // ============================================================================
  450. describe("registry defects", () => {
  451. it.each(RECOVERED_BY_GENERATION)("keeps %s registered, which only generation ever added", async (id) => {
  452. // Both sides are asserted on purpose. The generated document proves the emitter still
  453. // derives the entry from the canonical file; the committed one proves the emit actually
  454. // reached disk. Checking only the emitter would let `registry.json` silently regress to a
  455. // hand-edited copy that drops these 7 again — the exact failure this refactor ends.
  456. const document = await new RegistryEmitter(repoPath()).emit();
  457. expect(byId(document, "subagents").get(id)?.path).toContain(id);
  458. expect(byId(COMMITTED, "subagents").has(id), `${id} is missing from the committed registry`).toBe(
  459. true
  460. );
  461. });
  462. it("makes subagent:batch-executor resolvable for openagent and opencoder", async () => {
  463. // Both declare `subagent:batch-executor`. The hand-maintained registry contained no such
  464. // component, so install.sh resolved the dependency to nothing and silently installed an
  465. // orchestrator whose parallel executor was missing. Generation is what closed that.
  466. const document = await new RegistryEmitter(repoPath()).emit();
  467. const agents = byId(document, "agents");
  468. expect(agents.get("openagent")?.dependencies).toContain("subagent:batch-executor");
  469. expect(agents.get("opencoder")?.dependencies).toContain("subagent:batch-executor");
  470. expect(byId(document, "subagents").has("batch-executor")).toBe(true);
  471. });
  472. it("preserves the dependency edges that only registry.json used to declare", async () => {
  473. // These edges were hand-authored in registry.json and absent from both the agent files and
  474. // agent-metadata.json, so generating from the canonical tree used to lose them. Subtask 09b
  475. // backfilled them into `oac.dependencies`. This asserts the round trip: every edge the
  476. // committed registry declares is still declared by the generated one, so `install.sh`
  477. // resolves exactly what it resolves today.
  478. const document = await new RegistryEmitter(repoPath()).emit();
  479. const subagents = byId(document, "subagents");
  480. for (const [id, backfilled] of Object.entries(REGISTRY_ONLY_DEPENDENCIES)) {
  481. const before = new Set(byId(COMMITTED, "subagents").get(id)?.dependencies ?? []);
  482. const after = new Set(subagents.get(id)?.dependencies ?? []);
  483. for (const dependency of backfilled) {
  484. expect(before.has(dependency), `${id} -> ${dependency} vanished from registry.json`).toBe(
  485. true
  486. );
  487. expect(
  488. after.has(dependency),
  489. `${id} -> ${dependency} was dropped — backfill it into content/agents/, do not weaken this test`
  490. ).toBe(true);
  491. }
  492. }
  493. });
  494. it("emits the backfilled edges in the order registry.json declares them", async () => {
  495. // Order is not cosmetic: the emitter maps `oac.dependencies` positionally, and the
  496. // subtask-11 `oac build && git diff --exit-code` gate compares bytes. A reordered list is a
  497. // spurious diff, so the canonical files must carry registry.json's own order.
  498. const subagents = byId(await new RegistryEmitter(repoPath()).emit(), "subagents");
  499. for (const id of Object.keys(REGISTRY_ONLY_DEPENDENCIES)) {
  500. expect(subagents.get(id)?.dependencies, `${id} dependency order drifted`).toEqual(
  501. byId(COMMITTED, "subagents").get(id)?.dependencies
  502. );
  503. }
  504. });
  505. });