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