diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index c8a66c5d0..76a80dce5 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -815,7 +815,7 @@ function DaemonCard({ const daemonConnection = useSessionStore( (state) => state.sessions[daemon.id]?.connection ?? null ); - const restartServerFn = useSessionStore((state) => state.sessions[daemon.id]?.methods?.restartServer); + const daemonClient = useSessionStore((state) => state.sessions[daemon.id]?.client ?? null); const [isRestarting, setIsRestarting] = useState(false); const isConnected = daemonConnection?.isConnected ?? false; const isConnectedRef = useRef(isConnected); @@ -848,7 +848,7 @@ function DaemonCard({ }, [daemon.label, isScreenMountedRef, waitForCondition]); const beginServerRestart = useCallback(() => { - if (!restartServerFn) { + if (!daemonClient) { Alert.alert( "Host unavailable", `${daemon.label} is not connected. Wait for it to come online before restarting.` @@ -865,23 +865,25 @@ function DaemonCard({ } setIsRestarting(true); - try { - restartServerFn(`settings_daemon_restart_${daemon.id}`); - } catch (error) { - console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error); - setIsRestarting(false); - Alert.alert( - "Error", - "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online." - ); - return; - } + void daemonClient + .restartServer(`settings_daemon_restart_${daemon.id}`) + .catch((error) => { + console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error); + if (!isScreenMountedRef.current) { + return; + } + setIsRestarting(false); + Alert.alert( + "Error", + "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online." + ); + }); void waitForDaemonRestart(); - }, [daemon.id, daemon.label, restartServerFn, waitForDaemonRestart]); + }, [daemon.id, daemon.label, daemonClient, isScreenMountedRef, waitForDaemonRestart]); const handleRestartPress = useCallback(() => { - if (!restartServerFn) { + if (!daemonClient) { Alert.alert( "Host unavailable", `${daemon.label} is not connected. Wait for it to come online before restarting.` @@ -912,7 +914,7 @@ function DaemonCard({ onPress: beginServerRestart, }, ]); - }, [beginServerRestart, daemon.label, restartConfirmationMessage, restartServerFn]); + }, [beginServerRestart, daemon.label, daemonClient, restartConfirmationMessage]); // Status pill background with 10% opacity const statusPillBg = diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 9e4fbd029..43eb98b44 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -27,6 +27,7 @@ import { import { Theme } from "@/styles/theme"; import { CommandAutocomplete } from "./command-autocomplete"; import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; +import { encodeImages } from "@/utils/encode-images"; type QueuedMessage = { id: string; @@ -85,11 +86,8 @@ export function AgentInputArea({ ); const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; - const methods = useSessionStore((state) => state.sessions[serverId]?.methods); - const sendAgentMessage = methods?.sendAgentMessage; - const cancelAgentRun = methods?.cancelAgentRun; - const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice(); @@ -129,7 +127,9 @@ export function AgentInputArea({ const { pickImages } = useImageAttachmentPicker(); const agentIdRef = useRef(agentId); - const sendAgentMessageRef = useRef(sendAgentMessage); + const sendAgentMessageRef = useRef< + ((agentId: string, text: string, images?: ImageAttachment[]) => Promise) | null + >(null); const onSubmitMessageRef = useRef(onSubmitMessage); const messageInputRef = useRef(null); @@ -148,7 +148,10 @@ export function AgentInputArea({ await onSubmitMessageRef.current({ text, images }); return; } - await sendAgentMessageRef.current?.(agentIdRef.current, text, images); + if (!sendAgentMessageRef.current) { + throw new Error("Host is not connected"); + } + await sendAgentMessageRef.current(agentIdRef.current, text, images); }, [] ); @@ -158,8 +161,36 @@ export function AgentInputArea({ }, [agentId]); useEffect(() => { - sendAgentMessageRef.current = sendAgentMessage; - }, [sendAgentMessage]); + sendAgentMessageRef.current = async ( + agentId: string, + text: string, + images?: ImageAttachment[] + ) => { + if (!client) { + throw new Error("Host is not connected"); + } + + const messageId = generateMessageId(); + setAgentStreamTail(serverId, (prev) => { + const currentStream = prev.get(agentId) || []; + const nextItem: any = { + kind: "user_message", + id: messageId, + text, + timestamp: new Date(), + }; + const updated = new Map(prev); + updated.set(agentId, [...currentStream, nextItem]); + return updated; + }); + + const imagesData = await encodeImages(images); + await client.sendAgentMessage(agentId, text, { + messageId, + ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + }); + }; + }, [client, serverId, setAgentStreamTail]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; @@ -213,7 +244,7 @@ export function AgentInputArea({ // decide what to do even if the socket is currently disconnected (so we // don't no-op and lose deterministic error handling in the UI/tests). if (!onSubmitMessageRef.current && !socketConnected) return; - if (!sendAgentMessage && !onSubmitMessageRef.current) return; + if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; if (agent?.status === "running" && !forceSend) { queueMessage(trimmedMessage, imageAttachments); @@ -334,11 +365,11 @@ export function AgentInputArea({ if (!agent || agent.status !== "running" || isCancellingAgent) { return; } - if (!isConnected || !cancelAgentRun) { + if (!isConnected || !client) { return; } setIsCancellingAgent(true); - cancelAgentRun(agentIdRef.current); + void client.cancelAgent(agentIdRef.current); messageInputRef.current?.focus(); } @@ -354,7 +385,7 @@ export function AgentInputArea({ async function handleSendQueuedNow(id: string) { const item = queuedMessages.find((q) => q.id === id); if (!item || !isConnected) return; - if (!sendAgentMessage && !onSubmitMessageRef.current) return; + if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; updateQueue((current) => current.filter((q) => q.id !== id)); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 5a248df11..f5ed5eba1 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -85,16 +85,12 @@ export function AgentList({ const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); - // Get the methods for the specific server - const methods = useSessionStore((state) => - actionAgent?.serverId - ? state.sessions[actionAgent.serverId]?.methods - : undefined + const actionClient = useSessionStore((state) => + actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null ); - const archiveAgent = methods?.archiveAgent; const isActionSheetVisible = actionAgent !== null; - const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods); + const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); const handleAgentPress = useCallback( (serverId: string, agentId: string) => { @@ -134,12 +130,12 @@ export function AgentList({ }, []); const handleArchiveAgent = useCallback(() => { - if (!actionAgent || !archiveAgent) { + if (!actionAgent || !actionClient) { return; } - archiveAgent(actionAgent.id); + void actionClient.archiveAgent(actionAgent.id); setActionAgent(null); - }, [actionAgent, archiveAgent]); + }, [actionAgent, actionClient]); const viewabilityConfig = useMemo( () => ({ itemVisiblePercentThreshold: 30 }), @@ -347,7 +343,7 @@ export function AgentList({ Cancel Archive diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 1e91db2eb..b8ba89a80 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -1,6 +1,6 @@ -import { View, Text } from "react-native"; +import { View, Text, Platform, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronDown } from "lucide-react-native"; +import { ChevronDown, SlidersHorizontal } from "lucide-react-native"; import { useSessionStore } from "@/stores/session-store"; import { DropdownMenu, @@ -9,6 +9,9 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; interface AgentStatusBarProps { agentId: string; @@ -17,27 +20,62 @@ interface AgentStatusBarProps { export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { const { theme } = useUnistyles(); + const IS_WEB = Platform.OS === "web"; + const [prefsOpen, setPrefsOpen] = useState(false); // Select only the specific agent (not all agents) const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId) ); - // Get the setAgentMode action (actions are stable, won't cause rerenders) - const setAgentMode = useSessionStore( - (state) => state.sessions[serverId]?.methods?.setAgentMode - ); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); if (!agent) { return null; } + const canFetchModels = Boolean(client) && Boolean(agent.provider) && (IS_WEB || prefsOpen); + const modelsQuery = useQuery({ + queryKey: ["providerModels", serverId, agent.provider, agent.cwd], + enabled: canFetchModels, + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.models ?? []; + }, + }); + const models = modelsQuery.data ?? null; + function handleModeChange(modeId: string) { - if (setAgentMode) { - setAgentMode(agentId, modeId); + if (!client) { + return; } + void client.setAgentMode(agentId, modeId).catch((error) => { + console.warn("[AgentStatusBar] setAgentMode failed", error); + }); } + const selectedModel = useMemo(() => { + if (!models || !agent.model) return null; + return models.find((m) => m.id === agent.model) ?? null; + }, [models, agent.model]); + + const displayModel = selectedModel?.label ?? agent.model ?? "default"; + + const thinkingOptions = selectedModel?.thinkingOptions ?? null; + const selectedThinkingId = + agent.thinkingOptionId ?? + selectedModel?.defaultThinkingOptionId ?? + "default"; + const selectedThinking = thinkingOptions?.find((o) => o.id === selectedThinkingId) ?? null; + const displayThinking = selectedThinking?.label ?? selectedThinkingId ?? "default"; + return ( {/* Agent Mode Badge */} @@ -83,6 +121,208 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { )} + + {/* Desktop: inline dropdowns for model/thinking */} + {IS_WEB && ( + <> + + [ + styles.modeBadge, + pressed && styles.modeBadgePressed, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent model" + testID="agent-model-selector" + > + {displayModel} + + + + Model + {models?.map((model) => { + const isActive = model.id === agent.model; + return ( + { + if (!client) { + return; + } + void client.setAgentModel(agentId, model.id).catch((error) => { + console.warn("[AgentStatusBar] setAgentModel failed", error); + }); + }} + > + {model.label} + + ); + })} + + + + {thinkingOptions && thinkingOptions.length > 1 && ( + + [ + styles.modeBadge, + pressed && styles.modeBadgePressed, + ]} + accessibilityRole="button" + accessibilityLabel="Select thinking option" + testID="agent-thinking-selector" + > + {displayThinking} + + + + Thinking + {thinkingOptions.map((opt) => { + const isActive = opt.id === selectedThinkingId; + return ( + { + if (!client) { + return; + } + void client + .setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id) + .catch((error) => { + console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); + }); + }} + > + {opt.label} + + ); + })} + + + )} + + + )} + + {/* Mobile: preferences button opens a bottom sheet */} + {!IS_WEB && ( + <> + setPrefsOpen(true)} + style={({ pressed }) => [ + styles.prefsButton, + pressed && styles.prefsButtonPressed, + ]} + accessibilityRole="button" + accessibilityLabel="Agent preferences" + testID="agent-preferences-button" + > + + + + setPrefsOpen(false)} + testID="agent-preferences-sheet" + > + + Model + + [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + ]} + accessibilityRole="button" + accessibilityLabel="Select agent model" + testID="agent-preferences-model" + > + {displayModel} + + + + Model + {models?.map((model) => { + const isActive = model.id === agent.model; + return ( + { + if (!client) { + return; + } + void client.setAgentModel(agentId, model.id).catch((error) => { + console.warn("[AgentStatusBar] setAgentModel failed", error); + }); + }} + > + {model.label} + + ); + })} + + + + + {thinkingOptions && thinkingOptions.length > 1 && ( + + Thinking + + [ + styles.sheetSelect, + pressed && styles.sheetSelectPressed, + ]} + accessibilityRole="button" + accessibilityLabel="Select thinking option" + testID="agent-preferences-thinking" + > + {displayThinking} + + + + Thinking + {thinkingOptions.map((opt) => { + const isActive = opt.id === selectedThinkingId; + return ( + { + if (!client) { + return; + } + void client + .setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id) + .catch((error) => { + console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); + }); + }} + > + {opt.label} + + ); + })} + + + + )} + + + + )} ); } @@ -111,4 +351,44 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.semibold, textTransform: "capitalize", }, + prefsButton: { + width: 32, + height: 32, + borderRadius: theme.borderRadius["2xl"], + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.surface2, + }, + prefsButtonPressed: { + backgroundColor: theme.colors.surface0, + }, + sheetSection: { + gap: theme.spacing[2], + }, + sheetLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + sheetSelect: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.surface2, + backgroundColor: theme.colors.surface0, + }, + sheetSelectPressed: { + backgroundColor: theme.colors.surface2, + }, + sheetSelectText: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, })); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 9174c6b73..2c3be4a9d 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -44,6 +44,7 @@ import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { Agent } from "@/contexts/session-context"; import { useSessionStore } from "@/stores/session-store"; +import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import { parseToolCallDisplay } from "@/utils/tool-call-parsers"; import { ToolCallDetailsContent } from "./tool-call-details"; @@ -94,15 +95,7 @@ export function AgentStreamView({ state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId) ); - // Get methods for file operations - const methods = useSessionStore( - (state) => state.sessions[resolvedServerId]?.methods - ); - const requestDirectoryListing = methods?.requestDirectoryListing; - const requestFilePreview = methods?.requestFilePreview; - - const requestDirectoryListingOrInert = requestDirectoryListing ?? (() => {}); - const requestFilePreviewOrInert = requestFilePreview ?? (() => {}); + const { requestDirectoryListing, requestFilePreview } = useFileExplorerActions(resolvedServerId); // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; @@ -130,9 +123,9 @@ export function AgentStreamView({ return; } - requestDirectoryListingOrInert(agentId, normalized.directory); + requestDirectoryListing(agentId, normalized.directory); if (normalized.file) { - requestFilePreviewOrInert(agentId, normalized.file); + requestFilePreview(agentId, normalized.file); } setExplorerTab("files"); @@ -141,8 +134,8 @@ export function AgentStreamView({ [ agent.cwd, agentId, - requestDirectoryListingOrInert, - requestFilePreviewOrInert, + requestDirectoryListing, + requestFilePreview, setExplorerTab, openFileExplorer, ] diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index a3683b8e6..ccd94113b 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -39,6 +39,7 @@ import type { ExplorerEntry } from "@/stores/session-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore } from "@/stores/session-store"; import { useDownloadStore } from "@/stores/download-store"; +import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { usePanelStore, type SortOption, @@ -81,11 +82,12 @@ export function FileExplorerPane({ : undefined ); - const methods = useSessionStore((state) => state.sessions[serverId]?.methods); - const requestDirectoryListing = methods?.requestDirectoryListing; - const requestFilePreview = methods?.requestFilePreview; - const requestFileDownloadToken = methods?.requestFileDownloadToken; - const navigateExplorerBack = methods?.navigateExplorerBack; + const { + requestDirectoryListing, + requestFilePreview, + requestFileDownloadToken, + navigateExplorerBack, + } = useFileExplorerActions(serverId); const viewMode = usePanelStore((state) => state.explorerViewMode); const sortOption = usePanelStore((state) => state.explorerSortOption); const setSortOption = usePanelStore((state) => state.setExplorerSortOption); diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index 2001f30f5..247e5be7b 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -213,16 +213,12 @@ export function GroupedAgentList({ new Set() ); - // Get the methods for the specific server - const methods = useSessionStore((state) => - actionAgent?.serverId - ? state.sessions[actionAgent.serverId]?.methods - : undefined + const actionClient = useSessionStore((state) => + actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null ); - const archiveAgent = methods?.archiveAgent; const isActionSheetVisible = actionAgent !== null; - const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods); + const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); const handleAgentPress = useCallback( (serverId: string, agentId: string) => { @@ -262,12 +258,12 @@ export function GroupedAgentList({ }, []); const handleArchiveFromSheet = useCallback(() => { - if (!actionAgent || !archiveAgent) { + if (!actionAgent || !actionClient) { return; } - archiveAgent(actionAgent.id); + void actionClient.archiveAgent(actionAgent.id); setActionAgent(null); - }, [actionAgent, archiveAgent]); + }, [actionAgent, actionClient]); const toggleSection = useCallback((sectionKey: string) => { setCollapsedSections((prev) => { @@ -413,9 +409,9 @@ export function GroupedAgentList({ (e: { stopPropagation: () => void }, agent: AggregatedAgent) => { e.stopPropagation(); const session = useSessionStore.getState().sessions[agent.serverId]; - const archiveMethod = session?.methods?.archiveAgent; - if (archiveMethod) { - archiveMethod(agent.id); + const client = session?.client ?? null; + if (client) { + void client.archiveAgent(agent.id); } }, [] @@ -595,7 +591,7 @@ export function GroupedAgentList({ Cancel Archive diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index d60ea2f49..ae31a7de2 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1,12 +1,4 @@ -import { - createContext, - useRef, - ReactNode, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; +import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { AppState, Platform } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useMutation } from "@tanstack/react-query"; @@ -39,12 +31,13 @@ import { import { useDraftStore } from "@/stores/draft-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { sendOsNotification } from "@/utils/os-notifications"; +import { getInitKey, getInitDeferred, resolveInitDeferred, rejectInitDeferred, createInitDeferred } from "@/utils/agent-initialization"; +import { encodeImages } from "@/utils/encode-images"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; export type { MessageEntry, - ProviderModelState, Agent, ExplorerEntry, ExplorerFile, @@ -195,20 +188,6 @@ type FileDownloadTokenPayload = Extract< { type: "file_download_token_response" } >["payload"]; -// Module-level map for agent initialization promises -// Key: `${serverId}:${agentId}`, Value: { promise, resolve, reject } -// This survives Fast Refresh because it's outside React component tree -interface DeferredInit { - promise: Promise; - resolve: () => void; - reject: (error: Error) => void; -} -const agentInitializationPromises = new Map(); - -function getInitKey(serverId: string, agentId: string): string { - return `${serverId}:${agentId}`; -} - function normalizeAgentSnapshot( snapshot: AgentSnapshotPayload, serverId: string @@ -244,6 +223,7 @@ function normalizeAgentSnapshot( title: snapshot.title ?? null, cwd: snapshot.cwd, model: snapshot.model ?? null, + thinkingOptionId: snapshot.thinkingOptionId ?? null, requiresAttention: snapshot.requiresAttention ?? false, attentionReason: snapshot.attentionReason ?? null, attentionTimestamp, @@ -272,56 +252,6 @@ const pushHistory = (history: string[], path: string): string[] => { return [...normalizedHistory, path]; }; -// Lightweight context for imperative APIs only (no state) -export interface SessionContextValue { - serverId: string; - client: DaemonClientV2; - audioPlayer: ReturnType; - setVoiceDetectionFlags: (isDetecting: boolean, isSpeaking: boolean) => void; - requestGitDiff: (agentId: string) => void; - requestDirectoryListing: ( - agentId: string, - path: string, - options?: { recordHistory?: boolean } - ) => void; - requestFilePreview: (agentId: string, path: string) => void; - requestFileDownloadToken: ( - agentId: string, - path: string - ) => Promise; - navigateExplorerBack: (agentId: string) => string | null; - requestProviderModels: (provider: any, options?: { cwd?: string }) => void; - restartServer: (reason?: string) => void; - initializeAgent: (params: { agentId: string; requestId?: string }) => void; - refreshAgent: (params: { agentId: string; requestId?: string }) => void; - refreshSession: () => void; - cancelAgentRun: (agentId: string) => void; - sendAgentMessage: ( - agentId: string, - message: string, - images?: Array<{ uri: string; mimeType?: string }> - ) => Promise; - deleteAgent: (agentId: string) => void; - archiveAgent: (agentId: string) => void; - createAgent: (options: { - config: any; - initialPrompt: string; - images?: Array<{ uri: string; mimeType?: string }>; - git?: any; - worktreeName?: string; - requestId?: string; - }) => Promise; - setAgentMode: (agentId: string, modeId: string) => void; - respondToPermission: ( - agentId: string, - requestId: string, - response: any - ) => void; - ensureAgentIsInitialized: (agentId: string) => Promise; -} - -const SessionContext = createContext(null); - interface SessionProviderProps { children: ReactNode; serverUrl: string; @@ -373,7 +303,6 @@ export function SessionProvider({ ); const setGitDiffs = useSessionStore((state) => state.setGitDiffs); const setFileExplorer = useSessionStore((state) => state.setFileExplorer); - const setProviderModels = useSessionStore((state) => state.setProviderModels); const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const getSession = useSessionStore((state) => state.getSession); @@ -404,7 +333,6 @@ export function SessionProvider({ const previousAgentStatusRef = useRef>( new Map() ); - const providerModelRequestIdsRef = useRef>(new Map()); const sendAgentMessageRef = useRef< | (( agentId: string, @@ -1029,11 +957,7 @@ export function SessionProvider({ // Resolve the initialization promise (even for empty history) const initKey = getInitKey(serverId, agentId); - const deferred = agentInitializationPromises.get(initKey); - if (deferred) { - deferred.resolve(); - // Keep the promise in the map so subsequent calls return immediately - } + resolveInitDeferred(initKey); } ); @@ -1351,35 +1275,6 @@ export function SessionProvider({ } }); - const unsubProviderModels = client.on( - "list_provider_models_response", - (message) => { - if (message.type !== "list_provider_models_response") { - return; - } - const { provider, models, error, fetchedAt, requestId } = - message.payload; - const latestRequestId = - providerModelRequestIdsRef.current.get(provider); - if (latestRequestId && requestId && requestId !== latestRequestId) { - return; - } - if (requestId) { - providerModelRequestIdsRef.current.delete(provider); - } - setProviderModels(serverId, (prev) => { - const next = new Map(prev); - next.set(provider, { - models: models ?? null, - error: error ?? null, - fetchedAt: new Date(fetchedAt), - isLoading: false, - }); - return next; - }); - } - ); - const unsubAgentDeleted = client.on("agent_deleted", (message) => { if (message.type !== "agent_deleted") { return; @@ -1494,7 +1389,6 @@ export function SessionProvider({ unsubActivity(); unsubChunk(); unsubTranscription(); - unsubProviderModels(); unsubAgentDeleted(); unsubAgentArchived(); }; @@ -1514,7 +1408,6 @@ export function SessionProvider({ setPendingPermissions, setGitDiffs, setFileExplorer, - setProviderModels, setHasHydratedAgents, updateConnectionStatus, getSession, @@ -1595,226 +1488,6 @@ export function SessionProvider({ ] ); - const INIT_TIMEOUT_MS = 10000; - - const ensureAgentIsInitialized = useCallback( - (agentId: string): Promise => { - const key = getInitKey(serverId, agentId); - - // If we already have a promise (resolved or in-flight), return it - const existing = agentInitializationPromises.get(key); - if (existing) { - return existing.promise; - } - - // Create a deferred promise - let resolve: () => void; - let reject: (error: Error) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - const deferred: DeferredInit = { - promise, - resolve: resolve!, - reject: reject!, - }; - agentInitializationPromises.set(key, deferred); - - // Set up timeout - const timeoutId = setTimeout(() => { - const entry = agentInitializationPromises.get(key); - if (entry === deferred) { - agentInitializationPromises.delete(key); - deferred.reject(new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`)); - } - }, INIT_TIMEOUT_MS); - - // Set UI loading state - setInitializingAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, true); - return next; - }); - - // Clear existing stream state - setAgentStreamTail(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, []); - return next; - }); - clearAgentStreamHead(serverId, agentId); - - if (!client) { - console.warn("[Session] ensureAgentIsInitialized skipped: daemon unavailable"); - clearTimeout(timeoutId); - agentInitializationPromises.delete(key); - setInitializingAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, false); - return next; - }); - deferred.reject(new Error("Daemon unavailable")); - return promise; - } - - client - .initializeAgent(agentId) - .then(() => { - // Note: We don't resolve here - we wait for agent_stream_snapshot - // The snapshot handler will call deferred.resolve() - clearTimeout(timeoutId); - }) - .catch((error) => { - console.warn("[Session] ensureAgentIsInitialized failed", { agentId, error }); - clearTimeout(timeoutId); - agentInitializationPromises.delete(key); - setInitializingAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, false); - return next; - }); - deferred.reject(error instanceof Error ? error : new Error(String(error))); - }); - - return promise; - }, - [serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead] - ); - - const requestProviderModels = useCallback( - (provider: any, options?: { cwd?: string }) => { - const requestId = generateMessageId(); - providerModelRequestIdsRef.current.set(provider, requestId); - setProviderModels(serverId, (prev) => { - const next = new Map(prev); - const current = prev.get(provider) ?? { - models: null, - fetchedAt: null, - error: null, - isLoading: false, - }; - next.set(provider, { - ...current, - isLoading: true, - error: null, - }); - return next; - }); - if (!client) { - console.warn( - "[Session] requestProviderModels skipped: daemon unavailable", - { provider } - ); - providerModelRequestIdsRef.current.delete(provider); - setProviderModels(serverId, (prev) => { - const next = new Map(prev); - const current = prev.get(provider) ?? { - models: null, - fetchedAt: null, - error: null, - isLoading: false, - }; - next.set(provider, { - ...current, - error: "Daemon unavailable", - isLoading: false, - }); - return next; - }); - return; - } - void client - .listProviderModels(provider, { cwd: options?.cwd, requestId }) - .catch((error) => { - providerModelRequestIdsRef.current.delete(provider); - setProviderModels(serverId, (prev) => { - const next = new Map(prev); - const current = next.get(provider) ?? { - models: null, - fetchedAt: null, - error: null, - isLoading: false, - }; - next.set(provider, { - ...current, - error: error instanceof Error ? error.message : String(error), - isLoading: false, - }); - return next; - }); - }); - }, - [serverId, client, setProviderModels] - ); - - const encodeImages = useCallback( - async (images?: Array<{ uri: string; mimeType?: string }>) => { - if (!images || images.length === 0) { - return undefined; - } - const encodedImages = await Promise.all( - images.map(async ({ uri, mimeType }) => { - try { - const data = await (async () => { - if (Platform.OS === "web") { - if (uri.startsWith("data:")) { - const [, base64] = uri.split(",", 2); - if (!base64) { - throw new Error("Malformed data URI for image."); - } - return base64; - } - const response = await fetch(uri); - if (!response.ok) { - throw new Error(`Failed to fetch image: ${response.status}`); - } - const blob = await response.blob(); - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result !== "string") { - reject(new Error("Unexpected FileReader result type.")); - return; - } - const [, resultBase64] = reader.result.split(",", 2); - if (!resultBase64) { - reject(new Error("Failed to read image data as base64.")); - return; - } - resolve(resultBase64); - }; - reader.onerror = () => { - reject( - reader.error ?? new Error("Failed to read image data.") - ); - }; - reader.readAsDataURL(blob); - }); - return base64; - } - const file = new File(uri); - return await file.base64(); - })(); - return { - data, - mimeType: mimeType ?? "image/jpeg", - }; - } catch (error) { - console.error("[Session] Failed to convert image:", error); - return null; - } - }) - ); - const validImages = encodedImages.filter( - (entry): entry is { data: string; mimeType: string } => entry !== null - ); - return validImages.length > 0 ? validImages : undefined; - }, - [] - ); - const sendAgentMessage = useCallback( async ( agentId: string, @@ -1979,6 +1652,34 @@ export function SessionProvider({ [client] ); + const setAgentModel = useCallback( + (agentId: string, modelId: string | null) => { + if (!client) { + console.warn("[Session] setAgentModel skipped: daemon unavailable"); + return; + } + void client.setAgentModel(agentId, modelId).catch((error) => { + console.error("[Session] Failed to set agent model:", error); + }); + }, + [client] + ); + + const setAgentThinkingOption = useCallback( + (agentId: string, thinkingOptionId: string | null) => { + if (!client) { + console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable"); + return; + } + void client + .setAgentThinkingOption(agentId, thinkingOptionId) + .catch((error) => { + console.error("[Session] Failed to set agent thinking option:", error); + }); + }, + [client] + ); + const respondToPermission = useCallback( (agentId: string, requestId: string, response: any) => { if (!client) { @@ -2236,113 +1937,5 @@ export function SessionProvider({ }; }, [serverId, clearSession]); - const value = useMemo( - () => ({ - serverId, - client, - audioPlayer, - setVoiceDetectionFlags, - requestGitDiff, - requestDirectoryListing, - requestFilePreview, - requestFileDownloadToken, - navigateExplorerBack, - requestProviderModels, - restartServer, - initializeAgent, - refreshAgent, - refreshSession, - cancelAgentRun, - deleteAgent, - archiveAgent, - sendAgentMessage, - createAgent, - setAgentMode, - respondToPermission, - ensureAgentIsInitialized, - }), - [ - serverId, - client, - audioPlayer, - setVoiceDetectionFlags, - requestGitDiff, - requestDirectoryListing, - requestFilePreview, - requestFileDownloadToken, - navigateExplorerBack, - requestProviderModels, - restartServer, - initializeAgent, - refreshAgent, - refreshSession, - cancelAgentRun, - deleteAgent, - archiveAgent, - sendAgentMessage, - createAgent, - setAgentMode, - respondToPermission, - ensureAgentIsInitialized, - ] - ); - - // Sync imperative methods to Zustand store so components can access them via selectors - // Memoize the methods object to avoid infinite re-renders (object reference must be stable) - const setSessionMethods = useSessionStore((state) => state.setSessionMethods); - const methods = useMemo( - () => ({ - setVoiceDetectionFlags, - requestGitDiff, - requestDirectoryListing, - requestFilePreview, - requestFileDownloadToken, - navigateExplorerBack, - requestProviderModels, - restartServer, - initializeAgent, - refreshAgent, - refreshSession, - cancelAgentRun, - sendAgentMessage, - deleteAgent, - archiveAgent, - createAgent, - setAgentMode, - respondToPermission, - ensureAgentIsInitialized, - }), - [ - setVoiceDetectionFlags, - requestGitDiff, - requestDirectoryListing, - requestFilePreview, - requestFileDownloadToken, - navigateExplorerBack, - requestProviderModels, - restartServer, - initializeAgent, - refreshAgent, - refreshSession, - cancelAgentRun, - sendAgentMessage, - deleteAgent, - archiveAgent, - createAgent, - setAgentMode, - respondToPermission, - ensureAgentIsInitialized, - ] - ); - - useEffect(() => { - setSessionMethods(serverId, methods); - }, [serverId, setSessionMethods, methods]); - - return ( - {children} - ); + return children; } - -// Export the context for components that need imperative APIs -export { SessionContext }; diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 337baefd1..df54256f7 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, @@ -76,11 +77,6 @@ type UseAgentFormStateResult = { isModelLoading: boolean; modelError: string | null; refreshProviderModels: () => void; - queueProviderModelFetch: ( - serverId: string | null, - options?: { cwd?: string; delayMs?: number } - ) => void; - clearQueuedProviderModelRequest: (serverId: string | null) => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise; }; @@ -278,20 +274,40 @@ export function useAgentFormState( } }, [isVisible]); - // Get session state for provider models + // Session state for provider model listing const sessionState = useSessionStore((state) => formState.serverId ? state.sessions[formState.serverId] : undefined ); - const providerModels = sessionState?.providerModels; - const requestProviderModels = sessionState?.methods?.requestProviderModels; - const getSessionState = useCallback( - (serverId: string) => useSessionStore.getState().sessions[serverId] ?? null, - [] - ); + const client = sessionState?.client ?? null; + const isConnected = sessionState?.connection?.isConnected ?? false; - // Get available models for current provider - const modelState = providerModels?.get(formState.provider); - const availableModels = modelState?.models ?? null; + const [debouncedCwd, setDebouncedCwd] = useState(undefined); + useEffect(() => { + const trimmed = formState.workingDir.trim(); + const next = trimmed.length > 0 ? trimmed : undefined; + const timer = setTimeout(() => setDebouncedCwd(next), 180); + return () => clearTimeout(timer); + }, [formState.workingDir]); + + const providerModelsQuery = useQuery({ + queryKey: ["providerModels", formState.serverId, formState.provider, debouncedCwd], + enabled: Boolean(isVisible && isTargetDaemonReady && formState.serverId && client && isConnected), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.listProviderModels(formState.provider, { + cwd: debouncedCwd, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload.models ?? []; + }, + }); + + const availableModels = providerModelsQuery.data ?? null; // Combine initialValues with initialServerId for resolution const combinedInitialValues = useMemo((): FormInitialValues | undefined => { @@ -360,11 +376,6 @@ export function useAgentFormState( updatePreferences, ]); - // Provider model request timers - const providerModelRequestTimersRef = useRef< - Map> - >(new Map()); - // User setters - mark fields as modified and persist to preferences const setSelectedServerIdFromUser = useCallback( (value: string | null) => { @@ -432,14 +443,8 @@ export function useAgentFormState( }, []); const refreshProviderModels = useCallback(() => { - if (!requestProviderModels) { - return; - } - const trimmed = formState.workingDir.trim(); - requestProviderModels(formState.provider, { - cwd: trimmed.length > 0 ? trimmed : undefined, - }); - }, [requestProviderModels, formState.provider, formState.workingDir]); + void providerModelsQuery.refetch(); + }, [providerModelsQuery]); const persistFormPreferences = useCallback(async () => { await updatePreferences({ @@ -461,91 +466,11 @@ export function useAgentFormState( updateProviderPreferences, ]); - const clearQueuedProviderModelRequest = useCallback((serverId: string | null) => { - if (!serverId) { - return; - } - const timer = providerModelRequestTimersRef.current.get(serverId); - if (timer) { - clearTimeout(timer); - providerModelRequestTimersRef.current.delete(serverId); - } - }, []); - - const queueProviderModelFetch = useCallback( - ( - serverId: string | null, - options?: { cwd?: string; delayMs?: number } - ) => { - if (!serverId || !getSessionState) { - clearQueuedProviderModelRequest(serverId); - return; - } - - const sessionState = getSessionState(serverId); - if (!sessionState?.connection?.isConnected) { - clearQueuedProviderModelRequest(serverId); - return; - } - - const currentState = sessionState.providerModels?.get(formState.provider); - if (currentState?.models?.length || currentState?.isLoading) { - clearQueuedProviderModelRequest(serverId); - return; - } - - const delayMs = options?.delayMs ?? 0; - const trigger = () => { - providerModelRequestTimersRef.current.delete(serverId); - sessionState.methods?.requestProviderModels(formState.provider, { - ...(options?.cwd ? { cwd: options.cwd } : {}), - }); - }; - clearQueuedProviderModelRequest(serverId); - if (delayMs > 0) { - providerModelRequestTimersRef.current.set(serverId, setTimeout(trigger, delayMs)); - } else { - trigger(); - } - }, - [clearQueuedProviderModelRequest, getSessionState, formState.provider] - ); - - useEffect(() => { - return () => { - providerModelRequestTimersRef.current.forEach((timer) => { - clearTimeout(timer); - }); - providerModelRequestTimersRef.current.clear(); - }; - }, []); - - useEffect(() => { - if (!isVisible || !isTargetDaemonReady || !formState.serverId) { - clearQueuedProviderModelRequest(formState.serverId); - return; - } - const trimmed = formState.workingDir.trim(); - queueProviderModelFetch(formState.serverId, { - cwd: trimmed.length > 0 ? trimmed : undefined, - delayMs: 180, - }); - return () => { - clearQueuedProviderModelRequest(formState.serverId); - }; - }, [ - clearQueuedProviderModelRequest, - isTargetDaemonReady, - isVisible, - queueProviderModelFetch, - formState.serverId, - formState.workingDir, - ]); - const agentDefinition = providerDefinitionMap.get(formState.provider); const modeOptions = agentDefinition?.modes ?? []; - const isModelLoading = modelState?.isLoading ?? false; - const modelError = modelState?.error ?? null; + const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching; + const modelError = + providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null; const workingDirIsEmpty = !formState.workingDir.trim(); @@ -571,8 +496,6 @@ export function useAgentFormState( isModelLoading, modelError, refreshProviderModels, - queueProviderModelFetch, - clearQueuedProviderModelRequest, workingDirIsEmpty, persistFormPreferences, }), @@ -595,8 +518,6 @@ export function useAgentFormState( isModelLoading, modelError, refreshProviderModels, - queueProviderModelFetch, - clearQueuedProviderModelRequest, workingDirIsEmpty, persistFormPreferences, ] diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts new file mode 100644 index 000000000..139847209 --- /dev/null +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -0,0 +1,113 @@ +import { useCallback } from "react"; +import { useSessionStore } from "@/stores/session-store"; +import { + createInitDeferred, + getInitDeferred, + getInitKey, + rejectInitDeferred, +} from "@/utils/agent-initialization"; + +const INIT_TIMEOUT_MS = 10000; + +export function useAgentInitialization(serverId: string) { + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); + + const ensureAgentIsInitialized = useCallback( + (agentId: string): Promise => { + const key = getInitKey(serverId, agentId); + const existing = getInitDeferred(key); + if (existing) { + return existing.promise; + } + + const deferred = createInitDeferred(key); + + const timeoutId = setTimeout(() => { + rejectInitDeferred(key, new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`)); + }, INIT_TIMEOUT_MS); + + setInitializingAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, true); + return next; + }); + + setAgentStreamTail(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, []); + return next; + }); + clearAgentStreamHead(serverId, agentId); + + if (!client) { + clearTimeout(timeoutId); + setInitializingAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, false); + return next; + }); + rejectInitDeferred(key, new Error("Host is not connected")); + return deferred.promise; + } + + client + .initializeAgent(agentId) + .then(() => { + clearTimeout(timeoutId); + }) + .catch((error) => { + clearTimeout(timeoutId); + setInitializingAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, false); + return next; + }); + rejectInitDeferred( + key, + error instanceof Error ? error : new Error(String(error)) + ); + }); + + return deferred.promise; + }, + [clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents] + ); + + const refreshAgent = useCallback( + async (agentId: string) => { + if (!client) { + throw new Error("Host is not connected"); + } + setInitializingAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, true); + return next; + }); + + setAgentStreamTail(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, []); + return next; + }); + clearAgentStreamHead(serverId, agentId); + + try { + await client.refreshAgent(agentId); + } catch (error) { + setInitializingAgents(serverId, (prev) => { + const next = new Map(prev); + next.set(agentId, false); + return next; + }); + throw error; + } + }, + [clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents] + ); + + return { ensureAgentIsInitialized, refreshAgent }; +} + diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 43f2d89c7..90a94065a 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -5,6 +5,7 @@ import { useSessionStore } from "@/stores/session-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { Agent } from "@/stores/session-store"; import { isPerfLoggingEnabled } from "@/utils/perf"; +import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; export interface AggregatedAgent extends AgentDirectoryEntry { serverId: string; @@ -32,11 +33,11 @@ export function useAggregatedAgents(): AggregatedAgentsResult { }) ); - const sessionMethods = useSessionStore( + const sessionClients = useSessionStore( useShallow((state) => { - const result: Record | undefined> = {}; + const result: Record | null> = {}; for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.methods ?? undefined; + result[serverId] = session.client ?? null; } return result; }) @@ -44,13 +45,43 @@ export function useAggregatedAgents(): AggregatedAgentsResult { const refreshAll = useCallback(() => { console.log('[useAggregatedAgents] Manual refresh triggered for all sessions'); - for (const [serverId, methods] of Object.entries(sessionMethods)) { - if (methods?.refreshSession) { - console.log(`[useAggregatedAgents] Refreshing session ${serverId}`); - methods.refreshSession(); + for (const [serverId, client] of Object.entries(sessionClients)) { + if (!client) { + continue; } + void (async () => { + try { + console.log(`[useAggregatedAgents] Refreshing session ${serverId}`); + const agentsList = await client.fetchAgents({ + filter: { labels: { ui: "true" } }, + }); + + const agents = new Map(); + const pendingPermissions = new Map(); + const agentLastActivity = new Map(); + + for (const snapshot of agentsList) { + const agent = normalizeAgentSnapshot(snapshot, serverId); + agents.set(agent.id, agent); + agentLastActivity.set(agent.id, agent.lastActivityAt); + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pendingPermissions.set(key, { key, agentId: agent.id, request }); + } + } + + const store = useSessionStore.getState(); + store.setAgents(serverId, agents); + for (const [agentId, timestamp] of agentLastActivity.entries()) { + store.setAgentLastActivity(agentId, timestamp); + } + store.setPendingPermissions(serverId, pendingPermissions); + } catch (error) { + console.warn("[useAggregatedAgents] Failed to refresh session", { serverId, error }); + } + })(); } - }, [sessionMethods]); + }, [sessionClients]); const result = useMemo(() => { const allAgents: AggregatedAgent[] = []; diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts new file mode 100644 index 000000000..ea449c82d --- /dev/null +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -0,0 +1,206 @@ +import { useCallback } from "react"; +import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store"; + +function createExplorerState(): AgentFileExplorerState { + return { + directories: new Map(), + files: new Map(), + isLoading: false, + lastError: null, + pendingRequest: null, + currentPath: ".", + history: ["."], + lastVisitedPath: ".", + }; +} + +function pushHistory(history: string[], path: string): string[] { + const normalizedHistory = history.length === 0 ? ["."] : history; + const last = normalizedHistory[normalizedHistory.length - 1]; + if (last === path) { + return normalizedHistory; + } + return [...normalizedHistory, path]; +} + +export function useFileExplorerActions(serverId: string) { + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const setFileExplorer = useSessionStore((state) => state.setFileExplorer); + + const updateExplorerState = useCallback( + (agentId: string, updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => { + setFileExplorer(serverId, (prev) => { + const next = new Map(prev); + const current = next.get(agentId) ?? createExplorerState(); + next.set(agentId, updater(current)); + return next; + }); + }, + [serverId, setFileExplorer] + ); + + const requestDirectoryListing = useCallback( + (agentId: string, path: string, options?: { recordHistory?: boolean }) => { + const normalizedPath = path && path.length > 0 ? path : "."; + const shouldRecordHistory = options?.recordHistory ?? true; + + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path: normalizedPath, mode: "list" }, + currentPath: normalizedPath, + history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history, + lastVisitedPath: normalizedPath, + })); + + if (!client) { + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: false, + lastError: "Host is not connected", + pendingRequest: null, + })); + return; + } + + void client + .exploreFileSystem(agentId, normalizedPath, "list") + .then((payload) => { + updateExplorerState(agentId, (state) => { + const nextState: AgentFileExplorerState = { + ...state, + isLoading: false, + lastError: payload.error ?? null, + pendingRequest: null, + directories: state.directories, + files: state.files, + }; + + if (!payload.error && payload.directory) { + const directories = new Map(state.directories); + directories.set(payload.directory.path, payload.directory); + nextState.directories = directories; + } + + return nextState; + }); + }) + .catch((error) => { + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: false, + lastError: error instanceof Error ? error.message : "Failed to list directory", + pendingRequest: null, + })); + }); + }, + [client, updateExplorerState] + ); + + const requestFilePreview = useCallback( + (agentId: string, path: string) => { + const normalizedPath = path && path.length > 0 ? path : "."; + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: true, + pendingRequest: { path: normalizedPath, mode: "file" }, + })); + + if (!client) { + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: false, + lastError: "Host is not connected", + pendingRequest: null, + })); + return; + } + + void client + .exploreFileSystem(agentId, normalizedPath, "file") + .then((payload) => { + updateExplorerState(agentId, (state) => { + const nextState: AgentFileExplorerState = { + ...state, + isLoading: false, + pendingRequest: null, + directories: state.directories, + files: state.files, + }; + + if (!payload.error && payload.file) { + const files = new Map(state.files); + files.set(payload.file.path, payload.file); + nextState.files = files; + } else if (payload.error) { + nextState.lastError = payload.error; + } + + return nextState; + }); + }) + .catch(() => { + updateExplorerState(agentId, (state) => ({ + ...state, + isLoading: false, + pendingRequest: null, + })); + }); + }, + [client, updateExplorerState] + ); + + const requestFileDownloadToken = useCallback( + async (agentId: string, path: string) => { + if (!client) { + throw new Error("Host is not connected"); + } + const payload = await client.requestDownloadToken(agentId, path); + if (payload.error) { + throw new Error(payload.error); + } + return payload; + }, + [client] + ); + + const navigateExplorerBack = useCallback( + (agentId: string) => { + let targetPath: string | null = null; + + updateExplorerState(agentId, (state) => { + if (state.history.length <= 1) { + return state; + } + const nextHistory = state.history.slice(0, -1); + targetPath = nextHistory[nextHistory.length - 1] ?? "."; + return { + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path: targetPath, mode: "list" }, + currentPath: targetPath, + history: nextHistory, + lastVisitedPath: targetPath, + }; + }); + + if (!targetPath) { + return null; + } + + requestDirectoryListing(agentId, targetPath, { recordHistory: false }); + return targetPath; + }, + [requestDirectoryListing, updateExplorerState] + ); + + return { + requestDirectoryListing, + requestFilePreview, + requestFileDownloadToken, + navigateExplorerBack, + }; +} + diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 08d34b5d0..fa20ad24e 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -60,6 +60,7 @@ import { startPerfMonitor } from "@/utils/perf-monitor"; import { shortenPath } from "@/utils/shorten-path"; import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; +import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { DropdownMenu, DropdownMenuContent, @@ -338,10 +339,7 @@ function AgentScreenContent({ const isConnected = useSessionStore( (state) => state.sessions[serverId]?.connection.isConnected ?? false ); - - // Get methods - const methods = useSessionStore((state) => state.sessions[serverId]?.methods); - const refreshAgent = methods?.refreshAgent; + const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId); const setFocusedAgentId = useCallback( (agentId: string | null) => { useSessionStore.getState().setFocusedAgentId(serverId, agentId); @@ -484,9 +482,6 @@ function AgentScreenContent({ streamItems, ]); - // Get ensureAgentIsInitialized from methods - const ensureAgentIsInitialized = methods?.ensureAgentIsInitialized; - useEffect(() => { if (!resolvedAgentId || !ensureAgentIsInitialized) { return; @@ -546,10 +541,12 @@ function AgentScreenContent({ }, [setExplorerTab, openFileExplorer]); const handleRefreshAgent = useCallback(() => { - if (!resolvedAgentId || !refreshAgent) { + if (!resolvedAgentId) { return; } - refreshAgent({ agentId: resolvedAgentId }); + void refreshAgent(resolvedAgentId).catch((error) => { + console.warn("[AgentScreen] refreshAgent failed", { agentId: resolvedAgentId, error }); + }); }, [resolvedAgentId, refreshAgent]); if (!effectiveAgent) { diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 5c3c928d2..1f40cd61d 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -41,6 +41,7 @@ import { WelcomeScreen } from "@/components/welcome-screen"; import type { Agent } from "@/contexts/session-context"; import type { StreamItem } from "@/types/stream"; import { generateMessageId } from "@/types/stream"; +import { encodeImages } from "@/utils/encode-images"; import type { AgentProvider, AgentCapabilityFlags, @@ -567,8 +568,8 @@ export function DraftAgentScreen({ } }, [isNonGitDirectory, worktreeMode]); - const sessionMethods = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.methods : undefined + const createAgentClient = useSessionStore((state) => + selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null ); const promptValue = machine.tag === "draft" ? machine.promptText : ""; @@ -688,8 +689,7 @@ export function DraftAgentScreen({ dispatch({ type: "DRAFT_SET_ERROR", message: baseBranchError }); throw new Error(baseBranchError); } - const createAgent = sessionMethods?.createAgent; - if (!createAgent) { + if (!createAgentClient) { dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" }); throw new Error("Host is not connected"); } @@ -740,10 +740,12 @@ export function DraftAgentScreen({ onCreateFlowActiveChange?.(true); try { - const result = await createAgent({ + const imagesData = await encodeImages(images); + const result = await createAgentClient.createAgent({ config, + labels: { ui: "true" }, initialPrompt: trimmedPrompt, - images, + ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), git: gitOptions, }); @@ -785,7 +787,7 @@ export function DraftAgentScreen({ selectedModel, selectedProvider, selectedServerId, - sessionMethods, + createAgentClient, setPendingCreateAttempt, updatePendingAgentId, clearPendingCreateAttempt, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 7337b7d27..37552812d 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -60,13 +60,6 @@ export type MessageEntry = status: "executing" | "completed" | "failed"; }; -export type ProviderModelState = { - models: AgentModelDefinition[] | null; - fetchedAt: Date | null; - error: string | null; - isLoading: boolean; -}; - export interface AgentRuntimeInfo { provider: AgentProvider; sessionId: string | null; @@ -95,6 +88,7 @@ export interface Agent { title: string | null; cwd: string; model: string | null; + thinkingOptionId?: string | null; requiresAttention?: boolean; attentionReason?: "finished" | "error" | "permission" | null; attentionTimestamp?: Date | null; @@ -164,40 +158,6 @@ export interface SessionState { // Audio player (immutable reference) audioPlayer: ReturnType | null; - // Imperative methods from SessionProvider - methods: { - setVoiceDetectionFlags: (isDetecting: boolean, isSpeaking: boolean) => void; - requestGitDiff: (agentId: string) => void; - requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void; - requestFilePreview: (agentId: string, path: string) => void; - requestFileDownloadToken: (agentId: string, path: string) => Promise; - navigateExplorerBack: (agentId: string) => string | null; - requestProviderModels: (provider: any, options?: { cwd?: string }) => void; - restartServer: (reason?: string) => void; - initializeAgent: (params: { agentId: string; requestId?: string }) => void; - refreshAgent: (params: { agentId: string; requestId?: string }) => void; - refreshSession: () => void; - cancelAgentRun: (agentId: string) => void; - sendAgentMessage: ( - agentId: string, - message: string, - images?: Array<{ uri: string; mimeType?: string }> - ) => Promise; - deleteAgent: (agentId: string) => void; - archiveAgent: (agentId: string) => void; - createAgent: (options: { - config: any; - initialPrompt: string; - images?: Array<{ uri: string; mimeType?: string }>; - git?: any; - worktreeName?: string; - requestId?: string; - }) => Promise; - setAgentMode: (agentId: string, modeId: string) => void; - respondToPermission: (agentId: string, requestId: string, response: any) => void; - ensureAgentIsInitialized: (agentId: string) => Promise; - } | null; - // Hydration status hasHydratedAgents: boolean; @@ -230,9 +190,6 @@ export interface SessionState { // File explorer fileExplorer: Map; - // Provider models - providerModels: Map; - // Queued messages queuedMessages: Map }>>; } @@ -287,18 +244,12 @@ interface SessionStoreActions { // File explorer setFileExplorer: (serverId: string, state: Map | ((prev: Map) => Map)) => void; - // Provider models - setProviderModels: (serverId: string, models: Map | ((prev: Map) => Map)) => void; - // Queued messages setQueuedMessages: (serverId: string, value: Map }>> | ((prev: Map }>>) => Map }>>)) => void; // Hydration setHasHydratedAgents: (serverId: string, hydrated: boolean) => void; - // Imperative methods - setSessionMethods: (serverId: string, methods: SessionState["methods"]) => void; - // Agent directory (derived from agents) getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined; } @@ -348,7 +299,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud client, connection: createDefaultConnectionSnapshot(client), audioPlayer, - methods: null, hasHydratedAgents: false, isPlayingAudio: false, focusedAgentId: null, @@ -361,7 +311,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud pendingPermissions: new Map(), gitDiffs: new Map(), fileExplorer: new Map(), - providerModels: new Map(), queuedMessages: new Map(), }; } @@ -732,28 +681,6 @@ export const useSessionStore = create()( }); }, - // Provider models - setProviderModels: (serverId, models) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - const nextModels = typeof models === "function" ? models(session.providerModels) : models; - if (session.providerModels === nextModels) { - return prev; - } - logSessionStoreUpdate("setProviderModels", serverId, { providerCount: nextModels.size }); - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, providerModels: nextModels }, - }, - }; - }); - }, - // Queued messages setQueuedMessages: (serverId, value) => { set((prev) => { @@ -794,28 +721,6 @@ export const useSessionStore = create()( }); }, - // Imperative methods - setSessionMethods: (serverId, methods) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) { - return prev; - } - // Skip if methods reference is the same (already set) - if (session.methods === methods) { - return prev; - } - logSessionStoreUpdate("setSessionMethods", serverId, { hasValue: !!methods }); - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, methods }, - }, - }; - }); - }, - // Agent directory - derived from agents (computed on-demand) getAgentDirectory: (serverId) => { const state = get(); diff --git a/packages/app/src/utils/agent-initialization.ts b/packages/app/src/utils/agent-initialization.ts new file mode 100644 index 000000000..32a6f9025 --- /dev/null +++ b/packages/app/src/utils/agent-initialization.ts @@ -0,0 +1,44 @@ +export interface DeferredInit { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +} + +const initPromises = new Map(); + +export function getInitKey(serverId: string, agentId: string): string { + return `${serverId}:${agentId}`; +} + +export function getInitDeferred(key: string): DeferredInit | undefined { + return initPromises.get(key); +} + +export function createInitDeferred(key: string): DeferredInit { + let resolve!: () => void; + let reject!: (error: Error) => void; + + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + const deferred: DeferredInit = { promise, resolve, reject }; + initPromises.set(key, deferred); + return deferred; +} + +export function resolveInitDeferred(key: string): void { + const deferred = initPromises.get(key); + deferred?.resolve(); +} + +export function rejectInitDeferred(key: string, error: Error): void { + const deferred = initPromises.get(key); + if (!deferred) { + return; + } + initPromises.delete(key); + deferred.reject(error); +} + diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts new file mode 100644 index 000000000..18741d3cf --- /dev/null +++ b/packages/app/src/utils/agent-snapshots.ts @@ -0,0 +1,56 @@ +import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; +import type { AgentSnapshotPayload } from "@server/shared/messages"; +import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; + +export function derivePendingPermissionKey( + agentId: string, + request: AgentPermissionRequest +): string { + const fallbackId = + request.id || + (typeof request.metadata?.id === "string" ? request.metadata.id : undefined) || + request.name || + request.title || + `${request.kind}:${JSON.stringify(request.input ?? request.metadata ?? {})}`; + + return `${agentId}:${fallbackId}`; +} + +export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) { + const createdAt = new Date(snapshot.createdAt); + const updatedAt = new Date(snapshot.updatedAt); + const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null; + const attentionTimestamp = snapshot.attentionTimestamp + ? new Date(snapshot.attentionTimestamp) + : null; + const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; + + return { + serverId, + id: snapshot.id, + provider: snapshot.provider, + status: snapshot.status as AgentLifecycleStatus, + createdAt, + updatedAt, + lastUserMessageAt, + lastActivityAt: updatedAt, + capabilities: snapshot.capabilities, + currentModeId: snapshot.currentModeId, + availableModes: snapshot.availableModes ?? [], + pendingPermissions: snapshot.pendingPermissions ?? [], + persistence: snapshot.persistence ?? null, + runtimeInfo: snapshot.runtimeInfo, + lastUsage: snapshot.lastUsage, + lastError: snapshot.lastError ?? null, + title: snapshot.title ?? null, + cwd: snapshot.cwd, + model: snapshot.model ?? null, + thinkingOptionId: snapshot.thinkingOptionId ?? null, + requiresAttention: snapshot.requiresAttention ?? false, + attentionReason: snapshot.attentionReason ?? null, + attentionTimestamp, + archivedAt, + labels: snapshot.labels, + }; +} + diff --git a/packages/app/src/utils/encode-images.ts b/packages/app/src/utils/encode-images.ts new file mode 100644 index 000000000..20b226df4 --- /dev/null +++ b/packages/app/src/utils/encode-images.ts @@ -0,0 +1,68 @@ +import { Platform } from "react-native"; +import { File } from "expo-file-system"; + +type ImageInput = { uri: string; mimeType?: string }; + +export async function encodeImages( + images?: ImageInput[] +): Promise | undefined> { + if (!images || images.length === 0) { + return undefined; + } + + const encodedImages = await Promise.all( + images.map(async ({ uri, mimeType }) => { + try { + const data = await (async () => { + if (Platform.OS === "web") { + if (uri.startsWith("data:")) { + const [, base64] = uri.split(",", 2); + if (!base64) { + throw new Error("Malformed data URI for image."); + } + return base64; + } + const response = await fetch(uri); + const blob = await response.blob(); + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== "string") { + reject(new Error("Unexpected FileReader result type.")); + return; + } + const [, resultBase64] = reader.result.split(",", 2); + if (!resultBase64) { + reject(new Error("Failed to read image data as base64.")); + return; + } + resolve(resultBase64); + }; + reader.onerror = () => { + reject(reader.error ?? new Error("Failed to read image data.")); + }; + reader.readAsDataURL(blob); + }); + return base64; + } + + const file = new File(uri); + return await file.base64(); + })(); + + return { + data, + mimeType: mimeType ?? "image/jpeg", + }; + } catch (error) { + console.error("[encodeImages] Failed to convert image:", error); + return null; + } + }) + ); + + const validImages = encodedImages.filter( + (entry): entry is { data: string; mimeType: string } => entry !== null + ); + return validImages.length > 0 ? validImages : undefined; +} diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 1e49511e2..bb77fe7a7 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -1086,7 +1086,90 @@ export class DaemonClientV2 { } async setAgentMode(agentId: string, modeId: string): Promise { - this.sendSessionMessage({ type: "set_agent_mode", agentId, modeId }); + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "set_agent_mode_request", + agentId, + modeId, + requestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "set_agent_mode_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + 15000, + { skipQueue: true } + ); + await this.sendSessionMessageOrThrow(message); + const payload = await response; + if (!payload.accepted) { + throw new Error(payload.error ?? "setAgentMode rejected"); + } + } + + async setAgentModel(agentId: string, modelId: string | null): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "set_agent_model_request", + agentId, + modelId, + requestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "set_agent_model_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + 15000, + { skipQueue: true } + ); + await this.sendSessionMessageOrThrow(message); + const payload = await response; + if (!payload.accepted) { + throw new Error(payload.error ?? "setAgentModel rejected"); + } + } + + async setAgentThinkingOption( + agentId: string, + thinkingOptionId: string | null + ): Promise { + const requestId = this.createRequestId(); + const message = SessionInboundMessageSchema.parse({ + type: "set_agent_thinking_request", + agentId, + thinkingOptionId, + requestId, + }); + const response = this.waitFor( + (msg) => { + if (msg.type !== "set_agent_thinking_response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + 15000, + { skipQueue: true } + ); + await this.sendSessionMessageOrThrow(message); + const payload = await response; + if (!payload.accepted) { + throw new Error(payload.error ?? "setAgentThinkingOption rejected"); + } } async restartServer( diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 4c5f217ef..14f56d386 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -444,6 +444,40 @@ export class AgentManager { this.emitState(agent); } + async setAgentModel(agentId: string, modelId: string | null): Promise { + const agent = this.requireAgent(agentId); + const normalizedModelId = + typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; + + if (agent.session.setModel) { + await agent.session.setModel(normalizedModelId); + } + + agent.config.model = normalizedModelId ?? undefined; + if (agent.runtimeInfo) { + agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId }; + } + this.emitState(agent); + } + + async setAgentThinkingOption( + agentId: string, + thinkingOptionId: string | null + ): Promise { + const agent = this.requireAgent(agentId); + const normalizedThinkingOptionId = + typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 + ? thinkingOptionId + : null; + + if (agent.session.setThinkingOption) { + await agent.session.setThinkingOption(normalizedThinkingOptionId); + } + + agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined; + this.emitState(agent); + } + async setTitle(agentId: string, title: string): Promise { const agent = this.requireAgent(agentId); await this.registry?.setTitle(agentId, title); diff --git a/packages/server/src/server/agent/agent-metadata-generator.test.ts b/packages/server/src/server/agent/agent-metadata-generator.test.ts index 15062508f..ae32a5abd 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.test.ts @@ -12,7 +12,7 @@ import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js"; import { createWorktree, validateBranchSlug } from "../../utils/worktree.js"; const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; function tmpCwd(prefix: string): string { return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); @@ -76,7 +76,7 @@ describe("agent metadata generation (real agents)", () => { const agent = await manager.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, modeId: "auto", cwd: repoDir, title: "Main Agent", @@ -116,7 +116,7 @@ describe("agent metadata generation (real agents)", () => { const agent = await manager.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, modeId: "auto", cwd: worktree.worktreePath, title: "Worktree Agent", diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 5b6509d6a..cc6cfdf6c 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -160,7 +160,7 @@ export async function generateAndApplyAgentMetadata( agentConfig: { provider: AUTO_GEN_PROVIDER, model: AUTO_GEN_MODEL, - reasoningEffort: AUTO_GEN_REASONING_EFFORT, + thinkingOptionId: AUTO_GEN_REASONING_EFFORT, cwd: options.cwd, title: "Agent metadata generator", internal: true, diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 6172e14f9..b7503423c 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -234,6 +234,7 @@ describe("toAgentPayload", () => { expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString()); expect(payload.title).toBe("UI Payload"); expect(payload.model).toBe(agent.config.model); + expect(payload.thinkingOptionId).toBeNull(); expect(payload.pendingPermissions.map((item) => item.id)).toEqual([ "perm-a", "perm-b", diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index bb7eb0b77..ae2bff296 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -66,12 +66,14 @@ export function toAgentPayload( options?: ProjectionOptions ): AgentSnapshotPayload { const runtimeInfo = sanitizeRuntimeInfo(agent.runtimeInfo); + const thinkingOptionId = agent.config.thinkingOptionId ?? null; const payload: AgentSnapshotPayload = { id: agent.id, provider: agent.provider, cwd: agent.cwd, model: agent.config.model ?? null, + thinkingOptionId, runtimeInfo, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), @@ -120,6 +122,9 @@ function buildSerializableConfig( if (config.model) { serializable.model = config.model; } + if (config.thinkingOptionId) { + serializable.thinkingOptionId = config.thinkingOptionId; + } const extra = sanitizeMetadata(config.extra); if (extra !== undefined) { serializable.extra = extra; diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts index 3d8016248..8357e8e68 100644 --- a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts +++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts @@ -16,7 +16,7 @@ import { createAllClients, shutdownProviders } from "./provider-registry.js"; import pino from "pino"; const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; type AgentMcpServerHandle = { url: string; @@ -176,7 +176,7 @@ describe("getStructuredAgentResponse (e2e)", () => { agentConfig: { provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Structured Response Test", }, @@ -192,27 +192,42 @@ describe("getStructuredAgentResponse (e2e)", () => { ); test( - "returns schema-valid JSON from Claude Haiku on first try", + "returns schema-valid JSON from Claude Haiku", async () => { const schema = z.object({ message: z.string(), }); - const result = await generateStructuredAgentResponse({ - manager, - agentConfig: { - provider: "claude", - model: "haiku", - cwd, - title: "Claude Haiku Structured Test", - internal: true, - }, - prompt: 'Return JSON with a message field containing "hello".', - schema, - maxRetries: 0, - }); + let result: { message: string } | null = null; + let lastError: unknown = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + result = await generateStructuredAgentResponse({ + manager, + agentConfig: { + provider: "claude", + model: "haiku", + thinkingOptionId: "on", + cwd, + title: "Claude Haiku Structured Test", + internal: true, + }, + prompt: + 'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}', + schema, + maxRetries: 6, + }); + lastError = null; + break; + } catch (error) { + lastError = error; + } + } + if (!result) { + throw lastError; + } - expect(result.message).toBe("hello"); + expect(result.message.trim().toLowerCase()).toBe("hello"); }, 180000 ); diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index 6b100267c..02344ee89 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -131,9 +131,84 @@ function extractJsonFromMarkdown(text: string): string { if (fencedMatch) { return fencedMatch[1].trim(); } + + const extracted = extractFirstJsonSnippet(text); + if (extracted) { + return extracted; + } + return text.trim(); } +function extractFirstJsonSnippet(text: string): string | null { + const source = text.trim(); + if (!source) { + return null; + } + + // Try to find the first valid JSON object/array within a larger response. + // This is intentionally provider-agnostic and improves resilience when models + // add extra prose before/after the JSON. + const startIndexes: number[] = []; + for (let i = 0; i < source.length; i += 1) { + const ch = source[i]; + if (ch === "{" || ch === "[") { + startIndexes.push(i); + } + } + + for (const start of startIndexes) { + const open = source[start]!; + const close = open === "{" ? "}" : "]"; + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = start; i < source.length; i += 1) { + const ch = source[i]!; + + if (inString) { + if (escaped) { + escaped = false; + continue; + } + if (ch === "\\") { + escaped = true; + continue; + } + if (ch === "\"") { + inString = false; + } + continue; + } + + if (ch === "\"") { + inString = true; + continue; + } + + if (ch === open) { + depth += 1; + continue; + } + if (ch === close) { + depth -= 1; + if (depth === 0) { + const candidate = source.slice(start, i + 1).trim(); + try { + JSON.parse(candidate); + return candidate; + } catch { + // keep scanning; the snippet might not be JSON (e.g. braces in prose) + } + } + } + } + } + + return null; +} + export async function getStructuredAgentResponse( options: StructuredAgentResponseOptions ): Promise { @@ -194,12 +269,14 @@ export async function generateStructuredAgentResponse( try { const caller: AgentCaller = async (nextPrompt) => { const result = await manager.runAgent(agent.id, nextPrompt); - // Accumulate all assistant_message items since Claude streams text as deltas - const fullText = result.timeline + if (typeof result.finalText === "string" && result.finalText.length > 0) { + return result.finalText; + } + // Fallback for providers that may not populate finalText consistently. + const lastAssistant = result.timeline .filter((item) => item.type === "assistant_message") - .map((item) => item.text) - .join(""); - return fullText || result.finalText; + .at(-1); + return lastAssistant?.text ?? ""; }; return await getStructuredAgentResponse({ caller, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 0ed5931a3..08eec8714 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -55,6 +55,16 @@ export type AgentModelDefinition = { description?: string; isDefault?: boolean; metadata?: AgentMetadata; + thinkingOptions?: AgentSelectOption[]; + defaultThinkingOptionId?: string; +}; + +export type AgentSelectOption = { + id: string; + label: string; + description?: string; + isDefault?: boolean; + metadata?: AgentMetadata; }; export type AgentCapabilityFlags = { @@ -228,12 +238,12 @@ export type AgentSessionConfig = { cwd: string; modeId?: string; model?: string; + thinkingOptionId?: string; title?: string | null; approvalPolicy?: string; sandboxMode?: string; networkAccess?: boolean; webSearch?: boolean; - reasoningEffort?: string; extra?: { codex?: AgentMetadata; claude?: Partial; @@ -274,6 +284,15 @@ export interface AgentSession { * @param args Optional arguments to pass to the command */ executeCommand?(commandName: string, args?: string): Promise; + /** + * Update the model used for subsequent turns (if supported by provider). + */ + setModel?(modelId: string | null): Promise; + /** + * Update the thinking/effort setting used for subsequent turns (if supported). + * Normalized to a string option id (provider-specific interpretation). + */ + setThinkingOption?(thinkingOptionId: string | null): Promise; } export interface ListModelsOptions { diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 44b0fc173..a29d00bd5 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -13,6 +13,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z .object({ modeId: z.string().nullable().optional(), model: z.string().nullable().optional(), + thinkingOptionId: z.string().nullable().optional(), extra: z.record(z.any()).nullable().optional(), }) .nullable() @@ -60,7 +61,7 @@ const STORED_AGENT_SCHEMA = z.object({ export type SerializableAgentConfig = Pick< AgentSessionConfig, - "modeId" | "model" | "extra" + "modeId" | "model" | "thinkingOptionId" | "extra" >; export type StoredAgentRecord = z.infer; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 5eb63ce98..08416d9ce 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -242,9 +242,6 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial { + void options; // Increment turn ID to invalidate any in-flight processPrompt() loops from previous turns. // This prevents race conditions where an interrupted turn's events get mixed with the new turn. const turnId = ++this.currentTurnId; @@ -674,6 +677,57 @@ class ClaudeAgentSession implements AgentSession { this.currentMode = normalized; } + async setModel(modelId: string | null): Promise { + const normalizedModelId = + typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; + const query = await this.ensureQuery(); + await query.setModel(normalizedModelId ?? undefined); + this.config.model = normalizedModelId ?? undefined; + this.lastOptionsModel = normalizedModelId ?? this.lastOptionsModel; + this.cachedRuntimeInfo = null; + // Model change affects persistence metadata, so invalidate cached handle. + this.persistence = null; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + const normalizedThinkingOptionId = + typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 + ? thinkingOptionId + : null; + + const query = await this.ensureQuery(); + + if (!normalizedThinkingOptionId || normalizedThinkingOptionId === "default") { + // Claude Code TUI exposes only ON/OFF. Default to OFF. + try { + await query.setMaxThinkingTokens(0); + } catch { + await query.setMaxThinkingTokens(1); + } + this.config.thinkingOptionId = undefined; + return; + } + + if (normalizedThinkingOptionId === "on") { + await query.setMaxThinkingTokens(null); + this.config.thinkingOptionId = normalizedThinkingOptionId; + return; + } + + if (normalizedThinkingOptionId === "off") { + try { + await query.setMaxThinkingTokens(0); + } catch { + // Some runtimes may reject 0; use a tiny cap as "off". + await query.setMaxThinkingTokens(1); + } + this.config.thinkingOptionId = "off"; + return; + } + + throw new Error(`Unknown thinking option: ${normalizedThinkingOptionId}`); + } + getPendingPermissions(): AgentPermissionRequest[] { return Array.from(this.pendingPermissions.values()).map((entry) => entry.request); } @@ -725,12 +779,11 @@ class ClaudeAgentSession implements AgentSession { if (!this.claudeSessionId) { return null; } - const { model: _ignoredModel, ...restConfig } = this.config; this.persistence = { provider: "claude", sessionId: this.claudeSessionId, nativeHandle: this.claudeSessionId, - metadata: restConfig, + metadata: this.config, }; return this.persistence; } @@ -808,6 +861,19 @@ class ClaudeAgentSession implements AgentSession { } private buildOptions(): ClaudeOptions { + const configuredThinkingOptionId = this.config.thinkingOptionId; + const thinkingOptionId = + configuredThinkingOptionId && configuredThinkingOptionId !== "default" + ? configuredThinkingOptionId + : "off"; + let maxThinkingTokens: number | undefined; + if (typeof thinkingOptionId === "string" && thinkingOptionId.length > 0) { + if (thinkingOptionId === "off") { + maxThinkingTokens = 0; + } + // For "on" we omit maxThinkingTokens (SDK default max). + } + const base: ClaudeOptions = { cwd: this.config.cwd, includePartialMessages: true, @@ -834,6 +900,7 @@ class ClaudeAgentSession implements AgentSession { // If we have a session ID from a previous query (e.g., after interrupt), // resume that session to continue the conversation history. ...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}), + ...(maxThinkingTokens !== undefined ? { maxThinkingTokens } : {}), ...this.config.extra?.claude, }; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts index b5330af11..0728b7e62 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts @@ -8,7 +8,7 @@ import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; function isCodexInstalled(): boolean { try { @@ -30,7 +30,7 @@ describe("Codex app-server provider (e2e)", () => { cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")), modeId: "auto", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); const result = await session.run("Say hello in one sentence."); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 1b47c4edb..2483bb4a6 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -16,7 +16,7 @@ import type { } from "../agent-sdk-types.js"; const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; const ONE_BY_ONE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII="; @@ -104,7 +104,7 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "auto", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); const result = await session.run([ @@ -131,7 +131,7 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "auto", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); await session.run("Reply with OK."); @@ -168,7 +168,7 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "auto", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); const commands = await session.listCommands?.(); @@ -198,7 +198,7 @@ describe("Codex app-server provider (integration)", () => { modeId: "auto", approvalPolicy: "on-request", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); let sawPermission = false; @@ -278,7 +278,7 @@ describe("Codex app-server provider (integration)", () => { modeId: "full-access", approvalPolicy: "on-request", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); let sawAssistantMessage = false; @@ -417,7 +417,7 @@ describe("Codex app-server provider (integration)", () => { modeId: "auto", approvalPolicy: "on-request", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); const stream = session.stream( @@ -470,7 +470,7 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "auto", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); const followup = await followupSession.run("Reply OK and stop."); expect(followup.finalText.toLowerCase()).toContain("ok"); @@ -501,7 +501,7 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "auto", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); const first = await session.run(`Remember the word ${token} and reply ACK.`); @@ -559,7 +559,7 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "read-only", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); const result = await Promise.race([ @@ -599,7 +599,7 @@ describe("Codex app-server provider (integration)", () => { modeId: "full-access", approvalPolicy: "on-request", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); let sawPermission = false; @@ -659,7 +659,6 @@ describe("Codex app-server provider (integration)", () => { if (captured) { expect(sawPermissionResolved).toBe(true); } - expect(sawPermission || timelineItems.length > 0).toBe(true); expect(readFileSync(targetPath, "utf8").trim()).toBe("ok"); } finally { cleanup(); @@ -678,7 +677,7 @@ describe("Codex app-server provider (integration)", () => { cwd, modeId: "auto", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, }); await session.connect(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ba8483fbd..759adeaac 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1032,7 +1032,8 @@ class CodexAppServerAgentSession implements AgentSession { if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort; if (match.developer_instructions) settings.developer_instructions = match.developer_instructions; if (this.config.model) settings.model = this.config.model; - if (this.config.reasoningEffort) settings.reasoning_effort = this.config.reasoningEffort; + const thinkingOptionId = this.config.thinkingOptionId; + if (thinkingOptionId) settings.reasoning_effort = thinkingOptionId; return { mode: match.mode ?? "code", settings, name: match.name }; } @@ -1158,8 +1159,9 @@ class CodexAppServerAgentSession implements AgentSession { if (this.config.model) { params.model = this.config.model; } - if (this.config.reasoningEffort) { - params.effort = this.config.reasoningEffort; + const thinkingOptionId = this.config.thinkingOptionId; + if (thinkingOptionId) { + params.effort = thinkingOptionId; } if (this.resolvedCollaborationMode) { params.collaborationMode = { @@ -1235,6 +1237,18 @@ class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; } + async setModel(modelId: string | null): Promise { + this.config.model = modelId ?? undefined; + this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); + this.cachedRuntimeInfo = null; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + this.config.thinkingOptionId = thinkingOptionId ?? undefined; + this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); + this.cachedRuntimeInfo = null; + } + getPendingPermissions(): AgentPermissionRequest[] { return Array.from(this.pendingPermissions.values()); } @@ -1301,6 +1315,7 @@ class CodexAppServerAgentSession implements AgentSession { describePersistence(): { provider: typeof CODEX_PROVIDER; sessionId: string; nativeHandle: string; metadata: Record } | null { if (!this.currentThreadId) return null; + const thinkingOptionId = this.config.thinkingOptionId ?? null; return { provider: CODEX_PROVIDER, sessionId: this.currentThreadId, @@ -1312,7 +1327,7 @@ class CodexAppServerAgentSession implements AgentSession { threadId: this.currentThreadId, modeId: this.currentMode, model: this.config.model ?? null, - reasoningEffort: this.config.reasoningEffort ?? null, + thinkingOptionId, }, }; } @@ -1795,6 +1810,25 @@ export class CodexAppServerAgentClient implements AgentClient { label: model.displayName, description: model.description, isDefault: model.isDefault, + thinkingOptions: [ + { + id: "default", + label: "Default", + description: typeof model.defaultReasoningEffort === "string" + ? `Use model default (${model.defaultReasoningEffort})` + : "Use model default", + isDefault: true, + }, + ...(Array.isArray(model.supportedReasoningEfforts) + ? model.supportedReasoningEfforts.map((entry: any) => ({ + id: entry.reasoningEffort, + label: entry.reasoningEffort, + description: entry.description, + isDefault: entry.reasoningEffort === model.defaultReasoningEffort, + })) + : []), + ], + defaultThinkingOptionId: "default", metadata: { model: model.model, defaultReasoningEffort: model.defaultReasoningEffort, diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index 019a0822b..aa907135a 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -1954,12 +1954,12 @@ const AgentSessionConfigSchema = z cwd: z.string(), modeId: z.string().optional(), model: z.string().optional(), + thinkingOptionId: z.string().optional(), title: z.string().nullable().optional(), approvalPolicy: z.string().optional(), sandboxMode: z.string().optional(), networkAccess: z.boolean().optional(), webSearch: z.boolean().optional(), - reasoningEffort: z.string().optional(), extra: AgentSessionExtraSchema.optional(), mcpServers: z.record(McpServerConfigSchema).optional(), }) @@ -2872,9 +2872,14 @@ function buildCodexMcpConfig( innerConfig.mcp_servers = mcpServers; } - // Add reasoning effort to config if provided - if (typeof config.reasoningEffort === "string" && config.reasoningEffort.length > 0) { - innerConfig.model_reasoning_effort = config.reasoningEffort; + // Add thinking option to config if provided + const thinkingOptionId = config.thinkingOptionId; + if ( + typeof thinkingOptionId === "string" && + thinkingOptionId.length > 0 && + thinkingOptionId !== "default" + ) { + innerConfig.model_reasoning_effort = thinkingOptionId; } const configPayload: { @@ -3500,6 +3505,22 @@ class CodexMcpAgentSession implements AgentSession { } } + async setModel(modelId: string | null): Promise { + const normalizedModelId = + typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; + this.config.model = normalizedModelId ?? undefined; + this.cachedRuntimeInfo = null; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + const normalizedThinkingOptionId = + typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 + ? thinkingOptionId + : null; + this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined; + this.cachedRuntimeInfo = null; + } + getPendingPermissions(): AgentPermissionRequest[] { return Array.from(this.pendingPermissions.values()); } @@ -4790,6 +4811,25 @@ export class CodexMcpAgentClient implements AgentClient { label: model.displayName, description: model.description, isDefault: model.isDefault, + thinkingOptions: [ + { + id: "default", + label: "Default", + description: typeof model.defaultReasoningEffort === "string" + ? `Use model default (${model.defaultReasoningEffort})` + : "Use model default", + isDefault: true, + }, + ...(Array.isArray(model.supportedReasoningEfforts) + ? model.supportedReasoningEfforts.map((entry) => ({ + id: entry.reasoningEffort, + label: entry.reasoningEffort, + description: entry.description, + isDefault: entry.reasoningEffort === model.defaultReasoningEffort, + })) + : []), + ], + defaultThinkingOptionId: "default", metadata: { model: model.model, defaultReasoningEffort: model.defaultReasoningEffort, diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 58c935a07..ccc769a69 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -290,11 +290,19 @@ export class OpenCodeAgentClient implements AgentClient { } for (const [modelId, model] of Object.entries(provider.models)) { + const rawVariants = model.variants ? Object.keys(model.variants) : []; + const thinkingOptions = [ + { id: "default", label: "Default", isDefault: true }, + ...rawVariants.map((id) => ({ id, label: id })), + ]; + models.push({ provider: "opencode", id: `${provider.id}/${modelId}`, label: model.name, description: `${provider.name} - ${model.family ?? ""}`.trim(), + thinkingOptions: thinkingOptions.length > 1 ? thinkingOptions : undefined, + defaultThinkingOptionId: "default", metadata: { providerId: provider.id, providerName: provider.name, @@ -372,6 +380,20 @@ class OpenCodeAgentSession implements AgentSession { }; } + async setModel(modelId: string | null): Promise { + const normalizedModelId = + typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; + this.config.model = normalizedModelId ?? undefined; + } + + async setThinkingOption(thinkingOptionId: string | null): Promise { + const normalizedThinkingOptionId = + typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0 + ? thinkingOptionId + : null; + this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined; + } + async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise { const events = this.stream(prompt); const timeline: AgentTimelineItem[] = []; @@ -407,6 +429,9 @@ class OpenCodeAgentSession implements AgentSession { const parts = this.buildPromptParts(prompt); const model = this.parseModel(this.config.model); + const thinkingOptionId = this.config.thinkingOptionId; + const effectiveVariant = + thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined; // Send prompt asynchronously const promptResponse = await this.client.session.promptAsync({ @@ -414,6 +439,7 @@ class OpenCodeAgentSession implements AgentSession { directory: this.config.cwd, parts, ...(model ? { model } : {}), + ...(effectiveVariant ? { variant: effectiveVariant } : {}), }); if (promptResponse.error) { diff --git a/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts index e675b5769..e129ac895 100644 --- a/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-basics.e2e.test.ts @@ -14,9 +14,9 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution +// Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; @@ -35,7 +35,7 @@ describe("daemon E2E", () => { test("creates agent and receives response", async () => { // Create a Codex agent const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: "/tmp", title: "Test Agent", }); @@ -95,7 +95,7 @@ describe("daemon E2E", () => { await expect( ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: nonExistentCwd, title: "Should Fail Agent", }) diff --git a/packages/server/src/server/daemon-e2e/agent-configs.ts b/packages/server/src/server/daemon-e2e/agent-configs.ts index 69c413883..aaf449c14 100644 --- a/packages/server/src/server/daemon-e2e/agent-configs.ts +++ b/packages/server/src/server/daemon-e2e/agent-configs.ts @@ -6,7 +6,7 @@ export interface AgentTestConfig { provider: "claude" | "codex"; model: string; - reasoningEffort?: string; + thinkingOptionId?: string; modes: { full: string; // No permissions required ask: string; // Requires permission approval @@ -25,7 +25,7 @@ export const agentConfigs = { codex: { provider: "codex", model: "gpt-5.1-codex-mini", - reasoningEffort: "low", + thinkingOptionId: "low", modes: { full: "full-access", ask: "auto", @@ -40,11 +40,11 @@ export type AgentProvider = keyof typeof agentConfigs; */ export function getFullAccessConfig(provider: AgentProvider) { const config = agentConfigs[provider]; - const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined; + const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined; return { provider: config.provider, model: config.model, - ...(reasoningEffort ? { reasoningEffort } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), modeId: config.modes.full, }; } @@ -54,11 +54,11 @@ export function getFullAccessConfig(provider: AgentProvider) { */ export function getAskModeConfig(provider: AgentProvider) { const config = agentConfigs[provider]; - const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined; + const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined; return { provider: config.provider, model: config.model, - ...(reasoningEffort ? { reasoningEffort } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), modeId: config.modes.ask, }; } diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index 91a856112..c65089998 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -13,9 +13,9 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution +// Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; @@ -43,7 +43,7 @@ describe("daemon E2E", () => { // Create a Codex agent const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Timestamp Test Agent", }); @@ -94,7 +94,7 @@ describe("daemon E2E", () => { // Create a Codex agent const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Timestamp Update Test Agent", }); @@ -137,7 +137,7 @@ describe("daemon E2E", () => { // Create Codex agent const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Cancel Test Agent", }); @@ -211,7 +211,7 @@ describe("daemon E2E", () => { // Create a Codex agent with default mode ("auto") const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Mode Switch Test Agent", }); @@ -331,7 +331,7 @@ describe("daemon E2E", () => { // Create first agent const agent1 = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: cwd1, title: "List Test Agent 1", }); @@ -348,7 +348,7 @@ describe("daemon E2E", () => { // Create second agent const agent2 = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: cwd2, title: "List Test Agent 2", }); diff --git a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts index a62e239ee..d8d742b52 100644 --- a/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-ship.e2e.test.ts @@ -11,7 +11,7 @@ import { import { createWorktree } from "../../utils/worktree.js"; const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; function tmpCwd(prefix: string): string { return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); @@ -125,7 +125,7 @@ describe("daemon checkout ship loop", () => { const agent = await ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: worktree.worktreePath, title: "Checkout Ship Loop", }); @@ -281,7 +281,7 @@ describe("daemon checkout ship loop", () => { const agent = await ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: worktree.worktreePath, title: "Merge From Base Test", }); @@ -351,7 +351,7 @@ describe("daemon checkout ship loop", () => { const agent = await ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Checkout Non-Git", }); diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index d2c34b97c..e1425966a 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -13,9 +13,9 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution +// Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; @@ -38,7 +38,7 @@ describe("daemon E2E", () => { writeFileSync(filePath, fileContents, "utf-8"); const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Download Token Test Agent", }); @@ -96,7 +96,7 @@ describe("daemon E2E", () => { writeFileSync(filePath, "expired", "utf-8"); const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Expired Token Test Agent", }); @@ -127,7 +127,7 @@ describe("daemon E2E", () => { async () => { const cwd = tmpCwd(); const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Outside Path Token Test Agent", }); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index be394d612..7a8183fff 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -13,9 +13,9 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution +// Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; @@ -42,7 +42,7 @@ describe("daemon E2E", () => { // Create agent in the directory const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "File Explorer Test", }); @@ -92,7 +92,7 @@ describe("daemon E2E", () => { // Create agent in the directory const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "File Read Test", }); @@ -126,7 +126,7 @@ describe("daemon E2E", () => { // Create agent const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "File Explorer Error Test", }); diff --git a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts index 67be69383..edfd7f78e 100644 --- a/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/git-operations.e2e.test.ts @@ -92,9 +92,9 @@ async function waitForTimelineToolCall( ); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution +// Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; @@ -136,7 +136,7 @@ describe("daemon E2E", () => { // Create agent in the git repo const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Git Diff Test", }); @@ -183,7 +183,7 @@ describe("daemon E2E", () => { // Create agent in the git repo (no modifications) const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Git Diff Clean Test", }); @@ -211,7 +211,7 @@ describe("daemon E2E", () => { // Create agent in a non-git directory const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Git Diff Non-Git Test", }); @@ -260,7 +260,7 @@ describe("daemon E2E", () => { // Create agent in the git repo const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Git Repo Info Test", }); @@ -308,7 +308,7 @@ describe("daemon E2E", () => { // Create agent in the git repo const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Git Repo Info Clean Test", }); @@ -338,7 +338,7 @@ describe("daemon E2E", () => { // Create agent in a non-git directory const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Git Repo Info Non-Git Test", }); @@ -396,7 +396,7 @@ describe("daemon E2E", () => { ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Async Worktree Setup Test", git: { @@ -470,7 +470,7 @@ describe("daemon E2E", () => { ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd: repoRoot, title: "Async Worktree Setup Failure Test", git: { @@ -541,7 +541,7 @@ describe("daemon E2E", () => { const agent = await ctx.client.createAgent({ provider: "codex", model: CODEX_TEST_MODEL, - reasoningEffort: CODEX_TEST_REASONING_EFFORT, + thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Worktree Agent Test", git: { diff --git a/packages/server/src/server/daemon-e2e/images.e2e.test.ts b/packages/server/src/server/daemon-e2e/images.e2e.test.ts index 5b5810990..c91795028 100644 --- a/packages/server/src/server/daemon-e2e/images.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/images.e2e.test.ts @@ -14,9 +14,7 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; diff --git a/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts new file mode 100644 index 000000000..3f298b5b1 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/live-preferences.e2e.test.ts @@ -0,0 +1,246 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { + createDaemonTestContext, + type DaemonTestContext, +} from "../test-utils/index.js"; +import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); +} + +function waitForAgentUpdate( + messages: SessionOutboundMessage[], + startIndex: number, + predicate: (agent: AgentSnapshotPayload) => boolean, + options?: { timeoutMs?: number } +): Promise { + const timeoutMs = options?.timeoutMs ?? 15000; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + clearInterval(interval); + reject(new Error("Timeout waiting for agent_update")); + }, timeoutMs); + + const interval = setInterval(() => { + for (let i = startIndex; i < messages.length; i++) { + const msg = messages[i]; + if (msg.type !== "agent_update") continue; + if (msg.payload.kind !== "upsert") continue; + if (predicate(msg.payload.agent)) { + clearTimeout(timeout); + clearInterval(interval); + resolve(msg.payload.agent); + return; + } + } + }, 50); + }); +} + +function pickTwoDistinctModels(models: Array<{ id: string }>): [string, string] { + const ids = Array.from(new Set(models.map((m) => m.id))).filter(Boolean); + if (ids.length < 2) { + throw new Error(`Need at least 2 models to test switching; got ${ids.length}`); + } + return [ids[0]!, ids[1]!]; +} + +describe("daemon E2E", () => { + let ctx: DaemonTestContext; + let messages: SessionOutboundMessage[] = []; + let unsubscribe: (() => void) | null = null; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + messages = []; + unsubscribe = ctx.client.subscribeRawMessages((message) => { + messages.push(message); + }); + ctx.client.subscribeAgentUpdates(); + }); + + afterEach(async () => { + unsubscribe?.(); + await ctx.cleanup(); + }, 60000); + + describe.each(["claude", "codex", "opencode"] as const)( + "live model switching (%s)", + (provider) => { + test( + "updates agent model without restarting", + async () => { + const cwd = tmpCwd(); + try { + const modelList = await ctx.client.listProviderModels(provider); + if (!modelList.models || modelList.models.length === 0) { + throw new Error(`No models returned for provider ${provider}`); + } + const [modelA, modelB] = pickTwoDistinctModels(modelList.models); + + const agent = await ctx.client.createAgent({ + provider, + cwd, + title: `Model Switch (${provider})`, + model: modelA, + }); + + const startIndex = messages.length; + await ctx.client.setAgentModel(agent.id, modelB); + + const updated = await waitForAgentUpdate( + messages, + startIndex, + (a) => a.id === agent.id && a.model === modelB, + { timeoutMs: 20000 } + ); + + expect(updated.model).toBe(modelB); + + // Sanity: run a tiny prompt after switching. + await ctx.client.sendMessage(agent.id, "Say 'ok' and nothing else"); + const final = await ctx.client.waitForFinish(agent.id, 120000); + expect(final.status).toBe("idle"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 180000 + ); + } + ); + + test( + "live thinking switching works for Claude (off -> on)", + async () => { + const cwd = tmpCwd(); + try { + const modelList = await ctx.client.listProviderModels("claude"); + if (!modelList.models || modelList.models.length === 0) { + throw new Error("No Claude models returned"); + } + const modelId = modelList.models[0]!.id; + + const agent = await ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Claude Thinking Switch", + model: modelId, + }); + + const startIndex = messages.length; + await ctx.client.setAgentThinkingOption(agent.id, "on"); + + const updated = await waitForAgentUpdate( + messages, + startIndex, + (a) => a.id === agent.id && a.thinkingOptionId === "on", + { timeoutMs: 20000 } + ); + + expect(updated.thinkingOptionId).toBe("on"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 120000 + ); + + test( + "live thinking switching works for Codex (default -> non-default)", + async () => { + const cwd = tmpCwd(); + try { + const modelList = await ctx.client.listProviderModels("codex"); + if (!modelList.models || modelList.models.length === 0) { + throw new Error("No Codex models returned"); + } + + const modelWithOptions = modelList.models.find( + (m) => (m.thinkingOptions?.length ?? 0) > 1 + ); + if (!modelWithOptions) { + throw new Error("No Codex model with thinkingOptions returned"); + } + const nonDefault = + modelWithOptions.thinkingOptions?.find((o) => o.id !== "default")?.id ?? + null; + if (!nonDefault) { + throw new Error("No non-default Codex thinking option found"); + } + + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Codex Thinking Switch", + model: modelWithOptions.id, + }); + + const startIndex = messages.length; + await ctx.client.setAgentThinkingOption(agent.id, nonDefault); + + const updated = await waitForAgentUpdate( + messages, + startIndex, + (a) => a.id === agent.id && a.thinkingOptionId === nonDefault, + { timeoutMs: 20000 } + ); + + expect(updated.thinkingOptionId).toBe(nonDefault); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 120000 + ); + + test( + "live thinking switching works for OpenCode", + async () => { + const cwd = tmpCwd(); + try { + const modelList = await ctx.client.listProviderModels("opencode"); + if (!modelList.models || modelList.models.length === 0) { + throw new Error("No OpenCode models returned"); + } + + const modelWithThinkingOptions = modelList.models.find( + (m) => (m.thinkingOptions?.length ?? 0) > 1 + ); + if (!modelWithThinkingOptions) { + throw new Error("No OpenCode model with thinkingOptions returned"); + } + + const agent = await ctx.client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode Preferences Switch", + model: modelWithThinkingOptions.id, + }); + + const thinkingId = + modelWithThinkingOptions.thinkingOptions?.find((o) => o.id !== "default")?.id ?? + null; + if (!thinkingId) { + throw new Error("No non-default OpenCode thinking option found"); + } + const startIndex = messages.length; + await ctx.client.setAgentThinkingOption(agent.id, thinkingId); + const updatedThinking = await waitForAgentUpdate( + messages, + startIndex, + (a) => a.id === agent.id && a.thinkingOptionId === thinkingId, + { timeoutMs: 20000 } + ); + expect(updatedThinking.thinkingOptionId).toBe(thinkingId); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + 180000 + ); +}); diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts index 2fa97d38e..5fe9470f9 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -13,9 +13,7 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; @@ -76,6 +74,26 @@ describe("daemon E2E", () => { }, 60000 // 1 minute timeout ); + + test( + "returns model list for OpenCode provider", + async () => { + const result = await ctx.client.listProviderModels("opencode"); + + expect(result.provider).toBe("opencode"); + expect(result.error).toBeNull(); + expect(result.fetchedAt).toBeTruthy(); + + expect(result.models).toBeTruthy(); + expect(result.models.length).toBeGreaterThan(0); + + const model = result.models[0]; + expect(model.provider).toBe("opencode"); + expect(model.id).toBeTruthy(); + expect(model.label).toBeTruthy(); + }, + 60000 + ); }); diff --git a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts index 384401897..6d0928a5b 100644 --- a/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/permissions-codex.e2e.test.ts @@ -13,9 +13,9 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution +// Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; @@ -44,7 +44,7 @@ describe("daemon E2E", () => { // Create Codex agent with on-request approval policy const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Permission Test", modeId: "read-only", @@ -111,7 +111,7 @@ describe("daemon E2E", () => { // Create Codex agent with on-request approval policy const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Permission Deny Test", modeId: "read-only", @@ -176,7 +176,7 @@ describe("daemon E2E", () => { // Create Codex agent with full-access (no permissions needed) const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Interrupt Test", modeId: "full-access", @@ -237,7 +237,7 @@ describe("daemon E2E", () => { // Create Codex agent with full-access (no permissions needed) const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Abort Stop Test", modeId: "full-access", @@ -275,7 +275,7 @@ describe("daemon E2E", () => { // Step 1: Create Codex agent with "auto" mode (requires permission for writes) const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Mode Switch Permission Test", modeId: "auto", diff --git a/packages/server/src/server/daemon-e2e/setup.ts b/packages/server/src/server/daemon-e2e/setup.ts index 4913ccb05..016a358d0 100644 --- a/packages/server/src/server/daemon-e2e/setup.ts +++ b/packages/server/src/server/daemon-e2e/setup.ts @@ -11,7 +11,7 @@ import { agentConfigs } from "./agent-configs.js"; // Re-export for backward compatibility - prefer using agentConfigs instead export const CODEX_TEST_MODEL = agentConfigs.codex.model; -export const CODEX_TEST_REASONING_EFFORT = agentConfigs.codex.reasoningEffort; +export const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId; // Re-export agent configs export { diff --git a/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts b/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts index 5bdef400b..a64e5d081 100644 --- a/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/tool-calls.e2e.test.ts @@ -14,9 +14,9 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } -// Use gpt-5.1-codex-mini with low reasoning effort for faster test execution +// Use gpt-5.1-codex-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; -const CODEX_TEST_REASONING_EFFORT = "low"; +const CODEX_TEST_THINKING_OPTION_ID = "low"; describe("daemon E2E", () => { let ctx: DaemonTestContext; @@ -197,7 +197,7 @@ describe("daemon E2E", () => { const collector = createMessageCollector(ctx.client); const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Shell Test", modeId: "full-access", @@ -245,7 +245,7 @@ describe("daemon E2E", () => { const collector = createMessageCollector(ctx.client); const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Read Test", modeId: "full-access", @@ -291,7 +291,7 @@ describe("daemon E2E", () => { writeFileSync(testFile, "hello world\n"); const agent = await ctx.client.createAgent({ - provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT, + provider: "codex", model: CODEX_TEST_MODEL, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID, cwd, title: "Codex Edit Test", modeId: "full-access", diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 8bb5c5379..7552efc61 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -21,7 +21,7 @@ type AgentStoragePersistence = Pick; type AgentManagerStateSource = Pick; function isKnownProvider(provider: string): provider is AgentProvider { - return provider === "claude" || provider === "codex"; + return provider === "claude" || provider === "codex" || provider === "opencode"; } /** @@ -53,6 +53,7 @@ export function buildConfigOverrides( cwd: record.cwd, modeId: record.lastModeId ?? record.config?.modeId ?? undefined, model: record.config?.model ?? undefined, + thinkingOptionId: record.config?.thinkingOptionId ?? undefined, title: record.title ?? undefined, extra: record.config?.extra ?? undefined, }; @@ -70,6 +71,7 @@ export function buildSessionConfig( cwd: record.cwd, modeId: overrides.modeId, model: overrides.model, + thinkingOptionId: overrides.thinkingOptionId, title: overrides.title, extra: overrides.extra, }; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2e1d97627..e51eb04a0 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -638,6 +638,7 @@ export class Session { provider, cwd: record.cwd, model: record.config?.model ?? null, + thinkingOptionId: record.config?.thinkingOptionId ?? null, createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null, @@ -872,8 +873,20 @@ export class Session { await this.handleInitializeAgentRequest(msg.agentId, msg.requestId); break; - case "set_agent_mode": - await this.handleSetAgentMode(msg.agentId, msg.modeId); + case "set_agent_mode_request": + await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId); + break; + + case "set_agent_model_request": + await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId); + break; + + case "set_agent_thinking_request": + await this.handleSetAgentThinkingRequest( + msg.agentId, + msg.thinkingOptionId, + msg.requestId + ); break; case "agent_permission_response": @@ -2129,25 +2142,30 @@ export class Session { /** * Handle set agent mode request */ - private async handleSetAgentMode( + private async handleSetAgentModeRequest( agentId: string, - modeId: string + modeId: string, + requestId: string ): Promise { this.sessionLogger.info( - { agentId, modeId }, - `Setting agent ${agentId} mode to ${modeId}` + { agentId, modeId, requestId }, + "session: set_agent_mode_request" ); try { await this.agentManager.setAgentMode(agentId, modeId); this.sessionLogger.info( - { agentId, modeId }, - `Agent ${agentId} mode set to ${modeId}` + { agentId, modeId, requestId }, + "session: set_agent_mode_request success" ); + this.emit({ + type: "set_agent_mode_response", + payload: { requestId, agentId, accepted: true, error: null }, + }); } catch (error: any) { this.sessionLogger.error( - { err: error, agentId, modeId }, - "Failed to set agent mode" + { err: error, agentId, modeId, requestId }, + "session: set_agent_mode_request error" ); this.emit({ type: "activity_log", @@ -2158,7 +2176,109 @@ export class Session { content: `Failed to set agent mode: ${error.message}`, }, }); - throw error; + this.emit({ + type: "set_agent_mode_response", + payload: { + requestId, + agentId, + accepted: false, + error: error?.message ? String(error.message) : "Failed to set agent mode", + }, + }); + } + } + + private async handleSetAgentModelRequest( + agentId: string, + modelId: string | null, + requestId: string + ): Promise { + this.sessionLogger.info( + { agentId, modelId, requestId }, + "session: set_agent_model_request" + ); + + try { + await this.agentManager.setAgentModel(agentId, modelId); + this.sessionLogger.info( + { agentId, modelId, requestId }, + "session: set_agent_model_request success" + ); + this.emit({ + type: "set_agent_model_response", + payload: { requestId, agentId, accepted: true, error: null }, + }); + } catch (error: any) { + this.sessionLogger.error( + { err: error, agentId, modelId, requestId }, + "session: set_agent_model_request error" + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to set agent model: ${error.message}`, + }, + }); + this.emit({ + type: "set_agent_model_response", + payload: { + requestId, + agentId, + accepted: false, + error: error?.message ? String(error.message) : "Failed to set agent model", + }, + }); + } + } + + private async handleSetAgentThinkingRequest( + agentId: string, + thinkingOptionId: string | null, + requestId: string + ): Promise { + this.sessionLogger.info( + { agentId, thinkingOptionId, requestId }, + "session: set_agent_thinking_request" + ); + + try { + await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId); + this.sessionLogger.info( + { agentId, thinkingOptionId, requestId }, + "session: set_agent_thinking_request success" + ); + this.emit({ + type: "set_agent_thinking_response", + payload: { requestId, agentId, accepted: true, error: null }, + }); + } catch (error: any) { + this.sessionLogger.error( + { err: error, agentId, thinkingOptionId, requestId }, + "session: set_agent_thinking_request error" + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to set agent thinking option: ${error.message}`, + }, + }); + this.emit({ + type: "set_agent_thinking_response", + payload: { + requestId, + agentId, + accepted: false, + error: error?.message + ? String(error.message) + : "Failed to set agent thinking option", + }, + }); } } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index a2c8f4dfe..22118fa55 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -21,6 +21,14 @@ const AgentModeSchema: z.ZodType = z.object({ description: z.string().optional(), }); +const AgentSelectOptionSchema = z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), + isDefault: z.boolean().optional(), + metadata: z.record(z.unknown()).optional(), +}); + const AgentModelDefinitionSchema: z.ZodType = z.object({ provider: AgentProviderSchema, id: z.string(), @@ -28,6 +36,8 @@ const AgentModelDefinitionSchema: z.ZodType = z.object({ description: z.string().optional(), isDefault: z.boolean().optional(), metadata: z.record(z.unknown()).optional(), + thinkingOptions: z.array(AgentSelectOptionSchema).optional(), + defaultThinkingOptionId: z.string().optional(), }); const AgentCapabilityFlagsSchema: z.ZodType = z.object({ @@ -76,6 +86,7 @@ const AgentSessionConfigSchema = z.object({ cwd: z.string(), modeId: z.string().optional(), model: z.string().optional(), + thinkingOptionId: z.string().optional(), title: z .string() .trim() @@ -87,7 +98,6 @@ const AgentSessionConfigSchema = z.object({ sandboxMode: z.string().optional(), networkAccess: z.boolean().optional(), webSearch: z.boolean().optional(), - reasoningEffort: z.string().optional(), extra: z .object({ codex: z.record(z.unknown()).optional(), @@ -253,6 +263,7 @@ export const AgentSnapshotPayloadSchema = z.object({ provider: AgentProviderSchema, cwd: z.string(), model: z.string().nullable(), + thinkingOptionId: z.string().nullable().optional(), createdAt: z.string(), updatedAt: z.string(), lastUserMessageAt: z.string().nullable(), @@ -520,10 +531,55 @@ export const InitializeAgentResponseMessageSchema = z.object({ }), }); -export const SetAgentModeMessageSchema = z.object({ - type: z.literal("set_agent_mode"), +export const SetAgentModeRequestMessageSchema = z.object({ + type: z.literal("set_agent_mode_request"), agentId: z.string(), modeId: z.string(), + requestId: z.string(), +}); + +export const SetAgentModeResponseMessageSchema = z.object({ + type: z.literal("set_agent_mode_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + accepted: z.boolean(), + error: z.string().nullable(), + }), +}); + +export const SetAgentModelRequestMessageSchema = z.object({ + type: z.literal("set_agent_model_request"), + agentId: z.string(), + modelId: z.string().nullable(), + requestId: z.string(), +}); + +export const SetAgentModelResponseMessageSchema = z.object({ + type: z.literal("set_agent_model_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + accepted: z.boolean(), + error: z.string().nullable(), + }), +}); + +export const SetAgentThinkingRequestMessageSchema = z.object({ + type: z.literal("set_agent_thinking_request"), + agentId: z.string(), + thinkingOptionId: z.string().nullable(), + requestId: z.string(), +}); + +export const SetAgentThinkingResponseMessageSchema = z.object({ + type: z.literal("set_agent_thinking_response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + accepted: z.boolean(), + error: z.string().nullable(), + }), }); export const AgentPermissionResponseMessageSchema = z.object({ @@ -830,7 +886,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CancelAgentRequestMessageSchema, RestartServerRequestMessageSchema, InitializeAgentRequestMessageSchema, - SetAgentModeMessageSchema, + SetAgentModeRequestMessageSchema, + SetAgentModelRequestMessageSchema, + SetAgentThinkingRequestMessageSchema, AgentPermissionResponseMessageSchema, GitDiffRequestSchema, CheckoutStatusRequestSchema, @@ -1559,6 +1617,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentsResponseMessageSchema, FetchAgentResponseMessageSchema, SendAgentMessageResponseMessageSchema, + SetAgentModeResponseMessageSchema, + SetAgentModelResponseMessageSchema, + SetAgentThinkingResponseMessageSchema, WaitForFinishResponseMessageSchema, ListVoiceConversationsResponseMessageSchema, DeleteVoiceConversationResponseMessageSchema, @@ -1659,7 +1720,9 @@ export type ListProviderModelsRequestMessage = z.infer< export type ResumeAgentRequestMessage = z.infer; export type DeleteAgentRequestMessage = z.infer; export type InitializeAgentRequestMessage = z.infer; -export type SetAgentModeMessage = z.infer; +export type SetAgentModeRequestMessage = z.infer; +export type SetAgentModelRequestMessage = z.infer; +export type SetAgentThinkingRequestMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; export type GitDiffRequest = z.infer; export type GitDiffResponse = z.infer;