feat(coding-agent): list updated packages with version changes in pi update (#11707)

## Problem

`pi update`, `pi update --all` and `pi update --extensions` print only
`Updating <source>...` progress lines and a final `Updated packages`.
Users cannot see which extensions changed or to which version. Several
outdated npm packages in the same scope are installed as one batch, so
the progress output does not even name them.

## Change

`pi update` now prints a summary of what actually changed:

```
Updated 2 packages:
  npm:foo 1.2.0 -> 1.3.0
  git:github.com/user/bar (project) abc1234 -> def5678
```

When nothing changed it prints `Packages are already up to date` (or
`<source> is already up to date`).

- npm: compares the installed `package.json` version before and after
the batch install. The "before" version comes from the existing
`shouldUpdateNpmSource` check, so this adds no extra registry calls.
- git: compares `git rev-parse HEAD` before and after `updateGit`.
Commits are shortened to 7 characters.
- Packages whose version or commit did not change are omitted, including
npm installs that hit stale registry metadata (those already get the
existing warning).

## API

`PackageManager.update()` still returns `Promise<void>`.
`ProgressEvent.type` gains a `"result"` member. Each changed package is
reported through the existing progress callback as:

```ts
{ type: "result", action: "update", source: "npm:foo", message: "npm:foo 1.2.0 -> 1.3.0" }
```

The CLI collects these messages to print the summary. A separate type
keeps the existing contract that each `start` is followed by exactly one
`complete` or `error` for the same source. Reusing `complete` would have
produced a second `complete` for git packages and a `complete` without a
`start` for batched npm packages. The only compatibility cost is for
TypeScript consumers with an exhaustive `switch` on `event.type`.

## Tests

- `test/package-manager.test.ts`: npm update emits `start`, `complete`,
then a `result` with `npm:example@^1.0.0 (project) 1.0.0 -> 1.2.0`; an
up-to-date package emits no result.
- `test/git-update.test.ts`: git update emits a result with the old and
new commit; an up-to-date clone emits no result.

`should expand ~ in exclusion and exact patterns` fails locally on
Windows. It tests path resolution and is unrelated to this change.
This commit is contained in:
Petr Houška 2026-11-02 21:21:23 +01:00 • committed by GitHub
parent 15f00a786a
commit 342514bdf8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 114 additions and 10 deletions

View file

@ -150,8 +150,13 @@ describe("DefaultPackageManager git update", () => {
}
};
const updated: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message);
});
await packageManager.update();
expect(updated).toEqual([]);
expect(executedCommands).toContain(
"git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main",
);
@ -165,14 +170,20 @@ describe("DefaultPackageManager git update", () => {
it("should update to latest commit when remote has new commits", async () => {
setupRemoteAndInstall();
expect(getFileContent(installedDir, "extension.ts")).toBe("// v1");
const oldCommit = getCurrentCommit(installedDir);
// Add a new commit to remote
const newCommit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
// Update via package manager (no args = uses settings)
const updated: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message);
});
await packageManager.update();
// Verify update succeeded
expect(updated).toEqual([`${gitSource} ${oldCommit.slice(0, 7)} -> ${newCommit.slice(0, 7)}`]);
expect(getCurrentCommit(installedDir)).toBe(newCommit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
});

View file

@ -2612,10 +2612,21 @@ export default function(api) { api.registerTool({ name: "test", description: "te
const runCommandCaptureSpy = vi
.spyOn(packageManager as any, "runCommandCapture")
.mockResolvedValue('["1.0.0","1.2.0"]');
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockImplementation(async () => {
writeFileSync(join(installedPath, "package.json"), JSON.stringify({ name: "example", version: "1.2.0" }));
});
const events: ProgressEvent[] = [];
packageManager.setProgressCallback((event) => events.push(event));
await packageManager.update("npm:example");
// The result is a separate event, so each start still has exactly one complete.
expect(events.map((event) => [event.type, event.source, event.message])).toEqual([
["start", "npm:example@^1.0.0", "Updating npm:example@^1.0.0..."],
["complete", "npm:example@^1.0.0", undefined],
["result", "npm:example@^1.0.0", "npm:example@^1.0.0 (project) 1.0.0 -> 1.2.0"],
]);
expect(runCommandCaptureSpy).toHaveBeenCalledWith(
"npm",
["view", "example@^1.0.0", "version", "--json"],
@ -2647,8 +2658,14 @@ export default function(api) { api.registerTool({ name: "test", description: "te
.mockResolvedValue('["1.0.0","1.3.1","1.0.2"]');
const runCommandSpy = vi.spyOn(packageManager as any, "runCommand").mockResolvedValue(undefined);
const updated: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message);
});
await packageManager.update("npm:example");
expect(updated).toEqual([]);
expect(runCommandCaptureSpy).toHaveBeenCalledWith(
"npm",
["view", "example@^1.0.0", "version", "--json"],