diff --git a/apps/code/src/renderer/api/posthogClient.test.ts b/apps/code/src/renderer/api/posthogClient.test.ts index 14396d752..c0c58e2b5 100644 --- a/apps/code/src/renderer/api/posthogClient.test.ts +++ b/apps/code/src/renderer/api/posthogClient.test.ts @@ -42,6 +42,41 @@ describe("PostHogAPIClient", () => { ); }); + it("preserves Codex-native permission modes for cloud 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-mode", { + adapter: "codex", + model: "gpt-5.4", + initialPermissionMode: "auto", + }); + + expect(post).toHaveBeenCalledWith( + "/api/projects/{project_id}/tasks/{id}/run/", + expect.objectContaining({ + body: expect.objectContaining({ + initial_permission_mode: "auto", + }), + }), + ); + }); + it("rejects unsupported reasoning effort for cloud Codex runs", async () => { const client = new PostHogAPIClient( "http://localhost:8000", diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index a1e4e6451..a28eb3bef 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1,4 +1,5 @@ import { isSupportedReasoningEffort } from "@posthog/agent/adapters/reasoning-effort"; +import { type PermissionMode } from "@posthog/agent/execution-mode"; import type { ActionabilityJudgmentArtefact, AvailableSuggestedReviewer, @@ -755,7 +756,7 @@ export class PostHogAPIClient { runSource?: CloudRunSource; signalReportId?: string; githubUserToken?: string; - initialPermissionMode?: string; + initialPermissionMode?: PermissionMode; }, ): Promise { const teamId = await this.getTeamId(); diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index 9edee2b22..1a73f33b3 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -76,6 +76,8 @@ export function useSessionConnection({ typeof task.latest_run.state?.initial_permission_mode === "string" ? task.latest_run.state.initial_permission_mode : undefined; + const adapter = + task.latest_run.runtime_adapter === "codex" ? "codex" : "claude"; const cleanup = getSessionService().watchCloudTask( task.id, runId, @@ -86,6 +88,7 @@ export function useSessionConnection({ }, task.latest_run?.log_url, initialMode, + adapter, ); return cleanup; }, [ @@ -98,6 +101,7 @@ export function useSessionConnection({ task.id, task.latest_run?.id, task.latest_run?.log_url, + task.latest_run?.runtime_adapter, task.latest_run?.state?.initial_permission_mode, ]); 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 b8c1cb089..7682d60a8 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -172,9 +172,13 @@ vi.mock("@renderer/stores/connectivityStore", () => ({ getIsOnline: () => mockGetIsOnline(), })); +const mockSettingsState = vi.hoisted(() => ({ + customInstructions: "", +})); + vi.mock("@features/settings/stores/settingsStore", () => ({ useSettingsStore: { - getState: () => ({ customInstructions: "" }), + getState: () => mockSettingsState, }, })); @@ -282,6 +286,7 @@ describe("SessionService", () => { beforeEach(() => { vi.clearAllMocks(); resetSessionService(); + mockSettingsState.customInstructions = ""; mockGetIsOnline.mockReturnValue(true); mockGetConfigOptionByCategory.mockReturnValue(undefined); mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(undefined); @@ -516,6 +521,37 @@ describe("SessionService", () => { }); describe("watchCloudTask", () => { + it("builds codex cloud mode options using native codex modes", () => { + const service = getSessionService(); + + service.watchCloudTask( + "task-123", + "run-123", + "https://api.anthropic.com", + 123, + undefined, + undefined, + "full-access", + "codex", + ); + + expect(mockSessionStoreSetters.setSession).toHaveBeenCalledWith( + expect.objectContaining({ + configOptions: [ + expect.objectContaining({ + id: "mode", + currentValue: "full-access", + options: [ + expect.objectContaining({ value: "read-only" }), + expect.objectContaining({ value: "auto" }), + expect.objectContaining({ value: "full-access" }), + ], + }), + ], + }), + ); + }); + it("resets a same-run preloaded session before the first cloud snapshot", () => { const service = getSessionService(); mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 7279df08b..9ffc673f0 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -33,7 +33,10 @@ import { import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { taskViewedApi } from "@features/sidebar/hooks/useTaskViewed"; import { isNotification, POSTHOG_NOTIFICATIONS } from "@posthog/agent"; -import { getAvailableModes } from "@posthog/agent/execution-mode"; +import { + getAvailableCodexModes, + getAvailableModes, +} from "@posthog/agent/execution-mode"; import { DEFAULT_GATEWAY_MODEL } from "@posthog/agent/gateway-models"; import { getIsOnline } from "@renderer/stores/connectivityStore"; import { trpcClient } from "@renderer/trpc/client"; @@ -89,15 +92,23 @@ const LOCAL_SESSION_RECOVERY_FAILED_MESSAGE = * is available in the UI even without a local agent connection. */ function buildCloudDefaultConfigOptions( - initialMode = "plan", + initialMode: string | undefined, + adapter: Adapter = "claude", ): SessionConfigOption[] { - const modes = getAvailableModes(); + const modes = + adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); + const currentMode = + typeof initialMode === "string" + ? initialMode + : adapter === "codex" + ? "auto" + : "plan"; return [ { id: "mode", name: "Approval Preset", type: "select", - currentValue: initialMode, + currentValue: currentMode, options: modes.map((mode) => ({ value: mode.id, name: mode.name, @@ -399,7 +410,6 @@ export class SessionService { .getState() .getAdapter(taskRunId); const resolvedAdapter = adapter ?? storedAdapter; - const persistedConfigOptions = getPersistedConfigOptions(taskRunId); const session = this.createBaseSession(taskRunId, taskId, taskTitle); @@ -1684,7 +1694,20 @@ export class SessionService { // in run state (pending_user_message), NOT via user_message command. // Start the watcher immediately so we don't miss status updates. - this.watchCloudTask(session.taskId, newRun.id, auth.apiHost, auth.teamId); + const initialMode = + typeof newRun.state?.initial_permission_mode === "string" + ? newRun.state.initial_permission_mode + : undefined; + this.watchCloudTask( + session.taskId, + newRun.id, + auth.apiHost, + auth.teamId, + undefined, + newRun.log_url, + initialMode, + newRun.runtime_adapter ?? session.adapter ?? "claude", + ); // Invalidate task queries so the UI picks up the new run metadata queryClient.invalidateQueries({ queryKey: ["tasks"] }); @@ -2211,6 +2234,7 @@ export class SessionService { onStatusChange?: () => void, logUrl?: string, initialMode?: string, + adapter: Adapter = "claude", ): () => void { const taskRunId = runId; const startToken = ++this.nextCloudTaskWatchToken; @@ -2226,10 +2250,21 @@ export class SessionService { existingWatcher.onStatusChange = onStatusChange; // Ensure configOptions is populated on revisit const existing = sessionStoreSetters.getSessionByTaskId(taskId); - if (existing && !existing.configOptions?.length) { - sessionStoreSetters.updateSession(existing.taskRunId, { - configOptions: buildCloudDefaultConfigOptions(initialMode), - }); + if (existing) { + const existingMode = getConfigOptionByCategory( + existing.configOptions, + "mode", + )?.currentValue; + const currentMode = + typeof existingMode === "string" ? existingMode : initialMode; + const shouldRefreshConfigOptions = + !existing.configOptions?.length || existing.adapter !== adapter; + if (shouldRefreshConfigOptions) { + sessionStoreSetters.updateSession(existing.taskRunId, { + adapter, + configOptions: buildCloudDefaultConfigOptions(currentMode, adapter), + }); + } } return () => {}; } @@ -2263,14 +2298,28 @@ export class SessionService { const session = this.createBaseSession(taskRunId, taskId, taskTitle); session.status = "disconnected"; session.isCloud = true; - session.configOptions = buildCloudDefaultConfigOptions(initialMode); + session.adapter = adapter; + session.configOptions = buildCloudDefaultConfigOptions( + initialMode, + adapter, + ); sessionStoreSetters.setSession(session); } else { // Ensure cloud flag and configOptions are set on existing sessions const updates: Partial = {}; if (!existing.isCloud) updates.isCloud = true; - if (!existing.configOptions?.length) { - updates.configOptions = buildCloudDefaultConfigOptions(initialMode); + if (existing.adapter !== adapter) updates.adapter = adapter; + if (!existing.configOptions?.length || existing.adapter !== adapter) { + const existingMode = getConfigOptionByCategory( + existing.configOptions, + "mode", + )?.currentValue; + const currentMode = + typeof existingMode === "string" ? existingMode : initialMode; + updates.configOptions = buildCloudDefaultConfigOptions( + currentMode, + adapter, + ); } if (Object.keys(updates).length > 0) { sessionStoreSetters.updateSession(existing.taskRunId, updates); diff --git a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts index 3ffc25362..5a013b0f3 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts @@ -92,8 +92,12 @@ export function usePreviewConfig( ) { initialMode = lastUsedInitialTaskMode; } else { + const fallbackDefault = adapter === "codex" ? "auto" : "plan"; initialMode = - typeof serverDefault === "string" ? serverDefault : "plan"; + typeof serverDefault === "string" && + availableValues.includes(serverDefault) + ? serverDefault + : fallbackDefault; } const withMode = options.map((opt) => diff --git a/apps/code/src/renderer/sagas/task/task-creation.test.ts b/apps/code/src/renderer/sagas/task/task-creation.test.ts index 9b808de7d..5570eb927 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.test.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.test.ts @@ -166,7 +166,7 @@ describe("TaskCreationSaga", () => { runSource: "manual", signalReportId: undefined, githubUserToken: undefined, - initialPermissionMode: "plan", + initialPermissionMode: "auto", }, ); expect(sendRunCommandMock).not.toHaveBeenCalled(); diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 27d179467..8d4f9003f 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -308,7 +308,9 @@ export class TaskCreationSaga extends Saga< runSource: input.cloudRunSource ?? "manual", signalReportId: input.signalReportId, githubUserToken, - initialPermissionMode: input.executionMode ?? "plan", + initialPermissionMode: + input.executionMode ?? + (input.adapter === "codex" ? "auto" : "plan"), }); }, rollback: async () => {