diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fbd63786..b54776c0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ ### Fixed +- Fixed `pi update` reinstalling npm packages that are already at the latest published version by checking the installed package version before running `npm install @latest` ([#3000](https://github.com/badlogic/pi-mono/issues/3000)) - Fixed built-in tool wrapping to use the same extension-runner context path as extension tools, so built-in tools receive execution context and `read` can warn when the current model does not support images ([#3429](https://github.com/badlogic/pi-mono/issues/3429)) - Fixed threaded `/resume` session relationships and current-session detection to canonicalize symlinked session paths during selector comparisons, so shared session directories no longer break parent-child matching or active-session delete protection ([#3364](https://github.com/badlogic/pi-mono/issues/3364)) - Fixed `/session`, Sessions docs, and CLI help to consistently document that session reuse supports both file paths and session IDs, and that `/session` shows the current session ID ([#3390](https://github.com/badlogic/pi-mono/issues/3390)) diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 51a78461..4de0c335 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1001,6 +1001,20 @@ export class DefaultPackageManager implements PackageManager { const parsed = this.parseSource(source); if (parsed.type === "npm") { if (parsed.pinned) return; + + const installedPath = this.getNpmInstallPath(parsed, scope); + const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined; + if (installedVersion) { + try { + const latestVersion = await this.getLatestNpmVersion(parsed.name); + if (latestVersion === installedVersion) { + return; + } + } catch { + // Preserve existing update behavior when version lookup fails. + } + } + await this.withProgress("update", source, `Updating ${source}...`, async () => { await this.installNpm( { diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 907d8f4f..ffd639fd 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -1455,13 +1455,22 @@ export default function(api) { api.registerTool({ name: "test", description: "te }); describe("offline mode and network timeouts", () => { - it("should update project npm packages using @latest", async () => { + it("should update project npm packages using @latest when newer version is available", async () => { + const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.0.0" })); settingsManager.setProjectPackages(["npm:example"]); + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); await packageManager.update("npm:example"); + expect(runCommandCaptureSpy).toHaveBeenCalledWith( + "npm", + ["view", "example", "version", "--json"], + expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), + ); expect(runCommandSpy).toHaveBeenCalledWith( "npm", ["install", "example@latest", "--prefix", join(tempDir, ".pi", "npm")], @@ -1469,6 +1478,25 @@ export default function(api) { api.registerTool({ name: "test", description: "te ); }); + it("should skip project npm update when installed version matches latest", async () => { + const installedPath = join(tempDir, ".pi", "npm", "node_modules", "example"); + mkdirSync(installedPath, { recursive: true }); + writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.3" })); + settingsManager.setProjectPackages(["npm:example"]); + + const runCommandCaptureSpy = vi.spyOn(packageManager as any, "runCommandCapture").mockResolvedValue('"1.2.3"'); + const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined); + + await packageManager.update("npm:example"); + + expect(runCommandCaptureSpy).toHaveBeenCalledWith( + "npm", + ["view", "example", "version", "--json"], + expect.objectContaining({ cwd: tempDir, timeoutMs: expect.any(Number) }), + ); + expect(runCommandSpy).not.toHaveBeenCalled(); + }); + it("should suggest npm source prefixes for update lookups", async () => { settingsManager.setProjectPackages(["npm:example"]);