From 930b5ff9a7400c1bb7b02a7c854deaccae93eb83 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Tue, 5 May 2026 15:02:08 +0200 Subject: [PATCH] feat(agent): expose agent version to UI for capability gating Cloud sandbox agents can run a different (older) version than the desktop UI expects, which has caused multiple incidents where the UI assumed a notification or behaviour the agent doesn't yet emit. The agent now reports its semver on the `_posthog/run_started` notification, the renderer caches it on the session, and feature code can call `isAgentVersion(version, ">=0.40.1")` (full semver range grammar via the `semver` package) to gate UI behaviour. Generated-By: PostHog Code Task-Id: 9329a81e-85d2-4a7e-84cf-c2a9d0b89237 --- apps/code/package.json | 2 + .../sessions/hooks/useAgentVersion.ts | 34 +++++++++ .../features/sessions/service/service.test.ts | 55 ++++++++++++++ .../features/sessions/service/service.ts | 16 +++- .../features/sessions/stores/sessionStore.ts | 5 ++ .../src/renderer/utils/agentVersion.test.ts | 73 +++++++++++++++++++ apps/code/src/renderer/utils/agentVersion.ts | 29 ++++++++ packages/agent/README.md | 2 +- .../agent/src/server/agent-server.test.ts | 9 +++ packages/agent/src/server/agent-server.ts | 1 + pnpm-lock.yaml | 11 +++ 11 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 apps/code/src/renderer/features/sessions/hooks/useAgentVersion.ts create mode 100644 apps/code/src/renderer/utils/agentVersion.test.ts create mode 100644 apps/code/src/renderer/utils/agentVersion.ts diff --git a/apps/code/package.json b/apps/code/package.json index e374f8c7b..caeab6a55 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -63,6 +63,7 @@ "@types/node": "^24.0.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "@types/semver": "^7.5.0", "@vitejs/plugin-react": "^4.2.1", "@vitest/ui": "^4.0.10", "adm-zip": "^0.5.16", @@ -185,6 +186,7 @@ "react-markdown": "^10.1.0", "react-resizable-panels": "^3.0.6", "reflect-metadata": "^0.2.2", + "semver": "^7.6.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", diff --git a/apps/code/src/renderer/features/sessions/hooks/useAgentVersion.ts b/apps/code/src/renderer/features/sessions/hooks/useAgentVersion.ts new file mode 100644 index 000000000..0a0eb259d --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/useAgentVersion.ts @@ -0,0 +1,34 @@ +import { isAgentVersion } from "@utils/agentVersion"; +import { useSessionStore } from "../stores/sessionStore"; + +/** + * Returns the connected agent's version for the given task, or `undefined` + * if no session is active or the agent hasn't reported a version yet. + */ +export function useAgentVersion( + taskId: string | undefined, +): string | undefined { + return useSessionStore((s) => { + if (!taskId) return undefined; + const taskRunId = s.taskIdIndex[taskId]; + if (!taskRunId) return undefined; + return s.sessions[taskRunId]?.agentVersion; + }); +} + +/** + * Returns true when the connected agent's version satisfies the given semver + * range. Fails closed when the version is unknown — feature gates stay off. + * + * Examples: + * useIsAgentVersion(taskId, ">=0.40.1") + * useIsAgentVersion(taskId, ">1.0.0") + * useIsAgentVersion(taskId, ">=0.40.0 <1.0.0") + */ +export function useIsAgentVersion( + taskId: string | undefined, + range: string, +): boolean { + const version = useAgentVersion(taskId); + return isAgentVersion(version, range); +} diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index f896448be..1685817f6 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -1531,6 +1531,61 @@ describe("SessionService", () => { }); }); + it("captures agentVersion from run_started params onto the session", async () => { + const service = getSessionService(); + const hydratedSession = createMockSession({ + taskRunId: "run-123", + taskId: "task-123", + status: "disconnected", + isCloud: true, + events: [], + }); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + hydratedSession, + ); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-123": hydratedSession, + }); + mockTrpcLogs.readLocalLogs.query.mockResolvedValue(""); + mockTrpcLogs.fetchS3Logs.query.mockResolvedValue("{}"); + mockTrpcLogs.writeLocalLogs.mutate.mockResolvedValue(undefined); + + const runStartedEvent = { + type: "acp_message" as const, + ts: 1700000000, + message: { + jsonrpc: "2.0" as const, + method: "_posthog/run_started", + params: { + sessionId: "acp-session", + runId: "run-123", + taskId: "task-123", + agentVersion: "0.42.3", + }, + }, + }; + mockConvertStoredEntriesToEvents.mockReturnValueOnce([runStartedEvent]); + + service.watchCloudTask( + "task-123", + "run-123", + "https://api.anthropic.com", + 123, + undefined, + "https://logs.example.com/run-123", + ); + + await vi.waitFor(() => { + expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( + "run-123", + expect.objectContaining({ + agentVersion: "0.42.3", + status: "connected", + }), + ); + }); + }); + it("does not re-flip status when run_started arrives but session is already connected", async () => { const service = getSessionService(); const connectedSession = createMockSession({ diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 28486f737..e2406c49c 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -1074,10 +1074,20 @@ export class SessionService { isNotification(msg.method, POSTHOG_NOTIFICATIONS.RUN_STARTED) ) { const session = sessionStoreSetters.getSessions()[taskRunId]; + const params = (msg as { params?: { agentVersion?: unknown } }).params; + const agentVersion = + typeof params?.agentVersion === "string" + ? params.agentVersion + : undefined; + const updates: Partial = {}; + if (agentVersion && session?.agentVersion !== agentVersion) { + updates.agentVersion = agentVersion; + } if (session?.isCloud && session.status !== "connected") { - sessionStoreSetters.updateSession(taskRunId, { - status: "connected", - }); + updates.status = "connected"; + } + if (Object.keys(updates).length > 0) { + sessionStoreSetters.updateSession(taskRunId, updates); } } // Canonical "turn boundary" — flush any queued cloud messages now diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts index 726ac9815..5a0cb9ca8 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts @@ -93,6 +93,11 @@ export interface AgentSession { /** Pre-computed conversation summary for commit/PR generation context */ conversationSummary?: string; idleKilled?: boolean; + /** Semver of the connected agent process. Populated from the + * `_posthog/run_started` notification so that the UI can gate features + * against agent capabilities (especially relevant for cloud sandboxes + * where the agent version can lag behind the desktop). */ + agentVersion?: string; } // --- Config Option Helpers --- diff --git a/apps/code/src/renderer/utils/agentVersion.test.ts b/apps/code/src/renderer/utils/agentVersion.test.ts new file mode 100644 index 000000000..103aa6b0f --- /dev/null +++ b/apps/code/src/renderer/utils/agentVersion.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { isAgentVersion } from "./agentVersion"; + +describe("isAgentVersion", () => { + it("returns true when actual satisfies a >= range", () => { + expect(isAgentVersion("0.40.1", ">=0.40.1")).toBe(true); + expect(isAgentVersion("0.41.0", ">=0.40.1")).toBe(true); + expect(isAgentVersion("1.0.0", ">=0.40.1")).toBe(true); + }); + + it("returns false when actual is below a >= range", () => { + expect(isAgentVersion("0.40.0", ">=0.40.1")).toBe(false); + expect(isAgentVersion("0.39.99", ">=0.40.1")).toBe(false); + }); + + it("supports strict >, <, and <= comparators", () => { + expect(isAgentVersion("1.0.1", ">1.0.0")).toBe(true); + expect(isAgentVersion("1.0.0", ">1.0.0")).toBe(false); + expect(isAgentVersion("0.9.9", "<1.0.0")).toBe(true); + expect(isAgentVersion("1.0.0", "<1.0.0")).toBe(false); + expect(isAgentVersion("1.0.0", "<=1.0.0")).toBe(true); + }); + + it("supports caret and tilde ranges", () => { + expect(isAgentVersion("1.2.5", "^1.2.0")).toBe(true); + expect(isAgentVersion("2.0.0", "^1.2.0")).toBe(false); + expect(isAgentVersion("1.2.5", "~1.2.0")).toBe(true); + expect(isAgentVersion("1.3.0", "~1.2.0")).toBe(false); + }); + + it("supports compound ranges", () => { + expect(isAgentVersion("0.50.0", ">=0.40.0 <1.0.0")).toBe(true); + expect(isAgentVersion("1.0.0", ">=0.40.0 <1.0.0")).toBe(false); + expect(isAgentVersion("0.39.0", ">=0.40.0 <1.0.0")).toBe(false); + }); + + it("treats prereleases as comparable to their base version", () => { + // includePrerelease lets call sites match a prerelease against a stable + // range without callers having to opt in per call. + expect(isAgentVersion("0.40.1-rc.1", ">=0.40.0")).toBe(true); + expect(isAgentVersion("0.40.0-rc.1", ">=0.40.1")).toBe(false); + }); + + it("fails closed when the actual version is undefined", () => { + expect(isAgentVersion(undefined, ">=0.0.0")).toBe(false); + expect(isAgentVersion(undefined, "<99.0.0")).toBe(false); + }); + + it("fails closed when the actual version is empty", () => { + expect(isAgentVersion("", ">=0.0.0")).toBe(false); + }); + + it("returns false for malformed range strings", () => { + expect(isAgentVersion("1.0.0", "not a range")).toBe(false); + }); + + describe("dev sentinel (0.0.0-dev)", () => { + // Local dev builds ship the latest code under the placeholder version + // `0.0.0-dev`. Treat it as satisfying any range so feature gates don't + // silently disable in development. + it("satisfies any well-formed range", () => { + expect(isAgentVersion("0.0.0-dev", ">=0.40.1")).toBe(true); + expect(isAgentVersion("0.0.0-dev", ">99.0.0")).toBe(true); + expect(isAgentVersion("0.0.0-dev", "<0.0.0")).toBe(true); + expect(isAgentVersion("0.0.0-dev", "^1.2.0")).toBe(true); + expect(isAgentVersion("0.0.0-dev", ">=0.40.0 <1.0.0")).toBe(true); + }); + + it("still rejects malformed range strings", () => { + expect(isAgentVersion("0.0.0-dev", "not a range")).toBe(false); + }); + }); +}); diff --git a/apps/code/src/renderer/utils/agentVersion.ts b/apps/code/src/renderer/utils/agentVersion.ts new file mode 100644 index 000000000..30c7103c0 --- /dev/null +++ b/apps/code/src/renderer/utils/agentVersion.ts @@ -0,0 +1,29 @@ +import semver from "semver"; + +/** Sentinel version used by unbuilt dev builds (matches the placeholder in + * `packages/agent/package.json`). Real release builds inject a real semver. */ +const DEV_VERSION = "0.0.0-dev"; + +/** + * Check whether the connected agent's version satisfies a semver range. + * + * Examples: + * isAgentVersion(version, ">=0.40.1") + * isAgentVersion(version, ">1.0.0") + * isAgentVersion(version, ">=0.40.0 <1.0.0") + * + * Returns `false` when the agent version is unknown so feature gates fail + * closed — an unknown agent never accidentally enables a newer code path. + * + * The dev sentinel `0.0.0-dev` is treated as "satisfies any range": local + * dev builds carry the latest code, so we want feature gates to open even + * though the literal semver is below every released version. + */ +export function isAgentVersion( + actual: string | undefined, + range: string, +): boolean { + if (!actual) return false; + if (actual === DEV_VERSION) return semver.validRange(range) !== null; + return semver.satisfies(actual, range, { includePrerelease: true }); +} diff --git a/packages/agent/README.md b/packages/agent/README.md index 0d81a81ef..29d54f969 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -238,7 +238,7 @@ ACP defines standard methods like `session/prompt`, `session/update`, and `sessi **Session lifecycle** — events that track the run from start to finish. Clients use these to update UI state (show progress, enable/disable controls, display completion). The Django API uses `task_complete` to mark the run as finished. -- `_posthog/run_started` — `{ sessionId, runId, taskId? }` — session initialized and ready +- `_posthog/run_started` — `{ sessionId, runId, taskId?, agentVersion }` — session initialized and ready. `agentVersion` is the agent's semver, used by clients to gate UI features against agent capabilities - `_posthog/task_complete` — `{ sessionId, taskId }` — agent finished (success or end-turn) - `_posthog/error` — `{ sessionId, message, error? }` — unrecoverable error - `_posthog/status` — `{ sessionId, status, message? }` — progress updates diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index c487f8a35..479766c98 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -405,6 +405,15 @@ describe("AgentServer HTTP Mode", () => { runId: "test-run-id", taskId: "test-task-id", }); + // Agent reports its semver so clients can gate UI features + // against agent capabilities (e.g. `>=0.40.1`). The exact value + // is whatever the agent's package.json was at build time. + expect(typeof runStarted?.notification?.params?.agentVersion).toBe( + "string", + ); + expect( + (runStarted?.notification?.params?.agentVersion as string).length, + ).toBeGreaterThan(0); }, { timeout: 15000, interval: 100 }, ); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 18acf4506..30d5399a6 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -969,6 +969,7 @@ export class AgentServer { sessionId: acpSessionId, runId: payload.run_id, taskId: payload.task_id, + agentVersion: this.config.version ?? packageJson.version, }, }; this.broadcastEvent({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf9657e97..8a89ad113 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,6 +346,9 @@ importers: remark-gfm: specifier: ^4.0.1 version: 4.0.1 + semver: + specifier: ^7.6.0 + version: 7.7.3 shadcn: specifier: ^4.1.2 version: 4.1.2(@types/node@24.12.0)(typescript@5.9.3) @@ -443,6 +446,9 @@ importers: '@types/react-dom': specifier: ^19.1.0 version: 19.2.3(@types/react@19.2.11) + '@types/semver': + specifier: ^7.5.0 + version: 7.7.1 '@vitejs/plugin-react': specifier: ^4.2.1 version: 4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -5297,6 +5303,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -17032,6 +17041,8 @@ snapshots: dependencies: '@types/node': 24.12.0 + '@types/semver@7.7.1': {} + '@types/stack-utils@2.0.3': {} '@types/statuses@2.0.6': {}