feat: add live model and thinking option preferences UI (#8)

* feat: add live model and thinking option preferences UI

* refactor: remove agent variant support and clean up thinking options

* feat: live model + thinking presets (RPC)
This commit is contained in:
Mohamed Boudra
2026-02-04 08:18:15 +07:00
committed by GitHub
parent 03f383b908
commit 469a25c724
50 changed files with 1976 additions and 894 deletions
+12 -10
View File
@@ -815,7 +815,7 @@ function DaemonCard({
const daemonConnection = useSessionStore( const daemonConnection = useSessionStore(
(state) => state.sessions[daemon.id]?.connection ?? null (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 [isRestarting, setIsRestarting] = useState(false);
const isConnected = daemonConnection?.isConnected ?? false; const isConnected = daemonConnection?.isConnected ?? false;
const isConnectedRef = useRef(isConnected); const isConnectedRef = useRef(isConnected);
@@ -848,7 +848,7 @@ function DaemonCard({
}, [daemon.label, isScreenMountedRef, waitForCondition]); }, [daemon.label, isScreenMountedRef, waitForCondition]);
const beginServerRestart = useCallback(() => { const beginServerRestart = useCallback(() => {
if (!restartServerFn) { if (!daemonClient) {
Alert.alert( Alert.alert(
"Host unavailable", "Host unavailable",
`${daemon.label} is not connected. Wait for it to come online before restarting.` `${daemon.label} is not connected. Wait for it to come online before restarting.`
@@ -865,23 +865,25 @@ function DaemonCard({
} }
setIsRestarting(true); setIsRestarting(true);
try { void daemonClient
restartServerFn(`settings_daemon_restart_${daemon.id}`); .restartServer(`settings_daemon_restart_${daemon.id}`)
} catch (error) { .catch((error) => {
console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error); console.error(`[Settings] Failed to restart daemon ${daemon.label}`, error);
if (!isScreenMountedRef.current) {
return;
}
setIsRestarting(false); setIsRestarting(false);
Alert.alert( Alert.alert(
"Error", "Error",
"Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online." "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online."
); );
return; });
}
void waitForDaemonRestart(); void waitForDaemonRestart();
}, [daemon.id, daemon.label, restartServerFn, waitForDaemonRestart]); }, [daemon.id, daemon.label, daemonClient, isScreenMountedRef, waitForDaemonRestart]);
const handleRestartPress = useCallback(() => { const handleRestartPress = useCallback(() => {
if (!restartServerFn) { if (!daemonClient) {
Alert.alert( Alert.alert(
"Host unavailable", "Host unavailable",
`${daemon.label} is not connected. Wait for it to come online before restarting.` `${daemon.label} is not connected. Wait for it to come online before restarting.`
@@ -912,7 +914,7 @@ function DaemonCard({
onPress: beginServerRestart, onPress: beginServerRestart,
}, },
]); ]);
}, [beginServerRestart, daemon.label, restartConfirmationMessage, restartServerFn]); }, [beginServerRestart, daemon.label, daemonClient, restartConfirmationMessage]);
// Status pill background with 10% opacity // Status pill background with 10% opacity
const statusPillBg = const statusPillBg =
@@ -27,6 +27,7 @@ import {
import { Theme } from "@/styles/theme"; import { Theme } from "@/styles/theme";
import { CommandAutocomplete } from "./command-autocomplete"; import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -85,11 +86,8 @@ export function AgentInputArea({
); );
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; 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 setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice(); const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice();
@@ -129,7 +127,9 @@ export function AgentInputArea({
const { pickImages } = useImageAttachmentPicker(); const { pickImages } = useImageAttachmentPicker();
const agentIdRef = useRef(agentId); const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef(sendAgentMessage); const sendAgentMessageRef = useRef<
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage); const onSubmitMessageRef = useRef(onSubmitMessage);
const messageInputRef = useRef<MessageInputRef>(null); const messageInputRef = useRef<MessageInputRef>(null);
@@ -148,7 +148,10 @@ export function AgentInputArea({
await onSubmitMessageRef.current({ text, images }); await onSubmitMessageRef.current({ text, images });
return; 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]); }, [agentId]);
useEffect(() => { useEffect(() => {
sendAgentMessageRef.current = sendAgentMessage; sendAgentMessageRef.current = async (
}, [sendAgentMessage]); 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(() => { useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage; onSubmitMessageRef.current = onSubmitMessage;
@@ -213,7 +244,7 @@ export function AgentInputArea({
// decide what to do even if the socket is currently disconnected (so we // 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). // don't no-op and lose deterministic error handling in the UI/tests).
if (!onSubmitMessageRef.current && !socketConnected) return; if (!onSubmitMessageRef.current && !socketConnected) return;
if (!sendAgentMessage && !onSubmitMessageRef.current) return; if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
if (agent?.status === "running" && !forceSend) { if (agent?.status === "running" && !forceSend) {
queueMessage(trimmedMessage, imageAttachments); queueMessage(trimmedMessage, imageAttachments);
@@ -334,11 +365,11 @@ export function AgentInputArea({
if (!agent || agent.status !== "running" || isCancellingAgent) { if (!agent || agent.status !== "running" || isCancellingAgent) {
return; return;
} }
if (!isConnected || !cancelAgentRun) { if (!isConnected || !client) {
return; return;
} }
setIsCancellingAgent(true); setIsCancellingAgent(true);
cancelAgentRun(agentIdRef.current); void client.cancelAgent(agentIdRef.current);
messageInputRef.current?.focus(); messageInputRef.current?.focus();
} }
@@ -354,7 +385,7 @@ export function AgentInputArea({
async function handleSendQueuedNow(id: string) { async function handleSendQueuedNow(id: string) {
const item = queuedMessages.find((q) => q.id === id); const item = queuedMessages.find((q) => q.id === id);
if (!item || !isConnected) return; if (!item || !isConnected) return;
if (!sendAgentMessage && !onSubmitMessageRef.current) return; if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
updateQueue((current) => current.filter((q) => q.id !== id)); updateQueue((current) => current.filter((q) => q.id !== id));
+8 -13
View File
@@ -85,16 +85,12 @@ export function AgentList({
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
// Get the methods for the specific server const actionClient = useSessionStore((state) =>
const methods = useSessionStore((state) => actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
actionAgent?.serverId
? state.sessions[actionAgent.serverId]?.methods
: undefined
); );
const archiveAgent = methods?.archiveAgent;
const isActionSheetVisible = actionAgent !== null; const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods); const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback( const handleAgentPress = useCallback(
(serverId: string, agentId: string) => { (serverId: string, agentId: string) => {
@@ -134,12 +130,12 @@ export function AgentList({
}, []); }, []);
const handleArchiveAgent = useCallback(() => { const handleArchiveAgent = useCallback(() => {
if (!actionAgent || !archiveAgent) { if (!actionAgent || !actionClient) {
return; return;
} }
archiveAgent(actionAgent.id); void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null); setActionAgent(null);
}, [actionAgent, archiveAgent]); }, [actionAgent, actionClient]);
const viewabilityConfig = useMemo( const viewabilityConfig = useMemo(
() => ({ itemVisiblePercentThreshold: 30 }), () => ({ itemVisiblePercentThreshold: 30 }),
@@ -347,7 +343,7 @@ export function AgentList({
<Text style={styles.sheetCancelText}>Cancel</Text> <Text style={styles.sheetCancelText}>Cancel</Text>
</Pressable> </Pressable>
<Pressable <Pressable
disabled={!archiveAgent || isActionDaemonUnavailable} disabled={isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetArchiveButton]} style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveAgent} onPress={handleArchiveAgent}
testID="agent-action-archive" testID="agent-action-archive"
@@ -355,8 +351,7 @@ export function AgentList({
<Text <Text
style={[ style={[
styles.sheetArchiveText, styles.sheetArchiveText,
(!archiveAgent || isActionDaemonUnavailable) && isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
styles.sheetArchiveTextDisabled,
]} ]}
> >
Archive Archive
@@ -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 { 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 { useSessionStore } from "@/stores/session-store";
import { import {
DropdownMenu, DropdownMenu,
@@ -9,6 +9,9 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } 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 { interface AgentStatusBarProps {
agentId: string; agentId: string;
@@ -17,27 +20,62 @@ interface AgentStatusBarProps {
export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const IS_WEB = Platform.OS === "web";
const [prefsOpen, setPrefsOpen] = useState(false);
// Select only the specific agent (not all agents) // Select only the specific agent (not all agents)
const agent = useSessionStore((state) => const agent = useSessionStore((state) =>
state.sessions[serverId]?.agents?.get(agentId) state.sessions[serverId]?.agents?.get(agentId)
); );
// Get the setAgentMode action (actions are stable, won't cause rerenders) const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setAgentMode = useSessionStore(
(state) => state.sessions[serverId]?.methods?.setAgentMode
);
if (!agent) { if (!agent) {
return null; 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) { function handleModeChange(modeId: string) {
if (setAgentMode) { if (!client) {
setAgentMode(agentId, modeId); 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 ( return (
<View style={styles.container}> <View style={styles.container}>
{/* Agent Mode Badge */} {/* Agent Mode Badge */}
@@ -83,6 +121,208 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
{/* Desktop: inline dropdowns for model/thinking */}
{IS_WEB && (
<>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-model-selector"
>
<Text style={styles.modeBadgeText}>{displayModel}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" testID="agent-model-menu">
<DropdownMenuLabel>Model</DropdownMenuLabel>
{models?.map((model) => {
const isActive = model.id === agent.model;
return (
<DropdownMenuItem
key={model.id}
selected={isActive}
selectedVariant="accent"
description={model.description}
onSelect={() => {
if (!client) {
return;
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
{thinkingOptions && thinkingOptions.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.modeBadge,
pressed && styles.modeBadgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-thinking-selector"
>
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" testID="agent-thinking-menu">
<DropdownMenuLabel>Thinking</DropdownMenuLabel>
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
selectedVariant="accent"
description={opt.description}
onSelect={() => {
if (!client) {
return;
}
void client
.setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
{/* Mobile: preferences button opens a bottom sheet */}
{!IS_WEB && (
<>
<Pressable
onPress={() => setPrefsOpen(true)}
style={({ pressed }) => [
styles.prefsButton,
pressed && styles.prefsButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel="Agent preferences"
testID="agent-preferences-button"
>
<SlidersHorizontal size={16} color={theme.colors.foregroundMuted} />
</Pressable>
<AdaptiveModalSheet
title="Preferences"
visible={prefsOpen}
onClose={() => setPrefsOpen(false)}
testID="agent-preferences-sheet"
>
<View style={styles.sheetSection}>
<Text style={styles.sheetLabel}>Model</Text>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
]}
accessibilityRole="button"
accessibilityLabel="Select agent model"
testID="agent-preferences-model"
>
<Text style={styles.sheetSelectText}>{displayModel}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuLabel>Model</DropdownMenuLabel>
{models?.map((model) => {
const isActive = model.id === agent.model;
return (
<DropdownMenuItem
key={model.id}
selected={isActive}
selectedVariant="accent"
description={model.description}
onSelect={() => {
if (!client) {
return;
}
void client.setAgentModel(agentId, model.id).catch((error) => {
console.warn("[AgentStatusBar] setAgentModel failed", error);
});
}}
>
{model.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
{thinkingOptions && thinkingOptions.length > 1 && (
<View style={styles.sheetSection}>
<Text style={styles.sheetLabel}>Thinking</Text>
<DropdownMenu>
<DropdownMenuTrigger
style={({ pressed }) => [
styles.sheetSelect,
pressed && styles.sheetSelectPressed,
]}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
<DropdownMenuLabel>Thinking</DropdownMenuLabel>
{thinkingOptions.map((opt) => {
const isActive = opt.id === selectedThinkingId;
return (
<DropdownMenuItem
key={opt.id}
selected={isActive}
selectedVariant="accent"
description={opt.description}
onSelect={() => {
if (!client) {
return;
}
void client
.setAgentThinkingOption(agentId, opt.id === "default" ? null : opt.id)
.catch((error) => {
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
});
}}
>
{opt.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
)}
</AdaptiveModalSheet>
</>
)}
</View> </View>
); );
} }
@@ -111,4 +351,44 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
textTransform: "capitalize", 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,
},
})); }));
@@ -44,6 +44,7 @@ import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import type { DaemonClientV2 } from "@server/client/daemon-client-v2"; import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
import { parseToolCallDisplay } from "@/utils/tool-call-parsers"; import { parseToolCallDisplay } from "@/utils/tool-call-parsers";
import { ToolCallDetailsContent } from "./tool-call-details"; import { ToolCallDetailsContent } from "./tool-call-details";
@@ -94,15 +95,7 @@ export function AgentStreamView({
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId) state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
); );
// Get methods for file operations const { requestDirectoryListing, requestFilePreview } = useFileExplorerActions(resolvedServerId);
const methods = useSessionStore(
(state) => state.sessions[resolvedServerId]?.methods
);
const requestDirectoryListing = methods?.requestDirectoryListing;
const requestFilePreview = methods?.requestFilePreview;
const requestDirectoryListingOrInert = requestDirectoryListing ?? (() => {});
const requestFilePreviewOrInert = requestFilePreview ?? (() => {});
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes // Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422. // tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android"; const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -130,9 +123,9 @@ export function AgentStreamView({
return; return;
} }
requestDirectoryListingOrInert(agentId, normalized.directory); requestDirectoryListing(agentId, normalized.directory);
if (normalized.file) { if (normalized.file) {
requestFilePreviewOrInert(agentId, normalized.file); requestFilePreview(agentId, normalized.file);
} }
setExplorerTab("files"); setExplorerTab("files");
@@ -141,8 +134,8 @@ export function AgentStreamView({
[ [
agent.cwd, agent.cwd,
agentId, agentId,
requestDirectoryListingOrInert, requestDirectoryListing,
requestFilePreviewOrInert, requestFilePreview,
setExplorerTab, setExplorerTab,
openFileExplorer, openFileExplorer,
] ]
@@ -39,6 +39,7 @@ import type { ExplorerEntry } from "@/stores/session-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useDownloadStore } from "@/stores/download-store"; import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { import {
usePanelStore, usePanelStore,
type SortOption, type SortOption,
@@ -81,11 +82,12 @@ export function FileExplorerPane({
: undefined : undefined
); );
const methods = useSessionStore((state) => state.sessions[serverId]?.methods); const {
const requestDirectoryListing = methods?.requestDirectoryListing; requestDirectoryListing,
const requestFilePreview = methods?.requestFilePreview; requestFilePreview,
const requestFileDownloadToken = methods?.requestFileDownloadToken; requestFileDownloadToken,
const navigateExplorerBack = methods?.navigateExplorerBack; navigateExplorerBack,
} = useFileExplorerActions(serverId);
const viewMode = usePanelStore((state) => state.explorerViewMode); const viewMode = usePanelStore((state) => state.explorerViewMode);
const sortOption = usePanelStore((state) => state.explorerSortOption); const sortOption = usePanelStore((state) => state.explorerSortOption);
const setSortOption = usePanelStore((state) => state.setExplorerSortOption); const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
@@ -213,16 +213,12 @@ export function GroupedAgentList({
new Set() new Set()
); );
// Get the methods for the specific server const actionClient = useSessionStore((state) =>
const methods = useSessionStore((state) => actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
actionAgent?.serverId
? state.sessions[actionAgent.serverId]?.methods
: undefined
); );
const archiveAgent = methods?.archiveAgent;
const isActionSheetVisible = actionAgent !== null; const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods); const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
const handleAgentPress = useCallback( const handleAgentPress = useCallback(
(serverId: string, agentId: string) => { (serverId: string, agentId: string) => {
@@ -262,12 +258,12 @@ export function GroupedAgentList({
}, []); }, []);
const handleArchiveFromSheet = useCallback(() => { const handleArchiveFromSheet = useCallback(() => {
if (!actionAgent || !archiveAgent) { if (!actionAgent || !actionClient) {
return; return;
} }
archiveAgent(actionAgent.id); void actionClient.archiveAgent(actionAgent.id);
setActionAgent(null); setActionAgent(null);
}, [actionAgent, archiveAgent]); }, [actionAgent, actionClient]);
const toggleSection = useCallback((sectionKey: string) => { const toggleSection = useCallback((sectionKey: string) => {
setCollapsedSections((prev) => { setCollapsedSections((prev) => {
@@ -413,9 +409,9 @@ export function GroupedAgentList({
(e: { stopPropagation: () => void }, agent: AggregatedAgent) => { (e: { stopPropagation: () => void }, agent: AggregatedAgent) => {
e.stopPropagation(); e.stopPropagation();
const session = useSessionStore.getState().sessions[agent.serverId]; const session = useSessionStore.getState().sessions[agent.serverId];
const archiveMethod = session?.methods?.archiveAgent; const client = session?.client ?? null;
if (archiveMethod) { if (client) {
archiveMethod(agent.id); void client.archiveAgent(agent.id);
} }
}, },
[] []
@@ -595,7 +591,7 @@ export function GroupedAgentList({
<Text style={styles.sheetCancelText}>Cancel</Text> <Text style={styles.sheetCancelText}>Cancel</Text>
</Pressable> </Pressable>
<Pressable <Pressable
disabled={!archiveAgent || isActionDaemonUnavailable} disabled={isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetArchiveButton]} style={[styles.sheetButton, styles.sheetArchiveButton]}
onPress={handleArchiveFromSheet} onPress={handleArchiveFromSheet}
testID="agent-action-archive" testID="agent-action-archive"
@@ -603,8 +599,7 @@ export function GroupedAgentList({
<Text <Text
style={[ style={[
styles.sheetArchiveText, styles.sheetArchiveText,
(!archiveAgent || isActionDaemonUnavailable) && isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
styles.sheetArchiveTextDisabled,
]} ]}
> >
Archive Archive
+34 -441
View File
@@ -1,12 +1,4 @@
import { import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
createContext,
useRef,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { AppState, Platform } from "react-native"; import { AppState, Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
@@ -39,12 +31,13 @@ import {
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { AgentDirectoryEntry } from "@/types/agent-directory";
import { sendOsNotification } from "@/utils/os-notifications"; 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 // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
export type { export type {
MessageEntry, MessageEntry,
ProviderModelState,
Agent, Agent,
ExplorerEntry, ExplorerEntry,
ExplorerFile, ExplorerFile,
@@ -195,20 +188,6 @@ type FileDownloadTokenPayload = Extract<
{ type: "file_download_token_response" } { type: "file_download_token_response" }
>["payload"]; >["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<void>;
resolve: () => void;
reject: (error: Error) => void;
}
const agentInitializationPromises = new Map<string, DeferredInit>();
function getInitKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
}
function normalizeAgentSnapshot( function normalizeAgentSnapshot(
snapshot: AgentSnapshotPayload, snapshot: AgentSnapshotPayload,
serverId: string serverId: string
@@ -244,6 +223,7 @@ function normalizeAgentSnapshot(
title: snapshot.title ?? null, title: snapshot.title ?? null,
cwd: snapshot.cwd, cwd: snapshot.cwd,
model: snapshot.model ?? null, model: snapshot.model ?? null,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false, requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null, attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp, attentionTimestamp,
@@ -272,56 +252,6 @@ const pushHistory = (history: string[], path: string): string[] => {
return [...normalizedHistory, path]; return [...normalizedHistory, path];
}; };
// Lightweight context for imperative APIs only (no state)
export interface SessionContextValue {
serverId: string;
client: DaemonClientV2;
audioPlayer: ReturnType<typeof useAudioPlayer>;
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<FileDownloadTokenPayload>;
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<void>;
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<unknown>;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (
agentId: string,
requestId: string,
response: any
) => void;
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
}
const SessionContext = createContext<SessionContextValue | null>(null);
interface SessionProviderProps { interface SessionProviderProps {
children: ReactNode; children: ReactNode;
serverUrl: string; serverUrl: string;
@@ -373,7 +303,6 @@ export function SessionProvider({
); );
const setGitDiffs = useSessionStore((state) => state.setGitDiffs); const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const setProviderModels = useSessionStore((state) => state.setProviderModels);
const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const getSession = useSessionStore((state) => state.getSession); const getSession = useSessionStore((state) => state.getSession);
@@ -404,7 +333,6 @@ export function SessionProvider({
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>( const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(
new Map() new Map()
); );
const providerModelRequestIdsRef = useRef<Map<any, string>>(new Map());
const sendAgentMessageRef = useRef< const sendAgentMessageRef = useRef<
| (( | ((
agentId: string, agentId: string,
@@ -1029,11 +957,7 @@ export function SessionProvider({
// Resolve the initialization promise (even for empty history) // Resolve the initialization promise (even for empty history)
const initKey = getInitKey(serverId, agentId); const initKey = getInitKey(serverId, agentId);
const deferred = agentInitializationPromises.get(initKey); resolveInitDeferred(initKey);
if (deferred) {
deferred.resolve();
// Keep the promise in the map so subsequent calls return immediately
}
} }
); );
@@ -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) => { const unsubAgentDeleted = client.on("agent_deleted", (message) => {
if (message.type !== "agent_deleted") { if (message.type !== "agent_deleted") {
return; return;
@@ -1494,7 +1389,6 @@ export function SessionProvider({
unsubActivity(); unsubActivity();
unsubChunk(); unsubChunk();
unsubTranscription(); unsubTranscription();
unsubProviderModels();
unsubAgentDeleted(); unsubAgentDeleted();
unsubAgentArchived(); unsubAgentArchived();
}; };
@@ -1514,7 +1408,6 @@ export function SessionProvider({
setPendingPermissions, setPendingPermissions,
setGitDiffs, setGitDiffs,
setFileExplorer, setFileExplorer,
setProviderModels,
setHasHydratedAgents, setHasHydratedAgents,
updateConnectionStatus, updateConnectionStatus,
getSession, getSession,
@@ -1595,226 +1488,6 @@ export function SessionProvider({
] ]
); );
const INIT_TIMEOUT_MS = 10000;
const ensureAgentIsInitialized = useCallback(
(agentId: string): Promise<void> => {
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<void>((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<string>((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( const sendAgentMessage = useCallback(
async ( async (
agentId: string, agentId: string,
@@ -1979,6 +1652,34 @@ export function SessionProvider({
[client] [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( const respondToPermission = useCallback(
(agentId: string, requestId: string, response: any) => { (agentId: string, requestId: string, response: any) => {
if (!client) { if (!client) {
@@ -2236,113 +1937,5 @@ export function SessionProvider({
}; };
}, [serverId, clearSession]); }, [serverId, clearSession]);
const value = useMemo<SessionContextValue>( return children;
() => ({
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 (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
);
} }
// Export the context for components that need imperative APIs
export { SessionContext };
+36 -115
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { import {
AGENT_PROVIDER_DEFINITIONS, AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition, type AgentProviderDefinition,
@@ -76,11 +77,6 @@ type UseAgentFormStateResult = {
isModelLoading: boolean; isModelLoading: boolean;
modelError: string | null; modelError: string | null;
refreshProviderModels: () => void; refreshProviderModels: () => void;
queueProviderModelFetch: (
serverId: string | null,
options?: { cwd?: string; delayMs?: number }
) => void;
clearQueuedProviderModelRequest: (serverId: string | null) => void;
workingDirIsEmpty: boolean; workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>; persistFormPreferences: () => Promise<void>;
}; };
@@ -278,20 +274,40 @@ export function useAgentFormState(
} }
}, [isVisible]); }, [isVisible]);
// Get session state for provider models // Session state for provider model listing
const sessionState = useSessionStore((state) => const sessionState = useSessionStore((state) =>
formState.serverId ? state.sessions[formState.serverId] : undefined formState.serverId ? state.sessions[formState.serverId] : undefined
); );
const providerModels = sessionState?.providerModels; const client = sessionState?.client ?? null;
const requestProviderModels = sessionState?.methods?.requestProviderModels; const isConnected = sessionState?.connection?.isConnected ?? false;
const getSessionState = useCallback(
(serverId: string) => useSessionStore.getState().sessions[serverId] ?? null,
[]
);
// Get available models for current provider const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
const modelState = providerModels?.get(formState.provider); useEffect(() => {
const availableModels = modelState?.models ?? null; 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 // Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => { const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -360,11 +376,6 @@ export function useAgentFormState(
updatePreferences, updatePreferences,
]); ]);
// Provider model request timers
const providerModelRequestTimersRef = useRef<
Map<string, ReturnType<typeof setTimeout>>
>(new Map());
// User setters - mark fields as modified and persist to preferences // User setters - mark fields as modified and persist to preferences
const setSelectedServerIdFromUser = useCallback( const setSelectedServerIdFromUser = useCallback(
(value: string | null) => { (value: string | null) => {
@@ -432,14 +443,8 @@ export function useAgentFormState(
}, []); }, []);
const refreshProviderModels = useCallback(() => { const refreshProviderModels = useCallback(() => {
if (!requestProviderModels) { void providerModelsQuery.refetch();
return; }, [providerModelsQuery]);
}
const trimmed = formState.workingDir.trim();
requestProviderModels(formState.provider, {
cwd: trimmed.length > 0 ? trimmed : undefined,
});
}, [requestProviderModels, formState.provider, formState.workingDir]);
const persistFormPreferences = useCallback(async () => { const persistFormPreferences = useCallback(async () => {
await updatePreferences({ await updatePreferences({
@@ -461,91 +466,11 @@ export function useAgentFormState(
updateProviderPreferences, 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 agentDefinition = providerDefinitionMap.get(formState.provider);
const modeOptions = agentDefinition?.modes ?? []; const modeOptions = agentDefinition?.modes ?? [];
const isModelLoading = modelState?.isLoading ?? false; const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
const modelError = modelState?.error ?? null; const modelError =
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
const workingDirIsEmpty = !formState.workingDir.trim(); const workingDirIsEmpty = !formState.workingDir.trim();
@@ -571,8 +496,6 @@ export function useAgentFormState(
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
queueProviderModelFetch,
clearQueuedProviderModelRequest,
workingDirIsEmpty, workingDirIsEmpty,
persistFormPreferences, persistFormPreferences,
}), }),
@@ -595,8 +518,6 @@ export function useAgentFormState(
isModelLoading, isModelLoading,
modelError, modelError,
refreshProviderModels, refreshProviderModels,
queueProviderModelFetch,
clearQueuedProviderModelRequest,
workingDirIsEmpty, workingDirIsEmpty,
persistFormPreferences, persistFormPreferences,
] ]
@@ -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<void> => {
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 };
}
@@ -5,6 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
import { isPerfLoggingEnabled } from "@/utils/perf"; import { isPerfLoggingEnabled } from "@/utils/perf";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
export interface AggregatedAgent extends AgentDirectoryEntry { export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string; serverId: string;
@@ -32,11 +33,11 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
}) })
); );
const sessionMethods = useSessionStore( const sessionClients = useSessionStore(
useShallow((state) => { useShallow((state) => {
const result: Record<string, NonNullable<typeof state.sessions[string]["methods"]> | undefined> = {}; const result: Record<string, NonNullable<typeof state.sessions[string]["client"]> | null> = {};
for (const [serverId, session] of Object.entries(state.sessions)) { for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.methods ?? undefined; result[serverId] = session.client ?? null;
} }
return result; return result;
}) })
@@ -44,13 +45,43 @@ export function useAggregatedAgents(): AggregatedAgentsResult {
const refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
console.log('[useAggregatedAgents] Manual refresh triggered for all sessions'); console.log('[useAggregatedAgents] Manual refresh triggered for all sessions');
for (const [serverId, methods] of Object.entries(sessionMethods)) { for (const [serverId, client] of Object.entries(sessionClients)) {
if (methods?.refreshSession) { if (!client) {
continue;
}
void (async () => {
try {
console.log(`[useAggregatedAgents] Refreshing session ${serverId}`); console.log(`[useAggregatedAgents] Refreshing session ${serverId}`);
methods.refreshSession(); 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 });
} }
} }
}, [sessionMethods]);
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 });
}
})();
}
}, [sessionClients]);
const result = useMemo(() => { const result = useMemo(() => {
const allAgents: AggregatedAgent[] = []; const allAgents: AggregatedAgent[] = [];
@@ -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,
};
}
@@ -60,6 +60,7 @@ import { startPerfMonitor } from "@/utils/perf-monitor";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info"; import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -338,10 +339,7 @@ function AgentScreenContent({
const isConnected = useSessionStore( const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false (state) => state.sessions[serverId]?.connection.isConnected ?? false
); );
const { ensureAgentIsInitialized, refreshAgent } = useAgentInitialization(serverId);
// Get methods
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const refreshAgent = methods?.refreshAgent;
const setFocusedAgentId = useCallback( const setFocusedAgentId = useCallback(
(agentId: string | null) => { (agentId: string | null) => {
useSessionStore.getState().setFocusedAgentId(serverId, agentId); useSessionStore.getState().setFocusedAgentId(serverId, agentId);
@@ -484,9 +482,6 @@ function AgentScreenContent({
streamItems, streamItems,
]); ]);
// Get ensureAgentIsInitialized from methods
const ensureAgentIsInitialized = methods?.ensureAgentIsInitialized;
useEffect(() => { useEffect(() => {
if (!resolvedAgentId || !ensureAgentIsInitialized) { if (!resolvedAgentId || !ensureAgentIsInitialized) {
return; return;
@@ -546,10 +541,12 @@ function AgentScreenContent({
}, [setExplorerTab, openFileExplorer]); }, [setExplorerTab, openFileExplorer]);
const handleRefreshAgent = useCallback(() => { const handleRefreshAgent = useCallback(() => {
if (!resolvedAgentId || !refreshAgent) { if (!resolvedAgentId) {
return; return;
} }
refreshAgent({ agentId: resolvedAgentId }); void refreshAgent(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] refreshAgent failed", { agentId: resolvedAgentId, error });
});
}, [resolvedAgentId, refreshAgent]); }, [resolvedAgentId, refreshAgent]);
if (!effectiveAgent) { if (!effectiveAgent) {
@@ -41,6 +41,7 @@ import { WelcomeScreen } from "@/components/welcome-screen";
import type { Agent } from "@/contexts/session-context"; import type { Agent } from "@/contexts/session-context";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
import { encodeImages } from "@/utils/encode-images";
import type { import type {
AgentProvider, AgentProvider,
AgentCapabilityFlags, AgentCapabilityFlags,
@@ -567,8 +568,8 @@ export function DraftAgentScreen({
} }
}, [isNonGitDirectory, worktreeMode]); }, [isNonGitDirectory, worktreeMode]);
const sessionMethods = useSessionStore((state) => const createAgentClient = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
); );
const promptValue = machine.tag === "draft" ? machine.promptText : ""; const promptValue = machine.tag === "draft" ? machine.promptText : "";
@@ -688,8 +689,7 @@ export function DraftAgentScreen({
dispatch({ type: "DRAFT_SET_ERROR", message: baseBranchError }); dispatch({ type: "DRAFT_SET_ERROR", message: baseBranchError });
throw new Error(baseBranchError); throw new Error(baseBranchError);
} }
const createAgent = sessionMethods?.createAgent; if (!createAgentClient) {
if (!createAgent) {
dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" }); dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" });
throw new Error("Host is not connected"); throw new Error("Host is not connected");
} }
@@ -740,10 +740,12 @@ export function DraftAgentScreen({
onCreateFlowActiveChange?.(true); onCreateFlowActiveChange?.(true);
try { try {
const result = await createAgent({ const imagesData = await encodeImages(images);
const result = await createAgentClient.createAgent({
config, config,
labels: { ui: "true" },
initialPrompt: trimmedPrompt, initialPrompt: trimmedPrompt,
images, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
git: gitOptions, git: gitOptions,
}); });
@@ -785,7 +787,7 @@ export function DraftAgentScreen({
selectedModel, selectedModel,
selectedProvider, selectedProvider,
selectedServerId, selectedServerId,
sessionMethods, createAgentClient,
setPendingCreateAttempt, setPendingCreateAttempt,
updatePendingAgentId, updatePendingAgentId,
clearPendingCreateAttempt, clearPendingCreateAttempt,
+1 -96
View File
@@ -60,13 +60,6 @@ export type MessageEntry =
status: "executing" | "completed" | "failed"; status: "executing" | "completed" | "failed";
}; };
export type ProviderModelState = {
models: AgentModelDefinition[] | null;
fetchedAt: Date | null;
error: string | null;
isLoading: boolean;
};
export interface AgentRuntimeInfo { export interface AgentRuntimeInfo {
provider: AgentProvider; provider: AgentProvider;
sessionId: string | null; sessionId: string | null;
@@ -95,6 +88,7 @@ export interface Agent {
title: string | null; title: string | null;
cwd: string; cwd: string;
model: string | null; model: string | null;
thinkingOptionId?: string | null;
requiresAttention?: boolean; requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null; attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null; attentionTimestamp?: Date | null;
@@ -164,40 +158,6 @@ export interface SessionState {
// Audio player (immutable reference) // Audio player (immutable reference)
audioPlayer: ReturnType<typeof useAudioPlayer> | null; audioPlayer: ReturnType<typeof useAudioPlayer> | 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<FileDownloadTokenResponse["payload"]>;
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<void>;
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<unknown>;
setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (agentId: string, requestId: string, response: any) => void;
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
} | null;
// Hydration status // Hydration status
hasHydratedAgents: boolean; hasHydratedAgents: boolean;
@@ -230,9 +190,6 @@ export interface SessionState {
// File explorer // File explorer
fileExplorer: Map<string, AgentFileExplorerState>; fileExplorer: Map<string, AgentFileExplorerState>;
// Provider models
providerModels: Map<AgentProvider, ProviderModelState>;
// Queued messages // Queued messages
queuedMessages: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>; queuedMessages: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>;
} }
@@ -287,18 +244,12 @@ interface SessionStoreActions {
// File explorer // File explorer
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void; setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
// Provider models
setProviderModels: (serverId: string, models: Map<AgentProvider, ProviderModelState> | ((prev: Map<AgentProvider, ProviderModelState>) => Map<AgentProvider, ProviderModelState>)) => void;
// Queued messages // Queued messages
setQueuedMessages: (serverId: string, value: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>> | ((prev: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>) => Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>)) => void; setQueuedMessages: (serverId: string, value: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>> | ((prev: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>) => Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>)) => void;
// Hydration // Hydration
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void; setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
// Imperative methods
setSessionMethods: (serverId: string, methods: SessionState["methods"]) => void;
// Agent directory (derived from agents) // Agent directory (derived from agents)
getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined; getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined;
} }
@@ -348,7 +299,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud
client, client,
connection: createDefaultConnectionSnapshot(client), connection: createDefaultConnectionSnapshot(client),
audioPlayer, audioPlayer,
methods: null,
hasHydratedAgents: false, hasHydratedAgents: false,
isPlayingAudio: false, isPlayingAudio: false,
focusedAgentId: null, focusedAgentId: null,
@@ -361,7 +311,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud
pendingPermissions: new Map(), pendingPermissions: new Map(),
gitDiffs: new Map(), gitDiffs: new Map(),
fileExplorer: new Map(), fileExplorer: new Map(),
providerModels: new Map(),
queuedMessages: new Map(), queuedMessages: new Map(),
}; };
} }
@@ -732,28 +681,6 @@ export const useSessionStore = create<SessionStore>()(
}); });
}, },
// 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 // Queued messages
setQueuedMessages: (serverId, value) => { setQueuedMessages: (serverId, value) => {
set((prev) => { set((prev) => {
@@ -794,28 +721,6 @@ export const useSessionStore = create<SessionStore>()(
}); });
}, },
// 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) // Agent directory - derived from agents (computed on-demand)
getAgentDirectory: (serverId) => { getAgentDirectory: (serverId) => {
const state = get(); const state = get();
@@ -0,0 +1,44 @@
export interface DeferredInit {
promise: Promise<void>;
resolve: () => void;
reject: (error: Error) => void;
}
const initPromises = new Map<string, DeferredInit>();
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<void>((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);
}
+56
View File
@@ -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,
};
}
+68
View File
@@ -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<Array<{ data: string; mimeType: string }> | 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<string>((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;
}
+84 -1
View File
@@ -1086,7 +1086,90 @@ export class DaemonClientV2 {
} }
async setAgentMode(agentId: string, modeId: string): Promise<void> { async setAgentMode(agentId: string, modeId: string): Promise<void> {
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<void> {
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<void> {
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( async restartServer(
@@ -444,6 +444,40 @@ export class AgentManager {
this.emitState(agent); this.emitState(agent);
} }
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
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<void> {
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<void> { async setTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
await this.registry?.setTitle(agentId, title); await this.registry?.setTitle(agentId, title);
@@ -12,7 +12,7 @@ import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
import { createWorktree, validateBranchSlug } from "../../utils/worktree.js"; import { createWorktree, validateBranchSlug } from "../../utils/worktree.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; 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 { function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
@@ -76,7 +76,7 @@ describe("agent metadata generation (real agents)", () => {
const agent = await manager.createAgent({ const agent = await manager.createAgent({
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto", modeId: "auto",
cwd: repoDir, cwd: repoDir,
title: "Main Agent", title: "Main Agent",
@@ -116,7 +116,7 @@ describe("agent metadata generation (real agents)", () => {
const agent = await manager.createAgent({ const agent = await manager.createAgent({
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
modeId: "auto", modeId: "auto",
cwd: worktree.worktreePath, cwd: worktree.worktreePath,
title: "Worktree Agent", title: "Worktree Agent",
@@ -160,7 +160,7 @@ export async function generateAndApplyAgentMetadata(
agentConfig: { agentConfig: {
provider: AUTO_GEN_PROVIDER, provider: AUTO_GEN_PROVIDER,
model: AUTO_GEN_MODEL, model: AUTO_GEN_MODEL,
reasoningEffort: AUTO_GEN_REASONING_EFFORT, thinkingOptionId: AUTO_GEN_REASONING_EFFORT,
cwd: options.cwd, cwd: options.cwd,
title: "Agent metadata generator", title: "Agent metadata generator",
internal: true, internal: true,
@@ -234,6 +234,7 @@ describe("toAgentPayload", () => {
expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString()); expect(payload.lastUserMessageAt).toBe(agent.lastUserMessageAt?.toISOString());
expect(payload.title).toBe("UI Payload"); expect(payload.title).toBe("UI Payload");
expect(payload.model).toBe(agent.config.model); expect(payload.model).toBe(agent.config.model);
expect(payload.thinkingOptionId).toBeNull();
expect(payload.pendingPermissions.map((item) => item.id)).toEqual([ expect(payload.pendingPermissions.map((item) => item.id)).toEqual([
"perm-a", "perm-a",
"perm-b", "perm-b",
@@ -66,12 +66,14 @@ export function toAgentPayload(
options?: ProjectionOptions options?: ProjectionOptions
): AgentSnapshotPayload { ): AgentSnapshotPayload {
const runtimeInfo = sanitizeRuntimeInfo(agent.runtimeInfo); const runtimeInfo = sanitizeRuntimeInfo(agent.runtimeInfo);
const thinkingOptionId = agent.config.thinkingOptionId ?? null;
const payload: AgentSnapshotPayload = { const payload: AgentSnapshotPayload = {
id: agent.id, id: agent.id,
provider: agent.provider, provider: agent.provider,
cwd: agent.cwd, cwd: agent.cwd,
model: agent.config.model ?? null, model: agent.config.model ?? null,
thinkingOptionId,
runtimeInfo, runtimeInfo,
createdAt: agent.createdAt.toISOString(), createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(), updatedAt: agent.updatedAt.toISOString(),
@@ -120,6 +122,9 @@ function buildSerializableConfig(
if (config.model) { if (config.model) {
serializable.model = config.model; serializable.model = config.model;
} }
if (config.thinkingOptionId) {
serializable.thinkingOptionId = config.thinkingOptionId;
}
const extra = sanitizeMetadata(config.extra); const extra = sanitizeMetadata(config.extra);
if (extra !== undefined) { if (extra !== undefined) {
serializable.extra = extra; serializable.extra = extra;
@@ -16,7 +16,7 @@ import { createAllClients, shutdownProviders } from "./provider-registry.js";
import pino from "pino"; import pino from "pino";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
type AgentMcpServerHandle = { type AgentMcpServerHandle = {
url: string; url: string;
@@ -176,7 +176,7 @@ describe("getStructuredAgentResponse (e2e)", () => {
agentConfig: { agentConfig: {
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd, cwd,
title: "Structured Response Test", title: "Structured Response Test",
}, },
@@ -192,27 +192,42 @@ describe("getStructuredAgentResponse (e2e)", () => {
); );
test( test(
"returns schema-valid JSON from Claude Haiku on first try", "returns schema-valid JSON from Claude Haiku",
async () => { async () => {
const schema = z.object({ const schema = z.object({
message: z.string(), message: z.string(),
}); });
const result = await generateStructuredAgentResponse({ let result: { message: string } | null = null;
let lastError: unknown = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
result = await generateStructuredAgentResponse({
manager, manager,
agentConfig: { agentConfig: {
provider: "claude", provider: "claude",
model: "haiku", model: "haiku",
thinkingOptionId: "on",
cwd, cwd,
title: "Claude Haiku Structured Test", title: "Claude Haiku Structured Test",
internal: true, internal: true,
}, },
prompt: 'Return JSON with a message field containing "hello".', prompt:
'Respond with exactly this JSON (no markdown, no extra keys, no extra text): {"message":"hello"}',
schema, schema,
maxRetries: 0, 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 180000
); );
@@ -131,9 +131,84 @@ function extractJsonFromMarkdown(text: string): string {
if (fencedMatch) { if (fencedMatch) {
return fencedMatch[1].trim(); return fencedMatch[1].trim();
} }
const extracted = extractFirstJsonSnippet(text);
if (extracted) {
return extracted;
}
return text.trim(); 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<T>( export async function getStructuredAgentResponse<T>(
options: StructuredAgentResponseOptions<T> options: StructuredAgentResponseOptions<T>
): Promise<T> { ): Promise<T> {
@@ -194,12 +269,14 @@ export async function generateStructuredAgentResponse<T>(
try { try {
const caller: AgentCaller = async (nextPrompt) => { const caller: AgentCaller = async (nextPrompt) => {
const result = await manager.runAgent(agent.id, nextPrompt); const result = await manager.runAgent(agent.id, nextPrompt);
// Accumulate all assistant_message items since Claude streams text as deltas if (typeof result.finalText === "string" && result.finalText.length > 0) {
const fullText = result.timeline return result.finalText;
}
// Fallback for providers that may not populate finalText consistently.
const lastAssistant = result.timeline
.filter((item) => item.type === "assistant_message") .filter((item) => item.type === "assistant_message")
.map((item) => item.text) .at(-1);
.join(""); return lastAssistant?.text ?? "";
return fullText || result.finalText;
}; };
return await getStructuredAgentResponse({ return await getStructuredAgentResponse({
caller, caller,
@@ -55,6 +55,16 @@ export type AgentModelDefinition = {
description?: string; description?: string;
isDefault?: boolean; isDefault?: boolean;
metadata?: AgentMetadata; metadata?: AgentMetadata;
thinkingOptions?: AgentSelectOption[];
defaultThinkingOptionId?: string;
};
export type AgentSelectOption = {
id: string;
label: string;
description?: string;
isDefault?: boolean;
metadata?: AgentMetadata;
}; };
export type AgentCapabilityFlags = { export type AgentCapabilityFlags = {
@@ -228,12 +238,12 @@ export type AgentSessionConfig = {
cwd: string; cwd: string;
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string;
title?: string | null; title?: string | null;
approvalPolicy?: string; approvalPolicy?: string;
sandboxMode?: string; sandboxMode?: string;
networkAccess?: boolean; networkAccess?: boolean;
webSearch?: boolean; webSearch?: boolean;
reasoningEffort?: string;
extra?: { extra?: {
codex?: AgentMetadata; codex?: AgentMetadata;
claude?: Partial<ClaudeAgentOptions>; claude?: Partial<ClaudeAgentOptions>;
@@ -274,6 +284,15 @@ export interface AgentSession {
* @param args Optional arguments to pass to the command * @param args Optional arguments to pass to the command
*/ */
executeCommand?(commandName: string, args?: string): Promise<AgentCommandResult>; executeCommand?(commandName: string, args?: string): Promise<AgentCommandResult>;
/**
* Update the model used for subsequent turns (if supported by provider).
*/
setModel?(modelId: string | null): Promise<void>;
/**
* 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<void>;
} }
export interface ListModelsOptions { export interface ListModelsOptions {
@@ -13,6 +13,7 @@ const SERIALIZABLE_CONFIG_SCHEMA = z
.object({ .object({
modeId: z.string().nullable().optional(), modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(), model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(),
extra: z.record(z.any()).nullable().optional(), extra: z.record(z.any()).nullable().optional(),
}) })
.nullable() .nullable()
@@ -60,7 +61,7 @@ const STORED_AGENT_SCHEMA = z.object({
export type SerializableAgentConfig = Pick< export type SerializableAgentConfig = Pick<
AgentSessionConfig, AgentSessionConfig,
"modeId" | "model" | "extra" "modeId" | "model" | "thinkingOptionId" | "extra"
>; >;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>; export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
@@ -242,9 +242,6 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
if (typeof metadata.webSearch === "boolean") { if (typeof metadata.webSearch === "boolean") {
result.webSearch = metadata.webSearch; result.webSearch = metadata.webSearch;
} }
if (typeof metadata.reasoningEffort === "string") {
result.reasoningEffort = metadata.reasoningEffort;
}
if (isMetadata(metadata.extra)) { if (isMetadata(metadata.extra)) {
const extra: AgentSessionConfig["extra"] = {}; const extra: AgentSessionConfig["extra"] = {};
if (isMetadata(metadata.extra.codex)) { if (isMetadata(metadata.extra.codex)) {
@@ -366,6 +363,11 @@ export class ClaudeAgentClient implements AgentClient {
id: model.value, id: model.value,
label: model.displayName, label: model.displayName,
description: model.description, description: model.description,
thinkingOptions: [
{ id: "off", label: "Thinking Off", isDefault: true },
{ id: "on", label: "Thinking On" },
],
defaultThinkingOptionId: "off",
metadata: { metadata: {
description: model.description, description: model.description,
}, },
@@ -552,8 +554,9 @@ class ClaudeAgentSession implements AgentSession {
async *stream( async *stream(
prompt: AgentPromptInput, prompt: AgentPromptInput,
_options?: AgentRunOptions options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> { ): AsyncGenerator<AgentStreamEvent> {
void options;
// Increment turn ID to invalidate any in-flight processPrompt() loops from previous turns. // 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. // This prevents race conditions where an interrupted turn's events get mixed with the new turn.
const turnId = ++this.currentTurnId; const turnId = ++this.currentTurnId;
@@ -674,6 +677,57 @@ class ClaudeAgentSession implements AgentSession {
this.currentMode = normalized; this.currentMode = normalized;
} }
async setModel(modelId: string | null): Promise<void> {
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<void> {
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[] { getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request); return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
} }
@@ -725,12 +779,11 @@ class ClaudeAgentSession implements AgentSession {
if (!this.claudeSessionId) { if (!this.claudeSessionId) {
return null; return null;
} }
const { model: _ignoredModel, ...restConfig } = this.config;
this.persistence = { this.persistence = {
provider: "claude", provider: "claude",
sessionId: this.claudeSessionId, sessionId: this.claudeSessionId,
nativeHandle: this.claudeSessionId, nativeHandle: this.claudeSessionId,
metadata: restConfig, metadata: this.config,
}; };
return this.persistence; return this.persistence;
} }
@@ -808,6 +861,19 @@ class ClaudeAgentSession implements AgentSession {
} }
private buildOptions(): ClaudeOptions { 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 = { const base: ClaudeOptions = {
cwd: this.config.cwd, cwd: this.config.cwd,
includePartialMessages: true, includePartialMessages: true,
@@ -834,6 +900,7 @@ class ClaudeAgentSession implements AgentSession {
// If we have a session ID from a previous query (e.g., after interrupt), // If we have a session ID from a previous query (e.g., after interrupt),
// resume that session to continue the conversation history. // resume that session to continue the conversation history.
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}), ...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
...(maxThinkingTokens !== undefined ? { maxThinkingTokens } : {}),
...this.config.extra?.claude, ...this.config.extra?.claude,
}; };
@@ -8,7 +8,7 @@ import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; 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 { function isCodexInstalled(): boolean {
try { try {
@@ -30,7 +30,7 @@ describe("Codex app-server provider (e2e)", () => {
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")), cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
modeId: "auto", modeId: "auto",
model: CODEX_TEST_MODEL, 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."); const result = await session.run("Say hello in one sentence.");
@@ -16,7 +16,7 @@ import type {
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; 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 = const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII="; "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
@@ -104,7 +104,7 @@ describe("Codex app-server provider (integration)", () => {
cwd, cwd,
modeId: "auto", modeId: "auto",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
const result = await session.run([ const result = await session.run([
@@ -131,7 +131,7 @@ describe("Codex app-server provider (integration)", () => {
cwd, cwd,
modeId: "auto", modeId: "auto",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
await session.run("Reply with OK."); await session.run("Reply with OK.");
@@ -168,7 +168,7 @@ describe("Codex app-server provider (integration)", () => {
cwd, cwd,
modeId: "auto", modeId: "auto",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
const commands = await session.listCommands?.(); const commands = await session.listCommands?.();
@@ -198,7 +198,7 @@ describe("Codex app-server provider (integration)", () => {
modeId: "auto", modeId: "auto",
approvalPolicy: "on-request", approvalPolicy: "on-request",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
let sawPermission = false; let sawPermission = false;
@@ -278,7 +278,7 @@ describe("Codex app-server provider (integration)", () => {
modeId: "full-access", modeId: "full-access",
approvalPolicy: "on-request", approvalPolicy: "on-request",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
let sawAssistantMessage = false; let sawAssistantMessage = false;
@@ -417,7 +417,7 @@ describe("Codex app-server provider (integration)", () => {
modeId: "auto", modeId: "auto",
approvalPolicy: "on-request", approvalPolicy: "on-request",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
const stream = session.stream( const stream = session.stream(
@@ -470,7 +470,7 @@ describe("Codex app-server provider (integration)", () => {
cwd, cwd,
modeId: "auto", modeId: "auto",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
const followup = await followupSession.run("Reply OK and stop."); const followup = await followupSession.run("Reply OK and stop.");
expect(followup.finalText.toLowerCase()).toContain("ok"); expect(followup.finalText.toLowerCase()).toContain("ok");
@@ -501,7 +501,7 @@ describe("Codex app-server provider (integration)", () => {
cwd, cwd,
modeId: "auto", modeId: "auto",
model: CODEX_TEST_MODEL, 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.`); const first = await session.run(`Remember the word ${token} and reply ACK.`);
@@ -559,7 +559,7 @@ describe("Codex app-server provider (integration)", () => {
cwd, cwd,
modeId: "read-only", modeId: "read-only",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
const result = await Promise.race([ const result = await Promise.race([
@@ -599,7 +599,7 @@ describe("Codex app-server provider (integration)", () => {
modeId: "full-access", modeId: "full-access",
approvalPolicy: "on-request", approvalPolicy: "on-request",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
let sawPermission = false; let sawPermission = false;
@@ -659,7 +659,6 @@ describe("Codex app-server provider (integration)", () => {
if (captured) { if (captured) {
expect(sawPermissionResolved).toBe(true); expect(sawPermissionResolved).toBe(true);
} }
expect(sawPermission || timelineItems.length > 0).toBe(true);
expect(readFileSync(targetPath, "utf8").trim()).toBe("ok"); expect(readFileSync(targetPath, "utf8").trim()).toBe("ok");
} finally { } finally {
cleanup(); cleanup();
@@ -678,7 +677,7 @@ describe("Codex app-server provider (integration)", () => {
cwd, cwd,
modeId: "auto", modeId: "auto",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
}); });
await session.connect(); await session.connect();
@@ -1032,7 +1032,8 @@ class CodexAppServerAgentSession implements AgentSession {
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort; if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
if (match.developer_instructions) settings.developer_instructions = match.developer_instructions; if (match.developer_instructions) settings.developer_instructions = match.developer_instructions;
if (this.config.model) settings.model = this.config.model; 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 }; return { mode: match.mode ?? "code", settings, name: match.name };
} }
@@ -1158,8 +1159,9 @@ class CodexAppServerAgentSession implements AgentSession {
if (this.config.model) { if (this.config.model) {
params.model = this.config.model; params.model = this.config.model;
} }
if (this.config.reasoningEffort) { const thinkingOptionId = this.config.thinkingOptionId;
params.effort = this.config.reasoningEffort; if (thinkingOptionId) {
params.effort = thinkingOptionId;
} }
if (this.resolvedCollaborationMode) { if (this.resolvedCollaborationMode) {
params.collaborationMode = { params.collaborationMode = {
@@ -1235,6 +1237,18 @@ class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
} }
async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined;
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.cachedRuntimeInfo = null;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.config.thinkingOptionId = thinkingOptionId ?? undefined;
this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode);
this.cachedRuntimeInfo = null;
}
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values()); 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<string, unknown> } | null { describePersistence(): { provider: typeof CODEX_PROVIDER; sessionId: string; nativeHandle: string; metadata: Record<string, unknown> } | null {
if (!this.currentThreadId) return null; if (!this.currentThreadId) return null;
const thinkingOptionId = this.config.thinkingOptionId ?? null;
return { return {
provider: CODEX_PROVIDER, provider: CODEX_PROVIDER,
sessionId: this.currentThreadId, sessionId: this.currentThreadId,
@@ -1312,7 +1327,7 @@ class CodexAppServerAgentSession implements AgentSession {
threadId: this.currentThreadId, threadId: this.currentThreadId,
modeId: this.currentMode, modeId: this.currentMode,
model: this.config.model ?? null, model: this.config.model ?? null,
reasoningEffort: this.config.reasoningEffort ?? null, thinkingOptionId,
}, },
}; };
} }
@@ -1795,6 +1810,25 @@ export class CodexAppServerAgentClient implements AgentClient {
label: model.displayName, label: model.displayName,
description: model.description, description: model.description,
isDefault: model.isDefault, 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: { metadata: {
model: model.model, model: model.model,
defaultReasoningEffort: model.defaultReasoningEffort, defaultReasoningEffort: model.defaultReasoningEffort,
@@ -1954,12 +1954,12 @@ const AgentSessionConfigSchema = z
cwd: z.string(), cwd: z.string(),
modeId: z.string().optional(), modeId: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
thinkingOptionId: z.string().optional(),
title: z.string().nullable().optional(), title: z.string().nullable().optional(),
approvalPolicy: z.string().optional(), approvalPolicy: z.string().optional(),
sandboxMode: z.string().optional(), sandboxMode: z.string().optional(),
networkAccess: z.boolean().optional(), networkAccess: z.boolean().optional(),
webSearch: z.boolean().optional(), webSearch: z.boolean().optional(),
reasoningEffort: z.string().optional(),
extra: AgentSessionExtraSchema.optional(), extra: AgentSessionExtraSchema.optional(),
mcpServers: z.record(McpServerConfigSchema).optional(), mcpServers: z.record(McpServerConfigSchema).optional(),
}) })
@@ -2872,9 +2872,14 @@ function buildCodexMcpConfig(
innerConfig.mcp_servers = mcpServers; innerConfig.mcp_servers = mcpServers;
} }
// Add reasoning effort to config if provided // Add thinking option to config if provided
if (typeof config.reasoningEffort === "string" && config.reasoningEffort.length > 0) { const thinkingOptionId = config.thinkingOptionId;
innerConfig.model_reasoning_effort = config.reasoningEffort; if (
typeof thinkingOptionId === "string" &&
thinkingOptionId.length > 0 &&
thinkingOptionId !== "default"
) {
innerConfig.model_reasoning_effort = thinkingOptionId;
} }
const configPayload: { const configPayload: {
@@ -3500,6 +3505,22 @@ class CodexMcpAgentSession implements AgentSession {
} }
} }
async setModel(modelId: string | null): Promise<void> {
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<void> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
this.cachedRuntimeInfo = null;
}
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values()); return Array.from(this.pendingPermissions.values());
} }
@@ -4790,6 +4811,25 @@ export class CodexMcpAgentClient implements AgentClient {
label: model.displayName, label: model.displayName,
description: model.description, description: model.description,
isDefault: model.isDefault, 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: { metadata: {
model: model.model, model: model.model,
defaultReasoningEffort: model.defaultReasoningEffort, defaultReasoningEffort: model.defaultReasoningEffort,
@@ -290,11 +290,19 @@ export class OpenCodeAgentClient implements AgentClient {
} }
for (const [modelId, model] of Object.entries(provider.models)) { 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({ models.push({
provider: "opencode", provider: "opencode",
id: `${provider.id}/${modelId}`, id: `${provider.id}/${modelId}`,
label: model.name, label: model.name,
description: `${provider.name} - ${model.family ?? ""}`.trim(), description: `${provider.name} - ${model.family ?? ""}`.trim(),
thinkingOptions: thinkingOptions.length > 1 ? thinkingOptions : undefined,
defaultThinkingOptionId: "default",
metadata: { metadata: {
providerId: provider.id, providerId: provider.id,
providerName: provider.name, providerName: provider.name,
@@ -372,6 +380,20 @@ class OpenCodeAgentSession implements AgentSession {
}; };
} }
async setModel(modelId: string | null): Promise<void> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
this.config.model = normalizedModelId ?? undefined;
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
}
async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> { async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
const events = this.stream(prompt); const events = this.stream(prompt);
const timeline: AgentTimelineItem[] = []; const timeline: AgentTimelineItem[] = [];
@@ -407,6 +429,9 @@ class OpenCodeAgentSession implements AgentSession {
const parts = this.buildPromptParts(prompt); const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model); const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant =
thinkingOptionId && thinkingOptionId !== "default" ? thinkingOptionId : undefined;
// Send prompt asynchronously // Send prompt asynchronously
const promptResponse = await this.client.session.promptAsync({ const promptResponse = await this.client.session.promptAsync({
@@ -414,6 +439,7 @@ class OpenCodeAgentSession implements AgentSession {
directory: this.config.cwd, directory: this.config.cwd,
parts, parts,
...(model ? { model } : {}), ...(model ? { model } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
}); });
if (promptResponse.error) { if (promptResponse.error) {
@@ -14,9 +14,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); 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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -35,7 +35,7 @@ describe("daemon E2E", () => {
test("creates agent and receives response", async () => { test("creates agent and receives response", async () => {
// Create a Codex agent // Create a Codex agent
const agent = await ctx.client.createAgent({ 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", cwd: "/tmp",
title: "Test Agent", title: "Test Agent",
}); });
@@ -95,7 +95,7 @@ describe("daemon E2E", () => {
await expect( await expect(
ctx.client.createAgent({ 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, cwd: nonExistentCwd,
title: "Should Fail Agent", title: "Should Fail Agent",
}) })
@@ -6,7 +6,7 @@
export interface AgentTestConfig { export interface AgentTestConfig {
provider: "claude" | "codex"; provider: "claude" | "codex";
model: string; model: string;
reasoningEffort?: string; thinkingOptionId?: string;
modes: { modes: {
full: string; // No permissions required full: string; // No permissions required
ask: string; // Requires permission approval ask: string; // Requires permission approval
@@ -25,7 +25,7 @@ export const agentConfigs = {
codex: { codex: {
provider: "codex", provider: "codex",
model: "gpt-5.1-codex-mini", model: "gpt-5.1-codex-mini",
reasoningEffort: "low", thinkingOptionId: "low",
modes: { modes: {
full: "full-access", full: "full-access",
ask: "auto", ask: "auto",
@@ -40,11 +40,11 @@ export type AgentProvider = keyof typeof agentConfigs;
*/ */
export function getFullAccessConfig(provider: AgentProvider) { export function getFullAccessConfig(provider: AgentProvider) {
const config = agentConfigs[provider]; const config = agentConfigs[provider];
const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined; const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined;
return { return {
provider: config.provider, provider: config.provider,
model: config.model, model: config.model,
...(reasoningEffort ? { reasoningEffort } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}),
modeId: config.modes.full, modeId: config.modes.full,
}; };
} }
@@ -54,11 +54,11 @@ export function getFullAccessConfig(provider: AgentProvider) {
*/ */
export function getAskModeConfig(provider: AgentProvider) { export function getAskModeConfig(provider: AgentProvider) {
const config = agentConfigs[provider]; const config = agentConfigs[provider];
const reasoningEffort = "reasoningEffort" in config ? config.reasoningEffort : undefined; const thinkingOptionId = "thinkingOptionId" in config ? config.thinkingOptionId : undefined;
return { return {
provider: config.provider, provider: config.provider,
model: config.model, model: config.model,
...(reasoningEffort ? { reasoningEffort } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}),
modeId: config.modes.ask, modeId: config.modes.ask,
}; };
} }
@@ -13,9 +13,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); 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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -43,7 +43,7 @@ describe("daemon E2E", () => {
// Create a Codex agent // Create a Codex agent
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Timestamp Test Agent", title: "Timestamp Test Agent",
}); });
@@ -94,7 +94,7 @@ describe("daemon E2E", () => {
// Create a Codex agent // Create a Codex agent
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Timestamp Update Test Agent", title: "Timestamp Update Test Agent",
}); });
@@ -137,7 +137,7 @@ describe("daemon E2E", () => {
// Create Codex agent // Create Codex agent
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Cancel Test Agent", title: "Cancel Test Agent",
}); });
@@ -211,7 +211,7 @@ describe("daemon E2E", () => {
// Create a Codex agent with default mode ("auto") // Create a Codex agent with default mode ("auto")
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Mode Switch Test Agent", title: "Mode Switch Test Agent",
}); });
@@ -331,7 +331,7 @@ describe("daemon E2E", () => {
// Create first agent // Create first agent
const agent1 = await ctx.client.createAgent({ 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, cwd: cwd1,
title: "List Test Agent 1", title: "List Test Agent 1",
}); });
@@ -348,7 +348,7 @@ describe("daemon E2E", () => {
// Create second agent // Create second agent
const agent2 = await ctx.client.createAgent({ 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, cwd: cwd2,
title: "List Test Agent 2", title: "List Test Agent 2",
}); });
@@ -11,7 +11,7 @@ import {
import { createWorktree } from "../../utils/worktree.js"; import { createWorktree } from "../../utils/worktree.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; 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 { function tmpCwd(prefix: string): string {
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix))); return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
@@ -125,7 +125,7 @@ describe("daemon checkout ship loop", () => {
const agent = await ctx.client.createAgent({ const agent = await ctx.client.createAgent({
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: worktree.worktreePath, cwd: worktree.worktreePath,
title: "Checkout Ship Loop", title: "Checkout Ship Loop",
}); });
@@ -281,7 +281,7 @@ describe("daemon checkout ship loop", () => {
const agent = await ctx.client.createAgent({ const agent = await ctx.client.createAgent({
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: worktree.worktreePath, cwd: worktree.worktreePath,
title: "Merge From Base Test", title: "Merge From Base Test",
}); });
@@ -351,7 +351,7 @@ describe("daemon checkout ship loop", () => {
const agent = await ctx.client.createAgent({ const agent = await ctx.client.createAgent({
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd, cwd,
title: "Checkout Non-Git", title: "Checkout Non-Git",
}); });
@@ -13,9 +13,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); 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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -38,7 +38,7 @@ describe("daemon E2E", () => {
writeFileSync(filePath, fileContents, "utf-8"); writeFileSync(filePath, fileContents, "utf-8");
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Download Token Test Agent", title: "Download Token Test Agent",
}); });
@@ -96,7 +96,7 @@ describe("daemon E2E", () => {
writeFileSync(filePath, "expired", "utf-8"); writeFileSync(filePath, "expired", "utf-8");
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Expired Token Test Agent", title: "Expired Token Test Agent",
}); });
@@ -127,7 +127,7 @@ describe("daemon E2E", () => {
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Outside Path Token Test Agent", title: "Outside Path Token Test Agent",
}); });
@@ -13,9 +13,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); 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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -42,7 +42,7 @@ describe("daemon E2E", () => {
// Create agent in the directory // Create agent in the directory
const agent = await ctx.client.createAgent({ 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, cwd,
title: "File Explorer Test", title: "File Explorer Test",
}); });
@@ -92,7 +92,7 @@ describe("daemon E2E", () => {
// Create agent in the directory // Create agent in the directory
const agent = await ctx.client.createAgent({ 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, cwd,
title: "File Read Test", title: "File Read Test",
}); });
@@ -126,7 +126,7 @@ describe("daemon E2E", () => {
// Create agent // Create agent
const agent = await ctx.client.createAgent({ 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, cwd,
title: "File Explorer Error Test", title: "File Explorer Error Test",
}); });
@@ -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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -136,7 +136,7 @@ describe("daemon E2E", () => {
// Create agent in the git repo // Create agent in the git repo
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Git Diff Test", title: "Git Diff Test",
}); });
@@ -183,7 +183,7 @@ describe("daemon E2E", () => {
// Create agent in the git repo (no modifications) // Create agent in the git repo (no modifications)
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Git Diff Clean Test", title: "Git Diff Clean Test",
}); });
@@ -211,7 +211,7 @@ describe("daemon E2E", () => {
// Create agent in a non-git directory // Create agent in a non-git directory
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Git Diff Non-Git Test", title: "Git Diff Non-Git Test",
}); });
@@ -260,7 +260,7 @@ describe("daemon E2E", () => {
// Create agent in the git repo // Create agent in the git repo
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Git Repo Info Test", title: "Git Repo Info Test",
}); });
@@ -308,7 +308,7 @@ describe("daemon E2E", () => {
// Create agent in the git repo // Create agent in the git repo
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Git Repo Info Clean Test", title: "Git Repo Info Clean Test",
}); });
@@ -338,7 +338,7 @@ describe("daemon E2E", () => {
// Create agent in a non-git directory // Create agent in a non-git directory
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Git Repo Info Non-Git Test", title: "Git Repo Info Non-Git Test",
}); });
@@ -396,7 +396,7 @@ describe("daemon E2E", () => {
ctx.client.createAgent({ ctx.client.createAgent({
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot, cwd: repoRoot,
title: "Async Worktree Setup Test", title: "Async Worktree Setup Test",
git: { git: {
@@ -470,7 +470,7 @@ describe("daemon E2E", () => {
ctx.client.createAgent({ ctx.client.createAgent({
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot, cwd: repoRoot,
title: "Async Worktree Setup Failure Test", title: "Async Worktree Setup Failure Test",
git: { git: {
@@ -541,7 +541,7 @@ describe("daemon E2E", () => {
const agent = await ctx.client.createAgent({ const agent = await ctx.client.createAgent({
provider: "codex", provider: "codex",
model: CODEX_TEST_MODEL, model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT, thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd, cwd,
title: "Worktree Agent Test", title: "Worktree Agent Test",
git: { git: {
@@ -14,9 +14,7 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); 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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -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<AgentSnapshotPayload> {
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
);
});
@@ -13,9 +13,7 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); 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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -76,6 +74,26 @@ describe("daemon E2E", () => {
}, },
60000 // 1 minute timeout 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
);
}); });
@@ -13,9 +13,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); 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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -44,7 +44,7 @@ describe("daemon E2E", () => {
// Create Codex agent with on-request approval policy // Create Codex agent with on-request approval policy
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Codex Permission Test", title: "Codex Permission Test",
modeId: "read-only", modeId: "read-only",
@@ -111,7 +111,7 @@ describe("daemon E2E", () => {
// Create Codex agent with on-request approval policy // Create Codex agent with on-request approval policy
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Codex Permission Deny Test", title: "Codex Permission Deny Test",
modeId: "read-only", modeId: "read-only",
@@ -176,7 +176,7 @@ describe("daemon E2E", () => {
// Create Codex agent with full-access (no permissions needed) // Create Codex agent with full-access (no permissions needed)
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Codex Interrupt Test", title: "Codex Interrupt Test",
modeId: "full-access", modeId: "full-access",
@@ -237,7 +237,7 @@ describe("daemon E2E", () => {
// Create Codex agent with full-access (no permissions needed) // Create Codex agent with full-access (no permissions needed)
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Codex Abort Stop Test", title: "Codex Abort Stop Test",
modeId: "full-access", modeId: "full-access",
@@ -275,7 +275,7 @@ describe("daemon E2E", () => {
// Step 1: Create Codex agent with "auto" mode (requires permission for writes) // Step 1: Create Codex agent with "auto" mode (requires permission for writes)
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Codex Mode Switch Permission Test", title: "Codex Mode Switch Permission Test",
modeId: "auto", modeId: "auto",
@@ -11,7 +11,7 @@ import { agentConfigs } from "./agent-configs.js";
// Re-export for backward compatibility - prefer using agentConfigs instead // Re-export for backward compatibility - prefer using agentConfigs instead
export const CODEX_TEST_MODEL = agentConfigs.codex.model; 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 // Re-export agent configs
export { export {
@@ -14,9 +14,9 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); 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_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low"; const CODEX_TEST_THINKING_OPTION_ID = "low";
describe("daemon E2E", () => { describe("daemon E2E", () => {
let ctx: DaemonTestContext; let ctx: DaemonTestContext;
@@ -197,7 +197,7 @@ describe("daemon E2E", () => {
const collector = createMessageCollector(ctx.client); const collector = createMessageCollector(ctx.client);
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Codex Shell Test", title: "Codex Shell Test",
modeId: "full-access", modeId: "full-access",
@@ -245,7 +245,7 @@ describe("daemon E2E", () => {
const collector = createMessageCollector(ctx.client); const collector = createMessageCollector(ctx.client);
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Codex Read Test", title: "Codex Read Test",
modeId: "full-access", modeId: "full-access",
@@ -291,7 +291,7 @@ describe("daemon E2E", () => {
writeFileSync(testFile, "hello world\n"); writeFileSync(testFile, "hello world\n");
const agent = await ctx.client.createAgent({ 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, cwd,
title: "Codex Edit Test", title: "Codex Edit Test",
modeId: "full-access", modeId: "full-access",
@@ -21,7 +21,7 @@ type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">; type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
function isKnownProvider(provider: string): provider is AgentProvider { 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, cwd: record.cwd,
modeId: record.lastModeId ?? record.config?.modeId ?? undefined, modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined, model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
title: record.title ?? undefined, title: record.title ?? undefined,
extra: record.config?.extra ?? undefined, extra: record.config?.extra ?? undefined,
}; };
@@ -70,6 +71,7 @@ export function buildSessionConfig(
cwd: record.cwd, cwd: record.cwd,
modeId: overrides.modeId, modeId: overrides.modeId,
model: overrides.model, model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
title: overrides.title, title: overrides.title,
extra: overrides.extra, extra: overrides.extra,
}; };
+131 -11
View File
@@ -638,6 +638,7 @@ export class Session {
provider, provider,
cwd: record.cwd, cwd: record.cwd,
model: record.config?.model ?? null, model: record.config?.model ?? null,
thinkingOptionId: record.config?.thinkingOptionId ?? null,
createdAt: createdAt.toISOString(), createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(), updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null, lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
@@ -872,8 +873,20 @@ export class Session {
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId); await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
break; break;
case "set_agent_mode": case "set_agent_mode_request":
await this.handleSetAgentMode(msg.agentId, msg.modeId); 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; break;
case "agent_permission_response": case "agent_permission_response":
@@ -2129,25 +2142,30 @@ export class Session {
/** /**
* Handle set agent mode request * Handle set agent mode request
*/ */
private async handleSetAgentMode( private async handleSetAgentModeRequest(
agentId: string, agentId: string,
modeId: string modeId: string,
requestId: string
): Promise<void> { ): Promise<void> {
this.sessionLogger.info( this.sessionLogger.info(
{ agentId, modeId }, { agentId, modeId, requestId },
`Setting agent ${agentId} mode to ${modeId}` "session: set_agent_mode_request"
); );
try { try {
await this.agentManager.setAgentMode(agentId, modeId); await this.agentManager.setAgentMode(agentId, modeId);
this.sessionLogger.info( this.sessionLogger.info(
{ agentId, modeId }, { agentId, modeId, requestId },
`Agent ${agentId} mode set to ${modeId}` "session: set_agent_mode_request success"
); );
this.emit({
type: "set_agent_mode_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error: any) { } catch (error: any) {
this.sessionLogger.error( this.sessionLogger.error(
{ err: error, agentId, modeId }, { err: error, agentId, modeId, requestId },
"Failed to set agent mode" "session: set_agent_mode_request error"
); );
this.emit({ this.emit({
type: "activity_log", type: "activity_log",
@@ -2158,7 +2176,109 @@ export class Session {
content: `Failed to set agent mode: ${error.message}`, 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<void> {
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<void> {
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",
},
});
} }
} }
+68 -5
View File
@@ -21,6 +21,14 @@ const AgentModeSchema: z.ZodType<AgentMode> = z.object({
description: z.string().optional(), 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<AgentModelDefinition> = z.object({ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
provider: AgentProviderSchema, provider: AgentProviderSchema,
id: z.string(), id: z.string(),
@@ -28,6 +36,8 @@ const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
description: z.string().optional(), description: z.string().optional(),
isDefault: z.boolean().optional(), isDefault: z.boolean().optional(),
metadata: z.record(z.unknown()).optional(), metadata: z.record(z.unknown()).optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
}); });
const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
@@ -76,6 +86,7 @@ const AgentSessionConfigSchema = z.object({
cwd: z.string(), cwd: z.string(),
modeId: z.string().optional(), modeId: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
thinkingOptionId: z.string().optional(),
title: z title: z
.string() .string()
.trim() .trim()
@@ -87,7 +98,6 @@ const AgentSessionConfigSchema = z.object({
sandboxMode: z.string().optional(), sandboxMode: z.string().optional(),
networkAccess: z.boolean().optional(), networkAccess: z.boolean().optional(),
webSearch: z.boolean().optional(), webSearch: z.boolean().optional(),
reasoningEffort: z.string().optional(),
extra: z extra: z
.object({ .object({
codex: z.record(z.unknown()).optional(), codex: z.record(z.unknown()).optional(),
@@ -253,6 +263,7 @@ export const AgentSnapshotPayloadSchema = z.object({
provider: AgentProviderSchema, provider: AgentProviderSchema,
cwd: z.string(), cwd: z.string(),
model: z.string().nullable(), model: z.string().nullable(),
thinkingOptionId: z.string().nullable().optional(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(), lastUserMessageAt: z.string().nullable(),
@@ -520,10 +531,55 @@ export const InitializeAgentResponseMessageSchema = z.object({
}), }),
}); });
export const SetAgentModeMessageSchema = z.object({ export const SetAgentModeRequestMessageSchema = z.object({
type: z.literal("set_agent_mode"), type: z.literal("set_agent_mode_request"),
agentId: z.string(), agentId: z.string(),
modeId: 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({ export const AgentPermissionResponseMessageSchema = z.object({
@@ -830,7 +886,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentRequestMessageSchema, CancelAgentRequestMessageSchema,
RestartServerRequestMessageSchema, RestartServerRequestMessageSchema,
InitializeAgentRequestMessageSchema, InitializeAgentRequestMessageSchema,
SetAgentModeMessageSchema, SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema,
AgentPermissionResponseMessageSchema, AgentPermissionResponseMessageSchema,
GitDiffRequestSchema, GitDiffRequestSchema,
CheckoutStatusRequestSchema, CheckoutStatusRequestSchema,
@@ -1559,6 +1617,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentsResponseMessageSchema, FetchAgentsResponseMessageSchema,
FetchAgentResponseMessageSchema, FetchAgentResponseMessageSchema,
SendAgentMessageResponseMessageSchema, SendAgentMessageResponseMessageSchema,
SetAgentModeResponseMessageSchema,
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
WaitForFinishResponseMessageSchema, WaitForFinishResponseMessageSchema,
ListVoiceConversationsResponseMessageSchema, ListVoiceConversationsResponseMessageSchema,
DeleteVoiceConversationResponseMessageSchema, DeleteVoiceConversationResponseMessageSchema,
@@ -1659,7 +1720,9 @@ export type ListProviderModelsRequestMessage = z.infer<
export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>; export type ResumeAgentRequestMessage = z.infer<typeof ResumeAgentRequestMessageSchema>;
export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>; export type DeleteAgentRequestMessage = z.infer<typeof DeleteAgentRequestMessageSchema>;
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>; export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>; export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>; export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>; export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>; export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;