diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx
index d9b9bf2d2d..dec57f385f 100644
--- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx
+++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx
@@ -159,6 +159,9 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
}
break;
}
+ case "ThinkObservation":
+ observationKey = "OBSERVATION_MESSAGE$THINK";
+ break;
default:
// For unknown observations, use the type name
return observationType.replace("Observation", "").toUpperCase();
diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts
index bf443ea71c..7fb1c2ce1c 100644
--- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts
+++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts
@@ -190,7 +190,13 @@ const getThinkObservationContent = (
event: ObservationEvent
,
): string => {
const { observation } = event;
- return observation.content || "";
+
+ const textContent = observation.content
+ .filter((c) => c.type === "text")
+ .map((c) => c.text)
+ .join("\n");
+
+ return textContent || "";
};
const getFinishObservationContent = (
diff --git a/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts b/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts
index 17824a51c8..8e2a0cb253 100644
--- a/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts
+++ b/frontend/src/components/v1/chat/event-content-helpers/parse-message-from-event.ts
@@ -5,7 +5,7 @@ export const parseMessageFromEvent = (event: MessageEvent): string => {
const message = event.llm_message;
// Safety check: ensure llm_message exists and has content
- if (!message || !message.content) {
+ if (!message?.content) {
return "";
}
diff --git a/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts
index a5fdc62252..1171c21c92 100644
--- a/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts
+++ b/frontend/src/components/v1/chat/event-content-helpers/should-render-event.ts
@@ -18,6 +18,10 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
// For V1, action is an object with kind property
const actionType = event.action.kind;
+ if (!actionType) {
+ return false;
+ }
+
// Hide user commands from the chat interface
if (actionType === "ExecuteBashAction" && event.source === "user") {
return false;
diff --git a/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx
index aa0bbc09b4..221d758dd6 100644
--- a/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx
+++ b/frontend/src/components/v1/chat/event-message-components/observation-pair-event-message.tsx
@@ -34,7 +34,12 @@ export function ObservationPairEventMessage({
.map((t) => t.text)
.join("\n");
- if (thoughtContent && event.action.kind !== "ThinkAction") {
+ // Defensive check: ensure action exists and has kind property
+ if (
+ thoughtContent &&
+ event.action?.kind &&
+ event.action.kind !== "ThinkAction"
+ ) {
return (
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..0cf43b49ce 100644
--- a/frontend/src/contexts/conversation-websocket-context.tsx
+++ b/frontend/src/contexts/conversation-websocket-context.tsx
@@ -7,6 +7,7 @@ import React, {
useMemo,
useRef,
} from "react";
+import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
import { useEventStore } from "#/stores/use-event-store";
@@ -44,6 +45,7 @@ import { isBudgetOrCreditError } from "#/utils/error-handler";
import { useTracking } from "#/hooks/use-tracking";
import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation-file";
import useMetricsStore from "#/stores/metrics-store";
+import { I18nKey } from "#/i18n/declaration";
// eslint-disable-next-line @typescript-eslint/naming-convention
export type V1_WebSocketConnectionState =
@@ -123,6 +125,8 @@ export function ConversationWebSocketProvider({
conversationId: string;
} | null>(null);
+ const { t } = useTranslation();
+
// Helper function to update metrics from stats event
const updateMetricsFromStats = useCallback(
(event: ConversationStateUpdateEventStats) => {
@@ -578,9 +582,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
@@ -599,7 +607,7 @@ export function ConversationWebSocketProvider({
// This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation)
if (event.code !== 1000 && hasConnectedRefMain.current) {
setErrorMessage(
- `Connection lost: ${event.reason || "Unexpected disconnect"}`,
+ `${t(I18nKey.STATUS$CONNECTION_LOST)}: ${event.reason || t(I18nKey.STATUS$DISCONNECTED_REFRESH_PAGE)}`,
);
}
},
@@ -618,6 +626,7 @@ export function ConversationWebSocketProvider({
removeErrorMessage,
sessionApiKey,
conversationId,
+ conversationUrl,
]);
// Separate WebSocket options for planning agent connection
@@ -642,10 +651,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);
@@ -665,7 +679,7 @@ export function ConversationWebSocketProvider({
// This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation)
if (event.code !== 1000 && hasConnectedRefPlanning.current) {
setErrorMessage(
- `Connection lost: ${event.reason || "Unexpected disconnect"}`,
+ `${t(I18nKey.STATUS$CONNECTION_LOST)}: ${event.reason || t(I18nKey.STATUS$DISCONNECTED_REFRESH_PAGE)}`,
);
}
},
diff --git a/frontend/src/hooks/mutation/use-accept-tos.ts b/frontend/src/hooks/mutation/use-accept-tos.ts
new file mode 100644
index 0000000000..a159b1458c
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-accept-tos.ts
@@ -0,0 +1,54 @@
+import { useMutation } from "@tanstack/react-query";
+import { usePostHog } from "posthog-js/react";
+import { useNavigate } from "react-router";
+import { openHands } from "#/api/open-hands-axios";
+import { handleCaptureConsent } from "#/utils/handle-capture-consent";
+import { useTracking } from "#/hooks/use-tracking";
+
+interface AcceptTosVariables {
+ redirectUrl: string;
+}
+
+interface AcceptTosResponse {
+ redirect_url?: string;
+}
+
+export const useAcceptTos = () => {
+ const posthog = usePostHog();
+ const navigate = useNavigate();
+ const { trackUserSignupCompleted } = useTracking();
+
+ return useMutation({
+ mutationFn: async ({ redirectUrl }: AcceptTosVariables) => {
+ // Set consent for analytics
+ handleCaptureConsent(posthog, true);
+
+ // Call the API to record TOS acceptance in the database
+ return openHands.post
("/api/accept_tos", {
+ redirect_url: redirectUrl,
+ });
+ },
+ onSuccess: (response, { redirectUrl }) => {
+ // Track user signup completion
+ trackUserSignupCompleted();
+
+ // Get the redirect URL from the response
+ const finalRedirectUrl = response.data.redirect_url || redirectUrl;
+
+ // Check if the redirect URL is an external URL (starts with http or https)
+ if (
+ finalRedirectUrl.startsWith("http://") ||
+ finalRedirectUrl.startsWith("https://")
+ ) {
+ // For external URLs, redirect using window.location
+ window.location.href = finalRedirectUrl;
+ } else {
+ // For internal routes, use navigate
+ navigate(finalRedirectUrl);
+ }
+ },
+ onError: () => {
+ window.location.href = "/";
+ },
+ });
+};
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-billing-session.ts b/frontend/src/hooks/mutation/use-create-billing-session.ts
new file mode 100644
index 0000000000..f8f0716cb2
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-create-billing-session.ts
@@ -0,0 +1,19 @@
+import { useMutation } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { I18nKey } from "#/i18n/declaration";
+import BillingService from "#/api/billing-service/billing-service.api";
+import { displayErrorToast } from "#/utils/custom-toast-handlers";
+
+export const useCreateBillingSession = () => {
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: BillingService.createBillingSessionResponse,
+ onSuccess: (data) => {
+ window.location.href = data;
+ },
+ onError: () => {
+ displayErrorToast(t(I18nKey.BILLING$ERROR_WHILE_CREATING_SESSION));
+ },
+ });
+};
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-refresh-llm-api-key.ts b/frontend/src/hooks/mutation/use-refresh-llm-api-key.ts
new file mode 100644
index 0000000000..11a112e182
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-refresh-llm-api-key.ts
@@ -0,0 +1,23 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { openHands } from "#/api/open-hands-axios";
+import {
+ LLM_API_KEY_QUERY_KEY,
+ LlmApiKeyResponse,
+} from "#/hooks/query/use-llm-api-key";
+
+export function useRefreshLlmApiKey() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ const { data } = await openHands.post(
+ "/api/keys/llm/byor/refresh",
+ );
+ return data;
+ },
+ onSuccess: () => {
+ // Invalidate the LLM API key query to trigger a refetch
+ queryClient.invalidateQueries({ queryKey: [LLM_API_KEY_QUERY_KEY] });
+ },
+ });
+}
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-conversation-microagents.ts b/frontend/src/hooks/query/use-conversation-skills.ts
similarity index 62%
rename from frontend/src/hooks/query/use-conversation-microagents.ts
rename to frontend/src/hooks/query/use-conversation-skills.ts
index d51b2b311d..43cf23bd37 100644
--- a/frontend/src/hooks/query/use-conversation-microagents.ts
+++ b/frontend/src/hooks/query/use-conversation-skills.ts
@@ -1,19 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
+import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useConversationId } from "../use-conversation-id";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
+import { useSettings } from "./use-settings";
-export const useConversationMicroagents = () => {
+export const useConversationSkills = () => {
const { conversationId } = useConversationId();
const { curAgentState } = useAgentState();
+ const { data: settings } = useSettings();
return useQuery({
- queryKey: ["conversation", conversationId, "microagents"],
+ queryKey: ["conversation", conversationId, "skills", settings?.v1_enabled],
queryFn: async () => {
if (!conversationId) {
throw new Error("No conversation ID provided");
}
+
+ // Check if V1 is enabled and use the appropriate API
+ if (settings?.v1_enabled) {
+ const data = await V1ConversationService.getSkills(conversationId);
+ return data.skills;
+ }
+
const data = await ConversationService.getMicroagents(conversationId);
return data.microagents;
},
diff --git a/frontend/src/hooks/query/use-llm-api-key.ts b/frontend/src/hooks/query/use-llm-api-key.ts
index 5dcea9f714..58dee11411 100644
--- a/frontend/src/hooks/query/use-llm-api-key.ts
+++ b/frontend/src/hooks/query/use-llm-api-key.ts
@@ -1,4 +1,4 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useQuery } from "@tanstack/react-query";
import { openHands } from "#/api/open-hands-axios";
import { useConfig } from "./use-config";
@@ -23,20 +23,3 @@ export function useLlmApiKey() {
gcTime: 1000 * 60 * 15, // 15 minutes
});
}
-
-export function useRefreshLlmApiKey() {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: async () => {
- const { data } = await openHands.post(
- "/api/keys/llm/byor/refresh",
- );
- return data;
- },
- onSuccess: () => {
- // Invalidate the LLM API key query to trigger a refetch
- queryClient.invalidateQueries({ queryKey: [LLM_API_KEY_QUERY_KEY] });
- },
- });
-}
diff --git a/frontend/src/hooks/query/use-microagent-management-conversations.ts b/frontend/src/hooks/query/use-microagent-management-conversations.ts
index 4c83ca2f75..947cbcf509 100644
--- a/frontend/src/hooks/query/use-microagent-management-conversations.ts
+++ b/frontend/src/hooks/query/use-microagent-management-conversations.ts
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
-import MicroagentManagementService from "#/ui/microagent-management-service/microagent-management-service.api";
+import MicroagentManagementService from "#/api/microagent-management-service/microagent-management-service.api";
export const useMicroagentManagementConversations = (
selectedRepository: string,
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/query/use-unified-get-git-changes.ts b/frontend/src/hooks/query/use-unified-get-git-changes.ts
index ae5600469a..6b0856031c 100644
--- a/frontend/src/hooks/query/use-unified-get-git-changes.ts
+++ b/frontend/src/hooks/query/use-unified-get-git-changes.ts
@@ -103,5 +103,6 @@ export const useUnifiedGetGitChanges = () => {
isSuccess: result.isSuccess,
isError: result.isError,
error: result.error,
+ refetch: result.refetch,
};
};
diff --git a/frontend/src/hooks/use-conversation-name-context-menu.ts b/frontend/src/hooks/use-conversation-name-context-menu.ts
index 0e2c3e837e..6072d5331e 100644
--- a/frontend/src/hooks/use-conversation-name-context-menu.ts
+++ b/frontend/src/hooks/use-conversation-name-context-menu.ts
@@ -41,8 +41,7 @@ export function useConversationNameContextMenu({
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
- const [microagentsModalVisible, setMicroagentsModalVisible] =
- React.useState(false);
+ const [skillsModalVisible, setSkillsModalVisible] = React.useState(false);
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
const [confirmStopModalVisible, setConfirmStopModalVisible] =
@@ -161,11 +160,9 @@ export function useConversationNameContextMenu({
onContextMenuToggle?.(false);
};
- const handleShowMicroagents = (
- event: React.MouseEvent,
- ) => {
+ const handleShowSkills = (event: React.MouseEvent) => {
event.stopPropagation();
- setMicroagentsModalVisible(true);
+ setSkillsModalVisible(true);
onContextMenuToggle?.(false);
};
@@ -178,7 +175,7 @@ export function useConversationNameContextMenu({
handleDownloadViaVSCode,
handleDisplayCost,
handleShowAgentTools,
- handleShowMicroagents,
+ handleShowSkills,
handleConfirmDelete,
handleConfirmStop,
@@ -187,8 +184,8 @@ export function useConversationNameContextMenu({
setMetricsModalVisible,
systemModalVisible,
setSystemModalVisible,
- microagentsModalVisible,
- setMicroagentsModalVisible,
+ skillsModalVisible,
+ setSkillsModalVisible,
confirmDeleteModalVisible,
setConfirmDeleteModalVisible,
confirmStopModalVisible,
@@ -204,6 +201,6 @@ export function useConversationNameContextMenu({
shouldShowExport: Boolean(conversationId && showOptions),
shouldShowDisplayCost: showOptions,
shouldShowAgentTools: Boolean(showOptions && systemMessage),
- shouldShowMicroagents: Boolean(showOptions && conversationId),
+ shouldShowSkills: Boolean(showOptions && conversationId),
};
}
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/i18n/declaration.ts b/frontend/src/i18n/declaration.ts
index 420709ef9b..2f99b1aef6 100644
--- a/frontend/src/i18n/declaration.ts
+++ b/frontend/src/i18n/declaration.ts
@@ -532,6 +532,8 @@ export enum I18nKey {
SUGGESTIONS$ADD_DOCS = "SUGGESTIONS$ADD_DOCS",
SUGGESTIONS$ADD_DOCKERFILE = "SUGGESTIONS$ADD_DOCKERFILE",
STATUS$CONNECTED = "STATUS$CONNECTED",
+ STATUS$CONNECTION_LOST = "STATUS$CONNECTION_LOST",
+ STATUS$DISCONNECTED_REFRESH_PAGE = "STATUS$DISCONNECTED_REFRESH_PAGE",
BROWSER$NO_PAGE_LOADED = "BROWSER$NO_PAGE_LOADED",
USER$AVATAR_PLACEHOLDER = "USER$AVATAR_PLACEHOLDER",
ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT",
@@ -638,17 +640,16 @@ export enum I18nKey {
TOS$CONTINUE = "TOS$CONTINUE",
TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING",
TIPS$CUSTOMIZE_MICROAGENT = "TIPS$CUSTOMIZE_MICROAGENT",
- CONVERSATION$SHOW_MICROAGENTS = "CONVERSATION$SHOW_MICROAGENTS",
- CONVERSATION$NO_MICROAGENTS = "CONVERSATION$NO_MICROAGENTS",
+ CONVERSATION$NO_SKILLS = "CONVERSATION$NO_SKILLS",
CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS",
MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE",
- MICROAGENTS_MODAL$WARNING = "MICROAGENTS_MODAL$WARNING",
- MICROAGENTS_MODAL$TRIGGERS = "MICROAGENTS_MODAL$TRIGGERS",
+ SKILLS_MODAL$WARNING = "SKILLS_MODAL$WARNING",
+ COMMON$TRIGGERS = "COMMON$TRIGGERS",
MICROAGENTS_MODAL$INPUTS = "MICROAGENTS_MODAL$INPUTS",
MICROAGENTS_MODAL$TOOLS = "MICROAGENTS_MODAL$TOOLS",
- MICROAGENTS_MODAL$CONTENT = "MICROAGENTS_MODAL$CONTENT",
- MICROAGENTS_MODAL$NO_CONTENT = "MICROAGENTS_MODAL$NO_CONTENT",
- MICROAGENTS_MODAL$FETCH_ERROR = "MICROAGENTS_MODAL$FETCH_ERROR",
+ COMMON$CONTENT = "COMMON$CONTENT",
+ SKILLS_MODAL$NO_CONTENT = "SKILLS_MODAL$NO_CONTENT",
+ COMMON$FETCH_ERROR = "COMMON$FETCH_ERROR",
TIPS$SETUP_SCRIPT = "TIPS$SETUP_SCRIPT",
TIPS$VSCODE_INSTANCE = "TIPS$VSCODE_INSTANCE",
TIPS$SAVE_WORK = "TIPS$SAVE_WORK",
@@ -955,4 +956,6 @@ export enum I18nKey {
COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION",
PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED",
OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY",
+ CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS",
+ SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE",
}
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index 2278092e8e..fc4ca89dbc 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -8511,6 +8511,38 @@
"tr": "Bağlandı",
"uk": "Підключено"
},
+ "STATUS$CONNECTION_LOST": {
+ "en": "Connection lost",
+ "ja": "接続が切断されました",
+ "zh-CN": "连接已断开",
+ "zh-TW": "連接已斷開",
+ "ko-KR": "연결이 끊어졌습니다",
+ "de": "Verbindung verloren",
+ "no": "Tilkobling mistet",
+ "it": "Connessione persa",
+ "pt": "Conexão perdida",
+ "es": "Conexión perdida",
+ "ar": "فُقد الاتصال",
+ "fr": "Connexion perdue",
+ "tr": "Bağlantı kesildi",
+ "uk": "Втрачено з'єднання"
+ },
+ "STATUS$DISCONNECTED_REFRESH_PAGE": {
+ "en": "Disconnected. Please refresh the page",
+ "ja": "切断されました。ページを更新してください",
+ "zh-CN": "已断开连接。请刷新页面",
+ "zh-TW": "已斷開連接。請重新整理頁面",
+ "ko-KR": "연결이 끊어졌습니다. 페이지를 새로고침하세요",
+ "de": "Getrennt. Bitte aktualisieren Sie die Seite",
+ "no": "Koblet fra. Vennligst oppdater siden",
+ "it": "Disconnesso. Si prega di aggiornare la pagina",
+ "pt": "Desconectado. Por favor, atualize a página",
+ "es": "Desconectado. Por favor, actualice la página",
+ "ar": "تم قطع الاتصال. يرجى تحديث الصفحة",
+ "fr": "Déconnecté. Veuillez actualiser la page",
+ "tr": "Bağlantı kesildi. Lütfen sayfayı yenileyin",
+ "uk": "Відключено. Будь ласка, оновіть сторінку"
+ },
"BROWSER$NO_PAGE_LOADED": {
"en": "No page loaded",
"ja": "ブラウザは空です",
@@ -10207,37 +10239,21 @@
"tr": "Kullanılabilir bir mikro ajan kullanarak OpenHands'i deponuz için özelleştirebilirsiniz. OpenHands'ten deponun açıklamasını, kodun nasıl çalıştırılacağı dahil, .openhands/microagents/repo.md dosyasına koymasını isteyin.",
"uk": "Ви можете налаштувати OpenHands для свого репозиторію за допомогою доступного мікроагента. Попросіть OpenHands розмістити опис репозиторію, включаючи інформацію про те, як запустити код, у файлі .openhands/microagents/repo.md."
},
- "CONVERSATION$SHOW_MICROAGENTS": {
- "en": "Show Available Microagents",
- "ja": "利用可能なマイクロエージェントを表示",
- "zh-CN": "显示可用微代理",
- "zh-TW": "顯示可用微代理",
- "ko-KR": "사용 가능한 마이크로에이전트 표시",
- "no": "Vis tilgjengelige mikroagenter",
- "ar": "عرض الوكلاء المصغرين المتاحة",
- "de": "Verfügbare Mikroagenten anzeigen",
- "fr": "Afficher les micro-agents disponibles",
- "it": "Mostra microagenti disponibili",
- "pt": "Mostrar microagentes disponíveis",
- "es": "Mostrar microagentes disponibles",
- "tr": "Kullanılabilir mikro ajanları göster",
- "uk": "Показати доступних мікроагентів"
- },
- "CONVERSATION$NO_MICROAGENTS": {
- "en": "No available microagents found for this conversation.",
- "ja": "この会話用の利用可能なマイクロエージェントが見つかりませんでした。",
- "zh-CN": "未找到此对话的可用微代理。",
- "zh-TW": "未找到此對話的可用微代理。",
- "ko-KR": "이 대화에 대한 사용 가능한 마이크로에이전트를 찾을 수 없습니다.",
- "no": "Ingen tilgjengelige mikroagenter funnet for denne samtalen.",
- "ar": "لم يتم العثور على وكلاء مصغرين متاحة لهذه المحادثة.",
- "de": "Keine verfügbaren Mikroagenten für dieses Gespräch gefunden.",
- "fr": "Aucun micro-agent disponible trouvé pour cette conversation.",
- "it": "Nessun microagente disponibile trovato per questa conversazione.",
- "pt": "Nenhum microagente disponível encontrado para esta conversa.",
- "es": "No se encontraron microagentes disponibles para esta conversación.",
- "tr": "Bu konuşma için kullanılabilir mikro ajan bulunamadı.",
- "uk": "Для цієї розмови не знайдено доступних мікроагентів."
+ "CONVERSATION$NO_SKILLS": {
+ "en": "No available skills found for this conversation.",
+ "ja": "この会話には利用可能なスキルが見つかりません。",
+ "zh-CN": "本会话未找到可用技能。",
+ "zh-TW": "此對話中未找到可用技能。",
+ "ko-KR": "이 대화에서 사용 가능한 스킬을 찾을 수 없습니다.",
+ "no": "Ingen tilgjengelige ferdigheter ble funnet for denne samtalen.",
+ "ar": "لم يتم العثور على مهارات متاحة لهذه المحادثة.",
+ "de": "Für diese Unterhaltung wurden keine verfügbaren Skills gefunden.",
+ "fr": "Aucune compétence disponible trouvée pour cette conversation.",
+ "it": "Nessuna abilità disponibile trovata per questa conversazione.",
+ "pt": "Nenhuma habilidade disponível encontrada para esta conversa.",
+ "es": "No se encontraron habilidades disponibles para esta conversación.",
+ "tr": "Bu sohbet için kullanılabilir yetenek bulunamadı.",
+ "uk": "У цій розмові не знайдено доступних навичок."
},
"CONVERSATION$FAILED_TO_FETCH_MICROAGENTS": {
"en": "Failed to fetch available microagents",
@@ -10271,23 +10287,23 @@
"tr": "Kullanılabilir mikro ajanlar",
"uk": "Доступні мікроагенти"
},
- "MICROAGENTS_MODAL$WARNING": {
- "en": "If you update the microagents, you will need to stop the conversation and then click on the refresh button to see the changes.",
- "ja": "マイクロエージェントを更新する場合、会話を停止してから更新ボタンをクリックして変更を確認する必要があります。",
- "zh-CN": "如果您更新微代理,您需要停止对话,然后点击刷新按钮以查看更改。",
- "zh-TW": "如果您更新微代理,您需要停止對話,然後點擊重新整理按鈕以查看更改。",
- "ko-KR": "마이크로에이전트를 업데이트하는 경우 대화를 중지한 후 새로고침 버튼을 클릭하여 변경사항을 확인해야 합니다.",
- "no": "Hvis du oppdaterer mikroagentene, må du stoppe samtalen og deretter klikke på oppdater-knappen for å se endringene.",
- "ar": "إذا قمت بتحديث الوكلاء المصغرين، فستحتاج إلى إيقاف المحادثة ثم النقر على زر التحديث لرؤية التغييرات.",
- "de": "Wenn Sie die Mikroagenten aktualisieren, müssen Sie das Gespräch beenden und dann auf die Aktualisieren-Schaltfläche klicken, um die Änderungen zu sehen.",
- "fr": "Si vous mettez à jour les micro-agents, vous devrez arrêter la conversation puis cliquer sur le bouton actualiser pour voir les changements.",
- "it": "Se aggiorni i microagenti, dovrai fermare la conversazione e poi cliccare sul pulsante aggiorna per vedere le modifiche.",
- "pt": "Se você atualizar os microagentes, precisará parar a conversa e depois clicar no botão atualizar para ver as alterações.",
- "es": "Si actualiza los microagentes, necesitará detener la conversación y luego hacer clic en el botón actualizar para ver los cambios.",
- "tr": "Mikro ajanları güncellerseniz, konuşmayı durdurmanız ve ardından değişiklikleri görmek için yenile düğmesine tıklamanız gerekecektir.",
- "uk": "Якщо ви оновите мікроагенти, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни."
+ "SKILLS_MODAL$WARNING": {
+ "en": "If you update the skills, you will need to stop the conversation and then click on the refresh button to see the changes.",
+ "ja": "スキルを更新する場合、会話を停止し、その後、更新ボタンをクリックして変更を反映させる必要があります。",
+ "zh-CN": "如果您更新技能,需要先停止对话,然后点击刷新按钮以查看更改。",
+ "zh-TW": "如果您更新技能,需要先停止對話,然後點擊刷新按鈕以查看更改。",
+ "ko-KR": "스킬을 업데이트하면 대화를 중단한 후 새로 고침 버튼을 클릭해야 변경 사항을 볼 수 있습니다.",
+ "no": "Hvis du oppdaterer ferdighetene, må du stoppe samtalen og deretter klikke på oppdateringsknappen for å se endringene.",
+ "ar": "إذا قمت بتحديث المهارات، ستحتاج إلى إيقاف المحادثة ثم النقر على زر التحديث لرؤية التغييرات.",
+ "de": "Wenn Sie die Fähigkeiten aktualisieren, müssen Sie das Gespräch beenden und dann auf die Schaltfläche 'Aktualisieren' klicken, um die Änderungen zu sehen.",
+ "fr": "Si vous mettez à jour les compétences, vous devrez arrêter la conversation, puis cliquer sur le bouton d’actualisation pour voir les modifications.",
+ "it": "Se aggiorni le competenze, dovrai interrompere la conversazione e poi cliccare sul pulsante di aggiornamento per vedere le modifiche.",
+ "pt": "Se você atualizar as habilidades, precisará interromper a conversa e clicar no botão de atualizar para ver as mudanças.",
+ "es": "Si actualizas las habilidades, deberás detener la conversación y luego hacer clic en el botón de actualizar para ver los cambios.",
+ "tr": "Yetenekleri güncellerseniz, değişiklikleri görmek için sohbeti durdurmalı ve ardından yenile düğmesine tıklamalısınız.",
+ "uk": "Якщо ви оновите навички, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни."
},
- "MICROAGENTS_MODAL$TRIGGERS": {
+ "COMMON$TRIGGERS": {
"en": "Triggers",
"ja": "トリガー",
"zh-CN": "触发器",
@@ -10335,7 +10351,7 @@
"tr": "Araçlar",
"uk": "Інструменти"
},
- "MICROAGENTS_MODAL$CONTENT": {
+ "COMMON$CONTENT": {
"en": "Content",
"ja": "コンテンツ",
"zh-CN": "内容",
@@ -10351,37 +10367,37 @@
"tr": "İçerik",
"uk": "Вміст"
},
- "MICROAGENTS_MODAL$NO_CONTENT": {
- "en": "Microagent has no content",
- "ja": "マイクロエージェントにコンテンツがありません",
- "zh-CN": "微代理没有内容",
- "zh-TW": "微代理沒有內容",
- "ko-KR": "마이크로에이전트에 콘텐츠가 없습니다",
- "no": "Mikroagenten har ikke innhold",
- "ar": "الوكيل المصغر ليس لديه محتوى",
- "de": "Mikroagent hat keinen Inhalt",
- "fr": "Le micro-agent n'a pas de contenu",
- "it": "Il microagente non ha contenuto",
- "pt": "Microagente não tem conteúdo",
- "es": "El microagente no tiene contenido",
- "tr": "Mikroajanın içeriği yok",
- "uk": "Мікроагент не має вмісту"
+ "SKILLS_MODAL$NO_CONTENT": {
+ "en": "Skill has no content",
+ "ja": "スキルにはコンテンツがありません",
+ "zh-CN": "技能没有内容",
+ "zh-TW": "技能沒有內容",
+ "ko-KR": "스킬에 컨텐츠가 없습니다",
+ "no": "Ferdighet har ikke noe innhold",
+ "ar": "المهارة ليس لديها محتوى",
+ "de": "Die Fähigkeit hat keinen Inhalt",
+ "fr": "La compétence n'a pas de contenu",
+ "it": "La competenza non ha contenuti",
+ "pt": "A habilidade não possui conteúdo",
+ "es": "La habilidad no tiene contenido",
+ "tr": "Beceride içerik yok",
+ "uk": "У навички немає вмісту"
},
- "MICROAGENTS_MODAL$FETCH_ERROR": {
- "en": "Failed to fetch microagents. Please try again later.",
- "ja": "マイクロエージェントの取得に失敗しました。後でもう一度お試しください。",
- "zh-CN": "获取微代理失败。请稍后再试。",
- "zh-TW": "獲取微代理失敗。請稍後再試。",
- "ko-KR": "마이크로에이전트를 가져오지 못했습니다. 나중에 다시 시도해 주세요.",
- "no": "Kunne ikke hente mikroagenter. Prøv igjen senere.",
- "ar": "فشل في جلب الوكلاء المصغرين. يرجى المحاولة مرة أخرى لاحقًا.",
- "de": "Mikroagenten konnten nicht abgerufen werden. Bitte versuchen Sie es später erneut.",
- "fr": "Échec de la récupération des micro-agents. Veuillez réessayer plus tard.",
- "it": "Impossibile recuperare i microagenti. Riprova più tardi.",
- "pt": "Falha ao buscar microagentes. Por favor, tente novamente mais tarde.",
- "es": "Error al obtener microagentes. Por favor, inténtelo de nuevo más tarde.",
- "tr": "Mikroajanlar getirilemedi. Lütfen daha sonra tekrar deneyin.",
- "uk": "Не вдалося отримати мікроагентів. Будь ласка, спробуйте пізніше."
+ "COMMON$FETCH_ERROR": {
+ "en": "Failed to fetch skills. Please try again later.",
+ "ja": "スキルの取得に失敗しました。後でもう一度お試しください。",
+ "zh-CN": "获取技能失败。请稍后再试。",
+ "zh-TW": "取得技能失敗。請稍後再試。",
+ "ko-KR": "스킬을 가져오지 못했습니다. 나중에 다시 시도해주세요.",
+ "no": "Kunne ikke hente ferdigheter. Prøv igjen senere.",
+ "ar": "فشل في جلب المهارات. يرجى المحاولة لاحقًا.",
+ "de": "Die Fähigkeiten konnten nicht abgerufen werden. Bitte versuchen Sie es später erneut.",
+ "fr": "Échec de la récupération des compétences. Veuillez réessayer plus tard.",
+ "it": "Impossibile recuperare le competenze. Riprova più tardi.",
+ "pt": "Falha ao buscar as habilidades. Por favor, tente novamente mais tarde.",
+ "es": "No se pudieron obtener las habilidades. Por favor, inténtalo de nuevo más tarde.",
+ "tr": "Beceriler alınamadı. Lütfen daha sonra tekrar deneyin.",
+ "uk": "Не вдалося отримати навички. Будь ласка, спробуйте пізніше."
},
"TIPS$SETUP_SCRIPT": {
"en": "You can add .openhands/setup.sh to your repository to automatically run a setup script every time you start an OpenHands conversation.",
@@ -15278,5 +15294,37 @@
"tr": "Yetenek hazır",
"de": "Fähigkeit bereit",
"uk": "Навичка готова"
+ },
+ "CONVERSATION$SHOW_SKILLS": {
+ "en": "Show Available Skills",
+ "ja": "利用可能なスキルを表示",
+ "zh-CN": "显示可用技能",
+ "zh-TW": "顯示可用技能",
+ "ko-KR": "사용 가능한 스킬 표시",
+ "no": "Vis tilgjengelige ferdigheter",
+ "ar": "عرض المهارات المتاحة",
+ "de": "Verfügbare Fähigkeiten anzeigen",
+ "fr": "Afficher les compétences disponibles",
+ "it": "Mostra abilità disponibili",
+ "pt": "Mostrar habilidades disponíveis",
+ "es": "Mostrar habilidades disponibles",
+ "tr": "Kullanılabilir yetenekleri göster",
+ "uk": "Показати доступні навички"
+ },
+ "SKILLS_MODAL$TITLE": {
+ "en": "Available Skills",
+ "ja": "利用可能なスキル",
+ "zh-CN": "可用技能",
+ "zh-TW": "可用技能",
+ "ko-KR": "사용 가능한 스킬",
+ "no": "Tilgjengelige ferdigheter",
+ "ar": "المهارات المتاحة",
+ "de": "Verfügbare Fähigkeiten",
+ "fr": "Compétences disponibles",
+ "it": "Abilità disponibili",
+ "pt": "Habilidades disponíveis",
+ "es": "Habilidades disponibles",
+ "tr": "Kullanılabilir yetenekler",
+ "uk": "Доступні навички"
}
}
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/routes/vscode-tab.tsx b/frontend/src/routes/vscode-tab.tsx
index 0d64180c1d..e1bb2e8fe4 100644
--- a/frontend/src/routes/vscode-tab.tsx
+++ b/frontend/src/routes/vscode-tab.tsx
@@ -51,7 +51,7 @@ function VSCodeTab() {
);
}
- if (error || (data && data.error) || !data?.url || iframeError) {
+ if (error || data?.error || !data?.url || iframeError) {
return (
{iframeError ||
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/stores/home-store.ts b/frontend/src/stores/home-store.ts
index 3ec2ed2c26..6289f65f01 100644
--- a/frontend/src/stores/home-store.ts
+++ b/frontend/src/stores/home-store.ts
@@ -1,21 +1,26 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { GitRepository } from "#/types/git";
+import { Provider } from "#/types/settings";
interface HomeState {
recentRepositories: GitRepository[];
+ lastSelectedProvider: Provider | null;
}
interface HomeActions {
addRecentRepository: (repository: GitRepository) => void;
clearRecentRepositories: () => void;
getRecentRepositories: () => GitRepository[];
+ setLastSelectedProvider: (provider: Provider | null) => void;
+ getLastSelectedProvider: () => Provider | null;
}
type HomeStore = HomeState & HomeActions;
const initialState: HomeState = {
recentRepositories: [],
+ lastSelectedProvider: null,
};
export const useHomeStore = create()(
@@ -44,6 +49,13 @@ export const useHomeStore = create()(
})),
getRecentRepositories: () => get().recentRepositories,
+
+ setLastSelectedProvider: (provider: Provider | null) =>
+ set(() => ({
+ lastSelectedProvider: provider,
+ })),
+
+ getLastSelectedProvider: () => get().lastSelectedProvider,
}),
{
name: "home-store", // unique name for localStorage
diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css
index 8228f6b154..1673288564 100644
--- a/frontend/src/tailwind.css
+++ b/frontend/src/tailwind.css
@@ -318,8 +318,8 @@
background: transparent !important;
}
-/* Ensure all xterm elements have transparent backgrounds */
-.xterm * {
+/* Ensure all xterm DOM elements have transparent backgrounds. Exclude canvas elements */
+.xterm {
background: transparent !important;
}
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/types/v1/core/base/observation.ts b/frontend/src/types/v1/core/base/observation.ts
index 42726c2b32..a1c8a1a48d 100644
--- a/frontend/src/types/v1/core/base/observation.ts
+++ b/frontend/src/types/v1/core/base/observation.ts
@@ -36,7 +36,7 @@ export interface ThinkObservation extends ObservationBase<"ThinkObservation"> {
/**
* Confirmation message. DEFAULT: "Your thought has been logged."
*/
- content: string;
+ content: Array;
}
export interface BrowserObservation extends ObservationBase<"BrowserObservation"> {
diff --git a/frontend/src/types/v1/type-guards.ts b/frontend/src/types/v1/type-guards.ts
index ee831ea489..dec1816209 100644
--- a/frontend/src/types/v1/type-guards.ts
+++ b/frontend/src/types/v1/type-guards.ts
@@ -54,7 +54,10 @@ export const isObservationEvent = (
): event is ObservationEvent =>
event.source === "environment" &&
"action_id" in event &&
- "observation" in event;
+ "observation" in event &&
+ event.observation !== null &&
+ typeof event.observation === "object" &&
+ "kind" in event.observation;
/**
* Type guard function to check if an event is an agent error event
@@ -94,6 +97,9 @@ export const isUserMessageEvent = (
export const isActionEvent = (event: OpenHandsEvent): event is ActionEvent =>
event.source === "agent" &&
"action" in event &&
+ event.action !== null &&
+ typeof event.action === "object" &&
+ "kind" in event.action &&
"tool_name" in event &&
"tool_call_id" in event &&
typeof event.tool_name === "string" &&
diff --git a/frontend/src/utils/extract-model-and-provider.ts b/frontend/src/utils/extract-model-and-provider.ts
index 93ef12d8bf..ab0836079f 100644
--- a/frontend/src/utils/extract-model-and-provider.ts
+++ b/frontend/src/utils/extract-model-and-provider.ts
@@ -16,7 +16,7 @@ import {
* splitIsActuallyVersion(split) // returns true
*/
const splitIsActuallyVersion = (split: string[]) =>
- split[1] && split[1][0] && isNumber(split[1][0]);
+ split[1]?.[0] && isNumber(split[1][0]);
/**
* Given a model string, extract the provider and model name. Currently the supported separators are "/" and "."
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/parse-terminal-output.ts b/frontend/src/utils/parse-terminal-output.ts
index a6ccc73cfc..1cd54eb858 100644
--- a/frontend/src/utils/parse-terminal-output.ts
+++ b/frontend/src/utils/parse-terminal-output.ts
@@ -1,3 +1,5 @@
+const START = "[Python Interpreter: ";
+
/**
* Parses the raw output from the terminal into the command and symbol
* @param raw The raw output to be displayed in the terminal
@@ -13,9 +15,14 @@
* console.log(parsed.symbol); // openhands@659478cb008c:/workspace $
*/
export const parseTerminalOutput = (raw: string) => {
- const envRegex = /(.*)\[Python Interpreter: (.*)\]/s;
- const match = raw.match(envRegex);
-
- if (!match) return raw;
- return match[1]?.trim() || "";
+ const start = raw.indexOf(START);
+ if (start < 0) {
+ return raw;
+ }
+ const offset = start + START.length;
+ const end = raw.indexOf("]", offset);
+ if (end <= offset) {
+ return raw;
+ }
+ return raw.substring(0, start).trim();
};
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/frontend/tests/avatar-menu.spec.ts b/frontend/tests/avatar-menu.spec.ts
new file mode 100644
index 0000000000..a7ef4efe40
--- /dev/null
+++ b/frontend/tests/avatar-menu.spec.ts
@@ -0,0 +1,48 @@
+import test, { expect } from "@playwright/test";
+
+/**
+ * Test for issue #11933: Avatar context menu closes when moving cursor diagonally
+ *
+ * This test verifies that the user can move their cursor diagonally from the
+ * avatar to the context menu without the menu closing unexpectedly.
+ */
+test("avatar context menu stays open when moving cursor diagonally to menu", async ({
+ page,
+ browserName,
+}) => {
+ // Skip on WebKit - Playwright's mouse.move() doesn't reliably trigger CSS hover states
+ test.skip(browserName === "webkit", "Playwright hover simulation unreliable");
+
+ await page.goto("/");
+
+ // Get the user avatar button
+ const userAvatar = page.getByTestId("user-avatar");
+ await expect(userAvatar).toBeVisible();
+
+ // Get avatar bounding box first
+ const avatarBox = await userAvatar.boundingBox();
+ if (!avatarBox) {
+ throw new Error("Could not get bounding box for avatar");
+ }
+
+ // Use mouse.move to hover (not .hover() which may trigger click)
+ const avatarCenterX = avatarBox.x + avatarBox.width / 2;
+ const avatarCenterY = avatarBox.y + avatarBox.height / 2;
+ await page.mouse.move(avatarCenterX, avatarCenterY);
+
+ // The context menu should appear via CSS group-hover
+ const contextMenu = page.getByTestId("account-settings-context-menu");
+ await expect(contextMenu).toBeVisible();
+
+ // Move UP from the LEFT side of the avatar - simulating diagonal movement
+ // toward the menu (which is to the right). This exits the hover zone.
+ const leftX = avatarBox.x + 2;
+ const aboveY = avatarBox.y - 50;
+ await page.mouse.move(leftX, aboveY);
+
+ // The menu uses opacity-0/opacity-100 for visibility via CSS.
+ // Use toHaveCSS which auto-retries, avoiding flaky waitForTimeout.
+ // The menu should remain visible (opacity 1) to allow diagonal access to it.
+ const menuWrapper = contextMenu.locator("..");
+ await expect(menuWrapper).toHaveCSS("opacity", "1");
+});
diff --git a/frontend/tests/conversation-panel.test.ts b/frontend/tests/conversation-panel.test.ts
deleted file mode 100644
index 6e3f58cd45..0000000000
--- a/frontend/tests/conversation-panel.test.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import test, { expect, Page } from "@playwright/test";
-
-const toggleConversationPanel = async (page: Page) => {
- const panel = page.getByTestId("conversation-panel");
- await page.waitForTimeout(1000); // Wait for state to stabilize
- const panelIsVisible = await panel.isVisible();
-
- if (!panelIsVisible) {
- const conversationPanelButton = page.getByTestId(
- "toggle-conversation-panel",
- );
- await conversationPanelButton.click();
- }
-
- return page.getByTestId("conversation-panel");
-};
-
-const selectConversationCard = async (page: Page, index: number) => {
- const panel = await toggleConversationPanel(page);
-
- // select a conversation
- const conversationItem = panel.getByTestId("conversation-card").nth(index);
- await conversationItem.click();
-
- // panel should close
- await expect(panel).not.toBeVisible();
-
- await page.waitForURL(`/conversations/${index + 1}`);
- expect(page.url()).toBe(`http://localhost:3001/conversations/${index + 1}`);
-};
-
-test.beforeEach(async ({ page }) => {
- await page.goto("/");
-});
-
-test("should only display the create new conversation button when in a conversation", async ({
- page,
-}) => {
- const panel = page.getByTestId("conversation-panel");
-
- const newProjectButton = panel.getByTestId("new-conversation-button");
- await expect(newProjectButton).not.toBeVisible();
-
- await page.goto("/conversations/1");
- await expect(newProjectButton).toBeVisible();
-});
-
-test("redirect to /conversation with the session id as a path param when clicking on a conversation card", async ({
- page,
-}) => {
- const panel = page.getByTestId("conversation-panel");
-
- // select a conversation
- const conversationItem = panel.getByTestId("conversation-card").first();
- await conversationItem.click();
-
- // panel should close
- expect(panel).not.toBeVisible();
-
- await page.waitForURL("/conversations/1");
- expect(page.url()).toBe("http://localhost:3001/conversations/1");
-});
-
-test("redirect to the home screen if the current session was deleted", async ({
- page,
-}) => {
- await page.goto("/conversations/1");
- await page.waitForURL("/conversations/1");
-
- const panel = page.getByTestId("conversation-panel");
- const firstCard = panel.getByTestId("conversation-card").first();
-
- const ellipsisButton = firstCard.getByTestId("ellipsis-button");
- await ellipsisButton.click();
-
- const deleteButton = firstCard.getByTestId("delete-button");
- await deleteButton.click();
-
- // confirm modal
- const confirmButton = page.getByText("Confirm");
- await confirmButton.click();
-
- await page.waitForURL("/");
-});
-
-test("load relevant files in the file explorer", async ({ page }) => {
- await selectConversationCard(page, 0);
-
- // check if the file explorer has the correct files
- const fileExplorer = page.getByTestId("file-explorer");
-
- await expect(fileExplorer.getByText("file1.txt")).toBeVisible();
- await expect(fileExplorer.getByText("file2.txt")).toBeVisible();
- await expect(fileExplorer.getByText("file3.txt")).toBeVisible();
-
- await selectConversationCard(page, 2);
-
- // check if the file explorer has the correct files
- expect(fileExplorer.getByText("reboot_skynet.exe")).toBeVisible();
- expect(fileExplorer.getByText("target_list.txt")).toBeVisible();
- expect(fileExplorer.getByText("terminator_blueprint.txt")).toBeVisible();
-});
-
-test("should redirect to home screen if conversation deos not exist", async ({
- page,
-}) => {
- await page.goto("/conversations/9999");
- await page.waitForURL("/");
-});
-
-test("display the conversation details during a conversation", async ({
- page,
-}) => {
- const conversationPanelButton = page.getByTestId("toggle-conversation-panel");
- await expect(conversationPanelButton).toBeVisible();
- await conversationPanelButton.click();
-
- const panel = page.getByTestId("conversation-panel");
-
- // select a conversation
- const conversationItem = panel.getByTestId("conversation-card").first();
- await conversationItem.click();
-
- // panel should close
- await expect(panel).not.toBeVisible();
-
- await page.waitForURL("/conversations/1");
- expect(page.url()).toBe("http://localhost:3001/conversations/1");
-
- const conversationDetails = page.getByTestId("conversation-card");
-
- await expect(conversationDetails).toBeVisible();
- await expect(conversationDetails).toHaveText("Conversation 1");
-});
diff --git a/frontend/tests/fixtures/project.zip b/frontend/tests/fixtures/project.zip
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frontend/tests/helpers/confirm-settings.ts b/frontend/tests/helpers/confirm-settings.ts
deleted file mode 100644
index ca82edd35a..0000000000
--- a/frontend/tests/helpers/confirm-settings.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Page } from "@playwright/test";
-
-export const confirmSettings = async (page: Page) => {
- const confirmPreferenceButton = page.getByRole("button", {
- name: /confirm preferences/i,
- });
- await confirmPreferenceButton.click();
-
- const configSaveButton = page
- .getByRole("button", {
- name: /save/i,
- })
- .first();
- await configSaveButton.click();
-
- const confirmChanges = page.getByRole("button", {
- name: /yes, close settings/i,
- });
- await confirmChanges.click();
-};
diff --git a/frontend/tests/placeholder.spec.ts b/frontend/tests/placeholder.spec.ts
new file mode 100644
index 0000000000..48e76b587e
--- /dev/null
+++ b/frontend/tests/placeholder.spec.ts
@@ -0,0 +1,4 @@
+import { test } from "@playwright/test";
+
+// Placeholder test to ensure CI passes until real E2E tests are added
+test("placeholder", () => {});
diff --git a/frontend/tests/redirect.spec.ts b/frontend/tests/redirect.spec.ts
deleted file mode 100644
index 8425345ba6..0000000000
--- a/frontend/tests/redirect.spec.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { expect, test } from "@playwright/test";
-import path from "path";
-import { fileURLToPath } from "url";
-
-const filename = fileURLToPath(import.meta.url);
-const dirname = path.dirname(filename);
-
-test.beforeEach(async ({ page }) => {
- await page.goto("/");
-});
-
-test("should redirect to /conversations after uploading a project zip", async ({
- page,
-}) => {
- const fileInput = page.getByLabel("Upload a .zip");
- const filePath = path.join(dirname, "fixtures/project.zip");
- await fileInput.setInputFiles(filePath);
-
- await page.waitForURL(/\/conversations\/\d+/);
-});
-
-test("should redirect to /conversations after selecting a repo", async ({
- page,
-}) => {
- // enter a github token to view the repositories
- const connectToGitHubButton = page.getByRole("button", {
- name: /connect to github/i,
- });
- await connectToGitHubButton.click();
- const tokenInput = page.getByLabel(/github token\*/i);
- await tokenInput.fill("fake-token");
-
- const submitButton = page.getByTestId("connect-to-github");
- await submitButton.click();
-
- // select a repository
- const repoDropdown = page.getByLabel(/github repository/i);
- await repoDropdown.click();
-
- const repoItem = page.getByTestId("github-repo-item").first();
- await repoItem.click();
-
- await page.waitForURL(/\/conversations\/\d+/);
-});
-
-// FIXME: This fails because the MSW WS mocks change state too quickly,
-// missing the OPENING status where the initial query is rendered.
-test.skip("should redirect the user to /conversation with their initial query after selecting a project", async ({
- page,
-}) => {
- // enter query
- const testQuery = "this is my test query";
- const textbox = page.getByPlaceholder(/what do you want to build/i);
- expect(textbox).not.toBeNull();
- await textbox.fill(testQuery);
-
- const fileInput = page.getByLabel("Upload a .zip");
- const filePath = path.join(dirname, "fixtures/project.zip");
- await fileInput.setInputFiles(filePath);
-
- await page.waitForURL("/conversation");
-
- // get user message
- const userMessage = page.getByTestId("user-message");
- expect(await userMessage.textContent()).toBe(testQuery);
-});
diff --git a/frontend/tests/repo-selection-form.test.tsx b/frontend/tests/repo-selection-form.test.tsx
deleted file mode 100644
index 24666d49fc..0000000000
--- a/frontend/tests/repo-selection-form.test.tsx
+++ /dev/null
@@ -1,130 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
-import { RepositorySelectionForm } from "../src/components/features/home/repo-selection-form";
-import { useUserRepositories } from "../src/hooks/query/use-user-repositories";
-import { useRepositoryBranches } from "../src/hooks/query/use-repository-branches";
-import { useCreateConversation } from "../src/hooks/mutation/use-create-conversation";
-import { useIsCreatingConversation } from "../src/hooks/use-is-creating-conversation";
-
-// Mock the hooks
-vi.mock("../src/hooks/query/use-user-repositories");
-vi.mock("../src/hooks/query/use-repository-branches");
-vi.mock("../src/hooks/mutation/use-create-conversation");
-vi.mock("../src/hooks/use-is-creating-conversation");
-vi.mock("react-i18next", () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}));
-
-describe("RepositorySelectionForm", () => {
- const mockOnRepoSelection = vi.fn();
-
- beforeEach(() => {
- vi.resetAllMocks();
-
- // Mock the hooks with default values
- (useUserRepositories as any).mockReturnValue({
- data: [
- { id: "1", full_name: "test/repo1" },
- { id: "2", full_name: "test/repo2" }
- ],
- isLoading: false,
- isError: false,
- });
-
- (useRepositoryBranches as any).mockReturnValue({
- data: [
- { name: "main" },
- { name: "develop" }
- ],
- isLoading: false,
- isError: false,
- });
-
- (useCreateConversation as any).mockReturnValue({
- mutate: vi.fn(() => (useIsCreatingConversation as any).mockReturnValue(true)),
- isPending: false,
- isSuccess: false,
- });
-
- (useIsCreatingConversation as any).mockReturnValue(false);
- });
-
- it("should clear selected branch when input is empty", async () => {
- render();
-
- // First select a repository to enable the branch dropdown
- const repoDropdown = screen.getByTestId("repository-dropdown");
- fireEvent.change(repoDropdown, { target: { value: "test/repo1" } });
-
- // Get the branch dropdown and verify it's enabled
- const branchDropdown = screen.getByTestId("branch-dropdown");
- expect(branchDropdown).not.toBeDisabled();
-
- // Simulate deleting all text in the branch input
- fireEvent.change(branchDropdown, { target: { value: "" } });
-
- // Verify the branch input is cleared (no selected branch)
- expect(branchDropdown).toHaveValue("");
- });
-
- it("should clear selected branch when input contains only whitespace", async () => {
- render();
-
- // First select a repository to enable the branch dropdown
- const repoDropdown = screen.getByTestId("repository-dropdown");
- fireEvent.change(repoDropdown, { target: { value: "test/repo1" } });
-
- // Get the branch dropdown and verify it's enabled
- const branchDropdown = screen.getByTestId("branch-dropdown");
- expect(branchDropdown).not.toBeDisabled();
-
- // Simulate entering only whitespace in the branch input
- fireEvent.change(branchDropdown, { target: { value: " " } });
-
- // Verify the branch input is cleared (no selected branch)
- expect(branchDropdown).toHaveValue("");
- });
-
- it("should keep branch empty after being cleared even with auto-selection", async () => {
- render();
-
- // First select a repository to enable the branch dropdown
- const repoDropdown = screen.getByTestId("repository-dropdown");
- fireEvent.change(repoDropdown, { target: { value: "test/repo1" } });
-
- // Get the branch dropdown and verify it's enabled
- const branchDropdown = screen.getByTestId("branch-dropdown");
- expect(branchDropdown).not.toBeDisabled();
-
- // The branch should be auto-selected to "main" initially
- expect(branchDropdown).toHaveValue("main");
-
- // Simulate deleting all text in the branch input
- fireEvent.change(branchDropdown, { target: { value: "" } });
-
- // Verify the branch input is cleared (no selected branch)
- expect(branchDropdown).toHaveValue("");
-
- // Trigger a re-render by changing something else
- fireEvent.change(repoDropdown, { target: { value: "test/repo2" } });
- fireEvent.change(repoDropdown, { target: { value: "test/repo1" } });
-
- // The branch should be auto-selected to "main" again after repo change
- expect(branchDropdown).toHaveValue("main");
-
- // Clear it again
- fireEvent.change(branchDropdown, { target: { value: "" } });
-
- // Verify it stays empty
- expect(branchDropdown).toHaveValue("");
-
- // Simulate a component update without changing repos
- // This would normally trigger the useEffect if our fix wasn't working
- fireEvent.blur(branchDropdown);
-
- // Verify it still stays empty
- expect(branchDropdown).toHaveValue("");
- });
-});
diff --git a/frontend/tests/settings.spec.ts b/frontend/tests/settings.spec.ts
deleted file mode 100644
index e4c4ce3b35..0000000000
--- a/frontend/tests/settings.spec.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import test, { expect } from "@playwright/test";
-
-test("do not navigate to /settings/billing if not SaaS mode", async ({
- page,
-}) => {
- await page.goto("/settings/billing");
- await expect(page.getByTestId("settings-screen")).toBeVisible();
- expect(page.url()).toBe("http://localhost:3001/settings");
-});
-
-// FIXME: This test is failing because the config is not being set to SaaS mode
-// since MSW is always returning APP_MODE as "oss"
-test.skip("navigate to /settings/billing if SaaS mode", async ({ page }) => {
- await page.goto("/settings/billing");
- await expect(page.getByTestId("settings-screen")).toBeVisible();
- expect(page.url()).toBe("http://localhost:3001/settings/billing");
-});
diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py
index 85e5f88cbc..9dd814e9cf 100644
--- a/openhands/agenthub/codeact_agent/codeact_agent.py
+++ b/openhands/agenthub/codeact_agent/codeact_agent.py
@@ -194,9 +194,12 @@ class CodeActAgent(Agent):
# event we'll just return that instead of an action. The controller will
# immediately ask the agent to step again with the new view.
condensed_history: list[Event] = []
+ # Track which event IDs have been forgotten/condensed
+ forgotten_event_ids: set[int] = set()
match self.condenser.condensed_history(state):
- case View(events=events):
+ case View(events=events, forgotten_event_ids=forgotten_ids):
condensed_history = events
+ forgotten_event_ids = forgotten_ids
case Condensation(action=condensation_action):
return condensation_action
@@ -206,7 +209,9 @@ class CodeActAgent(Agent):
)
initial_user_message = self._get_initial_user_message(state.history)
- messages = self._get_messages(condensed_history, initial_user_message)
+ messages = self._get_messages(
+ condensed_history, initial_user_message, forgotten_event_ids
+ )
params: dict = {
'messages': messages,
}
@@ -245,7 +250,10 @@ class CodeActAgent(Agent):
return initial_user_message
def _get_messages(
- self, events: list[Event], initial_user_message: MessageAction
+ self,
+ events: list[Event],
+ initial_user_message: MessageAction,
+ forgotten_event_ids: set[int],
) -> list[Message]:
"""Constructs the message history for the LLM conversation.
@@ -284,6 +292,7 @@ class CodeActAgent(Agent):
messages = self.conversation_memory.process_events(
condensed_history=events,
initial_user_action=initial_user_message,
+ forgotten_event_ids=forgotten_event_ids,
max_message_chars=self.llm.config.max_message_chars,
vision_is_active=self.llm.vision_is_active(),
)
diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py
index 0c7ef99ce5..1c0ba914cb 100644
--- a/openhands/app_server/app_conversation/app_conversation_models.py
+++ b/openhands/app_server/app_conversation/app_conversation_models.py
@@ -1,5 +1,6 @@
from datetime import datetime
from enum import Enum
+from typing import Literal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
@@ -161,3 +162,12 @@ class AppConversationStartTask(BaseModel):
class AppConversationStartTaskPage(BaseModel):
items: list[AppConversationStartTask]
next_page_id: str | None = None
+
+
+class SkillResponse(BaseModel):
+ """Response model for skills endpoint."""
+
+ name: str
+ type: Literal['repo', 'knowledge']
+ content: str
+ triggers: list[str] = []
diff --git a/openhands/app_server/app_conversation/app_conversation_router.py b/openhands/app_server/app_conversation/app_conversation_router.py
index bf82840e96..a7a0414e31 100644
--- a/openhands/app_server/app_conversation/app_conversation_router.py
+++ b/openhands/app_server/app_conversation/app_conversation_router.py
@@ -1,11 +1,12 @@
"""Sandboxed Conversation router for OpenHands Server."""
import asyncio
+import logging
import os
import sys
import tempfile
from datetime import datetime
-from typing import Annotated, AsyncGenerator
+from typing import Annotated, AsyncGenerator, Literal
from uuid import UUID
import httpx
@@ -28,8 +29,8 @@ else:
return await async_iterator.__anext__()
-from fastapi import APIRouter, Query, Request
-from fastapi.responses import StreamingResponse
+from fastapi import APIRouter, Query, Request, status
+from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from openhands.app_server.app_conversation.app_conversation_models import (
@@ -39,10 +40,14 @@ from openhands.app_server.app_conversation.app_conversation_models import (
AppConversationStartTask,
AppConversationStartTaskPage,
AppConversationStartTaskSortOrder,
+ SkillResponse,
)
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
+from openhands.app_server.app_conversation.app_conversation_service_base import (
+ AppConversationServiceBase,
+)
from openhands.app_server.app_conversation.app_conversation_start_task_service import (
AppConversationStartTaskService,
)
@@ -65,9 +70,11 @@ from openhands.app_server.sandbox.sandbox_spec_service import SandboxSpecService
from openhands.app_server.utils.docker_utils import (
replace_localhost_hostname_for_docker,
)
+from openhands.sdk.context.skills import KeywordTrigger, TaskTrigger
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
router = APIRouter(prefix='/app-conversations', tags=['Conversations'])
+logger = logging.getLogger(__name__)
app_conversation_service_dependency = depends_app_conversation_service()
app_conversation_start_task_service_dependency = (
depends_app_conversation_start_task_service()
@@ -400,6 +407,145 @@ async def read_conversation_file(
return ''
+@router.get('/{conversation_id}/skills')
+async def get_conversation_skills(
+ conversation_id: UUID,
+ app_conversation_service: AppConversationService = (
+ app_conversation_service_dependency
+ ),
+ sandbox_service: SandboxService = sandbox_service_dependency,
+ sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency,
+) -> JSONResponse:
+ """Get all skills associated with the conversation.
+
+ This endpoint returns all skills that are loaded for the v1 conversation.
+ Skills are loaded from multiple sources:
+ - Sandbox skills (exposed URLs)
+ - Global skills (OpenHands/skills/)
+ - User skills (~/.openhands/skills/)
+ - Organization skills (org/.openhands repository)
+ - Repository skills (repo/.openhands/skills/ or .openhands/microagents/)
+
+ Returns:
+ JSONResponse: A JSON response containing the list of skills.
+ """
+ try:
+ # Get the conversation info
+ conversation = await app_conversation_service.get_app_conversation(
+ conversation_id
+ )
+ if not conversation:
+ return JSONResponse(
+ status_code=status.HTTP_404_NOT_FOUND,
+ content={'error': f'Conversation {conversation_id} not found'},
+ )
+
+ # Get the sandbox info
+ sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id)
+ if not sandbox or sandbox.status != SandboxStatus.RUNNING:
+ return JSONResponse(
+ status_code=status.HTTP_404_NOT_FOUND,
+ content={
+ 'error': f'Sandbox not found or not running for conversation {conversation_id}'
+ },
+ )
+
+ # Get the sandbox spec to find the working directory
+ sandbox_spec = await sandbox_spec_service.get_sandbox_spec(
+ sandbox.sandbox_spec_id
+ )
+ if not sandbox_spec:
+ return JSONResponse(
+ status_code=status.HTTP_404_NOT_FOUND,
+ content={'error': 'Sandbox spec not found'},
+ )
+
+ # Get the agent server URL
+ if not sandbox.exposed_urls:
+ return JSONResponse(
+ status_code=status.HTTP_404_NOT_FOUND,
+ content={'error': 'No agent server URL found for sandbox'},
+ )
+
+ agent_server_url = None
+ for exposed_url in sandbox.exposed_urls:
+ if exposed_url.name == AGENT_SERVER:
+ agent_server_url = exposed_url.url
+ break
+
+ if not agent_server_url:
+ return JSONResponse(
+ status_code=status.HTTP_404_NOT_FOUND,
+ content={'error': 'Agent server URL not found in sandbox'},
+ )
+
+ agent_server_url = replace_localhost_hostname_for_docker(agent_server_url)
+
+ # Create remote workspace
+ remote_workspace = AsyncRemoteWorkspace(
+ host=agent_server_url,
+ api_key=sandbox.session_api_key,
+ working_dir=sandbox_spec.working_dir,
+ )
+
+ # Load skills from all sources
+ logger.info(f'Loading skills for conversation {conversation_id}')
+
+ # Prefer the shared loader to avoid duplication; otherwise return empty list.
+ all_skills: list = []
+ if isinstance(app_conversation_service, AppConversationServiceBase):
+ all_skills = await app_conversation_service.load_and_merge_all_skills(
+ sandbox,
+ remote_workspace,
+ conversation.selected_repository,
+ sandbox_spec.working_dir,
+ )
+
+ logger.info(
+ f'Loaded {len(all_skills)} skills for conversation {conversation_id}: '
+ f'{[s.name for s in all_skills]}'
+ )
+
+ # Transform skills to response format
+ skills_response = []
+ for skill in all_skills:
+ # Determine type based on trigger
+ skill_type: Literal['repo', 'knowledge']
+ if skill.trigger is None:
+ skill_type = 'repo'
+ else:
+ skill_type = 'knowledge'
+
+ # Extract triggers
+ triggers = []
+ if isinstance(skill.trigger, (KeywordTrigger, TaskTrigger)):
+ if hasattr(skill.trigger, 'keywords'):
+ triggers = skill.trigger.keywords
+ elif hasattr(skill.trigger, 'triggers'):
+ triggers = skill.trigger.triggers
+
+ skills_response.append(
+ SkillResponse(
+ name=skill.name,
+ type=skill_type,
+ content=skill.content,
+ triggers=triggers,
+ )
+ )
+
+ return JSONResponse(
+ status_code=status.HTTP_200_OK,
+ content={'skills': [s.model_dump() for s in skills_response]},
+ )
+
+ except Exception as e:
+ logger.error(f'Error getting skills for conversation {conversation_id}: {e}')
+ return JSONResponse(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ content={'error': f'Error getting skills: {str(e)}'},
+ )
+
+
async def _consume_remaining(
async_iter, db_session: AsyncSession, httpx_client: httpx.AsyncClient
):
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..aa6add73fe 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
@@ -18,6 +22,7 @@ from openhands.app_server.app_conversation.app_conversation_service import (
)
from openhands.app_server.app_conversation.skill_loader import (
load_global_skills,
+ load_org_skills,
load_repo_skills,
load_sandbox_skills,
merge_skills,
@@ -29,6 +34,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__)
@@ -45,7 +58,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
init_git_in_empty_workspace: bool
user_context: UserContext
- async def _load_and_merge_all_skills(
+ async def load_and_merge_all_skills(
self,
sandbox: SandboxInfo,
remote_workspace: AsyncRemoteWorkspace,
@@ -82,13 +95,20 @@ class AppConversationServiceBase(AppConversationService, ABC):
except Exception as e:
_logger.warning(f'Failed to load user skills: {str(e)}')
user_skills = []
+
+ # Load organization-level skills
+ org_skills = await load_org_skills(
+ remote_workspace, selected_repository, working_dir, self.user_context
+ )
+
repo_skills = await load_repo_skills(
remote_workspace, selected_repository, working_dir
)
# Merge all skills (later lists override earlier ones)
+ # Precedence: sandbox < global < user < org < repo
all_skills = merge_skills(
- [sandbox_skills, global_skills, user_skills, repo_skills]
+ [sandbox_skills, global_skills, user_skills, org_skills, repo_skills]
)
_logger.info(
@@ -149,7 +169,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
Updated agent with skills loaded into context
"""
# Load and merge all skills
- all_skills = await self._load_and_merge_all_skills(
+ all_skills = await self.load_and_merge_all_skills(
sandbox, remote_workspace, selected_repository, working_dir
)
@@ -178,7 +198,7 @@ class AppConversationServiceBase(AppConversationService, ABC):
task.status = AppConversationStartTaskStatus.SETTING_UP_SKILLS
yield task
- await self._load_and_merge_all_skills(
+ await self.load_and_merge_all_skills(
sandbox,
workspace,
task.request.selected_repository,
@@ -379,3 +399,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 2f04bf9a71..a8d490489c 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,
)
@@ -70,9 +69,8 @@ from openhands.app_server.utils.docker_utils import (
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import ProviderType
from openhands.sdk import Agent, AgentContext, LocalWorkspace
-from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret
from openhands.sdk.llm import LLM
-from openhands.sdk.security.confirmation_policy import AlwaysConfirm
+from openhands.sdk.secret import LookupSecret, StaticSecret
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
from openhands.tools.preset.default import (
@@ -272,7 +270,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
user_id = await self.user_context.get_user_id()
app_conversation_info = AppConversationInfo(
id=info.id,
- title=f'Conversation {info.id.hex}',
+ title=f'Conversation {info.id.hex[:5]}',
sandbox_id=sandbox.id,
created_by_user_id=user_id,
llm_model=start_conversation_request.agent.llm.model,
@@ -308,6 +306,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
@@ -577,6 +585,204 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
return secrets
+ def _configure_llm(self, user: UserInfo, llm_model: str | None) -> LLM:
+ """Configure LLM settings.
+
+ Args:
+ user: User information containing LLM preferences
+ llm_model: Optional specific model to use, falls back to user default
+
+ Returns:
+ Configured LLM instance
+ """
+ model = llm_model or user.llm_model
+ base_url = user.llm_base_url
+ if model and model.startswith('openhands/'):
+ base_url = user.llm_base_url or self.openhands_provider_base_url
+
+ return LLM(
+ model=model,
+ base_url=base_url,
+ api_key=user.llm_api_key,
+ usage_id='agent',
+ )
+
+ async def _get_tavily_api_key(self, user: UserInfo) -> str | None:
+ """Get Tavily search API key, prioritizing user's key over service key.
+
+ Args:
+ user: User information
+
+ Returns:
+ Tavily API key if available, None otherwise
+ """
+ # Get the actual API key values, prioritizing user's key over service key
+ user_search_key = None
+ if user.search_api_key:
+ key_value = user.search_api_key.get_secret_value()
+ if key_value and key_value.strip():
+ user_search_key = key_value
+
+ service_tavily_key = None
+ if self.tavily_api_key:
+ # tavily_api_key is already a string (extracted in the factory method)
+ if self.tavily_api_key.strip():
+ service_tavily_key = self.tavily_api_key
+
+ return user_search_key or service_tavily_key
+
+ async def _add_system_mcp_servers(
+ self, mcp_servers: dict[str, Any], user: UserInfo
+ ) -> None:
+ """Add system-generated MCP servers (default OpenHands server and Tavily).
+
+ Args:
+ mcp_servers: Dictionary to add servers to
+ user: User information for API keys
+ """
+ if not self.web_url:
+ return
+
+ # Add default OpenHands MCP server
+ mcp_url = f'{self.web_url}/mcp/mcp'
+ mcp_servers['default'] = {'url': mcp_url}
+
+ # Add API key if available
+ mcp_api_key = await self.user_context.get_mcp_api_key()
+ if mcp_api_key:
+ mcp_servers['default']['headers'] = {
+ 'X-Session-API-Key': mcp_api_key,
+ }
+
+ # Add Tavily search if API key is available
+ tavily_api_key = await self._get_tavily_api_key(user)
+ if tavily_api_key:
+ _logger.info('Adding search engine to MCP config')
+ mcp_servers['tavily'] = {
+ 'url': f'https://mcp.tavily.com/mcp/?tavilyApiKey={tavily_api_key}'
+ }
+ else:
+ _logger.info('No search engine API key found, skipping search engine')
+
+ def _add_custom_sse_servers(
+ self, mcp_servers: dict[str, Any], sse_servers: list
+ ) -> None:
+ """Add custom SSE MCP servers from user configuration.
+
+ Args:
+ mcp_servers: Dictionary to add servers to
+ sse_servers: List of SSE server configurations
+ """
+ for sse_server in sse_servers:
+ server_config = {
+ 'url': sse_server.url,
+ 'transport': 'sse',
+ }
+ if sse_server.api_key:
+ server_config['headers'] = {
+ 'Authorization': f'Bearer {sse_server.api_key}'
+ }
+
+ # Generate unique server name using UUID
+ # TODO: Let the users specify the server name
+ server_name = f'sse_{uuid4().hex[:8]}'
+ mcp_servers[server_name] = server_config
+ _logger.debug(
+ f'Added custom SSE server: {server_name} for {sse_server.url}'
+ )
+
+ def _add_custom_shttp_servers(
+ self, mcp_servers: dict[str, Any], shttp_servers: list
+ ) -> None:
+ """Add custom SHTTP MCP servers from user configuration.
+
+ Args:
+ mcp_servers: Dictionary to add servers to
+ shttp_servers: List of SHTTP server configurations
+ """
+ for shttp_server in shttp_servers:
+ server_config = {
+ 'url': shttp_server.url,
+ 'transport': 'streamable-http',
+ }
+ if shttp_server.api_key:
+ server_config['headers'] = {
+ 'Authorization': f'Bearer {shttp_server.api_key}'
+ }
+ if shttp_server.timeout:
+ server_config['timeout'] = shttp_server.timeout
+
+ # Generate unique server name using UUID
+ # TODO: Let the users specify the server name
+ server_name = f'shttp_{uuid4().hex[:8]}'
+ mcp_servers[server_name] = server_config
+ _logger.debug(
+ f'Added custom SHTTP server: {server_name} for {shttp_server.url}'
+ )
+
+ def _add_custom_stdio_servers(
+ self, mcp_servers: dict[str, Any], stdio_servers: list
+ ) -> None:
+ """Add custom STDIO MCP servers from user configuration.
+
+ Args:
+ mcp_servers: Dictionary to add servers to
+ stdio_servers: List of STDIO server configurations
+ """
+ for stdio_server in stdio_servers:
+ server_config = {
+ 'command': stdio_server.command,
+ 'args': stdio_server.args,
+ }
+ if stdio_server.env:
+ server_config['env'] = stdio_server.env
+
+ # STDIO servers have an explicit name field
+ mcp_servers[stdio_server.name] = server_config
+ _logger.debug(f'Added custom STDIO server: {stdio_server.name}')
+
+ def _merge_custom_mcp_config(
+ self, mcp_servers: dict[str, Any], user: UserInfo
+ ) -> None:
+ """Merge custom MCP configuration from user settings.
+
+ Args:
+ mcp_servers: Dictionary to add servers to
+ user: User information containing custom MCP config
+ """
+ if not user.mcp_config:
+ return
+
+ try:
+ sse_count = len(user.mcp_config.sse_servers)
+ shttp_count = len(user.mcp_config.shttp_servers)
+ stdio_count = len(user.mcp_config.stdio_servers)
+
+ _logger.info(
+ f'Loading custom MCP config from user settings: '
+ f'{sse_count} SSE, {shttp_count} SHTTP, {stdio_count} STDIO servers'
+ )
+
+ # Add each type of custom server
+ self._add_custom_sse_servers(mcp_servers, user.mcp_config.sse_servers)
+ self._add_custom_shttp_servers(mcp_servers, user.mcp_config.shttp_servers)
+ self._add_custom_stdio_servers(mcp_servers, user.mcp_config.stdio_servers)
+
+ _logger.info(
+ f'Successfully merged custom MCP config: added {sse_count} SSE, '
+ f'{shttp_count} SHTTP, and {stdio_count} STDIO servers'
+ )
+
+ except Exception as e:
+ _logger.error(
+ f'Error loading custom MCP config from user settings: {e}',
+ exc_info=True,
+ )
+ # Continue with system config only, don't fail conversation startup
+ _logger.warning(
+ 'Continuing with system-generated MCP config only due to custom config error'
+ )
+
async def _configure_llm_and_mcp(
self, user: UserInfo, llm_model: str | None
) -> tuple[LLM, dict]:
@@ -590,56 +796,20 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
Tuple of (configured LLM instance, MCP config dictionary)
"""
# Configure LLM
- model = llm_model or user.llm_model
- base_url = user.llm_base_url
- if model and model.startswith('openhands/'):
- base_url = user.llm_base_url or self.openhands_provider_base_url
- llm = LLM(
- model=model,
- base_url=base_url,
- api_key=user.llm_api_key,
- usage_id='agent',
- )
+ llm = self._configure_llm(user, llm_model)
- # Configure MCP
- mcp_config: dict[str, Any] = {}
- if self.web_url:
- mcp_url = f'{self.web_url}/mcp/mcp'
- mcp_config = {
- 'default': {
- 'url': mcp_url,
- }
- }
+ # Configure MCP - SDK expects format: {'mcpServers': {'server_name': {...}}}
+ mcp_servers: dict[str, Any] = {}
- # Add API key if available
- mcp_api_key = await self.user_context.get_mcp_api_key()
- if mcp_api_key:
- mcp_config['default']['headers'] = {
- 'X-Session-API-Key': mcp_api_key,
- }
+ # Add system-generated servers (default + tavily)
+ await self._add_system_mcp_servers(mcp_servers, user)
- # Get the actual API key values, prioritizing user's key over service key
- user_search_key = None
- if user.search_api_key:
- key_value = user.search_api_key.get_secret_value()
- if key_value and key_value.strip():
- user_search_key = key_value
+ # Merge custom servers from user settings
+ self._merge_custom_mcp_config(mcp_servers, user)
- service_tavily_key = None
- if self.tavily_api_key:
- # tavily_api_key is already a string (extracted in the factory method)
- if self.tavily_api_key.strip():
- service_tavily_key = self.tavily_api_key
-
- tavily_api_key = user_search_key or service_tavily_key
-
- if tavily_api_key:
- _logger.info('Adding search engine to MCP config')
- mcp_config['tavily'] = {
- 'url': f'https://mcp.tavily.com/mcp/?tavilyApiKey={tavily_api_key}'
- }
- else:
- _logger.info('No search engine API key found, skipping search engine')
+ # Wrap in the mcpServers structure required by the SDK
+ mcp_config = {'mcpServers': mcp_servers} if mcp_servers else {}
+ _logger.info(f'Final MCP configuration: {mcp_config}')
return llm, mcp_config
@@ -650,6 +820,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
system_message_suffix: str | None,
mcp_config: dict,
condenser_max_size: int | None,
+ secrets: dict | None = None,
) -> Agent:
"""Create an agent with appropriate tools and context based on agent type.
@@ -659,6 +830,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
system_message_suffix: Optional suffix for system messages
mcp_config: MCP configuration dictionary
condenser_max_size: condenser_max_size setting
+ secrets: Optional dictionary of secrets for authentication
Returns:
Configured Agent instance with context
@@ -687,7 +859,9 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
)
# Add agent context
- agent_context = AgentContext(system_message_suffix=system_message_suffix)
+ agent_context = AgentContext(
+ system_message_suffix=system_message_suffix, secrets=secrets
+ )
agent = agent.model_copy(update={'agent_context': agent_context})
return agent
@@ -745,8 +919,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,
@@ -784,7 +958,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Create agent with context
agent = self._create_agent_with_context(
- llm, agent_type, system_message_suffix, mcp_config, user.condenser_max_size
+ llm,
+ agent_type,
+ system_message_suffix,
+ mcp_config,
+ user.condenser_max_size,
+ secrets=secrets,
)
# Finalize and return the conversation request
diff --git a/openhands/app_server/app_conversation/skill_loader.py b/openhands/app_server/app_conversation/skill_loader.py
index d8fca7cfc3..d237ff0542 100644
--- a/openhands/app_server/app_conversation/skill_loader.py
+++ b/openhands/app_server/app_conversation/skill_loader.py
@@ -14,6 +14,9 @@ from pathlib import Path
import openhands
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
+from openhands.app_server.user.user_context import UserContext
+from openhands.integrations.provider import ProviderType
+from openhands.integrations.service_types import AuthenticationError
from openhands.sdk.context.skills import Skill
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
@@ -119,6 +122,96 @@ def _determine_repo_root(working_dir: str, selected_repository: str | None) -> s
return working_dir
+async def _is_gitlab_repository(repo_name: str, user_context: UserContext) -> bool:
+ """Check if a repository is hosted on GitLab.
+
+ Args:
+ repo_name: Repository name (e.g., "gitlab.com/org/repo" or "org/repo")
+ user_context: UserContext to access provider handler
+
+ Returns:
+ True if the repository is hosted on GitLab, False otherwise
+ """
+ try:
+ provider_handler = await user_context.get_provider_handler() # type: ignore[attr-defined]
+ repository = await provider_handler.verify_repo_provider(repo_name)
+ return repository.git_provider == ProviderType.GITLAB
+ except Exception:
+ # If we can't determine the provider, assume it's not GitLab
+ # This is a safe fallback since we'll just use the default .openhands
+ return False
+
+
+async def _is_azure_devops_repository(
+ repo_name: str, user_context: UserContext
+) -> bool:
+ """Check if a repository is hosted on Azure DevOps.
+
+ Args:
+ repo_name: Repository name (e.g., "org/project/repo")
+ user_context: UserContext to access provider handler
+
+ Returns:
+ True if the repository is hosted on Azure DevOps, False otherwise
+ """
+ try:
+ provider_handler = await user_context.get_provider_handler() # type: ignore[attr-defined]
+ repository = await provider_handler.verify_repo_provider(repo_name)
+ return repository.git_provider == ProviderType.AZURE_DEVOPS
+ except Exception:
+ # If we can't determine the provider, assume it's not Azure DevOps
+ return False
+
+
+async def _determine_org_repo_path(
+ selected_repository: str, user_context: UserContext
+) -> tuple[str, str]:
+ """Determine the organization repository path and organization name.
+
+ Args:
+ selected_repository: Repository name (e.g., 'owner/repo' or 'org/project/repo')
+ user_context: UserContext to access provider handler
+
+ Returns:
+ Tuple of (org_repo_path, org_name) where:
+ - org_repo_path: Full path to org-level config repo
+ - org_name: Organization name extracted from repository
+
+ Examples:
+ - GitHub/Bitbucket: ('owner/.openhands', 'owner')
+ - GitLab: ('owner/openhands-config', 'owner')
+ - Azure DevOps: ('org/openhands-config/openhands-config', 'org')
+ """
+ repo_parts = selected_repository.split('/')
+
+ # Determine repository type
+ is_azure_devops = await _is_azure_devops_repository(
+ selected_repository, user_context
+ )
+ is_gitlab = await _is_gitlab_repository(selected_repository, user_context)
+
+ # Extract the org/user name
+ # Azure DevOps format: org/project/repo (3 parts) - extract org (first part)
+ # GitHub/GitLab/Bitbucket format: owner/repo (2 parts) - extract owner (first part)
+ if is_azure_devops and len(repo_parts) >= 3:
+ org_name = repo_parts[0] # Get org from org/project/repo
+ else:
+ org_name = repo_parts[-2] # Get owner from owner/repo
+
+ # For GitLab and Azure DevOps, use openhands-config (since .openhands is not a valid repo name)
+ # For other providers, use .openhands
+ if is_gitlab:
+ org_openhands_repo = f'{org_name}/openhands-config'
+ elif is_azure_devops:
+ # Azure DevOps format: org/project/repo
+ # For org-level config, use: org/openhands-config/openhands-config
+ org_openhands_repo = f'{org_name}/openhands-config/openhands-config'
+ else:
+ org_openhands_repo = f'{org_name}/.openhands'
+
+ return org_openhands_repo, org_name
+
+
async def _read_file_from_workspace(
workspace: AsyncRemoteWorkspace, file_path: str, working_dir: str
) -> str | None:
@@ -322,6 +415,248 @@ async def load_repo_skills(
return []
+def _validate_repository_for_org_skills(selected_repository: str) -> bool:
+ """Validate that the repository path has sufficient parts for org skills.
+
+ Args:
+ selected_repository: Repository name (e.g., 'owner/repo')
+
+ Returns:
+ True if repository is valid for org skills loading, False otherwise
+ """
+ repo_parts = selected_repository.split('/')
+ if len(repo_parts) < 2:
+ _logger.warning(
+ f'Repository path has insufficient parts ({len(repo_parts)} < 2), skipping org-level skills'
+ )
+ return False
+ return True
+
+
+async def _get_org_repository_url(
+ org_openhands_repo: str, user_context: UserContext
+) -> str | None:
+ """Get authenticated Git URL for organization repository.
+
+ Args:
+ org_openhands_repo: Organization repository path
+ user_context: UserContext to access authentication
+
+ Returns:
+ Authenticated Git URL if successful, None otherwise
+ """
+ try:
+ remote_url = await user_context.get_authenticated_git_url(org_openhands_repo)
+ return remote_url
+ except AuthenticationError as e:
+ _logger.debug(
+ f'org-level skill directory {org_openhands_repo} not found: {str(e)}'
+ )
+ return None
+ except Exception as e:
+ _logger.debug(
+ f'Failed to get authenticated URL for {org_openhands_repo}: {str(e)}'
+ )
+ return None
+
+
+async def _clone_org_repository(
+ workspace: AsyncRemoteWorkspace,
+ remote_url: str,
+ org_repo_dir: str,
+ working_dir: str,
+ org_openhands_repo: str,
+) -> bool:
+ """Clone organization repository to temporary directory.
+
+ Args:
+ workspace: AsyncRemoteWorkspace to execute commands
+ remote_url: Authenticated Git URL
+ org_repo_dir: Temporary directory path for cloning
+ working_dir: Working directory for command execution
+ org_openhands_repo: Organization repository path (for logging)
+
+ Returns:
+ True if clone successful, False otherwise
+ """
+ _logger.debug(f'Creating temporary directory for org repo: {org_repo_dir}')
+
+ # Clone the repo (shallow clone for efficiency)
+ clone_cmd = f'GIT_TERMINAL_PROMPT=0 git clone --depth 1 {remote_url} {org_repo_dir}'
+ _logger.info('Executing clone command for org-level repo')
+
+ result = await workspace.execute_command(clone_cmd, working_dir, timeout=120.0)
+
+ if result.exit_code != 0:
+ _logger.info(
+ f'No org-level skills found at {org_openhands_repo} (exit_code: {result.exit_code})'
+ )
+ _logger.debug(f'Clone command output: {result.stderr}')
+ return False
+
+ _logger.info(f'Successfully cloned org-level skills from {org_openhands_repo}')
+ return True
+
+
+async def _load_skills_from_org_directories(
+ workspace: AsyncRemoteWorkspace, org_repo_dir: str, working_dir: str
+) -> tuple[list[Skill], list[Skill]]:
+ """Load skills from both skills/ and microagents/ directories in org repo.
+
+ Args:
+ workspace: AsyncRemoteWorkspace to execute commands
+ org_repo_dir: Path to cloned organization repository
+ working_dir: Working directory for command execution
+
+ Returns:
+ Tuple of (skills_dir_skills, microagents_dir_skills)
+ """
+ skills_dir = f'{org_repo_dir}/skills'
+ skills_dir_skills = await _find_and_load_skill_md_files(
+ workspace, skills_dir, working_dir
+ )
+
+ microagents_dir = f'{org_repo_dir}/microagents'
+ microagents_dir_skills = await _find_and_load_skill_md_files(
+ workspace, microagents_dir, working_dir
+ )
+
+ return skills_dir_skills, microagents_dir_skills
+
+
+def _merge_org_skills_with_precedence(
+ skills_dir_skills: list[Skill], microagents_dir_skills: list[Skill]
+) -> list[Skill]:
+ """Merge skills from skills/ and microagents/ with proper precedence.
+
+ Precedence: skills/ > microagents/ (skills/ overrides microagents/ for same name)
+
+ Args:
+ skills_dir_skills: Skills loaded from skills/ directory
+ microagents_dir_skills: Skills loaded from microagents/ directory
+
+ Returns:
+ Merged list of skills with proper precedence applied
+ """
+ skills_by_name = {}
+ for skill in microagents_dir_skills + skills_dir_skills:
+ # Later sources (skills/) override earlier ones (microagents/)
+ if skill.name not in skills_by_name:
+ skills_by_name[skill.name] = skill
+ else:
+ _logger.debug(
+ f'Overriding org skill "{skill.name}" from microagents/ with skills/'
+ )
+ skills_by_name[skill.name] = skill
+
+ return list(skills_by_name.values())
+
+
+async def _cleanup_org_repository(
+ workspace: AsyncRemoteWorkspace, org_repo_dir: str, working_dir: str
+) -> None:
+ """Clean up cloned organization repository directory.
+
+ Args:
+ workspace: AsyncRemoteWorkspace to execute commands
+ org_repo_dir: Path to cloned organization repository
+ working_dir: Working directory for command execution
+ """
+ cleanup_cmd = f'rm -rf {org_repo_dir}'
+ await workspace.execute_command(cleanup_cmd, working_dir, timeout=10.0)
+
+
+async def load_org_skills(
+ workspace: AsyncRemoteWorkspace,
+ selected_repository: str | None,
+ working_dir: str,
+ user_context: UserContext,
+) -> list[Skill]:
+ """Load organization-level skills from the organization repository.
+
+ For example, if the repository is github.com/acme-co/api, this will check if
+ github.com/acme-co/.openhands exists. If it does, it will clone it and load
+ the skills from both the ./skills/ and ./microagents/ folders.
+
+ For GitLab repositories, it will use openhands-config instead of .openhands
+ since GitLab doesn't support repository names starting with non-alphanumeric
+ characters.
+
+ For Azure DevOps repositories, it will use org/openhands-config/openhands-config
+ format to match Azure DevOps's three-part repository structure (org/project/repo).
+
+ Args:
+ workspace: AsyncRemoteWorkspace to execute commands in the sandbox
+ selected_repository: Repository name (e.g., 'owner/repo') or None
+ working_dir: Working directory path
+ user_context: UserContext to access provider handler and authentication
+
+ Returns:
+ List of Skill objects loaded from organization repository.
+ Returns empty list if no repository selected or on errors.
+ """
+ if not selected_repository:
+ return []
+
+ try:
+ _logger.debug(
+ f'Starting org-level skill loading for repository: {selected_repository}'
+ )
+
+ # Validate repository path
+ if not _validate_repository_for_org_skills(selected_repository):
+ return []
+
+ # Determine organization repository path
+ org_openhands_repo, org_name = await _determine_org_repo_path(
+ selected_repository, user_context
+ )
+
+ _logger.info(f'Checking for org-level skills at {org_openhands_repo}')
+
+ # Get authenticated URL for org repository
+ remote_url = await _get_org_repository_url(org_openhands_repo, user_context)
+ if not remote_url:
+ return []
+
+ # Clone the organization repository
+ org_repo_dir = f'{working_dir}/_org_openhands_{org_name}'
+ clone_success = await _clone_org_repository(
+ workspace, remote_url, org_repo_dir, working_dir, org_openhands_repo
+ )
+ if not clone_success:
+ return []
+
+ # Load skills from both skills/ and microagents/ directories
+ (
+ skills_dir_skills,
+ microagents_dir_skills,
+ ) = await _load_skills_from_org_directories(
+ workspace, org_repo_dir, working_dir
+ )
+
+ # Merge skills with proper precedence
+ loaded_skills = _merge_org_skills_with_precedence(
+ skills_dir_skills, microagents_dir_skills
+ )
+
+ _logger.info(
+ f'Loaded {len(loaded_skills)} skills from org-level repository {org_openhands_repo}: {[s.name for s in loaded_skills]}'
+ )
+
+ # Clean up the org repo directory
+ await _cleanup_org_repository(workspace, org_repo_dir, working_dir)
+
+ return loaded_skills
+
+ except AuthenticationError as e:
+ _logger.debug(f'org-level skill directory not found: {str(e)}')
+ return []
+ except Exception as e:
+ _logger.warning(f'Failed to load org-level skills: {str(e)}')
+ return []
+
+
def merge_skills(skill_lists: list[list[Skill]]) -> list[Skill]:
"""Merge multiple skill lists, avoiding duplicates by name.
diff --git a/openhands/app_server/sandbox/docker_sandbox_spec_service.py b/openhands/app_server/sandbox/docker_sandbox_spec_service.py
index b7a9553e7e..063b4e8a96 100644
--- a/openhands/app_server/sandbox/docker_sandbox_spec_service.py
+++ b/openhands/app_server/sandbox/docker_sandbox_spec_service.py
@@ -14,9 +14,9 @@ from openhands.app_server.sandbox.sandbox_spec_models import (
SandboxSpecInfo,
)
from openhands.app_server.sandbox.sandbox_spec_service import (
- AGENT_SERVER_IMAGE,
SandboxSpecService,
SandboxSpecServiceInjector,
+ get_default_agent_server_image,
)
from openhands.app_server.services.injector import InjectorState
@@ -34,7 +34,7 @@ def get_docker_client() -> docker.DockerClient:
def get_default_sandbox_specs():
return [
SandboxSpecInfo(
- id=AGENT_SERVER_IMAGE,
+ id=get_default_agent_server_image(),
command=['--port', '8000'],
initial_env={
'OPENVSCODE_SERVER_ROOT': '/openhands/.openvscode-server',
diff --git a/openhands/app_server/sandbox/process_sandbox_spec_service.py b/openhands/app_server/sandbox/process_sandbox_spec_service.py
index b5476669f7..4e2e88a2f9 100644
--- a/openhands/app_server/sandbox/process_sandbox_spec_service.py
+++ b/openhands/app_server/sandbox/process_sandbox_spec_service.py
@@ -10,9 +10,9 @@ from openhands.app_server.sandbox.sandbox_spec_models import (
SandboxSpecInfo,
)
from openhands.app_server.sandbox.sandbox_spec_service import (
- AGENT_SERVER_IMAGE,
SandboxSpecService,
SandboxSpecServiceInjector,
+ get_default_agent_server_image,
)
from openhands.app_server.services.injector import InjectorState
@@ -20,7 +20,7 @@ from openhands.app_server.services.injector import InjectorState
def get_default_sandbox_specs():
return [
SandboxSpecInfo(
- id=AGENT_SERVER_IMAGE,
+ id=get_default_agent_server_image(),
command=['python', '-m', 'openhands.agent_server'],
initial_env={
# VSCode disabled for now
diff --git a/openhands/app_server/sandbox/remote_sandbox_service.py b/openhands/app_server/sandbox/remote_sandbox_service.py
index 5ee42218dc..035870cd45 100644
--- a/openhands/app_server/sandbox/remote_sandbox_service.py
+++ b/openhands/app_server/sandbox/remote_sandbox_service.py
@@ -303,6 +303,31 @@ class RemoteSandboxService(SandboxService):
self, session_api_key: str
) -> Union[SandboxInfo, None]:
"""Get a single sandbox by session API key."""
+ # TODO: We should definitely refactor this and store the session_api_key in
+ # the v1_remote_sandbox table
+ try:
+ response = await self._send_runtime_api_request(
+ 'GET',
+ '/list',
+ )
+ response.raise_for_status()
+ content = response.json()
+ for runtime in content['runtimes']:
+ if session_api_key == runtime['session_api_key']:
+ query = await self._secure_select()
+ query = query.filter(
+ StoredRemoteSandbox.id == runtime.get('session_id')
+ )
+ result = await self.db_session.execute(query)
+ sandbox = result.first()
+ if sandbox is None:
+ raise ValueError('sandbox_not_found')
+ return await self._to_sandbox_info(sandbox, runtime)
+ except Exception:
+ _logger.exception(
+ 'Error getting sandbox from session_api_key', stack_info=True
+ )
+
# Get all stored sandboxes for the current user
stmt = await self._secure_select()
result = await self.db_session.execute(stmt)
diff --git a/openhands/app_server/sandbox/remote_sandbox_spec_service.py b/openhands/app_server/sandbox/remote_sandbox_spec_service.py
index a2a7c58099..6228338d72 100644
--- a/openhands/app_server/sandbox/remote_sandbox_spec_service.py
+++ b/openhands/app_server/sandbox/remote_sandbox_spec_service.py
@@ -10,9 +10,9 @@ from openhands.app_server.sandbox.sandbox_spec_models import (
SandboxSpecInfo,
)
from openhands.app_server.sandbox.sandbox_spec_service import (
- AGENT_SERVER_IMAGE,
SandboxSpecService,
SandboxSpecServiceInjector,
+ get_default_agent_server_image,
)
from openhands.app_server.services.injector import InjectorState
@@ -20,7 +20,7 @@ from openhands.app_server.services.injector import InjectorState
def get_default_sandbox_specs():
return [
SandboxSpecInfo(
- id=AGENT_SERVER_IMAGE,
+ id=get_default_agent_server_image(),
command=['/usr/local/bin/openhands-agent-server', '--port', '60000'],
initial_env={
'OPENVSCODE_SERVER_ROOT': '/openhands/.openvscode-server',
diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py
index 3f1ff6b4d1..fe9d1653a9 100644
--- a/openhands/app_server/sandbox/sandbox_spec_service.py
+++ b/openhands/app_server/sandbox/sandbox_spec_service.py
@@ -1,4 +1,5 @@
import asyncio
+import os
from abc import ABC, abstractmethod
from openhands.app_server.errors import SandboxError
@@ -11,7 +12,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
-AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:37c4b35-python'
+AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:97652be-python'
class SandboxSpecService(ABC):
@@ -57,3 +58,11 @@ class SandboxSpecServiceInjector(
DiscriminatedUnionMixin, Injector[SandboxSpecService], ABC
):
pass
+
+
+def get_default_agent_server_image():
+ agent_server_image_repository = os.getenv('AGENT_SERVER_IMAGE_REPOSITORY')
+ agent_server_image_tag = os.getenv('AGENT_SERVER_IMAGE_TAG')
+ if agent_server_image_repository and agent_server_image_tag:
+ return f'{agent_server_image_repository}:{agent_server_image_tag}'
+ return AGENT_SERVER_IMAGE
diff --git a/openhands/app_server/user/auth_user_context.py b/openhands/app_server/user/auth_user_context.py
index 8ea95036f4..4d64888427 100644
--- a/openhands/app_server/user/auth_user_context.py
+++ b/openhands/app_server/user/auth_user_context.py
@@ -14,7 +14,7 @@ from openhands.integrations.provider import (
ProviderHandler,
ProviderType,
)
-from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
+from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth, get_user_auth
USER_AUTH_ATTR = 'user_auth'
diff --git a/openhands/app_server/user/specifiy_user_context.py b/openhands/app_server/user/specifiy_user_context.py
index 87e2d74da2..51e6233972 100644
--- a/openhands/app_server/user/specifiy_user_context.py
+++ b/openhands/app_server/user/specifiy_user_context.py
@@ -6,7 +6,7 @@ from openhands.app_server.errors import OpenHandsError
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.user.user_models import UserInfo
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
-from openhands.sdk.conversation.secret_source import SecretSource
+from openhands.sdk.secret import SecretSource
@dataclass(frozen=True)
diff --git a/openhands/app_server/user/user_context.py b/openhands/app_server/user/user_context.py
index 02c0ba8aaf..4102df5cf9 100644
--- a/openhands/app_server/user/user_context.py
+++ b/openhands/app_server/user/user_context.py
@@ -5,7 +5,7 @@ from openhands.app_server.user.user_models import (
UserInfo,
)
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
-from openhands.sdk.conversation.secret_source import SecretSource
+from openhands.sdk.secret import SecretSource
from openhands.sdk.utils.models import DiscriminatedUnionMixin
diff --git a/openhands/memory/conversation_memory.py b/openhands/memory/conversation_memory.py
index 5ff6ec7e58..5ae1a2cd71 100644
--- a/openhands/memory/conversation_memory.py
+++ b/openhands/memory/conversation_memory.py
@@ -76,6 +76,7 @@ class ConversationMemory:
self,
condensed_history: list[Event],
initial_user_action: MessageAction,
+ forgotten_event_ids: set[int] | None = None,
max_message_chars: int | None = None,
vision_is_active: bool = False,
) -> list[Message]:
@@ -85,16 +86,23 @@ class ConversationMemory:
Args:
condensed_history: The condensed history of events to convert
+ initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly.
+ forgotten_event_ids: Set of event IDs that have been forgotten/condensed. If the initial user action's ID
+ is in this set, it will not be re-inserted to prevent re-execution of old instructions.
max_message_chars: The maximum number of characters in the content of an event included
in the prompt to the LLM. Larger observations are truncated.
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included.
- initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly.
"""
events = condensed_history
+ # Default to empty set if not provided
+ if forgotten_event_ids is None:
+ forgotten_event_ids = set()
# Ensure the event list starts with SystemMessageAction, then MessageAction(source='user')
self._ensure_system_message(events)
- self._ensure_initial_user_message(events, initial_user_action)
+ self._ensure_initial_user_message(
+ events, initial_user_action, forgotten_event_ids
+ )
# log visual browsing status
logger.debug(f'Visual browsing: {self.agent_config.enable_som_visual_browsing}')
@@ -827,9 +835,23 @@ class ConversationMemory:
)
def _ensure_initial_user_message(
- self, events: list[Event], initial_user_action: MessageAction
+ self,
+ events: list[Event],
+ initial_user_action: MessageAction,
+ forgotten_event_ids: set[int],
) -> None:
- """Checks if the second event is a user MessageAction and inserts the provided one if needed."""
+ """Checks if the second event is a user MessageAction and inserts the provided one if needed.
+
+ IMPORTANT: If the initial user action has been condensed (its ID is in forgotten_event_ids),
+ we do NOT re-insert it. This prevents old instructions from being re-executed after
+ conversation condensation. The condensation summary already contains the context of
+ what was requested and completed.
+
+ Args:
+ events: The list of events to modify in-place
+ initial_user_action: The initial user message action from the full history
+ forgotten_event_ids: Set of event IDs that have been forgotten/condensed
+ """
if (
not events
): # Should have system message from previous step, but safety check
@@ -837,6 +859,17 @@ class ConversationMemory:
# Or raise? Let's log for now, _ensure_system_message should handle this.
return
+ # Check if the initial user action has been condensed/forgotten.
+ # If so, we should NOT re-insert it to prevent re-execution of old instructions.
+ # The condensation summary already contains the context of what was requested.
+ initial_user_action_id = initial_user_action.id
+ if initial_user_action_id in forgotten_event_ids:
+ logger.info(
+ f'Initial user action (id={initial_user_action_id}) has been condensed. '
+ 'Not re-inserting to prevent re-execution of old instructions.'
+ )
+ return
+
# We expect events[0] to be SystemMessageAction after _ensure_system_message
if len(events) == 1:
# Only system message exists
diff --git a/openhands/memory/view.py b/openhands/memory/view.py
index 87a20b6340..81dd8bab5d 100644
--- a/openhands/memory/view.py
+++ b/openhands/memory/view.py
@@ -18,6 +18,8 @@ class View(BaseModel):
events: list[Event]
unhandled_condensation_request: bool = False
+ # Set of event IDs that have been forgotten/condensed
+ forgotten_event_ids: set[int] = set()
def __len__(self) -> int:
return len(self.events)
@@ -90,4 +92,5 @@ class View(BaseModel):
return View(
events=kept_events,
unhandled_condensation_request=unhandled_condensation_request,
+ forgotten_event_ids=forgotten_event_ids,
)
diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py
index 5eb5429f71..c7a332166b 100644
--- a/openhands/runtime/base.py
+++ b/openhands/runtime/base.py
@@ -76,6 +76,8 @@ from openhands.utils.async_utils import (
call_sync_from_async,
)
+DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true'
+
def _default_env_vars(sandbox_config: SandboxConfig) -> dict[str, str]:
ret = {}
@@ -153,9 +155,11 @@ class Runtime(FileEditRuntimeMixin):
self.plugins = (
copy.deepcopy(plugins) if plugins is not None and len(plugins) > 0 else []
)
+
# add VSCode plugin if not in headless mode
- if not headless_mode:
+ if not headless_mode and not DISABLE_VSCODE_PLUGIN:
self.plugins.append(VSCodeRequirement())
+ logger.info(f'Loaded plugins for runtime {self.sid}: {self.plugins}')
self.status_callback = status_callback
self.attach_to_existing = attach_to_existing
diff --git a/openhands/runtime/browser/browser_env.py b/openhands/runtime/browser/browser_env.py
index 55e3ce1890..c8d09d9c2b 100644
--- a/openhands/runtime/browser/browser_env.py
+++ b/openhands/runtime/browser/browser_env.py
@@ -1,8 +1,10 @@
import atexit
import json
import multiprocessing
+import os
import time
import uuid
+from pathlib import Path
import browsergym.core # noqa F401 (we register the openended task as a gym environment)
import gymnasium as gym
@@ -67,6 +69,16 @@ class BrowserEnv:
raise BrowserInitException('Failed to start browser environment.')
def browser_process(self) -> None:
+ def _is_local_runtime() -> bool:
+ runtime_flag = os.getenv('RUNTIME', '').lower()
+ return runtime_flag == 'local'
+
+ # Default Playwright cache for local runs only; do not override in docker
+ if _is_local_runtime() and 'PLAYWRIGHT_BROWSERS_PATH' not in os.environ:
+ os.environ['PLAYWRIGHT_BROWSERS_PATH'] = str(
+ Path.home() / '.cache' / 'playwright'
+ )
+
if self.eval_mode:
assert self.browsergym_eval_env is not None
logger.info('Initializing browser env for web browsing evaluation.')
@@ -87,6 +99,11 @@ class BrowserEnv:
)
env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000)
else:
+ downloads_path = os.getenv('BROWSERGYM_DOWNLOAD_DIR')
+ if not downloads_path and _is_local_runtime():
+ downloads_path = str(Path.home() / '.cache' / 'browsergym-downloads')
+ if not downloads_path:
+ downloads_path = '/workspace/.downloads/'
env = gym.make(
'browsergym/openended',
task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'},
@@ -96,7 +113,7 @@ class BrowserEnv:
tags_to_mark='all',
timeout=100000,
pw_context_kwargs={'accept_downloads': True},
- pw_chromium_kwargs={'downloads_path': '/workspace/.downloads/'},
+ pw_chromium_kwargs={'downloads_path': downloads_path},
)
obs, info = env.reset()
diff --git a/openhands/runtime/impl/kubernetes/README.md b/openhands/runtime/impl/kubernetes/README.md
index d16247389d..36b7452d1e 100644
--- a/openhands/runtime/impl/kubernetes/README.md
+++ b/openhands/runtime/impl/kubernetes/README.md
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
- runtime_container_image = "docker.openhands.dev/openhands/runtime:0.62-nikolaik"
+ runtime_container_image = "docker.openhands.dev/openhands/runtime:1.0-nikolaik"
```
#### Additional Kubernetes Options
diff --git a/openhands/runtime/impl/local/local_runtime.py b/openhands/runtime/impl/local/local_runtime.py
index ed8d26996a..cf81b222eb 100644
--- a/openhands/runtime/impl/local/local_runtime.py
+++ b/openhands/runtime/impl/local/local_runtime.py
@@ -45,6 +45,8 @@ from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.http_session import httpx_verify_option
from openhands.utils.tenacity_stop import stop_if_should_exit
+DISABLE_VSCODE_PLUGIN = os.getenv('DISABLE_VSCODE_PLUGIN', 'false').lower() == 'true'
+
@dataclass
class ActionExecutionServerInfo:
@@ -247,7 +249,22 @@ class LocalRuntime(ActionExecutionClient):
)
else:
# Set up workspace directory
+ # For local runtime, prefer a stable host path over /workspace defaults.
+ if (
+ self.config.workspace_base is None
+ and self.config.runtime
+ and self.config.runtime.lower() == 'local'
+ ):
+ env_base = os.getenv('LOCAL_WORKSPACE_BASE')
+ if env_base:
+ self.config.workspace_base = os.path.abspath(env_base)
+ else:
+ self.config.workspace_base = os.path.abspath(
+ os.path.join(os.getcwd(), 'workspace', 'local')
+ )
+
if self.config.workspace_base is not None:
+ os.makedirs(self.config.workspace_base, exist_ok=True)
logger.warning(
f'Workspace base path is set to {self.config.workspace_base}. '
'It will be used as the path for the agent to run in. '
@@ -406,7 +423,7 @@ class LocalRuntime(ActionExecutionClient):
plugins = _get_plugins(config)
# Copy the logic from Runtime where we add a VSCodePlugin on init if missing
- if not headless_mode:
+ if not headless_mode and not DISABLE_VSCODE_PLUGIN:
plugins.append(VSCodeRequirement())
for _ in range(initial_num_warm_servers):
diff --git a/openhands/storage/settings/file_settings_store.py b/openhands/storage/settings/file_settings_store.py
index 3acedeb16f..5b43bf6b80 100644
--- a/openhands/storage/settings/file_settings_store.py
+++ b/openhands/storage/settings/file_settings_store.py
@@ -21,6 +21,11 @@ class FileSettingsStore(SettingsStore):
json_str = await call_sync_from_async(self.file_store.read, self.path)
kwargs = json.loads(json_str)
settings = Settings(**kwargs)
+
+ # Turn on V1 in OpenHands
+ # We can simplify / remove this as part of V0 removal
+ settings.v1_enabled = True
+
return settings
except FileNotFoundError:
return None
diff --git a/openhands/utils/llm.py b/openhands/utils/llm.py
index 9eeb7c5393..1686babd96 100644
--- a/openhands/utils/llm.py
+++ b/openhands/utils/llm.py
@@ -90,4 +90,4 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
]
model_list = clarifai_models + model_list
- return list(sorted(set(model_list)))
+ return sorted(set(model_list))
diff --git a/poetry.lock b/poetry.lock
index 06e67f3ca2..23789d3285 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -5675,14 +5675,14 @@ utils = ["numpydoc"]
[[package]]
name = "lmnr"
-version = "0.7.20"
+version = "0.7.24"
description = "Python SDK for Laminar"
optional = false
python-versions = "<4,>=3.10"
groups = ["main"]
files = [
- {file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
- {file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
+ {file = "lmnr-0.7.24-py3-none-any.whl", hash = "sha256:ad780d4a62ece897048811f3368639c240a9329ab31027da8c96545137a3a08a"},
+ {file = "lmnr-0.7.24.tar.gz", hash = "sha256:aa6973f46fc4ba95c9061c1feceb58afc02eb43c9376c21e32545371ff6123d7"},
]
[package.dependencies]
@@ -5705,14 +5705,15 @@ tqdm = ">=4.0"
[package.extras]
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
-all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
+all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
+claude-agent-sdk = ["lmnr-claude-code-proxy (>=0.1.0a5)"]
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
-langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
+langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1,<0.48.0)"]
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
@@ -7379,14 +7380,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
-version = "1.4.1"
+version = "1.6.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
- {file = "openhands_agent_server-1.4.1-py3-none-any.whl", hash = "sha256:1e621d15215a48e2398e23c58a791347f06c215c2344053aeb26b562c34a44ee"},
- {file = "openhands_agent_server-1.4.1.tar.gz", hash = "sha256:03010a5c8d63bbd5b088458eb75308ef16559018140d75a3644ae5bbc3531bbf"},
+ {file = "openhands_agent_server-1.6.0-py3-none-any.whl", hash = "sha256:e6ae865ac3e7a96b234e10a0faad23f6210e025bbf7721cb66bc7a71d160848c"},
+ {file = "openhands_agent_server-1.6.0.tar.gz", hash = "sha256:44ce7694ae2d4bb0666d318ef13e6618bd4dc73022c60354839fe6130e67d02a"},
]
[package.dependencies]
@@ -7403,14 +7404,14 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-sdk"
-version = "1.4.1"
+version = "1.6.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
- {file = "openhands_sdk-1.4.1-py3-none-any.whl", hash = "sha256:70e453eab7f9ab6b705198c2615fdd844b21e14b29d78afaf62724f4a440bcdc"},
- {file = "openhands_sdk-1.4.1.tar.gz", hash = "sha256:37365de25ed57cf8cc2a8003ab4d7a1fe2a40b49c8e8da84a3f1ea2b522eddf2"},
+ {file = "openhands_sdk-1.6.0-py3-none-any.whl", hash = "sha256:94d2f87fb35406373da6728ae2d88584137f9e9b67fa0e940444c72f2e44e7d3"},
+ {file = "openhands_sdk-1.6.0.tar.gz", hash = "sha256:f45742350e3874a7f5b08befc4a9d5adc7e4454f7ab5f8391c519eee3116090f"},
]
[package.dependencies]
@@ -7418,7 +7419,7 @@ deprecation = ">=2.1.0"
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
litellm = ">=1.80.7"
-lmnr = ">=0.7.20"
+lmnr = ">=0.7.24"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
python-json-logger = ">=3.3.0"
@@ -7430,14 +7431,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
-version = "1.4.1"
+version = "1.6.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
- {file = "openhands_tools-1.4.1-py3-none-any.whl", hash = "sha256:8f40189a08bf80eb4a33219ee9ccc528f9c6c4f2d5c9ab807b06c3f3fe21a612"},
- {file = "openhands_tools-1.4.1.tar.gz", hash = "sha256:4c0caf87f520a207d9035191c77b7b5c53eeec996350a24ffaf7f740a6566b22"},
+ {file = "openhands_tools-1.6.0-py3-none-any.whl", hash = "sha256:176556d44186536751b23fe052d3505492cc2afb8d52db20fb7a2cc0169cd57a"},
+ {file = "openhands_tools-1.6.0.tar.gz", hash = "sha256:d07ba31050fd4a7891a4c48388aa53ce9f703e17064ddbd59146d6c77e5980b3"},
]
[package.dependencies]
@@ -16822,4 +16823,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
-content-hash = "c208fcc692f74540f7b6e822136002dd0f079a3d8d1b93227a5bb07a7f4432cb"
+content-hash = "9764f3b69ec8ed35feebd78a826bbc6bfa4ac6d5b56bc999be8bc738b644e538"
diff --git a/pyproject.toml b/pyproject.toml
index 764751f2bc..c70c110dcc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
-version = "0.62.0"
+version = "1.0.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
@@ -116,9 +116,9 @@ pybase62 = "^1.0.0"
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" }
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" }
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d" }
-openhands-sdk = "1.4.1"
-openhands-agent-server = "1.4.1"
-openhands-tools = "1.4.1"
+openhands-sdk = "1.6.0"
+openhands-agent-server = "1.6.0"
+openhands-tools = "1.6.0"
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"
diff --git a/tests/unit/agenthub/test_agents.py b/tests/unit/agenthub/test_agents.py
index 2a90dcb668..09f28e991c 100644
--- a/tests/unit/agenthub/test_agents.py
+++ b/tests/unit/agenthub/test_agents.py
@@ -393,7 +393,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
# 2. The action message
# 3. The observation message
mock_state.history = [initial_user_message, action, observation]
- messages = agent._get_messages(mock_state.history, initial_user_message)
+ messages = agent._get_messages(mock_state.history, initial_user_message, set())
assert len(messages) == 4 # System + initial user + action + observation
assert messages[0].role == 'system' # First message should be the system message
assert (
@@ -404,7 +404,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
# The same should hold if the events are presented out-of-order
mock_state.history = [initial_user_message, observation, action]
- messages = agent._get_messages(mock_state.history, initial_user_message)
+ messages = agent._get_messages(mock_state.history, initial_user_message, set())
assert len(messages) == 4
assert messages[0].role == 'system' # First message should be the system message
assert (
@@ -414,7 +414,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
# If only one of the two events is present, then we should just get the system message
# plus any valid message from the event
mock_state.history = [initial_user_message, action]
- messages = agent._get_messages(mock_state.history, initial_user_message)
+ messages = agent._get_messages(mock_state.history, initial_user_message, set())
assert (
len(messages) == 2
) # System + initial user message, action is waiting for its observation
@@ -422,7 +422,7 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
assert messages[1].role == 'user'
mock_state.history = [initial_user_message, observation]
- messages = agent._get_messages(mock_state.history, initial_user_message)
+ messages = agent._get_messages(mock_state.history, initial_user_message, set())
assert (
len(messages) == 2
) # System + initial user message, observation has no matching action
diff --git a/tests/unit/agenthub/test_prompt_caching.py b/tests/unit/agenthub/test_prompt_caching.py
index 60cc0bb16f..2435b1320a 100644
--- a/tests/unit/agenthub/test_prompt_caching.py
+++ b/tests/unit/agenthub/test_prompt_caching.py
@@ -80,7 +80,7 @@ def test_get_messages(codeact_agent: CodeActAgent):
history.append(message_action_5)
codeact_agent.reset()
- messages = codeact_agent._get_messages(history, message_action_1)
+ messages = codeact_agent._get_messages(history, message_action_1, set())
assert (
len(messages) == 6
@@ -122,7 +122,7 @@ def test_get_messages_prompt_caching(codeact_agent: CodeActAgent):
history.append(message_action_agent)
codeact_agent.reset()
- messages = codeact_agent._get_messages(history, initial_user_message)
+ messages = codeact_agent._get_messages(history, initial_user_message, set())
# Check that only the last two user messages have cache_prompt=True
cached_user_messages = [
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..db31d8d3d2 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
@@ -13,6 +15,7 @@ from openhands.app_server.app_conversation.app_conversation_models import AgentT
from openhands.app_server.app_conversation.app_conversation_service_base import (
AppConversationServiceBase,
)
+from openhands.app_server.sandbox.sandbox_models import SandboxInfo
from openhands.app_server.user.user_context import UserContext
@@ -434,13 +437,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 +740,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
@@ -626,3 +917,350 @@ async def test_configure_git_user_settings_special_characters_in_name(mock_works
mock_workspace.execute_command.assert_any_call(
'git config --global user.name "Test O\'Brien"', '/workspace/project'
)
+
+
+# =============================================================================
+# Tests for load_and_merge_all_skills with org skills
+# =============================================================================
+
+
+class TestLoadAndMergeAllSkillsWithOrgSkills:
+ """Test load_and_merge_all_skills includes organization skills."""
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills'
+ )
+ async def test_load_and_merge_includes_org_skills(
+ self,
+ mock_load_repo,
+ mock_load_org,
+ mock_load_user,
+ mock_load_global,
+ mock_load_sandbox,
+ ):
+ """Test that load_and_merge_all_skills loads and merges org skills."""
+ # Arrange
+ mock_user_context = Mock(spec=UserContext)
+ with patch.object(
+ AppConversationServiceBase,
+ '__abstractmethods__',
+ set(),
+ ):
+ service = AppConversationServiceBase(
+ init_git_in_empty_workspace=True,
+ user_context=mock_user_context,
+ )
+
+ sandbox = Mock(spec=SandboxInfo)
+ sandbox.exposed_urls = []
+ remote_workspace = AsyncMock()
+
+ # Create distinct mock skills for each source
+ sandbox_skill = Mock()
+ sandbox_skill.name = 'sandbox_skill'
+ global_skill = Mock()
+ global_skill.name = 'global_skill'
+ user_skill = Mock()
+ user_skill.name = 'user_skill'
+ org_skill = Mock()
+ org_skill.name = 'org_skill'
+ repo_skill = Mock()
+ repo_skill.name = 'repo_skill'
+
+ mock_load_sandbox.return_value = [sandbox_skill]
+ mock_load_global.return_value = [global_skill]
+ mock_load_user.return_value = [user_skill]
+ mock_load_org.return_value = [org_skill]
+ mock_load_repo.return_value = [repo_skill]
+
+ # Act
+ result = await service.load_and_merge_all_skills(
+ sandbox, remote_workspace, 'owner/repo', '/workspace'
+ )
+
+ # Assert
+ assert len(result) == 5
+ names = {s.name for s in result}
+ assert names == {
+ 'sandbox_skill',
+ 'global_skill',
+ 'user_skill',
+ 'org_skill',
+ 'repo_skill',
+ }
+ mock_load_org.assert_called_once_with(
+ remote_workspace, 'owner/repo', '/workspace', mock_user_context
+ )
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills'
+ )
+ async def test_load_and_merge_org_skills_precedence(
+ self,
+ mock_load_repo,
+ mock_load_org,
+ mock_load_user,
+ mock_load_global,
+ mock_load_sandbox,
+ ):
+ """Test that org skills have correct precedence (higher than user, lower than repo)."""
+ # Arrange
+ mock_user_context = Mock(spec=UserContext)
+ with patch.object(
+ AppConversationServiceBase,
+ '__abstractmethods__',
+ set(),
+ ):
+ service = AppConversationServiceBase(
+ init_git_in_empty_workspace=True,
+ user_context=mock_user_context,
+ )
+
+ sandbox = Mock(spec=SandboxInfo)
+ sandbox.exposed_urls = []
+ remote_workspace = AsyncMock()
+
+ # Create skills with same name but different sources
+ user_skill = Mock()
+ user_skill.name = 'common_skill'
+ user_skill.source = 'user'
+
+ org_skill = Mock()
+ org_skill.name = 'common_skill'
+ org_skill.source = 'org'
+
+ repo_skill = Mock()
+ repo_skill.name = 'common_skill'
+ repo_skill.source = 'repo'
+
+ mock_load_sandbox.return_value = []
+ mock_load_global.return_value = []
+ mock_load_user.return_value = [user_skill]
+ mock_load_org.return_value = [org_skill]
+ mock_load_repo.return_value = [repo_skill]
+
+ # Act
+ result = await service.load_and_merge_all_skills(
+ sandbox, remote_workspace, 'owner/repo', '/workspace'
+ )
+
+ # Assert
+ # Should have only one skill with repo source (highest precedence)
+ assert len(result) == 1
+ assert result[0].source == 'repo'
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills'
+ )
+ async def test_load_and_merge_org_skills_override_user_skills(
+ self,
+ mock_load_repo,
+ mock_load_org,
+ mock_load_user,
+ mock_load_global,
+ mock_load_sandbox,
+ ):
+ """Test that org skills override user skills for same name."""
+ # Arrange
+ mock_user_context = Mock(spec=UserContext)
+ with patch.object(
+ AppConversationServiceBase,
+ '__abstractmethods__',
+ set(),
+ ):
+ service = AppConversationServiceBase(
+ init_git_in_empty_workspace=True,
+ user_context=mock_user_context,
+ )
+
+ sandbox = Mock(spec=SandboxInfo)
+ sandbox.exposed_urls = []
+ remote_workspace = AsyncMock()
+
+ # Create skills with same name
+ user_skill = Mock()
+ user_skill.name = 'shared_skill'
+ user_skill.priority = 'low'
+
+ org_skill = Mock()
+ org_skill.name = 'shared_skill'
+ org_skill.priority = 'high'
+
+ mock_load_sandbox.return_value = []
+ mock_load_global.return_value = []
+ mock_load_user.return_value = [user_skill]
+ mock_load_org.return_value = [org_skill]
+ mock_load_repo.return_value = []
+
+ # Act
+ result = await service.load_and_merge_all_skills(
+ sandbox, remote_workspace, 'owner/repo', '/workspace'
+ )
+
+ # Assert
+ assert len(result) == 1
+ assert result[0].priority == 'high' # Org skill should win
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills'
+ )
+ async def test_load_and_merge_handles_org_skills_failure(
+ self,
+ mock_load_repo,
+ mock_load_org,
+ mock_load_user,
+ mock_load_global,
+ mock_load_sandbox,
+ ):
+ """Test that failure to load org skills doesn't break the overall process."""
+ # Arrange
+ mock_user_context = Mock(spec=UserContext)
+ with patch.object(
+ AppConversationServiceBase,
+ '__abstractmethods__',
+ set(),
+ ):
+ service = AppConversationServiceBase(
+ init_git_in_empty_workspace=True,
+ user_context=mock_user_context,
+ )
+
+ sandbox = Mock(spec=SandboxInfo)
+ sandbox.exposed_urls = []
+ remote_workspace = AsyncMock()
+
+ global_skill = Mock()
+ global_skill.name = 'global_skill'
+ repo_skill = Mock()
+ repo_skill.name = 'repo_skill'
+
+ mock_load_sandbox.return_value = []
+ mock_load_global.return_value = [global_skill]
+ mock_load_user.return_value = []
+ mock_load_org.return_value = [] # Org skills failed/empty
+ mock_load_repo.return_value = [repo_skill]
+
+ # Act
+ result = await service.load_and_merge_all_skills(
+ sandbox, remote_workspace, 'owner/repo', '/workspace'
+ )
+
+ # Assert
+ # Should still have skills from other sources
+ assert len(result) == 2
+ names = {s.name for s in result}
+ assert names == {'global_skill', 'repo_skill'}
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_sandbox_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_global_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_user_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_org_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.load_repo_skills'
+ )
+ async def test_load_and_merge_no_selected_repository(
+ self,
+ mock_load_repo,
+ mock_load_org,
+ mock_load_user,
+ mock_load_global,
+ mock_load_sandbox,
+ ):
+ """Test skill loading when no repository is selected."""
+ # Arrange
+ mock_user_context = Mock(spec=UserContext)
+ with patch.object(
+ AppConversationServiceBase,
+ '__abstractmethods__',
+ set(),
+ ):
+ service = AppConversationServiceBase(
+ init_git_in_empty_workspace=True,
+ user_context=mock_user_context,
+ )
+
+ sandbox = Mock(spec=SandboxInfo)
+ sandbox.exposed_urls = []
+ remote_workspace = AsyncMock()
+
+ global_skill = Mock()
+ global_skill.name = 'global_skill'
+
+ mock_load_sandbox.return_value = []
+ mock_load_global.return_value = [global_skill]
+ mock_load_user.return_value = []
+ mock_load_org.return_value = []
+ mock_load_repo.return_value = []
+
+ # Act
+ result = await service.load_and_merge_all_skills(
+ sandbox, remote_workspace, None, '/workspace'
+ )
+
+ # Assert
+ assert len(result) == 1
+ # Org skills should be called even with None repository
+ mock_load_org.assert_called_once_with(
+ remote_workspace, None, '/workspace', mock_user_context
+ )
diff --git a/tests/unit/app_server/test_app_conversation_skills_endpoint.py b/tests/unit/app_server/test_app_conversation_skills_endpoint.py
new file mode 100644
index 0000000000..e84412bcd0
--- /dev/null
+++ b/tests/unit/app_server/test_app_conversation_skills_endpoint.py
@@ -0,0 +1,503 @@
+"""Unit tests for the V1 skills endpoint in app_conversation_router.
+
+This module tests the GET /{conversation_id}/skills endpoint functionality,
+following TDD best practices with AAA structure.
+"""
+
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+
+import pytest
+from fastapi import status
+
+from openhands.app_server.app_conversation.app_conversation_models import (
+ AppConversation,
+)
+from openhands.app_server.app_conversation.app_conversation_router import (
+ get_conversation_skills,
+)
+from openhands.app_server.app_conversation.app_conversation_service_base import (
+ AppConversationServiceBase,
+)
+from openhands.app_server.sandbox.sandbox_models import (
+ AGENT_SERVER,
+ ExposedUrl,
+ SandboxInfo,
+ SandboxStatus,
+)
+from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
+from openhands.app_server.user.user_context import UserContext
+from openhands.sdk.context.skills import KeywordTrigger, Skill, TaskTrigger
+
+
+def _make_service_mock(
+ *,
+ user_context: UserContext,
+ conversation_return: AppConversation | None = None,
+ skills_return: list[Skill] | None = None,
+ raise_on_load: bool = False,
+):
+ """Create a mock service that passes the isinstance check and returns the desired values."""
+
+ mock_cls = type('AppConversationServiceMock', (MagicMock,), {})
+ AppConversationServiceBase.register(mock_cls)
+
+ service = mock_cls()
+ service.user_context = user_context
+ service.get_app_conversation = AsyncMock(return_value=conversation_return)
+
+ async def _load_skills(*_args, **_kwargs):
+ if raise_on_load:
+ raise Exception('Skill loading failed')
+ return skills_return or []
+
+ service.load_and_merge_all_skills = AsyncMock(side_effect=_load_skills)
+ return service
+
+
+@pytest.mark.asyncio
+class TestGetConversationSkills:
+ """Test suite for get_conversation_skills endpoint."""
+
+ async def test_get_skills_returns_repo_and_knowledge_skills(self):
+ """Test successful retrieval of both repo and knowledge skills.
+
+ Arrange: Setup conversation, sandbox, and skills with different types
+ Act: Call get_conversation_skills endpoint
+ Assert: Response contains both repo and knowledge skills with correct types
+ """
+ # Arrange
+ conversation_id = uuid4()
+ sandbox_id = str(uuid4())
+ working_dir = '/workspace'
+
+ # Create mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test-user',
+ sandbox_id=sandbox_id,
+ selected_repository='owner/repo',
+ sandbox_status=SandboxStatus.RUNNING,
+ )
+
+ # Create mock sandbox with agent server URL
+ mock_sandbox = SandboxInfo(
+ id=sandbox_id,
+ created_by_user_id='test-user',
+ status=SandboxStatus.RUNNING,
+ sandbox_spec_id=str(uuid4()),
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000)
+ ],
+ )
+
+ # Create mock sandbox spec
+ mock_sandbox_spec = SandboxSpecInfo(
+ id=str(uuid4()), command=None, working_dir=working_dir
+ )
+
+ # Create mock skills - repo skill (no trigger)
+ repo_skill = Skill(
+ name='repo_skill',
+ content='Repository skill content',
+ trigger=None,
+ )
+
+ # Create mock skills - knowledge skill (with KeywordTrigger)
+ knowledge_skill = Skill(
+ name='knowledge_skill',
+ content='Knowledge skill content',
+ trigger=KeywordTrigger(keywords=['test', 'help']),
+ )
+
+ # Mock services
+ mock_user_context = MagicMock(spec=UserContext)
+ mock_app_conversation_service = _make_service_mock(
+ user_context=mock_user_context,
+ conversation_return=mock_conversation,
+ skills_return=[repo_skill, knowledge_skill],
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
+
+ mock_sandbox_spec_service = MagicMock()
+ mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
+ return_value=mock_sandbox_spec
+ )
+
+ # Act
+ response = await get_conversation_skills(
+ conversation_id=conversation_id,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Assert
+ assert response.status_code == status.HTTP_200_OK
+ content = response.body.decode('utf-8')
+ import json
+
+ data = json.loads(content)
+ assert 'skills' in data
+ assert len(data['skills']) == 2
+
+ # Check repo skill
+ repo_skill_data = next(
+ (s for s in data['skills'] if s['name'] == 'repo_skill'), None
+ )
+ assert repo_skill_data is not None
+ assert repo_skill_data['type'] == 'repo'
+ assert repo_skill_data['content'] == 'Repository skill content'
+ assert repo_skill_data['triggers'] == []
+
+ # Check knowledge skill
+ knowledge_skill_data = next(
+ (s for s in data['skills'] if s['name'] == 'knowledge_skill'), None
+ )
+ assert knowledge_skill_data is not None
+ assert knowledge_skill_data['type'] == 'knowledge'
+ assert knowledge_skill_data['content'] == 'Knowledge skill content'
+ assert knowledge_skill_data['triggers'] == ['test', 'help']
+
+ async def test_get_skills_returns_404_when_conversation_not_found(self):
+ """Test endpoint returns 404 when conversation doesn't exist.
+
+ Arrange: Setup mocks to return None for conversation
+ Act: Call get_conversation_skills endpoint
+ Assert: Response is 404 with appropriate error message
+ """
+ # Arrange
+ conversation_id = uuid4()
+
+ mock_user_context = MagicMock(spec=UserContext)
+ mock_app_conversation_service = _make_service_mock(
+ user_context=mock_user_context,
+ conversation_return=None,
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_spec_service = MagicMock()
+
+ # Act
+ response = await get_conversation_skills(
+ conversation_id=conversation_id,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Assert
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ content = response.body.decode('utf-8')
+ import json
+
+ data = json.loads(content)
+ assert 'error' in data
+ assert str(conversation_id) in data['error']
+
+ async def test_get_skills_returns_404_when_sandbox_not_found(self):
+ """Test endpoint returns 404 when sandbox doesn't exist.
+
+ Arrange: Setup conversation but no sandbox
+ Act: Call get_conversation_skills endpoint
+ Assert: Response is 404 with sandbox error message
+ """
+ # Arrange
+ conversation_id = uuid4()
+ sandbox_id = str(uuid4())
+
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test-user',
+ sandbox_id=sandbox_id,
+ sandbox_status=SandboxStatus.RUNNING,
+ )
+
+ mock_user_context = MagicMock(spec=UserContext)
+ mock_app_conversation_service = _make_service_mock(
+ user_context=mock_user_context,
+ conversation_return=mock_conversation,
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_service.get_sandbox = AsyncMock(return_value=None)
+
+ mock_sandbox_spec_service = MagicMock()
+
+ # Act
+ response = await get_conversation_skills(
+ conversation_id=conversation_id,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Assert
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ content = response.body.decode('utf-8')
+ import json
+
+ data = json.loads(content)
+ assert 'error' in data
+ assert 'Sandbox not found' in data['error']
+
+ async def test_get_skills_returns_404_when_sandbox_not_running(self):
+ """Test endpoint returns 404 when sandbox is not in RUNNING state.
+
+ Arrange: Setup conversation with stopped sandbox
+ Act: Call get_conversation_skills endpoint
+ Assert: Response is 404 with sandbox not running message
+ """
+ # Arrange
+ conversation_id = uuid4()
+ sandbox_id = str(uuid4())
+
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test-user',
+ sandbox_id=sandbox_id,
+ sandbox_status=SandboxStatus.PAUSED,
+ )
+
+ mock_sandbox = SandboxInfo(
+ id=sandbox_id,
+ created_by_user_id='test-user',
+ status=SandboxStatus.PAUSED,
+ sandbox_spec_id=str(uuid4()),
+ session_api_key='test-api-key',
+ )
+
+ mock_user_context = MagicMock(spec=UserContext)
+ mock_app_conversation_service = _make_service_mock(
+ user_context=mock_user_context,
+ conversation_return=mock_conversation,
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
+
+ mock_sandbox_spec_service = MagicMock()
+
+ # Act
+ response = await get_conversation_skills(
+ conversation_id=conversation_id,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Assert
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+ content = response.body.decode('utf-8')
+ import json
+
+ data = json.loads(content)
+ assert 'error' in data
+ assert 'not running' in data['error']
+
+ async def test_get_skills_handles_task_trigger_skills(self):
+ """Test endpoint correctly handles skills with TaskTrigger.
+
+ Arrange: Setup skill with TaskTrigger
+ Act: Call get_conversation_skills endpoint
+ Assert: Skill is categorized as knowledge type with correct triggers
+ """
+ # Arrange
+ conversation_id = uuid4()
+ sandbox_id = str(uuid4())
+
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test-user',
+ sandbox_id=sandbox_id,
+ sandbox_status=SandboxStatus.RUNNING,
+ )
+
+ mock_sandbox = SandboxInfo(
+ id=sandbox_id,
+ created_by_user_id='test-user',
+ status=SandboxStatus.RUNNING,
+ sandbox_spec_id=str(uuid4()),
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000)
+ ],
+ )
+
+ mock_sandbox_spec = SandboxSpecInfo(
+ id=str(uuid4()), command=None, working_dir='/workspace'
+ )
+
+ # Create task skill with TaskTrigger
+ task_skill = Skill(
+ name='task_skill',
+ content='Task skill content',
+ trigger=TaskTrigger(triggers=['task', 'execute']),
+ )
+
+ mock_user_context = MagicMock(spec=UserContext)
+ mock_app_conversation_service = _make_service_mock(
+ user_context=mock_user_context,
+ conversation_return=mock_conversation,
+ skills_return=[task_skill],
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
+
+ mock_sandbox_spec_service = MagicMock()
+ mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
+ return_value=mock_sandbox_spec
+ )
+
+ # Act
+ response = await get_conversation_skills(
+ conversation_id=conversation_id,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Assert
+ assert response.status_code == status.HTTP_200_OK
+ content = response.body.decode('utf-8')
+ import json
+
+ data = json.loads(content)
+ assert len(data['skills']) == 1
+ skill_data = data['skills'][0]
+ assert skill_data['type'] == 'knowledge'
+ assert skill_data['triggers'] == ['task', 'execute']
+
+ async def test_get_skills_returns_500_on_skill_loading_error(self):
+ """Test endpoint returns 500 when skill loading fails.
+
+ Arrange: Setup mocks to raise exception during skill loading
+ Act: Call get_conversation_skills endpoint
+ Assert: Response is 500 with error message
+ """
+ # Arrange
+ conversation_id = uuid4()
+ sandbox_id = str(uuid4())
+
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test-user',
+ sandbox_id=sandbox_id,
+ sandbox_status=SandboxStatus.RUNNING,
+ )
+
+ mock_sandbox = SandboxInfo(
+ id=sandbox_id,
+ created_by_user_id='test-user',
+ status=SandboxStatus.RUNNING,
+ sandbox_spec_id=str(uuid4()),
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000)
+ ],
+ )
+
+ mock_sandbox_spec = SandboxSpecInfo(
+ id=str(uuid4()), command=None, working_dir='/workspace'
+ )
+
+ mock_user_context = MagicMock(spec=UserContext)
+ mock_app_conversation_service = _make_service_mock(
+ user_context=mock_user_context,
+ conversation_return=mock_conversation,
+ raise_on_load=True,
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
+
+ mock_sandbox_spec_service = MagicMock()
+ mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
+ return_value=mock_sandbox_spec
+ )
+
+ # Act
+ response = await get_conversation_skills(
+ conversation_id=conversation_id,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Assert
+ assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
+ content = response.body.decode('utf-8')
+ import json
+
+ data = json.loads(content)
+ assert 'error' in data
+ assert 'Error getting skills' in data['error']
+
+ async def test_get_skills_returns_empty_list_when_no_skills_loaded(self):
+ """Test endpoint returns empty skills list when no skills are found.
+
+ Arrange: Setup all skill loaders to return empty lists
+ Act: Call get_conversation_skills endpoint
+ Assert: Response contains empty skills array
+ """
+ # Arrange
+ conversation_id = uuid4()
+ sandbox_id = str(uuid4())
+
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test-user',
+ sandbox_id=sandbox_id,
+ sandbox_status=SandboxStatus.RUNNING,
+ )
+
+ mock_sandbox = SandboxInfo(
+ id=sandbox_id,
+ created_by_user_id='test-user',
+ status=SandboxStatus.RUNNING,
+ sandbox_spec_id=str(uuid4()),
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://localhost:8000', port=8000)
+ ],
+ )
+
+ mock_sandbox_spec = SandboxSpecInfo(
+ id=str(uuid4()), command=None, working_dir='/workspace'
+ )
+
+ mock_user_context = MagicMock(spec=UserContext)
+ mock_app_conversation_service = _make_service_mock(
+ user_context=mock_user_context,
+ conversation_return=mock_conversation,
+ skills_return=[],
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
+
+ mock_sandbox_spec_service = MagicMock()
+ mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
+ return_value=mock_sandbox_spec
+ )
+
+ # Act
+ response = await get_conversation_skills(
+ conversation_id=conversation_id,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Assert
+ assert response.status_code == status.HTTP_200_OK
+ content = response.body.decode('utf-8')
+ import json
+
+ data = json.loads(content)
+ assert 'skills' in data
+ assert len(data['skills']) == 0
diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py
index 1dabdfa88a..f662f33146 100644
--- a/tests/unit/app_server/test_live_status_app_conversation_service.py
+++ b/tests/unit/app_server/test_live_status_app_conversation_service.py
@@ -6,16 +6,25 @@ from uuid import UUID, uuid4
import pytest
from openhands.agent_server.models import SendMessageRequest, StartConversationRequest
-from openhands.app_server.app_conversation.app_conversation_models import AgentType
+from openhands.app_server.app_conversation.app_conversation_models import (
+ AgentType,
+ AppConversationStartRequest,
+)
from openhands.app_server.app_conversation.live_status_app_conversation_service import (
LiveStatusAppConversationService,
)
-from openhands.app_server.sandbox.sandbox_models import SandboxInfo, SandboxStatus
+from openhands.app_server.sandbox.sandbox_models import (
+ AGENT_SERVER,
+ ExposedUrl,
+ SandboxInfo,
+ SandboxStatus,
+)
+from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderType
from openhands.sdk import Agent
-from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret
from openhands.sdk.llm import LLM
+from openhands.sdk.secret import LookupSecret, StaticSecret
from openhands.sdk.workspace import LocalWorkspace
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
@@ -68,6 +77,7 @@ class TestLiveStatusAppConversationService:
self.mock_user.search_api_key = None # Default to None
self.mock_user.condenser_max_size = None # Default to None
self.mock_user.llm_base_url = 'https://api.openai.com/v1'
+ self.mock_user.mcp_config = None # Default to None to avoid error handling path
# Mock sandbox
self.mock_sandbox = Mock(spec=SandboxInfo)
@@ -239,9 +249,16 @@ class TestLiveStatusAppConversationService:
assert llm.api_key.get_secret_value() == self.mock_user.llm_api_key
assert llm.usage_id == 'agent'
- assert 'default' in mcp_config
- assert mcp_config['default']['url'] == 'https://test.example.com/mcp/mcp'
- assert mcp_config['default']['headers']['X-Session-API-Key'] == 'mcp_api_key'
+ assert 'mcpServers' in mcp_config
+ assert 'default' in mcp_config['mcpServers']
+ assert (
+ mcp_config['mcpServers']['default']['url']
+ == 'https://test.example.com/mcp/mcp'
+ )
+ assert (
+ mcp_config['mcpServers']['default']['headers']['X-Session-API-Key']
+ == 'mcp_api_key'
+ )
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_openhands_model_prefers_user_base_url(self):
@@ -290,7 +307,7 @@ class TestLiveStatusAppConversationService:
)
# Assert
- assert llm.base_url is None
+ assert llm.base_url == 'https://llm-proxy.app.all-hands.dev/'
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_non_openhands_model_ignores_provider(self):
@@ -320,8 +337,9 @@ class TestLiveStatusAppConversationService:
# Assert
assert llm.model == self.mock_user.llm_model
- assert 'default' in mcp_config
- assert 'headers' not in mcp_config['default']
+ assert 'mcpServers' in mcp_config
+ assert 'default' in mcp_config['mcpServers']
+ assert 'headers' not in mcp_config['mcpServers']['default']
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_without_web_url(self):
@@ -354,10 +372,11 @@ class TestLiveStatusAppConversationService:
# Assert
assert isinstance(llm, LLM)
- assert 'default' in mcp_config
- assert 'tavily' in mcp_config
+ assert 'mcpServers' in mcp_config
+ assert 'default' in mcp_config['mcpServers']
+ assert 'tavily' in mcp_config['mcpServers']
assert (
- mcp_config['tavily']['url']
+ mcp_config['mcpServers']['tavily']['url']
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
)
@@ -375,10 +394,11 @@ class TestLiveStatusAppConversationService:
# Assert
assert isinstance(llm, LLM)
- assert 'default' in mcp_config
- assert 'tavily' in mcp_config
+ assert 'mcpServers' in mcp_config
+ assert 'default' in mcp_config['mcpServers']
+ assert 'tavily' in mcp_config['mcpServers']
assert (
- mcp_config['tavily']['url']
+ mcp_config['mcpServers']['tavily']['url']
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
)
@@ -399,9 +419,10 @@ class TestLiveStatusAppConversationService:
# Assert
assert isinstance(llm, LLM)
- assert 'tavily' in mcp_config
+ assert 'mcpServers' in mcp_config
+ assert 'tavily' in mcp_config['mcpServers']
assert (
- mcp_config['tavily']['url']
+ mcp_config['mcpServers']['tavily']['url']
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
)
@@ -420,8 +441,9 @@ class TestLiveStatusAppConversationService:
# Assert
assert isinstance(llm, LLM)
- assert 'default' in mcp_config
- assert 'tavily' not in mcp_config
+ assert 'mcpServers' in mcp_config
+ assert 'default' in mcp_config['mcpServers']
+ assert 'tavily' not in mcp_config['mcpServers']
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_saas_mode_no_tavily_without_user_key(self):
@@ -443,8 +465,9 @@ class TestLiveStatusAppConversationService:
# Assert
assert isinstance(llm, LLM)
- assert 'default' in mcp_config
- assert 'tavily' not in mcp_config
+ assert 'mcpServers' in mcp_config
+ assert 'default' in mcp_config['mcpServers']
+ assert 'tavily' not in mcp_config['mcpServers']
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_saas_mode_with_user_search_key(self):
@@ -467,10 +490,11 @@ class TestLiveStatusAppConversationService:
# Assert
assert isinstance(llm, LLM)
- assert 'default' in mcp_config
- assert 'tavily' in mcp_config
+ assert 'mcpServers' in mcp_config
+ assert 'default' in mcp_config['mcpServers']
+ assert 'tavily' in mcp_config['mcpServers']
assert (
- mcp_config['tavily']['url']
+ mcp_config['mcpServers']['tavily']['url']
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
)
@@ -491,10 +515,11 @@ class TestLiveStatusAppConversationService:
# Assert
assert isinstance(llm, LLM)
- assert 'tavily' in mcp_config
+ assert 'mcpServers' in mcp_config
+ assert 'tavily' in mcp_config['mcpServers']
# Should fall back to env key since user key is empty
assert (
- mcp_config['tavily']['url']
+ mcp_config['mcpServers']['tavily']['url']
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
)
@@ -515,10 +540,11 @@ class TestLiveStatusAppConversationService:
# Assert
assert isinstance(llm, LLM)
- assert 'tavily' in mcp_config
+ assert 'mcpServers' in mcp_config
+ assert 'tavily' in mcp_config['mcpServers']
# Should fall back to env key since user key is whitespace only
assert (
- mcp_config['tavily']['url']
+ mcp_config['mcpServers']['tavily']['url']
== 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
)
@@ -821,5 +847,510 @@ class TestLiveStatusAppConversationService:
'Test suffix',
mock_mcp_config,
self.mock_user.condenser_max_size,
+ secrets=mock_secrets,
)
self.service._finalize_conversation_request.assert_called_once()
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.AsyncRemoteWorkspace'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.ConversationInfo'
+ )
+ async def test_start_app_conversation_default_title_uses_first_five_characters(
+ self, mock_conversation_info_class, mock_remote_workspace_class
+ ):
+ """Test that v1 conversations use first 5 characters of conversation ID for default title."""
+ # Arrange
+ conversation_id = uuid4()
+ conversation_id_hex = conversation_id.hex
+ expected_title = f'Conversation {conversation_id_hex[:5]}'
+
+ # Mock user context
+ self.mock_user_context.get_user_id = AsyncMock(return_value='test_user_123')
+ self.mock_user_context.get_user_info = AsyncMock(return_value=self.mock_user)
+
+ # Mock sandbox and sandbox spec
+ mock_sandbox_spec = Mock(spec=SandboxSpecInfo)
+ mock_sandbox_spec.working_dir = '/test/workspace'
+ self.mock_sandbox.sandbox_spec_id = str(uuid4())
+ self.mock_sandbox.id = str(uuid4()) # Ensure sandbox.id is a string
+ self.mock_sandbox.session_api_key = 'test_session_key'
+ exposed_url = ExposedUrl(
+ name=AGENT_SERVER, url='http://agent-server:8000', port=60000
+ )
+ self.mock_sandbox.exposed_urls = [exposed_url]
+
+ self.mock_sandbox_service.get_sandbox = AsyncMock(
+ return_value=self.mock_sandbox
+ )
+ self.mock_sandbox_spec_service.get_sandbox_spec = AsyncMock(
+ return_value=mock_sandbox_spec
+ )
+
+ # Mock remote workspace
+ mock_remote_workspace = Mock()
+ mock_remote_workspace_class.return_value = mock_remote_workspace
+
+ # Mock the wait for sandbox and setup scripts
+ async def mock_wait_for_sandbox(task):
+ task.sandbox_id = self.mock_sandbox.id
+ yield task
+
+ async def mock_run_setup_scripts(task, sandbox, workspace):
+ yield task
+
+ self.service._wait_for_sandbox_start = mock_wait_for_sandbox
+ self.service.run_setup_scripts = mock_run_setup_scripts
+
+ # Mock build start conversation request
+ mock_agent = Mock(spec=Agent)
+ mock_agent.llm = Mock(spec=LLM)
+ mock_agent.llm.model = 'gpt-4'
+ mock_start_request = Mock(spec=StartConversationRequest)
+ mock_start_request.agent = mock_agent
+ mock_start_request.model_dump.return_value = {'test': 'data'}
+
+ self.service._build_start_conversation_request_for_user = AsyncMock(
+ return_value=mock_start_request
+ )
+
+ # Mock ConversationInfo returned from agent server
+ mock_conversation_info = Mock()
+ mock_conversation_info.id = conversation_id
+ mock_conversation_info_class.model_validate.return_value = (
+ mock_conversation_info
+ )
+
+ # Mock HTTP response from agent server
+ mock_response = Mock()
+ mock_response.json.return_value = {'id': str(conversation_id)}
+ mock_response.raise_for_status = Mock()
+ self.mock_httpx_client.post = AsyncMock(return_value=mock_response)
+
+ # Mock event callback service
+ self.mock_event_callback_service.save_event_callback = AsyncMock()
+
+ # Create request
+ request = AppConversationStartRequest()
+
+ # Act
+ async for task in self.service._start_app_conversation(request):
+ # Consume all tasks to reach the point where title is set
+ pass
+
+ # Assert
+ # Verify that save_app_conversation_info was called with the correct title format
+ self.mock_app_conversation_info_service.save_app_conversation_info.assert_called_once()
+ call_args = (
+ self.mock_app_conversation_info_service.save_app_conversation_info.call_args
+ )
+ saved_info = call_args[0][0] # First positional argument
+
+ assert saved_info.title == expected_title, (
+ f'Expected title to be "{expected_title}" (first 5 chars), '
+ f'but got "{saved_info.title}"'
+ )
+ assert saved_info.id == conversation_id
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_with_custom_sse_servers(self):
+ """Test _configure_llm_and_mcp merges custom SSE servers with UUID-based names."""
+ # Arrange
+
+ from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig
+
+ self.mock_user.mcp_config = MCPConfig(
+ sse_servers=[
+ MCPSSEServerConfig(url='https://linear.app/sse', api_key='linear_key'),
+ MCPSSEServerConfig(url='https://notion.com/sse'),
+ ]
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ assert isinstance(llm, LLM)
+ assert 'mcpServers' in mcp_config
+
+ # Should have default server + 2 custom SSE servers
+ mcp_servers = mcp_config['mcpServers']
+ assert 'default' in mcp_servers
+
+ # Find SSE servers (they have sse_ prefix)
+ sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')}
+ assert len(sse_servers) == 2
+
+ # Verify SSE server configurations
+ for server_name, server_config in sse_servers.items():
+ assert server_name.startswith('sse_')
+ assert len(server_name) > 4 # Has UUID suffix
+ assert 'url' in server_config
+ assert 'transport' in server_config
+ assert server_config['transport'] == 'sse'
+
+ # Check if this is the Linear server (has headers)
+ if 'headers' in server_config:
+ assert server_config['headers']['Authorization'] == 'Bearer linear_key'
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_with_custom_shttp_servers(self):
+ """Test _configure_llm_and_mcp merges custom SHTTP servers with timeout."""
+ # Arrange
+ from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig
+
+ self.mock_user.mcp_config = MCPConfig(
+ shttp_servers=[
+ MCPSHTTPServerConfig(
+ url='https://example.com/mcp',
+ api_key='test_key',
+ timeout=120,
+ )
+ ]
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ assert isinstance(llm, LLM)
+ mcp_servers = mcp_config['mcpServers']
+
+ # Find SHTTP servers
+ shttp_servers = {k: v for k, v in mcp_servers.items() if k.startswith('shttp_')}
+ assert len(shttp_servers) == 1
+
+ server_config = list(shttp_servers.values())[0]
+ assert server_config['url'] == 'https://example.com/mcp'
+ assert server_config['transport'] == 'streamable-http'
+ assert server_config['headers']['Authorization'] == 'Bearer test_key'
+ assert server_config['timeout'] == 120
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_with_custom_stdio_servers(self):
+ """Test _configure_llm_and_mcp merges custom STDIO servers with explicit names."""
+ # Arrange
+ from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
+
+ self.mock_user.mcp_config = MCPConfig(
+ stdio_servers=[
+ MCPStdioServerConfig(
+ name='my-custom-server',
+ command='npx',
+ args=['-y', 'my-package'],
+ env={'API_KEY': 'secret'},
+ )
+ ]
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ assert isinstance(llm, LLM)
+ mcp_servers = mcp_config['mcpServers']
+
+ # STDIO server should use its explicit name
+ assert 'my-custom-server' in mcp_servers
+ server_config = mcp_servers['my-custom-server']
+ assert server_config['command'] == 'npx'
+ assert server_config['args'] == ['-y', 'my-package']
+ assert server_config['env'] == {'API_KEY': 'secret'}
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_merges_system_and_custom_servers(self):
+ """Test _configure_llm_and_mcp merges both system and custom MCP servers."""
+ # Arrange
+ from pydantic import SecretStr
+
+ from openhands.core.config.mcp_config import (
+ MCPConfig,
+ MCPSSEServerConfig,
+ MCPStdioServerConfig,
+ )
+
+ self.mock_user.search_api_key = SecretStr('tavily_key')
+ self.mock_user.mcp_config = MCPConfig(
+ sse_servers=[MCPSSEServerConfig(url='https://custom.com/sse')],
+ stdio_servers=[
+ MCPStdioServerConfig(
+ name='custom-stdio', command='node', args=['app.js']
+ )
+ ],
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = 'mcp_api_key'
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ mcp_servers = mcp_config['mcpServers']
+
+ # Should have system servers
+ assert 'default' in mcp_servers
+ assert 'tavily' in mcp_servers
+
+ # Should have custom SSE server with UUID name
+ sse_servers = [k for k in mcp_servers if k.startswith('sse_')]
+ assert len(sse_servers) == 1
+
+ # Should have custom STDIO server with explicit name
+ assert 'custom-stdio' in mcp_servers
+
+ # Total: default + tavily + 1 SSE + 1 STDIO = 4 servers
+ assert len(mcp_servers) == 4
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_custom_config_error_handling(self):
+ """Test _configure_llm_and_mcp handles errors in custom MCP config gracefully."""
+ # Arrange
+ self.mock_user.mcp_config = Mock()
+ # Simulate error when accessing sse_servers
+ self.mock_user.mcp_config.sse_servers = property(
+ lambda self: (_ for _ in ()).throw(Exception('Config error'))
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert - should still return valid config with system servers only
+ assert isinstance(llm, LLM)
+ mcp_servers = mcp_config['mcpServers']
+ assert 'default' in mcp_servers
+ # Custom servers should not be added due to error
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_sdk_format_with_mcpservers_wrapper(self):
+ """Test _configure_llm_and_mcp returns SDK-required format with mcpServers key."""
+ # Arrange
+ self.mock_user_context.get_mcp_api_key.return_value = 'mcp_key'
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert - SDK expects {'mcpServers': {...}} format
+ assert 'mcpServers' in mcp_config
+ assert isinstance(mcp_config['mcpServers'], dict)
+
+ # Verify structure matches SDK expectations
+ for server_name, server_config in mcp_config['mcpServers'].items():
+ assert isinstance(server_name, str)
+ assert isinstance(server_config, dict)
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_empty_custom_config(self):
+ """Test _configure_llm_and_mcp handles empty custom MCP config."""
+ # Arrange
+ from openhands.core.config.mcp_config import MCPConfig
+
+ self.mock_user.mcp_config = MCPConfig(
+ sse_servers=[], stdio_servers=[], shttp_servers=[]
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ mcp_servers = mcp_config['mcpServers']
+ # Should only have system default server
+ assert 'default' in mcp_servers
+ assert len(mcp_servers) == 1
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_sse_server_without_api_key(self):
+ """Test _configure_llm_and_mcp handles SSE servers without API keys."""
+ # Arrange
+ from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig
+
+ self.mock_user.mcp_config = MCPConfig(
+ sse_servers=[MCPSSEServerConfig(url='https://public.com/sse')]
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ mcp_servers = mcp_config['mcpServers']
+ sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')}
+
+ # Server should exist but without headers
+ assert len(sse_servers) == 1
+ server_config = list(sse_servers.values())[0]
+ assert 'headers' not in server_config
+ assert server_config['url'] == 'https://public.com/sse'
+ assert server_config['transport'] == 'sse'
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_shttp_server_without_timeout(self):
+ """Test _configure_llm_and_mcp handles SHTTP servers without timeout."""
+ # Arrange
+ from openhands.core.config.mcp_config import MCPConfig, MCPSHTTPServerConfig
+
+ self.mock_user.mcp_config = MCPConfig(
+ shttp_servers=[MCPSHTTPServerConfig(url='https://example.com/mcp')]
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ mcp_servers = mcp_config['mcpServers']
+ shttp_servers = {k: v for k, v in mcp_servers.items() if k.startswith('shttp_')}
+
+ assert len(shttp_servers) == 1
+ server_config = list(shttp_servers.values())[0]
+ # Timeout should be included even if None (defaults to 60)
+ assert 'timeout' in server_config
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_stdio_server_without_env(self):
+ """Test _configure_llm_and_mcp handles STDIO servers without environment variables."""
+ # Arrange
+ from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
+
+ self.mock_user.mcp_config = MCPConfig(
+ stdio_servers=[
+ MCPStdioServerConfig(
+ name='simple-server', command='node', args=['app.js']
+ )
+ ]
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ mcp_servers = mcp_config['mcpServers']
+ assert 'simple-server' in mcp_servers
+ server_config = mcp_servers['simple-server']
+
+ # Should not have env key if not provided
+ assert 'env' not in server_config
+ assert server_config['command'] == 'node'
+ assert server_config['args'] == ['app.js']
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_multiple_servers_same_type(self):
+ """Test _configure_llm_and_mcp handles multiple custom servers of the same type."""
+ # Arrange
+ from openhands.core.config.mcp_config import MCPConfig, MCPSSEServerConfig
+
+ self.mock_user.mcp_config = MCPConfig(
+ sse_servers=[
+ MCPSSEServerConfig(url='https://server1.com/sse'),
+ MCPSSEServerConfig(url='https://server2.com/sse'),
+ MCPSSEServerConfig(url='https://server3.com/sse'),
+ ]
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ mcp_servers = mcp_config['mcpServers']
+ sse_servers = {k: v for k, v in mcp_servers.items() if k.startswith('sse_')}
+
+ # All 3 servers should be present with unique UUID-based names
+ assert len(sse_servers) == 3
+
+ # Verify all have unique names
+ server_names = list(sse_servers.keys())
+ assert len(set(server_names)) == 3 # All names are unique
+
+ # Verify all URLs are preserved
+ urls = [v['url'] for v in sse_servers.values()]
+ assert 'https://server1.com/sse' in urls
+ assert 'https://server2.com/sse' in urls
+ assert 'https://server3.com/sse' in urls
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_mixed_server_types(self):
+ """Test _configure_llm_and_mcp handles all three server types together."""
+ # Arrange
+ from openhands.core.config.mcp_config import (
+ MCPConfig,
+ MCPSHTTPServerConfig,
+ MCPSSEServerConfig,
+ MCPStdioServerConfig,
+ )
+
+ self.mock_user.mcp_config = MCPConfig(
+ sse_servers=[
+ MCPSSEServerConfig(url='https://sse.example.com/sse', api_key='sse_key')
+ ],
+ shttp_servers=[
+ MCPSHTTPServerConfig(url='https://shttp.example.com/mcp', timeout=90)
+ ],
+ stdio_servers=[
+ MCPStdioServerConfig(
+ name='stdio-server',
+ command='npx',
+ args=['mcp-server'],
+ env={'TOKEN': 'value'},
+ )
+ ],
+ )
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ mcp_servers = mcp_config['mcpServers']
+
+ # Check all server types are present
+ sse_count = len([k for k in mcp_servers if k.startswith('sse_')])
+ shttp_count = len([k for k in mcp_servers if k.startswith('shttp_')])
+ stdio_count = 1 if 'stdio-server' in mcp_servers else 0
+
+ assert sse_count == 1
+ assert shttp_count == 1
+ assert stdio_count == 1
+
+ # Verify each type has correct configuration
+ sse_server = next(v for k, v in mcp_servers.items() if k.startswith('sse_'))
+ assert sse_server['transport'] == 'sse'
+ assert sse_server['headers']['Authorization'] == 'Bearer sse_key'
+
+ shttp_server = next(v for k, v in mcp_servers.items() if k.startswith('shttp_'))
+ assert shttp_server['transport'] == 'streamable-http'
+ assert shttp_server['timeout'] == 90
+
+ stdio_server = mcp_servers['stdio-server']
+ assert stdio_server['command'] == 'npx'
+ assert stdio_server['env'] == {'TOKEN': 'value'}
diff --git a/tests/unit/app_server/test_skill_loader.py b/tests/unit/app_server/test_skill_loader.py
index c9e54ba5a1..e4daadfa14 100644
--- a/tests/unit/app_server/test_skill_loader.py
+++ b/tests/unit/app_server/test_skill_loader.py
@@ -11,15 +11,27 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from openhands.app_server.app_conversation.skill_loader import (
+ _cleanup_org_repository,
+ _clone_org_repository,
+ _determine_org_repo_path,
_determine_repo_root,
_find_and_load_global_skill_files,
_find_and_load_skill_md_files,
+ _get_org_repository_url,
+ _is_azure_devops_repository,
+ _is_gitlab_repository,
+ _load_skills_from_org_directories,
_load_special_files,
+ _merge_org_skills_with_precedence,
_read_file_from_workspace,
+ _validate_repository_for_org_skills,
load_global_skills,
+ load_org_skills,
load_repo_skills,
merge_skills,
)
+from openhands.integrations.provider import ProviderType
+from openhands.integrations.service_types import AuthenticationError
# ===== Test Fixtures =====
@@ -667,6 +679,669 @@ class TestMergeSkills:
assert len(result) == 2
+# ===== Tests for Organization Skills Functions =====
+
+
+class TestIsGitlabRepository:
+ """Test _is_gitlab_repository helper function."""
+
+ @pytest.mark.asyncio
+ async def test_is_gitlab_repository_true(self):
+ """Test GitLab repository detection returns True."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_provider_handler = AsyncMock()
+ mock_repository = Mock()
+ mock_repository.git_provider = ProviderType.GITLAB
+
+ mock_user_context.get_provider_handler.return_value = mock_provider_handler
+ mock_provider_handler.verify_repo_provider.return_value = mock_repository
+
+ # Act
+ result = await _is_gitlab_repository('owner/repo', mock_user_context)
+
+ # Assert
+ assert result is True
+ mock_provider_handler.verify_repo_provider.assert_called_once_with('owner/repo')
+
+ @pytest.mark.asyncio
+ async def test_is_gitlab_repository_false(self):
+ """Test non-GitLab repository detection returns False."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_provider_handler = AsyncMock()
+ mock_repository = Mock()
+ mock_repository.git_provider = ProviderType.GITHUB
+
+ mock_user_context.get_provider_handler.return_value = mock_provider_handler
+ mock_provider_handler.verify_repo_provider.return_value = mock_repository
+
+ # Act
+ result = await _is_gitlab_repository('owner/repo', mock_user_context)
+
+ # Assert
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_is_gitlab_repository_exception_handling(self):
+ """Test exception handling returns False."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_user_context.get_provider_handler.side_effect = Exception('API error')
+
+ # Act
+ result = await _is_gitlab_repository('owner/repo', mock_user_context)
+
+ # Assert
+ assert result is False
+
+
+class TestIsAzureDevOpsRepository:
+ """Test _is_azure_devops_repository helper function."""
+
+ @pytest.mark.asyncio
+ async def test_is_azure_devops_repository_true(self):
+ """Test Azure DevOps repository detection returns True."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_provider_handler = AsyncMock()
+ mock_repository = Mock()
+ mock_repository.git_provider = ProviderType.AZURE_DEVOPS
+
+ mock_user_context.get_provider_handler.return_value = mock_provider_handler
+ mock_provider_handler.verify_repo_provider.return_value = mock_repository
+
+ # Act
+ result = await _is_azure_devops_repository(
+ 'org/project/repo', mock_user_context
+ )
+
+ # Assert
+ assert result is True
+ mock_provider_handler.verify_repo_provider.assert_called_once_with(
+ 'org/project/repo'
+ )
+
+ @pytest.mark.asyncio
+ async def test_is_azure_devops_repository_false(self):
+ """Test non-Azure DevOps repository detection returns False."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_provider_handler = AsyncMock()
+ mock_repository = Mock()
+ mock_repository.git_provider = ProviderType.GITHUB
+
+ mock_user_context.get_provider_handler.return_value = mock_provider_handler
+ mock_provider_handler.verify_repo_provider.return_value = mock_repository
+
+ # Act
+ result = await _is_azure_devops_repository('owner/repo', mock_user_context)
+
+ # Assert
+ assert result is False
+
+ @pytest.mark.asyncio
+ async def test_is_azure_devops_repository_exception_handling(self):
+ """Test exception handling returns False."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_user_context.get_provider_handler.side_effect = Exception('Network error')
+
+ # Act
+ result = await _is_azure_devops_repository('owner/repo', mock_user_context)
+
+ # Assert
+ assert result is False
+
+
+class TestDetermineOrgRepoPath:
+ """Test _determine_org_repo_path helper function."""
+
+ @pytest.mark.asyncio
+ @patch('openhands.app_server.app_conversation.skill_loader._is_gitlab_repository')
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._is_azure_devops_repository'
+ )
+ async def test_github_repository_path(self, mock_is_azure, mock_is_gitlab):
+ """Test org path for GitHub repository."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_is_gitlab.return_value = False
+ mock_is_azure.return_value = False
+
+ # Act
+ org_repo, org_name = await _determine_org_repo_path(
+ 'owner/repo', mock_user_context
+ )
+
+ # Assert
+ assert org_repo == 'owner/.openhands'
+ assert org_name == 'owner'
+
+ @pytest.mark.asyncio
+ @patch('openhands.app_server.app_conversation.skill_loader._is_gitlab_repository')
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._is_azure_devops_repository'
+ )
+ async def test_gitlab_repository_path(self, mock_is_azure, mock_is_gitlab):
+ """Test org path for GitLab repository."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_is_gitlab.return_value = True
+ mock_is_azure.return_value = False
+
+ # Act
+ org_repo, org_name = await _determine_org_repo_path(
+ 'owner/repo', mock_user_context
+ )
+
+ # Assert
+ assert org_repo == 'owner/openhands-config'
+ assert org_name == 'owner'
+
+ @pytest.mark.asyncio
+ @patch('openhands.app_server.app_conversation.skill_loader._is_gitlab_repository')
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._is_azure_devops_repository'
+ )
+ async def test_azure_devops_repository_path(self, mock_is_azure, mock_is_gitlab):
+ """Test org path for Azure DevOps repository."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_is_gitlab.return_value = False
+ mock_is_azure.return_value = True
+
+ # Act
+ org_repo, org_name = await _determine_org_repo_path(
+ 'org/project/repo', mock_user_context
+ )
+
+ # Assert
+ assert org_repo == 'org/openhands-config/openhands-config'
+ assert org_name == 'org'
+
+
+class TestValidateRepositoryForOrgSkills:
+ """Test _validate_repository_for_org_skills helper function."""
+
+ def test_valid_repository_two_parts(self):
+ """Test validation passes for repository with two parts."""
+ # Act
+ result = _validate_repository_for_org_skills('owner/repo')
+
+ # Assert
+ assert result is True
+
+ def test_valid_repository_three_parts(self):
+ """Test validation passes for repository with three parts (Azure DevOps)."""
+ # Act
+ result = _validate_repository_for_org_skills('org/project/repo')
+
+ # Assert
+ assert result is True
+
+ def test_invalid_repository_one_part(self):
+ """Test validation fails for repository with only one part."""
+ # Act
+ result = _validate_repository_for_org_skills('repo')
+
+ # Assert
+ assert result is False
+
+ def test_invalid_repository_empty_string(self):
+ """Test validation fails for empty string."""
+ # Act
+ result = _validate_repository_for_org_skills('')
+
+ # Assert
+ assert result is False
+
+
+class TestGetOrgRepositoryUrl:
+ """Test _get_org_repository_url helper function."""
+
+ @pytest.mark.asyncio
+ async def test_successful_url_retrieval(self):
+ """Test successfully retrieving authenticated URL."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ expected_url = 'https://token@github.com/owner/.openhands.git'
+ mock_user_context.get_authenticated_git_url.return_value = expected_url
+
+ # Act
+ result = await _get_org_repository_url('owner/.openhands', mock_user_context)
+
+ # Assert
+ assert result == expected_url
+ mock_user_context.get_authenticated_git_url.assert_called_once_with(
+ 'owner/.openhands'
+ )
+
+ @pytest.mark.asyncio
+ async def test_authentication_error(self):
+ """Test handling of authentication error returns None."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_user_context.get_authenticated_git_url.side_effect = AuthenticationError(
+ 'Not found'
+ )
+
+ # Act
+ result = await _get_org_repository_url('owner/.openhands', mock_user_context)
+
+ # Assert
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_general_exception(self):
+ """Test handling of general exception returns None."""
+ # Arrange
+ mock_user_context = AsyncMock()
+ mock_user_context.get_authenticated_git_url.side_effect = Exception(
+ 'Network error'
+ )
+
+ # Act
+ result = await _get_org_repository_url('owner/.openhands', mock_user_context)
+
+ # Assert
+ assert result is None
+
+
+class TestCloneOrgRepository:
+ """Test _clone_org_repository helper function."""
+
+ @pytest.mark.asyncio
+ async def test_successful_clone(self, mock_async_remote_workspace):
+ """Test successful repository clone."""
+ # Arrange
+ result_obj = Mock()
+ result_obj.exit_code = 0
+ mock_async_remote_workspace.execute_command.return_value = result_obj
+
+ # Act
+ success = await _clone_org_repository(
+ mock_async_remote_workspace,
+ 'https://github.com/owner/.openhands.git',
+ '/workspace/_org_openhands_owner',
+ '/workspace',
+ 'owner/.openhands',
+ )
+
+ # Assert
+ assert success is True
+ mock_async_remote_workspace.execute_command.assert_called_once()
+ call_args = mock_async_remote_workspace.execute_command.call_args
+ assert 'git clone' in call_args[0][0]
+ assert '--depth 1' in call_args[0][0]
+
+ @pytest.mark.asyncio
+ async def test_failed_clone(self, mock_async_remote_workspace):
+ """Test failed repository clone."""
+ # Arrange
+ result_obj = Mock()
+ result_obj.exit_code = 1
+ result_obj.stderr = 'Repository not found'
+ mock_async_remote_workspace.execute_command.return_value = result_obj
+
+ # Act
+ success = await _clone_org_repository(
+ mock_async_remote_workspace,
+ 'https://github.com/owner/.openhands.git',
+ '/workspace/_org_openhands_owner',
+ '/workspace',
+ 'owner/.openhands',
+ )
+
+ # Assert
+ assert success is False
+
+
+class TestLoadSkillsFromOrgDirectories:
+ """Test _load_skills_from_org_directories helper function."""
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._find_and_load_skill_md_files'
+ )
+ async def test_load_from_both_directories(
+ self, mock_find_and_load, mock_async_remote_workspace, mock_skills_list
+ ):
+ """Test loading skills from both skills/ and microagents/ directories."""
+ # Arrange
+ skills_dir_skills = [mock_skills_list[0]]
+ microagents_dir_skills = [mock_skills_list[1], mock_skills_list[2]]
+ mock_find_and_load.side_effect = [skills_dir_skills, microagents_dir_skills]
+
+ # Act
+ result_skills, result_microagents = await _load_skills_from_org_directories(
+ mock_async_remote_workspace, '/workspace/_org_openhands_owner', '/workspace'
+ )
+
+ # Assert
+ assert result_skills == skills_dir_skills
+ assert result_microagents == microagents_dir_skills
+ assert mock_find_and_load.call_count == 2
+
+ # Verify correct directories were checked
+ first_call = mock_find_and_load.call_args_list[0]
+ second_call = mock_find_and_load.call_args_list[1]
+ assert '/skills' in first_call[0][1]
+ assert '/microagents' in second_call[0][1]
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._find_and_load_skill_md_files'
+ )
+ async def test_load_with_empty_directories(
+ self, mock_find_and_load, mock_async_remote_workspace
+ ):
+ """Test loading when both directories are empty."""
+ # Arrange
+ mock_find_and_load.side_effect = [[], []]
+
+ # Act
+ result_skills, result_microagents = await _load_skills_from_org_directories(
+ mock_async_remote_workspace, '/workspace/_org_openhands_owner', '/workspace'
+ )
+
+ # Assert
+ assert result_skills == []
+ assert result_microagents == []
+
+
+class TestMergeOrgSkillsWithPrecedence:
+ """Test _merge_org_skills_with_precedence helper function."""
+
+ def test_merge_no_duplicates(self, mock_skills_list):
+ """Test merging skills with no name conflicts."""
+ # Arrange
+ skills_dir_skills = [mock_skills_list[0]]
+ microagents_dir_skills = [mock_skills_list[1], mock_skills_list[2]]
+
+ # Act
+ result = _merge_org_skills_with_precedence(
+ skills_dir_skills, microagents_dir_skills
+ )
+
+ # Assert
+ assert len(result) == 3
+ names = {s.name for s in result}
+ assert names == {'skill_0', 'skill_1', 'skill_2'}
+
+ def test_merge_with_duplicate_skills_dir_wins(self):
+ """Test skills/ directory takes precedence over microagents/."""
+ # Arrange
+ skill_from_microagents = Mock()
+ skill_from_microagents.name = 'common_skill'
+ skill_from_microagents.source = 'microagents'
+
+ skill_from_skills = Mock()
+ skill_from_skills.name = 'common_skill'
+ skill_from_skills.source = 'skills'
+
+ # Act
+ result = _merge_org_skills_with_precedence(
+ [skill_from_skills], [skill_from_microagents]
+ )
+
+ # Assert
+ assert len(result) == 1
+ assert result[0].source == 'skills'
+
+ def test_merge_with_empty_lists(self):
+ """Test merging with empty skill lists."""
+ # Act
+ result = _merge_org_skills_with_precedence([], [])
+
+ # Assert
+ assert result == []
+
+ def test_merge_with_only_skills_dir(self, mock_skills_list):
+ """Test merging with only skills/ directory populated."""
+ # Act
+ result = _merge_org_skills_with_precedence([mock_skills_list[0]], [])
+
+ # Assert
+ assert len(result) == 1
+ assert result[0] == mock_skills_list[0]
+
+ def test_merge_with_only_microagents_dir(self, mock_skills_list):
+ """Test merging with only microagents/ directory populated."""
+ # Act
+ result = _merge_org_skills_with_precedence([], [mock_skills_list[0]])
+
+ # Assert
+ assert len(result) == 1
+ assert result[0] == mock_skills_list[0]
+
+
+class TestCleanupOrgRepository:
+ """Test _cleanup_org_repository helper function."""
+
+ @pytest.mark.asyncio
+ async def test_cleanup_successful(self, mock_async_remote_workspace):
+ """Test successful cleanup of org repository directory."""
+ # Arrange
+ result_obj = Mock()
+ result_obj.exit_code = 0
+ mock_async_remote_workspace.execute_command.return_value = result_obj
+
+ # Act
+ await _cleanup_org_repository(
+ mock_async_remote_workspace,
+ '/workspace/_org_openhands_owner',
+ '/workspace',
+ )
+
+ # Assert
+ mock_async_remote_workspace.execute_command.assert_called_once()
+ call_args = mock_async_remote_workspace.execute_command.call_args
+ assert 'rm -rf' in call_args[0][0]
+ assert '/workspace/_org_openhands_owner' in call_args[0][0]
+
+
+class TestLoadOrgSkills:
+ """Test load_org_skills main function."""
+
+ @pytest.mark.asyncio
+ async def test_load_org_skills_no_selected_repository(
+ self, mock_async_remote_workspace
+ ):
+ """Test load_org_skills returns empty list when no repository selected."""
+ # Arrange
+ mock_user_context = AsyncMock()
+
+ # Act
+ result = await load_org_skills(
+ mock_async_remote_workspace, None, '/workspace', mock_user_context
+ )
+
+ # Assert
+ assert result == []
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills'
+ )
+ async def test_load_org_skills_invalid_repository(
+ self, mock_validate, mock_async_remote_workspace
+ ):
+ """Test load_org_skills returns empty list for invalid repository."""
+ # Arrange
+ mock_validate.return_value = False
+ mock_user_context = AsyncMock()
+
+ # Act
+ result = await load_org_skills(
+ mock_async_remote_workspace, 'invalid', '/workspace', mock_user_context
+ )
+
+ # Assert
+ assert result == []
+ mock_validate.assert_called_once_with('invalid')
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._determine_org_repo_path'
+ )
+ @patch('openhands.app_server.app_conversation.skill_loader._get_org_repository_url')
+ async def test_load_org_skills_no_url_available(
+ self,
+ mock_get_url,
+ mock_determine_path,
+ mock_validate,
+ mock_async_remote_workspace,
+ ):
+ """Test load_org_skills returns empty list when URL cannot be retrieved."""
+ # Arrange
+ mock_validate.return_value = True
+ mock_determine_path.return_value = ('owner/.openhands', 'owner')
+ mock_get_url.return_value = None
+ mock_user_context = AsyncMock()
+
+ # Act
+ result = await load_org_skills(
+ mock_async_remote_workspace,
+ 'owner/repo',
+ '/workspace',
+ mock_user_context,
+ )
+
+ # Assert
+ assert result == []
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._determine_org_repo_path'
+ )
+ @patch('openhands.app_server.app_conversation.skill_loader._get_org_repository_url')
+ @patch('openhands.app_server.app_conversation.skill_loader._clone_org_repository')
+ async def test_load_org_skills_clone_fails(
+ self,
+ mock_clone,
+ mock_get_url,
+ mock_determine_path,
+ mock_validate,
+ mock_async_remote_workspace,
+ ):
+ """Test load_org_skills returns empty list when clone fails."""
+ # Arrange
+ mock_validate.return_value = True
+ mock_determine_path.return_value = ('owner/.openhands', 'owner')
+ mock_get_url.return_value = 'https://github.com/owner/.openhands.git'
+ mock_clone.return_value = False
+ mock_user_context = AsyncMock()
+
+ # Act
+ result = await load_org_skills(
+ mock_async_remote_workspace,
+ 'owner/repo',
+ '/workspace',
+ mock_user_context,
+ )
+
+ # Assert
+ assert result == []
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._determine_org_repo_path'
+ )
+ @patch('openhands.app_server.app_conversation.skill_loader._get_org_repository_url')
+ @patch('openhands.app_server.app_conversation.skill_loader._clone_org_repository')
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._load_skills_from_org_directories'
+ )
+ @patch('openhands.app_server.app_conversation.skill_loader._cleanup_org_repository')
+ async def test_load_org_skills_success(
+ self,
+ mock_cleanup,
+ mock_load_skills,
+ mock_clone,
+ mock_get_url,
+ mock_determine_path,
+ mock_validate,
+ mock_async_remote_workspace,
+ mock_skills_list,
+ ):
+ """Test successful org skills loading."""
+ # Arrange
+ mock_validate.return_value = True
+ mock_determine_path.return_value = ('owner/.openhands', 'owner')
+ mock_get_url.return_value = 'https://github.com/owner/.openhands.git'
+ mock_clone.return_value = True
+ mock_load_skills.return_value = ([mock_skills_list[0]], [mock_skills_list[1]])
+ mock_user_context = AsyncMock()
+
+ # Act
+ result = await load_org_skills(
+ mock_async_remote_workspace,
+ 'owner/repo',
+ '/workspace',
+ mock_user_context,
+ )
+
+ # Assert
+ assert len(result) == 2
+ mock_cleanup.assert_called_once()
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills'
+ )
+ async def test_load_org_skills_handles_authentication_error(
+ self, mock_validate, mock_async_remote_workspace
+ ):
+ """Test load_org_skills handles AuthenticationError gracefully."""
+ # Arrange
+ mock_validate.side_effect = AuthenticationError('Auth failed')
+ mock_user_context = AsyncMock()
+
+ # Act
+ result = await load_org_skills(
+ mock_async_remote_workspace,
+ 'owner/repo',
+ '/workspace',
+ mock_user_context,
+ )
+
+ # Assert
+ assert result == []
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.skill_loader._validate_repository_for_org_skills'
+ )
+ async def test_load_org_skills_handles_general_exception(
+ self, mock_validate, mock_async_remote_workspace
+ ):
+ """Test load_org_skills handles general exceptions gracefully."""
+ # Arrange
+ mock_validate.side_effect = Exception('Unexpected error')
+ mock_user_context = AsyncMock()
+
+ # Act
+ result = await load_org_skills(
+ mock_async_remote_workspace,
+ 'owner/repo',
+ '/workspace',
+ mock_user_context,
+ )
+
+ # Assert
+ assert result == []
+
+
# ===== Integration Tests =====
@@ -754,3 +1429,110 @@ class TestSkillLoaderIntegration:
# Should have only one skill with repo source (highest precedence)
assert len(all_skills) == 1
assert all_skills[0].source == 'repo'
+
+ @pytest.mark.asyncio
+ @patch('openhands.app_server.app_conversation.skill_loader.load_global_skills')
+ @patch('openhands.sdk.context.skills.load_user_skills')
+ @patch('openhands.app_server.app_conversation.skill_loader.load_org_skills')
+ @patch('openhands.app_server.app_conversation.skill_loader.load_repo_skills')
+ async def test_loading_with_org_skills_precedence(
+ self,
+ mock_load_repo,
+ mock_load_org,
+ mock_load_user,
+ mock_load_global,
+ mock_async_remote_workspace,
+ ):
+ """Test that org skills fit correctly in precedence order."""
+ # Arrange
+ # Create skills with same name but different sources
+ global_skill = Mock()
+ global_skill.name = 'shared_skill'
+ global_skill.priority = 'low'
+
+ user_skill = Mock()
+ user_skill.name = 'shared_skill'
+ user_skill.priority = 'medium'
+
+ org_skill = Mock()
+ org_skill.name = 'shared_skill'
+ org_skill.priority = 'high'
+
+ repo_skill = Mock()
+ repo_skill.name = 'shared_skill'
+ repo_skill.priority = 'highest'
+
+ mock_load_global.return_value = [global_skill]
+ mock_load_user.return_value = [user_skill]
+ mock_load_org.return_value = [org_skill]
+ mock_load_repo.return_value = [repo_skill]
+
+ mock_user_context = AsyncMock()
+
+ # Act
+ global_skills = mock_load_global()
+ user_skills = mock_load_user()
+ org_skills = await mock_load_org(
+ mock_async_remote_workspace, 'owner/repo', '/workspace', mock_user_context
+ )
+ repo_skills = await mock_load_repo(
+ mock_async_remote_workspace, 'owner/repo', '/workspace'
+ )
+
+ # Merge with correct precedence: global < user < org < repo
+ all_skills = merge_skills([global_skills, user_skills, org_skills, repo_skills])
+
+ # Assert
+ assert len(all_skills) == 1
+ assert all_skills[0].priority == 'highest' # Repo has highest precedence
+
+ @pytest.mark.asyncio
+ @patch('openhands.app_server.app_conversation.skill_loader.load_global_skills')
+ @patch('openhands.sdk.context.skills.load_user_skills')
+ @patch('openhands.app_server.app_conversation.skill_loader.load_org_skills')
+ @patch('openhands.app_server.app_conversation.skill_loader.load_repo_skills')
+ async def test_loading_org_skills_with_unique_names(
+ self,
+ mock_load_repo,
+ mock_load_org,
+ mock_load_user,
+ mock_load_global,
+ mock_async_remote_workspace,
+ ):
+ """Test loading org skills with unique names alongside other sources."""
+ # Arrange
+ global_skill = Mock()
+ global_skill.name = 'global_skill'
+
+ user_skill = Mock()
+ user_skill.name = 'user_skill'
+
+ org_skill = Mock()
+ org_skill.name = 'org_skill'
+
+ repo_skill = Mock()
+ repo_skill.name = 'repo_skill'
+
+ mock_load_global.return_value = [global_skill]
+ mock_load_user.return_value = [user_skill]
+ mock_load_org.return_value = [org_skill]
+ mock_load_repo.return_value = [repo_skill]
+
+ mock_user_context = AsyncMock()
+
+ # Act
+ global_skills = mock_load_global()
+ user_skills = mock_load_user()
+ org_skills = await mock_load_org(
+ mock_async_remote_workspace, 'owner/repo', '/workspace', mock_user_context
+ )
+ repo_skills = await mock_load_repo(
+ mock_async_remote_workspace, 'owner/repo', '/workspace'
+ )
+
+ all_skills = merge_skills([global_skills, user_skills, org_skills, repo_skills])
+
+ # Assert
+ assert len(all_skills) == 4
+ names = {s.name for s in all_skills}
+ assert names == {'global_skill', 'user_skill', 'org_skill', 'repo_skill'}
diff --git a/tests/unit/experiments/test_experiment_manager.py b/tests/unit/experiments/test_experiment_manager.py
index 85faa078f5..c389423cf5 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):
diff --git a/tests/unit/memory/test_conversation_memory.py b/tests/unit/memory/test_conversation_memory.py
index abaa8d9a3d..50fd48f49a 100644
--- a/tests/unit/memory/test_conversation_memory.py
+++ b/tests/unit/memory/test_conversation_memory.py
@@ -158,7 +158,8 @@ def test_ensure_initial_user_message_adds_if_only_system(
system_message = SystemMessageAction(content='System')
system_message._source = EventSource.AGENT
events = [system_message]
- conversation_memory._ensure_initial_user_message(events, initial_user_action)
+ # Pass empty set for forgotten_event_ids (no events have been condensed)
+ conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert len(events) == 2
assert events[0] == system_message
assert events[1] == initial_user_action
@@ -177,7 +178,8 @@ def test_ensure_initial_user_message_correct_already_present(
agent_message,
]
original_events = list(events)
- conversation_memory._ensure_initial_user_message(events, initial_user_action)
+ # Pass empty set for forgotten_event_ids (no events have been condensed)
+ conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert events == original_events
@@ -189,7 +191,8 @@ def test_ensure_initial_user_message_incorrect_at_index_1(
incorrect_second_message = MessageAction(content='Assistant')
incorrect_second_message._source = EventSource.AGENT
events = [system_message, incorrect_second_message]
- conversation_memory._ensure_initial_user_message(events, initial_user_action)
+ # Pass empty set for forgotten_event_ids (no events have been condensed)
+ conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert len(events) == 3
assert events[0] == system_message
assert events[1] == initial_user_action # Correct one inserted
@@ -206,7 +209,8 @@ def test_ensure_initial_user_message_correct_present_later(
# Correct initial message is present, but later in the list
events = [system_message, incorrect_second_message]
conversation_memory._ensure_system_message(events)
- conversation_memory._ensure_initial_user_message(events, initial_user_action)
+ # Pass empty set for forgotten_event_ids (no events have been condensed)
+ conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert len(events) == 3 # Should still insert at index 1, not remove the later one
assert events[0] == system_message
assert events[1] == initial_user_action # Correct one inserted at index 1
@@ -222,7 +226,8 @@ def test_ensure_initial_user_message_different_user_msg_at_index_1(
different_user_message = MessageAction(content='Different User Message')
different_user_message._source = EventSource.USER
events = [system_message, different_user_message]
- conversation_memory._ensure_initial_user_message(events, initial_user_action)
+ # Pass empty set for forgotten_event_ids (no events have been condensed)
+ conversation_memory._ensure_initial_user_message(events, initial_user_action, set())
assert len(events) == 2
assert events[0] == system_message
assert events[1] == different_user_message # Original second message remains
@@ -1583,3 +1588,132 @@ def test_process_ipython_observation_with_vision_disabled(
assert isinstance(message.content[1], ImageContent)
# Check that NO explanatory text about filtered images was added when vision is disabled
assert 'invalid or empty image(s) were filtered' not in message.content[0].text
+
+
+def test_ensure_initial_user_message_not_reinserted_when_condensed(
+ conversation_memory, initial_user_action
+):
+ """Test that initial user message is NOT re-inserted when it has been condensed.
+
+ This is a critical test for bug #11910: Old instructions should not be re-executed
+ after conversation condensation. If the initial user message has been condensed
+ (its ID is in the forgotten_event_ids set), we should NOT re-insert it to prevent
+ the LLM from seeing old instructions as fresh commands.
+ """
+ system_message = SystemMessageAction(content='System')
+ system_message._source = EventSource.AGENT
+
+ # Simulate that the initial_user_action has been condensed by adding its ID
+ # to the forgotten_event_ids set
+ initial_user_action._id = 1 # Assign an ID to the initial user action
+ forgotten_event_ids = {1} # The initial user action's ID is in the forgotten set
+
+ events = [system_message] # Only system message, no user message
+
+ # Call _ensure_initial_user_message with the condensed event ID
+ conversation_memory._ensure_initial_user_message(
+ events, initial_user_action, forgotten_event_ids
+ )
+
+ # The initial user action should NOT be inserted because it was condensed
+ assert len(events) == 1
+ assert events[0] == system_message
+ # Verify the initial user action was NOT added
+ assert initial_user_action not in events
+
+
+def test_ensure_initial_user_message_reinserted_when_not_condensed(
+ conversation_memory, initial_user_action
+):
+ """Test that initial user message IS re-inserted when it has NOT been condensed.
+
+ This ensures backward compatibility: when no condensation has happened,
+ the initial user message should still be inserted as before.
+ """
+ system_message = SystemMessageAction(content='System')
+ system_message._source = EventSource.AGENT
+
+ # The initial user action has NOT been condensed
+ initial_user_action._id = 1
+ forgotten_event_ids = {5, 10, 15} # Different IDs, not including the initial action
+
+ events = [system_message]
+
+ # Call _ensure_initial_user_message with non-matching forgotten IDs
+ conversation_memory._ensure_initial_user_message(
+ events, initial_user_action, forgotten_event_ids
+ )
+
+ # The initial user action SHOULD be inserted because it was NOT condensed
+ assert len(events) == 2
+ assert events[0] == system_message
+ assert events[1] == initial_user_action
+
+
+def test_process_events_does_not_reinsert_condensed_initial_message(
+ conversation_memory,
+):
+ """Test that process_events does not re-insert initial user message when condensed.
+
+ This is an integration test for the full process_events flow, verifying that
+ when the initial user message has been condensed, it is not re-inserted into
+ the conversation sent to the LLM.
+ """
+ # Create a system message
+ system_message = SystemMessageAction(content='System message')
+ system_message._source = EventSource.AGENT
+ system_message._id = 0
+
+ # Create the initial user message (will be marked as condensed)
+ initial_user_message = MessageAction(content='Do task A, B, and C')
+ initial_user_message._source = EventSource.USER
+ initial_user_message._id = 1
+
+ # Create a condensation summary observation
+ from openhands.events.observation.agent import AgentCondensationObservation
+
+ condensation_summary = AgentCondensationObservation(
+ content='Summary: User requested tasks A, B, C. Task A was completed successfully.'
+ )
+ condensation_summary._id = 2
+
+ # Create a recent user message (not condensed)
+ recent_user_message = MessageAction(content='Now continue with task D')
+ recent_user_message._source = EventSource.USER
+ recent_user_message._id = 3
+
+ # Simulate condensed history: system + summary + recent message
+ # The initial user message (id=1) has been condensed/forgotten
+ condensed_history = [system_message, condensation_summary, recent_user_message]
+
+ # The initial user message's ID is in the forgotten set
+ forgotten_event_ids = {1}
+
+ messages = conversation_memory.process_events(
+ condensed_history=condensed_history,
+ initial_user_action=initial_user_message,
+ forgotten_event_ids=forgotten_event_ids,
+ max_message_chars=None,
+ vision_is_active=False,
+ )
+
+ # Verify the structure of messages
+ # Should have: system, condensation summary, recent user message
+ # Should NOT have the initial user message "Do task A, B, and C"
+ assert len(messages) == 3
+ assert messages[0].role == 'system'
+ assert messages[0].content[0].text == 'System message'
+
+ # The second message should be the condensation summary, NOT the initial user message
+ assert messages[1].role == 'user'
+ assert 'Summary: User requested tasks A, B, C' in messages[1].content[0].text
+
+ # The third message should be the recent user message
+ assert messages[2].role == 'user'
+ assert 'Now continue with task D' in messages[2].content[0].text
+
+ # Critically, the old instruction should NOT appear
+ for msg in messages:
+ for content in msg.content:
+ if hasattr(content, 'text'):
+ assert 'Do task A, B, and C' not in content.text