Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
34 changes: 34 additions & 0 deletions apps/code/src/renderer/features/sessions/hooks/useAgentVersion.ts
Original file line number Diff line number Diff line change
@@ -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);
}
55 changes: 55 additions & 0 deletions apps/code/src/renderer/features/sessions/service/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}),
);
});
});
Comment on lines +1534 to +1587
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing local-session coverage for agentVersion capture

The PR description explicitly states that version capture is unconditional — it applies to both cloud and local sessions. The new test only exercises the cloud path (isCloud: true). A complementary test for a local (non-cloud) session verifying that agentVersion is stored — and that status is NOT flipped to "connected" — would confirm the unconditional behaviour described in the PR and guard against an accidental isCloud gate being added in future.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/sessions/service/service.test.ts
Line: 1534-1587

Comment:
**Missing local-session coverage for `agentVersion` capture**

The PR description explicitly states that version capture is unconditional — it applies to both cloud and local sessions. The new test only exercises the cloud path (`isCloud: true`). A complementary test for a local (non-cloud) session verifying that `agentVersion` is stored — and that `status` is NOT flipped to `"connected"` — would confirm the unconditional behaviour described in the PR and guard against an accidental `isCloud` gate being added in future.

How can I resolve this? If you propose a fix, please make it concise.


it("does not re-flip status when run_started arrives but session is already connected", async () => {
const service = getSessionService();
const connectedSession = createMockSession({
Expand Down
16 changes: 13 additions & 3 deletions apps/code/src/renderer/features/sessions/service/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentSession> = {};
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
73 changes: 73 additions & 0 deletions apps/code/src/renderer/utils/agentVersion.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Comment on lines +5 to +35
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 These test blocks contain multiple inline assertions for clearly data-driven inputs, which the team's style guide flags as a preference for parameterised tests (it.each). If the first assertion fails, subsequent ones never run, making debugging harder. Consider refactoring into it.each tables.

Suggested change
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.each([
["0.40.1", ">=0.40.1", true],
["0.41.0", ">=0.40.1", true],
["1.0.0", ">=0.40.1", true],
["0.40.0", ">=0.40.1", false],
["0.39.99", ">=0.40.1", false],
["1.0.1", ">1.0.0", true],
["1.0.0", ">1.0.0", false],
["0.9.9", "<1.0.0", true],
["1.0.0", "<1.0.0", false],
["1.0.0", "<=1.0.0", true],
["1.2.5", "^1.2.0", true],
["2.0.0", "^1.2.0", false],
["1.2.5", "~1.2.0", true],
["1.3.0", "~1.2.0", false],
["0.50.0", ">=0.40.0 <1.0.0", true],
["1.0.0", ">=0.40.0 <1.0.0", false],
["0.39.0", ">=0.40.0 <1.0.0", false],
])(
"isAgentVersion(%s, %s) === %s",
(version, range, expected) => {
expect(isAgentVersion(version, range)).toBe(expected);
},
);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/utils/agentVersion.test.ts
Line: 5-35

Comment:
These test blocks contain multiple inline assertions for clearly data-driven inputs, which the team's style guide flags as a preference for parameterised tests (`it.each`). If the first assertion fails, subsequent ones never run, making debugging harder. Consider refactoring into `it.each` tables.

```suggestion
  it.each([
    ["0.40.1", ">=0.40.1", true],
    ["0.41.0", ">=0.40.1", true],
    ["1.0.0", ">=0.40.1", true],
    ["0.40.0", ">=0.40.1", false],
    ["0.39.99", ">=0.40.1", false],
    ["1.0.1", ">1.0.0", true],
    ["1.0.0", ">1.0.0", false],
    ["0.9.9", "<1.0.0", true],
    ["1.0.0", "<1.0.0", false],
    ["1.0.0", "<=1.0.0", true],
    ["1.2.5", "^1.2.0", true],
    ["2.0.0", "^1.2.0", false],
    ["1.2.5", "~1.2.0", true],
    ["1.3.0", "~1.2.0", false],
    ["0.50.0", ">=0.40.0 <1.0.0", true],
    ["1.0.0", ">=0.40.0 <1.0.0", false],
    ["0.39.0", ">=0.40.0 <1.0.0", false],
  ])(
    "isAgentVersion(%s, %s) === %s",
    (version, range, expected) => {
      expect(isAgentVersion(version, range)).toBe(expected);
    },
  );
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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);
});
});
});
29 changes: 29 additions & 0 deletions apps/code/src/renderer/utils/agentVersion.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
2 changes: 1 addition & 1 deletion packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
);
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading