From 4058680d225535d25705aaf02644957c0ed7a096 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 20 Jan 2026 23:44:49 +0100 Subject: [PATCH] feat(coding-agent): progress callbacks, conflict detection, URL parsing, tests (#645) - Add progress callbacks to PackageManager for TUI status during install/remove/update - Add extension conflict detection (tools, commands, flags with same names) - Accept raw GitHub/GitLab URLs without git: prefix - Add tests for package-manager.ts and resource-loader.ts - Add empty fixture directories for skills tests --- .../coding-agent/src/core/package-manager.ts | 103 ++++++-- .../coding-agent/src/core/resource-loader.ts | 59 +++++ packages/coding-agent/src/index.ts | 2 + packages/coding-agent/src/main.ts | 17 +- .../test/fixtures/empty-agent/.gitkeep | 0 .../test/fixtures/empty-cwd/.gitkeep | 0 .../coding-agent/test/package-manager.test.ts | 161 ++++++++++++ .../coding-agent/test/resource-loader.test.ts | 231 ++++++++++++++++++ 8 files changed, 548 insertions(+), 25 deletions(-) create mode 100644 packages/coding-agent/test/fixtures/empty-agent/.gitkeep create mode 100644 packages/coding-agent/test/fixtures/empty-cwd/.gitkeep create mode 100644 packages/coding-agent/test/package-manager.test.ts create mode 100644 packages/coding-agent/test/resource-loader.test.ts diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 2bec5096..10072d02 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -15,6 +15,15 @@ export interface ResolvedPaths { export type MissingSourceAction = "install" | "skip" | "error"; +export interface ProgressEvent { + type: "start" | "progress" | "complete" | "error"; + action: "install" | "remove" | "update" | "clone" | "pull"; + source: string; + message?: string; +} + +export type ProgressCallback = (event: ProgressEvent) => void; + export interface PackageManager { resolve(onMissing?: (source: string) => Promise): Promise; install(source: string, options?: { local?: boolean }): Promise; @@ -24,6 +33,7 @@ export interface PackageManager { sources: string[], options?: { local?: boolean; temporary?: boolean }, ): Promise; + setProgressCallback(callback: ProgressCallback | undefined): void; } interface PackageManagerOptions { @@ -76,6 +86,7 @@ export class DefaultPackageManager implements PackageManager { private agentDir: string; private settingsManager: SettingsManager; private globalNpmRoot: string | undefined; + private progressCallback: ProgressCallback | undefined; constructor(options: PackageManagerOptions) { this.cwd = options.cwd; @@ -83,6 +94,14 @@ export class DefaultPackageManager implements PackageManager { this.settingsManager = options.settingsManager; } + setProgressCallback(callback: ProgressCallback | undefined): void { + this.progressCallback = callback; + } + + private emitProgress(event: ProgressEvent): void { + this.progressCallback?.(event); + } + async resolve(onMissing?: (source: string) => Promise): Promise { const accumulator = this.createAccumulator(); const globalSettings = this.settingsManager.getGlobalSettings(); @@ -134,29 +153,47 @@ export class DefaultPackageManager implements PackageManager { async install(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "global"; - if (parsed.type === "npm") { - await this.installNpm(parsed, scope, false); - return; + this.emitProgress({ type: "start", action: "install", source, message: `Installing ${source}...` }); + try { + if (parsed.type === "npm") { + await this.installNpm(parsed, scope, false); + this.emitProgress({ type: "complete", action: "install", source }); + return; + } + if (parsed.type === "git") { + await this.installGit(parsed, scope, false); + this.emitProgress({ type: "complete", action: "install", source }); + return; + } + throw new Error(`Unsupported install source: ${source}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emitProgress({ type: "error", action: "install", source, message }); + throw error; } - if (parsed.type === "git") { - await this.installGit(parsed, scope, false); - return; - } - throw new Error(`Unsupported install source: ${source}`); } async remove(source: string, options?: { local?: boolean }): Promise { const parsed = this.parseSource(source); const scope: SourceScope = options?.local ? "project" : "global"; - if (parsed.type === "npm") { - await this.uninstallNpm(parsed, scope); - return; + this.emitProgress({ type: "start", action: "remove", source, message: `Removing ${source}...` }); + try { + if (parsed.type === "npm") { + await this.uninstallNpm(parsed, scope); + this.emitProgress({ type: "complete", action: "remove", source }); + return; + } + if (parsed.type === "git") { + await this.removeGit(parsed, scope, false); + this.emitProgress({ type: "complete", action: "remove", source }); + return; + } + throw new Error(`Unsupported remove source: ${source}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emitProgress({ type: "error", action: "remove", source, message }); + throw error; } - if (parsed.type === "git") { - await this.removeGit(parsed, scope, false); - return; - } - throw new Error(`Unsupported remove source: ${source}`); } async update(source?: string): Promise { @@ -180,12 +217,28 @@ export class DefaultPackageManager implements PackageManager { const parsed = this.parseSource(source); if (parsed.type === "npm") { if (parsed.pinned) return; - await this.installNpm(parsed, scope, false); + this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` }); + try { + await this.installNpm(parsed, scope, false); + this.emitProgress({ type: "complete", action: "update", source }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emitProgress({ type: "error", action: "update", source, message }); + throw error; + } return; } if (parsed.type === "git") { if (parsed.pinned) return; - await this.updateGit(parsed, scope, false); + this.emitProgress({ type: "start", action: "update", source, message: `Updating ${source}...` }); + try { + await this.updateGit(parsed, scope, false); + this.emitProgress({ type: "complete", action: "update", source }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emitProgress({ type: "error", action: "update", source, message }); + throw error; + } return; } } @@ -281,10 +334,11 @@ export class DefaultPackageManager implements PackageManager { }; } - if (source.startsWith("git:")) { - const repoSpec = source.slice("git:".length).trim(); + // Accept git: prefix or raw URLs (https://github.com/..., github.com/...) + if (source.startsWith("git:") || this.looksLikeGitUrl(source)) { + const repoSpec = source.startsWith("git:") ? source.slice("git:".length).trim() : source; const [repo, ref] = repoSpec.split("@"); - const normalized = repo.replace(/^https?:\/\//, ""); + const normalized = repo.replace(/^https?:\/\//, "").replace(/\.git$/, ""); const parts = normalized.split("/"); const host = parts.shift() ?? ""; const repoPath = parts.join("/"); @@ -301,6 +355,13 @@ export class DefaultPackageManager implements PackageManager { return { type: "local", path: source }; } + private looksLikeGitUrl(source: string): boolean { + // Match URLs like https://github.com/..., github.com/..., gitlab.com/... + const gitHosts = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"]; + const normalized = source.replace(/^https?:\/\//, ""); + return gitHosts.some((host) => normalized.startsWith(`${host}/`)); + } + private parseNpmSpec(spec: string): { name: string; version?: string } { const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/); if (!match) { diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index a8be472b..11764621 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -280,6 +280,13 @@ export class DefaultResourceLoader implements ResourceLoader { const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.errors.push(...inlineExtensions.errors); + + // Detect extension conflicts (tools, commands, flags with same names from different extensions) + const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); + for (const conflict of conflicts) { + extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); + } + this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; const skillPaths = this.noSkills @@ -513,4 +520,56 @@ export class DefaultResourceLoader implements ResourceLoader { return undefined; } + + private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> { + const conflicts: Array<{ path: string; message: string }> = []; + + // Track which extension registered each tool, command, and flag + const toolOwners = new Map(); + const commandOwners = new Map(); + const flagOwners = new Map(); + + for (const ext of extensions) { + // Check tools + for (const toolName of ext.tools.keys()) { + const existingOwner = toolOwners.get(toolName); + if (existingOwner && existingOwner !== ext.path) { + conflicts.push({ + path: ext.path, + message: `Tool "${toolName}" conflicts with ${existingOwner}`, + }); + } else { + toolOwners.set(toolName, ext.path); + } + } + + // Check commands + for (const commandName of ext.commands.keys()) { + const existingOwner = commandOwners.get(commandName); + if (existingOwner && existingOwner !== ext.path) { + conflicts.push({ + path: ext.path, + message: `Command "/${commandName}" conflicts with ${existingOwner}`, + }); + } else { + commandOwners.set(commandName, ext.path); + } + } + + // Check flags + for (const flagName of ext.flags.keys()) { + const existingOwner = flagOwners.get(flagName); + if (existingOwner && existingOwner !== ext.path) { + conflicts.push({ + path: ext.path, + message: `Flag "--${flagName}" conflicts with ${existingOwner}`, + }); + } else { + flagOwners.set(flagName, ext.path); + } + } + } + + return conflicts; + } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7d4339de..ecf08a77 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -116,6 +116,8 @@ export { export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js"; export { convertToLlm } from "./core/messages.js"; export { ModelRegistry } from "./core/model-registry.js"; +export type { PackageManager, ProgressCallback, ProgressEvent } from "./core/package-manager.js"; +export { DefaultPackageManager } from "./core/package-manager.js"; export type { ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.js"; export { DefaultResourceLoader } from "./core/resource-loader.js"; // SDK for programmatic usage diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 6e75697f..4d8b4770 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -134,6 +134,15 @@ async function handlePackageCommand(args: string[]): Promise { const settingsManager = SettingsManager.create(cwd, agentDir); const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager }); + // Set up progress callback for CLI feedback + packageManager.setProgressCallback((event) => { + if (event.type === "start") { + process.stdout.write(chalk.dim(`${event.message}\n`)); + } else if (event.type === "error") { + console.error(chalk.red(`Error: ${event.message}`)); + } + }); + if (options.command === "install") { if (!options.source) { console.error(chalk.red("Missing install source.")); @@ -141,7 +150,7 @@ async function handlePackageCommand(args: string[]): Promise { } await packageManager.install(options.source, { local: options.local }); updateExtensionSources(settingsManager, options.source, options.local, "add"); - console.log(`Installed ${options.source}`); + console.log(chalk.green(`Installed ${options.source}`)); return true; } @@ -152,15 +161,15 @@ async function handlePackageCommand(args: string[]): Promise { } await packageManager.remove(options.source, { local: options.local }); updateExtensionSources(settingsManager, options.source, options.local, "remove"); - console.log(`Removed ${options.source}`); + console.log(chalk.green(`Removed ${options.source}`)); return true; } await packageManager.update(options.source); if (options.source) { - console.log(`Updated ${options.source}`); + console.log(chalk.green(`Updated ${options.source}`)); } else { - console.log("Updated extensions"); + console.log(chalk.green("Updated extensions")); } return true; } diff --git a/packages/coding-agent/test/fixtures/empty-agent/.gitkeep b/packages/coding-agent/test/fixtures/empty-agent/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/coding-agent/test/fixtures/empty-cwd/.gitkeep b/packages/coding-agent/test/fixtures/empty-cwd/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts new file mode 100644 index 00000000..4e5e7a9b --- /dev/null +++ b/packages/coding-agent/test/package-manager.test.ts @@ -0,0 +1,161 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DefaultPackageManager, type ProgressEvent } from "../src/core/package-manager.js"; +import { SettingsManager } from "../src/core/settings-manager.js"; + +describe("DefaultPackageManager", () => { + let tempDir: string; + let settingsManager: SettingsManager; + let packageManager: DefaultPackageManager; + + beforeEach(() => { + tempDir = join(tmpdir(), `pm-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + const agentDir = join(tempDir, "agent"); + mkdirSync(agentDir, { recursive: true }); + + settingsManager = SettingsManager.inMemory(); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("resolve", () => { + it("should return empty paths when no sources configured", async () => { + const result = await packageManager.resolve(); + expect(result.extensions).toEqual([]); + expect(result.skills).toEqual([]); + expect(result.prompts).toEqual([]); + expect(result.themes).toEqual([]); + }); + + it("should resolve local extension paths from settings", async () => { + const extPath = join(tempDir, "my-extension.ts"); + writeFileSync(extPath, "export default function() {}"); + settingsManager.setExtensionPaths([extPath]); + + const result = await packageManager.resolve(); + expect(result.extensions).toContain(extPath); + }); + + it("should resolve skill paths from settings", async () => { + const skillDir = join(tempDir, "skills"); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, "SKILL.md"), + `--- +name: test-skill +description: A test skill +--- +Content`, + ); + + settingsManager.setSkillPaths([skillDir]); + + const result = await packageManager.resolve(); + expect(result.skills).toContain(skillDir); + }); + }); + + describe("resolveExtensionSources", () => { + it("should resolve local paths", async () => { + const extPath = join(tempDir, "ext.ts"); + writeFileSync(extPath, "export default function() {}"); + + const result = await packageManager.resolveExtensionSources([extPath]); + expect(result.extensions).toContain(extPath); + }); + + it("should handle directories with pi manifest", async () => { + const pkgDir = join(tempDir, "my-package"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ + name: "my-package", + pi: { + extensions: ["./src/index.ts"], + skills: ["./skills"], + }, + }), + ); + mkdirSync(join(pkgDir, "src"), { recursive: true }); + writeFileSync(join(pkgDir, "src", "index.ts"), "export default function() {}"); + mkdirSync(join(pkgDir, "skills"), { recursive: true }); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + expect(result.extensions).toContain(join(pkgDir, "src", "index.ts")); + expect(result.skills).toContain(join(pkgDir, "skills")); + }); + + it("should handle directories with auto-discovery layout", async () => { + const pkgDir = join(tempDir, "auto-pkg"); + mkdirSync(join(pkgDir, "extensions"), { recursive: true }); + mkdirSync(join(pkgDir, "themes"), { recursive: true }); + writeFileSync(join(pkgDir, "extensions", "main.ts"), "export default function() {}"); + writeFileSync(join(pkgDir, "themes", "dark.json"), "{}"); + + const result = await packageManager.resolveExtensionSources([pkgDir]); + expect(result.extensions).toContain(join(pkgDir, "extensions")); + expect(result.themes).toContain(join(pkgDir, "themes")); + }); + }); + + describe("progress callback", () => { + it("should emit progress events", async () => { + const events: ProgressEvent[] = []; + packageManager.setProgressCallback((event) => events.push(event)); + + const extPath = join(tempDir, "ext.ts"); + writeFileSync(extPath, "export default function() {}"); + + // Local paths don't trigger install progress, but we can verify the callback is set + await packageManager.resolveExtensionSources([extPath]); + + // For now just verify no errors - npm/git would trigger actual events + expect(events.length).toBe(0); + }); + }); + + describe("source parsing", () => { + it("should emit progress events on install attempt", async () => { + const events: ProgressEvent[] = []; + packageManager.setProgressCallback((event) => events.push(event)); + + // Use public install method which emits progress events + try { + await packageManager.install("npm:nonexistent-package@1.0.0"); + } catch { + // Expected to fail - package doesn't exist + } + + // Should have emitted start event before failure + expect(events.some((e) => e.type === "start" && e.action === "install")).toBe(true); + // Should have emitted error event + expect(events.some((e) => e.type === "error")).toBe(true); + }); + + it("should recognize github URLs without git: prefix", async () => { + const events: ProgressEvent[] = []; + packageManager.setProgressCallback((event) => events.push(event)); + + // This should be parsed as a git source, not throw "unsupported" + try { + await packageManager.install("https://github.com/nonexistent/repo"); + } catch { + // Expected to fail - repo doesn't exist + } + + // Should have attempted clone, not thrown unsupported error + expect(events.some((e) => e.type === "start" && e.action === "install")).toBe(true); + }); + }); +}); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts new file mode 100644 index 00000000..cb0eea03 --- /dev/null +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -0,0 +1,231 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DefaultResourceLoader } from "../src/core/resource-loader.js"; +import type { Skill } from "../src/core/skills.js"; + +describe("DefaultResourceLoader", () => { + let tempDir: string; + let agentDir: string; + let cwd: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `rl-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + agentDir = join(tempDir, "agent"); + cwd = join(tempDir, "project"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("reload", () => { + it("should initialize with empty results before reload", () => { + const loader = new DefaultResourceLoader({ cwd, agentDir }); + + expect(loader.getExtensions().extensions).toEqual([]); + expect(loader.getSkills().skills).toEqual([]); + expect(loader.getPrompts().prompts).toEqual([]); + expect(loader.getThemes().themes).toEqual([]); + }); + + it("should discover skills from agentDir", async () => { + const skillsDir = join(agentDir, "skills"); + mkdirSync(skillsDir, { recursive: true }); + writeFileSync( + join(skillsDir, "test-skill.md"), + `--- +name: test-skill +description: A test skill +--- +Skill content here.`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + const { skills } = loader.getSkills(); + expect(skills.some((s) => s.name === "test-skill")).toBe(true); + }); + + it("should discover prompts from agentDir", async () => { + const promptsDir = join(agentDir, "prompts"); + mkdirSync(promptsDir, { recursive: true }); + writeFileSync( + join(promptsDir, "test-prompt.md"), + `--- +description: A test prompt +--- +Prompt content.`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + const { prompts } = loader.getPrompts(); + expect(prompts.some((p) => p.name === "test-prompt")).toBe(true); + }); + + it("should discover AGENTS.md context files", async () => { + writeFileSync(join(cwd, "AGENTS.md"), "# Project Guidelines\n\nBe helpful."); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + const { agentsFiles } = loader.getAgentsFiles(); + expect(agentsFiles.some((f) => f.path.includes("AGENTS.md"))).toBe(true); + }); + + it("should discover SYSTEM.md from cwd/.pi", async () => { + const piDir = join(cwd, ".pi"); + mkdirSync(piDir, { recursive: true }); + writeFileSync(join(piDir, "SYSTEM.md"), "You are a helpful assistant."); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + expect(loader.getSystemPrompt()).toBe("You are a helpful assistant."); + }); + + it("should discover APPEND_SYSTEM.md", async () => { + const piDir = join(cwd, ".pi"); + mkdirSync(piDir, { recursive: true }); + writeFileSync(join(piDir, "APPEND_SYSTEM.md"), "Additional instructions."); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + expect(loader.getAppendSystemPrompt()).toContain("Additional instructions."); + }); + }); + + describe("noSkills option", () => { + it("should skip skill discovery when noSkills is true", async () => { + const skillsDir = join(agentDir, "skills"); + mkdirSync(skillsDir, { recursive: true }); + writeFileSync( + join(skillsDir, "test-skill.md"), + `--- +name: test-skill +description: A test skill +--- +Content`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir, noSkills: true }); + await loader.reload(); + + const { skills } = loader.getSkills(); + expect(skills).toEqual([]); + }); + + it("should still load additional skill paths when noSkills is true", async () => { + const customSkillDir = join(tempDir, "custom-skills"); + mkdirSync(customSkillDir, { recursive: true }); + writeFileSync( + join(customSkillDir, "custom.md"), + `--- +name: custom +description: Custom skill +--- +Content`, + ); + + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + noSkills: true, + additionalSkillPaths: [customSkillDir], + }); + await loader.reload(); + + const { skills } = loader.getSkills(); + expect(skills.some((s) => s.name === "custom")).toBe(true); + }); + }); + + describe("override functions", () => { + it("should apply skillsOverride", async () => { + const injectedSkill: Skill = { + name: "injected", + description: "Injected skill", + filePath: "/fake/path", + baseDir: "/fake", + source: "custom", + }; + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + skillsOverride: () => ({ + skills: [injectedSkill], + diagnostics: [], + }), + }); + await loader.reload(); + + const { skills } = loader.getSkills(); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe("injected"); + }); + + it("should apply systemPromptOverride", async () => { + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + systemPromptOverride: () => "Custom system prompt", + }); + await loader.reload(); + + expect(loader.getSystemPrompt()).toBe("Custom system prompt"); + }); + }); + + describe("extension conflict detection", () => { + it("should detect tool conflicts between extensions", async () => { + // Create two extensions that register the same tool + const ext1Dir = join(agentDir, "extensions", "ext1"); + const ext2Dir = join(agentDir, "extensions", "ext2"); + mkdirSync(ext1Dir, { recursive: true }); + mkdirSync(ext2Dir, { recursive: true }); + + writeFileSync( + join(ext1Dir, "index.ts"), + ` +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +export default function(pi: ExtensionAPI) { + pi.registerTool({ + name: "duplicate-tool", + description: "First", + parameters: Type.Object({}), + execute: async () => ({ result: "1" }), + }); +}`, + ); + + writeFileSync( + join(ext2Dir, "index.ts"), + ` +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +export default function(pi: ExtensionAPI) { + pi.registerTool({ + name: "duplicate-tool", + description: "Second", + parameters: Type.Object({}), + execute: async () => ({ result: "2" }), + }); +}`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + const { errors } = loader.getExtensions(); + expect(errors.some((e) => e.error.includes("duplicate-tool") && e.error.includes("conflicts"))).toBe(true); + }); + }); +});