Просмотр исходного кода

fix: merge plugin selections (resolves #4565) (#4724)

Co-authored-by: Dax Raad <[email protected]>
Co-authored-by: GitHub Action <[email protected]>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <[email protected]>
Ariane Emory 4 месяцев назад
Родитель
Сommit
4273fa9ccf
2 измененных файлов с 114 добавлено и 5 удалено
  1. 16 5
      packages/opencode/src/config/config.ts
  2. 98 0
      packages/opencode/test/config/config.test.ts

+ 16 - 5
packages/opencode/src/config/config.ts

@@ -21,25 +21,36 @@ import { ConfigMarkdown } from "./markdown"
 export namespace Config {
   const log = Log.create({ service: "config" })
 
+  // Custom merge function that concatenates plugin arrays instead of replacing them
+  function mergeConfigWithPlugins(target: Info, source: Info): Info {
+    const merged = mergeDeep(target, source)
+    // If both configs have plugin arrays, concatenate them instead of replacing
+    if (target.plugin && source.plugin) {
+      const pluginSet = new Set([...target.plugin, ...source.plugin])
+      merged.plugin = Array.from(pluginSet)
+    }
+    return merged
+  }
+
   export const state = Instance.state(async () => {
     const auth = await Auth.all()
     let result = await global()
 
     // Override with custom config if provided
     if (Flag.OPENCODE_CONFIG) {
-      result = mergeDeep(result, await loadFile(Flag.OPENCODE_CONFIG))
+      result = mergeConfigWithPlugins(result, await loadFile(Flag.OPENCODE_CONFIG))
       log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
     }
 
     for (const file of ["opencode.jsonc", "opencode.json"]) {
       const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
       for (const resolved of found.toReversed()) {
-        result = mergeDeep(result, await loadFile(resolved))
+        result = mergeConfigWithPlugins(result, await loadFile(resolved))
       }
     }
 
     if (Flag.OPENCODE_CONFIG_CONTENT) {
-      result = mergeDeep(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
+      result = mergeConfigWithPlugins(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
       log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
     }
 
@@ -47,7 +58,7 @@ export namespace Config {
       if (value.type === "wellknown") {
         process.env[value.key] = value.token
         const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
-        result = mergeDeep(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
+        result = mergeConfigWithPlugins(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
       }
     }
 
@@ -78,7 +89,7 @@ export namespace Config {
       if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
         for (const file of ["opencode.jsonc", "opencode.json"]) {
           log.debug(`loading config from ${path.join(dir, file)}`)
-          result = mergeDeep(result, await loadFile(path.join(dir, file)))
+          result = mergeConfigWithPlugins(result, await loadFile(path.join(dir, file)))
           // to satisy the type checker
           result.agent ??= {}
           result.mode ??= {}

+ 98 - 0
packages/opencode/test/config/config.test.ts

@@ -403,3 +403,101 @@ test("resolves scoped npm plugins in config", async () => {
     },
   })
 })
+
+test("merges plugin arrays from global and local configs", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      // Create a nested project structure with local .opencode config
+      const projectDir = path.join(dir, "project")
+      const opencodeDir = path.join(projectDir, ".opencode")
+      await fs.mkdir(opencodeDir, { recursive: true })
+
+      // Global config with plugins
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          plugin: ["global-plugin-1", "global-plugin-2"],
+        }),
+      )
+
+      // Local .opencode config with different plugins
+      await Bun.write(
+        path.join(opencodeDir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          plugin: ["local-plugin-1"],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: path.join(tmp.path, "project"),
+    fn: async () => {
+      const config = await Config.get()
+      const plugins = config.plugin ?? []
+
+      // Should contain both global and local plugins
+      expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
+      expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
+      expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
+
+      // Should have all 3 plugins (not replaced, but merged)
+      const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
+      expect(pluginNames.length).toBeGreaterThanOrEqual(3)
+    },
+  })
+})
+
+test("deduplicates duplicate plugins from global and local configs", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      // Create a nested project structure with local .opencode config
+      const projectDir = path.join(dir, "project")
+      const opencodeDir = path.join(projectDir, ".opencode")
+      await fs.mkdir(opencodeDir, { recursive: true })
+
+      // Global config with plugins
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          plugin: ["duplicate-plugin", "global-plugin-1"],
+        }),
+      )
+
+      // Local .opencode config with some overlapping plugins
+      await Bun.write(
+        path.join(opencodeDir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          plugin: ["duplicate-plugin", "local-plugin-1"],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: path.join(tmp.path, "project"),
+    fn: async () => {
+      const config = await Config.get()
+      const plugins = config.plugin ?? []
+
+      // Should contain all unique plugins
+      expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
+      expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
+      expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
+
+      // Should deduplicate the duplicate plugin
+      const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
+      expect(duplicatePlugins.length).toBe(1)
+
+      // Should have exactly 3 unique plugins
+      const pluginNames = plugins.filter(
+        (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
+      )
+      expect(pluginNames.length).toBe(3)
+    },
+  })
+})