fix: we load extensions when agent starts so don't do it up front (#6350)

This commit is contained in:
Michael Neale
2026-01-07 17:03:51 +11:00
committed by GitHub
parent 7446cf9ed7
commit 7d751b3196
6 changed files with 21 additions and 393 deletions
@@ -477,6 +477,11 @@ impl ExtensionManager {
pub async fn add_extension(&self, config: ExtensionConfig) -> ExtensionResult<()> {
let config_name = config.key().to_string();
let sanitized_name = normalize(config_name.clone());
if self.extensions.lock().await.contains_key(&sanitized_name) {
return Ok(());
}
let mut temp_dir = None;
let client: Box<dyn McpClientTrait> = match &config {
+6 -42
View File
@@ -39,7 +39,7 @@ import PermissionSettingsView from './components/settings/permission/PermissionS
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
import RecipesView from './components/recipes/RecipesView';
import { View, ViewOptions } from './utils/navigationUtils';
import { NoProviderOrModelError, useAgent } from './hooks/useAgent';
import { useNavigation } from './hooks/useNavigation';
import { errorMessage } from './utils/conversionUtils';
import { usePageViewTracking } from './hooks/useAnalytics';
@@ -51,10 +51,10 @@ function PageViewTracker() {
}
// Route Components
const HubRouteWrapper = ({ isExtensionsLoading }: { isExtensionsLoading: boolean }) => {
const HubRouteWrapper = () => {
const setView = useNavigation();
return <Hub setView={setView} isExtensionsLoading={isExtensionsLoading} />;
return <Hub setView={setView} />;
};
const PairRouteWrapper = ({
@@ -363,15 +363,12 @@ const ExtensionsRoute = () => {
export function AppInner() {
const [fatalError, setFatalError] = useState<string | null>(null);
const [agentWaitingMessage, setAgentWaitingMessage] = useState<string | null>(null);
const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false);
const [sharedSessionError, setSharedSessionError] = useState<string | null>(null);
const [isExtensionsLoading, setIsExtensionsLoading] = useState(false);
const [didSelectProvider, setDidSelectProvider] = useState<boolean>(false);
const navigate = useNavigate();
const setView = useNavigation();
const location = useLocation();
const [chat, setChat] = useState<ChatType>({
sessionId: '',
@@ -384,7 +381,6 @@ export function AppInner() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const { addExtension } = useConfig();
const { loadCurrentChat } = useAgent();
useEffect(() => {
console.log('Sending reactReady signal to Electron');
@@ -398,28 +394,6 @@ export function AppInner() {
}
}, []);
// Handle URL parameters and deeplinks on app startup
const loadingHub = location.pathname === '/';
useEffect(() => {
if (loadingHub) {
(async () => {
try {
const loadedChat = await loadCurrentChat({
setAgentWaitingMessage,
setIsExtensionsLoading,
});
setChat(loadedChat);
} catch (e) {
if (e instanceof NoProviderOrModelError) {
// the onboarding flow will trigger
} else {
throw e;
}
}
})();
}
}, [loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub, setChat]);
useEffect(() => {
const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => {
const link = args[0] as string;
@@ -616,18 +590,13 @@ export function AppInner() {
path="/"
element={
<ProviderGuard didSelectProvider={didSelectProvider}>
<ChatProvider
chat={chat}
setChat={setChat}
contextKey="hub"
agentWaitingMessage={agentWaitingMessage}
>
<ChatProvider chat={chat} setChat={setChat} contextKey="hub">
<AppLayout />
</ChatProvider>
</ProviderGuard>
}
>
<Route index element={<HubRouteWrapper isExtensionsLoading={isExtensionsLoading} />} />
<Route index element={<HubRouteWrapper />} />
<Route
path="pair"
element={
@@ -643,12 +612,7 @@ export function AppInner() {
<Route
path="extensions"
element={
<ChatProvider
chat={chat}
setChat={setChat}
contextKey="extensions"
agentWaitingMessage={agentWaitingMessage}
>
<ChatProvider chat={chat} setChat={setChat} contextKey="extensions">
<ExtensionsRoute />
</ChatProvider>
}
+10 -15
View File
@@ -96,7 +96,6 @@ interface ChatInputProps {
initialPrompt?: string;
toolCount: number;
append?: (message: Message) => void;
isExtensionsLoading?: boolean;
}
export default function ChatInput({
@@ -121,7 +120,6 @@ export default function ChatInput({
initialPrompt,
toolCount,
append: _append,
isExtensionsLoading = false,
}: ChatInputProps) {
const [_value, setValue] = useState(initialValue);
const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback
@@ -1109,8 +1107,7 @@ export default function ChatInput({
isAnyImageLoading ||
isAnyDroppedFileLoading ||
isRecording ||
isTranscribing ||
isExtensionsLoading;
isTranscribing;
// Queue management functions - no storage persistence, only in-memory
const handleRemoveQueuedMessage = (messageId: string) => {
@@ -1353,17 +1350,15 @@ export default function ChatInput({
</TooltipTrigger>
<TooltipContent>
<p>
{isExtensionsLoading
? 'Loading extensions...'
: isAnyImageLoading
? 'Waiting for images to save...'
: isAnyDroppedFileLoading
? 'Processing dropped files...'
: isRecording
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: 'Send'}
{isAnyImageLoading
? 'Waiting for images to save...'
: isAnyDroppedFileLoading
? 'Processing dropped files...'
: isRecording
? 'Recording...'
: isTranscribing
? 'Transcribing...'
: 'Send'}
</p>
</TooltipContent>
</Tooltip>
-3
View File
@@ -23,10 +23,8 @@ import { startNewSession } from '../sessions';
export default function Hub({
setView,
isExtensionsLoading,
}: {
setView: (view: View, viewOptions?: ViewOptions) => void;
isExtensionsLoading: boolean;
}) {
const handleSubmit = async (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
@@ -59,7 +57,6 @@ export default function Hub({
messages={[]}
disableAnimation={false}
sessionCosts={undefined}
isExtensionsLoading={isExtensionsLoading}
toolCount={0}
/>
</div>
-4
View File
@@ -14,7 +14,6 @@ interface ChatContextType {
clearRecipe: () => void;
// Context identification
contextKey: string; // 'hub' or 'pair-{sessionId}'
agentWaitingMessage: string | null;
}
const ChatContext = createContext<ChatContextType | undefined>(undefined);
@@ -24,14 +23,12 @@ interface ChatProviderProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
contextKey?: string; // Optional context key, defaults to 'hub'
agentWaitingMessage: string | null;
}
export const ChatProvider: React.FC<ChatProviderProps> = ({
children,
chat,
setChat,
agentWaitingMessage,
contextKey = 'hub',
}) => {
const resetChat = () => {
@@ -69,7 +66,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
setRecipe,
clearRecipe,
contextKey,
agentWaitingMessage,
};
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
-329
View File
@@ -1,329 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { useConfig } from '../components/ConfigContext';
import { ChatType } from '../types/chat';
import { initializeSystem } from '../utils/providerUtils';
import {
backupConfig,
initConfig,
readAllConfig,
Recipe,
recoverConfig,
resumeAgent,
startAgent,
validateConfig,
} from '../api';
export enum AgentState {
UNINITIALIZED = 'uninitialized',
INITIALIZING = 'initializing',
NO_PROVIDER = 'no_provider',
INITIALIZED = 'initialized',
ERROR = 'error',
}
export interface InitializationContext {
recipe?: Recipe;
resumeSessionId?: string;
setAgentWaitingMessage: (msg: string | null) => void;
setIsExtensionsLoading?: (isLoading: boolean) => void;
}
interface UseAgentReturn {
agentState: AgentState;
resetChat: () => void;
loadCurrentChat: (context: InitializationContext) => Promise<ChatType>;
}
export class NoProviderOrModelError extends Error {
constructor() {
super('No provider or model configured');
this.name = this.constructor.name;
}
}
export function useAgent(): UseAgentReturn {
const [agentState, setAgentState] = useState<AgentState>(AgentState.UNINITIALIZED);
const [sessionId, setSessionId] = useState<string | null>(null);
const initPromiseRef = useRef<Promise<ChatType> | null>(null);
const deletedSessionsRef = useRef<Set<string>>(new Set());
const recipeIdFromConfig = useRef<string | null>(
(window.appConfig.get('recipeId') as string | null | undefined) ?? null
);
const recipeDeeplinkFromConfig = useRef<string | null>(
(window.appConfig.get('recipeDeeplink') as string | null | undefined) ?? null
);
const scheduledJobIdFromConfig = useRef<string | null>(
(window.appConfig.get('scheduledJobId') as string | null | undefined) ?? null
);
const { getExtensions, addExtension, read } = useConfig();
const resetChat = useCallback(() => {
setSessionId(null);
setAgentState(AgentState.UNINITIALIZED);
recipeIdFromConfig.current = null;
recipeDeeplinkFromConfig.current = null;
scheduledJobIdFromConfig.current = null;
deletedSessionsRef.current.clear();
}, []);
const agentIsInitialized = agentState === AgentState.INITIALIZED;
const currentChat = useCallback(
async (initContext: InitializationContext): Promise<ChatType> => {
// Skip deleted sessions
if (
initContext.resumeSessionId &&
deletedSessionsRef.current.has(initContext.resumeSessionId)
) {
initContext.resumeSessionId = undefined;
// Clear from URL
const url = new URL(window.location.href);
url.searchParams.delete('resumeSessionId');
window.history.replaceState({}, '', url.toString());
}
if (sessionId && deletedSessionsRef.current.has(sessionId)) {
setSessionId(null);
}
if (agentIsInitialized && sessionId && !deletedSessionsRef.current.has(sessionId)) {
let agentResponse;
try {
agentResponse = await resumeAgent({
body: {
session_id: sessionId,
load_model_and_extensions: false,
},
throwOnError: true,
});
} catch {
// Mark session as deleted and clear state
deletedSessionsRef.current.add(sessionId);
setSessionId(null);
// Clear from URL
const url = new URL(window.location.href);
if (url.searchParams.get('resumeSessionId')) {
url.searchParams.delete('resumeSessionId');
window.history.replaceState({}, '', url.toString());
}
}
// Fall through to create new session
if (agentResponse?.data) {
const agentSession = agentResponse.data;
const messages = agentSession.conversation || [];
return {
sessionId: agentSession.id,
name: agentSession.recipe?.title || agentSession.name,
messages,
recipe: agentSession.recipe,
recipeParameterValues: agentSession.user_recipe_values || null,
};
}
}
if (initPromiseRef.current) {
return initPromiseRef.current;
}
const initPromise = (async () => {
setAgentState(AgentState.INITIALIZING);
const agentWaitingMessage = initContext.setAgentWaitingMessage;
agentWaitingMessage('Agent is initializing');
try {
const config = window.electron.getConfig();
const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER;
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (!provider || !model) {
setAgentState(AgentState.NO_PROVIDER);
throw new NoProviderOrModelError();
}
let agentResponse;
try {
agentResponse = initContext.resumeSessionId
? await resumeAgent({
body: {
session_id: initContext.resumeSessionId,
load_model_and_extensions: false,
},
throwOnError: true,
})
: await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
...buildRecipeInput(
initContext.recipe,
recipeIdFromConfig.current,
recipeDeeplinkFromConfig.current
),
},
throwOnError: true,
});
} catch (error) {
// If resuming fails, mark session as deleted and create new agent
if (initContext.resumeSessionId) {
deletedSessionsRef.current.add(initContext.resumeSessionId);
// Clear from URL
const url = new URL(window.location.href);
url.searchParams.delete('resumeSessionId');
window.history.replaceState({}, '', url.toString());
agentResponse = await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
...buildRecipeInput(
initContext.recipe,
recipeIdFromConfig.current,
recipeDeeplinkFromConfig.current
),
},
throwOnError: true,
});
// Clear resume flag
initContext.resumeSessionId = undefined;
} else {
throw error;
}
}
const agentSession = agentResponse.data;
if (!agentSession) {
throw Error('Failed to get session info');
}
setSessionId(agentSession.id);
if (!initContext.recipe && agentSession.recipe && scheduledJobIdFromConfig.current) {
agentSession.recipe = {
...agentSession.recipe,
scheduledJobId: scheduledJobIdFromConfig.current,
isScheduledExecution: true,
} as Recipe;
scheduledJobIdFromConfig.current = null;
}
recipeIdFromConfig.current = null;
recipeDeeplinkFromConfig.current = null;
agentWaitingMessage('Agent is loading config');
await initConfig();
try {
await readAllConfig({ throwOnError: true });
} catch (error) {
console.warn('Initial config read failed, attempting recovery:', error);
await handleConfigRecovery();
}
agentWaitingMessage('Extensions are loading');
const recipeForInit = initContext.recipe || agentSession.recipe || undefined;
await initializeSystem(agentSession.id, provider as string, model as string, {
getExtensions,
addExtension,
setIsExtensionsLoading: initContext.setIsExtensionsLoading,
recipeParameters: agentSession.user_recipe_values,
recipe: recipeForInit,
});
const recipe = initContext.recipe || agentSession.recipe;
const conversation = agentSession.conversation || [];
// If we're loading a recipe from initContext (new recipe load), start with empty messages
// Otherwise, use the messages from the session
const messages = initContext.recipe && !initContext.resumeSessionId ? [] : conversation;
let initChat: ChatType = {
sessionId: agentSession.id,
name: agentSession.recipe?.title || agentSession.name,
messages: messages,
recipe: recipe,
recipeParameterValues: agentSession.user_recipe_values || null,
};
setAgentState(AgentState.INITIALIZED);
return initChat;
} catch (error) {
if (
(error + '').includes('Failed to create provider') ||
error instanceof NoProviderOrModelError
) {
setAgentState(AgentState.NO_PROVIDER);
throw error;
}
setAgentState(AgentState.ERROR);
if (typeof error === 'object' && error !== null && 'message' in error) {
let error_message = error.message as string;
throw new Error(error_message);
}
throw error;
} finally {
agentWaitingMessage(null);
initPromiseRef.current = null;
}
})();
initPromiseRef.current = initPromise;
return initPromise;
},
[agentIsInitialized, sessionId, read, getExtensions, addExtension]
);
return {
agentState,
resetChat,
loadCurrentChat: currentChat,
};
}
const handleConfigRecovery = async () => {
const configVersion = localStorage.getItem('configVersion');
const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3;
if (shouldMigrateExtensions) {
try {
await backupConfig({ throwOnError: true });
await initConfig();
} catch (migrationError) {
console.error('Migration failed:', migrationError);
}
}
try {
await validateConfig({ throwOnError: true });
await readAllConfig({ throwOnError: true });
} catch {
try {
await recoverConfig({ throwOnError: true });
await readAllConfig({ throwOnError: true });
} catch {
console.warn('Config recovery failed, reinitializing...');
await initConfig();
}
}
};
const buildRecipeInput = (
recipeOverride?: Recipe,
recipeId?: string | null,
recipeDeeplink?: string | null
) => {
if (recipeId) {
return { recipe_id: recipeId };
}
if (recipeDeeplink) {
return { recipe_deeplink: recipeDeeplink };
}
if (recipeOverride) {
return { recipe: recipeOverride };
}
return {};
};