diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 9212c6fcf05..7779870c00d 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -150,7 +150,65 @@ describe("loadPluginManifestRegistry", () => { }), ]; - expect(countDuplicateWarnings(loadRegistry(candidates))).toBe(1); + const registry = loadRegistry(candidates); + expect(countDuplicateWarnings(registry)).toBe(1); + // Only one record should be kept to prevent Gateway instability + expect(registry.plugins.filter((p) => p.id === "test-plugin")).toHaveLength(1); + }); + + it("keeps higher-precedence origin when genuine duplicate is detected from different paths", () => { + const bundledDir = makeTempDir(); + const globalDir = makeTempDir(); + const manifest = { id: "feishu-dup", configSchema: { type: "object" } }; + writeManifest(bundledDir, manifest); + writeManifest(globalDir, manifest); + + // Bundled discovered first, then global (npm-installed copy) + const candidates: PluginCandidate[] = [ + createPluginCandidate({ + idHint: "feishu-dup", + rootDir: bundledDir, + origin: "bundled", + }), + createPluginCandidate({ + idHint: "feishu-dup", + rootDir: globalDir, + origin: "global", + }), + ]; + + const registry = loadRegistry(candidates); + expect(countDuplicateWarnings(registry)).toBe(1); + expect(registry.plugins.filter((p) => p.id === "feishu-dup")).toHaveLength(1); + // Global has higher precedence than bundled (config > workspace > global > bundled) + expect(registry.plugins[0]?.origin).toBe("global"); + }); + + it("keeps existing record when genuine duplicate has lower precedence", () => { + const configDir = makeTempDir(); + const globalDir = makeTempDir(); + const manifest = { id: "lower-prec", configSchema: { type: "object" } }; + writeManifest(configDir, manifest); + writeManifest(globalDir, manifest); + + const candidates: PluginCandidate[] = [ + createPluginCandidate({ + idHint: "lower-prec", + rootDir: configDir, + origin: "config", + }), + createPluginCandidate({ + idHint: "lower-prec", + rootDir: globalDir, + origin: "global", + }), + ]; + + const registry = loadRegistry(candidates); + expect(countDuplicateWarnings(registry)).toBe(1); + expect(registry.plugins.filter((p) => p.id === "lower-prec")).toHaveLength(1); + // Config has higher precedence, should be kept + expect(registry.plugins[0]?.origin).toBe("config"); }); it("suppresses duplicate warning when candidates share the same physical directory via symlink", () => { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index d392144f925..db91697c23a 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -233,8 +233,22 @@ export function loadPluginManifestRegistry(params: { level: "warn", pluginId: manifest.id, source: candidate.source, - message: `duplicate plugin id detected; later plugin may be overridden (${candidate.source})`, + message: `duplicate plugin id detected; skipping duplicate from ${candidate.source}`, }); + // Genuine duplicate from a different physical path: apply the same + // precedence logic as same-path duplicates to avoid registering two + // records with the same plugin id (which causes Gateway instability). + if (PLUGIN_ORIGIN_RANK[candidate.origin] < PLUGIN_ORIGIN_RANK[existing.candidate.origin]) { + records[existing.recordIndex] = buildRecord({ + manifest, + candidate, + manifestPath: manifestRes.manifestPath, + schemaCacheKey, + configSchema, + }); + seenIds.set(manifest.id, { candidate, recordIndex: existing.recordIndex }); + } + continue; } else { seenIds.set(manifest.id, { candidate, recordIndex: records.length }); }