diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx index 27df1e9b4e..d1ac11b54b 100644 --- a/apps/mobile/src/app/task/[id].tsx +++ b/apps/mobile/src/app/task/[id].tsx @@ -1,15 +1,19 @@ import { Text } from "@components/text"; -import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes"; +import { getCloudReasoningConfigOptionId } from "@posthog/core/sessions/cloudSessionConfig"; +import { getDefaultExecutionModeForAdapter } from "@posthog/core/sessions/executionModes"; import { countUserMessages, getSessionActivityPhase, } from "@posthog/core/sessions/sessionActivity"; +import type { CloudComposerSelection } from "@posthog/core/task-detail/composerModelPolicy"; import { isTaskRunning } from "@posthog/core/tasks/taskArchive"; import { + type Adapter, + DEFAULT_CODEX_MODEL, DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, - getReasoningEffortOptions, + isSupportedReasoningEffort, type SupportedReasoningEffort, serializeCloudPrompt, type Task, @@ -57,6 +61,7 @@ import { import { useTaskSessionStore } from "@/features/tasks/stores/taskSessionStore"; import { useTaskStore } from "@/features/tasks/stores/taskStore"; import { confirmStopRun } from "@/features/tasks/utils/archiveGuard"; +import { buildCloudTaskRunConfig } from "@/features/tasks/utils/cloudTaskRunConfig"; import { useScreenInsets } from "@/hooks/useScreenInsets"; import { ANALYTICS_EVENTS, @@ -167,11 +172,37 @@ export default function TaskDetailScreen() { const [initialComposerMessage, setInitialComposerMessage] = useState< string | undefined >(); + const composerAdapter: Adapter = + task?.latest_run?.runtime_adapter && + !session?.terminalStatus && + composerConfig?.adapter !== task.latest_run.runtime_adapter + ? task.latest_run.runtime_adapter + : (composerConfig?.adapter ?? + task?.latest_run?.runtime_adapter ?? + "claude"); + const composerConfigMatchesAdapter = + composerConfig?.adapter === undefined + ? composerAdapter === "claude" + : composerConfig.adapter === composerAdapter; const composerMode: ExecutionMode = - composerConfig?.mode ?? DEFAULT_CLAUDE_EXECUTION_MODE; - const composerModel = composerConfig?.model ?? DEFAULT_GATEWAY_MODEL; + (composerConfigMatchesAdapter ? composerConfig?.mode : undefined) ?? + getDefaultExecutionModeForAdapter(composerAdapter); + const composerModel = + (composerConfigMatchesAdapter ? composerConfig?.model : undefined) ?? + task?.latest_run?.model ?? + (composerAdapter === "codex" ? DEFAULT_CODEX_MODEL : DEFAULT_GATEWAY_MODEL); + const requestedComposerReasoning = composerConfigMatchesAdapter + ? composerConfig?.reasoning + : undefined; const composerReasoning: SupportedReasoningEffort = - composerConfig?.reasoning ?? DEFAULT_REASONING_EFFORT; + requestedComposerReasoning && + isSupportedReasoningEffort( + composerAdapter, + composerModel, + requestedComposerReasoning, + ) + ? requestedComposerReasoning + : DEFAULT_REASONING_EFFORT; const messagingMode = useMessagingMode(taskId); const queuedCount = useQueuedCount(taskId); @@ -311,18 +342,18 @@ export default function TaskDetailScreen() { ) : text; - const supportsReasoning = - getReasoningEffortOptions("claude", composerModel) !== null; const updatedTask = await getPostHogApiClient().runTaskInCloud( taskId, undefined, { resumeFromRunId: task.latest_run?.id, pendingUserMessage, - adapter: "claude", - model: composerModel, - reasoningLevel: supportsReasoning ? composerReasoning : undefined, - initialPermissionMode: composerMode, + ...buildCloudTaskRunConfig({ + adapter: composerAdapter, + mode: composerMode, + model: composerModel, + reasoning: composerReasoning, + }), rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, }, ); @@ -346,6 +377,7 @@ export default function TaskDetailScreen() { connectToTask, updateTaskInCache, composerMode, + composerAdapter, composerModel, composerReasoning, ], @@ -500,6 +532,17 @@ export default function TaskDetailScreen() { [taskId, setComposerConfig, setConfigOption], ); + const handleAdapterChange = useCallback( + (selection: CloudComposerSelection) => { + if (!taskId) return; + setComposerConfig(taskId, selection); + const preferences = usePreferencesStore.getState(); + preferences.setLastNewTaskMode(selection.mode); + preferences.setLastUsedReasoningEffort(selection.reasoning); + }, + [taskId, setComposerConfig], + ); + const handleModelChange = useCallback( (value: string) => { if (!taskId) return; @@ -513,10 +556,14 @@ export default function TaskDetailScreen() { (value: SupportedReasoningEffort) => { if (!taskId) return; setComposerConfig(taskId, { reasoning: value }); - setConfigOption(taskId, "effort", value).catch(() => {}); + setConfigOption( + taskId, + getCloudReasoningConfigOptionId(composerAdapter), + value, + ).catch(() => {}); usePreferencesStore.getState().setLastUsedReasoningEffort(value); }, - [taskId, setComposerConfig, setConfigOption], + [taskId, composerAdapter, setComposerConfig, setConfigOption], ); const handleStop = useCallback(() => { @@ -568,6 +615,12 @@ export default function TaskDetailScreen() { undefined, { resumeFromRunId: task.latest_run?.id, + ...buildCloudTaskRunConfig({ + adapter: composerAdapter, + mode: composerMode, + model: composerModel, + reasoning: composerReasoning, + }), rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, }, ); @@ -584,7 +637,17 @@ export default function TaskDetailScreen() { "Could not restart the task. Please try again.", ); } - }, [taskId, task, disconnectFromTask, connectToTask, updateTaskInCache]); + }, [ + taskId, + task, + disconnectFromTask, + connectToTask, + updateTaskInCache, + composerAdapter, + composerModel, + composerReasoning, + composerMode, + ]); // Clear retrying once the agent finishes a turn or the run terminates. useEffect(() => { @@ -778,6 +841,9 @@ export default function TaskDetailScreen() { /> ) : null} ; - case "default": - return ; - case "acceptEdits": - return ; - case "bypassPermissions": - case "full-access": - return ; - case "read-only": - return ; - case "auto": - return ; - } -} - export default function NewTaskScreen() { const { prompt: initialPrompt, @@ -130,10 +103,10 @@ export default function NewTaskScreen() { const { insets, bottom } = useScreenInsets(); const keyboard = useReanimatedKeyboardAnimation(); const restingBottom = bottom("compact"); + const [adapter, setAdapter] = useState("claude"); const { configOptions, hasLiveConfig, isConfigReady } = - useCloudTaskConfigOptions("claude"); + useCloudTaskConfigOptions(adapter); const modelConfigOption = getModelConfigOption(configOptions); - const mobileModelOptions = getComposerModelOptions(modelConfigOption); const { error, hasGithubIntegration, @@ -197,7 +170,9 @@ export default function NewTaskScreen() { const prefs = usePreferencesStore.getState(); if (prefs.defaultInitialTaskMode === "last_used") { const last = prefs.lastNewTaskMode; - const isValidMode = EXECUTION_MODES.some((mode) => mode.id === last); + const isValidMode = getMobileExecutionModes( + getAvailableModesForAdapter("claude"), + ).some((mode) => mode.id === last); if (isValidMode) return last as ExecutionMode; } return DEFAULT_CLAUDE_EXECUTION_MODE; @@ -217,19 +192,16 @@ export default function NewTaskScreen() { useEffect(() => { if (!hasLiveConfig) return; const next = resolveCloudComposerModelChange({ - adapter: "claude", + adapter, modelOption: modelConfigOption, requestedModel: model, reasoning, }); if (next.model !== model) setModel(next.model); if (next.reasoning !== reasoning) setReasoning(next.reasoning); - }, [hasLiveConfig, model, modelConfigOption, reasoning]); + }, [adapter, hasLiveConfig, model, modelConfigOption, reasoning]); const [creating, setCreating] = useState(false); const [repoSheetOpen, setRepoSheetOpen] = useState(false); - const [modeSheetOpen, setModeSheetOpen] = useState(false); - const [modelSheetOpen, setModelSheetOpen] = useState(false); - const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); @@ -363,7 +335,7 @@ export default function NewTaskScreen() { // user picked here, so the task detail screen reflects them and every // subsequent run (resume-after-terminal) reuses the selected mode rather // than falling back to the default plan mode. - setComposerConfig(task.id, { mode, model, reasoning }); + setComposerConfig(task.id, { adapter, mode, model, reasoning }); const pendingUserMessage = attachments.length > 0 @@ -372,15 +344,9 @@ export default function NewTaskScreen() { ) : trimmedPrompt; - const supportsReasoning = - getReasoningEffortOptions("claude", model) !== null; - await client.runTaskInCloud(task.id, undefined, { pendingUserMessage, - adapter: "claude", - model, - reasoningLevel: supportsReasoning ? reasoning : undefined, - initialPermissionMode: mode, + ...buildCloudTaskRunConfig({ adapter, mode, model, reasoning }), autoPublish: usePreferencesStore.getState().autoPublishCloudRuns, rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, ...(signalReport @@ -401,6 +367,7 @@ export default function NewTaskScreen() { } }, [ attachments, + adapter, creating, mode, model, @@ -419,7 +386,7 @@ export default function NewTaskScreen() { hasContent && isRepositorySelectionComplete(selection) && !creating; - const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; + const reasoningOptions = getReasoningEffortOptions(adapter, model) ?? []; const showReasoningPill = reasoningOptions.length > 0; // Best-effort prewarm; failures are swallowed. `selection.integrationId` is @@ -429,7 +396,7 @@ export default function NewTaskScreen() { repository: selection.repository, githubIntegrationId: selection.integrationId, composerIsEmpty: !hasContent || !isConfigReady, - runtimeAdapter: "claude", + runtimeAdapter: adapter, model, reasoningEffort: showReasoningPill ? reasoning : null, }); @@ -500,320 +467,253 @@ export default function NewTaskScreen() { - - {repoSheetOpen ? null : prompt.trim().length === 0 ? ( - - - Suggestions - - - {SUGGESTIONS.map((suggestion) => ( - setPrompt(suggestion)} - className="rounded-2xl border border-gray-6 bg-card px-4 py-3 active:bg-gray-2" - > - - {suggestion} - - - ))} - - - ) : null} - - {/* Inline repo picker: pops up directly above the pill when - open, replacing the suggestions area. Rendered inline (not - a Modal) so it feels like a dropdown anchored to the pill - rather than a slide-in sheet. */} - - - setSelection(toRepositorySelection(option)) - } - onClose={() => setRepoSheetOpen(false)} - /> - - - - - {repositoryWarning ? ( - - ) : null} - - - setRepoSheetOpen((prev) => !prev)} - className={`flex-row items-center gap-2 rounded-full border py-1.5 pr-2.5 pl-2 active:bg-gray-2 ${ - repoSheetOpen - ? "border-accent-7 bg-accent-3" - : "border-gray-6 bg-card" - }`} - > - + + + + setSelection(toRepositorySelection(option)) } - weight={selectedRepositoryOption ? "fill" : "regular"} + onClose={() => setRepoSheetOpen(false)} /> - - {repositoryLabel} - - + + {repositoryWarning ? ( + - - + ) : null} - - - - - + setAttachmentSheetOpen(true)} - accessibilityLabel="Add attachment" - accessibilityRole="button" - className="h-9 w-9 items-center justify-center active:opacity-60" + onPress={() => setRepoSheetOpen((prev) => !prev)} + className={`flex-row items-center gap-2 rounded-md border py-1.5 pr-2.5 pl-2 active:bg-gray-2 ${ + repoSheetOpen + ? "border-accent-7 bg-accent-3" + : "border-gray-6 bg-card" + }`} > - 0 - ? themeColors.accent[11] + selectedRepositoryOption + ? themeColors.gray[12] : themeColors.gray[10] } - weight={attachments.length > 0 ? "fill" : "regular"} + weight={selectedRepositoryOption ? "fill" : "regular"} + /> + + {repositoryLabel} + + + - - + + + + + setAttachmentSheetOpen(true)} + accessibilityLabel="Add attachment" + accessibilityRole="button" + className="h-9 w-9 items-center justify-center active:opacity-60" > - 0 ? themeColors.accent[11] - : themeColors.gray[11], - )} - label={ - EXECUTION_MODES.find((option) => option.id === mode) - ?.name ?? mode + : themeColors.gray[10] } - accent={mode === "plan"} - onPress={() => setModeSheetOpen(true)} + weight={attachments.length > 0 ? "fill" : "regular"} /> - - } - label={ - getConfigOptionLabel( - modelConfigOption.options, - model, - ) ?? model - } - onPress={() => setModelSheetOpen(true)} + + + + + { + setAdapter(next.adapter); + setMode(next.mode); + setModel(next.model); + setReasoning(next.reasoning); + const preferences = usePreferencesStore.getState(); + preferences.setLastNewTaskMode(next.mode); + preferences.setLastUsedReasoningEffort( + next.reasoning, + ); + }} + onModeChange={(next) => { + setMode(next); + usePreferencesStore + .getState() + .setLastNewTaskMode(next); + }} + onModelChange={setModel} + onReasoningChange={(next) => { + setReasoning(next); + usePreferencesStore + .getState() + .setLastUsedReasoningEffort(next); + }} + /> + + {/* Right-edge fade hints that more pills exist when the row + overflows. Non-interactive so taps fall through. */} + - - {showReasoningPill ? ( - - } - label={ - reasoningOptions.find( - (option) => option.value === reasoning, - )?.name ?? reasoning + + + + {creating || isTranscribing ? ( + + ) : isRecording ? ( + + ) : hasContent ? ( + setReasoningSheetOpen(true)} + weight="bold" /> - ) : null} - - {/* Right-edge fade hints that more pills exist when the row - overflows. Non-interactive so taps fall through. */} - + ) : ( + + )} + + - - {creating || isTranscribing ? ( - - ) : isRecording ? ( - - ) : hasContent ? ( - - ) : ( - - )} - - + + Suggestions + + + {SUGGESTIONS.map((suggestion) => ( + setPrompt(suggestion)} + className="rounded-lg border border-gray-5 bg-gray-2 px-3 py-2.5 active:bg-gray-3" + > + + {suggestion} + + + ))} + + + ) : null} - { - const next = value as ExecutionMode; - setMode(next); - usePreferencesStore.getState().setLastNewTaskMode(next); - }} - onClose={() => setModeSheetOpen(false)} - options={EXECUTION_MODES.map((executionMode) => ({ - value: executionMode.id, - label: executionMode.name, - description: executionMode.description, - icon: modeIcon( - executionMode.id as ExecutionMode, - executionMode.id === "plan" - ? themeColors.accent[11] - : themeColors.gray[11], - 16, - ), - }))} - /> - - { - const next = resolveCloudComposerModelChange({ - adapter: "claude", - modelOption: modelConfigOption, - requestedModel: value, - reasoning, - }); - setModel(next.model); - setReasoning(next.reasoning); - }} - onClose={() => setModelSheetOpen(false)} - options={mobileModelOptions.map((modelOption) => ({ - value: modelOption.value, - label: modelOption.label, - description: modelOption.description, - disabled: modelOption.disabled, - icon: , - }))} - /> - - { - const next = value as SupportedReasoningEffort; - setReasoning(next); - usePreferencesStore.getState().setLastUsedReasoningEffort(next); - }} - onClose={() => setReasoningSheetOpen(false)} - options={reasoningOptions.map((reasoningLevel) => ({ - value: reasoningLevel.value, - label: reasoningLevel.name, - icon: , - }))} - /> - setAttachmentSheetOpen(false)} diff --git a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx index c12189fc1c..8c7909b6b5 100644 --- a/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx +++ b/apps/mobile/src/features/tasks/components/FloatingTaskHeader.tsx @@ -1,11 +1,10 @@ import { Text } from "@components/text"; -import { LinearGradient } from "expo-linear-gradient"; import { useRouter } from "expo-router"; import { CaretLeft } from "phosphor-react-native"; import type { ReactNode } from "react"; import { Platform, Pressable, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { toRgba, useThemeColors } from "@/lib/theme"; +import { useThemeColors } from "@/lib/theme"; interface FloatingTaskHeaderProps { title: string; @@ -14,12 +13,7 @@ interface FloatingTaskHeaderProps { rightSlot?: ReactNode; } -/** - * Floating header for the task detail screen — back arrow on the left, - * centered title + repo subtitle, optional right slot for actions. Sits over - * the content with a top-to-bottom fade so the scroll list disappears - * gracefully behind it rather than getting clipped by a hard edge. - */ +/** Task detail toolbar with navigation, task identity, and run actions. */ export function FloatingTaskHeader({ title, subtitle, @@ -38,30 +32,14 @@ export function FloatingTaskHeader({ // on iOS and fall back to the real inset on Android. const topInset = Platform.OS === "ios" ? 6 : insets.top; - // Fade height extends well past the title row so content scrolling up - // behind the header gets a long, gentle transition instead of crashing - // into the subtitle. Header row content sits in roughly the first - // (topInset + 44)pt; the rest is pure fade. - const fadeHeight = topInset + 96; + const headerHeight = topInset + 52; return ( - - - + { + const icon = (name: string) => (props: Record) => + createElement(name, props); + return { + BrainIcon: icon("BrainIcon"), + CaretDown: icon("CaretDown"), + Check: icon("Check"), + Cpu: icon("Cpu"), + PauseIcon: icon("PauseIcon"), + PencilIcon: icon("PencilIcon"), + Robot: icon("Robot"), + ShieldCheck: icon("ShieldCheck"), + Sparkle: icon("Sparkle"), + }; +}); + +vi.mock("@/components/SheetContainer", () => ({ + SheetContainer: ({ + open, + children, + }: { + open: boolean; + children: ReactNode; + }) => (open ? createElement("SheetContainer", null, children) : null), +})); + +vi.mock("@/lib/theme", () => ({ + useThemeColors: () => ({ + gray: { 10: "#777", 11: "#555" }, + accent: { 9: "#f60", 11: "#f60" }, + }), +})); + +const configOptions: CloudTaskConfigOption[] = [ + { + id: "model", + name: "Model", + type: "select", + currentValue: "claude-sonnet-4-6", + options: [{ value: "claude-sonnet-4-6", name: "Sonnet 4.6" }], + category: "model", + description: "Choose a model", + }, +]; + +function findPressableWithText( + renderer: ReturnType, + label: string, +) { + return renderer.root.find( + (node) => + typeof node.props.onPress === "function" && + node.findAll((child) => child.props.children === label).length > 0, + ); +} + +describe("AgentConfigControls", () => { + it("resets incompatible values when switching adapters", () => { + const onAdapterChange = vi.fn(); + const onModeChange = vi.fn(); + const onModelChange = vi.fn(); + const onReasoningChange = vi.fn(); + let renderer!: ReturnType; + + act(() => { + renderer = create( + createElement(AgentConfigControls, { + adapter: "claude", + mode: "plan", + model: "claude-sonnet-4-6", + reasoning: "high", + configOptions, + onAdapterChange, + onModeChange, + onModelChange, + onReasoningChange, + }), + ); + }); + + act(() => findPressableWithText(renderer, "Sonnet 4.6").props.onPress()); + act(() => + findPressableWithText(renderer, "Switch to Codex").props.onPress(), + ); + + expect(onAdapterChange).toHaveBeenCalledWith({ + adapter: "codex", + mode: "auto", + model: "gpt-5.5", + reasoning: "high", + }); + expect(onModeChange).not.toHaveBeenCalled(); + expect(onModelChange).not.toHaveBeenCalled(); + expect(onReasoningChange).not.toHaveBeenCalled(); + }); + + it("hides adapter switching while the active run locks the adapter", () => { + let renderer!: ReturnType; + + act(() => { + renderer = create( + createElement(AgentConfigControls, { + adapter: "claude", + mode: "plan", + model: "claude-sonnet-4-6", + reasoning: "high", + configOptions, + canChangeAdapter: false, + onAdapterChange: vi.fn(), + onModeChange: vi.fn(), + onModelChange: vi.fn(), + onReasoningChange: vi.fn(), + }), + ); + }); + + act(() => findPressableWithText(renderer, "Sonnet 4.6").props.onPress()); + + expect(() => findPressableWithText(renderer, "Switch to Codex")).toThrow(); + }); +}); diff --git a/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx b/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx new file mode 100644 index 0000000000..8f5d1aa360 --- /dev/null +++ b/apps/mobile/src/features/tasks/composer/AgentConfigControls.tsx @@ -0,0 +1,214 @@ +import { getAvailableModesForAdapter } from "@posthog/core/sessions/executionModes"; +import { + type CloudComposerSelection, + resolveCloudComposerAdapterChange, + resolveCloudComposerModelChange, +} from "@posthog/core/task-detail/composerModelPolicy"; +import { + type Adapter, + type CloudTaskConfigOption, + type ExecutionMode, + getReasoningEffortOptions, + type SupportedReasoningEffort, +} from "@posthog/shared"; +import { + BrainIcon, + Cpu, + PauseIcon, + PencilIcon, + Robot, + ShieldCheck, + Sparkle, +} from "phosphor-react-native"; +import { type ReactNode, useState } from "react"; +import { useThemeColors } from "@/lib/theme"; +import { + getComposerModelOptions, + getConfigOptionLabel, + getMobileExecutionModes, + getModelConfigOption, +} from "./options"; +import { Pill } from "./Pill"; +import { SelectSheet } from "./SelectSheet"; + +const SWITCH_ADAPTER_VALUE = "__switch_adapter__"; + +interface AgentConfigControlsProps { + adapter: Adapter; + mode: ExecutionMode; + model: string; + reasoning: SupportedReasoningEffort; + configOptions: readonly CloudTaskConfigOption[]; + onAdapterChange: (selection: CloudComposerSelection) => void; + onModeChange: (mode: ExecutionMode) => void; + onModelChange: (model: string) => void; + onReasoningChange: (reasoning: SupportedReasoningEffort) => void; + canChangeAdapter?: boolean; +} + +function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode { + switch (mode) { + case "plan": + return ; + case "default": + return ; + case "acceptEdits": + return ; + case "bypassPermissions": + case "full-access": + return ; + case "read-only": + return ; + case "auto": + return ; + } +} + +export function AgentConfigControls({ + adapter, + mode, + model, + reasoning, + configOptions, + onAdapterChange, + onModeChange, + onModelChange, + onReasoningChange, + canChangeAdapter = true, +}: AgentConfigControlsProps) { + const themeColors = useThemeColors(); + const [modeSheetOpen, setModeSheetOpen] = useState(false); + const [modelSheetOpen, setModelSheetOpen] = useState(false); + const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); + const executionModes = getMobileExecutionModes( + getAvailableModesForAdapter(adapter), + ); + const modelConfigOption = getModelConfigOption(configOptions); + const modelOptions = getComposerModelOptions(modelConfigOption); + const reasoningOptions = getReasoningEffortOptions(adapter, model) ?? []; + + return ( + <> + option.id === mode)?.name ?? mode + } + accent={mode === "plan"} + onPress={() => setModeSheetOpen(true)} + /> + + + ) : ( + + ) + } + label={getConfigOptionLabel(modelConfigOption.options, model) ?? model} + onPress={() => setModelSheetOpen(true)} + /> + + {reasoningOptions.length > 0 ? ( + } + label={ + reasoningOptions.find((option) => option.value === reasoning) + ?.name ?? reasoning + } + onPress={() => setReasoningSheetOpen(true)} + /> + ) : null} + + onModeChange(value as ExecutionMode)} + onClose={() => setModeSheetOpen(false)} + options={executionModes.map((option) => ({ + value: option.id, + label: option.name, + description: option.description, + icon: modeIcon( + option.id as ExecutionMode, + option.id === "plan" + ? themeColors.accent[11] + : themeColors.gray[11], + 16, + ), + }))} + /> + + { + if (value === SWITCH_ADAPTER_VALUE) { + onAdapterChange(resolveCloudComposerAdapterChange(adapter)); + return; + } + const next = resolveCloudComposerModelChange({ + adapter, + modelOption: modelConfigOption, + requestedModel: value, + reasoning, + }); + onModelChange(next.model); + onReasoningChange(next.reasoning); + }} + onClose={() => setModelSheetOpen(false)} + options={[ + ...modelOptions.map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + disabled: option.disabled, + icon: + adapter === "codex" ? ( + + ) : ( + + ), + })), + ...(canChangeAdapter + ? [ + { + value: SWITCH_ADAPTER_VALUE, + label: `Switch to ${adapter === "claude" ? "Codex" : "Claude Code"}`, + description: "Change coding agent", + disabled: false, + icon: + adapter === "claude" ? ( + + ) : ( + + ), + }, + ] + : []), + ]} + /> + + + onReasoningChange(value as SupportedReasoningEffort) + } + onClose={() => setReasoningSheetOpen(false)} + options={reasoningOptions.map((option) => ({ + value: option.value, + label: option.name, + icon: , + }))} + /> + + ); +} diff --git a/apps/mobile/src/features/tasks/composer/Pill.tsx b/apps/mobile/src/features/tasks/composer/Pill.tsx index 5860c05d0c..94446f3770 100644 --- a/apps/mobile/src/features/tasks/composer/Pill.tsx +++ b/apps/mobile/src/features/tasks/composer/Pill.tsx @@ -11,14 +11,23 @@ interface PillProps { placeholder?: boolean; /** Tone the label in accent (used for Plan Mode in the desktop). */ accent?: boolean; - onPress: () => void; + onPress?: () => void; + disabled?: boolean; } -export function Pill({ icon, label, placeholder, accent, onPress }: PillProps) { +export function Pill({ + icon, + label, + placeholder, + accent, + onPress, + disabled = false, +}: PillProps) { const themeColors = useThemeColors(); return ( {icon ? {icon} : null} @@ -34,7 +43,7 @@ export function Pill({ icon, label, placeholder, accent, onPress }: PillProps) { > {label} - + {disabled ? null : } ); } diff --git a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx index 28c737b2c4..5bc15e24a4 100644 --- a/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx +++ b/apps/mobile/src/features/tasks/composer/TaskChatComposer.tsx @@ -1,42 +1,27 @@ import { Text } from "@components/text"; -import { - DEFAULT_CLAUDE_EXECUTION_MODE, - getAvailableModes, -} from "@posthog/core/sessions/executionModes"; +import { DEFAULT_CLAUDE_EXECUTION_MODE } from "@posthog/core/sessions/executionModes"; +import type { CloudComposerSelection } from "@posthog/core/task-detail/composerModelPolicy"; import { resolveCloudComposerModelChange } from "@posthog/core/task-detail/composerModelPolicy"; import { + type Adapter, DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, type ExecutionMode, - getReasoningEffortOptions, type SupportedReasoningEffort, } from "@posthog/shared"; import * as Haptics from "expo-haptics"; import { ArrowUp, - BrainIcon, Lightning, Microphone, PaperclipIcon, - PauseIcon, PencilIcon, - Robot, - ShieldCheck, - Sparkle, Stack, Stop, } from "phosphor-react-native"; -import { - type ReactNode, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, - Animated, - Easing, Keyboard, Pressable, ScrollView, @@ -48,6 +33,7 @@ import { useCloudTaskConfigOptions } from "@/features/tasks/hooks/useCloudTaskCo import { logger } from "@/lib/logger"; import { useThemeColors } from "@/lib/theme"; import type { MessagingMode } from "../stores/messagingModeStore"; +import { AgentConfigControls } from "./AgentConfigControls"; import { AttachmentSheet } from "./attachments/AttachmentSheet"; import { AttachmentsBar } from "./attachments/AttachmentsBar"; import { @@ -56,19 +42,10 @@ import { pickPhotoFromLibrary, } from "./attachments/pickers"; import type { PendingAttachment } from "./attachments/types"; -import { - getComposerModelOptions, - getConfigOptionLabel, - getMobileExecutionModes, - getModelConfigOption, - resolveComposerPrimaryAction, -} from "./options"; +import { getModelConfigOption, resolveComposerPrimaryAction } from "./options"; import { Pill } from "./Pill"; -import { SelectSheet } from "./SelectSheet"; const log = logger.scope("task-chat-composer"); -const EXECUTION_MODES = getMobileExecutionModes(getAvailableModes()); - interface TaskChatComposerProps { onSend: (message: string, attachments: PendingAttachment[]) => void; onStop?: () => void; @@ -77,9 +54,12 @@ interface TaskChatComposerProps { initialMessage?: string; isUserTurn?: boolean; /** Current pill values (persisted per-task by the caller). */ + adapter: Adapter; mode: ExecutionMode; model: string; reasoning: SupportedReasoningEffort; + onAdapterChange: (selection: CloudComposerSelection) => void; + canChangeAdapter?: boolean; onModeChange: (mode: ExecutionMode) => void; onModelChange: (model: string) => void; onReasoningChange: (reasoning: SupportedReasoningEffort) => void; @@ -94,78 +74,6 @@ interface TaskChatComposerProps { onCancelEdit?: () => void; } -function modeIcon(mode: ExecutionMode, color: string, size = 14): ReactNode { - switch (mode) { - case "plan": - return ; - case "default": - return ; - case "acceptEdits": - return ; - case "bypassPermissions": - case "full-access": - return ; - case "read-only": - return ; - case "auto": - return ; - } -} - -function PulsingBorder({ active, color }: { active: boolean; color: string }) { - const opacity = useRef(new Animated.Value(0)).current; - const animRef = useRef(null); - - useEffect(() => { - if (active) { - opacity.setValue(0); - animRef.current = Animated.loop( - Animated.sequence([ - Animated.timing(opacity, { - toValue: 1, - duration: 1500, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - Animated.timing(opacity, { - toValue: 0, - duration: 1500, - easing: Easing.inOut(Easing.ease), - useNativeDriver: true, - }), - ]), - ); - animRef.current.start(); - } else { - animRef.current?.stop(); - animRef.current = null; - opacity.setValue(0); - } - return () => { - animRef.current?.stop(); - }; - }, [active, opacity]); - - if (!active) return null; - - return ( - - ); -} - export function TaskChatComposer({ onSend, onStop, @@ -173,9 +81,12 @@ export function TaskChatComposer({ placeholder = "Ask a question", initialMessage, isUserTurn = false, + adapter, mode, model, reasoning, + onAdapterChange, + canChangeAdapter = true, onModeChange, onModelChange, onReasoningChange, @@ -187,9 +98,8 @@ export function TaskChatComposer({ onCancelEdit, }: TaskChatComposerProps) { const themeColors = useThemeColors(); - const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions("claude"); + const { configOptions, hasLiveConfig } = useCloudTaskConfigOptions(adapter); const modelConfigOption = getModelConfigOption(configOptions); - const mobileModelOptions = getComposerModelOptions(modelConfigOption); const [message, setMessage] = useState(() => initialMessage ?? ""); const [attachments, setAttachments] = useState([]); const [attachmentSheetOpen, setAttachmentSheetOpen] = useState(false); @@ -208,7 +118,7 @@ export function TaskChatComposer({ useEffect(() => { if (!hasLiveConfig) return; const next = resolveCloudComposerModelChange({ - adapter: "claude", + adapter, modelOption: modelConfigOption, requestedModel: model, reasoning, @@ -216,6 +126,7 @@ export function TaskChatComposer({ if (next.model !== model) onModelChange(next.model); if (next.reasoning !== reasoning) onReasoningChange(next.reasoning); }, [ + adapter, hasLiveConfig, model, modelConfigOption, @@ -234,13 +145,6 @@ export function TaskChatComposer({ const isRecording = status === "recording"; const isTranscribing = status === "transcribing"; - const [modeSheetOpen, setModeSheetOpen] = useState(false); - const [modelSheetOpen, setModelSheetOpen] = useState(false); - const [reasoningSheetOpen, setReasoningSheetOpen] = useState(false); - - const reasoningOptions = getReasoningEffortOptions("claude", model) ?? []; - const showReasoningPill = reasoningOptions.length > 0; - const hasContent = message.trim().length > 0 || attachments.length > 0; const primaryAction = resolveComposerPrimaryAction({ hasContent, @@ -310,10 +214,9 @@ export function TaskChatComposer({ return ( <> - - - - + + + {editing ? ( @@ -339,7 +242,7 @@ export function TaskChatComposer({ /> + + - - option.id === mode) - ?.name ?? mode - } - accent={mode === "plan"} - onPress={() => setModeSheetOpen(true)} - /> - - } - label={ - getConfigOptionLabel(modelConfigOption.options, model) ?? - model - } - onPress={() => setModelSheetOpen(true)} - /> - - {showReasoningPill ? ( - } - label={ - reasoningOptions.find( - (option) => option.value === reasoning, - )?.name ?? reasoning - } - onPress={() => setReasoningSheetOpen(true)} - /> - ) : null} - onModeChange(v as ExecutionMode)} - onClose={() => setModeSheetOpen(false)} - options={EXECUTION_MODES.map((m) => ({ - value: m.id, - label: m.name, - description: m.description, - icon: modeIcon( - m.id as ExecutionMode, - m.id === "plan" ? themeColors.accent[11] : themeColors.gray[11], - 16, - ), - }))} - /> - - { - const next = resolveCloudComposerModelChange({ - adapter: "claude", - modelOption: modelConfigOption, - requestedModel: v, - reasoning, - }); - onModelChange(next.model); - if (next.reasoning !== reasoning) { - onReasoningChange(next.reasoning); - } - }} - onClose={() => setModelSheetOpen(false)} - options={mobileModelOptions.map((m) => ({ - value: m.value, - label: m.label, - description: m.description, - disabled: m.disabled, - icon: , - }))} - /> - - onReasoningChange(v as SupportedReasoningEffort)} - onClose={() => setReasoningSheetOpen(false)} - options={reasoningOptions.map((r) => ({ - value: r.value, - label: r.name, - icon: , - }))} - /> - setAttachmentSheetOpen(false)} diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts index c38504a1c3..a5000a8e21 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.test.ts @@ -263,6 +263,8 @@ describe("_resumeCloudRun", () => { mockGetTask.mockResolvedValue( previousTask({ branch: "feature", + runtime_adapter: "claude", + model: "claude-opus-4-8", reasoning_effort: "low", state: { initial_permission_mode: "acceptEdits" }, }), @@ -275,6 +277,7 @@ describe("_resumeCloudRun", () => { expect(mockRunTaskInCloud).toHaveBeenCalledWith("t1", { branch: "feature", runtimeAdapter: "claude", + model: "claude-opus-4-8", resumeFromRunId: "prev-run", pendingUserMessage: "hi", reasoningEffort: "low", @@ -299,7 +302,14 @@ describe("_resumeCloudRun", () => { it("prefers the composer's current selection over the previous run", async () => { useTaskStore.setState({ - composerConfigByTaskId: { t1: { mode: "plan", reasoning: "max" } }, + composerConfigByTaskId: { + t1: { + adapter: "codex", + mode: "plan", + model: "gpt-5.5", + reasoning: "high", + }, + }, }); mockGetTask.mockResolvedValue( previousTask({ @@ -316,11 +326,62 @@ describe("_resumeCloudRun", () => { expect(mockRunTaskInCloud).toHaveBeenCalledWith( "t1", expect.objectContaining({ - reasoningEffort: "max", + runtimeAdapter: "codex", + model: "gpt-5.5", + reasoningEffort: "high", initialPermissionMode: "plan", }), ); }); + + it("drops stored reasoning unsupported by the resumed model", async () => { + mockGetTask.mockResolvedValue( + previousTask({ + runtime_adapter: "claude", + model: "claude-sonnet-4-6", + reasoning_effort: "max", + }), + ); + + await useTaskSessionStore + .getState() + ._resumeCloudRun("t1", "prev-run", "hi"); + + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "t1", + expect.objectContaining({ reasoningEffort: undefined }), + ); + }); + + it("ignores legacy Claude composer values when resuming Codex", async () => { + useTaskStore.setState({ + composerConfigByTaskId: { + t1: { mode: "plan", model: "claude-opus-4-8", reasoning: "high" }, + }, + }); + mockGetTask.mockResolvedValue( + previousTask({ + runtime_adapter: "codex", + model: "gpt-5.5", + reasoning_effort: "medium", + state: { initial_permission_mode: "auto" }, + }), + ); + + await useTaskSessionStore + .getState() + ._resumeCloudRun("t1", "prev-run", "hi"); + + expect(mockRunTaskInCloud).toHaveBeenCalledWith( + "t1", + expect.objectContaining({ + runtimeAdapter: "codex", + model: "gpt-5.5", + reasoningEffort: "medium", + initialPermissionMode: "auto", + }), + ); + }); }); describe("compaction tracking from the log stream", () => { diff --git a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts index 0c57cc6d1a..83e191119f 100644 --- a/apps/mobile/src/features/tasks/stores/taskSessionStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskSessionStore.ts @@ -1,3 +1,4 @@ +import { resolveCloudResumeOptions } from "@posthog/core/sessions/cloudRunOptions"; import { convertStoredEntriesToPortableSessionEvents } from "@posthog/core/sessions/portableSessionEvents"; import { type CloudTaskUpdatePayload, @@ -1187,22 +1188,19 @@ export const useTaskSessionStore = create((set, get) => ({ const composerConfig = useTaskStore.getState().composerConfigByTaskId[taskId]; - const previousPermissionMode = previousRun?.state?.initial_permission_mode; - const reasoningEffort = - composerConfig?.reasoning ?? previousRun?.reasoning_effort ?? undefined; - const initialPermissionMode = - composerConfig?.mode ?? - (typeof previousPermissionMode === "string" - ? previousPermissionMode - : undefined); + const runtimeOptions = resolveCloudResumeOptions( + composerConfig, + previousRun, + ); const updatedTask = await runTaskInCloud(taskId, { branch: previousBranch, - runtimeAdapter: "claude", + runtimeAdapter: runtimeOptions.adapter, + model: runtimeOptions.model, resumeFromRunId: previousRunId, pendingUserMessage: prompt, - reasoningEffort, - initialPermissionMode, + reasoningEffort: runtimeOptions.reasoningLevel, + initialPermissionMode: runtimeOptions.initialPermissionMode, rtkEnabled: usePreferencesStore.getState().rtkEnabledCloud, }); diff --git a/apps/mobile/src/features/tasks/stores/taskStore.ts b/apps/mobile/src/features/tasks/stores/taskStore.ts index 39276a7eeb..f7589e0333 100644 --- a/apps/mobile/src/features/tasks/stores/taskStore.ts +++ b/apps/mobile/src/features/tasks/stores/taskStore.ts @@ -1,5 +1,9 @@ import type { TaskActivitySortMode } from "@posthog/core/tasks/taskActivity"; -import type { ExecutionMode, SupportedReasoningEffort } from "@posthog/shared"; +import type { + Adapter, + ExecutionMode, + SupportedReasoningEffort, +} from "@posthog/shared"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; @@ -16,6 +20,7 @@ const EMPTY_REPOSITORY_SELECTION: RepositorySelection = { /** Per-task chat composer pill values. Persisted so reopening a task keeps * the mode/model/reasoning the user last selected for it. */ export interface TaskComposerConfig { + adapter?: Adapter; mode?: ExecutionMode; model?: string; reasoning?: SupportedReasoningEffort; diff --git a/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts new file mode 100644 index 0000000000..320a2f6a56 --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { buildCloudTaskRunConfig } from "./cloudTaskRunConfig"; + +describe("buildCloudTaskRunConfig", () => { + it("forwards the selected Codex configuration to cloud task dispatch", () => { + expect( + buildCloudTaskRunConfig({ + adapter: "codex", + mode: "full-access", + model: "gpt-5.5", + reasoning: "high", + }), + ).toEqual({ + adapter: "codex", + initialPermissionMode: "full-access", + model: "gpt-5.5", + reasoningLevel: "high", + }); + }); + + it("omits reasoning when the selected model does not support it", () => { + expect( + buildCloudTaskRunConfig({ + adapter: "claude", + mode: "plan", + model: "claude-haiku-4-5", + reasoning: "high", + }).reasoningLevel, + ).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts new file mode 100644 index 0000000000..0ebddf8c4b --- /dev/null +++ b/apps/mobile/src/features/tasks/utils/cloudTaskRunConfig.ts @@ -0,0 +1,28 @@ +import { + type Adapter, + type ExecutionMode, + getReasoningEffortOptions, + type SupportedReasoningEffort, +} from "@posthog/shared"; + +export function buildCloudTaskRunConfig({ + adapter, + mode, + model, + reasoning, +}: { + adapter: Adapter; + mode: ExecutionMode; + model: string; + reasoning: SupportedReasoningEffort; +}) { + return { + adapter, + model, + reasoningLevel: + getReasoningEffortOptions(adapter, model) === null + ? undefined + : reasoning, + initialPermissionMode: mode, + }; +} diff --git a/packages/core/src/sessions/cloudRunOptions.test.ts b/packages/core/src/sessions/cloudRunOptions.test.ts index 4ee64d8db2..53f6a69fbc 100644 --- a/packages/core/src/sessions/cloudRunOptions.test.ts +++ b/packages/core/src/sessions/cloudRunOptions.test.ts @@ -5,6 +5,7 @@ import { getCloudPrAuthorshipMode, getCloudRunSource, getCloudRuntimeOptions, + resolveCloudResumeOptions, } from "./cloudRunOptions"; describe("getCloudPrAuthorshipMode", () => { @@ -115,3 +116,41 @@ describe("getCloudRuntimeOptions", () => { expect(result.initialPermissionMode).toBe(expected); }); }); + +describe("resolveCloudResumeOptions", () => { + it("ignores legacy Claude composer values when resuming Codex", () => { + expect( + resolveCloudResumeOptions( + { model: "claude-opus-4-8", reasoning: "high", mode: "plan" }, + { + runtime_adapter: "codex", + model: "gpt-5.5", + reasoning_effort: "medium", + state: { initial_permission_mode: "auto" }, + } as unknown as TaskRun, + ), + ).toEqual({ + adapter: "codex", + model: "gpt-5.5", + reasoningLevel: "medium", + initialPermissionMode: "auto", + }); + }); + + it("does not carry previous run options across an explicit adapter change", () => { + expect( + resolveCloudResumeOptions( + { adapter: "codex", model: "gpt-5.5", reasoning: "high", mode: "auto" }, + { + model: "claude-opus-4-8", + state: { initial_permission_mode: "plan" }, + } as unknown as TaskRun, + ), + ).toEqual({ + adapter: "codex", + model: "gpt-5.5", + reasoningLevel: "high", + initialPermissionMode: "auto", + }); + }); +}); diff --git a/packages/core/src/sessions/cloudRunOptions.ts b/packages/core/src/sessions/cloudRunOptions.ts index 402d936f00..4beec4d0ac 100644 --- a/packages/core/src/sessions/cloudRunOptions.ts +++ b/packages/core/src/sessions/cloudRunOptions.ts @@ -4,7 +4,9 @@ import { type CloudRunSource, type ExecutionMode, getConfigOptionByCategory, + isSupportedReasoningEffort, type PrAuthorshipMode, + type SupportedReasoningEffort, } from "@posthog/shared"; import type { TaskRun } from "@posthog/shared/domain-types"; @@ -38,6 +40,53 @@ export interface CloudRuntimeOptions { initialPermissionMode?: ExecutionMode; } +export interface StoredCloudComposerConfig { + adapter?: Adapter; + model?: string; + reasoning?: SupportedReasoningEffort; + mode?: ExecutionMode; +} + +export function resolveCloudResumeOptions( + composerConfig: StoredCloudComposerConfig | undefined, + previousRun: TaskRun | undefined, +): Required> & + Omit { + const adapter = + composerConfig?.adapter ?? previousRun?.runtime_adapter ?? "claude"; + const composerAdapter = composerConfig?.adapter ?? "claude"; + const useComposerConfig = + composerConfig !== undefined && composerAdapter === adapter; + const previousAdapter = previousRun?.runtime_adapter ?? "claude"; + const previousRunMatchesAdapter = + previousRun !== undefined && previousAdapter === adapter; + const model = + (useComposerConfig ? composerConfig.model : undefined) ?? + (previousRunMatchesAdapter ? previousRun?.model : undefined) ?? + undefined; + const requestedReasoning = + (useComposerConfig ? composerConfig.reasoning : undefined) ?? + (previousRunMatchesAdapter ? previousRun?.reasoning_effort : undefined) ?? + undefined; + const previousMode = previousRun?.state?.initial_permission_mode; + + return { + adapter, + model, + reasoningLevel: + model && + requestedReasoning && + isSupportedReasoningEffort(adapter, model, requestedReasoning) + ? requestedReasoning + : undefined, + initialPermissionMode: + (useComposerConfig ? composerConfig.mode : undefined) ?? + (previousRunMatchesAdapter && typeof previousMode === "string" + ? (previousMode as ExecutionMode) + : undefined), + }; +} + export function getCloudRuntimeOptions( session: AgentSession, previousRun?: TaskRun, diff --git a/packages/core/src/sessions/cloudSessionConfig.test.ts b/packages/core/src/sessions/cloudSessionConfig.test.ts index 5a6499f5e4..0b97ebbfcb 100644 --- a/packages/core/src/sessions/cloudSessionConfig.test.ts +++ b/packages/core/src/sessions/cloudSessionConfig.test.ts @@ -4,6 +4,7 @@ import { addMissingCloudRuntimeConfigOptions, buildCloudDefaultConfigOptions, extractLatestConfigOptionsFromEntries, + getCloudReasoningConfigOptionId, } from "./cloudSessionConfig"; function configUpdateEntry( @@ -19,6 +20,13 @@ function configUpdateEntry( } as unknown as StoredLogEntry; } +it.each([ + ["claude", "effort"], + ["codex", "reasoning_effort"], +] as const)("uses the %s reasoning config id", (adapter, expected) => { + expect(getCloudReasoningConfigOptionId(adapter)).toBe(expected); +}); + describe("extractLatestConfigOptionsFromEntries", () => { it("returns undefined when no config_option_update entries exist", () => { expect(extractLatestConfigOptionsFromEntries([])).toBeUndefined(); diff --git a/packages/core/src/sessions/cloudSessionConfig.ts b/packages/core/src/sessions/cloudSessionConfig.ts index b6a9e4f8f4..c340613ac4 100644 --- a/packages/core/src/sessions/cloudSessionConfig.ts +++ b/packages/core/src/sessions/cloudSessionConfig.ts @@ -1,11 +1,14 @@ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; import type { Adapter, StoredLogEntry } from "@posthog/shared"; import { - DEFAULT_CLAUDE_EXECUTION_MODE, - getAvailableCodexModes, - getAvailableModes, + getAvailableModesForAdapter, + getDefaultExecutionModeForAdapter, } from "./executionModes"; +export function getCloudReasoningConfigOptionId(adapter: Adapter): string { + return adapter === "codex" ? "reasoning_effort" : "effort"; +} + /** * Pure derivations of cloud session config options. No store or host access — * just shaping the config-option list the mode switcher renders. @@ -56,10 +59,8 @@ export function buildCloudDefaultConfigOptions( adapter: Adapter = "claude", extra: SessionConfigOption[] = [], ): SessionConfigOption[] { - const modes = - adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); - const fallbackMode = - adapter === "codex" ? "auto" : DEFAULT_CLAUDE_EXECUTION_MODE; + const modes = getAvailableModesForAdapter(adapter); + const fallbackMode = getDefaultExecutionModeForAdapter(adapter); const currentMode = typeof initialMode === "string" && modes.some((mode) => mode.id === initialMode) @@ -104,7 +105,7 @@ export function addMissingCloudRuntimeConfigOptions( if (initialReasoningEffort && !categories.has("thought_level")) { extras.push({ - id: adapter === "codex" ? "reasoning_effort" : "effort", + id: getCloudReasoningConfigOptionId(adapter), name: adapter === "codex" ? "Reasoning" : "Effort", type: "select", currentValue: initialReasoningEffort, diff --git a/packages/core/src/sessions/executionModes.test.ts b/packages/core/src/sessions/executionModes.test.ts new file mode 100644 index 0000000000..9246e9ce67 --- /dev/null +++ b/packages/core/src/sessions/executionModes.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { + getAvailableModesForAdapter, + getDefaultExecutionModeForAdapter, +} from "./executionModes"; + +describe("getAvailableModesForAdapter", () => { + it.each([ + ["claude", ["default", "acceptEdits", "plan", "bypassPermissions", "auto"]], + ["codex", ["plan", "read-only", "auto", "full-access"]], + ] as const)("returns %s execution modes", (adapter, expected) => { + expect(getAvailableModesForAdapter(adapter).map((mode) => mode.id)).toEqual( + expected, + ); + }); +}); + +describe("getDefaultExecutionModeForAdapter", () => { + it.each([ + ["claude", "plan"], + ["codex", "auto"], + ] as const)("returns the desktop default for %s", (adapter, expected) => { + expect(getDefaultExecutionModeForAdapter(adapter)).toBe(expected); + }); +}); diff --git a/packages/core/src/sessions/executionModes.ts b/packages/core/src/sessions/executionModes.ts index 2ccbadf354..a415b2716c 100644 --- a/packages/core/src/sessions/executionModes.ts +++ b/packages/core/src/sessions/executionModes.ts @@ -46,3 +46,15 @@ export function getAvailableModes(): ModeInfo[] { export function getAvailableCodexModes(): ModeInfo[] { return [...CODEX_MODE_PRESETS]; } + +export function getAvailableModesForAdapter( + adapter: "claude" | "codex", +): ModeInfo[] { + return adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); +} + +export function getDefaultExecutionModeForAdapter( + adapter: "claude" | "codex", +): ExecutionMode { + return adapter === "codex" ? "auto" : DEFAULT_CLAUDE_EXECUTION_MODE; +} diff --git a/packages/core/src/task-detail/composerModelPolicy.test.ts b/packages/core/src/task-detail/composerModelPolicy.test.ts index 235c6199f6..d95d47ee5e 100644 --- a/packages/core/src/task-detail/composerModelPolicy.test.ts +++ b/packages/core/src/task-detail/composerModelPolicy.test.ts @@ -6,7 +6,31 @@ import { type SupportedReasoningEffort, } from "@posthog/shared"; import { expect, it } from "vitest"; -import { resolveCloudComposerModelChange } from "./composerModelPolicy"; +import { + resolveCloudComposerAdapterChange, + resolveCloudComposerModelChange, +} from "./composerModelPolicy"; + +it.each([ + [ + "claude", + { adapter: "codex", mode: "auto", model: "gpt-5.5", reasoning: "high" }, + ], + [ + "codex", + { + adapter: "claude", + mode: "plan", + model: DEFAULT_GATEWAY_MODEL, + reasoning: "high", + }, + ], +] as const)( + "resets composer defaults when switching from %s", + (adapter, expected) => { + expect(resolveCloudComposerAdapterChange(adapter)).toEqual(expected); + }, +); const modelOption: CloudTaskConfigOption = { id: "model", diff --git a/packages/core/src/task-detail/composerModelPolicy.ts b/packages/core/src/task-detail/composerModelPolicy.ts index d9783335f2..1c496e5ce1 100644 --- a/packages/core/src/task-detail/composerModelPolicy.ts +++ b/packages/core/src/task-detail/composerModelPolicy.ts @@ -1,11 +1,34 @@ import { type Adapter, type CloudTaskConfigOption, + DEFAULT_CODEX_MODEL, + DEFAULT_GATEWAY_MODEL, DEFAULT_REASONING_EFFORT, + type ExecutionMode, isRestrictedModelOption, isSupportedReasoningEffort, type SupportedReasoningEffort, } from "@posthog/shared"; +import { getDefaultExecutionModeForAdapter } from "../sessions/executionModes"; + +export interface CloudComposerSelection { + adapter: Adapter; + mode: ExecutionMode; + model: string; + reasoning: SupportedReasoningEffort; +} + +export function resolveCloudComposerAdapterChange( + currentAdapter: Adapter, +): CloudComposerSelection { + const adapter: Adapter = currentAdapter === "claude" ? "codex" : "claude"; + return { + adapter, + mode: getDefaultExecutionModeForAdapter(adapter), + model: adapter === "codex" ? DEFAULT_CODEX_MODEL : DEFAULT_GATEWAY_MODEL, + reasoning: DEFAULT_REASONING_EFFORT, + }; +} export function resolveCloudComposerModelChange({ adapter,