import { useMutation, useQueryClient } from "@tanstack/react-query"; import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; import { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; import { SuggestedTask } from "#/utils/types"; import { Provider } from "#/types/settings"; import { useTracking } from "#/hooks/use-tracking"; import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; import { useAgentProfiles } from "#/hooks/query/use-agent-profiles"; import { useActiveBackend } from "#/contexts/active-backend-context"; import ProfilesService from "#/api/profiles-service/profiles-service.api"; import AgentProfilesService, { WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME, type AgentProfileListResponse, } from "#/api/agent-profiles-service/agent-profiles-service.api"; import PluginsManagementService, { type InstalledPluginInfo, } from "#/api/plugins-management-service"; import { PLUGINS_QUERY_KEYS, LLM_PROFILES_QUERY_KEYS, AGENT_PROFILES_QUERY_KEYS, AGENT_PROFILES_RETRY_OPTIONS, } from "#/hooks/query/query-keys"; import { pluginReferenceKey } from "#/utils/plugin-display"; import { getStoredConversationMetadata, setStoredConversationMetadata, toPluginCoordinates, type WorkspaceMode, } from "#/api/conversation-metadata-store"; export interface CreateConversationVariables { query?: string; repository?: { name: string; gitProvider: Provider; branch?: string; }; suggestedTask?: SuggestedTask; conversationInstructions?: string; parentConversationId?: string; agentType?: "default" | "plan"; plugins?: PluginSpec[]; workingDir?: string; workspaceMode?: WorkspaceMode; // Launch from a specific AgentProfile (local backend). When omitted, the // active AgentProfile (if any) is used so home-composed conversations // launch from the user's selected profile (#3727). agentProfileId?: string; entryPoint?: string; // analytics only; not forwarded to the service } export const CREATE_CONVERSATION_MUTATION_KEY = ["create-conversation"]; interface CreateConversationResponse { conversation_id: string; session_api_key: string | null; url: string | null; task_id?: string; } export const useCreateConversation = () => { const queryClient = useQueryClient(); const { trackConversationCreated } = useTracking(); // Cache-warm on the home page (the profile picker reads the same query). // Stamped onto the conversation at creation so the switcher can show the // exact profile even when several profiles share a model (#1082). const { data: llmProfiles } = useLlmProfiles(); // Warm the agent-profiles cache too — the launch path below awaits the same // query via ensureQueryData, so a warm cache makes home-launch instant. The // hook's maybe-unresolved data is deliberately NOT read at launch time: // activation is pointer-only (it never writes agent_settings), so racing a // cold cache into the agent_settings fallback would silently launch the // wrong agent. const { backend, orgId } = useActiveBackend(); useAgentProfiles(); return useMutation({ mutationKey: CREATE_CONVERSATION_MUTATION_KEY, mutationFn: async ( variables: CreateConversationVariables, ): Promise => { const { query, conversationInstructions, plugins, repository, workingDir, workspaceMode, parentConversationId, agentType, agentProfileId, } = variables; // The active AgentProfile is the default launch profile for new // conversations (#3727), on both local and cloud (cloud gained // /api/agent-profiles in OpenHands #15060, #3730). Await the list from // the shared query cache: a send fired before the home query resolves // must still launch from the active profile. Do not fall back to the // global agent_settings when profile discovery fails: activation is // pointer-only, so those settings may describe a different agent. const agentProfiles: AgentProfileListResponse = await queryClient.ensureQueryData({ queryKey: [...AGENT_PROFILES_QUERY_KEYS.all, backend.id, orgId], queryFn: AgentProfilesService.listProfiles, ...AGENT_PROFILES_RETRY_OPTIONS, }); const requestedAgentProfileId = agentProfileId ?? agentProfiles.active_agent_profile_id ?? undefined; // Fall back to the legacy agent_settings launch when the resolved agent // profile can't resolve its LLM. The agent-server seeds a `default` // openhands profile whose `llm_profile_ref` can point at an LLM profile // that doesn't exist (fresh store, or one configured with named profiles // only); launching from it 404s ("LLM profile '' not found") and // would brick home-launch. agent_settings reflects the active LLM, so the // fallback degrades cleanly until the seed mirrors it (SDK #3933). // ACP profiles carry no llm_profile_ref, so they're never gated here. const resolvedAgentProfile = requestedAgentProfileId ? agentProfiles?.profiles?.find( (profile) => profile.id === requestedAgentProfileId, ) : undefined; // Cloud has no `agent_settings` payload to fall back to — the downgrade // below only makes sense on local, where it exists and carries the // canvas-only enrichments. Gating it here keeps cloud always launching // from the resolved profile id, so the conversation gets // `launched_agent_profile` stamped and the profile's config applied // (#1571 review). const isCloud = backend.kind === "cloud"; let effectiveAgentProfileId = requestedAgentProfileId; // The account-wide active LLM profile from the launch-path fetch below // (null when that fetch didn't run or failed). Fresher than the // `useLlmProfiles()` render snapshot, which a fast send can outrun. let fetchedActiveLlmProfile: string | null = null; if ( !isCloud && resolvedAgentProfile?.name === WELL_KNOWN_DEFAULT_AGENT_PROFILE_NAME && resolvedAgentProfile?.agent_kind === "openhands" ) { // The seeded OpenHands `default` profile is the enriched baseline, not a // deliberate profile pick — it mirrors global agent_settings. Launch it // via agent_settings so the canvas-only enrichments the profile-resolution // path drops survive for the common home-launch: the // system-message suffix and project-skill loading (buildAgentContext). // Named profiles are deliberate custom configs and still use the profile // path (accepting that enrichment boundary). // Trade-off: per-profile fields set on `default` itself don't apply on // home-launch — custom per-profile config belongs in a named profile. // // Scoped to OpenHands: an ACP `default` must keep the profile path. // Activation is pointer-only, so global agent_settings is stale (often // still OpenHands) when an ACP profile is active — launching it via // agent_settings would start the wrong agent. ACP carries no // enrichment, so there's nothing to preserve. // // Scoped to local: cloud never writes agent_settings, so it always // resolves `default` server-side via agent_profile_id (validated below). effectiveAgentProfileId = undefined; } else if ( resolvedAgentProfile?.agent_kind === "openhands" && resolvedAgentProfile.llm_profile_ref ) { // Await the LLM-profile list rather than reading the maybe-unresolved // `useLlmProfiles()` result: a send fired before that query loads (or // after it errors) must still validate the ref, not launch blind. let llmProfileExists = false; try { const llm = await queryClient.ensureQueryData({ queryKey: [...LLM_PROFILES_QUERY_KEYS.all, backend.id, orgId], queryFn: ProfilesService.listProfiles, // Match the agent-profiles fetch above: on a backend where this // errors, fall back to agent_settings immediately rather than // stalling the send through the default exponential backoff. retry: false, }); llmProfileExists = llm.profiles.some( (profile) => profile.name === resolvedAgentProfile.llm_profile_ref, ); fetchedActiveLlmProfile = llm.active_profile ?? null; } catch { // List unavailable → can't validate → fall back to agent_settings. } if (!llmProfileExists) { // Downgrade is silent in the UI; leave a diagnosable trace. console.warn( `Agent profile "${resolvedAgentProfile.name}" references missing ` + `LLM profile "${resolvedAgentProfile.llm_profile_ref}"; ` + "launching from agent_settings instead.", ); effectiveAgentProfileId = undefined; } else if ( !isCloud && !agentProfileId && fetchedActiveLlmProfile && fetchedActiveLlmProfile !== resolvedAgentProfile.llm_profile_ref ) { // The home LLM pill shows — and its dropdown activates — the // account-wide active LLM profile, never the pinned ref // (useChatInputLlmProfileState), so when the two differ the launch // must run the selection or the UI advertises a model the // conversation won't use (#16539). Launch via agent_settings, which // the dropdown's profile activation syncs to the selection. Scoped // to the implicit active-profile launch: an explicit `agentProfileId` // (the in-conversation profile picker) is a deliberate profile pick, // so its pinned ref stays authoritative. Local-only like the // downgrades above — cloud has no agent_settings payload to fall // back to. Trade-off: the named profile's non-LLM config doesn't // apply to this launch; the start request has no per-launch LLM // override that could preserve it (AgentLaunchAdditions carries only // a system-message suffix). effectiveAgentProfileId = undefined; } } // Only extend the call with the profile fields when launching from a // profile, so a plain create stays byte-identical to the legacy // agent_settings path (#3727). sandboxId is unused here. const conversation = await AgentServerConversationService.createConversation({ initialUserMsg: query, conversationInstructions, plugins, metadata: repository ? { selected_repository: repository.name, selected_branch: repository.branch ?? null, git_provider: repository.gitProvider, } : null, workingDirOverride: workingDir, workspaceMode, parentConversationId, agentType, ...(effectiveAgentProfileId ? { agentProfileId: effectiveAgentProfileId, agentProfileKind: resolvedAgentProfile?.agent_kind, } : {}), }); // Stamp the active LLM profile onto the (local) conversation so the // chat switcher shows the exact profile even when several profiles // share a model (#1082). Cloud conversations don't use local profiles // (app_conversation_id stays null until the sandbox is READY). Merge so // the repo/workspace metadata the service just persisted is preserved. const localConversationId = conversation.app_conversation_id; // Snapshot the conversation's plugins into client-side metadata so the // in-conversation plugins view can show what's loaded (coordinates only // — strip parameters, which may carry secrets). The agent-server doesn't // return a live conversation's loaded plugins, so this snapshot is the // source for that view. Two sources, deduped by coordinates: // 1. plugins explicitly attached at creation (e.g. the /launch flow); // 2. enabled installed plugins, which the SDK auto-loads into every new // local conversation (see use-set-plugin-enabled). const explicitPlugins = plugins?.map(toPluginCoordinates) ?? []; let attachedPlugins: PluginSpec[] = explicitPlugins; if (localConversationId) { let installed: InstalledPluginInfo[] = []; try { installed = await queryClient.ensureQueryData({ // Same key `usePlugins` writes. Unscoped, this served the previous // backend's list and attached plugins the target server lacks. queryKey: [...PLUGINS_QUERY_KEYS.installed, backend.id, orgId], queryFn: () => PluginsManagementService.listInstalledPlugins(), }); } catch { // Best-effort: never let plugin lookup block conversation creation. } const seen = new Set(explicitPlugins.map(pluginReferenceKey)); const enabledInstalled = installed .filter((plugin) => plugin.enabled) .map((plugin) => ({ source: plugin.source, ref: plugin.resolved_ref ?? null, repo_path: plugin.repo_path ?? null, // Keep the human-friendly name so the plugins view shows e.g. // "city-weather" rather than deriving "local" from the source. name: plugin.name, })) .filter((plugin) => !seen.has(pluginReferenceKey(plugin))); attachedPlugins = [...explicitPlugins, ...enabledInstalled]; } // A launch from a named OpenHands profile runs that profile's // `llm_profile_ref`, which can differ from the standalone active LLM // profile — stamp the ref so the switcher pill names the exact profile // the conversation runs (#1082). The agent_settings paths (the `default` // baseline, a dangling ref, or a dropdown override (#16539) — where // effectiveAgentProfileId is cleared) run the active LLM, so they stamp // the active profile, preferring the launch-path fetch over the hook's // render snapshot. ACP profiles carry no LLM profile, so they fall // through to the active-profile stamp (unused by the ACP model chip). const activeProfile = effectiveAgentProfileId && resolvedAgentProfile?.agent_kind === "openhands" ? resolvedAgentProfile.llm_profile_ref : (fetchedActiveLlmProfile ?? llmProfiles?.active_profile ?? null); if (localConversationId && (activeProfile || attachedPlugins.length)) { const prev = getStoredConversationMetadata(localConversationId); setStoredConversationMetadata(localConversationId, { selected_repository: prev?.selected_repository ?? null, selected_branch: prev?.selected_branch ?? null, git_provider: prev?.git_provider ?? null, selected_workspace: prev?.selected_workspace ?? null, workspace_mode: prev?.workspace_mode ?? null, active_profile: activeProfile ?? prev?.active_profile ?? null, plugins: attachedPlugins.length ? attachedPlugins : (prev?.plugins ?? null), }); } // OpenHands cloud pattern: when the start task isn't immediately // READY (cloud sandbox is still provisioning), // app_conversation_id is null. We return a `task-{id}` URL so the // conversation route's useTaskPolling can drive it to READY and // then redirect to the real `/conversations/{app_conversation_id}`. const conversationId = conversation.app_conversation_id ? conversation.app_conversation_id : `task-${conversation.id}`; return { conversation_id: conversationId, session_api_key: null, url: conversation.agent_server_url, task_id: conversation.id, }; }, onSuccess: async (data, variables) => { trackConversationCreated({ conversationId: data.conversation_id, taskId: data.task_id, hasRepository: !!variables.repository, gitProvider: variables.repository?.gitProvider, hasWorkspace: !!variables.workingDir, workspaceMode: variables.workspaceMode, hasInitialQuery: !!variables.query, agentType: variables.agentType, hasParentConversation: !!variables.parentConversationId, entryPoint: variables.entryPoint, }); // Invalidate (rather than remove) so the existing paginated list stays // rendered while a background refetch picks up the new conversation. // `removeQueries` would wipe the cache and force the panel back to its // initial loading state, dropping loaded pages and scroll position. queryClient.invalidateQueries({ queryKey: ["user", "conversations"], }); // The cloud path returns a start task (no app_conversation_id // yet); the sidebar surfaces those via `useStartTasks` which doesn't // poll, so invalidate it explicitly so the in-flight task shows up // in the conversation list immediately. queryClient.invalidateQueries({ queryKey: ["start-tasks"], }); }, }); };