From 9a195396174be6955684b2a5c00fdee9d1d249b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Hou=C5=A1ka?= Date: Tue, 3 Nov 2026 21:59:05 +0100 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 10 +++++++ packages/coding-agent/src/utils/changelog.ts | 19 +++++++----- packages/coding-agent/test/changelog.test.ts | 29 ++++++++++++++++++- ...teractive-mode-extension-changelog.test.ts | 22 ++++++++++++++ 5 files changed, 73 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d051c8226..8718e766e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 6c6fbcf37..1af1ad6d3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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; diff --git a/packages/coding-agent/src/utils/changelog.ts b/packages/coding-agent/src/utils/changelog.ts index 2c8ce4a60..be813eb3c 100644 --- a/packages/coding-agent/src/utils/changelog.ts +++ b/packages/coding-agent/src/utils/changelog.ts @@ -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: "", }; diff --git a/packages/coding-agent/test/changelog.test.ts b/packages/coding-agent/test/changelog.test.ts index 979e7cdf6..7a42ac95c 100644 --- a/packages/coding-agent/test/changelog.test.ts +++ b/packages/coding-agent/test/changelog.test.ts @@ -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(); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts b/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts index f588ba474..853be1ec8 100644 --- a/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts +++ b/packages/coding-agent/test/interactive-mode-extension-changelog.test.ts @@ -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();