command.test.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Effect, PubSub, Schema, Stream } from "effect"
  5. import { Config as ConfigSchema } from "@opencode-ai/schema/config"
  6. import { CommandV2 } from "@opencode-ai/core/command"
  7. import { AgentV2 } from "@opencode-ai/core/agent"
  8. import { Config } from "@opencode-ai/core/config"
  9. import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
  10. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  11. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  12. import { FSUtil } from "@opencode-ai/util/fs-util"
  13. import { EventV2 } from "@opencode-ai/core/event"
  14. import { Location } from "@opencode-ai/core/location"
  15. import { MCP } from "@opencode-ai/core/mcp/index"
  16. import { ModelV2 } from "@opencode-ai/core/model"
  17. import { ProviderV2 } from "@opencode-ai/core/provider"
  18. import { AbsolutePath } from "@opencode-ai/core/schema"
  19. import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
  20. import { tmpdir } from "../fixture/tmpdir"
  21. import { testEffect } from "../lib/effect"
  22. import { host } from "../plugin/host"
  23. const it = testEffect(
  24. AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
  25. [MCP.node, emptyMcpLayer],
  26. [Config.node, emptyConfigLayer],
  27. [Location.node, testLocationLayer],
  28. ]),
  29. )
  30. const decode = Schema.decodeUnknownSync(Config.Info)
  31. describe("ConfigCommandPlugin.Plugin", () => {
  32. it.live("loads inline and file-based commands in config order", () =>
  33. Effect.acquireRelease(
  34. Effect.promise(() => tmpdir()),
  35. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  36. ).pipe(
  37. Effect.flatMap((tmp) =>
  38. Effect.gen(function* () {
  39. yield* Effect.promise(async () => {
  40. await fs.mkdir(path.join(tmp.path, "commands", "nested"), { recursive: true })
  41. await fs.writeFile(
  42. path.join(tmp.path, "commands", "review.md"),
  43. `---
  44. description: File review
  45. agent: reviewer
  46. model: anthropic/claude#high
  47. subtask: true
  48. ---
  49. Review files`,
  50. )
  51. await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs")
  52. await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "")
  53. })
  54. const command = yield* CommandV2.Service
  55. const events = yield* EventV2.Service
  56. const update = yield* events.publish(ConfigSchema.Event.Updated, {})
  57. const updates = yield* PubSub.unbounded<typeof update>()
  58. yield* ConfigCommandPlugin.Plugin.effect(
  59. host({
  60. command: {
  61. list: () => Effect.die("unused command.list"),
  62. transform: command.transform,
  63. reload: command.reload,
  64. },
  65. event: { subscribe: () => Stream.fromPubSub(updates) },
  66. }),
  67. ).pipe(
  68. Effect.provideService(
  69. Config.Service,
  70. Config.Service.of({
  71. entries: () =>
  72. Effect.succeed([
  73. new Config.Document({
  74. type: "document",
  75. info: decode({ commands: { review: { template: "Inline review" } } }),
  76. }),
  77. new Config.Directory({ type: "directory", path: AbsolutePath.make(tmp.path) }),
  78. ]),
  79. }),
  80. ),
  81. )
  82. expect(yield* command.list()).toEqual([
  83. CommandV2.Info.make({
  84. name: "review",
  85. template: "Review files",
  86. description: "File review",
  87. agent: AgentV2.ID.make("reviewer"),
  88. model: {
  89. providerID: ProviderV2.ID.make("anthropic"),
  90. id: ModelV2.ID.make("claude"),
  91. variant: ModelV2.VariantID.make("high"),
  92. },
  93. subtask: true,
  94. }),
  95. CommandV2.Info.make({ name: "empty", template: "" }),
  96. CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
  97. ])
  98. yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
  99. yield* Effect.sleep("10 millis")
  100. yield* PubSub.publish(updates, update)
  101. for (let attempt = 0; attempt < 100; attempt++) {
  102. if ((yield* command.get("review"))?.template === "Review again") break
  103. yield* Effect.sleep("10 millis")
  104. }
  105. expect((yield* command.get("review"))?.template).toBe("Review again")
  106. }),
  107. ),
  108. ),
  109. )
  110. })