diff --git a/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx b/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx index 862d79127..c8ae09184 100644 --- a/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/ModelSelector.tsx @@ -41,13 +41,9 @@ export function ModelSelector({ const handleChange = (value: string) => { onModelChange?.(value); - if (taskId && session?.status === "connected") { - getSessionService().setSessionConfigOption( - taskId, - selectOption.id, - value, - ); - } + if (!taskId || !session) return; + if (session.status !== "connected" && !session.isCloud) return; + getSessionService().setSessionConfigOption(taskId, selectOption.id, value); }; const currentValue = selectOption.currentValue; diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index 1a73f33b3..4b9f7071a 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -78,6 +78,7 @@ export function useSessionConnection({ : undefined; const adapter = task.latest_run.runtime_adapter === "codex" ? "codex" : "claude"; + const initialModel = task.latest_run.model ?? undefined; const cleanup = getSessionService().watchCloudTask( task.id, runId, @@ -89,6 +90,7 @@ export function useSessionConnection({ task.latest_run?.log_url, initialMode, adapter, + initialModel, ); return cleanup; }, [ @@ -101,6 +103,7 @@ export function useSessionConnection({ task.id, task.latest_run?.id, task.latest_run?.log_url, + task.latest_run?.model, 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 7682d60a8..a1fc033d5 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -18,6 +18,7 @@ const mockTrpcAgent = vi.hoisted(() => ({ onPermissionRequest: { subscribe: vi.fn() }, onSessionIdleKilled: { subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })) }, resetAll: { mutate: vi.fn().mockResolvedValue(undefined) }, + getPreviewConfigOptions: { query: vi.fn().mockResolvedValue([]) }, })); const mockTrpcWorkspace = vi.hoisted(() => ({ @@ -78,6 +79,18 @@ vi.mock("@features/sessions/stores/sessionStore", () => ({ sessionStoreSetters: mockSessionStoreSetters, getConfigOptionByCategory: mockGetConfigOptionByCategory, mergeConfigOptions: vi.fn((live: unknown[], _persisted: unknown[]) => live), + flattenSelectOptions: vi.fn( + (options: Array<{ options?: unknown[] }> | undefined) => { + if (!options?.length) return []; + const first = options[0] as { options?: unknown[] }; + if (first && Array.isArray(first.options)) { + return options.flatMap( + (group) => (group as { options: unknown[] }).options, + ); + } + return options; + }, + ), })); const mockAuthenticatedClient = vi.hoisted(() => ({ @@ -748,6 +761,100 @@ describe("SessionService", () => { }); }); + it("merges model and effort options fetched from preview-config into the cloud session", async () => { + const service = getSessionService(); + + const sessionAfterInit = createMockSession({ + taskRunId: "run-model-123", + taskId: "task-model-123", + isCloud: true, + configOptions: [ + { + id: "mode", + name: "Approval Preset", + type: "select", + category: "mode", + currentValue: "plan", + options: [], + }, + ], + }); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-model-123": sessionAfterInit, + }); + + mockTrpcAgent.getPreviewConfigOptions.query.mockResolvedValueOnce([ + { + id: "mode", + name: "Approval Preset", + type: "select", + category: "mode", + currentValue: "plan", + options: [], + }, + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "claude-opus-4-7", + options: [ + { value: "claude-opus-4-7", name: "Opus 4.7" }, + { value: "claude-sonnet-4-6", name: "Sonnet 4.6" }, + ], + }, + { + id: "effort", + name: "Effort", + type: "select", + category: "thought_level", + currentValue: "high", + options: [], + }, + ]); + + service.watchCloudTask( + "task-model-123", + "run-model-123", + "https://api.example.com", + 7, + undefined, + undefined, + undefined, + "claude", + "claude-sonnet-4-6", + ); + + await vi.waitFor(() => { + expect( + mockTrpcAgent.getPreviewConfigOptions.query, + ).toHaveBeenCalledWith({ + apiHost: "https://api.example.com", + adapter: "claude", + }); + }); + + await vi.waitFor(() => { + const calls = mockSessionStoreSetters.updateSession.mock.calls as Array< + [string, { configOptions?: Array<{ id: string }> }] + >; + const modelUpdate = calls.find( + ([runId, patch]) => + runId === "run-model-123" && + patch.configOptions?.some((o) => o.id === "model"), + ); + expect(modelUpdate).toBeTruthy(); + const ids = modelUpdate?.[1].configOptions?.map((o) => o.id); + expect(ids).toEqual( + expect.arrayContaining(["mode", "model", "effort"]), + ); + const modelOpt = modelUpdate?.[1].configOptions?.find( + (o) => o.id === "model", + ) as { currentValue?: string } | undefined; + expect(modelOpt?.currentValue).toBe("claude-sonnet-4-6"); + }); + }); + it("retries an errored cloud watcher in place", async () => { const service = getSessionService(); mockSessionStoreSetters.getSessionByTaskId.mockReturnValue({ @@ -1346,6 +1453,58 @@ describe("SessionService", () => { mockSessionConfigStore.updatePersistedConfigOptionValue, ).toHaveBeenLastCalledWith("run-123", "mode", "default"); }); + + it("routes cloud sessions through sendCommand with set_config_option", async () => { + const service = getSessionService(); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "in_progress", + configOptions: [ + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "claude-opus-4-7", + options: [], + }, + ], + }), + ); + mockTrpcCloudTask.sendCommand.mutate.mockResolvedValue({ + success: true, + }); + + await service.setSessionConfigOption( + "task-123", + "model", + "claude-sonnet-4-6", + ); + + expect(mockTrpcAgent.setConfigOption.mutate).not.toHaveBeenCalled(); + expect(mockTrpcCloudTask.sendCommand.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + method: "set_config_option", + params: { configId: "model", value: "claude-sonnet-4-6" }, + }), + ); + expect(mockSessionStoreSetters.updateSession).toHaveBeenCalledWith( + "run-123", + { + configOptions: [ + { + id: "model", + name: "Model", + type: "select", + category: "model", + currentValue: "claude-sonnet-4-6", + options: [], + }, + ], + }, + ); + }); }); describe("clearSessionError", () => { diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 9ffc673f0..e65ebce65 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -26,6 +26,7 @@ import type { PermissionRequest, } from "@features/sessions/stores/sessionStore"; import { + flattenSelectOptions, getConfigOptionByCategory, mergeConfigOptions, sessionStoreSetters, @@ -90,10 +91,15 @@ const LOCAL_SESSION_RECOVERY_FAILED_MESSAGE = /** * Build default configOptions for cloud sessions so the mode switcher * is available in the UI even without a local agent connection. + * + * The `extra` options (model, thought_level) come from the preview-config + * trpc query, which is async. Callers populate them by calling + * `fetchAndApplyCloudPreviewOptions` after the session exists in the store. */ function buildCloudDefaultConfigOptions( initialMode: string | undefined, adapter: Adapter = "claude", + extra: SessionConfigOption[] = [], ): SessionConfigOption[] { const modes = adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); @@ -116,6 +122,7 @@ function buildCloudDefaultConfigOptions( category: "mode" as SessionConfigOption["category"], description: "Choose an approval and sandboxing preset for your session", }, + ...extra, ]; } @@ -186,6 +193,14 @@ export class SessionService { /** Maps toolCallId → cloud requestId for routing permission responses */ private cloudPermissionRequestIds = new Map(); private idleKilledSubscription: { unsubscribe: () => void } | null = null; + /** + * Cached preview-config-options responses keyed by `${apiHost}::${adapter}`. + * Shared across cloud sessions so switching model/adapter reuses the list. + */ + private previewConfigOptionsCache = new Map< + string, + Promise + >(); constructor() { this.idleKilledSubscription = @@ -1698,6 +1713,12 @@ export class SessionService { typeof newRun.state?.initial_permission_mode === "string" ? newRun.state.initial_permission_mode : undefined; + const priorModel = getConfigOptionByCategory( + session.configOptions, + "model", + )?.currentValue; + const initialModel = + newRun.model ?? (typeof priorModel === "string" ? priorModel : undefined); this.watchCloudTask( session.taskId, newRun.id, @@ -1707,6 +1728,7 @@ export class SessionService { newRun.log_url, initialMode, newRun.runtime_adapter ?? session.adapter ?? "claude", + initialModel, ); // Invalidate task queries so the UI picks up the new run metadata @@ -2218,6 +2240,70 @@ export class SessionService { ); } + /** + * Fetch model/effort options from the main-process preview-config endpoint + * and merge them into the cloud session's configOptions. Cached per + * (apiHost, adapter) so repeated visits don't refetch. + * + * Runs fire-and-forget: the session stays usable with just the `mode` option + * if the fetch fails or is still in flight. + */ + private async fetchAndApplyCloudPreviewOptions( + taskRunId: string, + apiHost: string, + adapter: Adapter, + initialModel?: string, + ): Promise { + const cacheKey = `${apiHost}::${adapter}`; + let pending = this.previewConfigOptionsCache.get(cacheKey); + if (!pending) { + pending = trpcClient.agent.getPreviewConfigOptions + .query({ apiHost, adapter }) + .catch((err: unknown) => { + log.warn("Failed to fetch preview config options for cloud session", { + apiHost, + adapter, + error: err, + }); + this.previewConfigOptionsCache.delete(cacheKey); + return [] as SessionConfigOption[]; + }); + this.previewConfigOptionsCache.set(cacheKey, pending); + } + + const previewOptions = await pending; + const extras = previewOptions + .filter( + (opt) => opt.category === "model" || opt.category === "thought_level", + ) + .map((opt) => { + if ( + opt.category === "model" && + opt.type === "select" && + typeof initialModel === "string" + ) { + const flat = flattenSelectOptions(opt.options); + if (flat.some((o) => o.value === initialModel)) { + return { ...opt, currentValue: initialModel }; + } + } + return opt; + }); + + if (extras.length === 0) return; + + const session = sessionStoreSetters.getSessions()[taskRunId]; + if (!session) return; + + const existingOptions = session.configOptions ?? []; + const existingIds = new Set(existingOptions.map((o) => o.id)); + const newExtras = extras.filter((o) => !existingIds.has(o.id)); + if (newExtras.length === 0) return; + const merged = [...existingOptions, ...newExtras]; + + sessionStoreSetters.updateSession(taskRunId, { configOptions: merged }); + } + /** * Start watching a cloud task via main-process CloudTaskService. * @@ -2235,6 +2321,7 @@ export class SessionService { logUrl?: string, initialMode?: string, adapter: Adapter = "claude", + initialModel?: string, ): () => void { const taskRunId = runId; const startToken = ++this.nextCloudTaskWatchToken; @@ -2265,6 +2352,12 @@ export class SessionService { configOptions: buildCloudDefaultConfigOptions(currentMode, adapter), }); } + void this.fetchAndApplyCloudPreviewOptions( + existing.taskRunId, + apiHost, + adapter, + initialModel, + ); } return () => {}; } @@ -2326,6 +2419,13 @@ export class SessionService { } } + void this.fetchAndApplyCloudPreviewOptions( + taskRunId, + apiHost, + adapter, + initialModel, + ); + if (shouldHydrateSession) { this.hydrateCloudTaskSessionFromLogs(taskId, taskRunId, logUrl); }