Fix provider refresh scope and self-healing
This commit is contained in:
@@ -92,7 +92,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
selectedProvider: AgentProvider;
|
selectedProvider: AgentProvider | null;
|
||||||
onSelectProvider: (provider: AgentProvider) => void;
|
onSelectProvider: (provider: AgentProvider) => void;
|
||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
@@ -255,7 +255,8 @@ function ControlledStatusBar({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||||
const ProviderIcon = getProviderIcon(provider);
|
const hasSelectedProvider = provider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
|
||||||
|
|
||||||
const hasAnyControl =
|
const hasAnyControl =
|
||||||
Boolean(providerOptions?.length) ||
|
Boolean(providerOptions?.length) ||
|
||||||
@@ -620,7 +621,9 @@ function ControlledStatusBar({
|
|||||||
accessibilityLabel="Agent preferences"
|
accessibilityLabel="Agent preferences"
|
||||||
testID="agent-preferences-button"
|
testID="agent-preferences-button"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
||||||
{displayModel}
|
{displayModel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -662,7 +665,12 @@ function ControlledStatusBar({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
testID="agent-preferences-model"
|
testID="agent-preferences-model"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
@@ -1096,6 +1104,7 @@ export function DraftAgentStatusBar({
|
|||||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||||
|
const hasSelectedProvider = selectedProvider !== null;
|
||||||
|
|
||||||
if (platformIsWeb) {
|
if (platformIsWeb) {
|
||||||
return (
|
return (
|
||||||
@@ -1103,7 +1112,7 @@ export function DraftAgentStatusBar({
|
|||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider ?? ""}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
@@ -1119,20 +1128,22 @@ export function DraftAgentStatusBar({
|
|||||||
onOpen={onModelSelectorOpen}
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
{selectedProvider ? (
|
||||||
provider={selectedProvider}
|
<ControlledStatusBar
|
||||||
providerDefinitions={providerDefinitions}
|
provider={selectedProvider}
|
||||||
modeOptions={mappedModeOptions}
|
providerDefinitions={providerDefinitions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
modeOptions={mappedModeOptions}
|
||||||
onSelectMode={onSelectMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
onSelectMode={onSelectMode}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
features={features}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
onSetFeature={onSetFeature}
|
features={features}
|
||||||
onDropdownClose={onDropdownClose}
|
onSetFeature={onSetFeature}
|
||||||
disabled={disabled}
|
onDropdownClose={onDropdownClose}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1145,10 +1156,10 @@ export function DraftAgentStatusBar({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider ?? ""}
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
|
|||||||
@@ -568,10 +568,14 @@ export function CombinedModelSelector({
|
|||||||
[onSelect],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
|
if (!hasSelectedProvider) {
|
||||||
|
return "Select model";
|
||||||
|
}
|
||||||
return isLoading ? "Loading..." : "Select model";
|
return isLoading ? "Loading..." : "Select model";
|
||||||
}
|
}
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
@@ -580,7 +584,7 @@ export function CombinedModelSelector({
|
|||||||
}
|
}
|
||||||
const model = models.find((entry) => entry.id === selectedModel);
|
const model = models.find((entry) => entry.id === selectedModel);
|
||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||||
|
|
||||||
const desktopFixedHeight = useMemo(() => {
|
const desktopFixedHeight = useMemo(() => {
|
||||||
if (view.kind !== "provider") {
|
if (view.kind !== "provider") {
|
||||||
@@ -643,7 +647,9 @@ export function CombinedModelSelector({
|
|||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -8,18 +8,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
@@ -34,6 +24,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -312,48 +303,6 @@ export function FileExplorerPane({
|
|||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refetchExplorer();
|
void refetchExplorer();
|
||||||
}, [refetchExplorer]);
|
}, [refetchExplorer]);
|
||||||
const refreshIconRotation = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isRefreshFetching) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
refreshIconRotation.value = withRepeat(
|
|
||||||
withTiming(360, {
|
|
||||||
duration: 700,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAnimation(refreshIconRotation);
|
|
||||||
const remainder = refreshIconRotation.value % 360;
|
|
||||||
if (Math.abs(remainder) < 0.001) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = 360 - remainder;
|
|
||||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
|
||||||
refreshIconRotation.value = withTiming(
|
|
||||||
360,
|
|
||||||
{
|
|
||||||
duration,
|
|
||||||
easing: Easing.linear,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (finished) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [isRefreshFetching, refreshIconRotation]);
|
|
||||||
|
|
||||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||||
|
|
||||||
@@ -563,11 +512,15 @@ export function FileExplorerPane({
|
|||||||
(hovered || pressed) && styles.iconButtonHovered,
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Refresh files"
|
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||||
>
|
>
|
||||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
<View style={styles.refreshIcon}>
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
{isRefreshFetching ? (
|
||||||
</Animated.View>
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ function useDisableOuterSpacing(disableOuterSpacing: boolean | undefined) {
|
|||||||
|
|
||||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
const WEB_TOOLCALL_SHIMMER_KEYFRAME_ID = "paseo-toolcall-shimmer-keyframes";
|
||||||
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
const WEB_TOOLCALL_SHIMMER_ANIMATION_NAME = "paseo-toolcall-shimmer";
|
||||||
|
const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||||
|
"data:image/png;base64",
|
||||||
|
"data:image/gif;base64",
|
||||||
|
"data:image/jpeg;base64",
|
||||||
|
"https://",
|
||||||
|
"http://",
|
||||||
|
] as const;
|
||||||
|
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||||
|
|
||||||
|
type MarkdownWithStableRendererProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
style: ReturnType<typeof createMarkdownStyles>;
|
||||||
|
rules: RenderRules;
|
||||||
|
markdownit: MarkdownIt;
|
||||||
|
onLinkPress: (url: string) => boolean;
|
||||||
|
allowedImageHandlers: readonly string[];
|
||||||
|
topLevelMaxExceededItem: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||||||
0% {
|
0% {
|
||||||
@@ -947,9 +967,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
|||||||
onLinkPress,
|
onLinkPress,
|
||||||
}: MemoizedMarkdownBlockProps) {
|
}: MemoizedMarkdownBlockProps) {
|
||||||
return (
|
return (
|
||||||
<Markdown style={styles} rules={rules} markdownit={parser} onLinkPress={onLinkPress}>
|
<MarkdownWithStableRenderer
|
||||||
|
style={styles}
|
||||||
|
rules={rules}
|
||||||
|
markdownit={parser}
|
||||||
|
onLinkPress={onLinkPress}
|
||||||
|
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||||
|
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||||
|
>
|
||||||
{text}
|
{text}
|
||||||
</Markdown>
|
</MarkdownWithStableRenderer>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { AlertCircle, Search } from "lucide-react-native";
|
import { AlertCircle, RotateCw, Search } from "lucide-react-native";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
@@ -82,8 +82,12 @@ export function ProviderDiagnosticSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refresh([provider as AgentProvider]);
|
if (!provider) {
|
||||||
void fetchDiagnostic({ keepCurrent: true });
|
return;
|
||||||
|
}
|
||||||
|
void Promise.all([refresh([provider as AgentProvider]), fetchDiagnostic()]).catch((err) => {
|
||||||
|
setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider");
|
||||||
|
});
|
||||||
}, [fetchDiagnostic, provider, refresh]);
|
}, [fetchDiagnostic, provider, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,13 +161,15 @@ export function ProviderDiagnosticSheet({
|
|||||||
refreshInFlight ? sheetStyles.disabled : null,
|
refreshInFlight ? sheetStyles.disabled : null,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Refresh ${providerLabel}`}
|
accessibilityLabel={
|
||||||
|
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<SpinningRefreshIcon
|
{refreshInFlight ? (
|
||||||
spinning={refreshInFlight}
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
size={theme.iconSize.sm}
|
) : (
|
||||||
color={theme.colors.foregroundMuted}
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
/>
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { RefreshCw } from "lucide-react-native";
|
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
|
|
||||||
interface SpinningRefreshIconProps {
|
|
||||||
spinning: boolean;
|
|
||||||
size: number;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SpinningRefreshIcon({ spinning, size, color }: SpinningRefreshIconProps) {
|
|
||||||
const rotation = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (spinning) {
|
|
||||||
rotation.value = 0;
|
|
||||||
rotation.value = withRepeat(
|
|
||||||
withTiming(360, {
|
|
||||||
duration: 1000,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAnimation(rotation);
|
|
||||||
const remainder = rotation.value % 360;
|
|
||||||
if (Math.abs(remainder) < 0.001) {
|
|
||||||
rotation.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rotation.value = withTiming(360, {
|
|
||||||
duration: Math.max(80, Math.round(((360 - remainder) / 360) * 1000)),
|
|
||||||
easing: Easing.linear,
|
|
||||||
});
|
|
||||||
}, [rotation, spinning]);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ rotate: `${rotation.value}deg` }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
animatedStyle,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<RefreshCw size={size} color={color} />
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ActivityIndicator, type ActivityIndicatorProps } from "react-native";
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
color: string;
|
||||||
|
size?: ActivityIndicatorProps["size"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({ color, size = "small" }: LoadingSpinnerProps) {
|
||||||
|
return <ActivityIndicator size={size} color={color} />;
|
||||||
|
}
|
||||||
@@ -179,6 +179,9 @@ export function WorkspaceSetupDialog() {
|
|||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Workspace setup composer state is required");
|
throw new Error("Workspace setup composer state is required");
|
||||||
}
|
}
|
||||||
|
if (!composerState.selectedProvider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
|
|
||||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||||
const encodedImages = await encodeImages(wirePayload.images);
|
const encodedImages = await encodeImages(wirePayload.images);
|
||||||
|
|||||||
@@ -161,6 +161,37 @@ describe("useAgentFormState", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
it("keeps provider, mode, and model unset on first open without preferences or explicit values", () => {
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: false,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: null,
|
||||||
|
modeId: "",
|
||||||
|
model: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
makeProviderMap(TEST_CLAUDE_DEFINITION, TEST_CODEX_DEFINITION),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not auto-select a model on fresh drafts without preferences", () => {
|
it("does not auto-select a model on fresh drafts without preferences", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -374,7 +405,7 @@ describe("useAgentFormState", () => {
|
|||||||
expect(resolved.thinkingOptionId).toBe("low");
|
expect(resolved.thinkingOptionId).toBe("low");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves provider only from allowed provider map", () => {
|
it("clears an invalid provider instead of falling back to the first allowed provider", () => {
|
||||||
const resolved = __private__.resolveFormState(
|
const resolved = __private__.resolveFormState(
|
||||||
undefined,
|
undefined,
|
||||||
{ provider: "codex" },
|
{ provider: "codex" },
|
||||||
@@ -399,7 +430,121 @@ describe("useAgentFormState", () => {
|
|||||||
claudeProviderMap,
|
claudeProviderMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(resolved.provider).toBe("claude");
|
expect(resolved.provider).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves a user-selected provider and model while that provider is loading during refresh", () => {
|
||||||
|
const loadingEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "loading",
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(loadingEntries);
|
||||||
|
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: loadingEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: true,
|
||||||
|
modeId: true,
|
||||||
|
model: true,
|
||||||
|
thinkingOptionId: true,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBe("codex");
|
||||||
|
expect(resolved.modeId).toBe("full-access");
|
||||||
|
expect(resolved.model).toBe("gpt-5.3-codex");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a user-selected provider when the refreshed snapshot marks it unavailable", () => {
|
||||||
|
const unavailableEntries: ProviderSnapshotEntry[] = [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
status: "unavailable",
|
||||||
|
label: TEST_CODEX_DEFINITION.label,
|
||||||
|
description: TEST_CODEX_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CODEX_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CODEX_DEFINITION.modes,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
status: "ready",
|
||||||
|
label: TEST_CLAUDE_DEFINITION.label,
|
||||||
|
description: TEST_CLAUDE_DEFINITION.description,
|
||||||
|
defaultModeId: TEST_CLAUDE_DEFINITION.defaultModeId,
|
||||||
|
modes: TEST_CLAUDE_DEFINITION.modes,
|
||||||
|
models: [{ provider: "claude", id: "default", label: "Default", isDefault: true }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const providerDefinitions = buildProviderDefinitions(unavailableEntries);
|
||||||
|
const resolvableProviderMap = __private__.buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries: unavailableEntries,
|
||||||
|
providerDefinitions,
|
||||||
|
statuses: new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resolved = __private__.resolveFormState(
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
serverId: false,
|
||||||
|
provider: true,
|
||||||
|
modeId: false,
|
||||||
|
model: false,
|
||||||
|
thinkingOptionId: false,
|
||||||
|
workingDir: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
serverId: null,
|
||||||
|
provider: "codex",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
thinkingOptionId: "xhigh",
|
||||||
|
workingDir: "",
|
||||||
|
},
|
||||||
|
new Set<string>(),
|
||||||
|
resolvableProviderMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.provider).toBeNull();
|
||||||
|
expect(resolved.modeId).toBe("");
|
||||||
|
expect(resolved.model).toBe("");
|
||||||
|
expect(resolved.thinkingOptionId).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not force fallback provider when allowed provider map is empty", () => {
|
it("does not force fallback provider when allowed provider map is empty", () => {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
|||||||
// Internal form state
|
// Internal form state
|
||||||
interface FormState {
|
interface FormState {
|
||||||
serverId: string | null;
|
serverId: string | null;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider | null;
|
||||||
modeId: string;
|
modeId: string;
|
||||||
model: string;
|
model: string;
|
||||||
thinkingOptionId: string;
|
thinkingOptionId: string;
|
||||||
@@ -68,7 +68,7 @@ export type UseAgentFormStateResult = {
|
|||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
setSelectedServerId: (value: string | null) => void;
|
setSelectedServerId: (value: string | null) => void;
|
||||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||||
selectedProvider: AgentProvider;
|
selectedProvider: AgentProvider | null;
|
||||||
setProviderFromUser: (provider: AgentProvider) => void;
|
setProviderFromUser: (provider: AgentProvider) => void;
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
setModeFromUser: (modeId: string) => void;
|
setModeFromUser: (modeId: string) => void;
|
||||||
@@ -97,9 +97,6 @@ export type UseAgentFormStateResult = {
|
|||||||
persistFormPreferences: () => Promise<void>;
|
persistFormPreferences: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PROVIDER: AgentProvider = "claude";
|
|
||||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
|
|
||||||
|
|
||||||
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
function normalizeSelectedModelId(modelId: string | null | undefined): string {
|
||||||
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
const normalized = typeof modelId === "string" ? modelId.trim() : "";
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -177,8 +174,6 @@ function resolveFormState(
|
|||||||
): FormState {
|
): FormState {
|
||||||
// Start with current state - we only update non-user-modified fields
|
// Start with current state - we only update non-user-modified fields
|
||||||
const result = { ...currentState };
|
const result = { ...currentState };
|
||||||
const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined;
|
|
||||||
|
|
||||||
// 1. Resolve provider first (other fields depend on it)
|
// 1. Resolve provider first (other fields depend on it)
|
||||||
if (!userModified.provider) {
|
if (!userModified.provider) {
|
||||||
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
if (initialValues?.provider && allowedProviderMap.has(initialValues.provider)) {
|
||||||
@@ -188,22 +183,33 @@ function resolveFormState(
|
|||||||
allowedProviderMap.has(preferences.provider as AgentProvider)
|
allowedProviderMap.has(preferences.provider as AgentProvider)
|
||||||
) {
|
) {
|
||||||
result.provider = preferences.provider as AgentProvider;
|
result.provider = preferences.provider as AgentProvider;
|
||||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
} else if (
|
||||||
result.provider = fallbackProvider;
|
result.provider &&
|
||||||
|
allowedProviderMap.size > 0 &&
|
||||||
|
!allowedProviderMap.has(result.provider)
|
||||||
|
) {
|
||||||
|
result.provider = null;
|
||||||
}
|
}
|
||||||
// else keep current (initialized to DEFAULT_PROVIDER)
|
} else if (
|
||||||
} else if (!allowedProviderMap.has(result.provider) && fallbackProvider) {
|
result.provider &&
|
||||||
result.provider = fallbackProvider;
|
allowedProviderMap.size > 0 &&
|
||||||
|
!allowedProviderMap.has(result.provider)
|
||||||
|
) {
|
||||||
|
result.provider = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerDef = allowedProviderMap.get(result.provider);
|
const providerDef = result.provider ? allowedProviderMap.get(result.provider) : undefined;
|
||||||
const providerPrefs = preferences?.providerPreferences?.[result.provider];
|
const providerPrefs = result.provider
|
||||||
|
? preferences?.providerPreferences?.[result.provider]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 2. Resolve modeId (depends on provider)
|
// 2. Resolve modeId (depends on provider)
|
||||||
if (!userModified.modeId) {
|
if (!userModified.modeId) {
|
||||||
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
const validModeIds = providerDef?.modes.map((m) => m.id) ?? [];
|
||||||
|
|
||||||
if (
|
if (!result.provider) {
|
||||||
|
result.modeId = "";
|
||||||
|
} else if (
|
||||||
typeof initialValues?.modeId === "string" &&
|
typeof initialValues?.modeId === "string" &&
|
||||||
initialValues.modeId.length > 0 &&
|
initialValues.modeId.length > 0 &&
|
||||||
validModeIds.includes(initialValues.modeId)
|
validModeIds.includes(initialValues.modeId)
|
||||||
@@ -223,7 +229,9 @@ function resolveFormState(
|
|||||||
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
|
||||||
const defaultModelId = resolveDefaultModelId(availableModels);
|
const defaultModelId = resolveDefaultModelId(availableModels);
|
||||||
|
|
||||||
if (initialModel) {
|
if (!result.provider) {
|
||||||
|
result.model = "";
|
||||||
|
} else if (initialModel) {
|
||||||
if (!availableModels || isValidModel(initialModel)) {
|
if (!availableModels || isValidModel(initialModel)) {
|
||||||
result.model = initialModel;
|
result.model = initialModel;
|
||||||
} else {
|
} else {
|
||||||
@@ -246,7 +254,9 @@ function resolveFormState(
|
|||||||
? initialValues.thinkingOptionId.trim()
|
? initialValues.thinkingOptionId.trim()
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (!userModified.thinkingOptionId) {
|
if (!result.provider) {
|
||||||
|
result.thinkingOptionId = "";
|
||||||
|
} else if (!userModified.thinkingOptionId) {
|
||||||
const effectiveModelId = result.model.trim();
|
const effectiveModelId = result.model.trim();
|
||||||
const preferredThinking = effectiveModelId
|
const preferredThinking = effectiveModelId
|
||||||
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "")
|
||||||
@@ -262,7 +272,7 @@ function resolveFormState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate thinking option once model metadata is available.
|
// Validate thinking option once model metadata is available.
|
||||||
if (availableModels) {
|
if (result.provider && availableModels) {
|
||||||
result.thinkingOptionId = resolveThinkingOptionId({
|
result.thinkingOptionId = resolveThinkingOptionId({
|
||||||
availableModels,
|
availableModels,
|
||||||
modelId: result.model,
|
modelId: result.model,
|
||||||
@@ -312,6 +322,37 @@ function combineInitialValues(
|
|||||||
return initialValues;
|
return initialValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RESOLVABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready", "loading"]);
|
||||||
|
const SELECTABLE_PROVIDER_STATUSES = new Set<ProviderSnapshotEntry["status"]>(["ready"]);
|
||||||
|
|
||||||
|
function buildProviderDefinitionMap(
|
||||||
|
providerDefinitions: AgentProviderDefinition[],
|
||||||
|
): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
return new Map<AgentProvider, AgentProviderDefinition>(
|
||||||
|
providerDefinitions.map((definition) => [definition.id, definition]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProviderDefinitionMapForStatuses(args: {
|
||||||
|
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
||||||
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
|
statuses: ReadonlySet<ProviderSnapshotEntry["status"]>;
|
||||||
|
}): Map<AgentProvider, AgentProviderDefinition> {
|
||||||
|
if (!args.snapshotEntries?.length) {
|
||||||
|
return buildProviderDefinitionMap(args.providerDefinitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingProviders = new Set(
|
||||||
|
args.snapshotEntries
|
||||||
|
.filter((entry) => args.statuses.has(entry.status))
|
||||||
|
.map((entry) => entry.provider),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildProviderDefinitionMap(
|
||||||
|
args.providerDefinitions.filter((definition) => matchingProviders.has(definition.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
|
||||||
const {
|
const {
|
||||||
initialServerId = null,
|
initialServerId = null,
|
||||||
@@ -335,8 +376,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
// Form state
|
// Form state
|
||||||
const [formState, setFormState] = useState<FormState>(() => ({
|
const [formState, setFormState] = useState<FormState>(() => ({
|
||||||
serverId: initialServerId,
|
serverId: initialServerId,
|
||||||
provider: DEFAULT_PROVIDER,
|
provider: null,
|
||||||
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
|
modeId: "",
|
||||||
model: "",
|
model: "",
|
||||||
thinkingOptionId: "",
|
thinkingOptionId: "",
|
||||||
workingDir: "",
|
workingDir: "",
|
||||||
@@ -371,25 +412,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotProviderDefinitionMap = useMemo(
|
const snapshotProviderDefinitionMap = useMemo(
|
||||||
() =>
|
() => buildProviderDefinitionMap(snapshotProviderDefinitions),
|
||||||
new Map<AgentProvider, AgentProviderDefinition>(
|
|
||||||
snapshotProviderDefinitions.map((definition) => [definition.id, definition]),
|
|
||||||
),
|
|
||||||
[snapshotProviderDefinitions],
|
[snapshotProviderDefinitions],
|
||||||
);
|
);
|
||||||
|
const snapshotResolvableProviderDefinitionMap = useMemo(
|
||||||
|
() =>
|
||||||
|
buildProviderDefinitionMapForStatuses({
|
||||||
|
snapshotEntries,
|
||||||
|
providerDefinitions: snapshotProviderDefinitions,
|
||||||
|
statuses: RESOLVABLE_PROVIDER_STATUSES,
|
||||||
|
}),
|
||||||
|
[snapshotEntries, snapshotProviderDefinitions],
|
||||||
|
);
|
||||||
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
|
||||||
if (!snapshotEntries?.length) {
|
return buildProviderDefinitionMapForStatuses({
|
||||||
return snapshotProviderDefinitionMap;
|
snapshotEntries,
|
||||||
}
|
providerDefinitions: snapshotProviderDefinitions,
|
||||||
const readyProviders = new Set(
|
statuses: SELECTABLE_PROVIDER_STATUSES,
|
||||||
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
|
});
|
||||||
);
|
}, [snapshotEntries, snapshotProviderDefinitions]);
|
||||||
return new Map<AgentProvider, AgentProviderDefinition>(
|
|
||||||
snapshotProviderDefinitions
|
|
||||||
.filter((definition) => readyProviders.has(definition.id))
|
|
||||||
.map((definition) => [definition.id, definition]),
|
|
||||||
);
|
|
||||||
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
|
|
||||||
const snapshotAllProviderModels = useMemo(() => {
|
const snapshotAllProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
for (const entry of snapshotEntries ?? []) {
|
for (const entry of snapshotEntries ?? []) {
|
||||||
@@ -398,13 +439,16 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
return map;
|
return map;
|
||||||
}, [snapshotEntries]);
|
}, [snapshotEntries]);
|
||||||
const snapshotSelectedEntry = useMemo(
|
const snapshotSelectedEntry = useMemo(
|
||||||
() => (snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null,
|
() =>
|
||||||
|
formState.provider
|
||||||
|
? ((snapshotEntries ?? []).find((entry) => entry.provider === formState.provider) ?? null)
|
||||||
|
: null,
|
||||||
[formState.provider, snapshotEntries],
|
[formState.provider, snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null;
|
||||||
const snapshotSelectedProviderModes =
|
const snapshotSelectedProviderModes =
|
||||||
snapshotSelectedEntry?.modes ??
|
snapshotSelectedEntry?.modes ??
|
||||||
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
|
(formState.provider ? snapshotProviderDefinitionMap.get(formState.provider)?.modes : []) ??
|
||||||
[];
|
[];
|
||||||
const providerDefinitions = snapshotProviderDefinitions;
|
const providerDefinitions = snapshotProviderDefinitions;
|
||||||
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
const providerDefinitionMap = snapshotProviderDefinitionMap;
|
||||||
@@ -437,7 +481,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
userModified,
|
userModified,
|
||||||
formStateRef.current,
|
formStateRef.current,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
selectableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only update if something changed
|
// Only update if something changed
|
||||||
@@ -462,7 +506,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
availableModels,
|
availableModels,
|
||||||
userModified,
|
userModified,
|
||||||
validServerIds,
|
validServerIds,
|
||||||
selectableProviderDefinitionMap,
|
snapshotResolvableProviderDefinitionMap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-select the first online host when:
|
// Auto-select the first online host when:
|
||||||
@@ -629,12 +673,17 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
}, [refetchSnapshotIfStale]);
|
}, [refetchSnapshotIfStale]);
|
||||||
|
|
||||||
const persistFormPreferences = useCallback(async () => {
|
const persistFormPreferences = useCallback(async () => {
|
||||||
|
if (!formState.provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = formState.provider;
|
||||||
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const modelId = resolvedModel?.id ?? formState.model;
|
const modelId = resolvedModel?.id ?? formState.model;
|
||||||
await updatePreferences((current) =>
|
await updatePreferences((current) =>
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
provider: formState.provider,
|
provider,
|
||||||
updates: {
|
updates: {
|
||||||
model: modelId || undefined,
|
model: modelId || undefined,
|
||||||
mode: formState.modeId || undefined,
|
mode: formState.modeId || undefined,
|
||||||
@@ -657,7 +706,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
updatePreferences,
|
updatePreferences,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
const agentDefinition = formState.provider
|
||||||
|
? providerDefinitionMap.get(formState.provider)
|
||||||
|
: undefined;
|
||||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||||
@@ -738,6 +789,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
export type CreateAgentInitialValues = FormInitialValues;
|
export type CreateAgentInitialValues = FormInitialValues;
|
||||||
|
|
||||||
export const __private__ = {
|
export const __private__ = {
|
||||||
|
buildProviderDefinitionMap,
|
||||||
|
buildProviderDefinitionMapForStatuses,
|
||||||
combineInitialValues,
|
combineInitialValues,
|
||||||
resolveDefaultModel,
|
resolveDefaultModel,
|
||||||
resolveFormState,
|
resolveFormState,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
| ComposerAttachment[]
|
| ComposerAttachment[]
|
||||||
@@ -113,7 +114,7 @@ function resolveEffectiveComposerThinkingOptionId(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDraftComposerCommandConfig(input: {
|
function buildDraftComposerCommandConfig(input: {
|
||||||
provider: DraftAgentStatusBarProps["selectedProvider"];
|
provider: AgentProvider | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
@@ -122,7 +123,7 @@ function buildDraftComposerCommandConfig(input: {
|
|||||||
featureValues?: Record<string, unknown>;
|
featureValues?: Record<string, unknown>;
|
||||||
}): DraftCommandConfig | undefined {
|
}): DraftCommandConfig | undefined {
|
||||||
const cwd = input.cwd.trim();
|
const cwd = input.cwd.trim();
|
||||||
if (!cwd) {
|
if (!input.provider || !cwd) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type DraftFeatureConfig = Pick<
|
|||||||
|
|
||||||
export function useDraftAgentFeatures(input: {
|
export function useDraftAgentFeatures(input: {
|
||||||
serverId: string | null | undefined;
|
serverId: string | null | undefined;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider | null;
|
||||||
cwd: string | null | undefined;
|
cwd: string | null | undefined;
|
||||||
modeId: string | null | undefined;
|
modeId: string | null | undefined;
|
||||||
modelId: string | null | undefined;
|
modelId: string | null | undefined;
|
||||||
@@ -28,30 +28,31 @@ export function useDraftAgentFeatures(input: {
|
|||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
const normalizedCwd = cwd?.trim() || "";
|
const normalizedCwd = cwd?.trim() || "";
|
||||||
|
const normalizedProvider = provider ?? null;
|
||||||
const persistedFeatureValues = useMemo(
|
const persistedFeatureValues = useMemo(
|
||||||
() => preferences.providerPreferences?.[provider]?.featureValues ?? {},
|
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
|
||||||
[preferences.providerPreferences, provider],
|
[preferences.providerPreferences, provider],
|
||||||
);
|
);
|
||||||
|
|
||||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||||
if (!normalizedCwd) {
|
if (!normalizedProvider || !normalizedCwd) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider,
|
provider: normalizedProvider,
|
||||||
cwd: normalizedCwd,
|
cwd: normalizedCwd,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(modelId ? { model: modelId } : {}),
|
...(modelId ? { model: modelId } : {}),
|
||||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||||
};
|
};
|
||||||
}, [modeId, modelId, normalizedCwd, provider, thinkingOptionId]);
|
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
|
||||||
|
|
||||||
const featuresQuery = useQuery({
|
const featuresQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"providerFeatures",
|
"providerFeatures",
|
||||||
serverId ?? null,
|
serverId ?? null,
|
||||||
provider,
|
normalizedProvider,
|
||||||
normalizedCwd || null,
|
normalizedCwd || null,
|
||||||
modeId ?? null,
|
modeId ?? null,
|
||||||
modelId ?? null,
|
modelId ?? null,
|
||||||
@@ -106,6 +107,9 @@ export function useDraftAgentFeatures(input: {
|
|||||||
|
|
||||||
return { ...current, [featureId]: value };
|
return { ...current, [featureId]: value };
|
||||||
});
|
});
|
||||||
|
if (!provider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
void updatePreferences((current) =>
|
void updatePreferences((current) =>
|
||||||
mergeProviderPreferences({
|
mergeProviderPreferences({
|
||||||
preferences: current,
|
preferences: current,
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
providersSnapshotQueryKey,
|
||||||
|
shouldApplyProvidersSnapshotUpdate,
|
||||||
|
useProvidersSnapshot,
|
||||||
|
} from "./use-providers-snapshot";
|
||||||
|
|
||||||
|
const { mockClient, mockRuntime } = vi.hoisted(() => {
|
||||||
|
const mockClient = {
|
||||||
|
getProvidersSnapshot: vi.fn(),
|
||||||
|
refreshProvidersSnapshot: vi.fn(),
|
||||||
|
on: vi.fn(() => () => {}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
mockClient,
|
||||||
|
mockRuntime: {
|
||||||
|
client: mockClient,
|
||||||
|
isConnected: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHostRuntimeClient: () => mockRuntime.client,
|
||||||
|
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const serverId = "server-1";
|
||||||
|
|
||||||
|
function createQueryClient(): QueryClient {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableProvidersSnapshot(): void {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().initializeSession(serverId, mockClient as unknown as DaemonClient);
|
||||||
|
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||||
|
serverId,
|
||||||
|
hostname: "localhost",
|
||||||
|
version: "test",
|
||||||
|
features: { providersSnapshot: true },
|
||||||
|
} as never);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProvidersSnapshotHook(cwd?: string | null) {
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||||
|
|
||||||
|
return renderHook(() => useProvidersSnapshot(serverId, cwd), { wrapper });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
useSessionStore.getState().clearSession(serverId);
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("providers snapshot hook cache scope", () => {
|
||||||
|
it("uses no cwd in the settings query key", () => {
|
||||||
|
expect(providersSnapshotQueryKey(serverId)).toEqual(["providersSnapshot", serverId, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts concrete home update events for settings snapshots", () => {
|
||||||
|
expect(shouldApplyProvidersSnapshotUpdate(undefined, "/Users/alex")).toBe(true);
|
||||||
|
expect(shouldApplyProvidersSnapshotUpdate(null, "/home/alex")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps workspace snapshot updates scoped to matching cwd keys", () => {
|
||||||
|
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex/project")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldApplyProvidersSnapshotUpdate("/Users/alex/project-a", "/Users/alex/project-b"),
|
||||||
|
).toBe(false);
|
||||||
|
expect(shouldApplyProvidersSnapshotUpdate("/Users/alex/project", "/Users/alex")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends no cwd for settings snapshot loads and refreshes", async () => {
|
||||||
|
enableProvidersSnapshot();
|
||||||
|
mockClient.getProvidersSnapshot.mockResolvedValue({
|
||||||
|
entries: [],
|
||||||
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
requestId: "settings",
|
||||||
|
});
|
||||||
|
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||||
|
acknowledged: true,
|
||||||
|
requestId: "settings-refresh",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderProvidersSnapshotHook();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refresh(["codex"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({ providers: ["codex"] });
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends cwd for workspace snapshot loads and refreshes", async () => {
|
||||||
|
enableProvidersSnapshot();
|
||||||
|
mockClient.getProvidersSnapshot.mockResolvedValue({
|
||||||
|
entries: [],
|
||||||
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
requestId: "workspace",
|
||||||
|
});
|
||||||
|
mockClient.refreshProvidersSnapshot.mockResolvedValue({
|
||||||
|
acknowledged: true,
|
||||||
|
requestId: "workspace-refresh",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderProvidersSnapshotHook("/repo");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenCalledWith({ cwd: "/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refresh(["codex"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockClient.refreshProvidersSnapshot).toHaveBeenCalledWith({
|
||||||
|
cwd: "/repo",
|
||||||
|
providers: ["codex"],
|
||||||
|
});
|
||||||
|
expect(mockClient.getProvidersSnapshot).toHaveBeenLastCalledWith({ cwd: "/repo" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||||
|
|
||||||
function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
export function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
||||||
const trimmed = cwd?.trim();
|
const trimmed = cwd?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
return null;
|
||||||
@@ -15,10 +15,33 @@ function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null {
|
|||||||
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldApplyProvidersSnapshotUpdate(
|
||||||
|
currentCwd?: string | null,
|
||||||
|
messageCwd?: string | null,
|
||||||
|
): boolean {
|
||||||
|
const currentCwdKey = normalizeProvidersSnapshotCwdKey(currentCwd);
|
||||||
|
const messageCwdKey = normalizeProvidersSnapshotCwdKey(messageCwd);
|
||||||
|
return messageCwdKey === currentCwdKey || (currentCwdKey === null && messageCwdKey === "~");
|
||||||
|
}
|
||||||
|
|
||||||
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
|
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
|
||||||
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
|
return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providersSnapshotRequest(cwd?: string): { cwd?: string } {
|
||||||
|
return cwd ? { cwd } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshProvidersSnapshotRequest(
|
||||||
|
cwd: string | undefined,
|
||||||
|
providers: AgentProvider[] | undefined,
|
||||||
|
): { cwd?: string; providers?: AgentProvider[] } {
|
||||||
|
return {
|
||||||
|
...providersSnapshotRequest(cwd),
|
||||||
|
...(providers ? { providers } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface UseProvidersSnapshotResult {
|
interface UseProvidersSnapshotResult {
|
||||||
entries: ProviderSnapshotEntry[] | undefined;
|
entries: ProviderSnapshotEntry[] | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -62,7 +85,7 @@ export function useProvidersSnapshot(
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return client.getProvidersSnapshot({ cwd: normalizedCwd });
|
return client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +94,9 @@ export function useProvidersSnapshot(
|
|||||||
if (!client) {
|
if (!client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers });
|
await client.refreshProvidersSnapshot(
|
||||||
|
refreshProvidersSnapshotRequest(normalizedCwd, providers),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||||
@@ -85,8 +110,7 @@ export function useProvidersSnapshot(
|
|||||||
if (message.type !== "providers_snapshot_update") {
|
if (message.type !== "providers_snapshot_update") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const messageCwdKey = normalizeProvidersSnapshotCwdKey(message.payload.cwd);
|
if (!shouldApplyProvidersSnapshotUpdate(normalizedCwd, message.payload.cwd)) {
|
||||||
if (messageCwdKey !== normalizedCwdKey) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
queryClient.setQueryData(queryKey, {
|
queryClient.setQueryData(queryKey, {
|
||||||
@@ -99,6 +123,7 @@ export function useProvidersSnapshot(
|
|||||||
client,
|
client,
|
||||||
enabled,
|
enabled,
|
||||||
isConnected,
|
isConnected,
|
||||||
|
normalizedCwd,
|
||||||
normalizedCwdKey,
|
normalizedCwdKey,
|
||||||
queryClient,
|
queryClient,
|
||||||
queryKey,
|
queryKey,
|
||||||
@@ -108,9 +133,14 @@ export function useProvidersSnapshot(
|
|||||||
|
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (providers?: AgentProvider[]) => {
|
async (providers?: AgentProvider[]) => {
|
||||||
|
if (!client) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await refreshSnapshot(providers);
|
await refreshSnapshot(providers);
|
||||||
|
const snapshot = await client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd));
|
||||||
|
queryClient.setQueryData(queryKey, snapshot);
|
||||||
},
|
},
|
||||||
[refreshSnapshot],
|
[client, normalizedCwd, queryClient, queryKey, refreshSnapshot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchIfStale = useCallback(() => {
|
const refetchIfStale = useCallback(() => {
|
||||||
@@ -139,6 +169,6 @@ export function prefetchProvidersSnapshot(
|
|||||||
void singletonQueryClient.prefetchQuery({
|
void singletonQueryClient.prefetchQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
queryFn: () => client.getProvidersSnapshot({ cwd: normalizedCwd }),
|
queryFn: () => client.getProvidersSnapshot(providersSnapshotRequest(normalizedCwd)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -806,6 +806,9 @@ function DraftAgentScreenContent({
|
|||||||
if (providerDefinitions.length === 0) {
|
if (providerDefinitions.length === 0) {
|
||||||
return "No available providers on the selected host";
|
return "No available providers on the selected host";
|
||||||
}
|
}
|
||||||
|
if (!composerState.selectedProvider) {
|
||||||
|
return "Select a model";
|
||||||
|
}
|
||||||
if (gitBlockingError) {
|
if (gitBlockingError) {
|
||||||
return gitBlockingError;
|
return gitBlockingError;
|
||||||
}
|
}
|
||||||
@@ -846,6 +849,9 @@ function DraftAgentScreenContent({
|
|||||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||||
".";
|
".";
|
||||||
const provider = composerState.selectedProvider;
|
const provider = composerState.selectedProvider;
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
const model = effectiveModelId || null;
|
const model = effectiveModelId || null;
|
||||||
const thinkingOptionId = effectiveThinkingOptionId || null;
|
const thinkingOptionId = effectiveThinkingOptionId || null;
|
||||||
const modeId =
|
const modeId =
|
||||||
@@ -889,8 +895,12 @@ function DraftAgentScreenContent({
|
|||||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? composerState.selectedMode
|
? composerState.selectedMode
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const provider = composerState.selectedProvider;
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
provider: composerState.selectedProvider,
|
provider,
|
||||||
cwd: resolvedWorkingDir,
|
cwd: resolvedWorkingDir,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ export function NewWorkspaceScreen({
|
|||||||
if (!composerState) {
|
if (!composerState) {
|
||||||
throw new Error("Composer state is required");
|
throw new Error("Composer state is required");
|
||||||
}
|
}
|
||||||
|
if (!composerState.selectedProvider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
|
|
||||||
const initialPrompt = text.trim();
|
const initialPrompt = text.trim();
|
||||||
const encodedImages = await encodeImages(images);
|
const encodedImages = await encodeImages(images);
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
|||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||||
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { StatusBadge } from "@/components/ui/status-badge";
|
import { StatusBadge } from "@/components/ui/status-badge";
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
|
import { RotateCw } from "lucide-react-native";
|
||||||
|
|
||||||
export interface ProvidersSectionProps {
|
export interface ProvidersSectionProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -33,13 +34,13 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={settingsStyles.sectionHeaderLink}
|
style={settingsStyles.sectionHeaderLink}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Refresh providers"
|
accessibilityLabel={providerRefreshInFlight ? "Refreshing providers" : "Refresh providers"}
|
||||||
>
|
>
|
||||||
<SpinningRefreshIcon
|
{providerRefreshInFlight ? (
|
||||||
spinning={providerRefreshInFlight}
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
size={theme.iconSize.sm}
|
) : (
|
||||||
color={theme.colors.foregroundMuted}
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
/>
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ export function WorkspaceDraftAgentTab({
|
|||||||
if (composerState.providerDefinitions.length === 0) {
|
if (composerState.providerDefinitions.length === 0) {
|
||||||
return "No available providers on the selected host";
|
return "No available providers on the selected host";
|
||||||
}
|
}
|
||||||
|
if (!composerState.selectedProvider) {
|
||||||
|
return "Select a model";
|
||||||
|
}
|
||||||
if (composerState.isModelLoading) {
|
if (composerState.isModelLoading) {
|
||||||
return "Model defaults are still loading";
|
return "Model defaults are still loading";
|
||||||
}
|
}
|
||||||
@@ -127,10 +130,14 @@ export function WorkspaceDraftAgentTab({
|
|||||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? composerState.selectedMode
|
? composerState.selectedMode
|
||||||
: null;
|
: null;
|
||||||
|
const provider = composerState.selectedProvider;
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
id: tabId,
|
id: tabId,
|
||||||
provider: composerState.selectedProvider,
|
provider,
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -141,7 +148,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
availableModes: [],
|
availableModes: [],
|
||||||
pendingPermissions: [],
|
pendingPermissions: [],
|
||||||
persistence: null,
|
persistence: null,
|
||||||
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
|
runtimeInfo: { provider, sessionId: null, model, modeId },
|
||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
model,
|
model,
|
||||||
@@ -157,8 +164,12 @@ export function WorkspaceDraftAgentTab({
|
|||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const provider = composerState.selectedProvider;
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error("Select a model");
|
||||||
|
}
|
||||||
const config = buildWorkspaceDraftAgentConfig({
|
const config = buildWorkspaceDraftAgentConfig({
|
||||||
provider: composerState.selectedProvider,
|
provider,
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? { modeId: composerState.selectedMode }
|
? { modeId: composerState.selectedMode }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import pino from "pino";
|
|||||||
|
|
||||||
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
|
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
|
||||||
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
|
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
|
||||||
|
import { withTimeout } from "../src/utils/promise-timeout.js";
|
||||||
|
|
||||||
type RoundTripResult = {
|
type RoundTripResult = {
|
||||||
voiceAgentId: string;
|
voiceAgentId: string;
|
||||||
@@ -10,10 +11,6 @@ type RoundTripResult = {
|
|||||||
audioChunks: number;
|
audioChunks: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
|
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
@@ -103,13 +100,7 @@ async function runVoiceRoundTrip(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await withTimeout(done, params.timeoutMs, "Timed out waiting for audio_output");
|
||||||
done,
|
|
||||||
(async () => {
|
|
||||||
await sleep(params.timeoutMs);
|
|
||||||
throw new Error("Timed out waiting for audio_output");
|
|
||||||
})(),
|
|
||||||
]);
|
|
||||||
} finally {
|
} finally {
|
||||||
offStream();
|
offStream();
|
||||||
await client.setVoiceMode(false).catch(() => undefined);
|
await client.setVoiceMode(false).catch(() => undefined);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
import { buildVoiceAgentMcpServerConfig } from "../src/server/session.js";
|
import { buildVoiceAgentMcpServerConfig } from "../src/server/session.js";
|
||||||
import { createAgentMcpServer } from "../src/server/agent/mcp-server.js";
|
import { createAgentMcpServer } from "../src/server/agent/mcp-server.js";
|
||||||
import { createVoiceMcpSocketBridgeManager } from "../src/server/voice-mcp-bridge.js";
|
import { createVoiceMcpSocketBridgeManager } from "../src/server/voice-mcp-bridge.js";
|
||||||
|
import { withTimeout } from "../src/utils/promise-timeout.js";
|
||||||
|
|
||||||
type CliOptions = {
|
type CliOptions = {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -54,17 +55,13 @@ async function streamWithTimeout(
|
|||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
onEvent: (event: AgentStreamEvent) => Promise<void> | void,
|
onEvent: (event: AgentStreamEvent) => Promise<void> | void,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
const consume = (async () => {
|
const consume = (async () => {
|
||||||
for await (const event of iterator) {
|
for await (const event of iterator) {
|
||||||
await onEvent(event);
|
await onEvent(event);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
await Promise.race([consume, timeout]);
|
await withTimeout(consume, timeoutMs, `Timed out after ${timeoutMs}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pino from "pino";
|
|||||||
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../src/server/test-utils/paseo-daemon.js";
|
||||||
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
|
import { DaemonClient } from "../src/server/test-utils/daemon-client.js";
|
||||||
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
|
import { OpenAITTS } from "../src/server/speech/providers/openai/tts.js";
|
||||||
|
import { withTimeout } from "../src/utils/promise-timeout.js";
|
||||||
|
|
||||||
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
|
async function streamToBuffer(stream: AsyncIterable<unknown>): Promise<Buffer> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
@@ -151,18 +152,8 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.race([
|
await withTimeout(firstAudio, 120000, "Timed out waiting for first audio_output");
|
||||||
firstAudio,
|
await withTimeout(lastAudio, 120000, "Timed out waiting for final audio_output");
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("Timed out waiting for first audio_output")), 120000),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
await Promise.race([
|
|
||||||
lastAudio,
|
|
||||||
new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("Timed out waiting for final audio_output")), 120000),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log("success", { audioChunkCount });
|
console.log("success", { audioChunkCount });
|
||||||
await client.setVoiceMode(false);
|
await client.setVoiceMode(false);
|
||||||
|
|||||||
@@ -2745,7 +2745,7 @@ export class AgentManager {
|
|||||||
const client = this.clients.get(normalized.provider);
|
const client = this.clients.get(normalized.provider);
|
||||||
if (client) {
|
if (client) {
|
||||||
try {
|
try {
|
||||||
const models = await client.listModels();
|
const models = await client.listModels({ cwd: normalized.cwd, force: false });
|
||||||
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
|
const defaultModel = models.find((model) => model.isDefault) ?? models[0];
|
||||||
if (defaultModel) {
|
if (defaultModel) {
|
||||||
normalized.model = defaultModel.id;
|
normalized.model = defaultModel.id;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { experimental_createMCPClient } from "ai";
|
|||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
|
||||||
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||||
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
import { createPaseoDaemon, type PaseoDaemonConfig } from "../bootstrap.js";
|
||||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||||
|
|
||||||
@@ -23,26 +24,6 @@ type McpClient = {
|
|||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function withTimeout<T>(options: {
|
|
||||||
promise: Promise<T>;
|
|
||||||
timeoutMs: number;
|
|
||||||
label: string;
|
|
||||||
}): Promise<T> {
|
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
|
|
||||||
}, options.timeoutMs);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
return await Promise.race([options.promise, timeout]);
|
|
||||||
} finally {
|
|
||||||
if (timeoutHandle) {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForPathExists(options: {
|
async function waitForPathExists(options: {
|
||||||
targetPath: string;
|
targetPath: string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
|||||||
@@ -490,11 +490,13 @@ export interface AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ListModelsOptions {
|
export interface ListModelsOptions {
|
||||||
cwd?: string;
|
cwd: string;
|
||||||
|
force: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListModesOptions {
|
export interface ListModesOptions {
|
||||||
cwd?: string;
|
cwd: string;
|
||||||
|
force: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentClient {
|
export interface AgentClient {
|
||||||
@@ -509,8 +511,8 @@ export interface AgentClient {
|
|||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession>;
|
): Promise<AgentSession>;
|
||||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
listModes?(options?: ListModesOptions): Promise<AgentMode[]>;
|
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||||
/**
|
/**
|
||||||
* Check if this provider is available (CLI binary is installed).
|
* Check if this provider is available (CLI binary is installed).
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { runAsyncWorktreeBootstrap } from "../worktree-bootstrap.js";
|
|||||||
import type { ScheduleService } from "../schedule/service.js";
|
import type { ScheduleService } from "../schedule/service.js";
|
||||||
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
import { ScheduleSummarySchema, StoredScheduleSchema } from "../schedule/types.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
|
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||||
import {
|
import {
|
||||||
AgentModelSchema,
|
AgentModelSchema,
|
||||||
AgentProviderEnum,
|
AgentProviderEnum,
|
||||||
@@ -1296,11 +1297,27 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
const scheduleTarget =
|
const scheduleTarget =
|
||||||
target === "self"
|
target === "self"
|
||||||
? (() => {
|
? (() => {
|
||||||
if (!callerAgentId) {
|
const callerAgent = resolveCallerAgent();
|
||||||
|
if (!callerAgentId || !callerAgent) {
|
||||||
throw new Error("target=self requires a caller agent");
|
throw new Error("target=self requires a caller agent");
|
||||||
}
|
}
|
||||||
if (provider !== undefined || cwd !== undefined) {
|
const trimmedCwd = cwd?.trim();
|
||||||
throw new Error("provider and cwd can only be used with target=new-agent");
|
if (trimmedCwd && expandUserPath(trimmedCwd) !== callerAgent.cwd) {
|
||||||
|
throw new Error("cwd can only differ from the caller agent when target=new-agent");
|
||||||
|
}
|
||||||
|
if (provider !== undefined) {
|
||||||
|
const resolved = resolveProviderAndModel({
|
||||||
|
provider,
|
||||||
|
defaultProvider: callerAgent.provider,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
resolved.provider !== callerAgent.provider ||
|
||||||
|
(resolved.model !== undefined && resolved.model !== callerAgent.config.model)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"provider can only differ from the caller agent when target=new-agent",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { type: "agent" as const, agentId: callerAgentId };
|
return { type: "agent" as const, agentId: callerAgentId };
|
||||||
})()
|
})()
|
||||||
@@ -1498,7 +1515,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
throw new Error(`Provider ${provider} is not configured`);
|
throw new Error(`Provider ${provider} is not configured`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = await definition.fetchModels();
|
const models = await definition.fetchModels({ cwd: resolveSnapshotCwd(), force: false });
|
||||||
return {
|
return {
|
||||||
content: [],
|
content: [],
|
||||||
structuredContent: ensureValidJson({
|
structuredContent: ensureValidJson({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { buildProviderRegistry } from "./provider-registry.js";
|
import { buildProviderRegistry } from "./provider-registry.js";
|
||||||
|
import { resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||||
import { expandTilde } from "../../utils/path.js";
|
import { expandTilde } from "../../utils/path.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
@@ -21,7 +22,8 @@ export async function resolveAgentModel(
|
|||||||
try {
|
try {
|
||||||
const providerRegistry = buildProviderRegistry(options.logger);
|
const providerRegistry = buildProviderRegistry(options.logger);
|
||||||
const models = await providerRegistry[options.provider].fetchModels({
|
const models = await providerRegistry[options.provider].fetchModels({
|
||||||
cwd: options.cwd ? expandTilde(options.cwd) : undefined,
|
cwd: resolveSnapshotCwd(options.cwd ? expandTilde(options.cwd) : undefined),
|
||||||
|
force: false,
|
||||||
});
|
});
|
||||||
const preferred = models.find((model) => model.isDefault) ?? models[0];
|
const preferred = models.find((model) => model.isDefault) ?? models[0];
|
||||||
return preferred?.id;
|
return preferred?.id;
|
||||||
|
|||||||
@@ -578,7 +578,10 @@ describe("buildProviderRegistry", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const models = await registry.claude.fetchModels();
|
const models = await registry.claude.fetchModels({
|
||||||
|
cwd: "/tmp/registry-models",
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
expect(models.map((model) => model.id)).toEqual(["profile-fast"]);
|
expect(models.map((model) => model.id)).toEqual(["profile-fast"]);
|
||||||
});
|
});
|
||||||
@@ -610,7 +613,10 @@ describe("buildProviderRegistry", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const models = await registry.claude.fetchModels();
|
const models = await registry.claude.fetchModels({
|
||||||
|
cwd: "/tmp/registry-models",
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
expect(models).toEqual([
|
expect(models).toEqual([
|
||||||
{
|
{
|
||||||
@@ -645,7 +651,10 @@ describe("buildProviderRegistry", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const models = await registry.claude.fetchModels();
|
const models = await registry.claude.fetchModels({
|
||||||
|
cwd: "/tmp/registry-models",
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
expect(models).toEqual([
|
expect(models).toEqual([
|
||||||
{
|
{
|
||||||
@@ -668,7 +677,10 @@ describe("buildProviderRegistry", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const registry = buildProviderRegistry(logger);
|
const registry = buildProviderRegistry(logger);
|
||||||
const models = await registry.claude.fetchModels();
|
const models = await registry.claude.fetchModels({
|
||||||
|
cwd: "/tmp/registry-models",
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
|
||||||
expect(models).toEqual([
|
expect(models).toEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export { AGENT_PROVIDER_DEFINITIONS, getAgentProviderDefinition };
|
|||||||
|
|
||||||
export interface ProviderDefinition extends AgentProviderDefinition {
|
export interface ProviderDefinition extends AgentProviderDefinition {
|
||||||
createClient: (logger: Logger) => AgentClient;
|
createClient: (logger: Logger) => AgentClient;
|
||||||
fetchModels: (options?: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
fetchModels: (options: ListModelsOptions) => Promise<AgentModelDefinition[]>;
|
||||||
fetchModes: (options?: ListModesOptions) => Promise<AgentMode[]>;
|
fetchModes: (options: ListModesOptions) => Promise<AgentMode[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BuildProviderRegistryOptions = {
|
export type BuildProviderRegistryOptions = {
|
||||||
@@ -329,9 +329,9 @@ function createRegistryEntry(
|
|||||||
const inner = resolved.createBaseClient(providerLogger);
|
const inner = resolved.createBaseClient(providerLogger);
|
||||||
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
|
return inner.provider === provider ? inner : wrapClientProvider(provider, inner);
|
||||||
},
|
},
|
||||||
fetchModels: async (options?: ListModelsOptions) =>
|
fetchModels: async (options: ListModelsOptions) =>
|
||||||
mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)),
|
mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)),
|
||||||
fetchModes: async (options?: ListModesOptions) => {
|
fetchModes: async (options: ListModesOptions) => {
|
||||||
const modes = modelClient.listModes
|
const modes = modelClient.listModes
|
||||||
? await modelClient.listModes(options)
|
? await modelClient.listModes(options)
|
||||||
: resolved.definition.modes;
|
: resolved.definition.modes;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { homedir } from "node:os";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
@@ -7,6 +8,8 @@ import type {
|
|||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
|
ListModesOptions,
|
||||||
|
ListModelsOptions,
|
||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
@@ -24,8 +27,8 @@ type MockProviderOptions = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
defaultModeId?: string | null;
|
defaultModeId?: string | null;
|
||||||
isAvailable?: () => Promise<boolean>;
|
isAvailable?: () => Promise<boolean>;
|
||||||
fetchModels?: (cwd?: string) => Promise<AgentModelDefinition[]>;
|
fetchModels?: (cwd: string, force: boolean) => Promise<AgentModelDefinition[]>;
|
||||||
fetchModes?: (cwd?: string) => Promise<AgentMode[]>;
|
fetchModes?: (cwd: string, force: boolean) => Promise<AgentMode[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MockProviderHandle = {
|
type MockProviderHandle = {
|
||||||
@@ -441,21 +444,22 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refresh during an in-flight refresh is a no-op", async () => {
|
test("explicit refresh bypasses an in-flight background warm-up", async () => {
|
||||||
const fetchModels = deferred<AgentModelDefinition[]>();
|
const initialFetchModels = deferred<AgentModelDefinition[]>();
|
||||||
const fetchModes = deferred<AgentMode[]>();
|
const explicitFetchModels = deferred<AgentModelDefinition[]>();
|
||||||
const { registry, handles } = createRegistry([
|
const { registry, handles } = createRegistry([
|
||||||
createMockProvider({
|
createMockProvider({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
fetchModels: async () => fetchModels.promise,
|
fetchModels: vi
|
||||||
fetchModes: async () => fetchModes.promise,
|
.fn<() => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockImplementationOnce(async () => initialFetchModels.promise)
|
||||||
|
.mockImplementationOnce(async () => explicitFetchModels.promise),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
const changes: ProviderSnapshotEntry[][] = [];
|
|
||||||
manager.on("change", (entries) => changes.push(entries));
|
|
||||||
|
|
||||||
manager.refresh({ cwd: projectCwd });
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
expect(manager.getSnapshot(projectCwd)).toEqual([
|
expect(manager.getSnapshot(projectCwd)).toEqual([
|
||||||
{
|
{
|
||||||
@@ -467,15 +471,18 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
manager.refresh({ cwd: projectCwd });
|
await vi.waitFor(() => {
|
||||||
manager.refresh({ cwd: projectCwd });
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||||
manager.refresh({ cwd: projectCwd });
|
});
|
||||||
|
|
||||||
expect(changes).toHaveLength(1);
|
const refreshPromise = manager.refresh({ cwd: projectCwd, providers: ["codex"] });
|
||||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
fetchModels.resolve([createModel("codex", "gpt-5.2")]);
|
await vi.waitFor(() => {
|
||||||
fetchModes.resolve([createMode("auto")]);
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
explicitFetchModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||||
|
await refreshPromise;
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
|
||||||
@@ -486,12 +493,126 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
initialFetchModels.resolve([createModel("codex", "stale-background-model")]);
|
||||||
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(1);
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
"gpt-5.2",
|
||||||
|
);
|
||||||
|
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
expect(handles.codex?.fetchModes).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("warmUpSnapshotForCwd awaits an in-flight loading provider without force", async () => {
|
||||||
|
const loadingFetchModels = deferred<AgentModelDefinition[]>();
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockImplementation(async (_cwd, _force) => loadingFetchModels.promise);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd, force) => fetchModels(cwd, force),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const warmUpPromise = manager.warmUpSnapshotForCwd({
|
||||||
|
cwd: projectCwd,
|
||||||
|
providers: ["codex"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchModels).toHaveBeenCalledWith(projectCwd, false);
|
||||||
|
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
|
||||||
|
|
||||||
|
loadingFetchModels.resolve([createModel("codex", "gpt-5.4")]);
|
||||||
|
await warmUpPromise;
|
||||||
|
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchModels).not.toHaveBeenCalledWith(projectCwd, true);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings refresh refreshes only home cwd and invalidates matching provider elsewhere", async () => {
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockImplementation(async (cwd) => [createModel("codex", cwd)]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd, force) => fetchModels(cwd, force),
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
createMockProvider({
|
||||||
|
provider: "claude",
|
||||||
|
fetchModels: async (cwd) => [createModel("claude", cwd)],
|
||||||
|
fetchModes: async () => [createMode("default")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectACwd);
|
||||||
|
manager.getSnapshot(projectBCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.status).toBe("ready");
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||||
|
|
||||||
|
expect(fetchModels.mock.calls.map(([cwd]) => cwd)).toEqual([
|
||||||
|
projectACwd,
|
||||||
|
projectBCwd,
|
||||||
|
homedir(),
|
||||||
|
]);
|
||||||
|
expect(fetchModels.mock.calls.map(([, force]) => force)).toEqual([false, false, true]);
|
||||||
|
|
||||||
|
const projectASnapshot = manager.getSnapshot(projectACwd);
|
||||||
|
expect(getProviderEntry(projectASnapshot, "codex")?.status).toBe("loading");
|
||||||
|
expect(getProviderEntry(projectASnapshot, "claude")?.status).toBe("ready");
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh marks a slow provider as error after the timeout", async () => {
|
||||||
|
const fetchModels = deferred<AgentModelDefinition[]>();
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async () => fetchModels.promise,
|
||||||
|
fetchModes: async () => [createMode("auto")],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger(), {
|
||||||
|
refreshTimeoutMs: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.refresh({ cwd: projectCwd, providers: ["codex"] });
|
||||||
|
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
|
||||||
|
provider: "codex",
|
||||||
|
status: "error",
|
||||||
|
error: "Timed out refreshing codex after 5ms",
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
fetchModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||||
|
});
|
||||||
|
|
||||||
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
|
test("getSnapshot returns stale ready entries and starts background warm-up when snapshot is older than TTL", async () => {
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
const fetchModels = vi
|
const fetchModels = vi
|
||||||
@@ -799,6 +920,91 @@ describe("ProviderSnapshotManager", () => {
|
|||||||
manager.destroy();
|
manager.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("missing cwd resolves to home and shares the explicit home cache entry", async () => {
|
||||||
|
const seenCwds: string[] = [];
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd) => {
|
||||||
|
seenCwds.push(cwd);
|
||||||
|
return [createModel("codex", cwd)];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(homedir()), "codex")?.status).toBe("ready");
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.getSnapshot(" ");
|
||||||
|
|
||||||
|
expect(seenCwds).toEqual([homedir()]);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(), "codex")?.models?.[0]?.id).toBe(homedir());
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cwd normalization resolves tilde relative paths and trailing slashes before provider calls", async () => {
|
||||||
|
const seenCwds: string[] = [];
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd) => {
|
||||||
|
seenCwds.push(cwd);
|
||||||
|
return [createModel("codex", cwd)];
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot("~/paseo-provider-test/../paseo-provider-test/");
|
||||||
|
manager.getSnapshot("relative-provider-test/..");
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(seenCwds).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seenCwds).toEqual([resolve(homedir(), "paseo-provider-test"), resolve(".")]);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace refresh refreshes only the requested cwd with force true", async () => {
|
||||||
|
const fetchModels = vi
|
||||||
|
.fn<(cwd: string, force: boolean) => Promise<AgentModelDefinition[]>>()
|
||||||
|
.mockImplementation(async (cwd) => [createModel("codex", cwd)]);
|
||||||
|
const { registry } = createRegistry([
|
||||||
|
createMockProvider({
|
||||||
|
provider: "codex",
|
||||||
|
fetchModels: async (cwd, force) => fetchModels(cwd, force),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||||
|
|
||||||
|
manager.getSnapshot(projectACwd);
|
||||||
|
manager.getSnapshot(projectBCwd);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.refreshSnapshotForCwd({ cwd: projectACwd, providers: ["codex"] });
|
||||||
|
|
||||||
|
expect(fetchModels.mock.calls).toEqual([
|
||||||
|
[projectACwd, false],
|
||||||
|
[projectBCwd, false],
|
||||||
|
[projectACwd, true],
|
||||||
|
]);
|
||||||
|
expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
|
||||||
|
projectBCwd,
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test("snapshot includes user-defined providers from the registry", async () => {
|
test("snapshot includes user-defined providers from the registry", async () => {
|
||||||
const { registry } = createRegistry([
|
const { registry } = createRegistry([
|
||||||
createMockProvider({ provider: "claude" }),
|
createMockProvider({ provider: "claude" }),
|
||||||
@@ -911,14 +1117,16 @@ function createRegistry(handles: MockProviderHandle[]): {
|
|||||||
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
||||||
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
|
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
|
||||||
const fetchModels = vi.fn(
|
const fetchModels = vi.fn(
|
||||||
async (listOptions?: { cwd?: string }) =>
|
async (listOptions: ListModelsOptions) =>
|
||||||
options.fetchModels?.(listOptions?.cwd) ?? [
|
options.fetchModels?.(listOptions.cwd, listOptions.force) ?? [
|
||||||
createModel(options.provider, `${options.provider}-default`),
|
createModel(options.provider, `${options.provider}-default`),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const fetchModes = vi.fn(
|
const fetchModes = vi.fn(
|
||||||
async (listOptions?: { cwd?: string }) =>
|
async (listOptions: ListModesOptions) =>
|
||||||
options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
|
options.fetchModes?.(listOptions.cwd, listOptions.force) ?? [
|
||||||
|
createMode(`${options.provider}-mode`),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const definition: ProviderDefinition = {
|
const definition: ProviderDefinition = {
|
||||||
@@ -937,7 +1145,7 @@ function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
|||||||
async resumeSession() {
|
async resumeSession() {
|
||||||
throw new Error("not implemented");
|
throw new Error("not implemented");
|
||||||
},
|
},
|
||||||
async listModels() {
|
async listModels(_options: ListModelsOptions) {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
async isAvailable() {
|
async isAvailable() {
|
||||||
|
|||||||
@@ -1,31 +1,43 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||||
import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
import type { AgentProvider, ProviderSnapshotEntry } from "./agent-sdk-types.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
|
|
||||||
const DEFAULT_CWD_KEY = "__default__";
|
|
||||||
const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
|
const DEFAULT_SNAPSHOT_TTL_MS = 300_000;
|
||||||
|
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd?: string) => void;
|
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void;
|
||||||
type ProviderSnapshotManagerOptions = {
|
type ProviderSnapshotManagerOptions = {
|
||||||
ttlMs?: number;
|
ttlMs?: number;
|
||||||
|
refreshTimeoutMs?: number;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
};
|
};
|
||||||
type ProviderSnapshotRefreshOptions = {
|
type ProviderSnapshotRefreshOptions = {
|
||||||
cwd?: string;
|
cwd: string;
|
||||||
providers?: AgentProvider[];
|
providers?: AgentProvider[];
|
||||||
};
|
};
|
||||||
|
type ProviderLoadOptions = {
|
||||||
|
cwd: string;
|
||||||
|
providers: AgentProvider[];
|
||||||
|
force: boolean;
|
||||||
|
};
|
||||||
|
type ProviderLoad = {
|
||||||
|
promise: Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export class ProviderSnapshotManager {
|
export class ProviderSnapshotManager {
|
||||||
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
||||||
private readonly lastCheckedAts = new Map<string, number>();
|
private readonly lastCheckedAts = new Map<string, number>();
|
||||||
private readonly warmUps = new Map<string, Promise<void>>();
|
private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>();
|
||||||
private readonly events = new EventEmitter();
|
private readonly events = new EventEmitter();
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
private readonly ttlMs: number;
|
private readonly ttlMs: number;
|
||||||
|
private readonly refreshTimeoutMs: number;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -34,37 +46,81 @@ export class ProviderSnapshotManager {
|
|||||||
options: ProviderSnapshotManagerOptions = {},
|
options: ProviderSnapshotManagerOptions = {},
|
||||||
) {
|
) {
|
||||||
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
|
this.ttlMs = options.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS;
|
||||||
|
this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS;
|
||||||
this.now = options.now ?? Date.now;
|
this.now = options.now ?? Date.now;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
const resolvedCwd = resolveSnapshotCwd(cwd);
|
||||||
const entries = this.snapshots.get(cwdKey);
|
const entries = this.snapshots.get(resolvedCwd);
|
||||||
if (!entries) {
|
if (!entries) {
|
||||||
const loadingEntries = this.resetSnapshotToLoading(cwdKey);
|
const loadingEntries = this.resetSnapshotToLoading(resolvedCwd);
|
||||||
void this.warmUp(cwd);
|
void this.warmUp(resolvedCwd);
|
||||||
return entriesToArray(loadingEntries);
|
return entriesToArray(loadingEntries);
|
||||||
}
|
}
|
||||||
if (this.shouldRevalidate(cwdKey)) {
|
const missingProviders = this.getProviderIds().filter((provider) => !entries.has(provider));
|
||||||
void this.warmUp(cwd);
|
if (missingProviders.length > 0) {
|
||||||
|
this.resetSnapshotToLoading(resolvedCwd, missingProviders);
|
||||||
|
void this.warmUp(resolvedCwd, missingProviders);
|
||||||
|
}
|
||||||
|
if (this.shouldRevalidate(resolvedCwd)) {
|
||||||
|
void this.warmUp(resolvedCwd);
|
||||||
}
|
}
|
||||||
return entriesToArray(entries);
|
return entriesToArray(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(options: ProviderSnapshotRefreshOptions = {}): Promise<void> {
|
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||||
const { cwd } = options;
|
const cwd = resolveSnapshotCwd(options.cwd);
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
const inFlight = this.warmUps.get(cwdKey);
|
this.resetSnapshotToLoading(cwd, providers);
|
||||||
if (inFlight) {
|
this.emitChange(cwd);
|
||||||
await inFlight;
|
await this.refreshProviders(cwd, providers ?? this.getProviderIds());
|
||||||
|
if (!providers) {
|
||||||
|
this.lastCheckedAts.set(cwd, this.now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSettingsSnapshot(
|
||||||
|
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const homeCwd = resolveSnapshotCwd();
|
||||||
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
|
const providersToRefresh = providers ?? this.getProviderIds();
|
||||||
|
|
||||||
|
this.resetSnapshotToLoading(homeCwd, providers);
|
||||||
|
this.emitChange(homeCwd);
|
||||||
|
await this.refreshProviders(homeCwd, providersToRefresh);
|
||||||
|
if (!providers) {
|
||||||
|
this.lastCheckedAts.set(homeCwd, this.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.invalidateNonHomeSnapshots({ homeCwd, providers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||||
|
const cwd = resolveSnapshotCwd(options.cwd);
|
||||||
|
const providers = this.resolveRefreshProviders(options.providers);
|
||||||
|
if (options.providers && providers?.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const providers = this.resolveRefreshProviders(options.providers);
|
|
||||||
this.resetSnapshotToLoading(cwdKey, providers);
|
const snapshot = this.snapshots.get(cwd);
|
||||||
this.emitChange(cwdKey);
|
if (!snapshot) {
|
||||||
|
this.resetSnapshotToLoading(cwd, providers);
|
||||||
|
} else if (providers) {
|
||||||
|
const missingProviders = providers.filter((provider) => !snapshot.has(provider));
|
||||||
|
if (missingProviders.length > 0) {
|
||||||
|
this.resetSnapshotToLoading(cwd, missingProviders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.warmUp(cwd, providers);
|
await this.warmUp(cwd, providers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refresh(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||||
|
await this.refreshSnapshotForCwd(options);
|
||||||
|
}
|
||||||
|
|
||||||
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
on(event: "change", listener: ProviderSnapshotChangeListener): this {
|
||||||
this.events.on(event, listener);
|
this.events.on(event, listener);
|
||||||
return this;
|
return this;
|
||||||
@@ -80,7 +136,7 @@ export class ProviderSnapshotManager {
|
|||||||
this.events.removeAllListeners();
|
this.events.removeAllListeners();
|
||||||
this.snapshots.clear();
|
this.snapshots.clear();
|
||||||
this.lastCheckedAts.clear();
|
this.lastCheckedAts.clear();
|
||||||
this.warmUps.clear();
|
this.providerLoads.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
|
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
|
||||||
@@ -98,49 +154,87 @@ export class ProviderSnapshotManager {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async warmUp(cwd?: string, providers?: AgentProvider[]): Promise<void> {
|
private async warmUp(cwd: string, providers?: AgentProvider[]): Promise<void> {
|
||||||
const cwdKey = normalizeCwdKey(cwd);
|
|
||||||
const inFlight = this.warmUps.get(cwdKey);
|
|
||||||
if (inFlight) {
|
|
||||||
return inFlight;
|
|
||||||
}
|
|
||||||
const providersToRefresh = providers ?? this.getProviderIds();
|
const providersToRefresh = providers ?? this.getProviderIds();
|
||||||
|
|
||||||
const warmUpPromise = Promise.allSettled(
|
await this.loadProviders({
|
||||||
providersToRefresh.map((provider) => this.refreshProvider(cwdKey, provider, cwd)),
|
cwd,
|
||||||
).then(() => {
|
providers: providersToRefresh,
|
||||||
if (!providers) {
|
force: false,
|
||||||
this.lastCheckedAts.set(cwdKey, this.now());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
if (!providers) {
|
||||||
this.warmUps.set(cwdKey, warmUpPromise);
|
this.lastCheckedAts.set(cwd, this.now());
|
||||||
|
|
||||||
try {
|
|
||||||
await warmUpPromise;
|
|
||||||
} finally {
|
|
||||||
if (this.warmUps.get(cwdKey) === warmUpPromise) {
|
|
||||||
this.warmUps.delete(cwdKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshProvider(
|
private async refreshProviders(cwd: string, providers: AgentProvider[]): Promise<void> {
|
||||||
cwdKey: string,
|
await this.loadProviders({ cwd, providers, force: true });
|
||||||
provider: AgentProvider,
|
}
|
||||||
cwd?: string,
|
|
||||||
): Promise<void> {
|
private async loadProviders(options: ProviderLoadOptions): Promise<void> {
|
||||||
const definition = this.providerRegistry[provider];
|
await Promise.allSettled(
|
||||||
|
options.providers.map((provider) => this.loadProvider({ ...options, provider })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadProvider(options: ProviderLoadOptions & { provider: AgentProvider }): Promise<void> {
|
||||||
|
const definition = this.providerRegistry[options.provider];
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
return;
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = this.getOrCreateSnapshot(cwdKey);
|
const existingLoad = this.getProviderLoad(options.cwd, options.provider);
|
||||||
|
if (existingLoad && !options.force) {
|
||||||
|
return existingLoad.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const load: ProviderLoad = {
|
||||||
|
promise: Promise.resolve(),
|
||||||
|
};
|
||||||
|
this.setProviderLoad(options.cwd, options.provider, load);
|
||||||
|
load.promise = Promise.resolve()
|
||||||
|
.then(() =>
|
||||||
|
this.refreshProvider({
|
||||||
|
cwd: options.cwd,
|
||||||
|
provider: options.provider,
|
||||||
|
definition,
|
||||||
|
load,
|
||||||
|
force: options.force,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
const providerLoads = this.providerLoads.get(options.cwd);
|
||||||
|
if (providerLoads?.get(options.provider) === load) {
|
||||||
|
providerLoads.delete(options.provider);
|
||||||
|
}
|
||||||
|
if (providerLoads?.size === 0) {
|
||||||
|
this.providerLoads.delete(options.cwd);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return load.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshProvider(options: {
|
||||||
|
cwd: string;
|
||||||
|
provider: AgentProvider;
|
||||||
|
definition: ProviderDefinition;
|
||||||
|
load: ProviderLoad;
|
||||||
|
force: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { cwd, provider, definition, load, force } = options;
|
||||||
|
const snapshot = this.getOrCreateSnapshot(options.cwd);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = definition.createClient(this.logger);
|
const client = definition.createClient(this.logger);
|
||||||
const available = await client.isAvailable();
|
const available = await withTimeout(
|
||||||
|
client.isAvailable(),
|
||||||
|
this.refreshTimeoutMs,
|
||||||
|
`Timed out checking ${definition.label} availability after ${this.refreshTimeoutMs}ms`,
|
||||||
|
);
|
||||||
if (!available) {
|
if (!available) {
|
||||||
|
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
snapshot.set(provider, {
|
snapshot.set(provider, {
|
||||||
provider,
|
provider,
|
||||||
status: "unavailable",
|
status: "unavailable",
|
||||||
@@ -148,15 +242,22 @@ export class ProviderSnapshotManager {
|
|||||||
description: definition.description,
|
description: definition.description,
|
||||||
defaultModeId: definition.defaultModeId,
|
defaultModeId: definition.defaultModeId,
|
||||||
});
|
});
|
||||||
this.emitChange(cwdKey);
|
this.emitChange(cwd);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [models, modes] = await Promise.all([
|
const [models, modes] = await withTimeout(
|
||||||
definition.fetchModels({ cwd }),
|
Promise.all([
|
||||||
definition.fetchModes({ cwd }),
|
definition.fetchModels({ cwd, force }),
|
||||||
]);
|
definition.fetchModes({ cwd, force }),
|
||||||
|
]),
|
||||||
|
this.refreshTimeoutMs,
|
||||||
|
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
snapshot.set(provider, {
|
snapshot.set(provider, {
|
||||||
provider,
|
provider,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
@@ -167,8 +268,11 @@ export class ProviderSnapshotManager {
|
|||||||
description: definition.description,
|
description: definition.description,
|
||||||
defaultModeId: definition.defaultModeId,
|
defaultModeId: definition.defaultModeId,
|
||||||
});
|
});
|
||||||
this.emitChange(cwdKey);
|
this.emitChange(cwd);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
snapshot.set(provider, {
|
snapshot.set(provider, {
|
||||||
provider,
|
provider,
|
||||||
status: "error",
|
status: "error",
|
||||||
@@ -177,14 +281,32 @@ export class ProviderSnapshotManager {
|
|||||||
description: definition.description,
|
description: definition.description,
|
||||||
defaultModeId: definition.defaultModeId,
|
defaultModeId: definition.defaultModeId,
|
||||||
});
|
});
|
||||||
this.logger.warn(
|
this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot");
|
||||||
{ err: error, provider, cwd: cwdKey },
|
this.emitChange(cwd);
|
||||||
"Failed to refresh provider snapshot",
|
|
||||||
);
|
|
||||||
this.emitChange(cwdKey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getProviderLoad(cwdKey: string, provider: AgentProvider): ProviderLoad | undefined {
|
||||||
|
return this.providerLoads.get(cwdKey)?.get(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setProviderLoad(cwdKey: string, provider: AgentProvider, load: ProviderLoad): void {
|
||||||
|
let providerLoads = this.providerLoads.get(cwdKey);
|
||||||
|
if (!providerLoads) {
|
||||||
|
providerLoads = new Map<AgentProvider, ProviderLoad>();
|
||||||
|
this.providerLoads.set(cwdKey, providerLoads);
|
||||||
|
}
|
||||||
|
providerLoads.set(provider, load);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCurrentProviderLoad(
|
||||||
|
cwdKey: string,
|
||||||
|
provider: AgentProvider,
|
||||||
|
load: ProviderLoad,
|
||||||
|
): boolean {
|
||||||
|
return this.providerLoads.get(cwdKey)?.get(provider) === load;
|
||||||
|
}
|
||||||
|
|
||||||
private emitChange(cwdKey: string): void {
|
private emitChange(cwdKey: string): void {
|
||||||
if (this.destroyed) {
|
if (this.destroyed) {
|
||||||
return;
|
return;
|
||||||
@@ -193,11 +315,11 @@ export class ProviderSnapshotManager {
|
|||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.events.emit("change", entriesToArray(snapshot), denormalizeCwdKey(cwdKey));
|
this.events.emit("change", entriesToArray(snapshot), cwdKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldRevalidate(cwdKey: string): boolean {
|
private shouldRevalidate(cwdKey: string): boolean {
|
||||||
if (this.warmUps.has(cwdKey)) {
|
if (this.providerLoads.has(cwdKey)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
|
const lastCheckedAt = this.lastCheckedAts.get(cwdKey);
|
||||||
@@ -259,23 +381,51 @@ export class ProviderSnapshotManager {
|
|||||||
const providerIds = new Set(this.getProviderIds());
|
const providerIds = new Set(this.getProviderIds());
|
||||||
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
|
return Array.from(new Set(providers)).filter((provider) => providerIds.has(provider));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private invalidateNonHomeSnapshots(options: {
|
||||||
|
homeCwd: string;
|
||||||
|
providers?: AgentProvider[];
|
||||||
|
}): void {
|
||||||
|
for (const cwd of Array.from(this.snapshots.keys())) {
|
||||||
|
if (cwd === options.homeCwd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.providers) {
|
||||||
|
this.snapshots.delete(cwd);
|
||||||
|
this.lastCheckedAts.delete(cwd);
|
||||||
|
this.providerLoads.delete(cwd);
|
||||||
|
this.events.emit("change", [], cwd);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = this.snapshots.get(cwd);
|
||||||
|
if (!snapshot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
for (const provider of options.providers) {
|
||||||
|
changed = snapshot.delete(provider) || changed;
|
||||||
|
this.providerLoads.get(cwd)?.delete(provider);
|
||||||
|
}
|
||||||
|
if (this.providerLoads.get(cwd)?.size === 0) {
|
||||||
|
this.providerLoads.delete(cwd);
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
this.emitChange(cwd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCwdKey(cwd?: string): string {
|
export function resolveSnapshotCwd(cwd?: string | null): string {
|
||||||
if (!cwd) {
|
const trimmed = cwd?.trim();
|
||||||
return DEFAULT_CWD_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = cwd.trim();
|
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return DEFAULT_CWD_KEY;
|
return homedir();
|
||||||
}
|
}
|
||||||
|
const expanded =
|
||||||
return resolve(trimmed);
|
trimmed === "~" || trimmed.startsWith("~/") ? `${homedir()}${trimmed.slice(1)}` : trimmed;
|
||||||
}
|
return resolve(expanded);
|
||||||
|
|
||||||
function denormalizeCwdKey(cwdKey: string): string | undefined {
|
|
||||||
return cwdKey === DEFAULT_CWD_KEY ? undefined : cwdKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function entriesToArray(
|
function entriesToArray(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
ACPAgentClient,
|
ACPAgentClient,
|
||||||
ACPAgentSession,
|
ACPAgentSession,
|
||||||
|
type SpawnedACPProcess,
|
||||||
type SessionStateResponse,
|
type SessionStateResponse,
|
||||||
deriveModelDefinitionsFromACP,
|
deriveModelDefinitionsFromACP,
|
||||||
deriveModesFromACP,
|
deriveModesFromACP,
|
||||||
@@ -218,7 +219,7 @@ describe("deriveModelDefinitionsFromACP", () => {
|
|||||||
describe("ACPAgentClient modelTransformer", () => {
|
describe("ACPAgentClient modelTransformer", () => {
|
||||||
test("applies modelTransformer after deriving ACP models", async () => {
|
test("applies modelTransformer after deriving ACP models", async () => {
|
||||||
class TestACPAgentClient extends ACPAgentClient {
|
class TestACPAgentClient extends ACPAgentClient {
|
||||||
protected override async spawnProcess(): Promise<any> {
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||||
return {
|
return {
|
||||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||||
connection: {
|
connection: {
|
||||||
@@ -237,7 +238,7 @@ describe("ACPAgentClient modelTransformer", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
initialize: { agentCapabilities: {} },
|
initialize: { agentCapabilities: {} },
|
||||||
};
|
} as unknown as SpawnedACPProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async closeProbe(): Promise<void> {}
|
protected override async closeProbe(): Promise<void> {}
|
||||||
@@ -250,7 +251,7 @@ describe("ACPAgentClient modelTransformer", () => {
|
|||||||
modelTransformer: transformPiModels,
|
modelTransformer: transformPiModels,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(client.listModels()).resolves.toEqual([
|
await expect(client.listModels({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual([
|
||||||
{
|
{
|
||||||
provider: "pi",
|
provider: "pi",
|
||||||
id: "openrouter/openai/gpt-4.1-mini",
|
id: "openrouter/openai/gpt-4.1-mini",
|
||||||
@@ -266,7 +267,7 @@ describe("ACPAgentClient modelTransformer", () => {
|
|||||||
|
|
||||||
describe("ACPAgentClient sessionResponseTransformer", () => {
|
describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||||
class TestACPAgentClient extends ACPAgentClient {
|
class TestACPAgentClient extends ACPAgentClient {
|
||||||
protected override async spawnProcess(): Promise<any> {
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||||
const response: SessionStateResponse = {
|
const response: SessionStateResponse = {
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
modes: {
|
modes: {
|
||||||
@@ -283,7 +284,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
|||||||
newSession: vi.fn().mockResolvedValue(response),
|
newSession: vi.fn().mockResolvedValue(response),
|
||||||
},
|
},
|
||||||
initialize: { agentCapabilities: {} },
|
initialize: { agentCapabilities: {} },
|
||||||
};
|
} as unknown as SpawnedACPProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async closeProbe(): Promise<void> {}
|
protected override async closeProbe(): Promise<void> {}
|
||||||
@@ -304,7 +305,7 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(client.listModes()).resolves.toEqual([
|
await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([
|
||||||
{
|
{
|
||||||
id: "review",
|
id: "review",
|
||||||
label: "Review",
|
label: "Review",
|
||||||
@@ -315,9 +316,44 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ACPAgentClient listModes", () => {
|
describe("ACPAgentClient listModes", () => {
|
||||||
|
test("passes the requested cwd to list model and mode probes", async () => {
|
||||||
|
const newSession = vi.fn().mockResolvedValue({ modes: null, models: null, configOptions: [] });
|
||||||
|
|
||||||
|
class TestACPAgentClient extends ACPAgentClient {
|
||||||
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||||
|
return {
|
||||||
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||||
|
connection: { newSession },
|
||||||
|
initialize: { agentCapabilities: {} },
|
||||||
|
} as unknown as SpawnedACPProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async closeProbe(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new TestACPAgentClient({
|
||||||
|
provider: "pi",
|
||||||
|
logger: createTestLogger(),
|
||||||
|
defaultCommand: ["test-acp"],
|
||||||
|
defaultModes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.listModels({ cwd: "/tmp/acp-model-cwd", force: false });
|
||||||
|
await client.listModes({ cwd: "/tmp/acp-mode-cwd", force: false });
|
||||||
|
|
||||||
|
expect(newSession).toHaveBeenNthCalledWith(1, {
|
||||||
|
cwd: "/tmp/acp-model-cwd",
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
expect(newSession).toHaveBeenNthCalledWith(2, {
|
||||||
|
cwd: "/tmp/acp-mode-cwd",
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => {
|
test("returns an empty array when no ACP modes are reported and fallback modes are empty", async () => {
|
||||||
class TestACPAgentClient extends ACPAgentClient {
|
class TestACPAgentClient extends ACPAgentClient {
|
||||||
protected override async spawnProcess(): Promise<any> {
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||||
return {
|
return {
|
||||||
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
|
||||||
connection: {
|
connection: {
|
||||||
@@ -340,7 +376,7 @@ describe("ACPAgentClient listModes", () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
initialize: { agentCapabilities: {} },
|
initialize: { agentCapabilities: {} },
|
||||||
};
|
} as unknown as SpawnedACPProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async closeProbe(): Promise<void> {}
|
protected override async closeProbe(): Promise<void> {}
|
||||||
@@ -353,7 +389,7 @@ describe("ACPAgentClient listModes", () => {
|
|||||||
defaultModes: [],
|
defaultModes: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(client.listModes()).resolves.toEqual([]);
|
await expect(client.listModes({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ type ACPAgentSessionOptions = {
|
|||||||
initialCommandsWaitTimeoutMs?: number;
|
initialCommandsWaitTimeoutMs?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SpawnedACPProcess = {
|
export type SpawnedACPProcess = {
|
||||||
child: ChildProcessWithoutNullStreams;
|
child: ChildProcessWithoutNullStreams;
|
||||||
connection: ClientSideConnection;
|
connection: ClientSideConnection;
|
||||||
initialize: InitializeResponse;
|
initialize: InitializeResponse;
|
||||||
@@ -394,8 +394,8 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
const cwd = options?.cwd ?? process.cwd();
|
const { cwd } = options;
|
||||||
const probe = await this.spawnProcess(PROBE_ENV);
|
const probe = await this.spawnProcess(PROBE_ENV);
|
||||||
try {
|
try {
|
||||||
const response = await probe.connection.newSession({
|
const response = await probe.connection.newSession({
|
||||||
@@ -414,8 +414,8 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModes(options?: ListModesOptions): Promise<AgentMode[]> {
|
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
|
||||||
const cwd = options?.cwd ?? process.cwd();
|
const { cwd } = options;
|
||||||
const probe = await this.spawnProcess(PROBE_ENV);
|
const probe = await this.spawnProcess(PROBE_ENV);
|
||||||
try {
|
try {
|
||||||
const response = await probe.connection.newSession({
|
const response = await probe.connection.newSession({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
|||||||
|
|
||||||
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
|
import type { AgentSession, AgentStreamEvent, ToolCallTimelineItem } from "../agent-sdk-types.js";
|
||||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||||
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
|
|
||||||
@@ -40,21 +41,7 @@ async function nextStreamEvent(
|
|||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
label: string,
|
label: string,
|
||||||
): Promise<IteratorResult<AgentStreamEvent>> {
|
): Promise<IteratorResult<AgentStreamEvent>> {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
return await withTimeout(stream.next(), timeoutMs, `Timed out waiting for ${label}`);
|
||||||
try {
|
|
||||||
return await Promise.race([
|
|
||||||
stream.next(),
|
|
||||||
new Promise<never>((_, reject) => {
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
reject(new Error(`Timed out waiting for ${label}`));
|
|
||||||
}, timeoutMs);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectUntilTerminal(
|
async function collectUntilTerminal(
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ describe("ClaudeAgentClient.listModels", () => {
|
|||||||
|
|
||||||
test("returns hardcoded claude models", async () => {
|
test("returns hardcoded claude models", async () => {
|
||||||
const client = new ClaudeAgentClient({ logger });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
const models = await client.listModels();
|
const models = await client.listModels({ cwd: "/tmp/claude-models", force: false });
|
||||||
|
|
||||||
expect(models.map((m) => m.id)).toEqual([
|
expect(models.map((m) => m.id)).toEqual([
|
||||||
"claude-opus-4-7[1m]",
|
"claude-opus-4-7[1m]",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import type {
|
|||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
import { applyProviderEnv, type ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
import { execCommand, spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||||
|
|
||||||
@@ -1107,7 +1108,8 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
|
// Claude exposes a static catalog here; cwd/force are intentionally irrelevant.
|
||||||
return getClaudeModels();
|
return getClaudeModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1157,7 +1159,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
try {
|
try {
|
||||||
const models = await this.listModels();
|
const models = await this.listModels({ cwd: os.homedir(), force: false });
|
||||||
modelsValue = String(models.length);
|
modelsValue = String(models.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||||
@@ -2097,12 +2099,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
this.logger.trace({ label }, "Claude query operation wait start");
|
this.logger.trace({ label }, "Claude query operation wait start");
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await withTimeout(promise, 3_000, "timeout");
|
||||||
promise,
|
|
||||||
new Promise<never>((_, reject) => {
|
|
||||||
setTimeout(() => reject(new Error("timeout")), 3_000);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{ label, durationMs: Date.now() - startedAt },
|
{ label, durationMs: Date.now() - startedAt },
|
||||||
"Claude query operation settled",
|
"Claude query operation settled",
|
||||||
|
|||||||
@@ -222,21 +222,26 @@ describe("Codex app-server provider (e2e)", () => {
|
|||||||
"lists models and runs a simple prompt",
|
"lists models and runs a simple prompt",
|
||||||
async () => {
|
async () => {
|
||||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||||
const models = await client.listModels();
|
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-"));
|
||||||
|
const models = await client.listModels({ cwd, force: false });
|
||||||
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
|
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
|
||||||
|
|
||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
|
cwd,
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
model: CODEX_TEST_MODEL,
|
model: CODEX_TEST_MODEL,
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
});
|
});
|
||||||
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true);
|
try {
|
||||||
|
expect(session.features?.some((feature) => feature.id === "plan_mode")).toBe(true);
|
||||||
|
|
||||||
const result = await session.run("Say hello in one sentence.");
|
const result = await session.run("Say hello in one sentence.");
|
||||||
expect(result.finalText.length).toBeGreaterThan(0);
|
expect(result.finalText.length).toBeGreaterThan(0);
|
||||||
await session.close();
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
30000,
|
30000,
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
|
|||||||
process.on("uncaughtException", onUncaught);
|
process.on("uncaughtException", onUncaught);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(client.listModels()).rejects.toThrow();
|
await expect(client.listModels({ cwd: "/tmp/codex-models", force: false })).rejects.toThrow();
|
||||||
// Drain microtask queue to ensure no deferred uncaught errors
|
// Drain microtask queue to ensure no deferred uncaught errors
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
expect(uncaughtErrors).toHaveLength(0);
|
expect(uncaughtErrors).toHaveLength(0);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
@@ -4137,7 +4138,8 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
|
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
|
||||||
const child = await this.spawnAppServer();
|
const child = await this.spawnAppServer();
|
||||||
const client = new CodexAppServerClient(child, this.logger);
|
const client = new CodexAppServerClient(child, this.logger);
|
||||||
|
|
||||||
@@ -4247,7 +4249,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
entries.push({ label: "Models", value: "Not checked" });
|
entries.push({ label: "Models", value: "Not checked" });
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const models = await this.listModels();
|
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||||
entries.push({ label: "Models", value: String(models.length) });
|
entries.push({ label: "Models", value: String(models.length) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
entries.push({
|
entries.push({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||||
@@ -69,7 +70,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
|||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
try {
|
try {
|
||||||
const models = await this.listModels();
|
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||||
modelsValue = String(models.length);
|
modelsValue = String(models.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||||
@@ -81,7 +82,7 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
|||||||
|
|
||||||
if (!modelsValue.startsWith("Error -")) {
|
if (!modelsValue.startsWith("Error -")) {
|
||||||
try {
|
try {
|
||||||
await this.listModes();
|
await this.listModes({ cwd: homedir(), force: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status = formatDiagnosticStatus(available, {
|
status = formatDiagnosticStatus(available, {
|
||||||
source: "mode fetch",
|
source: "mode fetch",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
test("default model is a five minute foreground stream", async () => {
|
test("default model is a five minute foreground stream", async () => {
|
||||||
const client = new MockLoadTestAgentClient();
|
const client = new MockLoadTestAgentClient();
|
||||||
|
|
||||||
const models = await client.listModels();
|
const models = await client.listModels({ cwd: "/tmp/mock-models", force: false });
|
||||||
|
|
||||||
expect(models[0]).toMatchObject({
|
expect(models[0]).toMatchObject({
|
||||||
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
|
ListModesOptions,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
@@ -197,11 +198,11 @@ export class MockLoadTestAgentClient implements AgentClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
return MODELS;
|
return MODELS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModes(): Promise<AgentMode[]> {
|
async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
|
||||||
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
|
return getAgentProviderDefinition(MOCK_LOAD_TEST_PROVIDER_ID).modes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-2
@@ -50,11 +50,14 @@ describe("OpenCodeAgentClient.listModels timeout", () => {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||||
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }),
|
acquire: vi.fn().mockResolvedValue({
|
||||||
|
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||||
|
release: vi.fn(),
|
||||||
|
}),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
const modelsPromise = client.listModels();
|
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(15_000);
|
await vi.advanceTimersByTimeAsync(15_000);
|
||||||
|
|
||||||
@@ -66,4 +69,28 @@ describe("OpenCodeAgentClient.listModels timeout", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes explicit refresh force through server acquisition", async () => {
|
||||||
|
vi.mocked(createOpencodeClient).mockReturnValue({
|
||||||
|
provider: {
|
||||||
|
list: vi.fn().mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
connected: ["openai"],
|
||||||
|
all: [{ id: "openai", name: "OpenAI", models: {} }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
const acquire = vi.fn().mockResolvedValue({
|
||||||
|
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||||
|
release: vi.fn(),
|
||||||
|
});
|
||||||
|
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ acquire } as never);
|
||||||
|
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
|
|
||||||
|
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
|
||||||
|
|
||||||
|
expect(acquire).toHaveBeenCalledWith({ force: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-1
@@ -66,7 +66,10 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({
|
||||||
ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }),
|
acquire: vi.fn().mockResolvedValue({
|
||||||
|
server: { port: 1234, url: "http://127.0.0.1:1234" },
|
||||||
|
release: vi.fn(),
|
||||||
|
}),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger());
|
const client = new OpenCodeAgentClient(createTestLogger());
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
|||||||
logger.info("beforeAll: Starting model selection");
|
logger.info("beforeAll: Starting model selection");
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(logger);
|
const client = new OpenCodeAgentClient(logger);
|
||||||
const models = await client.listModels();
|
const models = await client.listModels({ cwd: os.homedir(), force: false });
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ modelCount: models.length, elapsed: Date.now() - startTime },
|
{ modelCount: models.length, elapsed: Date.now() - startTime },
|
||||||
@@ -182,7 +182,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
|
|||||||
|
|
||||||
test("listModels returns models with required fields", async () => {
|
test("listModels returns models with required fields", async () => {
|
||||||
const client = new OpenCodeAgentClient(logger);
|
const client = new OpenCodeAgentClient(logger);
|
||||||
const models = await client.listModels();
|
const models = await client.listModels({ cwd: os.homedir(), force: false });
|
||||||
|
|
||||||
// HARD ASSERT: Returns an array
|
// HARD ASSERT: Returns an array
|
||||||
expect(Array.isArray(models)).toBe(true);
|
expect(Array.isArray(models)).toBe(true);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import {
|
import {
|
||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
type AssistantMessage as OpenCodeAssistantMessage,
|
type AssistantMessage as OpenCodeAssistantMessage,
|
||||||
@@ -45,6 +46,7 @@ import {
|
|||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||||
|
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||||
import { spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||||
import {
|
import {
|
||||||
@@ -700,9 +702,10 @@ export const __openCodeInternals = {
|
|||||||
export class OpenCodeServerManager {
|
export class OpenCodeServerManager {
|
||||||
private static instance: OpenCodeServerManager | null = null;
|
private static instance: OpenCodeServerManager | null = null;
|
||||||
private static exitHandlerRegistered = false;
|
private static exitHandlerRegistered = false;
|
||||||
private server: ChildProcess | null = null;
|
private currentServer: OpenCodeServerGeneration | null = null;
|
||||||
private port: number | null = null;
|
private retiredServers = new Set<OpenCodeServerGeneration>();
|
||||||
private startPromise: Promise<{ port: number; url: string }> | null = null;
|
private startPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||||
|
private forcedRefreshPromise: Promise<OpenCodeServerGeneration> | null = null;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly runtimeSettingsKey: string;
|
private readonly runtimeSettingsKey: string;
|
||||||
@@ -741,9 +744,7 @@ export class OpenCodeServerManager {
|
|||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
const instance = OpenCodeServerManager.instance;
|
const instance = OpenCodeServerManager.instance;
|
||||||
if (instance?.server && !instance.server.killed) {
|
void instance?.shutdown();
|
||||||
instance.server.kill("SIGTERM");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
process.on("exit", cleanup);
|
process.on("exit", cleanup);
|
||||||
@@ -752,35 +753,99 @@ export class OpenCodeServerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async ensureRunning(): Promise<{ port: number; url: string }> {
|
async ensureRunning(): Promise<{ port: number; url: string }> {
|
||||||
|
const acquisition = await this.acquire({ force: false });
|
||||||
|
acquisition.release();
|
||||||
|
return acquisition.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire(options: { force: boolean }): Promise<{
|
||||||
|
server: { port: number; url: string };
|
||||||
|
release: () => void;
|
||||||
|
}> {
|
||||||
|
const server = options.force
|
||||||
|
? await this.getForcedRefreshServer()
|
||||||
|
: await this.getCurrentServer();
|
||||||
|
server.refCount += 1;
|
||||||
|
let released = false;
|
||||||
|
return {
|
||||||
|
server: { port: server.port, url: server.url },
|
||||||
|
release: () => {
|
||||||
|
if (released) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
released = true;
|
||||||
|
server.refCount -= 1;
|
||||||
|
this.cleanupRetiredServers();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getForcedRefreshServer(): Promise<OpenCodeServerGeneration> {
|
||||||
|
if (this.forcedRefreshPromise) {
|
||||||
|
return this.forcedRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.forcedRefreshPromise = Promise.resolve()
|
||||||
|
.then(async () => {
|
||||||
|
await this.rotateCurrentServer();
|
||||||
|
return this.getCurrentServer();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.forcedRefreshPromise = null;
|
||||||
|
});
|
||||||
|
return this.forcedRefreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCurrentServer(): Promise<OpenCodeServerGeneration> {
|
||||||
if (this.startPromise) {
|
if (this.startPromise) {
|
||||||
return this.startPromise;
|
return this.startPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.server && this.port && !this.server.killed) {
|
if (this.currentServer && !this.currentServer.process.killed) {
|
||||||
return { port: this.port, url: `http://127.0.0.1:${this.port}` };
|
return this.currentServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.startPromise = this.startServer();
|
this.startPromise = this.startServer();
|
||||||
try {
|
try {
|
||||||
const result = await this.startPromise;
|
const result = await this.startPromise;
|
||||||
|
if (!result.retired) {
|
||||||
|
this.currentServer = result;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} finally {
|
} finally {
|
||||||
this.startPromise = null;
|
this.startPromise = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startServer(): Promise<{ port: number; url: string }> {
|
private async rotateCurrentServer(): Promise<void> {
|
||||||
this.port = await findAvailablePort();
|
const existing = this.currentServer;
|
||||||
const url = `http://127.0.0.1:${this.port}`;
|
if (existing) {
|
||||||
|
existing.retired = true;
|
||||||
|
this.retiredServers.add(existing);
|
||||||
|
this.currentServer = null;
|
||||||
|
this.cleanupRetiredServers();
|
||||||
|
}
|
||||||
|
if (this.startPromise) {
|
||||||
|
const pending = await this.startPromise;
|
||||||
|
pending.retired = true;
|
||||||
|
this.retiredServers.add(pending);
|
||||||
|
this.currentServer = null;
|
||||||
|
this.cleanupRetiredServers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startServer(): Promise<OpenCodeServerGeneration> {
|
||||||
|
const port = await findAvailablePort();
|
||||||
|
const url = `http://127.0.0.1:${port}`;
|
||||||
const launchPrefix = await resolveProviderCommandPrefix(
|
const launchPrefix = await resolveProviderCommandPrefix(
|
||||||
this.runtimeSettings?.command,
|
this.runtimeSettings?.command,
|
||||||
resolveOpenCodeBinary,
|
resolveOpenCodeBinary,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.server = spawnProcess(
|
const serverProcess = spawnProcess(
|
||||||
launchPrefix.command,
|
launchPrefix.command,
|
||||||
[...launchPrefix.args, "serve", "--port", String(this.port)],
|
[...launchPrefix.args, "serve", "--port", String(port)],
|
||||||
{
|
{
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: applyProviderEnv(process.env, this.runtimeSettings),
|
env: applyProviderEnv(process.env, this.runtimeSettings),
|
||||||
@@ -794,54 +859,94 @@ export class OpenCodeServerManager {
|
|||||||
}
|
}
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
this.server.stdout?.on("data", (data: Buffer) => {
|
serverProcess.stdout?.on("data", (data: Buffer) => {
|
||||||
const output = data.toString();
|
const output = data.toString();
|
||||||
if (output.includes("listening on") && !started) {
|
if (output.includes("listening on") && !started) {
|
||||||
started = true;
|
started = true;
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
resolve({ port: this.port!, url });
|
resolve({
|
||||||
|
process: serverProcess,
|
||||||
|
port,
|
||||||
|
url,
|
||||||
|
refCount: 0,
|
||||||
|
retired: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.stderr?.on("data", (data: Buffer) => {
|
serverProcess.stderr?.on("data", (data: Buffer) => {
|
||||||
this.logger.error({ stderr: data.toString().trim() }, "OpenCode server stderr");
|
this.logger.error({ stderr: data.toString().trim() }, "OpenCode server stderr");
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.on("error", (error) => {
|
serverProcess.on("error", (error) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.server.on("exit", (code) => {
|
serverProcess.on("exit", (code) => {
|
||||||
if (!started) {
|
if (!started) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
reject(new Error(`OpenCode server exited with code ${code}`));
|
reject(new Error(`OpenCode server exited with code ${code}`));
|
||||||
}
|
}
|
||||||
this.server = null;
|
if (this.currentServer?.process === serverProcess) {
|
||||||
this.port = null;
|
this.currentServer = null;
|
||||||
|
}
|
||||||
|
for (const retired of Array.from(this.retiredServers)) {
|
||||||
|
if (retired.process === serverProcess) {
|
||||||
|
this.retiredServers.delete(retired);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
if (this.server && !this.server.killed) {
|
const servers = [
|
||||||
this.server.kill("SIGTERM");
|
...(this.currentServer ? [this.currentServer] : []),
|
||||||
await new Promise<void>((resolve) => {
|
...Array.from(this.retiredServers),
|
||||||
const timeout = setTimeout(() => {
|
];
|
||||||
this.server?.kill("SIGKILL");
|
for (const server of servers) {
|
||||||
resolve();
|
await this.killServer(server);
|
||||||
}, 5000);
|
|
||||||
this.server?.on("exit", () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
this.server = null;
|
this.currentServer = null;
|
||||||
this.port = null;
|
this.retiredServers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupRetiredServers(): void {
|
||||||
|
for (const server of Array.from(this.retiredServers)) {
|
||||||
|
if (server.refCount === 0) {
|
||||||
|
this.retiredServers.delete(server);
|
||||||
|
void this.killServer(server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
|
||||||
|
if (server.process.killed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
server.process.kill("SIGKILL");
|
||||||
|
resolve();
|
||||||
|
}, 5000);
|
||||||
|
server.process.on("exit", () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
server.process.kill("SIGTERM");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenCodeServerGeneration = {
|
||||||
|
process: ChildProcess;
|
||||||
|
port: number;
|
||||||
|
url: string;
|
||||||
|
refCount: number;
|
||||||
|
retired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export class OpenCodeAgentClient implements AgentClient {
|
export class OpenCodeAgentClient implements AgentClient {
|
||||||
readonly provider: "opencode" = "opencode";
|
readonly provider: "opencode" = "opencode";
|
||||||
readonly capabilities = OPENCODE_CAPABILITIES;
|
readonly capabilities = OPENCODE_CAPABILITIES;
|
||||||
@@ -862,40 +967,43 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
_launchContext?: AgentLaunchContext,
|
_launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const openCodeConfig = this.assertConfig(config);
|
const openCodeConfig = this.assertConfig(config);
|
||||||
const { url } = await this.serverManager.ensureRunning();
|
const acquisition = await this.serverManager.acquire({ force: false });
|
||||||
|
const { url } = acquisition.server;
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
baseUrl: url,
|
baseUrl: url,
|
||||||
directory: openCodeConfig.cwd,
|
directory: openCodeConfig.cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set a timeout for session creation to fail fast
|
try {
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const response = await withTimeout(
|
||||||
setTimeout(() => reject(new Error("OpenCode session.create timed out after 10s")), 10_000);
|
client.session.create({ directory: openCodeConfig.cwd }),
|
||||||
});
|
10_000,
|
||||||
|
"OpenCode session.create timed out after 10s",
|
||||||
|
);
|
||||||
|
|
||||||
const response = await Promise.race([
|
if (response.error) {
|
||||||
client.session.create({ directory: openCodeConfig.cwd }),
|
throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.error)}`);
|
||||||
timeoutPromise,
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
if (response.error) {
|
const session = response.data;
|
||||||
throw new Error(`Failed to create OpenCode session: ${JSON.stringify(response.error)}`);
|
if (!session) {
|
||||||
|
throw new Error("OpenCode session creation returned no data");
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||||
|
|
||||||
|
return new OpenCodeAgentSession(
|
||||||
|
openCodeConfig,
|
||||||
|
client,
|
||||||
|
session.id,
|
||||||
|
this.logger,
|
||||||
|
new Map(this.modelContextWindows),
|
||||||
|
acquisition.release,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
acquisition.release();
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = response.data;
|
|
||||||
if (!session) {
|
|
||||||
throw new Error("OpenCode session creation returned no data");
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
|
||||||
|
|
||||||
return new OpenCodeAgentSession(
|
|
||||||
openCodeConfig,
|
|
||||||
client,
|
|
||||||
session.id,
|
|
||||||
this.logger,
|
|
||||||
new Map(this.modelContextWindows),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeSession(
|
async resumeSession(
|
||||||
@@ -914,119 +1022,125 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
const openCodeConfig = this.assertConfig(config);
|
const openCodeConfig = this.assertConfig(config);
|
||||||
const { url } = await this.serverManager.ensureRunning();
|
const acquisition = await this.serverManager.acquire({ force: false });
|
||||||
|
const { url } = acquisition.server;
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
baseUrl: url,
|
baseUrl: url,
|
||||||
directory: openCodeConfig.cwd,
|
directory: openCodeConfig.cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
try {
|
||||||
|
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||||
|
|
||||||
return new OpenCodeAgentSession(
|
return new OpenCodeAgentSession(
|
||||||
openCodeConfig,
|
openCodeConfig,
|
||||||
client,
|
client,
|
||||||
handle.sessionId,
|
handle.sessionId,
|
||||||
this.logger,
|
this.logger,
|
||||||
new Map(this.modelContextWindows),
|
new Map(this.modelContextWindows),
|
||||||
);
|
acquisition.release,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
acquisition.release();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
const { url } = await this.serverManager.ensureRunning();
|
const acquisition = await this.serverManager.acquire({ force: options.force });
|
||||||
|
const { url } = acquisition.server;
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
baseUrl: url,
|
baseUrl: url,
|
||||||
directory: options?.cwd ?? process.cwd(),
|
directory: options.cwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background model discovery can be legitimately slow while OpenCode refreshes
|
try {
|
||||||
// provider state, so allow longer than turn execution paths.
|
// Background model discovery can be legitimately slow while OpenCode refreshes
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
// provider state, so allow longer than turn execution paths.
|
||||||
setTimeout(
|
const response = await withTimeout(
|
||||||
() =>
|
client.provider.list({ directory: options.cwd }),
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
|
OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
|
||||||
|
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const response = await Promise.race([
|
if (response.error) {
|
||||||
client.provider.list({ directory: options?.cwd ?? process.cwd() }),
|
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
||||||
timeoutPromise,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
throw new Error(`Failed to fetch OpenCode providers: ${JSON.stringify(response.error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const providers = response.data;
|
|
||||||
if (!providers) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include models from connected providers (ones that are actually available)
|
|
||||||
const connectedProviderIds = new Set(providers.connected);
|
|
||||||
|
|
||||||
// Fail fast if no providers are connected
|
|
||||||
if (connectedProviderIds.size === 0) {
|
|
||||||
throw new Error(
|
|
||||||
"OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const models: AgentModelDefinition[] = [];
|
|
||||||
this.modelContextWindows.clear();
|
|
||||||
for (const provider of providers.all) {
|
|
||||||
// Skip providers that aren't connected/configured
|
|
||||||
if (!connectedProviderIds.has(provider.id)) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
const providers = response.data;
|
||||||
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
|
if (!providers) {
|
||||||
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
|
return [];
|
||||||
if (contextWindowMaxTokens !== undefined) {
|
}
|
||||||
this.modelContextWindows.set(
|
|
||||||
buildOpenCodeModelLookupKey(provider.id, modelId),
|
// Only include models from connected providers (ones that are actually available)
|
||||||
contextWindowMaxTokens,
|
const connectedProviderIds = new Set(providers.connected);
|
||||||
);
|
|
||||||
|
// Fail fast if no providers are connected
|
||||||
|
if (connectedProviderIds.size === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"OpenCode has no connected providers. Please authenticate with at least one provider (e.g., openai, anthropic) or set appropriate environment variables (e.g., OPENAI_API_KEY).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const models: AgentModelDefinition[] = [];
|
||||||
|
this.modelContextWindows.clear();
|
||||||
|
for (const provider of providers.all) {
|
||||||
|
// Skip providers that aren't connected/configured
|
||||||
|
if (!connectedProviderIds.has(provider.id)) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
models.push(definition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return models;
|
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||||
|
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
|
||||||
|
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
|
||||||
|
if (contextWindowMaxTokens !== undefined) {
|
||||||
|
this.modelContextWindows.set(
|
||||||
|
buildOpenCodeModelLookupKey(provider.id, modelId),
|
||||||
|
contextWindowMaxTokens,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
models.push(definition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models;
|
||||||
|
} finally {
|
||||||
|
acquisition.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModes(options?: ListModesOptions): Promise<AgentMode[]> {
|
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
|
||||||
const { url } = await this.serverManager.ensureRunning();
|
const acquisition = await this.serverManager.acquire({ force: options.force });
|
||||||
const directory = options?.cwd ?? process.cwd();
|
const { url } = acquisition.server;
|
||||||
|
const directory = options.cwd;
|
||||||
const client = createOpencodeClient({ baseUrl: url, directory });
|
const client = createOpencodeClient({ baseUrl: url, directory });
|
||||||
|
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
try {
|
||||||
setTimeout(() => reject(new Error("OpenCode app.agents timed out after 10s")), 10_000);
|
const response = await withTimeout(
|
||||||
});
|
client.app.agents({ directory }),
|
||||||
|
10_000,
|
||||||
|
"OpenCode app.agents timed out after 10s",
|
||||||
|
);
|
||||||
|
|
||||||
const response = await Promise.race([client.app.agents({ directory }), timeoutPromise]);
|
if (response.error || !response.data) {
|
||||||
|
return DEFAULT_MODES;
|
||||||
|
}
|
||||||
|
|
||||||
if (response.error || !response.data) {
|
const discovered = response.data
|
||||||
return DEFAULT_MODES;
|
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
||||||
|
.map((agent) => ({
|
||||||
|
id: agent.name,
|
||||||
|
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
|
||||||
|
description:
|
||||||
|
typeof agent.description === "string" && agent.description.trim().length > 0
|
||||||
|
? agent.description.trim()
|
||||||
|
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES;
|
||||||
|
} finally {
|
||||||
|
acquisition.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
const discovered = response.data
|
|
||||||
.filter((agent) => agent.mode === "primary" && agent.hidden !== true)
|
|
||||||
.map((agent) => ({
|
|
||||||
id: agent.name,
|
|
||||||
label: agent.name.charAt(0).toUpperCase() + agent.name.slice(1),
|
|
||||||
description:
|
|
||||||
typeof agent.description === "string" && agent.description.trim().length > 0
|
|
||||||
? agent.description.trim()
|
|
||||||
: DEFAULT_MODES.find((mode) => mode.id === agent.name)?.description,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return discovered.length > 0 ? sortOpenCodeModes(discovered) : DEFAULT_MODES;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listPersistedAgents(
|
async listPersistedAgents(
|
||||||
@@ -1061,7 +1175,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
try {
|
try {
|
||||||
const models = await this.listModels();
|
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||||
modelsValue = String(models.length);
|
modelsValue = String(models.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||||
@@ -1073,7 +1187,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
if (!modelsValue.startsWith("Error -")) {
|
if (!modelsValue.startsWith("Error -")) {
|
||||||
try {
|
try {
|
||||||
await this.listModes();
|
await this.listModes({ cwd: homedir(), force: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status = formatDiagnosticStatus(available, {
|
status = formatDiagnosticStatus(available, {
|
||||||
source: "mode fetch",
|
source: "mode fetch",
|
||||||
@@ -1671,12 +1785,14 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
private readonly runningToolCalls = new Map<string, ToolCallTimelineItem>();
|
||||||
private selectedModelContextWindowMaxTokens: number | undefined;
|
private selectedModelContextWindowMaxTokens: number | undefined;
|
||||||
|
private releaseServer: (() => void) | null;
|
||||||
constructor(
|
constructor(
|
||||||
config: OpenCodeAgentConfig,
|
config: OpenCodeAgentConfig,
|
||||||
client: OpencodeClient,
|
client: OpencodeClient,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||||
|
releaseServer?: () => void,
|
||||||
) {
|
) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
@@ -1684,6 +1800,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
|
||||||
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
this.currentMode = normalizeOpenCodeModeId(config.modeId);
|
||||||
|
this.releaseServer = releaseServer ?? null;
|
||||||
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
|
||||||
config.model,
|
config.model,
|
||||||
);
|
);
|
||||||
@@ -2278,15 +2395,20 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
this.abortController?.abort();
|
try {
|
||||||
await reconcileOpenCodeSessionClose({
|
this.abortController?.abort();
|
||||||
client: this.client,
|
await reconcileOpenCodeSessionClose({
|
||||||
sessionId: this.sessionId,
|
client: this.client,
|
||||||
directory: this.config.cwd,
|
sessionId: this.sessionId,
|
||||||
logger: this.logger,
|
directory: this.config.cwd,
|
||||||
});
|
logger: this.logger,
|
||||||
this.subscribers.clear();
|
});
|
||||||
this.activeForegroundTurnId = null;
|
this.subscribers.clear();
|
||||||
|
this.activeForegroundTurnId = null;
|
||||||
|
} finally {
|
||||||
|
this.releaseServer?.();
|
||||||
|
this.releaseServer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
|
import { OpenCodeServerManager } from "./opencode-agent.js";
|
||||||
|
|
||||||
|
type FakeServerProcess = EventEmitter & {
|
||||||
|
killed: boolean;
|
||||||
|
kill: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FakeGeneration = {
|
||||||
|
process: FakeServerProcess;
|
||||||
|
port: number;
|
||||||
|
url: string;
|
||||||
|
refCount: number;
|
||||||
|
retired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("OpenCodeServerManager generations", () => {
|
||||||
|
test("rotation creates a new current server without killing a referenced old server", async () => {
|
||||||
|
const manager = createTestManager();
|
||||||
|
const first = createGeneration(4101);
|
||||||
|
const second = createGeneration(4102);
|
||||||
|
stubGenerations(manager, [first, second]);
|
||||||
|
|
||||||
|
const oldAcquisition = await manager.acquire({ force: false });
|
||||||
|
const newAcquisition = await manager.acquire({ force: true });
|
||||||
|
|
||||||
|
expect(oldAcquisition.server.url).toBe("http://127.0.0.1:4101");
|
||||||
|
expect(newAcquisition.server.url).toBe("http://127.0.0.1:4102");
|
||||||
|
expect(first.process.kill).not.toHaveBeenCalled();
|
||||||
|
expect(second.process.kill).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
newAcquisition.release();
|
||||||
|
oldAcquisition.release();
|
||||||
|
|
||||||
|
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new acquisitions after rotation use the new server", async () => {
|
||||||
|
const manager = createTestManager();
|
||||||
|
const first = createGeneration(4201);
|
||||||
|
const second = createGeneration(4202);
|
||||||
|
stubGenerations(manager, [first, second]);
|
||||||
|
|
||||||
|
const oldAcquisition = await manager.acquire({ force: false });
|
||||||
|
const rotatedAcquisition = await manager.acquire({ force: true });
|
||||||
|
rotatedAcquisition.release();
|
||||||
|
|
||||||
|
const nextAcquisition = await manager.acquire({ force: false });
|
||||||
|
|
||||||
|
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4202");
|
||||||
|
expect(first.process.kill).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
nextAcquisition.release();
|
||||||
|
oldAcquisition.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("concurrent forced acquisitions share one fresh generation", async () => {
|
||||||
|
const manager = createTestManager();
|
||||||
|
const first = createGeneration(4251);
|
||||||
|
const second = createGeneration(4252);
|
||||||
|
const third = createGeneration(4253);
|
||||||
|
const startServer = stubGenerations(manager, [first, second, third]);
|
||||||
|
|
||||||
|
const initialAcquisition = await manager.acquire({ force: false });
|
||||||
|
initialAcquisition.release();
|
||||||
|
|
||||||
|
const [modelsAcquisition, modesAcquisition] = await Promise.all([
|
||||||
|
manager.acquire({ force: true }),
|
||||||
|
manager.acquire({ force: true }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(modelsAcquisition.server.url).toBe("http://127.0.0.1:4252");
|
||||||
|
expect(modesAcquisition.server.url).toBe("http://127.0.0.1:4252");
|
||||||
|
expect(startServer).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
modesAcquisition.release();
|
||||||
|
modelsAcquisition.release();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("release is idempotent", async () => {
|
||||||
|
const manager = createTestManager();
|
||||||
|
const first = createGeneration(4301);
|
||||||
|
const second = createGeneration(4302);
|
||||||
|
stubGenerations(manager, [first, second]);
|
||||||
|
|
||||||
|
const oldAcquisition = await manager.acquire({ force: false });
|
||||||
|
const newAcquisition = await manager.acquire({ force: true });
|
||||||
|
newAcquisition.release();
|
||||||
|
|
||||||
|
oldAcquisition.release();
|
||||||
|
oldAcquisition.release();
|
||||||
|
|
||||||
|
expect(first.refCount).toBe(0);
|
||||||
|
expect(first.process.kill).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shutdown kills current and retired servers", async () => {
|
||||||
|
const manager = createTestManager();
|
||||||
|
const first = createGeneration(4401);
|
||||||
|
const second = createGeneration(4402);
|
||||||
|
stubGenerations(manager, [first, second]);
|
||||||
|
|
||||||
|
await manager.acquire({ force: false });
|
||||||
|
await manager.acquire({ force: true });
|
||||||
|
|
||||||
|
await manager.shutdown();
|
||||||
|
|
||||||
|
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
expect(second.process.kill).toHaveBeenCalledWith("SIGTERM");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("repeated rotations leave zero unreferenced retired servers", async () => {
|
||||||
|
const manager = createTestManager();
|
||||||
|
const first = createGeneration(4501);
|
||||||
|
const second = createGeneration(4502);
|
||||||
|
const third = createGeneration(4503);
|
||||||
|
stubGenerations(manager, [first, second, third]);
|
||||||
|
|
||||||
|
const firstAcquisition = await manager.acquire({ force: false });
|
||||||
|
const secondAcquisition = await manager.acquire({ force: true });
|
||||||
|
secondAcquisition.release();
|
||||||
|
const thirdAcquisition = await manager.acquire({ force: true });
|
||||||
|
thirdAcquisition.release();
|
||||||
|
firstAcquisition.release();
|
||||||
|
|
||||||
|
const retiredServers = (manager as unknown as { retiredServers: Set<FakeGeneration> })
|
||||||
|
.retiredServers;
|
||||||
|
expect(Array.from(retiredServers).filter((server) => server.refCount === 0)).toHaveLength(0);
|
||||||
|
expect(first.process.kill).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second.process.kill).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createTestManager(): OpenCodeServerManager {
|
||||||
|
const ManagerConstructor = OpenCodeServerManager as unknown as {
|
||||||
|
new (logger: ReturnType<typeof createTestLogger>): OpenCodeServerManager;
|
||||||
|
};
|
||||||
|
return new ManagerConstructor(createTestLogger());
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubGenerations(
|
||||||
|
manager: OpenCodeServerManager,
|
||||||
|
generations: FakeGeneration[],
|
||||||
|
): ReturnType<typeof vi.fn> {
|
||||||
|
const startServer = vi.fn(async () => {
|
||||||
|
const generation = generations.shift();
|
||||||
|
if (!generation) {
|
||||||
|
throw new Error("No fake OpenCode server generation available");
|
||||||
|
}
|
||||||
|
return generation;
|
||||||
|
});
|
||||||
|
(manager as unknown as { startServer: typeof startServer }).startServer = startServer;
|
||||||
|
return startServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGeneration(port: number): FakeGeneration {
|
||||||
|
const process = new EventEmitter() as FakeServerProcess;
|
||||||
|
process.killed = false;
|
||||||
|
process.kill = vi.fn((signal?: NodeJS.Signals) => {
|
||||||
|
process.killed = true;
|
||||||
|
process.emit("exit", signal ?? "SIGTERM");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
process,
|
||||||
|
port,
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
refCount: 0,
|
||||||
|
retired: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import type {
|
|||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
AgentUsage,
|
AgentUsage,
|
||||||
|
ListModesOptions,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
ToolCallDetail,
|
ToolCallDetail,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
@@ -1448,7 +1449,8 @@ export class PiDirectAgentClient implements AgentClient {
|
|||||||
return new PiDirectAgentSession(session, this.getModelRegistry(), mergedConfig);
|
return new PiDirectAgentSession(session, this.getModelRegistry(), mergedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
|
// Pi Direct uses an in-process global registry; cwd/force are intentionally irrelevant.
|
||||||
const models = this.getModelRegistry()
|
const models = this.getModelRegistry()
|
||||||
.getAll()
|
.getAll()
|
||||||
.map((model) => ({
|
.map((model) => ({
|
||||||
@@ -1467,7 +1469,7 @@ export class PiDirectAgentClient implements AgentClient {
|
|||||||
return transformPiModels(models);
|
return transformPiModels(models);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModes(): Promise<AgentMode[]> {
|
async listModes(_options: ListModesOptions): Promise<AgentMode[]> {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1504,7 +1506,7 @@ export class PiDirectAgentClient implements AgentClient {
|
|||||||
|
|
||||||
if (available) {
|
if (available) {
|
||||||
try {
|
try {
|
||||||
const models = await this.listModels();
|
const models = await this.listModels({ cwd: homedir(), force: false });
|
||||||
modelsValue = String(models.length);
|
modelsValue = String(models.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { tmpdir } from "os";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||||
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js";
|
||||||
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
||||||
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
|
import { deriveWorktreeProjectHash } from "../../utils/worktree.js";
|
||||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||||
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
|
||||||
@@ -21,26 +22,6 @@ function tmpCwd(): string {
|
|||||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withTimeout<T>(options: {
|
|
||||||
promise: Promise<T>;
|
|
||||||
timeoutMs: number;
|
|
||||||
label: string;
|
|
||||||
}): Promise<T> {
|
|
||||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
const timeout = new Promise<never>((_, reject) => {
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
reject(new Error(`Timed out after ${options.timeoutMs}ms (${options.label})`));
|
|
||||||
}, options.timeoutMs);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
return await Promise.race([options.promise, timeout]);
|
|
||||||
} finally {
|
|
||||||
if (timeoutHandle) {
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findTimelineToolCall(
|
function findTimelineToolCall(
|
||||||
messages: SessionOutboundMessage[],
|
messages: SessionOutboundMessage[],
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@@ -451,15 +451,20 @@ describe(
|
|||||||
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
|
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
|
||||||
async () => {
|
async () => {
|
||||||
const client = createPiClient();
|
const client = createPiClient();
|
||||||
const models = await client.listModels();
|
const cwd = tmpCwd("pi-list-models-");
|
||||||
|
try {
|
||||||
|
const models = await client.listModels({ cwd, force: false });
|
||||||
|
|
||||||
expect(models.length).toBeGreaterThan(0);
|
expect(models.length).toBeGreaterThan(0);
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
expect(model.provider).toBe("pi");
|
expect(model.provider).toBe("pi");
|
||||||
expect(typeof model.id).toBe("string");
|
expect(typeof model.id).toBe("string");
|
||||||
expect(model.id.length).toBeGreaterThan(0);
|
expect(model.id.length).toBeGreaterThan(0);
|
||||||
expect(typeof model.label).toBe("string");
|
expect(typeof model.label).toBe("string");
|
||||||
expect(model.label.length).toBeGreaterThan(0);
|
expect(model.label.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
PI_TEST_TIMEOUT_MS,
|
PI_TEST_TIMEOUT_MS,
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { execSync } from "child_process";
|
import { execSync } from "child_process";
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { homedir, tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { CheckoutPrStatusSchema } from "../shared/messages.js";
|
import { CheckoutPrStatusSchema } from "../shared/messages.js";
|
||||||
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
|
import { normalizeCheckoutPrStatusPayload, Session } from "./session.js";
|
||||||
|
import type {
|
||||||
|
AgentClient,
|
||||||
|
AgentMode,
|
||||||
|
AgentModelDefinition,
|
||||||
|
ListModesOptions,
|
||||||
|
ListModelsOptions,
|
||||||
|
} from "./agent/agent-sdk-types.js";
|
||||||
|
import type { ProviderDefinition } from "./agent/provider-registry.js";
|
||||||
|
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||||
|
|
||||||
const checkoutGitMocks = vi.hoisted(() => ({
|
const checkoutGitMocks = vi.hoisted(() => ({
|
||||||
checkoutResolvedBranch: vi.fn(),
|
checkoutResolvedBranch: vi.fn(),
|
||||||
@@ -36,6 +45,31 @@ const paseoWorktreeServiceMocks = vi.hoisted(() => ({
|
|||||||
createPaseoWorktree: vi.fn(),
|
createPaseoWorktree: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type Deferred<T> = {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_CAPABILITIES = {
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function deferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
vi.mock("../utils/checkout-git.js", async (importOriginal) => {
|
vi.mock("../utils/checkout-git.js", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../utils/checkout-git.js")>();
|
const actual = await importOriginal<typeof import("../utils/checkout-git.js")>();
|
||||||
return {
|
return {
|
||||||
@@ -113,6 +147,7 @@ function createSessionForTest(options?: {
|
|||||||
scriptRuntimeStore?: unknown;
|
scriptRuntimeStore?: unknown;
|
||||||
getDaemonTcpPort?: () => number | null;
|
getDaemonTcpPort?: () => number | null;
|
||||||
getDaemonTcpHost?: () => string | null;
|
getDaemonTcpHost?: () => string | null;
|
||||||
|
providerSnapshotManager?: ProviderSnapshotManager;
|
||||||
messages?: unknown[];
|
messages?: unknown[];
|
||||||
}): Session {
|
}): Session {
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
@@ -165,6 +200,7 @@ function createSessionForTest(options?: {
|
|||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
terminalManager: (options?.terminalManager ?? null) as any,
|
terminalManager: (options?.terminalManager ?? null) as any,
|
||||||
|
providerSnapshotManager: options?.providerSnapshotManager,
|
||||||
scriptRouteStore: options?.scriptRouteStore as any,
|
scriptRouteStore: options?.scriptRouteStore as any,
|
||||||
scriptRuntimeStore: options?.scriptRuntimeStore as any,
|
scriptRuntimeStore: options?.scriptRuntimeStore as any,
|
||||||
getDaemonTcpPort: options?.getDaemonTcpPort,
|
getDaemonTcpPort: options?.getDaemonTcpPort,
|
||||||
@@ -207,6 +243,20 @@ function createWorkspaceGitSnapshot(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createProviderSnapshotManagerStub(): ProviderSnapshotManager {
|
||||||
|
const stub = {
|
||||||
|
getSnapshot: vi.fn(() => []),
|
||||||
|
refreshSnapshotForCwd: vi.fn(async () => {}),
|
||||||
|
refreshSettingsSnapshot: vi.fn(async () => {}),
|
||||||
|
warmUpSnapshotForCwd: vi.fn(async () => {}),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
};
|
||||||
|
stub.on.mockImplementation(() => stub);
|
||||||
|
stub.off.mockImplementation(() => stub);
|
||||||
|
return stub as unknown as ProviderSnapshotManager;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -243,6 +293,159 @@ describe("session PR status payload normalization", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("session provider refresh cwd routing", () => {
|
||||||
|
test("routes no-cwd provider snapshot refreshes through settings refresh", async () => {
|
||||||
|
const providerSnapshotManager = createProviderSnapshotManagerStub();
|
||||||
|
const session = createSessionForTest({ providerSnapshotManager });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "refresh_providers_snapshot_request",
|
||||||
|
providers: ["codex"],
|
||||||
|
requestId: "refresh-settings",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(providerSnapshotManager.refreshSettingsSnapshot).toHaveBeenCalledWith({
|
||||||
|
providers: ["codex"],
|
||||||
|
});
|
||||||
|
expect(providerSnapshotManager.refreshSnapshotForCwd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("routes cwd provider snapshot refreshes through workspace refresh", async () => {
|
||||||
|
const providerSnapshotManager = createProviderSnapshotManagerStub();
|
||||||
|
const session = createSessionForTest({ providerSnapshotManager });
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "refresh_providers_snapshot_request",
|
||||||
|
cwd: "/tmp/workspace-refresh",
|
||||||
|
providers: ["codex"],
|
||||||
|
requestId: "refresh-workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(providerSnapshotManager.refreshSnapshotForCwd).toHaveBeenCalledWith({
|
||||||
|
cwd: "/tmp/workspace-refresh",
|
||||||
|
providers: ["codex"],
|
||||||
|
});
|
||||||
|
expect(providerSnapshotManager.refreshSettingsSnapshot).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizes legacy model and mode list requests without cwd to home", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const session = createSessionForTest({ messages });
|
||||||
|
const fetchModels = vi.fn(async () => []);
|
||||||
|
const fetchModes = vi.fn(async () => []);
|
||||||
|
(session as unknown as { providerRegistry: unknown }).providerRegistry = {
|
||||||
|
codex: {
|
||||||
|
fetchModels,
|
||||||
|
fetchModes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "list_provider_models_request",
|
||||||
|
provider: "codex",
|
||||||
|
requestId: "models-home",
|
||||||
|
});
|
||||||
|
await session.handleMessage({
|
||||||
|
type: "list_provider_modes_request",
|
||||||
|
provider: "codex",
|
||||||
|
requestId: "modes-home",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchModels).toHaveBeenCalledWith({ cwd: homedir(), force: false });
|
||||||
|
expect(fetchModes).toHaveBeenCalledWith({ cwd: homedir(), force: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy model list request without cwd awaits loading snapshot without forced discovery", async () => {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const models = deferred<AgentModelDefinition[]>();
|
||||||
|
const fetchModels = vi.fn(
|
||||||
|
async (options: ListModelsOptions): Promise<AgentModelDefinition[]> => {
|
||||||
|
expect(options.cwd).toBe(homedir());
|
||||||
|
return models.promise;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const fetchModes = vi.fn(async (_options: ListModesOptions): Promise<AgentMode[]> => []);
|
||||||
|
const providerDefinition: ProviderDefinition = {
|
||||||
|
id: "codex",
|
||||||
|
label: "Codex",
|
||||||
|
description: "Codex test provider",
|
||||||
|
defaultModeId: null,
|
||||||
|
modes: [],
|
||||||
|
createClient: () =>
|
||||||
|
({
|
||||||
|
provider: "codex",
|
||||||
|
capabilities: TEST_CAPABILITIES,
|
||||||
|
async createSession() {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
},
|
||||||
|
async resumeSession() {
|
||||||
|
throw new Error("not implemented");
|
||||||
|
},
|
||||||
|
async listModels(options: ListModelsOptions) {
|
||||||
|
return fetchModels(options);
|
||||||
|
},
|
||||||
|
async isAvailable() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}) satisfies AgentClient,
|
||||||
|
fetchModels,
|
||||||
|
fetchModes,
|
||||||
|
};
|
||||||
|
const providerSnapshotManager = new ProviderSnapshotManager(
|
||||||
|
{ codex: providerDefinition },
|
||||||
|
pino({ level: "silent" }),
|
||||||
|
);
|
||||||
|
const session = createSessionForTest({ messages, providerSnapshotManager });
|
||||||
|
|
||||||
|
providerSnapshotManager.getSnapshot();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const responsePromise = session.handleMessage({
|
||||||
|
type: "list_provider_models_request",
|
||||||
|
provider: "codex",
|
||||||
|
requestId: "models-loading-home",
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchModels).toHaveBeenCalledWith({ cwd: homedir(), force: false });
|
||||||
|
expect(fetchModels).not.toHaveBeenCalledWith({ cwd: homedir(), force: true });
|
||||||
|
|
||||||
|
models.resolve([
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "GPT-5.4",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await responsePromise;
|
||||||
|
|
||||||
|
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchModels).not.toHaveBeenCalledWith({ cwd: homedir(), force: true });
|
||||||
|
expect(messages).toContainEqual({
|
||||||
|
type: "list_provider_models_response",
|
||||||
|
payload: {
|
||||||
|
provider: "codex",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
id: "gpt-5.4",
|
||||||
|
label: "GPT-5.4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
error: null,
|
||||||
|
fetchedAt: expect.any(String),
|
||||||
|
requestId: "models-loading-home",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
providerSnapshotManager.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("session checkout merge handling", () => {
|
describe("session checkout merge handling", () => {
|
||||||
test("uses workspace git service snapshot for merge-to-base preflight", async () => {
|
test("uses workspace git service snapshot for merge-to-base preflight", async () => {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ import type {
|
|||||||
ProviderOverride,
|
ProviderOverride,
|
||||||
} from "./agent/provider-launch-config.js";
|
} from "./agent/provider-launch-config.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
import { ProviderSnapshotManager, resolveSnapshotCwd } from "./agent/provider-snapshot-manager.js";
|
||||||
import type {
|
import type {
|
||||||
AgentTimelineCursor,
|
AgentTimelineCursor,
|
||||||
AgentTimelineFetchDirection,
|
AgentTimelineFetchDirection,
|
||||||
@@ -805,7 +805,7 @@ export class Session {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (this.providerSnapshotManager) {
|
if (this.providerSnapshotManager) {
|
||||||
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => {
|
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => {
|
||||||
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
|
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
|
||||||
const visibleEntries = entries.filter((entry) =>
|
const visibleEntries = entries.filter((entry) =>
|
||||||
this.isProviderVisibleToClient(entry.provider),
|
this.isProviderVisibleToClient(entry.provider),
|
||||||
@@ -3071,13 +3071,16 @@ export class Session {
|
|||||||
private async handleListProviderModelsRequest(
|
private async handleListProviderModelsRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const cwd = msg.cwd ? expandTilde(msg.cwd) : undefined;
|
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
|
||||||
const fetchedAt = new Date().toISOString();
|
const fetchedAt = new Date().toISOString();
|
||||||
const manager = this.providerSnapshotManager;
|
const manager = this.providerSnapshotManager;
|
||||||
|
|
||||||
if (!manager) {
|
if (!manager) {
|
||||||
try {
|
try {
|
||||||
const models = await this.providerRegistry[msg.provider].fetchModels({ cwd });
|
const models = await this.providerRegistry[msg.provider].fetchModels({
|
||||||
|
cwd,
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "list_provider_models_response",
|
type: "list_provider_models_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -3106,16 +3109,7 @@ export class Session {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const findEntry = () =>
|
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
|
||||||
manager.getSnapshot(cwd).find((candidate) => candidate.provider === msg.provider);
|
|
||||||
|
|
||||||
let entry = findEntry();
|
|
||||||
if (!entry || entry.status === "loading") {
|
|
||||||
// Awaits the in-flight warmup (deduped per-cwd) so old clients still get
|
|
||||||
// a resolved answer rather than a loading placeholder.
|
|
||||||
await manager.refresh({ cwd, providers: [msg.provider] });
|
|
||||||
entry = findEntry();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
this.emit({
|
this.emit({
|
||||||
@@ -3164,9 +3158,60 @@ export class Session {
|
|||||||
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fetchedAt = new Date().toISOString();
|
const fetchedAt = new Date().toISOString();
|
||||||
|
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
|
||||||
|
const manager = this.providerSnapshotManager;
|
||||||
|
|
||||||
|
if (manager) {
|
||||||
|
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
this.emit({
|
||||||
|
type: "list_provider_modes_response",
|
||||||
|
payload: {
|
||||||
|
provider: msg.provider,
|
||||||
|
error: `Unknown provider: ${msg.provider}`,
|
||||||
|
fetchedAt,
|
||||||
|
requestId: msg.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.status === "ready") {
|
||||||
|
this.emit({
|
||||||
|
type: "list_provider_modes_response",
|
||||||
|
payload: {
|
||||||
|
provider: msg.provider,
|
||||||
|
modes: entry.modes ?? [],
|
||||||
|
error: null,
|
||||||
|
fetchedAt: entry.fetchedAt ?? fetchedAt,
|
||||||
|
requestId: msg.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
entry.status === "error"
|
||||||
|
? (entry.error ?? `Failed to list modes for ${msg.provider}`)
|
||||||
|
: `Provider ${msg.provider} is not available`;
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "list_provider_modes_response",
|
||||||
|
payload: {
|
||||||
|
provider: msg.provider,
|
||||||
|
error: errorMessage,
|
||||||
|
fetchedAt,
|
||||||
|
requestId: msg.requestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const modes = await this.providerRegistry[msg.provider].fetchModes({
|
const modes = await this.providerRegistry[msg.provider].fetchModes({
|
||||||
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
cwd,
|
||||||
|
force: false,
|
||||||
});
|
});
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "list_provider_modes_response",
|
type: "list_provider_modes_response",
|
||||||
@@ -3195,6 +3240,28 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getProviderSnapshotEntryForRead(
|
||||||
|
cwd: string,
|
||||||
|
provider: AgentProvider,
|
||||||
|
): Promise<ProviderSnapshotEntry | undefined> {
|
||||||
|
const manager = this.providerSnapshotManager;
|
||||||
|
if (!manager) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const findEntry = () =>
|
||||||
|
manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider);
|
||||||
|
|
||||||
|
let entry = findEntry();
|
||||||
|
if (!entry || entry.status === "loading") {
|
||||||
|
// Awaits the in-flight warmup (deduped per-cwd) so old clients still get
|
||||||
|
// a resolved answer rather than a loading placeholder.
|
||||||
|
await manager.warmUpSnapshotForCwd({ cwd, providers: [provider] });
|
||||||
|
entry = findEntry();
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
private buildDraftAgentSessionConfig(draftConfig: {
|
private buildDraftAgentSessionConfig(draftConfig: {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -3301,10 +3368,16 @@ export class Session {
|
|||||||
private async handleRefreshProvidersSnapshotRequest(
|
private async handleRefreshProvidersSnapshotRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.providerSnapshotManager?.refresh({
|
if (msg.cwd) {
|
||||||
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
await this.providerSnapshotManager?.refreshSnapshotForCwd({
|
||||||
providers: msg.providers,
|
cwd: expandTilde(msg.cwd),
|
||||||
});
|
providers: msg.providers,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.providerSnapshotManager?.refreshSettingsSnapshot({
|
||||||
|
providers: msg.providers,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "refresh_providers_snapshot_response",
|
type: "refresh_providers_snapshot_response",
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -906,7 +906,7 @@ class FakeAgentClient implements AgentClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
async listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
if (this.provider === "claude") {
|
if (this.provider === "claude") {
|
||||||
return [
|
return [
|
||||||
{ provider: this.provider, id: "haiku", label: "Haiku", isDefault: true },
|
{ provider: this.provider, id: "haiku", label: "Haiku", isDefault: true },
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/in
|
|||||||
import { OpenAITTS } from "./speech/providers/openai/tts.js";
|
import { OpenAITTS } from "./speech/providers/openai/tts.js";
|
||||||
import { OpenAISTT } from "./speech/providers/openai/stt.js";
|
import { OpenAISTT } from "./speech/providers/openai/stt.js";
|
||||||
import { STTManager } from "./agent/stt-manager.js";
|
import { STTManager } from "./agent/stt-manager.js";
|
||||||
|
import { withTimeout } from "../utils/promise-timeout.js";
|
||||||
|
|
||||||
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
|
||||||
const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey);
|
const shouldRun = process.env.PASEO_VOICE_ROUNDTRIP_E2E === "1" && Boolean(openaiApiKey);
|
||||||
@@ -71,24 +72,6 @@ function waitForSignal<T>(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withTimeout<T>(label: string, timeoutMs: number, task: Promise<T>): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
reject(new Error(`Timed out during ${label} after ${timeoutMs}ms`));
|
|
||||||
}, timeoutMs);
|
|
||||||
task.then(
|
|
||||||
(value) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(value);
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function streamToBuffer(stream: Readable): Promise<Buffer> {
|
async function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
@@ -148,20 +131,20 @@ describe("voice roundtrip e2e", () => {
|
|||||||
path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`),
|
path.join(tmpdir(), `voice-roundtrip-agent-${targetProvider}-`),
|
||||||
);
|
);
|
||||||
const voiceAgent = await withTimeout(
|
const voiceAgent = await withTimeout(
|
||||||
"createVoiceTargetAgent",
|
|
||||||
30000,
|
|
||||||
ctx.client.createAgent({
|
ctx.client.createAgent({
|
||||||
config: {
|
config: {
|
||||||
...getVoiceRoundtripConfig(targetProvider),
|
...getVoiceRoundtripConfig(targetProvider),
|
||||||
cwd: voiceCwd,
|
cwd: voiceCwd,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
30000,
|
||||||
|
"Timed out during createVoiceTargetAgent after 30000ms",
|
||||||
);
|
);
|
||||||
const voiceAgentId = voiceAgent.id;
|
const voiceAgentId = voiceAgent.id;
|
||||||
const voiceMode = await withTimeout(
|
const voiceMode = await withTimeout(
|
||||||
"setVoiceMode",
|
|
||||||
15000,
|
|
||||||
ctx.client.setVoiceMode(true, voiceAgentId),
|
ctx.client.setVoiceMode(true, voiceAgentId),
|
||||||
|
15000,
|
||||||
|
"Timed out during setVoiceMode after 15000ms",
|
||||||
);
|
);
|
||||||
expect(voiceMode.accepted).toBe(true);
|
expect(voiceMode.accepted).toBe(true);
|
||||||
expect(voiceMode.enabled).toBe(true);
|
expect(voiceMode.enabled).toBe(true);
|
||||||
@@ -194,14 +177,14 @@ describe("voice roundtrip e2e", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const inputSpeech = await withTimeout(
|
const inputSpeech = await withTimeout(
|
||||||
"synthesizeInputAudio",
|
|
||||||
30000,
|
|
||||||
ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."),
|
ttsProvider.synthesizeSpeech("Use the speak tool and say exactly round trip successful."),
|
||||||
|
30000,
|
||||||
|
"Timed out during synthesizeInputAudio after 30000ms",
|
||||||
);
|
);
|
||||||
const inputPcm = await withTimeout(
|
const inputPcm = await withTimeout(
|
||||||
"collectInputAudio",
|
|
||||||
15000,
|
|
||||||
streamToBuffer(inputSpeech.stream),
|
streamToBuffer(inputSpeech.stream),
|
||||||
|
15000,
|
||||||
|
"Timed out during collectInputAudio after 15000ms",
|
||||||
);
|
);
|
||||||
const outputAudio = await (async () => {
|
const outputAudio = await (async () => {
|
||||||
try {
|
try {
|
||||||
@@ -281,16 +264,24 @@ describe("voice roundtrip e2e", () => {
|
|||||||
);
|
);
|
||||||
const isLast = offset + chunkBytes >= inputPcm.length;
|
const isLast = offset + chunkBytes >= inputPcm.length;
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
"sendVoiceAudioChunk",
|
|
||||||
5000,
|
|
||||||
ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast),
|
ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, isLast),
|
||||||
|
5000,
|
||||||
|
"Timed out during sendVoiceAudioChunk after 5000ms",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const transcript = await withTimeout("waitForTranscription", 35000, transcriptPromise);
|
const transcript = await withTimeout(
|
||||||
|
transcriptPromise,
|
||||||
|
35000,
|
||||||
|
"Timed out during waitForTranscription after 35000ms",
|
||||||
|
);
|
||||||
if (transcript.text.trim().length === 0) {
|
if (transcript.text.trim().length === 0) {
|
||||||
throw new Error(`empty transcription (lowConfidence=${transcript.isLowConfidence})`);
|
throw new Error(`empty transcription (lowConfidence=${transcript.isLowConfidence})`);
|
||||||
}
|
}
|
||||||
return await withTimeout("waitForAudioOutput", 95000, outputAudioPromise);
|
return await withTimeout(
|
||||||
|
outputAudioPromise,
|
||||||
|
95000,
|
||||||
|
"Timed out during waitForAudioOutput after 95000ms",
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`${error instanceof Error ? error.message : String(error)} | requestedVoiceAgentId=${voiceAgentId} | timelineTools=${JSON.stringify(timelineTools)} | timelineToolAgentIds=${JSON.stringify(Array.from(timelineToolAgentIds))} | activityErrors=${JSON.stringify(activityErrors)}`,
|
`${error instanceof Error ? error.message : String(error)} | requestedVoiceAgentId=${voiceAgentId} | timelineTools=${JSON.stringify(timelineTools)} | timelineToolAgentIds=${JSON.stringify(Array.from(timelineToolAgentIds))} | activityErrors=${JSON.stringify(activityErrors)}`,
|
||||||
@@ -311,11 +302,11 @@ describe("voice roundtrip e2e", () => {
|
|||||||
? "audio/wav"
|
? "audio/wav"
|
||||||
: `audio/${outputAudio.format}`;
|
: `audio/${outputAudio.format}`;
|
||||||
const transcription = await withTimeout(
|
const transcription = await withTimeout(
|
||||||
"transcribeOutputAudio",
|
|
||||||
60000,
|
|
||||||
sttOutput.transcribe(outputRaw, outputFormat, {
|
sttOutput.transcribe(outputRaw, outputFormat, {
|
||||||
label: "voice-roundtrip-output",
|
label: "voice-roundtrip-output",
|
||||||
}),
|
}),
|
||||||
|
60000,
|
||||||
|
"Timed out during transcribeOutputAudio after 60000ms",
|
||||||
);
|
);
|
||||||
const normalized = transcription.text.trim().toLowerCase();
|
const normalized = transcription.text.trim().toLowerCase();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
type TimeoutOptions<T> = {
|
||||||
|
promise: Promise<T>;
|
||||||
|
timeoutMs: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T>;
|
||||||
|
export function withTimeout<T>(options: TimeoutOptions<T>): Promise<T>;
|
||||||
|
export function withTimeout<T>(
|
||||||
|
promiseOrOptions: Promise<T> | TimeoutOptions<T>,
|
||||||
|
timeoutMs?: number,
|
||||||
|
message?: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const options =
|
||||||
|
typeof timeoutMs === "number"
|
||||||
|
? { promise: promiseOrOptions as Promise<T>, timeoutMs, message }
|
||||||
|
: resolveTimeoutOptions(promiseOrOptions as TimeoutOptions<T>);
|
||||||
|
|
||||||
|
if (typeof options.timeoutMs !== "number" || !options.message) {
|
||||||
|
return Promise.reject(new Error("Timeout duration and message are required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error(options.message)), options.timeoutMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([options.promise, timeoutPromise]).finally(() => {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTimeoutOptions<T>(options: TimeoutOptions<T>): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
timeoutMs: number;
|
||||||
|
message: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
promise: options.promise,
|
||||||
|
timeoutMs: options.timeoutMs,
|
||||||
|
message: `Timed out after ${options.timeoutMs}ms (${options.label})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user