fix(coding-agent): ignore prerelease suffix of last seen changelog version (#11753)

Fixes #11703.

**Problem:** `getNewEntries()` parsed the saved version with
`split(".")`. `0.1.5-20261020.7` became `["0", "1", "5-20261020", "7"]`,
`Number("5-20261020")` is `NaN`, and `|| 0` turned it into `0.1.0`. The
`[0.1.1]` to `[0.1.5]` notes counted as new, were shown, and the same
prerelease string was saved again, so they repeated on every fresh
launch.

**Fix:** read only the leading `major.minor.patch` of the saved version,
the same way changelog headings are parsed. `0.1.5-20261020.7` and
`0.1.5+build.3` now count as `0.1.5`.

If the saved version has no `major.minor.patch` (for example `latest`),
nothing is shown and the installed version is saved in its place, like
on a fresh install. Before, it was read as `0.0.0` and the whole
changelog was shown. Saving the installed version matters because
callers only save when there are new entries, so an unreadable value
would otherwise silence the changelog for good.

Alternatives considered (noted in a code comment):
- plain string comparison: orders `0.10.0` before `0.9.0`
- `localeCompare` with `numeric: true`: works only because `0.1.5-x`
happens to sort after `0.1.5`, and depends on locale collation
- strict semver: orders `0.1.5-x` before `0.1.5`, so the `[0.1.5]` notes
would still repeat forever

Tests added in `test/changelog.test.ts` and
`test/interactive-mode-extension-changelog.test.ts`. `npm run check`
passes.
This commit is contained in:
Petr Houška 2026-11-03 21:59:05 +01:00 • committed by GitHub
parent 4d70507ffb
commit 9a19539617
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 73 additions and 8 deletions

View file

@ -11,6 +11,7 @@
- A failed token refresh of an MCP connection no longer registers an OAuth client or replaces the PKCE verifier of a sign-in in progress, which made that sign-in fail. MCP sign-in now refreshes the stored tokens with the stored client even when the loopback callback port changed, instead of registering a new client and opening the browser. Ctrl+C in `/mcp` and the `--timeout` of `pi mcp login` now also stop a sign-in while it waits up to 25 seconds for another pi process's token refresh, and a refresh lock that fails for another reason than that wait now fails the sign-in instead of opening the browser.
- Fixed RPC mode ignoring an extension's `ctx.shutdown()` while idle, for example from a timer: the process kept running until the client sent another command. pi now shuts down right away when idle, and a shutdown requested by an extension command waits until the command's `prompt` response is written ([#11599](https://github.com/earendil-works/pi/issues/11599))
- Fixed changelog notes repeating on every fresh launch when the installed package version has a prerelease suffix, such as `0.1.5-20261020.7`: the saved version was read as `0.1.0`, so the `0.1.1` to `0.1.5` notes counted as new each time. A saved version without `major.minor.patch` now shows no notes and is replaced by the installed version, instead of showing the whole changelog ([#11703](https://github.com/earendil-works/pi/issues/11703))
## [1.5.0] - 2026-11-02

View file

@ -1372,6 +1372,11 @@ export class InteractiveMode {
}
const newEntries = getNewEntries(entries, lastVersion);
if (!newEntries) {
// Unreadable saved version: show nothing and record the current one so later updates compare again
this.settingsManager.setLastChangelogVersion(VERSION);
return undefined;
}
if (newEntries.length > 0) {
this.settingsManager.setLastChangelogVersion(VERSION);
this.reportInstallTelemetry(VERSION);
@ -1413,6 +1418,11 @@ export class InteractiveMode {
}
const newEntries = getNewEntries(parseChangelog(sourceInfo.changelogPath), lastVersion);
if (!newEntries) {
// Unreadable saved version: show nothing and record the current one so later updates compare again
this.settingsManager.setExtensionChangelogVersion(key, pkg.version);
continue;
}
if (newEntries.length > 0) {
this.settingsManager.setExtensionChangelogVersion(key, pkg.version);
const name = pkg.name || sourceInfo.source;

View file

@ -177,15 +177,20 @@ export function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number
}
/**
* Get entries newer than lastVersion
* Get entries newer than lastVersion, or undefined when lastVersion has no major.minor.patch to compare against
*/
export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] {
// Parse lastVersion
const parts = lastVersion.split(".").map(Number);
export function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[] | undefined {
// Read only the leading major.minor.patch, like the changelog headings, so a prerelease such as
// 0.1.5-20261020.7 counts as 0.1.5 and its notes are not shown again on every launch (#11703).
// Alternatives considered: plain string comparison orders 0.10.0 before 0.9.0; numeric localeCompare
// works by accident and is locale-dependent; strict semver orders 0.1.5-x before 0.1.5, which repeats
// the [0.1.5] notes forever.
const parts = lastVersion.trim().match(/^v?(\d+)\.(\d+)\.(\d+)/);
if (!parts) return undefined;
const last: ChangelogEntry = {
major: parts[0] || 0,
minor: parts[1] || 0,
patch: parts[2] || 0,
major: Number(parts[1]),
minor: Number(parts[2]),
patch: Number(parts[3]),
content: "",
};

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts";
import { type ChangelogEntry, getNewEntries, normalizeChangelogLinks } from "../src/utils/changelog.ts";
const entry: ChangelogEntry = {
major: 0,
@ -47,3 +47,30 @@ describe("normalizeChangelogLinks", () => {
);
});
});
describe("getNewEntries", () => {
const entries: ChangelogEntry[] = [
{ major: 0, minor: 1, patch: 5, content: "0.1.5" },
{ major: 0, minor: 1, patch: 4, content: "0.1.4" },
];
const newContent = (lastVersion: string) => getNewEntries(entries, lastVersion)?.map((e) => e.content);
// #11703
test("ignores prerelease and build suffixes of the last seen version", () => {
expect(newContent("0.1.5-20261020.7")).toEqual([]);
expect(newContent("0.1.5+build.3")).toEqual([]);
expect(newContent("0.1.4-beta.1")).toEqual(["0.1.5"]);
});
test("compares release versions numerically", () => {
expect(newContent("0.1.5")).toEqual([]);
expect(newContent("0.1.3")).toEqual(["0.1.5", "0.1.4"]);
expect(getNewEntries([{ major: 0, minor: 10, patch: 0, content: "0.10.0" }], "0.9.0")?.length).toBe(1);
});
test("returns undefined for a last seen version without major.minor.patch", () => {
expect(newContent("latest")).toBeUndefined();
expect(newContent("0.1")).toBeUndefined();
expect(newContent("")).toBeUndefined();
});
});

View file

@ -117,6 +117,28 @@ describe("InteractiveMode extension changelog", () => {
expect(display()).toEqual([]);
});
// #11703
test("does not repeat entries for a prerelease version", () => {
writePackage("1.0.0");
display();
writePackage("1.2.0-20261020.7");
expect(display()).toHaveLength(1);
expect(display()).toEqual([]);
});
test("shows nothing for an unreadable last seen version and records the current one", () => {
const key = getSourcePackageKey(sourceInfo);
settingsManager.setExtensionChangelogVersion(key, "latest");
writePackage("1.0.0");
expect(display()).toEqual([]);
expect(settingsManager.getExtensionChangelogVersion(key)).toBe("1.0.0");
writePackage("1.2.0");
expect(display()).toHaveLength(1);
});
test("skips resumed sessions", () => {
writePackage("1.0.0");
display();