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
4 changes: 2 additions & 2 deletions packages/agent/src/execution-mode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CODEX_MODE_PRESETS } from "@posthog/shared";
import { CODEX_MODE_PRESETS, type ExecutionMode } from "@posthog/shared";
import { ALLOW_BYPASS } from "./utils/common";

export interface ModeInfo {
Expand Down Expand Up @@ -68,7 +68,7 @@ export const CODEX_NATIVE_MODES = ["auto", "read-only", "full-access"] as const;
export type CodexNativeMode = (typeof CODEX_NATIVE_MODES)[number];

/** Union of all permission mode IDs across adapters */
export type PermissionMode = CodeExecutionMode | CodexNativeMode;
export type PermissionMode = ExecutionMode;

export function isCodexNativeMode(mode: string): mode is CodexNativeMode {
return (CODEX_NATIVE_MODES as readonly string[]).includes(mode);
Expand Down
164 changes: 164 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,74 @@ describe("PostHogAPIClient", () => {
);
});

it("maps plan to read-only for cloud Codex runs", async () => {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);

const post = vi.fn().mockResolvedValue({
id: "task-123",
title: "Task",
description: "Task",
created_at: "2026-04-14T00:00:00Z",
updated_at: "2026-04-14T00:00:00Z",
origin_product: "user_created",
});

(client as unknown as { api: { post: typeof post } }).api = { post };

await client.runTaskInCloud("task-123", "feature/codex-plan", {
adapter: "codex",
model: "gpt-5.4",
initialPermissionMode: "plan",
});

expect(post).toHaveBeenCalledWith(
"/api/projects/{project_id}/tasks/{id}/run/",
expect.objectContaining({
body: expect.objectContaining({
initial_permission_mode: "read-only",
}),
}),
);
});

it("omits the permission mode when no adapter is set", async () => {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);

const post = vi.fn().mockResolvedValue({
id: "task-123",
title: "Task",
description: "Task",
created_at: "2026-04-14T00:00:00Z",
updated_at: "2026-04-14T00:00:00Z",
origin_product: "user_created",
});

(client as unknown as { api: { post: typeof post } }).api = { post };

await client.runTaskInCloud("task-123", "feature/no-adapter", {
initialPermissionMode: "plan",
});

expect(post).toHaveBeenCalledWith(
"/api/projects/{project_id}/tasks/{id}/run/",
expect.objectContaining({
body: expect.not.objectContaining({
initial_permission_mode: expect.anything(),
}),
}),
);
});

it("rejects unsupported reasoning effort for cloud Codex runs", async () => {
const client = new PostHogAPIClient(
"http://localhost:8000",
Expand Down Expand Up @@ -177,6 +245,102 @@ describe("PostHogAPIClient", () => {
);
});

it("maps the permission mode per adapter when creating task runs", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "run-123", environment: "cloud" }),
});
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);

(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = {
baseUrl: "http://localhost:8000",
fetcher: { fetch },
};

await client.createTaskRun("task-123", {
environment: "cloud",
adapter: "claude",
model: "claude-opus-4-8",
initialPermissionMode: "read-only",
});

const body = JSON.parse(fetch.mock.calls[0][0].overrides.body as string);
expect(body.initial_permission_mode).toBe("plan");
});

it("omits the permission mode from created task runs without an adapter", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: "run-123", environment: "cloud" }),
});
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);

(
client as unknown as {
api: { baseUrl: string; fetcher: { fetch: typeof fetch } };
}
).api = {
baseUrl: "http://localhost:8000",
fetcher: { fetch },
};

await client.createTaskRun("task-123", {
environment: "cloud",
initialPermissionMode: "plan",
});

const body = JSON.parse(fetch.mock.calls[0][0].overrides.body as string);
expect(body).not.toHaveProperty("initial_permission_mode");
});

it("omits the permission mode when none is selected", async () => {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
async () => "token",
123,
);

const post = vi.fn().mockResolvedValue({
id: "task-123",
title: "Task",
description: "Task",
created_at: "2026-04-14T00:00:00Z",
updated_at: "2026-04-14T00:00:00Z",
origin_product: "user_created",
});

(client as unknown as { api: { post: typeof post } }).api = { post };

await client.runTaskInCloud("task-123", "feature/no-mode", {
adapter: "codex",
model: "gpt-5.4",
});

expect(post).toHaveBeenCalledWith(
"/api/projects/{project_id}/tasks/{id}/run/",
expect.objectContaining({
body: expect.not.objectContaining({
initial_permission_mode: expect.anything(),
}),
}),
);
});

it("starts an existing cloud task run with run-scoped artifact ids", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
Expand Down
15 changes: 10 additions & 5 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import "./generated.augment";
import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort";
import type { PermissionMode } from "@posthog/agent/execution-mode";
import type {
Adapter,
CloudRunSource,
ExecutionMode,
PrAuthorshipMode,
SeatData,
StoredLogEntry,
Expand All @@ -12,6 +12,7 @@ import type {
import {
DISMISSAL_REASON_OPTIONS,
type DismissalReasonOptionValue,
resolveCloudInitialPermissionMode,
SEAT_PRODUCT_KEY,
} from "@posthog/shared";
import type {
Expand Down Expand Up @@ -491,7 +492,7 @@ interface CloudRunOptions {
autoPublish?: boolean;
runSource?: CloudRunSource;
signalReportId?: string;
initialPermissionMode?: PermissionMode;
initialPermissionMode?: ExecutionMode;
homeQuickAction?: string;
}

Expand Down Expand Up @@ -546,6 +547,13 @@ function buildCloudRunRequestBody(
}
body.reasoning_effort = options.reasoningLevel;
}
// The API rejects initial_permission_mode without runtime_adapter and validates it per adapter.
if (options.initialPermissionMode) {
body.initial_permission_mode = resolveCloudInitialPermissionMode(
options.adapter,
options.initialPermissionMode,
);
}
}
if (options?.resumeFromRunId) {
body.resume_from_run_id = options.resumeFromRunId;
Expand Down Expand Up @@ -574,9 +582,6 @@ function buildCloudRunRequestBody(
if (options?.signalReportId) {
body.signal_report_id = options.signalReportId;
}
if (options?.initialPermissionMode) {
body.initial_permission_mode = options.initialPermissionMode;
}
if (options?.homeQuickAction) {
body.home_quick_action = options.homeQuickAction;
}
Expand Down
26 changes: 26 additions & 0 deletions packages/shared/src/execution-modes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { resolveCloudInitialPermissionMode } from "./execution-modes";

describe("resolveCloudInitialPermissionMode", () => {
it.each([
["codex", "auto", "auto"],
["codex", "read-only", "read-only"],
["codex", "full-access", "full-access"],
["codex", "plan", "read-only"],
["codex", "default", "auto"],
["codex", "acceptEdits", "auto"],
["codex", "bypassPermissions", "full-access"],
["claude", "default", "default"],
["claude", "acceptEdits", "acceptEdits"],
["claude", "plan", "plan"],
["claude", "bypassPermissions", "bypassPermissions"],
["claude", "auto", "auto"],
["claude", "read-only", "plan"],
["claude", "full-access", "bypassPermissions"],
] as const)(
"resolves %s adapter mode %s to %s",
(adapter, mode, expected) => {
expect(resolveCloudInitialPermissionMode(adapter, mode)).toBe(expected);
},
);
});
63 changes: 63 additions & 0 deletions packages/shared/src/execution-modes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { Adapter } from "./adapter";
import type { ExecutionMode } from "./exec-types";

export interface CodexModePreset {
id: "plan" | "read-only" | "auto" | "full-access";
name: string;
Expand Down Expand Up @@ -32,3 +35,63 @@ export const CODEX_MODE_PRESETS: readonly CodexModePreset[] = [
description: "Auto-approves all operations",
},
];

const CLAUDE_CLOUD_PERMISSION_MODES = [
"default",
"acceptEdits",
"plan",
"bypassPermissions",
"auto",
] as const;

const CODEX_CLOUD_PERMISSION_MODES = [
"auto",
"read-only",
"full-access",
] as const;

type ClaudeCloudPermissionMode = (typeof CLAUDE_CLOUD_PERMISSION_MODES)[number];
type CodexCloudPermissionMode = (typeof CODEX_CLOUD_PERMISSION_MODES)[number];

function isClaudeCloudPermissionMode(
mode: ExecutionMode,
): mode is ClaudeCloudPermissionMode {
return (CLAUDE_CLOUD_PERMISSION_MODES as readonly string[]).includes(mode);
}

function isCodexCloudPermissionMode(
mode: ExecutionMode,
): mode is CodexCloudPermissionMode {
return (CODEX_CLOUD_PERMISSION_MODES as readonly string[]).includes(mode);
}

// The cloud API's per-adapter mode enums are narrower than the local presets (no "plan" for codex); degrade to the nearest permission ceiling.
const CODEX_CLOUD_MODE_FALLBACKS: Record<
Exclude<ClaudeCloudPermissionMode, "auto">,
CodexCloudPermissionMode
> = {
default: "auto",
acceptEdits: "auto",
plan: "read-only",
bypassPermissions: "full-access",
};

const CLAUDE_CLOUD_MODE_FALLBACKS: Record<
Exclude<CodexCloudPermissionMode, "auto">,
ClaudeCloudPermissionMode
> = {
"read-only": "plan",
"full-access": "bypassPermissions",
};

export function resolveCloudInitialPermissionMode(
adapter: Adapter,
mode: ExecutionMode,
): ExecutionMode {
if (adapter === "codex") {
if (isCodexCloudPermissionMode(mode)) return mode;
return CODEX_CLOUD_MODE_FALLBACKS[mode] ?? "auto";
}
if (isClaudeCloudPermissionMode(mode)) return mode;
return CLAUDE_CLOUD_MODE_FALLBACKS[mode] ?? "default";
}
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export type { ExecutionMode } from "./exec-types";
export {
CODEX_MODE_PRESETS,
type CodexModePreset,
resolveCloudInitialPermissionMode,
} from "./execution-modes";
export * from "./flags";
export * from "./git-domain";
Expand Down
Loading