capability-agreement.test.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
  1. /**
  2. * Cross-adapter capability agreement.
  3. *
  4. * ## Why this file exists
  5. *
  6. * A platform cannot have two answers about itself. That invariant was already written down —
  7. * `tests/unit/adapters/ClaudeAdapter.test.ts` has an "agrees with the CapabilityMatrix rather
  8. * than restating it" case, added after the matrix called Claude `json` while ClaudeAdapter
  9. * called it `markdown`.
  10. *
  11. * But that test pinned ONE field (`configFormat`) on ONE platform (`claude`) — 1 of 11 fields
  12. * on 1 of 4 adapters. So when `CursorAdapter.getCapabilities()` hand-wrote
  13. * `supportsContexts: true` ("✅ Can inline context") against the matrix's
  14. * `externalContext: "none"` ("context must be inline in .cursorrules"), the same bug lived on
  15. * undetected in a second platform.
  16. *
  17. * The invariant was right; its coverage was the problem. This file generalises it to every
  18. * registered adapter × every field of {@link ToolCapabilities}, which is what the original
  19. * test's own comment claimed to be checking.
  20. *
  21. * `notes` and `displayName` are deliberately excluded: adapters own those (the matrix's `notes`
  22. * are per-(feature, platform) strings, a different shape and a different question), and each
  23. * adapter's `getCapabilities()` overrides `displayName` on purpose.
  24. */
  25. import { describe, it, expect } from "vitest";
  26. import { ClaudeAdapter } from "../../../src/adapters/ClaudeAdapter.js";
  27. import { CursorAdapter } from "../../../src/adapters/CursorAdapter.js";
  28. import { OpenCodeAdapter } from "../../../src/adapters/OpenCodeAdapter.js";
  29. import { WindsurfAdapter } from "../../../src/adapters/WindsurfAdapter.js";
  30. import { getToolCapabilities, type Platform } from "../../../src/core/CapabilityMatrix.js";
  31. import type { BaseAdapter } from "../../../src/adapters/BaseAdapter.js";
  32. import type { ToolCapabilities } from "../../../src/types.js";
  33. /**
  34. * Every adapter, paired with its matrix column.
  35. *
  36. * Adding an adapter without adding it here is the failure mode this file cannot catch on its
  37. * own — `covers every adapter in the Platform union` below closes that by comparing this list
  38. * against the union's own members.
  39. */
  40. const ADAPTERS: ReadonlyArray<{ platform: Exclude<Platform, "oac">; adapter: BaseAdapter }> = [
  41. { platform: "claude", adapter: new ClaudeAdapter() },
  42. { platform: "cursor", adapter: new CursorAdapter() },
  43. { platform: "windsurf", adapter: new WindsurfAdapter() },
  44. { platform: "opencode", adapter: new OpenCodeAdapter() },
  45. ];
  46. /** The fields both sides answer. `notes`/`displayName` are adapter-owned — see the header. */
  47. const AGREED_FIELDS = [
  48. "name",
  49. "supportsMultipleAgents",
  50. "supportsSkills",
  51. "supportsHooks",
  52. "supportsGranularPermissions",
  53. "supportsContexts",
  54. "supportsCustomModels",
  55. "supportsTemperature",
  56. "supportsMaxSteps",
  57. "configFormat",
  58. "outputStructure",
  59. ] as const satisfies ReadonlyArray<keyof ToolCapabilities>;
  60. describe("capability agreement (adapter vs CapabilityMatrix)", () => {
  61. describe.each(ADAPTERS)("$platform", ({ platform, adapter }) => {
  62. it.each(AGREED_FIELDS)("agrees on %s", (field) => {
  63. expect(adapter.getCapabilities()[field]).toEqual(getToolCapabilities(platform)[field]);
  64. });
  65. });
  66. // Guards the gap this file would otherwise have: a new adapter that never gets listed in
  67. // ADAPTERS is silently unchecked. The Platform union is the roster of record.
  68. it("covers every adapter in the Platform union", () => {
  69. const platformsUnderTest = ADAPTERS.map((entry) => entry.platform).sort();
  70. const expected: Array<Exclude<Platform, "oac">> = [
  71. "claude",
  72. "cursor",
  73. "opencode",
  74. "windsurf",
  75. ];
  76. expect(platformsUnderTest).toEqual(expected.sort());
  77. });
  78. // The specific regression. Cursor claimed to support external context because it can inline
  79. // the bytes; the matrix said none, because inlining loses the reference, the file boundary
  80. // and the priority. Ruled 2026-07-15 for the matrix. Pinned so it cannot silently flip back.
  81. it("reports Cursor as NOT supporting external context (inlining is degradation)", () => {
  82. expect(new CursorAdapter().getCapabilities().supportsContexts).toBe(false);
  83. });
  84. // OpenCode had no column at all until 2026-07-15, which is why it hand-wrote its own answers.
  85. it("describes OpenCode, the canonical target, in the matrix", () => {
  86. const openCode = getToolCapabilities("opencode");
  87. expect(openCode.displayName).toBe("OpenCode");
  88. // The one thing that makes OpenCode the canonical target: it carries ordered, scoped
  89. // permission rules with no degradation. If this ever reports false, the build is lying.
  90. expect(openCode.supportsGranularPermissions).toBe(true);
  91. });
  92. });