Files
software-mansion__argent/packages/tool-server/test/http-update-note.test.ts
filip131311 ee97c443b7 feat(tool-server): make the update reminder configurable and release-age aware (#276)
## Why

The tools-server shows an update reminder (a `note` appended to every
tool response) once a newer `@swmansion/argent` lands on npm. Two pain
points:

1. **No way to turn it off.** Suppression is in-memory only
(`dismiss-update`, 30-min auto), so it comes back on every server
restart.
2. **It nags about versions you can't install yet.** Registries with a
**minimum-release-age** (package-cooldown) security policy refuse to
install a version until it has been public for N days. Until then the
reminder repeats every day for the whole cooldown window with nothing
the user can do about it — exactly the "reminded for 5+ days" situation.

## What's in this PR

**1. A config option to disable the reminder**
- New env var **`ARGENT_DISABLE_UPDATE_NOTIFICATIONS=1`** (`"1"` or
`"true"`).
- When set, `startUpdateChecker()` is a no-op: **no registry request**
is made and **no note** is ever attached.
- The opt-out is also surfaced inside the reminder text itself, so the
agent can tell the user how to silence it permanently.

**2. Minimum-release-age awareness** (answers "detect the policy and
check the update is actually installable first")
- The checker now fetches the **npm packument**
(`registry.npmjs.org/@swmansion/argent`) instead of `/latest`, because
only the packument carries the `time` map of version → publish
timestamp.
- It detects the machine's policy and only reminds once the latest
version has **aged past** it. Notification is gated on a new
**`updateInstallable`** flag (newer **and** old enough) rather than
`updateAvailable`.
- `updateAvailable` keeps its pure version-comparison meaning; with no
policy, `updateInstallable === updateAvailable` (no behavior change for
users without a policy).

### Is detecting the user's min-age policy possible? Yes — partially.

Each package manager stores it differently, and the tools-server has no
reliable signal for *which* PM installed argent, so we probe every PM
present and take the **most restrictive** (largest) value. Erring toward
over-waiting is intentional: a slightly late reminder beats nagging
about a forbidden version.

| PM | Config key | Unit | Detect via | Min PM version |
|----|-----------|------|-----------|----------------|
| npm | `min-release-age` | days | `npm config get min-release-age` |
11.10.0 |
| pnpm | `minimumReleaseAge` | minutes | `pnpm config get
minimumReleaseAge` | 10.16 |
| yarn (berry) | `npmMinimalAgeGate` | minutes | `yarn config get
npmMinimalAgeGate` | 4.10 |
| bun | `minimumReleaseAge` (bunfig.toml `[install]`) | seconds | no
`config get` — not auto-probed | 1.3 |
| yarn classic | — (unsupported) | — | — | — |

Because bun has no `config get` for this and auto-detection is
best-effort, there's also an explicit override:

- **`ARGENT_MIN_RELEASE_AGE_DAYS`** — positive number of days; wins
outright and skips probing (useful for bun users, locked-down CI, or
pinning the value).

When a policy holds an update back, a single stderr line explains the
silence; no reminder reaches the agent.

## Conservative edge handling
- Network/registry failure → state unchanged (never crashes the server).
- Policy in effect but publish time missing/unparseable → treated as
**not** installable (stay silent rather than nag).
- PM probe errors / PM absent → treated as no policy (0).
- Probes shell out via PATH (`exec`, 3s timeout, constant commands only
— no caller input) so Windows `.cmd`/`.ps1` shims resolve.

## Tests
- `test/min-release-age.test.ts` (new): unit-level parsing, override
precedence, unit normalization, most-restrictive-wins, PM-absent.
- `test/update-checker.test.ts`: rewritten for the packument shape; new
cases for held-by-policy, aged-past-policy, unknown publish time, and
the disable flag (probe mocked so tests never spawn a real PM).
- `test/http-update-note.test.ts`: gate now keys off
`updateInstallable`; added a "available but held by policy → no note"
case.
- Full tool-server suite green (**732 tests**), `tsc --noEmit` and
`typecheck:tests` clean.

## Follow-up (not in this PR)
The env-var reference lives in the `argent-private` submodule
(`docs/environment-variables.md`), which is a separate repo — needs a
companion PR. Suggested rows for the tools-server table:

```
| `ARGENT_DISABLE_UPDATE_NOTIFICATIONS` | `"1"`/`"true"` or unset | disabled | When truthy, disables the update reminder entirely (no registry check, no note). |
| `ARGENT_MIN_RELEASE_AGE_DAYS`         | number                  | _(auto-detect)_ | Minimum age (days) a newer version must reach before it is advertised. Overrides auto-detection of the npm/pnpm/yarn minimum-release-age policy. |
```

## Open questions for review
- **Most-restrictive-across-PMs** vs. detecting a single PM — is "max
wins" the behavior we want, or should it follow `npm_config_user_agent`?
- Should an update held by policy stay fully silent (current), or emit a
softened "available, installable in ~N days" note?

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 17:15:12 +02:00

318 lines
9.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import supertest from "supertest";
import { createHttpApp, type HttpAppHandle } from "../src/http";
import type { UpdateState } from "../src/utils/update-checker";
import type { Registry } from "@argent/registry";
let suppressed = false;
/** Build a full UpdateState from partial overrides so tests stay terse. */
function mkState(overrides: Partial<UpdateState> = {}): UpdateState {
return {
updateAvailable: false,
updateInstallable: false,
installableVersion: null,
latestVersion: null,
latestPublishedAt: null,
minReleaseAgeMs: 0,
currentVersion: "1.0.0",
...overrides,
};
}
// Mock update-checker before any imports that use it transitively.
vi.mock("../src/utils/update-checker", () => ({
getUpdateState: vi.fn((): UpdateState => mkState()),
isUpdateNoteSuppressed: vi.fn(() => suppressed),
suppressUpdateNote: vi.fn(),
}));
import { getUpdateState, suppressUpdateNote } from "../src/utils/update-checker";
const mockGetUpdateState = vi.mocked(getUpdateState);
const mockSuppressUpdateNote = vi.mocked(suppressUpdateNote);
function stubRegistry(toolResult: unknown = { ok: true }): Registry {
return {
getSnapshot: vi.fn(() => ({ services: new Map(), namespaces: [], tools: ["test-tool"] })),
getTool: vi.fn((name: string) => {
if (name === "test-tool") {
return {
id: "test-tool",
description: "A stub tool for testing",
inputSchema: { type: "object", properties: {} },
services: () => ({}),
execute: async () => toolResult,
};
}
return undefined;
}),
invokeTool: vi.fn(async (_name: string, _params: unknown) => toolResult),
} as unknown as Registry;
}
describe("HTTP update note injection", () => {
let handle: HttpAppHandle;
const request = supertest;
beforeEach(async () => {
suppressed = false;
mockGetUpdateState.mockReturnValue(mkState());
mockSuppressUpdateNote.mockClear();
});
afterEach(() => {
handle?.dispose();
vi.clearAllMocks();
});
it("does NOT include a note when update is not available", async () => {
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body).toHaveProperty("data");
expect(res.body).not.toHaveProperty("note");
});
it("includes a note when an installable update is available", async () => {
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body).toHaveProperty("data");
expect(res.body).toHaveProperty("note");
expect(typeof res.body.note).toBe("string");
expect(res.body.note.length).toBeGreaterThan(0);
});
it("does NOT include a note when an update is available but held by the release-age policy", async () => {
// Newer version exists, but it has not aged past the machine's policy yet,
// so it is not installable — the reminder must stay silent.
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: false,
latestVersion: "1.2.3",
minReleaseAgeMs: 7 * 24 * 60 * 60 * 1000,
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body).toHaveProperty("data");
expect(res.body).not.toHaveProperty("note");
expect(mockSuppressUpdateNote).not.toHaveBeenCalled();
});
it("note contains the current -> latest version string", async () => {
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body.note).toContain("1.0.0 -> 1.2.3");
});
it("note mentions `npx @swmansion/argent update`", async () => {
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body.note).toContain("npx @swmansion/argent update");
});
it("note mentions `update-argent` tool", async () => {
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body.note).toContain("update-argent");
});
it("note mentions `dismiss-update` tool", async () => {
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body.note).toContain("dismiss-update");
});
it("note instructs agent to persist the update info", async () => {
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body.note).toContain("Save a note");
});
it("does NOT include a note on 500 error responses", async () => {
const errorRegistry = {
getSnapshot: vi.fn(() => ({ services: new Map(), namespaces: [], tools: ["test-tool"] })),
getTool: vi.fn(() => ({
id: "test-tool",
description: "A stub tool",
inputSchema: {},
services: () => ({}),
execute: async () => {
throw new Error("tool blew up");
},
})),
invokeTool: vi.fn(async () => {
throw new Error("tool blew up");
}),
} as unknown as Registry;
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(errorRegistry);
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(500);
expect(res.body).toHaveProperty("error");
expect(res.body).not.toHaveProperty("note");
});
it("does NOT include a note on 404 (unknown tool) responses", async () => {
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/nonexistent-tool").send({}).expect(404);
expect(res.body).toHaveProperty("error");
expect(res.body).not.toHaveProperty("note");
});
it("note says 'unknown' when installableVersion is null but the update is installable", async () => {
mockGetUpdateState.mockReturnValue(
mkState({ updateAvailable: true, updateInstallable: true, installableVersion: null })
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body).toHaveProperty("note");
expect(res.body.note).toContain("unknown");
expect(res.body.note).not.toContain("null");
});
// ── Suppression behavior ──────────────────────────────────────────
it("calls suppressUpdateNote after delivering a note", async () => {
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(mockSuppressUpdateNote).toHaveBeenCalledOnce();
expect(mockSuppressUpdateNote).toHaveBeenCalledWith(30 * 60 * 1000);
});
it("does NOT include a note when suppressed", async () => {
suppressed = true;
mockGetUpdateState.mockReturnValue(
mkState({
updateAvailable: true,
updateInstallable: true,
latestVersion: "1.2.3",
installableVersion: "1.2.3",
})
);
handle = createHttpApp(stubRegistry());
const res = await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(res.body).toHaveProperty("data");
expect(res.body).not.toHaveProperty("note");
expect(mockSuppressUpdateNote).not.toHaveBeenCalled();
});
it("does NOT call suppressUpdateNote when no update is available", async () => {
handle = createHttpApp(stubRegistry());
await request(handle.app).post("/tools/test-tool").send({}).expect(200);
expect(mockSuppressUpdateNote).not.toHaveBeenCalled();
});
});