@@ -34,7 +24,6 @@ export function SettingsLayout({
isMobileMenuOpen={isMobileMenuOpen}
onToggleMenu={toggleMobileMenu}
/>
-
{/* Desktop layout with navigation and main content */}
{/* Navigation */}
@@ -43,7 +32,6 @@ export function SettingsLayout({
onCloseMobileMenu={closeMobileMenu}
navigationItems={navigationItems}
/>
-
{/* Main content */}
{children}
diff --git a/frontend/src/components/features/settings/settings-navigation.tsx b/frontend/src/components/features/settings/settings-navigation.tsx
index ce9e49aa09..5a35f01495 100644
--- a/frontend/src/components/features/settings/settings-navigation.tsx
+++ b/frontend/src/components/features/settings/settings-navigation.tsx
@@ -5,17 +5,12 @@ import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import SettingsIcon from "#/icons/settings-gear.svg?react";
import CloseIcon from "#/icons/close.svg?react";
-
-interface NavigationItem {
- to: string;
- icon: React.ReactNode;
- text: string;
-}
+import { SettingsNavItem } from "#/constants/settings-nav";
interface SettingsNavigationProps {
isMobileMenuOpen: boolean;
onCloseMobileMenu: () => void;
- navigationItems: NavigationItem[];
+ navigationItems: SettingsNavItem[];
}
export function SettingsNavigation({
@@ -34,7 +29,6 @@ export function SettingsNavigation({
onClick={onCloseMobileMenu}
/>
)}
-
{/* Navigation sidebar */}
-
+
- settings?.EMAIL_VERIFIED === false
+ settings?.email_verified === false
? null
: setConversationPanelIsOpen((prev) => !prev)
}
- disabled={settings?.EMAIL_VERIFIED === false}
+ disabled={settings?.email_verified === false}
/>
diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx
index e08b59c8e0..b31b04eb53 100644
--- a/frontend/src/components/shared/modals/settings/settings-form.tsx
+++ b/frontend/src/components/shared/modals/settings/settings-form.tsx
@@ -41,11 +41,11 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
onClose();
posthog.capture("settings_saved", {
- LLM_MODEL: newSettings.LLM_MODEL,
- LLM_API_KEY_SET: newSettings.LLM_API_KEY_SET ? "SET" : "UNSET",
- SEARCH_API_KEY_SET: newSettings.SEARCH_API_KEY ? "SET" : "UNSET",
+ LLM_MODEL: newSettings.llm_model,
+ LLM_API_KEY_SET: newSettings.llm_api_key_set ? "SET" : "UNSET",
+ SEARCH_API_KEY_SET: newSettings.search_api_key ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
- newSettings.REMOTE_RUNTIME_RESOURCE_FACTOR,
+ newSettings.remote_runtime_resource_factor,
});
},
});
@@ -67,7 +67,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
}
};
- const isLLMKeySet = settings.LLM_API_KEY_SET;
+ const isLLMKeySet = settings.llm_api_key_set;
return (
@@ -80,7 +80,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
diff --git a/frontend/src/components/v1/chat/task-tracking/task-item.tsx b/frontend/src/components/v1/chat/task-tracking/task-item.tsx
index b25664a611..a50b6829d3 100644
--- a/frontend/src/components/v1/chat/task-tracking/task-item.tsx
+++ b/frontend/src/components/v1/chat/task-tracking/task-item.tsx
@@ -20,9 +20,7 @@ export function TaskItem({ task }: TaskItemProps) {
case "todo":
return
;
case "in_progress":
- return (
-
- );
+ return
;
case "done":
return
;
default:
diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx
index 68c50f9499..8a8a205cd6 100644
--- a/frontend/src/contexts/conversation-websocket-context.tsx
+++ b/frontend/src/contexts/conversation-websocket-context.tsx
@@ -578,9 +578,13 @@ export function ConversationWebSocketProvider({
removeErrorMessage(); // Clear any previous error messages on successful connection
// Fetch expected event count for history loading detection
- if (conversationId) {
+ if (conversationId && conversationUrl) {
try {
- const count = await EventService.getEventCount(conversationId);
+ const count = await EventService.getEventCount(
+ conversationId,
+ conversationUrl,
+ sessionApiKey,
+ );
setExpectedEventCountMain(count);
// If no events expected, mark as loaded immediately
@@ -618,6 +622,7 @@ export function ConversationWebSocketProvider({
removeErrorMessage,
sessionApiKey,
conversationId,
+ conversationUrl,
]);
// Separate WebSocket options for planning agent connection
@@ -642,10 +647,15 @@ export function ConversationWebSocketProvider({
removeErrorMessage(); // Clear any previous error messages on successful connection
// Fetch expected event count for history loading detection
- if (planningAgentConversation?.id) {
+ if (
+ planningAgentConversation?.id &&
+ planningAgentConversation.conversation_url
+ ) {
try {
const count = await EventService.getEventCount(
planningAgentConversation.id,
+ planningAgentConversation.conversation_url,
+ planningAgentConversation.session_api_key,
);
setExpectedEventCountPlanning(count);
diff --git a/frontend/src/hooks/mutation/use-add-mcp-server.ts b/frontend/src/hooks/mutation/use-add-mcp-server.ts
index 25581cbdaf..c9aaf4e446 100644
--- a/frontend/src/hooks/mutation/use-add-mcp-server.ts
+++ b/frontend/src/hooks/mutation/use-add-mcp-server.ts
@@ -24,7 +24,7 @@ export function useAddMcpServer() {
mutationFn: async (server: MCPServerConfig): Promise
=> {
if (!settings) return;
- const currentConfig = settings.MCP_CONFIG || {
+ const currentConfig = settings.mcp_config || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
@@ -57,7 +57,7 @@ export function useAddMcpServer() {
const apiSettings = {
mcp_config: newConfig,
- v1_enabled: settings.V1_ENABLED,
+ v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);
diff --git a/frontend/src/hooks/mutation/use-create-conversation.ts b/frontend/src/hooks/mutation/use-create-conversation.ts
index 8f6df2c272..85e8dd880c 100644
--- a/frontend/src/hooks/mutation/use-create-conversation.ts
+++ b/frontend/src/hooks/mutation/use-create-conversation.ts
@@ -51,7 +51,7 @@ export const useCreateConversation = () => {
agentType,
} = variables;
- const useV1 = !!settings?.V1_ENABLED && !createMicroagent;
+ const useV1 = !!settings?.v1_enabled && !createMicroagent;
if (useV1) {
// Use V1 API - creates a conversation start task
diff --git a/frontend/src/hooks/mutation/use-delete-mcp-server.ts b/frontend/src/hooks/mutation/use-delete-mcp-server.ts
index 42ee01601f..43d1b2a7cc 100644
--- a/frontend/src/hooks/mutation/use-delete-mcp-server.ts
+++ b/frontend/src/hooks/mutation/use-delete-mcp-server.ts
@@ -9,9 +9,9 @@ export function useDeleteMcpServer() {
return useMutation({
mutationFn: async (serverId: string): Promise => {
- if (!settings?.MCP_CONFIG) return;
+ if (!settings?.mcp_config) return;
- const newConfig: MCPConfig = { ...settings.MCP_CONFIG };
+ const newConfig: MCPConfig = { ...settings.mcp_config };
const [serverType, indexStr] = serverId.split("-");
const index = parseInt(indexStr, 10);
@@ -25,7 +25,7 @@ export function useDeleteMcpServer() {
const apiSettings = {
mcp_config: newConfig,
- v1_enabled: settings.V1_ENABLED,
+ v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);
diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts
index 168c1d11f1..f335fd83ec 100644
--- a/frontend/src/hooks/mutation/use-save-settings.ts
+++ b/frontend/src/hooks/mutation/use-save-settings.ts
@@ -2,43 +2,28 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/api/settings-service/settings-service.api";
-import { PostSettings } from "#/types/settings";
-import { PostApiSettings } from "#/api/settings-service/settings.types";
+import { Settings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
-const saveSettingsMutationFn = async (settings: Partial) => {
- const apiSettings: Partial = {
- llm_model: settings.LLM_MODEL,
- llm_base_url: settings.LLM_BASE_URL,
- agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
- language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE,
- confirmation_mode: settings.CONFIRMATION_MODE,
- security_analyzer: settings.SECURITY_ANALYZER,
+const saveSettingsMutationFn = async (settings: Partial) => {
+ const settingsToSave: Partial = {
+ ...settings,
+ agent: settings.agent || DEFAULT_SETTINGS.agent,
+ language: settings.language || DEFAULT_SETTINGS.language,
llm_api_key:
settings.llm_api_key === ""
? ""
: settings.llm_api_key?.trim() || undefined,
- remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
- enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
condenser_max_size:
- settings.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
- enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
- user_consents_to_analytics: settings.user_consents_to_analytics,
- provider_tokens_set: settings.PROVIDER_TOKENS_SET,
- mcp_config: settings.MCP_CONFIG,
- enable_proactive_conversation_starters:
- settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
- enable_solvability_analysis: settings.ENABLE_SOLVABILITY_ANALYSIS,
- search_api_key: settings.SEARCH_API_KEY?.trim() || "",
- max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
+ settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size,
+ search_api_key: settings.search_api_key?.trim() || "",
git_user_name:
- settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME,
+ settings.git_user_name?.trim() || DEFAULT_SETTINGS.git_user_name,
git_user_email:
- settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL,
- v1_enabled: settings.V1_ENABLED,
+ settings.git_user_email?.trim() || DEFAULT_SETTINGS.git_user_email,
};
- await SettingsService.saveSettings(apiSettings);
+ await SettingsService.saveSettings(settingsToSave);
};
export const useSaveSettings = () => {
@@ -47,18 +32,18 @@ export const useSaveSettings = () => {
const { data: currentSettings } = useSettings();
return useMutation({
- mutationFn: async (settings: Partial) => {
+ mutationFn: async (settings: Partial) => {
const newSettings = { ...currentSettings, ...settings };
// Track MCP configuration changes
if (
- settings.MCP_CONFIG &&
- currentSettings?.MCP_CONFIG !== settings.MCP_CONFIG
+ settings.mcp_config &&
+ currentSettings?.mcp_config !== settings.mcp_config
) {
- const hasMcpConfig = !!settings.MCP_CONFIG;
- const sseServersCount = settings.MCP_CONFIG?.sse_servers?.length || 0;
+ const hasMcpConfig = !!settings.mcp_config;
+ const sseServersCount = settings.mcp_config?.sse_servers?.length || 0;
const stdioServersCount =
- settings.MCP_CONFIG?.stdio_servers?.length || 0;
+ settings.mcp_config?.stdio_servers?.length || 0;
// Track MCP configuration usage
posthog.capture("mcp_config_updated", {
diff --git a/frontend/src/hooks/mutation/use-update-mcp-server.ts b/frontend/src/hooks/mutation/use-update-mcp-server.ts
index 7d7b7c9fd4..558997b500 100644
--- a/frontend/src/hooks/mutation/use-update-mcp-server.ts
+++ b/frontend/src/hooks/mutation/use-update-mcp-server.ts
@@ -28,9 +28,9 @@ export function useUpdateMcpServer() {
serverId: string;
server: MCPServerConfig;
}): Promise => {
- if (!settings?.MCP_CONFIG) return;
+ if (!settings?.mcp_config) return;
- const newConfig = { ...settings.MCP_CONFIG };
+ const newConfig = { ...settings.mcp_config };
const [serverType, indexStr] = serverId.split("-");
const index = parseInt(indexStr, 10);
@@ -59,7 +59,7 @@ export function useUpdateMcpServer() {
const apiSettings = {
mcp_config: newConfig,
- v1_enabled: settings.V1_ENABLED,
+ v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);
diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts
index 3f2e57c90d..faf34d5dae 100644
--- a/frontend/src/hooks/query/use-settings.ts
+++ b/frontend/src/hooks/query/use-settings.ts
@@ -6,37 +6,18 @@ import { Settings } from "#/types/settings";
import { useIsAuthed } from "./use-is-authed";
const getSettingsQueryFn = async (): Promise => {
- const apiSettings = await SettingsService.getSettings();
+ const settings = await SettingsService.getSettings();
return {
- LLM_MODEL: apiSettings.llm_model,
- LLM_BASE_URL: apiSettings.llm_base_url,
- AGENT: apiSettings.agent,
- LANGUAGE: apiSettings.language,
- CONFIRMATION_MODE: apiSettings.confirmation_mode,
- SECURITY_ANALYZER: apiSettings.security_analyzer,
- LLM_API_KEY_SET: apiSettings.llm_api_key_set,
- SEARCH_API_KEY_SET: apiSettings.search_api_key_set,
- REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
- PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
- ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
- CONDENSER_MAX_SIZE:
- apiSettings.condenser_max_size ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
- ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
- ENABLE_PROACTIVE_CONVERSATION_STARTERS:
- apiSettings.enable_proactive_conversation_starters,
- ENABLE_SOLVABILITY_ANALYSIS: apiSettings.enable_solvability_analysis,
- USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
- SEARCH_API_KEY: apiSettings.search_api_key || "",
- MAX_BUDGET_PER_TASK: apiSettings.max_budget_per_task,
- EMAIL: apiSettings.email || "",
- EMAIL_VERIFIED: apiSettings.email_verified,
- MCP_CONFIG: apiSettings.mcp_config,
- GIT_USER_NAME: apiSettings.git_user_name || DEFAULT_SETTINGS.GIT_USER_NAME,
- GIT_USER_EMAIL:
- apiSettings.git_user_email || DEFAULT_SETTINGS.GIT_USER_EMAIL,
- IS_NEW_USER: false,
- V1_ENABLED: apiSettings.v1_enabled ?? DEFAULT_SETTINGS.V1_ENABLED,
+ ...settings,
+ condenser_max_size:
+ settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size,
+ search_api_key: settings.search_api_key || "",
+ email: settings.email || "",
+ git_user_name: settings.git_user_name || DEFAULT_SETTINGS.git_user_name,
+ git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email,
+ is_new_user: false,
+ v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled,
};
};
diff --git a/frontend/src/hooks/query/use-start-tasks.ts b/frontend/src/hooks/query/use-start-tasks.ts
index 6af56f2296..3fb1e8d47d 100644
--- a/frontend/src/hooks/query/use-start-tasks.ts
+++ b/frontend/src/hooks/query/use-start-tasks.ts
@@ -15,7 +15,7 @@ import { useSettings } from "#/hooks/query/use-settings";
*/
export const useStartTasks = (limit = 10) => {
const { data: settings } = useSettings();
- const isV1Enabled = settings?.V1_ENABLED;
+ const isV1Enabled = settings?.v1_enabled;
return useQuery({
queryKey: ["start-tasks", "search", limit],
diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts
new file mode 100644
index 0000000000..aa67e8cb9a
--- /dev/null
+++ b/frontend/src/hooks/use-settings-nav-items.ts
@@ -0,0 +1,15 @@
+import { useConfig } from "#/hooks/query/use-config";
+import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
+
+export function useSettingsNavItems() {
+ const { data: config } = useConfig();
+
+ const shouldHideLlmSettings = !!config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS;
+ const isSaasMode = config?.APP_MODE === "saas";
+
+ const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
+
+ return shouldHideLlmSettings
+ ? items.filter((item) => item.to !== "/settings")
+ : items;
+}
diff --git a/frontend/src/hooks/use-sync-posthog-consent.ts b/frontend/src/hooks/use-sync-posthog-consent.ts
index 615aa9a1bf..5032122794 100644
--- a/frontend/src/hooks/use-sync-posthog-consent.ts
+++ b/frontend/src/hooks/use-sync-posthog-consent.ts
@@ -19,7 +19,7 @@ export const useSyncPostHogConsent = () => {
return;
}
- const backendConsent = settings.USER_CONSENTS_TO_ANALYTICS;
+ const backendConsent = settings.user_consents_to_analytics;
// Only sync if there's a backend preference set
if (backendConsent !== null) {
diff --git a/frontend/src/hooks/use-tracking.ts b/frontend/src/hooks/use-tracking.ts
index 0dfc0f0705..d04cdbb81a 100644
--- a/frontend/src/hooks/use-tracking.ts
+++ b/frontend/src/hooks/use-tracking.ts
@@ -17,7 +17,7 @@ export const useTracking = () => {
app_surface: config?.APP_MODE || "unknown",
plan_tier: null,
current_url: window.location.href,
- user_email: settings?.EMAIL || settings?.GIT_USER_EMAIL || null,
+ user_email: settings?.email || settings?.git_user_email || null,
};
const trackLoginButtonClick = ({ provider }: { provider: Provider }) => {
diff --git a/frontend/src/hooks/use-user-providers.ts b/frontend/src/hooks/use-user-providers.ts
index d60102c2e0..c09130990b 100644
--- a/frontend/src/hooks/use-user-providers.ts
+++ b/frontend/src/hooks/use-user-providers.ts
@@ -6,8 +6,8 @@ export const useUserProviders = () => {
const { data: settings, isLoading: isLoadingSettings } = useSettings();
const providers = React.useMemo(
- () => convertRawProvidersToList(settings?.PROVIDER_TOKENS_SET),
- [settings?.PROVIDER_TOKENS_SET],
+ () => convertRawProvidersToList(settings?.provider_tokens_set),
+ [settings?.provider_tokens_set],
);
return {
diff --git a/frontend/src/icons/loading.svg b/frontend/src/icons/loading.svg
index 2da678957f..a5217fd608 100644
--- a/frontend/src/icons/loading.svg
+++ b/frontend/src/icons/loading.svg
@@ -1,3 +1,3 @@
-
-
+
+
diff --git a/frontend/src/mocks/analytics-handlers.ts b/frontend/src/mocks/analytics-handlers.ts
new file mode 100644
index 0000000000..09b3ac0c60
--- /dev/null
+++ b/frontend/src/mocks/analytics-handlers.ts
@@ -0,0 +1,7 @@
+import { http, HttpResponse } from "msw";
+
+export const ANALYTICS_HANDLERS = [
+ http.post("https://us.i.posthog.com/e", async () =>
+ HttpResponse.json(null, { status: 200 }),
+ ),
+];
diff --git a/frontend/src/mocks/auth-handlers.ts b/frontend/src/mocks/auth-handlers.ts
new file mode 100644
index 0000000000..bb4baf2397
--- /dev/null
+++ b/frontend/src/mocks/auth-handlers.ts
@@ -0,0 +1,23 @@
+import { http, HttpResponse } from "msw";
+import { GitUser } from "#/types/git";
+
+export const AUTH_HANDLERS = [
+ http.get("/api/user/info", () => {
+ const user: GitUser = {
+ id: "1",
+ login: "octocat",
+ avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
+ company: "GitHub",
+ email: "placeholder@placeholder.placeholder",
+ name: "monalisa octocat",
+ };
+
+ return HttpResponse.json(user);
+ }),
+
+ http.post("/api/authenticate", async () =>
+ HttpResponse.json({ message: "Authenticated" }),
+ ),
+
+ http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })),
+];
diff --git a/frontend/src/mocks/conversation-handlers.ts b/frontend/src/mocks/conversation-handlers.ts
new file mode 100644
index 0000000000..1ec536fd92
--- /dev/null
+++ b/frontend/src/mocks/conversation-handlers.ts
@@ -0,0 +1,118 @@
+import { http, delay, HttpResponse } from "msw";
+import { Conversation, ResultSet } from "#/api/open-hands.types";
+
+const conversations: Conversation[] = [
+ {
+ conversation_id: "1",
+ title: "My New Project",
+ selected_repository: null,
+ git_provider: null,
+ selected_branch: null,
+ last_updated_at: new Date().toISOString(),
+ created_at: new Date().toISOString(),
+ status: "RUNNING",
+ runtime_status: "STATUS$READY",
+ url: null,
+ session_api_key: null,
+ },
+ {
+ conversation_id: "2",
+ title: "Repo Testing",
+ selected_repository: "octocat/hello-world",
+ git_provider: "github",
+ selected_branch: null,
+ last_updated_at: new Date(
+ Date.now() - 2 * 24 * 60 * 60 * 1000,
+ ).toISOString(),
+ created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
+ status: "STOPPED",
+ runtime_status: null,
+ url: null,
+ session_api_key: null,
+ },
+ {
+ conversation_id: "3",
+ title: "Another Project",
+ selected_repository: "octocat/earth",
+ git_provider: null,
+ selected_branch: "main",
+ last_updated_at: new Date(
+ Date.now() - 5 * 24 * 60 * 60 * 1000,
+ ).toISOString(),
+ created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
+ status: "STOPPED",
+ runtime_status: null,
+ url: null,
+ session_api_key: null,
+ },
+];
+
+const CONVERSATIONS = new Map(
+ conversations.map((c) => [c.conversation_id, c]),
+);
+
+export const CONVERSATION_HANDLERS = [
+ http.get("/api/conversations", async () => {
+ const values = Array.from(CONVERSATIONS.values());
+ const results: ResultSet = {
+ results: values,
+ next_page_id: null,
+ };
+ return HttpResponse.json(results);
+ }),
+
+ http.get("/api/conversations/:conversationId", async ({ params }) => {
+ const conversationId = params.conversationId as string;
+ const project = CONVERSATIONS.get(conversationId);
+ if (project) return HttpResponse.json(project);
+ return HttpResponse.json(null, { status: 404 });
+ }),
+
+ http.post("/api/conversations", async () => {
+ await delay();
+ const conversation: Conversation = {
+ conversation_id: (Math.random() * 100).toString(),
+ title: "New Conversation",
+ selected_repository: null,
+ git_provider: null,
+ selected_branch: null,
+ last_updated_at: new Date().toISOString(),
+ created_at: new Date().toISOString(),
+ status: "RUNNING",
+ runtime_status: "STATUS$READY",
+ url: null,
+ session_api_key: null,
+ };
+ CONVERSATIONS.set(conversation.conversation_id, conversation);
+ return HttpResponse.json(conversation, { status: 201 });
+ }),
+
+ http.patch(
+ "/api/conversations/:conversationId",
+ async ({ params, request }) => {
+ const conversationId = params.conversationId as string;
+ const conversation = CONVERSATIONS.get(conversationId);
+
+ if (conversation) {
+ const body = await request.json();
+ if (typeof body === "object" && body?.title) {
+ CONVERSATIONS.set(conversationId, {
+ ...conversation,
+ title: body.title,
+ });
+ return HttpResponse.json(null, { status: 200 });
+ }
+ }
+ return HttpResponse.json(null, { status: 404 });
+ },
+ ),
+
+ http.delete("/api/conversations/:conversationId", async ({ params }) => {
+ const conversationId = params.conversationId as string;
+ if (CONVERSATIONS.has(conversationId)) {
+ CONVERSATIONS.delete(conversationId);
+ return HttpResponse.json(null, { status: 200 });
+ }
+ return HttpResponse.json(null, { status: 404 });
+ }),
+];
diff --git a/frontend/src/mocks/feedback-handlers.ts b/frontend/src/mocks/feedback-handlers.ts
new file mode 100644
index 0000000000..8e4e602b33
--- /dev/null
+++ b/frontend/src/mocks/feedback-handlers.ts
@@ -0,0 +1,15 @@
+import { http, delay, HttpResponse } from "msw";
+
+export const FEEDBACK_HANDLERS = [
+ http.post("/api/submit-feedback", async () => {
+ await delay(1200);
+ return HttpResponse.json({
+ statusCode: 200,
+ body: { message: "Success", link: "fake-url.com", password: "abc123" },
+ });
+ }),
+
+ http.post("/api/submit-feedback", async () =>
+ HttpResponse.json({ statusCode: 200 }, { status: 200 }),
+ ),
+];
diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts
index 09053bfc31..999903ba93 100644
--- a/frontend/src/mocks/handlers.ts
+++ b/frontend/src/mocks/handlers.ts
@@ -1,146 +1,17 @@
-import { delay, http, HttpResponse } from "msw";
-import { GetConfigResponse } from "#/api/option-service/option.types";
-import { Conversation, ResultSet } from "#/api/open-hands.types";
-import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
-import { Provider } from "#/types/settings";
-import {
- ApiSettings,
- PostApiSettings,
-} from "#/api/settings-service/settings.types";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
-import { GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
import { SECRETS_HANDLERS } from "./secrets-handlers";
import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers";
-
-export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
- llm_model: DEFAULT_SETTINGS.LLM_MODEL,
- llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
- llm_api_key: null,
- llm_api_key_set: DEFAULT_SETTINGS.LLM_API_KEY_SET,
- search_api_key_set: DEFAULT_SETTINGS.SEARCH_API_KEY_SET,
- agent: DEFAULT_SETTINGS.AGENT,
- language: DEFAULT_SETTINGS.LANGUAGE,
- confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
- security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
- remote_runtime_resource_factor:
- DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
- provider_tokens_set: {},
- enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
- condenser_max_size: DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
- enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
- enable_proactive_conversation_starters:
- DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
- enable_solvability_analysis: DEFAULT_SETTINGS.ENABLE_SOLVABILITY_ANALYSIS,
- user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
- max_budget_per_task: DEFAULT_SETTINGS.MAX_BUDGET_PER_TASK,
-};
-
-const MOCK_USER_PREFERENCES: {
- settings: ApiSettings | PostApiSettings | null;
-} = {
- settings: null,
-};
-
-/**
- * Set the user settings to the default settings
- *
- * Useful for resetting the settings in tests
- */
-export const resetTestHandlersMockSettings = () => {
- MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
-};
-
-const conversations: Conversation[] = [
- {
- conversation_id: "1",
- title: "My New Project",
- selected_repository: null,
- git_provider: null,
- selected_branch: null,
- last_updated_at: new Date().toISOString(),
- created_at: new Date().toISOString(),
- status: "RUNNING",
- runtime_status: "STATUS$READY",
- url: null,
- session_api_key: null,
- },
- {
- conversation_id: "2",
- title: "Repo Testing",
- selected_repository: "octocat/hello-world",
- git_provider: "github",
- selected_branch: null,
- // 2 days ago
- last_updated_at: new Date(
- Date.now() - 2 * 24 * 60 * 60 * 1000,
- ).toISOString(),
- created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
- status: "STOPPED",
- runtime_status: null,
- url: null,
- session_api_key: null,
- },
- {
- conversation_id: "3",
- title: "Another Project",
- selected_repository: "octocat/earth",
- git_provider: null,
- selected_branch: "main",
- // 5 days ago
- last_updated_at: new Date(
- Date.now() - 5 * 24 * 60 * 60 * 1000,
- ).toISOString(),
- created_at: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
- status: "STOPPED",
- runtime_status: null,
- url: null,
- session_api_key: null,
- },
-];
-
-const CONVERSATIONS = new Map(
- conversations.map((conversation) => [
- conversation.conversation_id,
- conversation,
- ]),
-);
-
-const openHandsHandlers = [
- http.get("/api/options/models", async () =>
- HttpResponse.json([
- "gpt-3.5-turbo",
- "gpt-4o",
- "gpt-4o-mini",
- "anthropic/claude-3.5",
- "anthropic/claude-sonnet-4-20250514",
- "anthropic/claude-sonnet-4-5-20250929",
- "anthropic/claude-haiku-4-5-20251001",
- "openhands/claude-sonnet-4-20250514",
- "openhands/claude-sonnet-4-5-20250929",
- "openhands/claude-haiku-4-5-20251001",
- "sambanova/Meta-Llama-3.1-8B-Instruct",
- ]),
- ),
-
- http.get("/api/options/agents", async () =>
- HttpResponse.json(["CodeActAgent", "CoActAgent"]),
- ),
-
- http.get("/api/options/security-analyzers", async () =>
- HttpResponse.json(["llm", "none"]),
- ),
-
- http.post("http://localhost:3001/api/submit-feedback", async () => {
- await delay(1200);
-
- return HttpResponse.json({
- statusCode: 200,
- body: { message: "Success", link: "fake-url.com", password: "abc123" },
- });
- }),
-];
+import {
+ SETTINGS_HANDLERS,
+ MOCK_DEFAULT_USER_SETTINGS,
+ resetTestHandlersMockSettings,
+} from "./settings-handlers";
+import { CONVERSATION_HANDLERS } from "./conversation-handlers";
+import { AUTH_HANDLERS } from "./auth-handlers";
+import { FEEDBACK_HANDLERS } from "./feedback-handlers";
+import { ANALYTICS_HANDLERS } from "./analytics-handlers";
export const handlers = [
...STRIPE_BILLING_HANDLERS,
@@ -148,192 +19,11 @@ export const handlers = [
...TASK_SUGGESTIONS_HANDLERS,
...SECRETS_HANDLERS,
...GIT_REPOSITORY_HANDLERS,
- ...openHandsHandlers,
- http.get("/api/user/info", () => {
- const user: GitUser = {
- id: "1",
- login: "octocat",
- avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4",
- company: "GitHub",
- email: "placeholder@placeholder.placeholder",
- name: "monalisa octocat",
- };
-
- return HttpResponse.json(user);
- }),
- http.post("http://localhost:3001/api/submit-feedback", async () =>
- HttpResponse.json({ statusCode: 200 }, { status: 200 }),
- ),
- http.post("https://us.i.posthog.com/e", async () =>
- HttpResponse.json(null, { status: 200 }),
- ),
- http.get("/api/options/config", () => {
- const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true";
-
- const config: GetConfigResponse = {
- APP_MODE: mockSaas ? "saas" : "oss",
- GITHUB_CLIENT_ID: "fake-github-client-id",
- POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
- FEATURE_FLAGS: {
- ENABLE_BILLING: false,
- HIDE_LLM_SETTINGS: mockSaas,
- ENABLE_JIRA: false,
- ENABLE_JIRA_DC: false,
- ENABLE_LINEAR: false,
- },
- // Uncomment the following to test the maintenance banner
- // MAINTENANCE: {
- // startTime: "2024-01-15T10:00:00-05:00", // EST timestamp
- // },
- };
-
- return HttpResponse.json(config);
- }),
- http.get("/api/settings", async () => {
- await delay();
-
- const { settings } = MOCK_USER_PREFERENCES;
-
- if (!settings) return HttpResponse.json(null, { status: 404 });
-
- return HttpResponse.json(settings);
- }),
- http.post("/api/settings", async ({ request }) => {
- await delay();
- const body = await request.json();
-
- if (body) {
- const current = MOCK_USER_PREFERENCES.settings || {
- ...MOCK_DEFAULT_USER_SETTINGS,
- };
- // Persist new values over current/mock defaults
- MOCK_USER_PREFERENCES.settings = {
- ...current,
- ...(body as Partial),
- };
- return HttpResponse.json(null, { status: 200 });
- }
-
- return HttpResponse.json(null, { status: 400 });
- }),
-
- http.post("/api/authenticate", async () =>
- HttpResponse.json({ message: "Authenticated" }),
- ),
-
- http.get("/api/conversations", async () => {
- const values = Array.from(CONVERSATIONS.values());
- const results: ResultSet = {
- results: values,
- next_page_id: null,
- };
-
- return HttpResponse.json(results, { status: 200 });
- }),
-
- http.delete("/api/conversations/:conversationId", async ({ params }) => {
- const { conversationId } = params;
-
- if (typeof conversationId === "string") {
- CONVERSATIONS.delete(conversationId);
- return HttpResponse.json(null, { status: 200 });
- }
-
- return HttpResponse.json(null, { status: 404 });
- }),
-
- http.patch(
- "/api/conversations/:conversationId",
- async ({ params, request }) => {
- const { conversationId } = params;
-
- if (typeof conversationId === "string") {
- const conversation = CONVERSATIONS.get(conversationId);
-
- if (conversation) {
- const body = await request.json();
- if (typeof body === "object" && body?.title) {
- CONVERSATIONS.set(conversationId, {
- ...conversation,
- title: body.title,
- });
- return HttpResponse.json(null, { status: 200 });
- }
- }
- }
-
- return HttpResponse.json(null, { status: 404 });
- },
- ),
-
- http.post("/api/conversations", async () => {
- await delay();
-
- const conversation: Conversation = {
- conversation_id: (Math.random() * 100).toString(),
- title: "New Conversation",
- selected_repository: null,
- git_provider: null,
- selected_branch: null,
- last_updated_at: new Date().toISOString(),
- created_at: new Date().toISOString(),
- status: "RUNNING",
- runtime_status: "STATUS$READY",
- url: null,
- session_api_key: null,
- };
-
- CONVERSATIONS.set(conversation.conversation_id, conversation);
- return HttpResponse.json(conversation, { status: 201 });
- }),
-
- http.get("/api/conversations/:conversationId", async ({ params }) => {
- const { conversationId } = params;
-
- if (typeof conversationId === "string") {
- const project = CONVERSATIONS.get(conversationId);
-
- if (project) {
- return HttpResponse.json(project, { status: 200 });
- }
- }
-
- return HttpResponse.json(null, { status: 404 });
- }),
-
- http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })),
-
- http.post("/api/reset-settings", async () => {
- await delay();
- MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS };
- return HttpResponse.json(null, { status: 200 });
- }),
-
- http.post("/api/add-git-providers", async ({ request }) => {
- const body = await request.json();
-
- if (typeof body === "object" && body?.provider_tokens) {
- const rawTokens = body.provider_tokens as Record<
- string,
- { token?: string }
- >;
-
- const providerTokensSet: Partial> =
- Object.fromEntries(
- Object.entries(rawTokens)
- .filter(([, val]) => val && val.token)
- .map(([provider]) => [provider as Provider, ""]),
- );
-
- const newSettings = {
- ...(MOCK_USER_PREFERENCES.settings ?? MOCK_DEFAULT_USER_SETTINGS),
- provider_tokens_set: providerTokensSet,
- };
- MOCK_USER_PREFERENCES.settings = newSettings;
-
- return HttpResponse.json(true, { status: 200 });
- }
-
- return HttpResponse.json(null, { status: 400 });
- }),
+ ...SETTINGS_HANDLERS,
+ ...CONVERSATION_HANDLERS,
+ ...AUTH_HANDLERS,
+ ...FEEDBACK_HANDLERS,
+ ...ANALYTICS_HANDLERS,
];
+
+export { MOCK_DEFAULT_USER_SETTINGS, resetTestHandlersMockSettings };
diff --git a/frontend/src/mocks/settings-handlers.ts b/frontend/src/mocks/settings-handlers.ts
new file mode 100644
index 0000000000..c08cd8dc36
--- /dev/null
+++ b/frontend/src/mocks/settings-handlers.ts
@@ -0,0 +1,151 @@
+import { http, delay, HttpResponse } from "msw";
+import { GetConfigResponse } from "#/api/option-service/option.types";
+import { DEFAULT_SETTINGS } from "#/services/settings";
+import { Provider, Settings } from "#/types/settings";
+
+export const MOCK_DEFAULT_USER_SETTINGS: Settings = {
+ llm_model: DEFAULT_SETTINGS.llm_model,
+ llm_base_url: DEFAULT_SETTINGS.llm_base_url,
+ llm_api_key: null,
+ llm_api_key_set: DEFAULT_SETTINGS.llm_api_key_set,
+ search_api_key_set: DEFAULT_SETTINGS.search_api_key_set,
+ agent: DEFAULT_SETTINGS.agent,
+ language: DEFAULT_SETTINGS.language,
+ confirmation_mode: DEFAULT_SETTINGS.confirmation_mode,
+ security_analyzer: DEFAULT_SETTINGS.security_analyzer,
+ remote_runtime_resource_factor:
+ DEFAULT_SETTINGS.remote_runtime_resource_factor,
+ provider_tokens_set: {},
+ enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser,
+ condenser_max_size: DEFAULT_SETTINGS.condenser_max_size,
+ enable_sound_notifications: DEFAULT_SETTINGS.enable_sound_notifications,
+ enable_proactive_conversation_starters:
+ DEFAULT_SETTINGS.enable_proactive_conversation_starters,
+ enable_solvability_analysis: DEFAULT_SETTINGS.enable_solvability_analysis,
+ user_consents_to_analytics: DEFAULT_SETTINGS.user_consents_to_analytics,
+ max_budget_per_task: DEFAULT_SETTINGS.max_budget_per_task,
+};
+
+const MOCK_USER_PREFERENCES: {
+ settings: Settings | null;
+} = {
+ settings: null,
+};
+
+// Reset mock
+export const resetTestHandlersMockSettings = () => {
+ MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
+};
+
+// --- Handlers for options/config/settings ---
+
+export const SETTINGS_HANDLERS = [
+ http.get("/api/options/models", async () =>
+ HttpResponse.json([
+ "gpt-3.5-turbo",
+ "gpt-4o",
+ "gpt-4o-mini",
+ "anthropic/claude-3.5",
+ "anthropic/claude-sonnet-4-20250514",
+ "anthropic/claude-sonnet-4-5-20250929",
+ "anthropic/claude-haiku-4-5-20251001",
+ "openhands/claude-sonnet-4-20250514",
+ "openhands/claude-sonnet-4-5-20250929",
+ "openhands/claude-haiku-4-5-20251001",
+ "sambanova/Meta-Llama-3.1-8B-Instruct",
+ ]),
+ ),
+
+ http.get("/api/options/agents", async () =>
+ HttpResponse.json(["CodeActAgent", "CoActAgent"]),
+ ),
+
+ http.get("/api/options/security-analyzers", async () =>
+ HttpResponse.json(["llm", "none"]),
+ ),
+
+ http.get("/api/options/config", () => {
+ const mockSaas = import.meta.env.VITE_MOCK_SAAS === "true";
+
+ const config: GetConfigResponse = {
+ APP_MODE: mockSaas ? "saas" : "oss",
+ GITHUB_CLIENT_ID: "fake-github-client-id",
+ POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
+ FEATURE_FLAGS: {
+ ENABLE_BILLING: false,
+ HIDE_LLM_SETTINGS: mockSaas,
+ ENABLE_JIRA: false,
+ ENABLE_JIRA_DC: false,
+ ENABLE_LINEAR: false,
+ },
+ // Uncomment the following to test the maintenance banner
+ // MAINTENANCE: {
+ // startTime: "2024-01-15T10:00:00-05:00", // EST timestamp
+ // },
+ };
+
+ return HttpResponse.json(config);
+ }),
+
+ http.get("/api/settings", async () => {
+ await delay();
+ const { settings } = MOCK_USER_PREFERENCES;
+
+ if (!settings) return HttpResponse.json(null, { status: 404 });
+
+ return HttpResponse.json(settings);
+ }),
+
+ http.post("/api/settings", async ({ request }) => {
+ await delay();
+ const body = await request.json();
+
+ if (body) {
+ const current = MOCK_USER_PREFERENCES.settings || {
+ ...MOCK_DEFAULT_USER_SETTINGS,
+ };
+
+ MOCK_USER_PREFERENCES.settings = {
+ ...current,
+ ...(body as Partial),
+ };
+
+ return HttpResponse.json(null, { status: 200 });
+ }
+
+ return HttpResponse.json(null, { status: 400 });
+ }),
+
+ http.post("/api/reset-settings", async () => {
+ await delay();
+ MOCK_USER_PREFERENCES.settings = { ...MOCK_DEFAULT_USER_SETTINGS };
+ return HttpResponse.json(null, { status: 200 });
+ }),
+
+ http.post("/api/add-git-providers", async ({ request }) => {
+ const body = await request.json();
+
+ if (typeof body === "object" && body?.provider_tokens) {
+ const rawTokens = body.provider_tokens as Record<
+ string,
+ { token?: string }
+ >;
+
+ const providerTokensSet: Partial> =
+ Object.fromEntries(
+ Object.entries(rawTokens)
+ .filter(([, val]) => val && val.token)
+ .map(([provider]) => [provider as Provider, ""]),
+ );
+
+ MOCK_USER_PREFERENCES.settings = {
+ ...(MOCK_USER_PREFERENCES.settings || MOCK_DEFAULT_USER_SETTINGS),
+ provider_tokens_set: providerTokensSet,
+ };
+
+ return HttpResponse.json(true, { status: 200 });
+ }
+
+ return HttpResponse.json(null, { status: 400 });
+ }),
+];
diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx
index e825bb3e0f..a8524cc989 100644
--- a/frontend/src/routes/app-settings.tsx
+++ b/frontend/src/routes/app-settings.tsx
@@ -56,7 +56,7 @@ function AppSettingsScreen() {
const languageValue = AvailableLanguages.find(
({ label }) => label === languageLabel,
)?.value;
- const language = languageValue || DEFAULT_SETTINGS.LANGUAGE;
+ const language = languageValue || DEFAULT_SETTINGS.language;
const enableAnalytics =
formData.get("enable-analytics-switch")?.toString() === "on";
@@ -77,21 +77,21 @@ function AppSettingsScreen() {
const gitUserName =
formData.get("git-user-name-input")?.toString() ||
- DEFAULT_SETTINGS.GIT_USER_NAME;
+ DEFAULT_SETTINGS.git_user_name;
const gitUserEmail =
formData.get("git-user-email-input")?.toString() ||
- DEFAULT_SETTINGS.GIT_USER_EMAIL;
+ DEFAULT_SETTINGS.git_user_email;
saveSettings(
{
- LANGUAGE: language,
+ language,
user_consents_to_analytics: enableAnalytics,
- ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
- ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
- ENABLE_SOLVABILITY_ANALYSIS: enableSolvabilityAnalysis,
- MAX_BUDGET_PER_TASK: maxBudgetPerTask,
- GIT_USER_NAME: gitUserName,
- GIT_USER_EMAIL: gitUserEmail,
+ enable_sound_notifications: enableSoundNotifications,
+ enable_proactive_conversation_starters: enableProactiveConversations,
+ enable_solvability_analysis: enableSolvabilityAnalysis,
+ max_budget_per_task: maxBudgetPerTask,
+ git_user_name: gitUserName,
+ git_user_email: gitUserEmail,
},
{
onSuccess: () => {
@@ -120,7 +120,7 @@ function AppSettingsScreen() {
({ label: langValue }) => langValue === value,
)?.label;
const currentLanguage = AvailableLanguages.find(
- ({ value: langValue }) => langValue === settings?.LANGUAGE,
+ ({ value: langValue }) => langValue === settings?.language,
)?.label;
setLanguageInputHasChanged(selectedLanguage !== currentLanguage);
@@ -128,12 +128,12 @@ function AppSettingsScreen() {
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
// Treat null as true since analytics is opt-in by default
- const currentAnalytics = settings?.USER_CONSENTS_TO_ANALYTICS ?? true;
+ const currentAnalytics = settings?.user_consents_to_analytics ?? true;
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
};
const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => {
- const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS;
+ const currentSoundNotifications = !!settings?.enable_sound_notifications;
setSoundNotificationsSwitchHasChanged(
checked !== currentSoundNotifications,
);
@@ -141,14 +141,14 @@ function AppSettingsScreen() {
const checkIfProactiveConversationsSwitchHasChanged = (checked: boolean) => {
const currentProactiveConversations =
- !!settings?.ENABLE_PROACTIVE_CONVERSATION_STARTERS;
+ !!settings?.enable_proactive_conversation_starters;
setProactiveConversationsSwitchHasChanged(
checked !== currentProactiveConversations,
);
};
const checkIfSolvabilityAnalysisSwitchHasChanged = (checked: boolean) => {
- const currentSolvabilityAnalysis = !!settings?.ENABLE_SOLVABILITY_ANALYSIS;
+ const currentSolvabilityAnalysis = !!settings?.enable_solvability_analysis;
setSolvabilityAnalysisSwitchHasChanged(
checked !== currentSolvabilityAnalysis,
);
@@ -156,17 +156,17 @@ function AppSettingsScreen() {
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
const newValue = parseMaxBudgetPerTask(value);
- const currentValue = settings?.MAX_BUDGET_PER_TASK;
+ const currentValue = settings?.max_budget_per_task;
setMaxBudgetPerTaskHasChanged(newValue !== currentValue);
};
const checkIfGitUserNameHasChanged = (value: string) => {
- const currentValue = settings?.GIT_USER_NAME;
+ const currentValue = settings?.git_user_name;
setGitUserNameHasChanged(value !== currentValue);
};
const checkIfGitUserEmailHasChanged = (value: string) => {
- const currentValue = settings?.GIT_USER_EMAIL;
+ const currentValue = settings?.git_user_email;
setGitUserEmailHasChanged(value !== currentValue);
};
@@ -193,14 +193,14 @@ function AppSettingsScreen() {
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
@@ -209,7 +209,7 @@ function AppSettingsScreen() {
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
@@ -220,7 +220,7 @@ function AppSettingsScreen() {
testId="enable-proactive-conversations-switch"
name="enable-proactive-conversations-switch"
defaultIsToggled={
- !!settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS
+ !!settings.enable_proactive_conversation_starters
}
onToggle={checkIfProactiveConversationsSwitchHasChanged}
>
@@ -232,20 +232,20 @@ function AppSettingsScreen() {
{t(I18nKey.SETTINGS$SOLVABILITY_ANALYSIS)}
)}
- {!settings?.V1_ENABLED && (
+ {!settings?.v1_enabled && (
(
@@ -111,7 +111,7 @@ function LlmSettingsScreen() {
);
// Determine if we should hide the API key input and use OpenHands-managed key (when using OpenHands provider in SaaS mode)
- const currentModel = currentSelectedModel || settings?.LLM_MODEL;
+ const currentModel = currentSelectedModel || settings?.llm_model;
const isSaasMode = config?.APP_MODE === "saas";
@@ -124,7 +124,7 @@ function LlmSettingsScreen() {
if (dirtyInputs.model) {
return currentModel?.startsWith("openhands/");
}
- return settings?.LLM_MODEL?.startsWith("openhands/");
+ return settings?.llm_model?.startsWith("openhands/");
}
return false;
@@ -133,13 +133,13 @@ function LlmSettingsScreen() {
const shouldUseOpenHandsKey = isOpenHandsProvider() && isSaasMode;
// Determine if we should hide the agent dropdown when V1 conversation API is enabled
- const isV1Enabled = settings?.V1_ENABLED;
+ const isV1Enabled = settings?.v1_enabled;
React.useEffect(() => {
const determineWhetherToToggleAdvancedSettings = () => {
if (resources && settings) {
return (
- isCustomModel(resources.models, settings.LLM_MODEL) ||
+ isCustomModel(resources.models, settings.llm_model) ||
hasAdvancedSettingsSet({
...settings,
})
@@ -157,24 +157,24 @@ function LlmSettingsScreen() {
// Initialize currentSelectedModel with the current settings
React.useEffect(() => {
- if (settings?.LLM_MODEL) {
- setCurrentSelectedModel(settings.LLM_MODEL);
+ if (settings?.llm_model) {
+ setCurrentSelectedModel(settings.llm_model);
}
- }, [settings?.LLM_MODEL]);
+ }, [settings?.llm_model]);
// Update confirmation mode state when settings change
React.useEffect(() => {
- if (settings?.CONFIRMATION_MODE !== undefined) {
- setConfirmationModeEnabled(settings.CONFIRMATION_MODE);
+ if (settings?.confirmation_mode !== undefined) {
+ setConfirmationModeEnabled(settings.confirmation_mode);
}
- }, [settings?.CONFIRMATION_MODE]);
+ }, [settings?.confirmation_mode]);
// Update selected security analyzer state when settings change
React.useEffect(() => {
- if (settings?.SECURITY_ANALYZER !== undefined) {
- setSelectedSecurityAnalyzer(settings.SECURITY_ANALYZER || "none");
+ if (settings?.security_analyzer !== undefined) {
+ setSelectedSecurityAnalyzer(settings.security_analyzer || "none");
}
- }, [settings?.SECURITY_ANALYZER]);
+ }, [settings?.security_analyzer]);
// Handle URL parameters for SaaS subscription redirects
React.useEffect(() => {
@@ -230,19 +230,19 @@ function LlmSettingsScreen() {
saveSettings(
{
- LLM_MODEL: fullLlmModel,
+ llm_model: fullLlmModel,
llm_api_key: finalApiKey || null,
- SEARCH_API_KEY: searchApiKey || "",
- CONFIRMATION_MODE: confirmationMode,
- SECURITY_ANALYZER:
+ search_api_key: searchApiKey || "",
+ confirmation_mode: confirmationMode,
+ security_analyzer:
securityAnalyzer === "none"
? null
- : securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
+ : securityAnalyzer || DEFAULT_SETTINGS.security_analyzer,
// reset advanced settings
- LLM_BASE_URL: DEFAULT_SETTINGS.LLM_BASE_URL,
- AGENT: DEFAULT_SETTINGS.AGENT,
- ENABLE_DEFAULT_CONDENSER: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
+ llm_base_url: DEFAULT_SETTINGS.llm_base_url,
+ agent: DEFAULT_SETTINGS.agent,
+ enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser,
},
{
onSuccess: handleSuccessfulMutation,
@@ -281,19 +281,19 @@ function LlmSettingsScreen() {
saveSettings(
{
- LLM_MODEL: model,
- LLM_BASE_URL: baseUrl,
+ llm_model: model,
+ llm_base_url: baseUrl,
llm_api_key: finalApiKey || null,
- SEARCH_API_KEY: searchApiKey || "",
- AGENT: agent,
- CONFIRMATION_MODE: confirmationMode,
- ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
- CONDENSER_MAX_SIZE:
- condenserMaxSize ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
- SECURITY_ANALYZER:
+ search_api_key: searchApiKey || "",
+ agent,
+ confirmation_mode: confirmationMode,
+ enable_default_condenser: enableDefaultCondenser,
+ condenser_max_size:
+ condenserMaxSize ?? DEFAULT_SETTINGS.condenser_max_size,
+ security_analyzer:
securityAnalyzer === "none"
? null
- : securityAnalyzer || DEFAULT_SETTINGS.SECURITY_ANALYZER,
+ : securityAnalyzer || DEFAULT_SETTINGS.security_analyzer,
},
{
onSuccess: handleSuccessfulMutation,
@@ -323,7 +323,7 @@ function LlmSettingsScreen() {
) => {
// openai providers are special case; see ModelSelector
// component for details
- const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", "");
+ const modelIsDirty = model !== settings?.llm_model.replace("openai/", "");
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
@@ -351,7 +351,7 @@ function LlmSettingsScreen() {
};
const handleSearchApiKeyIsDirty = (searchApiKey: string) => {
- const searchApiKeyIsDirty = searchApiKey !== settings?.SEARCH_API_KEY;
+ const searchApiKeyIsDirty = searchApiKey !== settings?.search_api_key;
setDirtyInputs((prev) => ({
...prev,
searchApiKey: searchApiKeyIsDirty,
@@ -359,7 +359,7 @@ function LlmSettingsScreen() {
};
const handleCustomModelIsDirty = (model: string) => {
- const modelIsDirty = model !== settings?.LLM_MODEL && model !== "";
+ const modelIsDirty = model !== settings?.llm_model && model !== "";
setDirtyInputs((prev) => ({
...prev,
model: modelIsDirty,
@@ -370,7 +370,7 @@ function LlmSettingsScreen() {
};
const handleBaseUrlIsDirty = (baseUrl: string) => {
- const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL;
+ const baseUrlIsDirty = baseUrl !== settings?.llm_base_url;
setDirtyInputs((prev) => ({
...prev,
baseUrl: baseUrlIsDirty,
@@ -378,7 +378,7 @@ function LlmSettingsScreen() {
};
const handleAgentIsDirty = (agent: string) => {
- const agentIsDirty = agent !== settings?.AGENT && agent !== "";
+ const agentIsDirty = agent !== settings?.agent && agent !== "";
setDirtyInputs((prev) => ({
...prev,
agent: agentIsDirty,
@@ -386,7 +386,7 @@ function LlmSettingsScreen() {
};
const handleConfirmationModeIsDirty = (isToggled: boolean) => {
- const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE;
+ const confirmationModeIsDirty = isToggled !== settings?.confirmation_mode;
setDirtyInputs((prev) => ({
...prev,
confirmationMode: confirmationModeIsDirty,
@@ -395,7 +395,7 @@ function LlmSettingsScreen() {
// When confirmation mode is enabled, set default security analyzer to "llm" if not already set
if (isToggled && !selectedSecurityAnalyzer) {
- setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.SECURITY_ANALYZER);
+ setSelectedSecurityAnalyzer(DEFAULT_SETTINGS.security_analyzer);
setDirtyInputs((prev) => ({
...prev,
securityAnalyzer: true,
@@ -405,7 +405,7 @@ function LlmSettingsScreen() {
const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => {
const enableDefaultCondenserIsDirty =
- isToggled !== settings?.ENABLE_DEFAULT_CONDENSER;
+ isToggled !== settings?.enable_default_condenser;
setDirtyInputs((prev) => ({
...prev,
enableDefaultCondenser: enableDefaultCondenserIsDirty,
@@ -416,8 +416,8 @@ function LlmSettingsScreen() {
const parsed = value ? Number.parseInt(value, 10) : undefined;
const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined;
const condenserMaxSizeIsDirty =
- (bounded ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
- (settings?.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE);
+ (bounded ?? DEFAULT_SETTINGS.condenser_max_size) !==
+ (settings?.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size);
setDirtyInputs((prev) => ({
...prev,
condenserMaxSize: condenserMaxSizeIsDirty,
@@ -426,7 +426,7 @@ function LlmSettingsScreen() {
const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => {
const securityAnalyzerIsDirty =
- securityAnalyzer !== settings?.SECURITY_ANALYZER;
+ securityAnalyzer !== settings?.security_analyzer;
setDirtyInputs((prev) => ({
...prev,
securityAnalyzer: securityAnalyzerIsDirty,
@@ -512,12 +512,12 @@ function LlmSettingsScreen() {
<>
- {(settings.LLM_MODEL?.startsWith("openhands/") ||
+ {(settings.llm_model?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
)}
@@ -532,11 +532,11 @@ function LlmSettingsScreen() {
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-full max-w-[680px]"
- placeholder={settings.LLM_API_KEY_SET ? "" : ""}
+ placeholder={settings.llm_api_key_set ? "" : ""}
onChange={handleApiKeyIsDirty}
startContent={
- settings.LLM_API_KEY_SET && (
-
+ settings.llm_api_key_set && (
+
)
}
/>
@@ -561,13 +561,13 @@ function LlmSettingsScreen() {
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
- defaultValue={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL}
+ defaultValue={settings.llm_model || DEFAULT_OPENHANDS_MODEL}
placeholder={DEFAULT_OPENHANDS_MODEL}
type="text"
className="w-full max-w-[680px]"
onChange={handleCustomModelIsDirty}
/>
- {(settings.LLM_MODEL?.startsWith("openhands/") ||
+ {(settings.llm_model?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
)}
@@ -576,7 +576,7 @@ function LlmSettingsScreen() {
testId="base-url-input"
name="base-url-input"
label={t(I18nKey.SETTINGS$BASE_URL)}
- defaultValue={settings.LLM_BASE_URL}
+ defaultValue={settings.llm_base_url}
placeholder="https://api.openai.com"
type="text"
className="w-full max-w-[680px]"
@@ -591,11 +591,11 @@ function LlmSettingsScreen() {
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
type="password"
className="w-full max-w-[680px]"
- placeholder={settings.LLM_API_KEY_SET ? "" : ""}
+ placeholder={settings.llm_api_key_set ? "" : ""}
onChange={handleApiKeyIsDirty}
startContent={
- settings.LLM_API_KEY_SET && (
-
+ settings.llm_api_key_set && (
+
)
}
/>
@@ -616,12 +616,12 @@ function LlmSettingsScreen() {
label={t(I18nKey.SETTINGS$SEARCH_API_KEY)}
type="password"
className="w-full max-w-[680px]"
- defaultValue={settings.SEARCH_API_KEY || ""}
+ defaultValue={settings.search_api_key || ""}
onChange={handleSearchApiKeyIsDirty}
placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)}
startContent={
- settings.SEARCH_API_KEY_SET && (
-
+ settings.search_api_key_set && (
+
)
}
/>
@@ -644,7 +644,7 @@ function LlmSettingsScreen() {
label: agent, // TODO: Add i18n support for agent names
})) || []
}
- defaultSelectedKey={settings.AGENT}
+ defaultSelectedKey={settings.agent}
isClearable={false}
onInputChange={handleAgentIsDirty}
wrapperClassName="w-full max-w-[680px]"
@@ -662,11 +662,11 @@ function LlmSettingsScreen() {
step={1}
label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)}
defaultValue={(
- settings.CONDENSER_MAX_SIZE ??
- DEFAULT_SETTINGS.CONDENSER_MAX_SIZE
+ settings.condenser_max_size ??
+ DEFAULT_SETTINGS.condenser_max_size
)?.toString()}
onChange={(value) => handleCondenserMaxSizeIsDirty(value)}
- isDisabled={!settings.ENABLE_DEFAULT_CONDENSER}
+ isDisabled={!settings.enable_default_condenser}
/>
{t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP)}
@@ -676,7 +676,7 @@ function LlmSettingsScreen() {
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
@@ -688,7 +688,7 @@ function LlmSettingsScreen() {
testId="enable-confirmation-mode-switch"
name="enable-confirmation-mode-switch"
onToggle={handleConfirmationModeIsDirty}
- defaultIsToggled={settings.CONFIRMATION_MODE}
+ defaultIsToggled={settings.confirmation_mode}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
diff --git a/frontend/src/routes/mcp-settings.tsx b/frontend/src/routes/mcp-settings.tsx
index 0a4224182b..e308b45228 100644
--- a/frontend/src/routes/mcp-settings.tsx
+++ b/frontend/src/routes/mcp-settings.tsx
@@ -41,7 +41,7 @@ function MCPSettingsScreen() {
useState(false);
const [serverToDelete, setServerToDelete] = useState(null);
- const mcpConfig: MCPConfig = settings?.MCP_CONFIG || {
+ const mcpConfig: MCPConfig = settings?.mcp_config || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx
index 264ae541c8..876c4d8c11 100644
--- a/frontend/src/routes/root-layout.tsx
+++ b/frontend/src/routes/root-layout.tsx
@@ -106,16 +106,16 @@ export default function MainApp() {
React.useEffect(() => {
// Don't change language when on TOS page
- if (!isOnTosPage && settings?.LANGUAGE) {
- i18n.changeLanguage(settings.LANGUAGE);
+ if (!isOnTosPage && settings?.language) {
+ i18n.changeLanguage(settings.language);
}
- }, [settings?.LANGUAGE, isOnTosPage]);
+ }, [settings?.language, isOnTosPage]);
React.useEffect(() => {
// Don't show consent form when on TOS page
if (!isOnTosPage) {
const consentFormModalIsOpen =
- settings?.USER_CONSENTS_TO_ANALYTICS === null;
+ settings?.user_consents_to_analytics === null;
setConsentFormIsOpen(consentFormModalIsOpen);
}
@@ -134,10 +134,10 @@ export default function MainApp() {
}, [isOnTosPage]);
React.useEffect(() => {
- if (settings?.IS_NEW_USER && config.data?.APP_MODE === "saas") {
+ if (settings?.is_new_user && config.data?.APP_MODE === "saas") {
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
}
- }, [settings?.IS_NEW_USER, config.data?.APP_MODE]);
+ }, [settings?.is_new_user, config.data?.APP_MODE]);
React.useEffect(() => {
// Don't do any redirects when on TOS page
@@ -249,7 +249,7 @@ export default function MainApp() {
{config.data?.FEATURE_FLAGS.ENABLE_BILLING &&
config.data?.APP_MODE === "saas" &&
- settings?.IS_NEW_USER && }
+ settings?.is_new_user && }
);
}
diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx
index 2d7f7cb6b6..4f35595d13 100644
--- a/frontend/src/routes/settings.tsx
+++ b/frontend/src/routes/settings.tsx
@@ -1,14 +1,13 @@
import { useMemo } from "react";
import { Outlet, redirect, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
-import { useConfig } from "#/hooks/query/use-config";
import { Route } from "./+types/settings";
import OptionService from "#/api/option-service/option-service.api";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/option-service/option.types";
-import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
-import { Typography } from "#/ui/typography";
import { SettingsLayout } from "#/components/features/settings/settings-layout";
+import { Typography } from "#/ui/typography";
+import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
const SAAS_ONLY_PATHS = [
"/settings/user",
@@ -33,14 +32,10 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
// if in OSS mode, do not allow access to saas-only paths
return redirect("/settings");
}
-
// If LLM settings are hidden and user tries to access the LLM settings page
if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS && pathname === "/settings") {
// Redirect to the first available settings page
- if (isSaas) {
- return redirect("/settings/user");
- }
- return redirect("/settings/mcp");
+ return isSaas ? redirect("/settings/user") : redirect("/settings/mcp");
}
return null;
@@ -48,37 +43,15 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
function SettingsScreen() {
const { t } = useTranslation();
- const { data: config } = useConfig();
const location = useLocation();
-
- const isSaas = config?.APP_MODE === "saas";
-
- // Navigation items configuration
- const navItems = useMemo(() => {
- const items = [];
- if (isSaas) {
- items.push(...SAAS_NAV_ITEMS);
- } else {
- items.push(...OSS_NAV_ITEMS);
- }
-
- // Filter out LLM settings if the feature flag is enabled
- if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS) {
- return items.filter((item) => item.to !== "/settings");
- }
-
- return items;
- }, [isSaas, config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS]);
-
+ const navItems = useSettingsNavItems();
// Current section title for the main content area
const currentSectionTitle = useMemo(() => {
const currentItem = navItems.find((item) => item.to === location.pathname);
- if (currentItem) {
- return currentItem.text;
- }
-
// Default to the first available navigation item if current page is not found
- return navItems.length > 0 ? navItems[0].text : "SETTINGS$TITLE";
+ return currentItem
+ ? currentItem.text
+ : (navItems[0]?.text ?? "SETTINGS$TITLE");
}, [navItems, location.pathname]);
return (
diff --git a/frontend/src/routes/user-settings.tsx b/frontend/src/routes/user-settings.tsx
index 93366574b0..cddc38466e 100644
--- a/frontend/src/routes/user-settings.tsx
+++ b/frontend/src/routes/user-settings.tsx
@@ -122,12 +122,12 @@ function UserSettingsScreen() {
const prevVerificationStatusRef = useRef(undefined);
useEffect(() => {
- if (settings?.EMAIL) {
- setEmail(settings.EMAIL);
- setOriginalEmail(settings.EMAIL);
- setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
+ if (settings?.email) {
+ setEmail(settings.email);
+ setOriginalEmail(settings.email);
+ setIsEmailValid(EMAIL_REGEX.test(settings.email));
}
- }, [settings?.EMAIL]);
+ }, [settings?.email]);
useEffect(() => {
if (pollingIntervalRef.current) {
@@ -137,7 +137,7 @@ function UserSettingsScreen() {
if (
prevVerificationStatusRef.current === false &&
- settings?.EMAIL_VERIFIED === true
+ settings?.email_verified === true
) {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
@@ -146,9 +146,9 @@ function UserSettingsScreen() {
}, 2000);
}
- prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
+ prevVerificationStatusRef.current = settings?.email_verified;
- if (settings?.EMAIL_VERIFIED === false) {
+ if (settings?.email_verified === false) {
pollingIntervalRef.current = window.setInterval(() => {
refetch();
}, 5000);
@@ -160,7 +160,7 @@ function UserSettingsScreen() {
pollingIntervalRef.current = null;
}
};
- }, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
+ }, [settings?.email_verified, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent) => {
const newEmail = e.target.value;
@@ -215,10 +215,10 @@ function UserSettingsScreen() {
isSaving={isSaving}
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
- emailVerified={settings?.EMAIL_VERIFIED}
+ emailVerified={settings?.email_verified}
isEmailValid={isEmailValid}
>
- {settings?.EMAIL_VERIFIED === false && }
+ {settings?.email_verified === false && }
)}
diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts
index 7c648247d6..1191e0ea68 100644
--- a/frontend/src/services/settings.ts
+++ b/frontend/src/services/settings.ts
@@ -3,35 +3,36 @@ import { Settings } from "#/types/settings";
export const LATEST_SETTINGS_VERSION = 5;
export const DEFAULT_SETTINGS: Settings = {
- LLM_MODEL: "openhands/claude-sonnet-4-20250514",
- LLM_BASE_URL: "",
- AGENT: "CodeActAgent",
- LANGUAGE: "en",
- LLM_API_KEY_SET: false,
- SEARCH_API_KEY_SET: false,
- CONFIRMATION_MODE: false,
- SECURITY_ANALYZER: "llm",
- REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
- PROVIDER_TOKENS_SET: {},
- ENABLE_DEFAULT_CONDENSER: true,
- CONDENSER_MAX_SIZE: 120,
- ENABLE_SOUND_NOTIFICATIONS: false,
- USER_CONSENTS_TO_ANALYTICS: false,
- ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
- ENABLE_SOLVABILITY_ANALYSIS: false,
- SEARCH_API_KEY: "",
- IS_NEW_USER: true,
- MAX_BUDGET_PER_TASK: null,
- EMAIL: "",
- EMAIL_VERIFIED: true, // Default to true to avoid restricting access unnecessarily
- MCP_CONFIG: {
+ llm_model: "openhands/claude-sonnet-4-20250514",
+ llm_base_url: "",
+ agent: "CodeActAgent",
+ language: "en",
+ llm_api_key: null,
+ llm_api_key_set: false,
+ search_api_key_set: false,
+ confirmation_mode: false,
+ security_analyzer: "llm",
+ remote_runtime_resource_factor: 1,
+ provider_tokens_set: {},
+ enable_default_condenser: true,
+ condenser_max_size: 120,
+ enable_sound_notifications: false,
+ user_consents_to_analytics: false,
+ enable_proactive_conversation_starters: false,
+ enable_solvability_analysis: false,
+ search_api_key: "",
+ is_new_user: true,
+ max_budget_per_task: null,
+ email: "",
+ email_verified: true, // Default to true to avoid restricting access unnecessarily
+ mcp_config: {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
},
- GIT_USER_NAME: "openhands",
- GIT_USER_EMAIL: "openhands@all-hands.dev",
- V1_ENABLED: false,
+ git_user_name: "openhands",
+ git_user_email: "openhands@all-hands.dev",
+ v1_enabled: false,
};
/**
diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts
index 2299288132..e5db0296bd 100644
--- a/frontend/src/types/settings.ts
+++ b/frontend/src/types/settings.ts
@@ -38,37 +38,31 @@ export type MCPConfig = {
};
export type Settings = {
- LLM_MODEL: string;
- LLM_BASE_URL: string;
- AGENT: string;
- LANGUAGE: string;
- LLM_API_KEY_SET: boolean;
- SEARCH_API_KEY_SET: boolean;
- CONFIRMATION_MODE: boolean;
- SECURITY_ANALYZER: string | null;
- REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
- PROVIDER_TOKENS_SET: Partial
>;
- ENABLE_DEFAULT_CONDENSER: boolean;
+ llm_model: string;
+ llm_base_url: string;
+ agent: string;
+ language: string;
+ llm_api_key: string | null;
+ llm_api_key_set: boolean;
+ search_api_key_set: boolean;
+ confirmation_mode: boolean;
+ security_analyzer: string | null;
+ remote_runtime_resource_factor: number | null;
+ provider_tokens_set: Partial>;
+ enable_default_condenser: boolean;
// Maximum number of events before the condenser runs
- CONDENSER_MAX_SIZE: number | null;
- ENABLE_SOUND_NOTIFICATIONS: boolean;
- ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean;
- ENABLE_SOLVABILITY_ANALYSIS: boolean;
- USER_CONSENTS_TO_ANALYTICS: boolean | null;
- SEARCH_API_KEY?: string;
- IS_NEW_USER?: boolean;
- MCP_CONFIG?: MCPConfig;
- MAX_BUDGET_PER_TASK: number | null;
- EMAIL?: string;
- EMAIL_VERIFIED?: boolean;
- GIT_USER_NAME?: string;
- GIT_USER_EMAIL?: string;
- V1_ENABLED?: boolean;
-};
-
-export type PostSettings = Settings & {
+ condenser_max_size: number | null;
+ enable_sound_notifications: boolean;
+ enable_proactive_conversation_starters: boolean;
+ enable_solvability_analysis: boolean;
user_consents_to_analytics: boolean | null;
- llm_api_key?: string | null;
search_api_key?: string;
+ is_new_user?: boolean;
mcp_config?: MCPConfig;
+ max_budget_per_task: number | null;
+ email?: string;
+ email_verified?: boolean;
+ git_user_name?: string;
+ git_user_email?: string;
+ v1_enabled?: boolean;
};
diff --git a/frontend/src/utils/__tests__/settings-utils.test.ts b/frontend/src/utils/__tests__/settings-utils.test.ts
index bebdaa0f88..bf2ae794f2 100644
--- a/frontend/src/utils/__tests__/settings-utils.test.ts
+++ b/frontend/src/utils/__tests__/settings-utils.test.ts
@@ -67,10 +67,10 @@ describe("extractSettings", () => {
// Verify that the model name case is preserved
const expectedModel = `${provider}/${model}`;
- expect(settings.LLM_MODEL).toBe(expectedModel);
+ expect(settings.llm_model).toBe(expectedModel);
// Only test that it's not lowercased if the original has uppercase letters
if (expectedModel !== expectedModel.toLowerCase()) {
- expect(settings.LLM_MODEL).not.toBe(expectedModel.toLowerCase());
+ expect(settings.llm_model).not.toBe(expectedModel.toLowerCase());
}
});
});
@@ -85,7 +85,7 @@ describe("extractSettings", () => {
const settings = extractSettings(formData);
// Custom model should take precedence and preserve case
- expect(settings.LLM_MODEL).toBe("Custom-Model-Name");
- expect(settings.LLM_MODEL).not.toBe("custom-model-name");
+ expect(settings.llm_model).toBe("Custom-Model-Name");
+ expect(settings.llm_model).not.toBe("custom-model-name");
});
});
diff --git a/frontend/src/utils/has-advanced-settings-set.ts b/frontend/src/utils/has-advanced-settings-set.ts
index 8cf3f10a39..b873425239 100644
--- a/frontend/src/utils/has-advanced-settings-set.ts
+++ b/frontend/src/utils/has-advanced-settings-set.ts
@@ -3,4 +3,4 @@ import { Settings } from "#/types/settings";
export const hasAdvancedSettingsSet = (settings: Partial): boolean =>
Object.keys(settings).length > 0 &&
- (!!settings.LLM_BASE_URL || settings.AGENT !== DEFAULT_SETTINGS.AGENT);
+ (!!settings.llm_base_url || settings.agent !== DEFAULT_SETTINGS.agent);
diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts
index ca56b25170..4259226d77 100644
--- a/frontend/src/utils/settings-utils.ts
+++ b/frontend/src/utils/settings-utils.ts
@@ -67,9 +67,7 @@ export const parseMaxBudgetPerTask = (value: string): number | null => {
: null;
};
-export const extractSettings = (
- formData: FormData,
-): Partial & { llm_api_key?: string | null } => {
+export const extractSettings = (formData: FormData): Partial => {
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
extractBasicFormData(formData);
@@ -82,14 +80,14 @@ export const extractSettings = (
} = extractAdvancedFormData(formData);
return {
- LLM_MODEL: CUSTOM_LLM_MODEL || LLM_MODEL,
- LLM_API_KEY_SET: !!LLM_API_KEY,
- AGENT,
- LANGUAGE,
- LLM_BASE_URL,
- CONFIRMATION_MODE,
- SECURITY_ANALYZER,
- ENABLE_DEFAULT_CONDENSER,
+ llm_model: CUSTOM_LLM_MODEL || LLM_MODEL,
+ llm_api_key_set: !!LLM_API_KEY,
+ agent: AGENT,
+ language: LANGUAGE,
+ llm_base_url: LLM_BASE_URL,
+ confirmation_mode: CONFIRMATION_MODE,
+ security_analyzer: SECURITY_ANALYZER,
+ enable_default_condenser: ENABLE_DEFAULT_CONDENSER,
llm_api_key: LLM_API_KEY,
};
};
diff --git a/openhands/app_server/app_conversation/app_conversation_service_base.py b/openhands/app_server/app_conversation/app_conversation_service_base.py
index f524167524..d5d34bd109 100644
--- a/openhands/app_server/app_conversation/app_conversation_service_base.py
+++ b/openhands/app_server/app_conversation/app_conversation_service_base.py
@@ -4,7 +4,11 @@ import tempfile
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
-from typing import AsyncGenerator
+from typing import TYPE_CHECKING, AsyncGenerator
+from uuid import UUID
+
+if TYPE_CHECKING:
+ import httpx
import base62
@@ -29,6 +33,14 @@ from openhands.sdk.context.agent_context import AgentContext
from openhands.sdk.context.condenser import LLMSummarizingCondenser
from openhands.sdk.context.skills import load_user_skills
from openhands.sdk.llm import LLM
+from openhands.sdk.security.analyzer import SecurityAnalyzerBase
+from openhands.sdk.security.confirmation_policy import (
+ AlwaysConfirm,
+ ConfirmationPolicyBase,
+ ConfirmRisky,
+ NeverConfirm,
+)
+from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
_logger = logging.getLogger(__name__)
@@ -379,3 +391,95 @@ class AppConversationServiceBase(AppConversationService, ABC):
condenser = LLMSummarizingCondenser(**condenser_kwargs)
return condenser
+
+ def _create_security_analyzer_from_string(
+ self, security_analyzer_str: str | None
+ ) -> SecurityAnalyzerBase | None:
+ """Convert security analyzer string from settings to SecurityAnalyzerBase instance.
+
+ Args:
+ security_analyzer_str: String value from settings. Valid values:
+ - "llm" -> LLMSecurityAnalyzer
+ - "none" or None -> None
+ - Other values -> None (unsupported analyzers are ignored)
+
+ Returns:
+ SecurityAnalyzerBase instance or None
+ """
+ if not security_analyzer_str or security_analyzer_str.lower() == 'none':
+ return None
+
+ if security_analyzer_str.lower() == 'llm':
+ return LLMSecurityAnalyzer()
+
+ # For unknown values, log a warning and return None
+ _logger.warning(
+ f'Unknown security analyzer value: {security_analyzer_str}. '
+ 'Supported values: "llm", "none". Defaulting to None.'
+ )
+ return None
+
+ def _select_confirmation_policy(
+ self, confirmation_mode: bool, security_analyzer: str | None
+ ) -> ConfirmationPolicyBase:
+ """Choose confirmation policy using only mode flag and analyzer string."""
+ if not confirmation_mode:
+ return NeverConfirm()
+
+ analyzer_kind = (security_analyzer or '').lower()
+ if analyzer_kind == 'llm':
+ return ConfirmRisky()
+
+ return AlwaysConfirm()
+
+ async def _set_security_analyzer_from_settings(
+ self,
+ agent_server_url: str,
+ session_api_key: str | None,
+ conversation_id: UUID,
+ security_analyzer_str: str | None,
+ httpx_client: 'httpx.AsyncClient',
+ ) -> None:
+ """Set security analyzer on conversation using only the analyzer string.
+
+ Args:
+ agent_server_url: URL of the agent server
+ session_api_key: Session API key for authentication
+ conversation_id: ID of the conversation to update
+ security_analyzer_str: String value from settings
+ httpx_client: HTTP client for making API requests
+ """
+
+ if session_api_key is None:
+ return
+
+ security_analyzer = self._create_security_analyzer_from_string(
+ security_analyzer_str
+ )
+
+ # Only make API call if we have a security analyzer to set
+ # (None is the default, so we can skip the call if it's None)
+ if security_analyzer is None:
+ return
+
+ try:
+ # Prepare the request payload
+ payload = {'security_analyzer': security_analyzer.model_dump()}
+
+ # Call agent server API to set security analyzer
+ response = await httpx_client.post(
+ f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer',
+ json=payload,
+ headers={'X-Session-API-Key': session_api_key},
+ timeout=30.0,
+ )
+ response.raise_for_status()
+ _logger.info(
+ f'Successfully set security analyzer for conversation {conversation_id}'
+ )
+ except Exception as e:
+ # Log error but don't fail conversation creation
+ _logger.warning(
+ f'Failed to set security analyzer for conversation {conversation_id}: {e}',
+ exc_info=True,
+ )
diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py
index 0762ceb5f7..6024152218 100644
--- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py
+++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py
@@ -13,7 +13,6 @@ from pydantic import Field, SecretStr, TypeAdapter
from openhands.agent_server.models import (
ConversationInfo,
- NeverConfirm,
SendMessageRequest,
StartConversationRequest,
)
@@ -73,7 +72,6 @@ from openhands.integrations.provider import ProviderType
from openhands.sdk import Agent, AgentContext, LocalWorkspace
from openhands.sdk.llm import LLM
from openhands.sdk.secret import LookupSecret, StaticSecret
-from openhands.sdk.security.confirmation_policy import AlwaysConfirm
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
from openhands.tools.preset.default import (
@@ -320,6 +318,16 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
)
)
+ # Set security analyzer from settings
+ user = await self.user_context.get_user_info()
+ await self._set_security_analyzer_from_settings(
+ agent_server_url,
+ sandbox.session_api_key,
+ info.id,
+ user.security_analyzer,
+ self.httpx_client,
+ )
+
# Update the start task
task.status = AppConversationStartTaskStatus.READY
task.app_conversation_id = info.id
@@ -884,8 +892,8 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
conversation_id=conversation_id,
agent=agent,
workspace=workspace,
- confirmation_policy=(
- AlwaysConfirm() if user.confirmation_mode else NeverConfirm()
+ confirmation_policy=self._select_confirmation_policy(
+ bool(user.confirmation_mode), user.security_analyzer
),
initial_message=initial_message,
secrets=secrets,
diff --git a/tests/unit/app_server/test_app_conversation_service_base.py b/tests/unit/app_server/test_app_conversation_service_base.py
index a179a11c24..356c454fcf 100644
--- a/tests/unit/app_server/test_app_conversation_service_base.py
+++ b/tests/unit/app_server/test_app_conversation_service_base.py
@@ -1,11 +1,13 @@
-"""Unit tests for git functionality in AppConversationServiceBase.
+"""Unit tests for git and security functionality in AppConversationServiceBase.
This module tests the git-related functionality, specifically the clone_or_init_git_repo method
and the recent bug fixes for git checkout operations.
"""
import subprocess
+from types import MethodType
from unittest.mock import AsyncMock, MagicMock, Mock, patch
+from uuid import uuid4
import pytest
@@ -434,13 +436,298 @@ def test_create_condenser_plan_agent_with_custom_max_size(mock_condenser_class):
mock_llm.model_copy.assert_called_once()
+# =============================================================================
+# Tests for security analyzer helpers
+# =============================================================================
+
+
+@pytest.mark.parametrize('value', [None, '', 'none', 'NoNe'])
+def test_create_security_analyzer_returns_none_for_empty_values(value):
+ """_create_security_analyzer_from_string returns None for empty/none values."""
+ # Arrange
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',)
+ )
+
+ # Act
+ result = service._create_security_analyzer_from_string(value)
+
+ # Assert
+ assert result is None
+
+
+def test_create_security_analyzer_returns_llm_analyzer():
+ """_create_security_analyzer_from_string returns LLMSecurityAnalyzer for llm string."""
+ # Arrange
+ security_analyzer_str = 'llm'
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',)
+ )
+
+ # Act
+ result = service._create_security_analyzer_from_string(security_analyzer_str)
+
+ # Assert
+ from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
+
+ assert isinstance(result, LLMSecurityAnalyzer)
+
+
+def test_create_security_analyzer_logs_warning_for_unknown_value():
+ """_create_security_analyzer_from_string logs warning and returns None for unknown."""
+ # Arrange
+ unknown_value = 'custom'
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(), bind_methods=('_create_security_analyzer_from_string',)
+ )
+
+ # Act
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base._logger'
+ ) as mock_logger:
+ result = service._create_security_analyzer_from_string(unknown_value)
+
+ # Assert
+ assert result is None
+ mock_logger.warning.assert_called_once()
+
+
+def test_select_confirmation_policy_when_disabled_returns_never_confirm():
+ """_select_confirmation_policy returns NeverConfirm when confirmation_mode is False."""
+ # Arrange
+ confirmation_mode = False
+ security_analyzer = 'llm'
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(), bind_methods=('_select_confirmation_policy',)
+ )
+
+ # Act
+ policy = service._select_confirmation_policy(confirmation_mode, security_analyzer)
+
+ # Assert
+ from openhands.sdk.security.confirmation_policy import NeverConfirm
+
+ assert isinstance(policy, NeverConfirm)
+
+
+def test_select_confirmation_policy_llm_returns_confirm_risky():
+ """_select_confirmation_policy uses ConfirmRisky when analyzer is llm."""
+ # Arrange
+ confirmation_mode = True
+ security_analyzer = 'llm'
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(), bind_methods=('_select_confirmation_policy',)
+ )
+
+ # Act
+ policy = service._select_confirmation_policy(confirmation_mode, security_analyzer)
+
+ # Assert
+ from openhands.sdk.security.confirmation_policy import ConfirmRisky
+
+ assert isinstance(policy, ConfirmRisky)
+
+
+@pytest.mark.parametrize('security_analyzer', [None, '', 'none', 'custom'])
+def test_select_confirmation_policy_non_llm_returns_always_confirm(
+ security_analyzer,
+):
+ """_select_confirmation_policy falls back to AlwaysConfirm for non-llm values."""
+ # Arrange
+ confirmation_mode = True
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(), bind_methods=('_select_confirmation_policy',)
+ )
+
+ # Act
+ policy = service._select_confirmation_policy(confirmation_mode, security_analyzer)
+
+ # Assert
+ from openhands.sdk.security.confirmation_policy import AlwaysConfirm
+
+ assert isinstance(policy, AlwaysConfirm)
+
+
+@pytest.mark.asyncio
+async def test_set_security_analyzer_skips_when_no_session_key():
+ """_set_security_analyzer_from_settings exits early without session_api_key."""
+ # Arrange
+ agent_server_url = 'https://agent.example.com'
+ conversation_id = uuid4()
+ httpx_client = AsyncMock()
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(),
+ bind_methods=(
+ '_create_security_analyzer_from_string',
+ '_set_security_analyzer_from_settings',
+ ),
+ )
+
+ with patch.object(service, '_create_security_analyzer_from_string') as mock_create:
+ # Act
+ await service._set_security_analyzer_from_settings(
+ agent_server_url=agent_server_url,
+ session_api_key=None,
+ conversation_id=conversation_id,
+ security_analyzer_str='llm',
+ httpx_client=httpx_client,
+ )
+
+ # Assert
+ mock_create.assert_not_called()
+ httpx_client.post.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_set_security_analyzer_skips_when_analyzer_none():
+ """_set_security_analyzer_from_settings skips API call when analyzer resolves to None."""
+ # Arrange
+ agent_server_url = 'https://agent.example.com'
+ session_api_key = 'session-key'
+ conversation_id = uuid4()
+ httpx_client = AsyncMock()
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(),
+ bind_methods=(
+ '_create_security_analyzer_from_string',
+ '_set_security_analyzer_from_settings',
+ ),
+ )
+
+ with patch.object(
+ service, '_create_security_analyzer_from_string', return_value=None
+ ) as mock_create:
+ # Act
+ await service._set_security_analyzer_from_settings(
+ agent_server_url=agent_server_url,
+ session_api_key=session_api_key,
+ conversation_id=conversation_id,
+ security_analyzer_str='none',
+ httpx_client=httpx_client,
+ )
+
+ # Assert
+ mock_create.assert_called_once_with('none')
+ httpx_client.post.assert_not_called()
+
+
+class DummyAnalyzer:
+ """Simple analyzer stub for testing model_dump contract."""
+
+ def __init__(self, payload: dict):
+ self._payload = payload
+
+ def model_dump(self) -> dict:
+ return self._payload
+
+
+@pytest.mark.asyncio
+async def test_set_security_analyzer_successfully_calls_agent_server():
+ """_set_security_analyzer_from_settings posts analyzer payload when available."""
+ # Arrange
+ agent_server_url = 'https://agent.example.com'
+ session_api_key = 'session-key'
+ conversation_id = uuid4()
+ analyzer_payload = {'type': 'llm'}
+ httpx_client = AsyncMock()
+ http_response = MagicMock()
+ http_response.raise_for_status = MagicMock()
+ httpx_client.post.return_value = http_response
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(),
+ bind_methods=(
+ '_create_security_analyzer_from_string',
+ '_set_security_analyzer_from_settings',
+ ),
+ )
+
+ analyzer = DummyAnalyzer(analyzer_payload)
+
+ with (
+ patch.object(
+ service,
+ '_create_security_analyzer_from_string',
+ return_value=analyzer,
+ ) as mock_create,
+ patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base._logger'
+ ) as mock_logger,
+ ):
+ # Act
+ await service._set_security_analyzer_from_settings(
+ agent_server_url=agent_server_url,
+ session_api_key=session_api_key,
+ conversation_id=conversation_id,
+ security_analyzer_str='llm',
+ httpx_client=httpx_client,
+ )
+
+ # Assert
+ mock_create.assert_called_once_with('llm')
+ httpx_client.post.assert_awaited_once_with(
+ f'{agent_server_url}/api/conversations/{conversation_id}/security_analyzer',
+ json={'security_analyzer': analyzer_payload},
+ headers={'X-Session-API-Key': session_api_key},
+ timeout=30.0,
+ )
+ http_response.raise_for_status.assert_called_once()
+ mock_logger.info.assert_called()
+
+
+@pytest.mark.asyncio
+async def test_set_security_analyzer_logs_warning_on_failure():
+ """_set_security_analyzer_from_settings warns but does not raise on errors."""
+ # Arrange
+ agent_server_url = 'https://agent.example.com'
+ session_api_key = 'session-key'
+ conversation_id = uuid4()
+ analyzer_payload = {'type': 'llm'}
+ httpx_client = AsyncMock()
+ httpx_client.post.side_effect = RuntimeError('network down')
+ service, _ = _create_service_with_mock_user_context(
+ MockUserInfo(),
+ bind_methods=(
+ '_create_security_analyzer_from_string',
+ '_set_security_analyzer_from_settings',
+ ),
+ )
+
+ analyzer = DummyAnalyzer(analyzer_payload)
+
+ with (
+ patch.object(
+ service,
+ '_create_security_analyzer_from_string',
+ return_value=analyzer,
+ ) as mock_create,
+ patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base._logger'
+ ) as mock_logger,
+ ):
+ # Act
+ await service._set_security_analyzer_from_settings(
+ agent_server_url=agent_server_url,
+ session_api_key=session_api_key,
+ conversation_id=conversation_id,
+ security_analyzer_str='llm',
+ httpx_client=httpx_client,
+ )
+
+ # Assert
+ mock_create.assert_called_once_with('llm')
+ httpx_client.post.assert_awaited_once()
+ mock_logger.warning.assert_called()
+
+
# =============================================================================
# Tests for _configure_git_user_settings
# =============================================================================
-def _create_service_with_mock_user_context(user_info: MockUserInfo) -> tuple:
- """Create a mock service with the actual _configure_git_user_settings method.
+def _create_service_with_mock_user_context(
+ user_info: MockUserInfo, bind_methods: tuple[str, ...] | None = None
+) -> tuple:
+ """Create a mock service with selected real methods bound for testing.
Uses MagicMock for the service but binds the real method for testing.
@@ -452,13 +739,16 @@ def _create_service_with_mock_user_context(user_info: MockUserInfo) -> tuple:
# Create a simple mock service and set required attribute
service = MagicMock()
service.user_context = mock_user_context
+ methods_to_bind = ['_configure_git_user_settings']
+ if bind_methods:
+ methods_to_bind.extend(bind_methods)
+ # Remove potential duplicates while keeping order
+ methods_to_bind = list(dict.fromkeys(methods_to_bind))
- # Bind the actual method from the real class to test real implementation
- service._configure_git_user_settings = (
- lambda workspace: AppConversationServiceBase._configure_git_user_settings(
- service, workspace
- )
- )
+ # Bind actual methods from the real class to test implementations directly
+ for method_name in methods_to_bind:
+ real_method = getattr(AppConversationServiceBase, method_name)
+ setattr(service, method_name, MethodType(real_method, service))
return service, mock_user_context
diff --git a/tests/unit/experiments/test_experiment_manager.py b/tests/unit/experiments/test_experiment_manager.py
index 1dd32cfa5e..f16ea4e036 100644
--- a/tests/unit/experiments/test_experiment_manager.py
+++ b/tests/unit/experiments/test_experiment_manager.py
@@ -153,6 +153,7 @@ class TestExperimentManagerIntegration:
llm_api_key=None,
confirmation_mode=False,
condenser_max_size=None,
+ security_analyzer=None,
)
async def get_secrets(self):