diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index dafcaf169..702c8de93 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -49,6 +49,7 @@ export const startSessionInput = z.object({ additionalDirectories: z.array(z.string()).optional(), customInstructions: z.string().max(2000).optional(), effort: effortLevelSchema.optional(), + model: z.string().optional(), }); export type StartSessionInput = z.infer; @@ -292,3 +293,10 @@ export const getGatewayModelsInput = z.object({ }); export const getGatewayModelsOutput = z.array(modelOptionSchema); + +export const getPreviewConfigOptionsInput = z.object({ + apiHost: z.string(), + adapter: z.enum(["claude", "codex"]), +}); + +export const getPreviewConfigOptionsOutput = z.array(sessionConfigOptionSchema); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 9d64c2ac9..a21ecc5e9 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -14,11 +14,16 @@ import { } from "@agentclientprotocol/sdk"; import { isMcpToolReadOnly } from "@posthog/agent"; import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; +import { getEffortOptions } from "@posthog/agent/adapters/claude/session/models"; import { Agent } from "@posthog/agent/agent"; +import { getAvailableModes } from "@posthog/agent/execution-mode"; import { + DEFAULT_GATEWAY_MODEL, fetchGatewayModels, formatGatewayModelName, getProviderName, + isAnthropicModel, + isOpenAIModel, } from "@posthog/agent/gateway-models"; import { getLlmGatewayUrl } from "@posthog/agent/posthog-api"; import type { OnLogCallback } from "@posthog/agent/types"; @@ -189,6 +194,8 @@ interface SessionConfig { customInstructions?: string; /** Effort level for Claude sessions */ effort?: EffortLevel; + /** Model to use for the session (e.g. "claude-sonnet-4-6") */ + model?: string; } interface ManagedSession { @@ -465,9 +472,10 @@ export class AgentService extends TypedEventEmitter { permissionMode, customInstructions, effort, + model, } = config; - // Preview sessions don't need a real repo — use a temp directory + // Preview config doesn't need a real repo — use a temp directory const repoPath = taskId === "__preview__" ? tmpdir() : rawRepoPath; if (!isRetry) { @@ -638,6 +646,7 @@ export class AgentService extends TypedEventEmitter { sessionId: existingSessionId, systemPrompt, ...(permissionMode && { permissionMode }), + ...(model != null && { model }), claudeCode: { options: { ...(additionalDirectories?.length && { @@ -669,6 +678,7 @@ export class AgentService extends TypedEventEmitter { taskRunId, systemPrompt, ...(permissionMode && { permissionMode }), + ...(model != null && { model }), claudeCode: { options: { ...(additionalDirectories?.length && { additionalDirectories }), @@ -1362,6 +1372,7 @@ For git operations while detached: customInstructions: "customInstructions" in params ? params.customInstructions : undefined, effort: "effort" in params ? params.effort : undefined, + model: "model" in params ? params.model : undefined, }; } @@ -1513,4 +1524,98 @@ For git operations while detached: return getModelTier(a.modelId) - getModelTier(b.modelId); }); } + + async getPreviewConfigOptions( + apiHost: string, + adapter: "claude" | "codex" = "claude", + ): Promise { + const gatewayUrl = getLlmGatewayUrl(apiHost); + const gatewayModels = await fetchGatewayModels({ gatewayUrl }); + + const modelFilter = adapter === "codex" ? isOpenAIModel : isAnthropicModel; + + const modelOptions = gatewayModels + .filter((model) => modelFilter(model)) + .map((model) => ({ + value: model.id, + name: formatGatewayModelName(model), + description: `Context: ${model.context_window.toLocaleString()} tokens`, + })); + + const defaultModel = + adapter === "codex" + ? (modelOptions[0]?.value ?? "") + : DEFAULT_GATEWAY_MODEL; + + const resolvedModelId = modelOptions.some((o) => o.value === defaultModel) + ? defaultModel + : (modelOptions[0]?.value ?? defaultModel); + + if (!modelOptions.some((o) => o.value === resolvedModelId)) { + modelOptions.unshift({ + value: resolvedModelId, + name: resolvedModelId, + description: "Custom model", + }); + } + + const modeOptions = getAvailableModes().map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description ?? undefined, + })); + + const configOptions: SessionConfigOption[] = [ + { + id: "mode", + name: "Approval Preset", + type: "select", + currentValue: "plan", + options: modeOptions, + category: "mode", + description: + "Choose an approval and sandboxing preset for your session", + }, + { + id: "model", + name: "Model", + type: "select", + currentValue: resolvedModelId, + options: modelOptions, + category: "model", + description: "Choose which model Claude should use", + }, + ]; + + if (adapter === "codex") { + configOptions.push({ + id: "reasoning_effort", + name: "Reasoning Level", + type: "select", + currentValue: "high", + options: [ + { value: "low", name: "Low" }, + { value: "medium", name: "Medium" }, + { value: "high", name: "High" }, + ], + category: "thought_level", + description: "Controls how much reasoning effort the model uses", + }); + } else { + const effortOpts = getEffortOptions(resolvedModelId); + if (effortOpts) { + configOptions.push({ + id: "effort", + name: "Effort", + type: "select", + currentValue: "high", + options: effortOpts, + category: "thought_level", + description: "Controls how much effort Claude puts into its response", + }); + } + } + + return configOptions; + } } diff --git a/apps/code/src/main/trpc/routers/agent.ts b/apps/code/src/main/trpc/routers/agent.ts index f6907f61a..07cd63025 100644 --- a/apps/code/src/main/trpc/routers/agent.ts +++ b/apps/code/src/main/trpc/routers/agent.ts @@ -8,6 +8,8 @@ import { cancelSessionInput, getGatewayModelsInput, getGatewayModelsOutput, + getPreviewConfigOptionsInput, + getPreviewConfigOptionsOutput, listSessionsInput, listSessionsOutput, notifySessionContextInput, @@ -193,4 +195,11 @@ export const agentRouter = router({ .input(getGatewayModelsInput) .output(getGatewayModelsOutput) .query(({ input }) => getService().getGatewayModels(input.apiHost)), + + getPreviewConfigOptions: publicProcedure + .input(getPreviewConfigOptionsInput) + .output(getPreviewConfigOptionsOutput) + .query(({ input }) => + getService().getPreviewConfigOptions(input.apiHost, input.adapter), + ), }); diff --git a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx index 8f4e5cb13..77495339d 100644 --- a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx @@ -6,7 +6,6 @@ import type { MessageEditorHandle } from "@features/message-editor/components/Me import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; -import { getSessionService } from "@features/sessions/service/service"; import { cycleModeOption, getCurrentModeFromConfigOptions, @@ -14,7 +13,7 @@ import { import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { TaskInputEditor } from "@features/task-detail/components/TaskInputEditor"; import { WorkspaceModeSelect } from "@features/task-detail/components/WorkspaceModeSelect"; -import { usePreviewSession } from "@features/task-detail/hooks/usePreviewSession"; +import { usePreviewConfig } from "@features/task-detail/hooks/usePreviewConfig"; import { useTaskCreation } from "@features/task-detail/hooks/useTaskCreation"; import { useGithubBranches, @@ -104,9 +103,14 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; - // Preview session for config options — always claude - const { modeOption, thoughtOption, previewTaskId, isConnecting } = - usePreviewSession("claude"); + // Preview config options — always claude + const { + modeOption, + modelOption, + thoughtOption, + isLoading: isPreviewLoading, + setConfigOption, + } = usePreviewConfig("claude"); const currentExecutionMode = getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ?? @@ -193,13 +197,9 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { const handleCycleMode = useCallback(() => { const nextValue = cycleModeOption(modeOption, allowBypassPermissions); if (nextValue && modeOption) { - getSessionService().setSessionConfigOption( - previewTaskId, - modeOption.id, - nextValue, - ); + setConfigOption(modeOption.id, nextValue); } - }, [modeOption, allowBypassPermissions, previewTaskId]); + }, [modeOption, allowBypassPermissions, setConfigOption]); useHotkeys( "shift+tab", @@ -388,9 +388,16 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { directoryTooltip="Select a repository first" onEmptyChange={setEditorIsEmpty} adapter="claude" - previewTaskId={previewTaskId} + modelOption={modelOption} + thoughtOption={thoughtOption} + onConfigOptionChange={(configId, value) => { + setConfigOption(configId, value); + if (configId === modelOption?.id) { + handleModelChange(value); + } + }} onAdapterChange={() => {}} - isPreviewConnecting={isConnecting} + isLoading={isPreviewLoading} autoFocus={false} tourHighlight={ isHighlighted("model-selector") @@ -399,7 +406,6 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { ? "submit-button" : null } - onModelChange={handleModelChange} /> diff --git a/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx b/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx index e9e8083bc..df4b15c77 100644 --- a/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/ReasoningLevelSelector.tsx @@ -1,25 +1,21 @@ -import { Select, Text } from "@radix-ui/themes"; -import { getSessionService } from "../service/service"; -import { - flattenSelectOptions, - useAdapterForTask, - useSessionForTask, - useThoughtLevelConfigOptionForTask, -} from "../stores/sessionStore"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { Brain } from "@phosphor-icons/react"; +import { Flex, Select, Text } from "@radix-ui/themes"; +import { flattenSelectOptions } from "../stores/sessionStore"; interface ReasoningLevelSelectorProps { - taskId?: string; + thoughtOption?: SessionConfigOption; + adapter?: "claude" | "codex"; + onChange?: (value: string) => void; disabled?: boolean; } export function ReasoningLevelSelector({ - taskId, + thoughtOption, + adapter, + onChange, disabled, }: ReasoningLevelSelectorProps) { - const session = useSessionForTask(taskId); - const thoughtOption = useThoughtLevelConfigOptionForTask(taskId); - const adapter = useAdapterForTask(taskId); - if (!thoughtOption || thoughtOption.type !== "select") { return null; } @@ -30,37 +26,35 @@ export function ReasoningLevelSelector({ const activeLabel = options.find((opt) => opt.value === activeLevel)?.name ?? activeLevel; - const handleChange = (value: string) => { - if (taskId && session?.status === "connected") { - getSessionService().setSessionConfigOption( - taskId, - thoughtOption.id, - value, - ); - } - }; - return ( onChange?.(value)} disabled={disabled} size="1" > - - {adapter === "codex" ? "Reasoning" : "Effort"}: {activeLabel} - + + + + {adapter === "codex" ? "Reasoning" : "Effort"}: {activeLabel} + + {options.map((level) => ( diff --git a/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx b/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx index efcd848f2..b18359cd4 100644 --- a/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx +++ b/apps/code/src/renderer/features/sessions/components/UnifiedModelSelector.tsx @@ -1,4 +1,7 @@ -import type { SessionConfigSelectGroup } from "@agentclientprotocol/sdk"; +import type { + SessionConfigOption, + SessionConfigSelectGroup, +} from "@agentclientprotocol/sdk"; import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { ArrowsClockwise, @@ -9,12 +12,7 @@ import { } from "@phosphor-icons/react"; import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes"; import { Fragment, useMemo } from "react"; -import { getSessionService } from "../service/service"; -import { - flattenSelectOptions, - useModelConfigOptionForTask, - useSessionForTask, -} from "../stores/sessionStore"; +import { flattenSelectOptions } from "../stores/sessionStore"; const ADAPTER_ICONS: Record = { claude: , @@ -31,25 +29,22 @@ function getOtherAdapter(adapter: AgentAdapter): AgentAdapter { } interface UnifiedModelSelectorProps { - taskId?: string; + modelOption?: SessionConfigOption; adapter: AgentAdapter; onAdapterChange: (adapter: AgentAdapter) => void; + onModelChange?: (model: string) => void; disabled?: boolean; isConnecting?: boolean; - onModelChange?: (model: string) => void; } export function UnifiedModelSelector({ - taskId, + modelOption, adapter, onAdapterChange, + onModelChange, disabled, isConnecting, - onModelChange, }: UnifiedModelSelectorProps) { - const session = useSessionForTask(taskId); - const modelOption = useModelConfigOptionForTask(taskId); - const selectOption = modelOption?.type === "select" ? modelOption : undefined; const options = selectOption ? flattenSelectOptions(selectOption.options) @@ -69,10 +64,7 @@ export function UnifiedModelSelector({ const otherAdapter = getOtherAdapter(adapter); const handleModelSelect = (value: string) => { - if (taskId && session?.status === "connected" && modelOption) { - getSessionService().setSessionConfigOption(taskId, modelOption.id, value); - onModelChange?.(value); - } + onModelChange?.(value); }; const triggerStyle = { diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index 1c8eab208..cb7e20789 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -86,28 +86,24 @@ export function useSessionConnection({ return; } - connectingTasks.add(taskId); - - const isNewSession = !task.latest_run?.id; - const hasInitialPrompt = isNewSession && task.description; + // New sessions (no latest_run) are handled by the task creation saga, + // which passes model/adapter/executionMode. Only reconnect existing ones here. + if (!task.latest_run?.id) return; - if (hasInitialPrompt) { - markActivity(task.id); - } + connectingTasks.add(taskId); - log.info("Connecting to task session", { + log.info("Reconnecting to existing task session", { taskId: task.id, hasLatestRun: !!task.latest_run, sessionStatus: session?.status ?? "none", }); + markActivity(task.id); + getSessionService() .connectToTask({ task, repoPath, - initialPrompt: hasInitialPrompt - ? [{ type: "text", text: task.description }] - : undefined, }) .finally(() => { connectingTasks.delete(taskId); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 5eb919f47..5de0ce60e 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -61,8 +61,6 @@ import { const log = logger.scope("session-service"); -export const PREVIEW_TASK_ID = "__preview__"; - interface AuthCredentials { apiHost: string; projectId: number; @@ -112,8 +110,6 @@ export class SessionService { permission?: { unsubscribe: () => void }; } >(); - /** AbortController for the current in-flight preview session start */ - private previewAbort: AbortController | null = null; /** Active cloud task watchers, keyed by taskId */ private cloudTaskWatchers = new Map< string, @@ -536,6 +532,7 @@ export class SessionService { const { customInstructions: startCustomInstructions } = useSettingsStore.getState(); + const preferredModel = model ?? DEFAULT_GATEWAY_MODEL; const result = await trpcClient.agent.start.mutate({ taskId, taskRunId: taskRun.id, @@ -548,6 +545,7 @@ export class SessionService { effort: effortLevelSchema.safeParse(reasoningLevel).success ? (reasoningLevel as EffortLevel) : undefined, + model: preferredModel, }); const session = this.createBaseSession(taskRun.id, taskId, taskTitle); @@ -586,32 +584,6 @@ export class SessionService { adapter, }); - const preferredModel = model ?? DEFAULT_GATEWAY_MODEL; - const configPromises: Promise[] = []; - if (preferredModel) { - configPromises.push( - this.setSessionConfigOptionByCategory( - taskId, - "model", - preferredModel, - ).catch((err) => log.warn("Failed to set model", { taskId, err })), - ); - } - if (reasoningLevel) { - configPromises.push( - this.setSessionConfigOptionByCategory( - taskId, - "thought_level", - reasoningLevel, - ).catch((err) => - log.warn("Failed to set reasoning level", { taskId, err }), - ), - ); - } - if (configPromises.length > 0) { - await Promise.all(configPromises); - } - if (initialPrompt?.length) { await this.sendPrompt(taskId, initialPrompt); } @@ -643,113 +615,6 @@ export class SessionService { await this.teardownSession(session.taskRunId); } - // --- Preview Session Management --- - - async startPreviewSession(params: { - adapter: "claude" | "codex"; - }): Promise { - this.previewAbort?.abort(); - const abort = new AbortController(); - this.previewAbort = abort; - - await this.cleanupPreviewSession(); - if (abort.signal.aborted) return; - - const auth = await this.getAuthCredentials(); - if (!auth) { - log.info("Skipping preview session - not authenticated"); - return; - } - - const taskRunId = `preview-${crypto.randomUUID()}`; - const session = this.createBaseSession( - taskRunId, - PREVIEW_TASK_ID, - "Preview", - ); - session.adapter = params.adapter; - sessionStoreSetters.setSession(session); - - try { - const { - customInstructions: previewCustomInstructions, - defaultInitialTaskMode, - lastUsedInitialTaskMode, - } = useSettingsStore.getState(); - const initialMode = - defaultInitialTaskMode === "last_used" - ? lastUsedInitialTaskMode - : "plan"; - const result = await trpcClient.agent.start.mutate({ - taskId: PREVIEW_TASK_ID, - taskRunId, - repoPath: "__preview__", - apiHost: auth.apiHost, - projectId: auth.projectId, - adapter: params.adapter, - permissionMode: initialMode, - customInstructions: previewCustomInstructions || undefined, - }); - - if (abort.signal.aborted) { - trpcClient.agent.cancel - .mutate({ sessionId: taskRunId }) - .catch((err) => { - log.warn("Failed to cancel stale preview session", { - taskRunId, - error: err, - }); - }); - sessionStoreSetters.removeSession(taskRunId); - return; - } - - const configOptions = result.configOptions as - | SessionConfigOption[] - | undefined; - - sessionStoreSetters.updateSession(taskRunId, { - status: "connected", - channel: result.channel, - configOptions, - }); - - this.subscribeToChannel(taskRunId); - - log.info("Preview session started", { - taskRunId, - adapter: params.adapter, - configOptionsCount: configOptions?.length ?? 0, - }); - } catch (error) { - if (abort.signal.aborted) return; - log.error("Failed to start preview session", { error }); - sessionStoreSetters.removeSession(taskRunId); - } - } - - async cancelPreviewSession(): Promise { - this.previewAbort?.abort(); - this.previewAbort = null; - await this.cleanupPreviewSession(); - } - - private async cleanupPreviewSession(): Promise { - const session = sessionStoreSetters.getSessionByTaskId(PREVIEW_TASK_ID); - if (!session) return; - - const { taskRunId } = session; - - this.unsubscribeFromChannel(taskRunId); - sessionStoreSetters.removeSession(taskRunId); - - try { - await trpcClient.agent.cancel.mutate({ sessionId: taskRunId }); - } catch (error) { - log.warn("Failed to cancel preview session", { taskRunId, error }); - } - } - // --- Subscription Management --- private subscribeToChannel(taskRunId: string): void { @@ -825,8 +690,6 @@ export class SessionService { } this.connectingTasks.clear(); - this.previewAbort?.abort(); - this.previewAbort = null; this.idleKilledSubscription?.unsubscribe(); this.idleKilledSubscription = null; } diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index bcfdc822d..0c055e51f 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -13,7 +13,6 @@ import { import type { MessageEditorHandle } from "@features/message-editor/components/MessageEditor"; import { ModeIndicatorInput } from "@features/message-editor/components/ModeIndicatorInput"; import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; -import { getSessionService } from "@features/sessions/service/service"; import { cycleModeOption, getCurrentModeFromConfigOptions, @@ -32,7 +31,7 @@ import { useNavigationStore } from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { usePreviewSession } from "../hooks/usePreviewSession"; +import { usePreviewConfig } from "../hooks/usePreviewConfig"; import { useTaskCreation } from "../hooks/useTaskCreation"; import { TaskInputEditor } from "./TaskInputEditor"; import { type WorkspaceMode, WorkspaceModeSelect } from "./WorkspaceModeSelect"; @@ -150,14 +149,13 @@ export function TaskInput({ } }, [selectedDirectory, newBranchName, gitActions]); - // Preview session provides adapter-specific config options const { modeOption, modelOption, thoughtOption, - previewTaskId, - isConnecting, - } = usePreviewSession(adapter); + isLoading: isPreviewLoading, + setConfigOption, + } = usePreviewConfig(adapter); const { folders } = useFolders(); @@ -194,8 +192,8 @@ export function TaskInput({ const effectiveWorkspaceMode = workspaceMode; - // Get current values from preview session config options for task creation. - // Defaults ensure values are always passed even before the preview session loads. + // Get current values from preview config options for task creation. + // Defaults ensure values are always passed even before the preview config loads. const currentModel = modelOption?.type === "select" ? modelOption.currentValue : undefined; const modeFallback = @@ -234,13 +232,9 @@ export function TaskInput({ const handleCycleMode = useCallback(() => { const nextValue = cycleModeOption(modeOption, allowBypassPermissions); if (nextValue && modeOption) { - getSessionService().setSessionConfigOption( - previewTaskId, - modeOption.id, - nextValue, - ); + setConfigOption(modeOption.id, nextValue); } - }, [modeOption, allowBypassPermissions, previewTaskId]); + }, [modeOption, allowBypassPermissions, setConfigOption]); // Global shift+tab to cycle mode regardless of focus useHotkeys( @@ -462,9 +456,11 @@ export function TaskInput({ } onEmptyChange={setEditorIsEmpty} adapter={adapter} - previewTaskId={previewTaskId} + modelOption={modelOption} + thoughtOption={thoughtOption} + onConfigOptionChange={setConfigOption} onAdapterChange={setAdapter} - isPreviewConnecting={isConnecting} + isLoading={isPreviewLoading} /> void; adapter?: "claude" | "codex"; - previewTaskId?: string; + modelOption?: SessionConfigOption; + thoughtOption?: SessionConfigOption; + onConfigOptionChange?: (configId: string, value: string) => void; onAdapterChange?: (adapter: AgentAdapter) => void; - isPreviewConnecting?: boolean; + isLoading?: boolean; autoFocus?: boolean; tourHighlight?: "model-selector" | "submit-button" | null; - onModelChange?: (model: string) => void; } export const TaskInputEditor = forwardRef< @@ -48,12 +50,13 @@ export const TaskInputEditor = forwardRef< directoryTooltip = "Select a folder first", onEmptyChange, adapter, - previewTaskId, + modelOption, + thoughtOption, + onConfigOptionChange, onAdapterChange, - isPreviewConnecting, + isLoading, autoFocus = true, tourHighlight, - onModelChange, }, ref, ) => { @@ -86,7 +89,7 @@ export const TaskInputEditor = forwardRef< submitDisabled: !isOnline, isLoading: isCreatingTask, autoFocus, - context: { repoPath, taskId: previewTaskId }, + context: { repoPath }, capabilities: { commands: true, bashMode: false }, clearOnSubmit: false, getPromptHistory, @@ -135,6 +138,18 @@ export const TaskInputEditor = forwardRef< return "Create task"; }; + const handleModelChange = (value: string) => { + if (modelOption) { + onConfigOptionChange?.(modelOption.id, value); + } + }; + + const handleThoughtChange = (value: string) => { + if (thoughtOption) { + onConfigOptionChange?.(thoughtOption.id, value); + } + }; + return ( {})} disabled={isCreatingTask} - isConnecting={isPreviewConnecting} - onModelChange={onModelChange} + isConnecting={isLoading} + onModelChange={handleModelChange} /> - {!isPreviewConnecting && ( + {!isLoading && ( )} diff --git a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts new file mode 100644 index 000000000..4687b214a --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts @@ -0,0 +1,146 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { getEffortOptions } from "@posthog/agent/adapters/claude/session/models"; +import { trpcClient } from "@renderer/trpc/client"; +import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import { logger } from "@utils/logger"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +const log = logger.scope("preview-config"); + +interface PreviewConfigResult { + configOptions: SessionConfigOption[]; + modeOption: SessionConfigOption | undefined; + modelOption: SessionConfigOption | undefined; + thoughtOption: SessionConfigOption | undefined; + isLoading: boolean; + setConfigOption: (configId: string, value: string) => void; +} + +function getOptionByCategory( + options: SessionConfigOption[], + category: string, +): SessionConfigOption | undefined { + return options.find( + (opt) => opt.category === category || opt.id === category, + ); +} + +/** + * Fetches config options (models, modes, effort levels) for the task input + * page via a lightweight tRPC query. No agent session is created. + * + * Returns config options as local state with a setter for local updates. + */ +export function usePreviewConfig( + adapter: "claude" | "codex", +): PreviewConfigResult { + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const apiHost = useMemo( + () => (cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null), + [cloudRegion], + ); + const [configOptions, setConfigOptions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const abortRef = useRef(null); + + useEffect(() => { + if (!apiHost) return; + + abortRef.current?.abort(); + const abort = new AbortController(); + abortRef.current = abort; + + setIsLoading(true); + + trpcClient.agent.getPreviewConfigOptions + .query({ apiHost, adapter }) + .then((options) => { + if (abort.signal.aborted) return; + + const { defaultInitialTaskMode, lastUsedInitialTaskMode } = + useSettingsStore.getState(); + const initialMode = + defaultInitialTaskMode === "last_used" + ? lastUsedInitialTaskMode + : "plan"; + + const withMode = options.map((opt) => + opt.id === "mode" + ? ({ ...opt, currentValue: initialMode } as SessionConfigOption) + : opt, + ); + + setConfigOptions(withMode); + setIsLoading(false); + }) + .catch((error) => { + if (abort.signal.aborted) return; + log.error("Failed to fetch preview config options", { error }); + setIsLoading(false); + }); + + return () => { + abort.abort(); + }; + }, [adapter, apiHost]); + + const setConfigOption = useCallback((configId: string, value: string) => { + setConfigOptions((prev) => { + let updated = prev.map((opt) => + opt.id === configId + ? ({ ...opt, currentValue: value } as SessionConfigOption) + : opt, + ); + + if (configId === "model") { + const effortOpts = getEffortOptions(value); + const existingIdx = updated.findIndex((o) => o.id === "effort"); + + if (effortOpts && existingIdx >= 0) { + const currentEffort = updated[existingIdx].currentValue; + const validEffort = effortOpts.some((e) => e.value === currentEffort) + ? currentEffort + : "high"; + updated[existingIdx] = { + ...updated[existingIdx], + currentValue: validEffort, + options: effortOpts, + } as SessionConfigOption; + } else if (effortOpts && existingIdx === -1) { + updated = [ + ...updated, + { + id: "effort", + name: "Effort", + type: "select", + currentValue: "high", + options: effortOpts, + category: "thought_level", + description: + "Controls how much effort Claude puts into its response", + } as SessionConfigOption, + ]; + } else if (!effortOpts && existingIdx >= 0) { + updated = updated.filter((o) => o.id !== "effort"); + } + } + + return updated; + }); + }, []); + + const modeOption = getOptionByCategory(configOptions, "mode"); + const modelOption = getOptionByCategory(configOptions, "model"); + const thoughtOption = getOptionByCategory(configOptions, "thought_level"); + + return { + configOptions, + modeOption, + modelOption, + thoughtOption, + isLoading, + setConfigOption, + }; +} diff --git a/apps/code/src/renderer/features/task-detail/hooks/usePreviewSession.ts b/apps/code/src/renderer/features/task-detail/hooks/usePreviewSession.ts deleted file mode 100644 index 90f07764c..000000000 --- a/apps/code/src/renderer/features/task-detail/hooks/usePreviewSession.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { - getSessionService, - PREVIEW_TASK_ID, -} from "@features/sessions/service/service"; -import { - useModeConfigOptionForTask, - useModelConfigOptionForTask, - useSessionForTask, - useThoughtLevelConfigOptionForTask, -} from "@features/sessions/stores/sessionStore"; -import { useEffect } from "react"; - -interface PreviewSessionResult { - modeOption: SessionConfigOption | undefined; - modelOption: SessionConfigOption | undefined; - thoughtOption: SessionConfigOption | undefined; - previewTaskId: string; - /** True while the preview session is connecting (no config options yet) */ - isConnecting: boolean; -} - -/** - * Manages a lightweight preview session that provides adapter-specific - * config options (models, modes, reasoning levels) for the task input page. - * - * Starts a new preview session when adapter changes, - * and cleans up on unmount or when inputs change. - */ -export function usePreviewSession( - adapter: "claude" | "codex", -): PreviewSessionResult { - const projectId = useAuthStateValue((state) => state.projectId); - - useEffect(() => { - if (!projectId) return; - - const service = getSessionService(); - service.startPreviewSession({ adapter }); - - return () => { - service.cancelPreviewSession(); - }; - }, [adapter, projectId]); - - const session = useSessionForTask(PREVIEW_TASK_ID); - const modeOption = useModeConfigOptionForTask(PREVIEW_TASK_ID); - const modelOption = useModelConfigOptionForTask(PREVIEW_TASK_ID); - const thoughtOption = useThoughtLevelConfigOptionForTask(PREVIEW_TASK_ID); - - // Connecting if we have a session but it's not connected yet, - // or if we don't have a session at all (start hasn't created one yet) - const isConnecting = !session || session.status === "connecting"; - - return { - modeOption, - modelOption, - thoughtOption, - previewTaskId: PREVIEW_TASK_ID, - isConnecting, - }; -} diff --git a/packages/agent/package.json b/packages/agent/package.json index 56f15c2fb..e112079e8 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -44,6 +44,14 @@ "types": "./dist/adapters/claude/session/jsonl-hydration.d.ts", "import": "./dist/adapters/claude/session/jsonl-hydration.js" }, + "./adapters/claude/session/models": { + "types": "./dist/adapters/claude/session/models.d.ts", + "import": "./dist/adapters/claude/session/models.js" + }, + "./execution-mode": { + "types": "./dist/execution-mode.d.ts", + "import": "./dist/execution-mode.js" + }, "./server": { "types": "./dist/server/agent-server.d.ts", "import": "./dist/server/agent-server.js" diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index f4a036857..78719875f 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -876,58 +876,92 @@ export class ClaudeAcpAgent extends BaseAcpAgent { { sessionId, taskId, taskRunId: meta?.taskRunId }, ); - try { - const result = await withTimeout( - q.initializationResult(), - SESSION_VALIDATION_TIMEOUT_MS, - ); - if (result.result === "timeout") { - throw new Error( - `Session ${isResume ? (forkSession ? "fork" : "resumption") : "initialization"} timed out for sessionId=${sessionId}`, + if (isResume) { + // Resume must block on initialization to validate the session is still alive. + // For stale sessions this throws (e.g. "No conversation found"). + try { + const result = await withTimeout( + q.initializationResult(), + SESSION_VALIDATION_TIMEOUT_MS, ); + if (result.result === "timeout") { + throw new Error( + `Session ${forkSession ? "fork" : "resumption"} timed out for sessionId=${sessionId}`, + ); + } + } catch (err) { + settingsManager.dispose(); + if ( + err instanceof Error && + err.message === "Query closed before response received" + ) { + throw RequestError.resourceNotFound(sessionId); + } + this.logger.error( + forkSession ? "Session fork failed" : "Session resumption failed", + { + sessionId, + taskId, + taskRunId: meta?.taskRunId, + error: err instanceof Error ? err.message : String(err), + }, + ); + throw err; } - } catch (err) { - settingsManager.dispose(); - if ( - isResume && - err instanceof Error && - err.message === "Query closed before response received" - ) { - throw RequestError.resourceNotFound(sessionId); - } - this.logger.error( - isResume - ? forkSession - ? "Session fork failed" - : "Session resumption failed" - : "Session initialization failed", - { + } + + // Kick off SDK initialization for new sessions so it runs concurrently + // with the model config fetch below (the gateway REST call is independent). + const initPromise = !isResume + ? withTimeout(q.initializationResult(), SESSION_VALIDATION_TIMEOUT_MS) + : undefined; + + const [modelOptions] = await Promise.all([ + this.getModelConfigOptions( + settingsManager.getSettings().model || meta?.model || undefined, + ), + ...(meta?.taskRunId + ? [ + this.client.extNotification("_posthog/sdk_session", { + taskRunId: meta.taskRunId, + sessionId, + adapter: "claude", + }), + ] + : []), + ]); + + if (initPromise) { + try { + const initResult = await initPromise; + if (initResult.result === "timeout") { + settingsManager.dispose(); + throw new Error( + `Session initialization timed out for sessionId=${sessionId}`, + ); + } + } catch (err) { + settingsManager.dispose(); + this.logger.error("Session initialization failed", { sessionId, taskId, taskRunId: meta?.taskRunId, error: err instanceof Error ? err.message : String(err), - }, - ); - throw err; - } - - if (meta?.taskRunId) { - await this.client.extNotification("_posthog/sdk_session", { - taskRunId: meta.taskRunId, - sessionId, - adapter: "claude", - }); + }); + throw err; + } } - // Resolve model: settings model takes priority, then gateway const settingsModel = settingsManager.getSettings().model; - const modelOptions = await this.getModelConfigOptions(); - const resolvedModelId = settingsModel || modelOptions.currentModelId; + const metaModel = meta?.model; + const resolvedModelId = + settingsModel || metaModel || modelOptions.currentModelId; session.modelId = resolvedModelId; session.lastContextWindowSize = this.getContextWindowForModel(resolvedModelId); const resolvedSdkModel = toSdkModelId(resolvedModelId); + if (!isResume && resolvedSdkModel !== DEFAULT_MODEL) { await this.session.query.setModel(resolvedSdkModel); } diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 02ea340d9..8bc8cce35 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -108,6 +108,8 @@ export type NewSessionMeta = { persistence?: { taskId?: string; runId?: string; logUrl?: string }; additionalRoots?: string[]; allowedDomains?: string[]; + /** Model ID to use for this session (e.g. "claude-sonnet-4-6") */ + model?: string; claudeCode?: { options?: Options; }; diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index a64b90d1a..82e6bd956 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -79,6 +79,13 @@ export function isAnthropicModel(model: GatewayModel): boolean { return model.id.startsWith("claude-") || model.id.startsWith("anthropic/"); } +export function isOpenAIModel(model: GatewayModel): boolean { + if (model.owned_by) { + return model.owned_by === "openai"; + } + return model.id.startsWith("gpt-") || model.id.startsWith("openai/"); +} + export interface ModelInfo { id: string; owned_by?: string; diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index 9298935d7..9af0bc8ff 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -81,6 +81,8 @@ export default defineConfig([ "src/adapters/claude/tools.ts", "src/adapters/claude/conversion/tool-use-to-acp.ts", "src/adapters/claude/session/jsonl-hydration.ts", + "src/adapters/claude/session/models.ts", + "src/execution-mode.ts", "src/server/agent-server.ts", ], format: ["esm"],