({
+ key: agent,
+ label: agent, // TODO: Add i18n support for agent names
+ })) || []
+ }
+ defaultSelectedKey={settings.agent}
+ isClearable={false}
+ onInputChange={handleAgentIsDirty}
+ wrapperClassName="w-full max-w-[680px]"
+ />
+ )}
>
)}
@@ -600,11 +662,11 @@ function LlmSettingsScreen() {
step={1}
label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)}
defaultValue={(
- settings.CONDENSER_MAX_SIZE ??
- DEFAULT_SETTINGS.CONDENSER_MAX_SIZE
+ settings.condenser_max_size ??
+ DEFAULT_SETTINGS.condenser_max_size
)?.toString()}
onChange={(value) => handleCondenserMaxSizeIsDirty(value)}
- isDisabled={!settings.ENABLE_DEFAULT_CONDENSER}
+ isDisabled={!settings.enable_default_condenser}
/>
{t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP)}
@@ -614,7 +676,7 @@ function LlmSettingsScreen() {
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
@@ -626,7 +688,7 @@ function LlmSettingsScreen() {
testId="enable-confirmation-mode-switch"
name="enable-confirmation-mode-switch"
onToggle={handleConfirmationModeIsDirty}
- defaultIsToggled={settings.CONFIRMATION_MODE}
+ defaultIsToggled={settings.confirmation_mode}
isBeta
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
diff --git a/frontend/src/routes/mcp-settings.tsx b/frontend/src/routes/mcp-settings.tsx
index 0a4224182bd0..e308b45228cc 100644
--- a/frontend/src/routes/mcp-settings.tsx
+++ b/frontend/src/routes/mcp-settings.tsx
@@ -41,7 +41,7 @@ function MCPSettingsScreen() {
useState(false);
const [serverToDelete, setServerToDelete] = useState(null);
- const mcpConfig: MCPConfig = settings?.MCP_CONFIG || {
+ const mcpConfig: MCPConfig = settings?.mcp_config || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
diff --git a/frontend/src/routes/planner-tab.tsx b/frontend/src/routes/planner-tab.tsx
index a3002c665119..f17a0acbc52a 100644
--- a/frontend/src/routes/planner-tab.tsx
+++ b/frontend/src/routes/planner-tab.tsx
@@ -1,49 +1,31 @@
+import React from "react";
import { useTranslation } from "react-i18next";
-import Markdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-import remarkBreaks from "remark-breaks";
import { I18nKey } from "#/i18n/declaration";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
-import { useConversationStore } from "#/state/conversation-store";
-import { code } from "#/components/features/markdown/code";
-import { ul, ol } from "#/components/features/markdown/list";
-import { paragraph } from "#/components/features/markdown/paragraph";
-import { anchor } from "#/components/features/markdown/anchor";
-import {
- h1,
- h2,
- h3,
- h4,
- h5,
- h6,
-} from "#/components/features/markdown/headings";
+import { useConversationStore } from "#/stores/conversation-store";
+import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
+import { MarkdownRenderer } from "#/components/features/markdown/markdown-renderer";
+import { useHandlePlanClick } from "#/hooks/use-handle-plan-click";
function PlannerTab() {
const { t } = useTranslation();
+ const { scrollRef: scrollContainerRef, onChatBodyScroll } = useScrollToBottom(
+ React.useRef(null),
+ );
- const { planContent, setConversationMode } = useConversationStore();
+ const { planContent } = useConversationStore();
+ const { handlePlanClick } = useHandlePlanClick();
- if (planContent) {
+ if (planContent !== null && planContent !== undefined) {
return (
-
-
+ onChatBodyScroll(e.currentTarget)}
+ className="flex flex-col w-full h-full p-4 overflow-auto"
+ >
+
{planContent}
-
+
);
}
@@ -56,7 +38,7 @@ function PlannerTab() {
setConversationMode("plan")}
+ onClick={handlePlanClick}
className="flex w-[164px] h-[40px] p-2 justify-center items-center shrink-0 rounded-lg bg-white overflow-hidden text-black text-ellipsis font-sans text-[16px] not-italic font-normal leading-[20px] hover:cursor-pointer hover:opacity-80"
>
{t(I18nKey.COMMON$CREATE_A_PLAN)}
diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx
index 264ae541c88d..876c4d8c11a1 100644
--- a/frontend/src/routes/root-layout.tsx
+++ b/frontend/src/routes/root-layout.tsx
@@ -106,16 +106,16 @@ export default function MainApp() {
React.useEffect(() => {
// Don't change language when on TOS page
- if (!isOnTosPage && settings?.LANGUAGE) {
- i18n.changeLanguage(settings.LANGUAGE);
+ if (!isOnTosPage && settings?.language) {
+ i18n.changeLanguage(settings.language);
}
- }, [settings?.LANGUAGE, isOnTosPage]);
+ }, [settings?.language, isOnTosPage]);
React.useEffect(() => {
// Don't show consent form when on TOS page
if (!isOnTosPage) {
const consentFormModalIsOpen =
- settings?.USER_CONSENTS_TO_ANALYTICS === null;
+ settings?.user_consents_to_analytics === null;
setConsentFormIsOpen(consentFormModalIsOpen);
}
@@ -134,10 +134,10 @@ export default function MainApp() {
}, [isOnTosPage]);
React.useEffect(() => {
- if (settings?.IS_NEW_USER && config.data?.APP_MODE === "saas") {
+ if (settings?.is_new_user && config.data?.APP_MODE === "saas") {
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
}
- }, [settings?.IS_NEW_USER, config.data?.APP_MODE]);
+ }, [settings?.is_new_user, config.data?.APP_MODE]);
React.useEffect(() => {
// Don't do any redirects when on TOS page
@@ -249,7 +249,7 @@ export default function MainApp() {
{config.data?.FEATURE_FLAGS.ENABLE_BILLING &&
config.data?.APP_MODE === "saas" &&
- settings?.IS_NEW_USER && }
+ settings?.is_new_user && }
);
}
diff --git a/frontend/src/routes/served-tab.tsx b/frontend/src/routes/served-tab.tsx
index f2f6b2688383..b6abb5b3d3ca 100644
--- a/frontend/src/routes/served-tab.tsx
+++ b/frontend/src/routes/served-tab.tsx
@@ -65,6 +65,7 @@ function ServedApp() {
type="button"
onClick={() => window.open(fullUrl, "_blank")}
className="text-sm"
+ aria-label={t(I18nKey.BUTTON$OPEN_IN_NEW_TAB)}
>
@@ -72,11 +73,17 @@ function ServedApp() {
type="button"
onClick={() => setRefreshKey((prev) => prev + 1)}
className="text-sm"
+ aria-label={t(I18nKey.BUTTON$REFRESH)}
>
- resetUrl()} className="text-sm">
+ resetUrl()}
+ className="text-sm"
+ aria-label={t(I18nKey.BUTTON$HOME)}
+ >
diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx
index 19370245b330..4f35595d1319 100644
--- a/frontend/src/routes/settings.tsx
+++ b/frontend/src/routes/settings.tsx
@@ -1,14 +1,13 @@
import { useMemo } from "react";
import { Outlet, redirect, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
-import { useConfig } from "#/hooks/query/use-config";
import { Route } from "./+types/settings";
import OptionService from "#/api/option-service/option-service.api";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/option-service/option.types";
-import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
-import { Typography } from "#/ui/typography";
import { SettingsLayout } from "#/components/features/settings/settings-layout";
+import { Typography } from "#/ui/typography";
+import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
const SAAS_ONLY_PATHS = [
"/settings/user",
@@ -33,32 +32,26 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
// if in OSS mode, do not allow access to saas-only paths
return redirect("/settings");
}
+ // If LLM settings are hidden and user tries to access the LLM settings page
+ if (config?.FEATURE_FLAGS?.HIDE_LLM_SETTINGS && pathname === "/settings") {
+ // Redirect to the first available settings page
+ return isSaas ? redirect("/settings/user") : redirect("/settings/mcp");
+ }
return null;
};
function SettingsScreen() {
const { t } = useTranslation();
- const { data: config } = useConfig();
const location = useLocation();
-
- const isSaas = config?.APP_MODE === "saas";
-
- // Navigation items configuration
- const navItems = useMemo(() => {
- const items = [];
- if (isSaas) {
- items.push(...SAAS_NAV_ITEMS);
- } else {
- items.push(...OSS_NAV_ITEMS);
- }
- return items;
- }, [isSaas]);
-
+ const navItems = useSettingsNavItems();
// Current section title for the main content area
const currentSectionTitle = useMemo(() => {
const currentItem = navItems.find((item) => item.to === location.pathname);
- return currentItem ? currentItem.text : "SETTINGS$NAV_LLM";
+ // Default to the first available navigation item if current page is not found
+ return currentItem
+ ? currentItem.text
+ : (navItems[0]?.text ?? "SETTINGS$TITLE");
}, [navItems, location.pathname]);
return (
diff --git a/frontend/src/routes/user-settings.tsx b/frontend/src/routes/user-settings.tsx
index 93366574b051..cddc38466ebf 100644
--- a/frontend/src/routes/user-settings.tsx
+++ b/frontend/src/routes/user-settings.tsx
@@ -122,12 +122,12 @@ function UserSettingsScreen() {
const prevVerificationStatusRef = useRef(undefined);
useEffect(() => {
- if (settings?.EMAIL) {
- setEmail(settings.EMAIL);
- setOriginalEmail(settings.EMAIL);
- setIsEmailValid(EMAIL_REGEX.test(settings.EMAIL));
+ if (settings?.email) {
+ setEmail(settings.email);
+ setOriginalEmail(settings.email);
+ setIsEmailValid(EMAIL_REGEX.test(settings.email));
}
- }, [settings?.EMAIL]);
+ }, [settings?.email]);
useEffect(() => {
if (pollingIntervalRef.current) {
@@ -137,7 +137,7 @@ function UserSettingsScreen() {
if (
prevVerificationStatusRef.current === false &&
- settings?.EMAIL_VERIFIED === true
+ settings?.email_verified === true
) {
// Display toast notification instead of setting state
displaySuccessToast(t("SETTINGS$EMAIL_VERIFIED_SUCCESSFULLY"));
@@ -146,9 +146,9 @@ function UserSettingsScreen() {
}, 2000);
}
- prevVerificationStatusRef.current = settings?.EMAIL_VERIFIED;
+ prevVerificationStatusRef.current = settings?.email_verified;
- if (settings?.EMAIL_VERIFIED === false) {
+ if (settings?.email_verified === false) {
pollingIntervalRef.current = window.setInterval(() => {
refetch();
}, 5000);
@@ -160,7 +160,7 @@ function UserSettingsScreen() {
pollingIntervalRef.current = null;
}
};
- }, [settings?.EMAIL_VERIFIED, refetch, queryClient, t]);
+ }, [settings?.email_verified, refetch, queryClient, t]);
const handleEmailChange = (e: React.ChangeEvent) => {
const newEmail = e.target.value;
@@ -215,10 +215,10 @@ function UserSettingsScreen() {
isSaving={isSaving}
isResendingVerification={isResendingVerification}
isEmailChanged={isEmailChanged}
- emailVerified={settings?.EMAIL_VERIFIED}
+ emailVerified={settings?.email_verified}
isEmailValid={isEmailValid}
>
- {settings?.EMAIL_VERIFIED === false && }
+ {settings?.email_verified === false && }
)}
diff --git a/frontend/src/routes/vscode-tab.tsx b/frontend/src/routes/vscode-tab.tsx
index 0d64180c1d2f..e1bb2e8fe4cb 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/actions.ts b/frontend/src/services/actions.ts
index 86b89106ff09..6f03c526e124 100644
--- a/frontend/src/services/actions.ts
+++ b/frontend/src/services/actions.ts
@@ -1,6 +1,6 @@
import { trackError } from "#/utils/error-handler";
import useMetricsStore from "#/stores/metrics-store";
-import { useStatusStore } from "#/state/status-store";
+import { useStatusStore } from "#/stores/status-store";
import ActionType from "#/types/action-type";
import {
ActionMessage,
@@ -8,7 +8,7 @@ import {
StatusMessage,
} from "#/types/message";
import { handleObservationMessage } from "./observations";
-import { useCommandStore } from "#/state/command-store";
+import { useCommandStore } from "#/stores/command-store";
import { queryClient } from "#/query-client-config";
import {
ActionSecurityRisk,
diff --git a/frontend/src/services/observations.ts b/frontend/src/services/observations.ts
index 40cc1daa8a27..8f1d8d3b4119 100644
--- a/frontend/src/services/observations.ts
+++ b/frontend/src/services/observations.ts
@@ -1,5 +1,5 @@
import { ObservationMessage } from "#/types/message";
-import { useCommandStore } from "#/state/command-store";
+import { useCommandStore } from "#/stores/command-store";
import ObservationType from "#/types/observation-type";
import { useBrowserStore } from "#/stores/browser-store";
import { useAgentStore } from "#/stores/agent-store";
diff --git a/frontend/src/services/settings.ts b/frontend/src/services/settings.ts
index f7cad15b43a9..e4a04b1e87f2 100644
--- a/frontend/src/services/settings.ts
+++ b/frontend/src/services/settings.ts
@@ -3,34 +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-opus-4-5-20251101",
+ 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",
+ git_user_name: "openhands",
+ git_user_email: "openhands@all-hands.dev",
+ v1_enabled: false,
};
/**
diff --git a/frontend/src/settings-service/settings.types.ts b/frontend/src/settings-service/settings.types.ts
deleted file mode 100644
index bdd1610f4923..000000000000
--- a/frontend/src/settings-service/settings.types.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Provider } from "#/types/settings";
-
-export type ApiSettings = {
- 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;
- enable_default_condenser: boolean;
- // Max size for condenser in backend 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;
- search_api_key?: string;
- provider_tokens_set: Partial
>;
- max_budget_per_task: number | null;
- mcp_config?: {
- sse_servers: (string | { url: string; api_key?: string })[];
- stdio_servers: {
- name: string;
- command: string;
- args?: string[];
- env?: Record;
- }[];
- shttp_servers: (string | { url: string; api_key?: string })[];
- };
- email?: string;
- email_verified?: boolean;
- git_user_name?: string;
- git_user_email?: string;
-};
-
-export type PostApiSettings = ApiSettings & {
- user_consents_to_analytics: boolean | null;
- search_api_key?: string;
- mcp_config?: {
- sse_servers: (string | { url: string; api_key?: string })[];
- stdio_servers: {
- name: string;
- command: string;
- args?: string[];
- env?: Record;
- }[];
- shttp_servers: (string | { url: string; api_key?: string })[];
- };
-};
diff --git a/frontend/src/state/command-store.ts b/frontend/src/stores/command-store.ts
similarity index 100%
rename from frontend/src/state/command-store.ts
rename to frontend/src/stores/command-store.ts
diff --git a/frontend/src/state/conversation-store.ts b/frontend/src/stores/conversation-store.ts
similarity index 68%
rename from frontend/src/state/conversation-store.ts
rename to frontend/src/stores/conversation-store.ts
index 77186ce69c9a..a8edd16f6a44 100644
--- a/frontend/src/state/conversation-store.ts
+++ b/frontend/src/stores/conversation-store.ts
@@ -56,14 +56,53 @@ interface ConversationActions {
setHasRightPanelToggled: (hasRightPanelToggled: boolean) => void;
setConversationMode: (conversationMode: ConversationMode) => void;
setSubConversationTaskId: (taskId: string | null) => void;
+ setPlanContent: (planContent: string | null) => void;
}
type ConversationStore = ConversationState & ConversationActions;
-// Helper function to get initial right panel state from localStorage
+const getConversationIdFromLocation = (): string | null => {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ const match = window.location.pathname.match(/\/conversations\/([^/]+)/);
+ return match ? match[1] : null;
+};
+
+const parseStoredBoolean = (value: string | null): boolean | null => {
+ if (value === null) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+};
+
const getInitialRightPanelState = (): boolean => {
- const stored = localStorage.getItem("conversation-right-panel-shown");
- return stored !== null ? JSON.parse(stored) : true;
+ if (typeof window === "undefined") {
+ return true;
+ }
+
+ const conversationId = getConversationIdFromLocation();
+ const keysToCheck = conversationId
+ ? [`conversation-right-panel-shown-${conversationId}`]
+ : [];
+
+ // Fallback to legacy global key for users who haven't switched tabs yet
+ keysToCheck.push("conversation-right-panel-shown");
+
+ for (const key of keysToCheck) {
+ const parsed = parseStoredBoolean(localStorage.getItem(key));
+ if (parsed !== null) {
+ return parsed;
+ }
+ }
+
+ return true;
};
export const useConversationStore = create()(
@@ -81,91 +120,7 @@ export const useConversationStore = create()(
submittedMessage: null,
shouldHideSuggestions: false,
hasRightPanelToggled: true,
- planContent: `
-# Improve Developer Onboarding and Examples
-
-## Overview
-
-Based on the analysis of Browser-Use's current documentation and examples, this plan addresses gaps in developer onboarding by creating a progressive learning path, troubleshooting resources, and practical examples that address real-world scenarios (like the LM Studio/local LLM integration issues encountered).
-
-## Current State Analysis
-
-**Strengths:**
-
-- Good quickstart documentation in \`docs/quickstart.mdx\`
-- Extensive examples across multiple categories (60+ example files)
-- Well-structured docs with multiple LLM provider examples
-- Active community support via Discord
-
-**Gaps Identified:**
-
-- No progressive tutorial series that builds complexity gradually
-- Limited troubleshooting documentation for common issues
-- Sparse comments in example files explaining what's happening
-- Local LLM setup (Ollama/LM Studio) not prominently featured
-- No "first 10 minutes" success path
-- Missing visual/conceptual architecture guides for beginners
-- Error messages don't always point to solutions
-
-## Proposed Improvements
-
-### 1. Create Interactive Tutorial Series (\`examples/tutorials/\`)
-
-**New folder structure:**
-
-\`\`\`
-examples/tutorials/
-├── README.md # Tutorial overview and prerequisites
-├── 00_hello_world.py # Absolute minimal example
-├── 01_your_first_search.py # Basic search with detailed comments
-├── 02_understanding_actions.py # How actions work
-├── 03_data_extraction_basics.py # Extract data step-by-step
-├── 04_error_handling.py # Common errors and solutions
-├── 05_custom_tools_intro.py # First custom tool
-├── 06_local_llm_setup.py # Ollama/LM Studio complete guide
-└── 07_debugging_tips.py # Debugging strategies
-\`\`\`
-
-**Key Features:**
-
-- Each file 50–80 lines max
-- Extensive inline comments explaining every concept
-- Clear learning objectives at the top of each file
-- "What you'll learn" and "Prerequisites" sections
-- Common pitfalls highlighted
-- Expected output shown in comments
-
-### 2. Troubleshooting Guide (\`docs/troubleshooting.mdx\`)
-
-**Sections:**
-
-- Installation issues (Chromium, dependencies, virtual environments)
-- LLM provider connection errors (API keys, timeouts, rate limits)
-- Local LLM setup (Ollama vs LM Studio, model compatibility)
-- Browser automation issues (element not found, timeout errors)
-- Common error messages with solutions
-- Performance optimization tips
-- When to ask for help (Discord/GitHub)
-
-**Format:**
-
-**Error: "LLM call timed out after 60 seconds"**
-
-**What it means:**
-The model took too long to respond
-
-**Common causes:**
-
-1. Model is too slow for the task
-2. LM Studio/Ollama not responding properly
-3. Complex page overwhelming the model
-
-**Solutions:**
-
-- Use flash_mode for faster execution
-- Try a faster model (Gemini Flash, GPT-4 Turbo Mini)
-- Simplify the task
-- Check model server logs`,
+ planContent: null,
conversationMode: "code",
subConversationTaskId: null,
@@ -304,6 +259,7 @@ The model took too long to respond
shouldHideSuggestions: false,
conversationMode: "code",
subConversationTaskId: null,
+ planContent: null,
},
false,
"resetConversationState",
@@ -317,6 +273,9 @@ The model took too long to respond
setSubConversationTaskId: (subConversationTaskId) =>
set({ subConversationTaskId }, false, "setSubConversationTaskId"),
+
+ setPlanContent: (planContent) =>
+ set({ planContent }, false, "setPlanContent"),
}),
{
name: "conversation-store",
diff --git a/frontend/src/stores/home-store.ts b/frontend/src/stores/home-store.ts
index 3ec2ed2c26a4..6289f65f0160 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/state/microagent-management-store.ts b/frontend/src/stores/microagent-management-store.ts
similarity index 100%
rename from frontend/src/state/microagent-management-store.ts
rename to frontend/src/stores/microagent-management-store.ts
diff --git a/frontend/src/state/status-store.ts b/frontend/src/stores/status-store.ts
similarity index 100%
rename from frontend/src/state/status-store.ts
rename to frontend/src/stores/status-store.ts
diff --git a/frontend/src/stores/use-event-store.ts b/frontend/src/stores/use-event-store.ts
index 307f4ced0d7b..2d8ecf0a3b6c 100644
--- a/frontend/src/stores/use-event-store.ts
+++ b/frontend/src/stores/use-event-store.ts
@@ -5,7 +5,9 @@ import { OpenHandsParsedEvent } from "#/types/core";
import { isV1Event } from "#/types/v1/type-guards";
// While we transition to v1 events, our store can handle both v0 and v1 events
-type OHEvent = OpenHandsEvent | OpenHandsParsedEvent;
+type OHEvent = (OpenHandsEvent | OpenHandsParsedEvent) & {
+ isFromPlanningAgent?: boolean;
+};
interface EventState {
events: OHEvent[];
diff --git a/frontend/src/tailwind.css b/frontend/src/tailwind.css
index 8228f6b15440..16732885646a 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/core/actions.ts b/frontend/src/types/core/actions.ts
index 89852f16e31e..bb80971e3285 100644
--- a/frontend/src/types/core/actions.ts
+++ b/frontend/src/types/core/actions.ts
@@ -31,8 +31,7 @@ export interface CommandAction extends OpenHandsActionEvent<"run"> {
};
}
-export interface AssistantMessageAction
- extends OpenHandsActionEvent<"message"> {
+export interface AssistantMessageAction extends OpenHandsActionEvent<"message"> {
source: "agent";
args: {
thought: string;
@@ -87,8 +86,7 @@ export interface BrowseAction extends OpenHandsActionEvent<"browse"> {
};
}
-export interface BrowseInteractiveAction
- extends OpenHandsActionEvent<"browse_interactive"> {
+export interface BrowseInteractiveAction extends OpenHandsActionEvent<"browse_interactive"> {
source: "agent";
timeout: number;
args: {
@@ -162,8 +160,7 @@ export interface MCPAction extends OpenHandsActionEvent<"call_tool_mcp"> {
};
}
-export interface TaskTrackingAction
- extends OpenHandsActionEvent<"task_tracking"> {
+export interface TaskTrackingAction extends OpenHandsActionEvent<"task_tracking"> {
source: "agent";
args: {
command: string;
diff --git a/frontend/src/types/core/base.ts b/frontend/src/types/core/base.ts
index 4014d2bbb5fa..e305bf7d4d6d 100644
--- a/frontend/src/types/core/base.ts
+++ b/frontend/src/types/core/base.ts
@@ -30,14 +30,16 @@ interface OpenHandsBaseEvent {
timestamp: string; // ISO 8601
}
-export interface OpenHandsActionEvent
- extends OpenHandsBaseEvent {
+export interface OpenHandsActionEvent<
+ T extends OpenHandsEventType,
+> extends OpenHandsBaseEvent {
action: T;
args: Record;
}
-export interface OpenHandsObservationEvent
- extends OpenHandsBaseEvent {
+export interface OpenHandsObservationEvent<
+ T extends OpenHandsEventType,
+> extends OpenHandsBaseEvent {
cause: number;
observation: T;
content: string;
diff --git a/frontend/src/types/core/observations.ts b/frontend/src/types/core/observations.ts
index 01a73ec81beb..2741926fdaf7 100644
--- a/frontend/src/types/core/observations.ts
+++ b/frontend/src/types/core/observations.ts
@@ -1,8 +1,7 @@
import { AgentState } from "../agent-state";
import { OpenHandsObservationEvent } from "./base";
-export interface AgentStateChangeObservation
- extends OpenHandsObservationEvent<"agent_state_changed"> {
+export interface AgentStateChangeObservation extends OpenHandsObservationEvent<"agent_state_changed"> {
source: "agent";
extras: {
agent_state: AgentState;
@@ -19,8 +18,7 @@ export interface CommandObservation extends OpenHandsObservationEvent<"run"> {
};
}
-export interface IPythonObservation
- extends OpenHandsObservationEvent<"run_ipython"> {
+export interface IPythonObservation extends OpenHandsObservationEvent<"run_ipython"> {
source: "agent";
extras: {
code: string;
@@ -28,8 +26,7 @@ export interface IPythonObservation
};
}
-export interface DelegateObservation
- extends OpenHandsObservationEvent<"delegate"> {
+export interface DelegateObservation extends OpenHandsObservationEvent<"delegate"> {
source: "agent";
extras: {
outputs: Record;
@@ -53,8 +50,7 @@ export interface BrowseObservation extends OpenHandsObservationEvent<"browse"> {
};
}
-export interface BrowseInteractiveObservation
- extends OpenHandsObservationEvent<"browse_interactive"> {
+export interface BrowseInteractiveObservation extends OpenHandsObservationEvent<"browse_interactive"> {
source: "agent";
extras: {
url: string;
@@ -103,8 +99,7 @@ export interface ErrorObservation extends OpenHandsObservationEvent<"error"> {
};
}
-export interface AgentThinkObservation
- extends OpenHandsObservationEvent<"think"> {
+export interface AgentThinkObservation extends OpenHandsObservationEvent<"think"> {
source: "agent";
extras: {
thought: string;
@@ -141,14 +136,12 @@ export interface MCPObservation extends OpenHandsObservationEvent<"mcp"> {
};
}
-export interface UserRejectedObservation
- extends OpenHandsObservationEvent<"user_rejected"> {
+export interface UserRejectedObservation extends OpenHandsObservationEvent<"user_rejected"> {
source: "agent";
extras: Record;
}
-export interface TaskTrackingObservation
- extends OpenHandsObservationEvent<"task_tracking"> {
+export interface TaskTrackingObservation extends OpenHandsObservationEvent<"task_tracking"> {
source: "agent";
extras: {
command: string;
diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts
index f76fcaa19a95..e5db0296bd1b 100644
--- a/frontend/src/types/settings.ts
+++ b/frontend/src/types/settings.ts
@@ -38,36 +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;
-};
-
-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/action.ts b/frontend/src/types/v1/core/base/action.ts
index ce08d5a1b99a..8d3ec41bff48 100644
--- a/frontend/src/types/v1/core/base/action.ts
+++ b/frontend/src/types/v1/core/base/action.ts
@@ -41,6 +41,25 @@ export interface ExecuteBashAction extends ActionBase<"ExecuteBashAction"> {
reset: boolean;
}
+export interface TerminalAction extends ActionBase<"TerminalAction"> {
+ /**
+ * The terminal command to execute.
+ */
+ command: string;
+ /**
+ * If True, the command is an input to the running process. If False, the command is executed directly.
+ */
+ is_input: boolean;
+ /**
+ * Optional max time limit (seconds) for the command.
+ */
+ timeout: number | null;
+ /**
+ * If True, reset the terminal session before running the command.
+ */
+ reset: boolean;
+}
+
export interface FileEditorAction extends ActionBase<"FileEditorAction"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
@@ -72,8 +91,7 @@ export interface FileEditorAction extends ActionBase<"FileEditorAction"> {
view_range: [number, number] | null;
}
-export interface StrReplaceEditorAction
- extends ActionBase<"StrReplaceEditorAction"> {
+export interface StrReplaceEditorAction extends ActionBase<"StrReplaceEditorAction"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
*/
@@ -115,8 +133,7 @@ export interface TaskTrackerAction extends ActionBase<"TaskTrackerAction"> {
task_list: TaskItem[];
}
-export interface BrowserNavigateAction
- extends ActionBase<"BrowserNavigateAction"> {
+export interface BrowserNavigateAction extends ActionBase<"BrowserNavigateAction"> {
/**
* The URL to navigate to
*/
@@ -149,16 +166,14 @@ export interface BrowserTypeAction extends ActionBase<"BrowserTypeAction"> {
text: string;
}
-export interface BrowserGetStateAction
- extends ActionBase<"BrowserGetStateAction"> {
+export interface BrowserGetStateAction extends ActionBase<"BrowserGetStateAction"> {
/**
* Whether to include a screenshot of the current page. Default: False
*/
include_screenshot: boolean;
}
-export interface BrowserGetContentAction
- extends ActionBase<"BrowserGetContentAction"> {
+export interface BrowserGetContentAction extends ActionBase<"BrowserGetContentAction"> {
/**
* Whether to include links in the content (default: False)
*/
@@ -180,21 +195,18 @@ export interface BrowserGoBackAction extends ActionBase<"BrowserGoBackAction"> {
// No additional properties - this action has no parameters
}
-export interface BrowserListTabsAction
- extends ActionBase<"BrowserListTabsAction"> {
+export interface BrowserListTabsAction extends ActionBase<"BrowserListTabsAction"> {
// No additional properties - this action has no parameters
}
-export interface BrowserSwitchTabAction
- extends ActionBase<"BrowserSwitchTabAction"> {
+export interface BrowserSwitchTabAction extends ActionBase<"BrowserSwitchTabAction"> {
/**
* 4 Character Tab ID of the tab to switch to (from browser_list_tabs)
*/
tab_id: string;
}
-export interface BrowserCloseTabAction
- extends ActionBase<"BrowserCloseTabAction"> {
+export interface BrowserCloseTabAction extends ActionBase<"BrowserCloseTabAction"> {
/**
* 4 Character Tab ID of the tab to close (from browser_list_tabs)
*/
@@ -206,6 +218,7 @@ export type Action =
| FinishAction
| ThinkAction
| ExecuteBashAction
+ | TerminalAction
| FileEditorAction
| StrReplaceEditorAction
| TaskTrackerAction
diff --git a/frontend/src/types/v1/core/base/base.ts b/frontend/src/types/v1/core/base/base.ts
index 5925e8599d43..7704f1105de5 100644
--- a/frontend/src/types/v1/core/base/base.ts
+++ b/frontend/src/types/v1/core/base/base.ts
@@ -3,9 +3,11 @@ type EventType =
| "Finish"
| "Think"
| "ExecuteBash"
+ | "Terminal"
| "FileEditor"
| "StrReplaceEditor"
- | "TaskTracker";
+ | "TaskTracker"
+ | "PlanningFileEditor";
type ActionOnlyType =
| "BrowserNavigate"
@@ -24,7 +26,8 @@ type ObservationOnlyType = "Browser";
type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`;
type ObservationEventType =
| `${ObservationOnlyType}Observation`
- | `${EventType}Observation`;
+ | `${EventType}Observation`
+ | "TerminalObservation";
export interface ActionBase {
kind: T;
diff --git a/frontend/src/types/v1/core/base/observation.ts b/frontend/src/types/v1/core/base/observation.ts
index e406e30593fb..a1c8a1a48d25 100644
--- a/frontend/src/types/v1/core/base/observation.ts
+++ b/frontend/src/types/v1/core/base/observation.ts
@@ -6,8 +6,7 @@ import {
ImageContent,
} from "./common";
-export interface MCPToolObservation
- extends ObservationBase<"MCPToolObservation"> {
+export interface MCPToolObservation extends ObservationBase<"MCPToolObservation"> {
/**
* Content returned from the MCP tool converted to LLM Ready TextContent or ImageContent
*/
@@ -22,23 +21,25 @@ export interface MCPToolObservation
tool_name: string;
}
-export interface FinishObservation
- extends ObservationBase<"FinishObservation"> {
+export interface FinishObservation extends ObservationBase<"FinishObservation"> {
/**
- * Final message sent to the user
+ * Content returned from the finish action as a list of TextContent/ImageContent objects.
*/
- message: string;
+ content: Array;
+ /**
+ * Whether the finish action resulted in an error
+ */
+ is_error: boolean;
}
export interface ThinkObservation extends ObservationBase<"ThinkObservation"> {
/**
* Confirmation message. DEFAULT: "Your thought has been logged."
*/
- content: string;
+ content: Array;
}
-export interface BrowserObservation
- extends ObservationBase<"BrowserObservation"> {
+export interface BrowserObservation extends ObservationBase<"BrowserObservation"> {
/**
* The output message from the browser operation
*/
@@ -53,8 +54,7 @@ export interface BrowserObservation
screenshot_data: string | null;
}
-export interface ExecuteBashObservation
- extends ObservationBase<"ExecuteBashObservation"> {
+export interface ExecuteBashObservation extends ObservationBase<"ExecuteBashObservation"> {
/**
* Content returned from the tool as a list of TextContent/ImageContent objects.
*/
@@ -81,8 +81,34 @@ export interface ExecuteBashObservation
metadata: CmdOutputMetadata;
}
-export interface FileEditorObservation
- extends ObservationBase<"FileEditorObservation"> {
+export interface TerminalObservation extends ObservationBase<"TerminalObservation"> {
+ /**
+ * Content returned from the terminal as a list of TextContent/ImageContent objects.
+ */
+ content: Array;
+ /**
+ * The bash command that was executed.
+ */
+ command: string | null;
+ /**
+ * The exit code of the command if it has finished.
+ */
+ exit_code: number | null;
+ /**
+ * Whether the command execution produced an error.
+ */
+ is_error: boolean;
+ /**
+ * Whether the command execution timed out.
+ */
+ timeout: boolean;
+ /**
+ * Additional metadata captured from the shell after command execution.
+ */
+ metadata: CmdOutputMetadata;
+}
+
+export interface FileEditorObservation extends ObservationBase<"FileEditorObservation"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
*/
@@ -114,8 +140,7 @@ export interface FileEditorObservation
}
// Keep StrReplaceEditorObservation as a separate interface for backward compatibility
-export interface StrReplaceEditorObservation
- extends ObservationBase<"StrReplaceEditorObservation"> {
+export interface StrReplaceEditorObservation extends ObservationBase<"StrReplaceEditorObservation"> {
/**
* The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
*/
@@ -146,8 +171,7 @@ export interface StrReplaceEditorObservation
error: string | null;
}
-export interface TaskTrackerObservation
- extends ObservationBase<"TaskTrackerObservation"> {
+export interface TaskTrackerObservation extends ObservationBase<"TaskTrackerObservation"> {
/**
* The formatted task list or status message.
*/
@@ -162,12 +186,45 @@ export interface TaskTrackerObservation
task_list: TaskItem[];
}
+export interface PlanningFileEditorObservation extends ObservationBase<"PlanningFileEditorObservation"> {
+ /**
+ * Content returned from the tool as a list of TextContent/ImageContent objects.
+ */
+ content: Array;
+ /**
+ * Whether the call resulted in an error.
+ */
+ is_error: boolean;
+ /**
+ * The commands to run. Allowed options are: `view`, `create`, `str_replace`, `insert`, `undo_edit`.
+ */
+ command: "view" | "create" | "str_replace" | "insert" | "undo_edit";
+ /**
+ * The file path that was edited.
+ */
+ path: string | null;
+ /**
+ * Indicates if the file previously existed. If not, it was created.
+ */
+ prev_exist: boolean;
+ /**
+ * The content of the file before the edit.
+ */
+ old_content: string | null;
+ /**
+ * The content of the file after the edit.
+ */
+ new_content: string | null;
+}
+
export type Observation =
| MCPToolObservation
| FinishObservation
| ThinkObservation
| BrowserObservation
| ExecuteBashObservation
+ | TerminalObservation
| FileEditorObservation
| StrReplaceEditorObservation
- | TaskTrackerObservation;
+ | TaskTrackerObservation
+ | PlanningFileEditorObservation;
diff --git a/frontend/src/types/v1/core/events/conversation-state-event.ts b/frontend/src/types/v1/core/events/conversation-state-event.ts
index 81b3640dfa1a..93679d667170 100644
--- a/frontend/src/types/v1/core/events/conversation-state-event.ts
+++ b/frontend/src/types/v1/core/events/conversation-state-event.ts
@@ -1,11 +1,63 @@
import { BaseEvent } from "../base/event";
import { V1ExecutionStatus } from "../base/common";
+/**
+ * Token usage metrics for LLM calls
+ */
+export interface TokenUsage {
+ model: string;
+ prompt_tokens: number;
+ completion_tokens: number;
+ cache_read_tokens: number;
+ cache_write_tokens: number;
+ reasoning_tokens: number;
+ context_window: number;
+ per_turn_token: number;
+ response_id: string;
+}
+
+/**
+ * LLM metrics for a specific component (agent or condenser)
+ */
+export interface LLMMetrics {
+ model_name: string;
+ accumulated_cost: number;
+ max_budget_per_task: number | null;
+ accumulated_token_usage: TokenUsage;
+ costs: Array<{
+ model: string;
+ cost: number;
+ timestamp: number;
+ }>;
+ response_latencies: Array<{
+ model: string;
+ latency: number;
+ response_id: string;
+ }>;
+ token_usages: TokenUsage[];
+}
+
+/**
+ * Usage metrics mapping for different components
+ */
+export interface UsageToMetrics {
+ agent: LLMMetrics;
+ condenser: LLMMetrics;
+}
+
+/**
+ * Stats containing usage metrics
+ */
+export interface ConversationStats {
+ usage_to_metrics: UsageToMetrics;
+}
+
/**
* Conversation state value types
*/
export interface ConversationState {
execution_status: V1ExecutionStatus;
+ stats?: ConversationStats;
// Add other conversation state fields here as needed
}
@@ -19,32 +71,37 @@ interface ConversationStateUpdateEventBase extends BaseEvent {
* Unique key for this state update event.
* Can be "full_state" for full state snapshots or field names for partial updates.
*/
- key: "full_state" | "execution_status"; // Extend with other keys as needed
+ key: "full_state" | "execution_status" | "stats"; // Extend with other keys as needed
/**
* Conversation state updates
*/
- value: ConversationState | V1ExecutionStatus;
+ value: ConversationState | V1ExecutionStatus | ConversationStats;
}
// Narrowed interfaces for full state update event
-export interface ConversationStateUpdateEventFullState
- extends ConversationStateUpdateEventBase {
+export interface ConversationStateUpdateEventFullState extends ConversationStateUpdateEventBase {
key: "full_state";
value: ConversationState;
}
// Narrowed interface for agent status update event
-export interface ConversationStateUpdateEventAgentStatus
- extends ConversationStateUpdateEventBase {
+export interface ConversationStateUpdateEventAgentStatus extends ConversationStateUpdateEventBase {
key: "execution_status";
value: V1ExecutionStatus;
}
+// Narrowed interface for stats update event
+export interface ConversationStateUpdateEventStats extends ConversationStateUpdateEventBase {
+ key: "stats";
+ value: ConversationStats;
+}
+
// Conversation state update event - contains conversation state updates
export type ConversationStateUpdateEvent =
| ConversationStateUpdateEventFullState
- | ConversationStateUpdateEventAgentStatus;
+ | ConversationStateUpdateEventAgentStatus
+ | ConversationStateUpdateEventStats;
// Conversation error event - contains error information
export interface ConversationErrorEvent extends BaseEvent {
diff --git a/frontend/src/types/v1/core/events/observation-event.ts b/frontend/src/types/v1/core/events/observation-event.ts
index 62750d72898c..bf4e22a70983 100644
--- a/frontend/src/types/v1/core/events/observation-event.ts
+++ b/frontend/src/types/v1/core/events/observation-event.ts
@@ -21,8 +21,9 @@ export interface ObservationBaseEvent extends BaseEvent {
}
// Main observation event interface
-export interface ObservationEvent
- extends ObservationBaseEvent {
+export interface ObservationEvent<
+ T extends Observation = Observation,
+> extends ObservationBaseEvent {
/**
* The observation (tool call) sent to LLM
*/
diff --git a/frontend/src/types/v1/type-guards.ts b/frontend/src/types/v1/type-guards.ts
index b479e4697b5a..dec181620923 100644
--- a/frontend/src/types/v1/type-guards.ts
+++ b/frontend/src/types/v1/type-guards.ts
@@ -3,7 +3,12 @@ import {
ObservationEvent,
BaseEvent,
ExecuteBashAction,
+ TerminalAction,
ExecuteBashObservation,
+ PlanningFileEditorObservation,
+ TerminalObservation,
+ BrowserObservation,
+ BrowserNavigateAction,
} from "./core";
import { AgentErrorEvent } from "./core/events/observation-event";
import { MessageEvent } from "./core/events/message-event";
@@ -12,6 +17,7 @@ import {
ConversationStateUpdateEvent,
ConversationStateUpdateEventAgentStatus,
ConversationStateUpdateEventFullState,
+ ConversationStateUpdateEventStats,
ConversationErrorEvent,
} from "./core/events/conversation-state-event";
import { SystemPromptEvent } from "./core/events/system-event";
@@ -48,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
@@ -88,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" &&
@@ -98,17 +110,45 @@ export const isActionEvent = (event: OpenHandsEvent): event is ActionEvent =>
*/
export const isExecuteBashActionEvent = (
event: OpenHandsEvent,
-): event is ActionEvent =>
- isActionEvent(event) && event.action.kind === "ExecuteBashAction";
+): event is ActionEvent =>
+ isActionEvent(event) &&
+ (event.action.kind === "ExecuteBashAction" ||
+ event.action.kind === "TerminalAction");
/**
- * Type guard function to check if an observation event is an ExecuteBashObservation
+ * Type guard function to check if an observation event contains terminal output
*/
export const isExecuteBashObservationEvent = (
event: OpenHandsEvent,
-): event is ObservationEvent =>
+): event is ObservationEvent =>
isObservationEvent(event) &&
- event.observation.kind === "ExecuteBashObservation";
+ (event.observation.kind === "ExecuteBashObservation" ||
+ event.observation.kind === "TerminalObservation");
+
+/**
+ * Type guard function to check if an observation event is a PlanningFileEditorObservation
+ */
+export const isPlanningFileEditorObservationEvent = (
+ event: OpenHandsEvent,
+): event is ObservationEvent =>
+ isObservationEvent(event) &&
+ event.observation.kind === "PlanningFileEditorObservation";
+
+/**
+ * Type guard function to check if an observation event is a BrowserObservation
+ */
+export const isBrowserObservationEvent = (
+ event: OpenHandsEvent,
+): event is ObservationEvent =>
+ isObservationEvent(event) && event.observation.kind === "BrowserObservation";
+
+/**
+ * Type guard function to check if an action event is a BrowserNavigateAction
+ */
+export const isBrowserNavigateActionEvent = (
+ event: OpenHandsEvent,
+): event is ActionEvent =>
+ isActionEvent(event) && event.action.kind === "BrowserNavigateAction";
/**
* Type guard function to check if an event is a system prompt event
@@ -139,6 +179,10 @@ export const isAgentStatusConversationStateUpdateEvent = (
): event is ConversationStateUpdateEventAgentStatus =>
event.key === "execution_status";
+export const isStatsConversationStateUpdateEvent = (
+ event: ConversationStateUpdateEvent,
+): event is ConversationStateUpdateEventStats => event.key === "stats";
+
/**
* Type guard function to check if an event is a conversation error event
*/
diff --git a/frontend/src/utils/extract-model-and-provider.ts b/frontend/src/utils/extract-model-and-provider.ts
index 93ef12d8bf16..ab0836079f5b 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/feature-flags.ts b/frontend/src/utils/feature-flags.ts
index acbe83d7d7e5..0f38a4d7eace 100644
--- a/frontend/src/utils/feature-flags.ts
+++ b/frontend/src/utils/feature-flags.ts
@@ -17,6 +17,4 @@ export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
-export const USE_V1_CONVERSATION_API = () =>
- loadFeatureFlag("USE_V1_CONVERSATION_API");
export const USE_PLANNING_AGENT = () => loadFeatureFlag("USE_PLANNING_AGENT");
diff --git a/frontend/src/utils/format-time-delta.ts b/frontend/src/utils/format-time-delta.ts
index 8f2425a234fe..6785d9c845cc 100644
--- a/frontend/src/utils/format-time-delta.ts
+++ b/frontend/src/utils/format-time-delta.ts
@@ -1,16 +1,45 @@
+/**
+ * Parses a date string as UTC if it doesn't have a timezone indicator.
+ * This fixes the issue where ISO strings without timezone info are interpreted as local time.
+ * @param dateString ISO 8601 date string
+ * @returns Date object parsed as UTC
+ *
+ * @example
+ * parseDateAsUTC("2025-12-01T11:53:37.273886"); // Parsed as UTC
+ * parseDateAsUTC("2025-12-01T11:53:37.273886Z"); // Already has timezone, parsed correctly
+ * parseDateAsUTC("2025-12-01T11:53:37+00:00"); // Already has timezone, parsed correctly
+ */
+const parseDateAsUTC = (dateString: string): Date => {
+ // Check if the string already has a timezone indicator
+ // Look for 'Z' (UTC), '+' (positive offset), or '-' after the time part (negative offset)
+ const hasTimezone =
+ dateString.includes("Z") || dateString.match(/[+-]\d{2}:\d{2}$/) !== null;
+
+ if (hasTimezone) {
+ // Already has timezone info, parse normally
+ return new Date(dateString);
+ }
+
+ // No timezone indicator - append 'Z' to force UTC parsing
+ return new Date(`${dateString}Z`);
+};
+
/**
* Formats a date into a compact string representing the time delta between the given date and the current date.
- * @param date The date to format
+ * @param date The date to format (Date object or ISO 8601 string)
* @returns A compact string representing the time delta between the given date and the current date
*
* @example
* // now is 2024-01-01T00:00:00Z
* formatTimeDelta(new Date("2023-12-31T23:59:59Z")); // "1s"
- * formatTimeDelta(new Date("2022-01-01T00:00:00Z")); // "2y"
+ * formatTimeDelta("2023-12-31T23:59:59Z"); // "1s"
+ * formatTimeDelta("2025-12-01T11:53:37.273886"); // Parsed as UTC automatically
*/
-export const formatTimeDelta = (date: Date) => {
+export const formatTimeDelta = (date: Date | string) => {
+ // Parse string dates as UTC if needed, or use Date object directly
+ const dateObj = typeof date === "string" ? parseDateAsUTC(date) : date;
const now = new Date();
- const delta = now.getTime() - date.getTime();
+ const delta = now.getTime() - dateObj.getTime();
const seconds = Math.floor(delta / 1000);
const minutes = Math.floor(seconds / 60);
diff --git a/frontend/src/utils/has-advanced-settings-set.ts b/frontend/src/utils/has-advanced-settings-set.ts
index 8cf3f10a393d..b87342523917 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 a6ccc73cfc58..1cd54eb8581a 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/reo.ts b/frontend/src/utils/reo.ts
index 9f76c98d314f..b2b8773ec804 100644
--- a/frontend/src/utils/reo.ts
+++ b/frontend/src/utils/reo.ts
@@ -4,6 +4,8 @@
* Using CDN approach for better TypeScript compatibility
*/
+import EventLogger from "./event-logger";
+
export interface ReoIdentity {
username: string;
type: "github" | "email";
@@ -41,7 +43,7 @@ class ReoService {
this.initialized = true;
}
} catch (error) {
- console.error("Failed to initialize Reo.dev tracking:", error);
+ EventLogger.error(`Failed to initialize Reo.dev tracking: ${error}`);
}
}
@@ -78,7 +80,7 @@ class ReoService {
*/
identify(identity: ReoIdentity): void {
if (!this.initialized) {
- console.warn("Reo.dev not initialized. Call init() first.");
+ EventLogger.warning("Reo.dev not initialized. Call init() first.");
return;
}
@@ -87,7 +89,7 @@ class ReoService {
window.Reo.identify(identity);
}
} catch (error) {
- console.error("Failed to identify user in Reo.dev:", error);
+ EventLogger.error(`Failed to identify user in Reo.dev: ${error}`);
}
}
diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts
index ca56b251707e..4259226d7718 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/src/utils/utils.ts b/frontend/src/utils/utils.ts
index 620fb2c444ba..69ff7aae5f01 100644
--- a/frontend/src/utils/utils.ts
+++ b/frontend/src/utils/utils.ts
@@ -606,10 +606,15 @@ export const shouldIncludeRepository = (
* @returns The query string for searching OpenHands repositories
*/
export const getOpenHandsQuery = (provider: Provider | null): string => {
- if (provider === "gitlab") {
- return "openhands-config";
- }
- return ".openhands";
+ const providerRepositorySuffix: Record = {
+ gitlab: "openhands-config",
+ azure_devops: "openhands-config",
+ default: ".openhands",
+ } as const;
+
+ return provider && provider in providerRepositorySuffix
+ ? providerRepositorySuffix[provider]
+ : providerRepositorySuffix.default;
};
/**
@@ -621,12 +626,7 @@ export const getOpenHandsQuery = (provider: Provider | null): string => {
export const hasOpenHandsSuffix = (
repo: GitRepository,
provider: Provider | null,
-): boolean => {
- if (provider === "gitlab") {
- return repo.full_name.endsWith("/openhands-config");
- }
- return repo.full_name.endsWith("/.openhands");
-};
+): boolean => repo.full_name.endsWith(`/${getOpenHandsQuery(provider)}`);
/**
* Build headers for V1 API requests that require session authentication
diff --git a/frontend/src/utils/verified-models.ts b/frontend/src/utils/verified-models.ts
index 12453c6c8642..dcf5f7251766 100644
--- a/frontend/src/utils/verified-models.ts
+++ b/frontend/src/utils/verified-models.ts
@@ -19,6 +19,7 @@ export const VERIFIED_MODELS = [
"claude-haiku-4-5-20251001",
"claude-opus-4-20250514",
"claude-opus-4-1-20250805",
+ "claude-opus-4-5-20251101",
"gemini-2.5-pro",
"o4-mini",
"deepseek-chat",
@@ -59,6 +60,7 @@ export const VERIFIED_ANTHROPIC_MODELS = [
"claude-haiku-4-5-20251001",
"claude-opus-4-20250514",
"claude-opus-4-1-20250805",
+ "claude-opus-4-5-20251101",
];
// LiteLLM does not return the compatible Mistral models with the provider, so we list them here to set them ourselves
@@ -79,6 +81,7 @@ export const VERIFIED_OPENHANDS_MODELS = [
"gpt-5-mini-2025-08-07",
"claude-opus-4-20250514",
"claude-opus-4-1-20250805",
+ "claude-opus-4-5-20251101",
"gemini-2.5-pro",
"o3",
"o4-mini",
@@ -90,4 +93,4 @@ export const VERIFIED_OPENHANDS_MODELS = [
];
// Default model for OpenHands provider
-export const DEFAULT_OPENHANDS_MODEL = "openhands/claude-sonnet-4-20250514";
+export const DEFAULT_OPENHANDS_MODEL = "openhands/claude-opus-4-5-20251101";
diff --git a/frontend/src/utils/websocket-url.ts b/frontend/src/utils/websocket-url.ts
index fa6b907d0e11..a0aebf4151cb 100644
--- a/frontend/src/utils/websocket-url.ts
+++ b/frontend/src/utils/websocket-url.ts
@@ -9,6 +9,17 @@ export function extractBaseHost(
if (conversationUrl && !conversationUrl.startsWith("/")) {
try {
const url = new URL(conversationUrl);
+ // If the backend returns a localhost URL but the UI is accessed via
+ // another hostname (e.g., from a remote machine), swap the hostname
+ // while preserving the backend-provided port so the socket remains
+ // reachable.
+ if (
+ ["localhost", "127.0.0.1"].includes(url.hostname) &&
+ window.location.hostname !== url.hostname
+ ) {
+ return `${window.location.hostname}${url.port ? `:${url.port}` : ""}`;
+ }
+
return url.host; // e.g., "localhost:3000"
} catch {
return window.location.host;
diff --git a/frontend/tests/avatar-menu.spec.ts b/frontend/tests/avatar-menu.spec.ts
new file mode 100644
index 000000000000..a7ef4efe4044
--- /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 6e3f58cd458f..000000000000
--- 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/helpers/confirm-settings.ts b/frontend/tests/helpers/confirm-settings.ts
deleted file mode 100644
index ca82edd35a15..000000000000
--- 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 000000000000..48e76b587ecb
--- /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 8425345ba624..000000000000
--- 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 24666d49fc23..000000000000
--- 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 e4c4ce3b35df..000000000000
--- 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/README.md b/openhands/README.md
index 5864a39b0e12..93f06f26b3ba 100644
--- a/openhands/README.md
+++ b/openhands/README.md
@@ -2,8 +2,7 @@
This directory contains the core components of OpenHands.
-This diagram provides an overview of the roles of each component and how they communicate and collaborate.
-
+For an overview of the system architecture, see the [architecture documentation](https://docs.openhands.dev/usage/architecture/backend) (v0 backend architecture).
## Classes
diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py
index 85e5f88cbcb7..9dd814e9cf77 100644
--- a/openhands/agenthub/codeact_agent/codeact_agent.py
+++ b/openhands/agenthub/codeact_agent/codeact_agent.py
@@ -194,9 +194,12 @@ def step(self, state: State) -> 'Action':
# 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 @@ def step(self, state: State) -> 'Action':
)
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 @@ def _get_initial_user_message(self, history: list[Event]) -> MessageAction:
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 @@ def _get_messages(
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_info_service.py b/openhands/app_server/app_conversation/app_conversation_info_service.py
index 22305c1ff01a..8e9f1ffe6828 100644
--- a/openhands/app_server/app_conversation/app_conversation_info_service.py
+++ b/openhands/app_server/app_conversation/app_conversation_info_service.py
@@ -9,6 +9,7 @@
AppConversationSortOrder,
)
from openhands.app_server.services.injector import Injector
+from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.sdk.utils.models import DiscriminatedUnionMixin
@@ -92,6 +93,19 @@ async def save_app_conversation_info(
Return the stored info
"""
+ @abstractmethod
+ async def process_stats_event(
+ self,
+ event: ConversationStateUpdateEvent,
+ conversation_id: UUID,
+ ) -> None:
+ """Process a stats event and update conversation statistics.
+
+ Args:
+ event: The ConversationStateUpdateEvent with key='stats'
+ conversation_id: The ID of the conversation to update
+ """
+
class AppConversationInfoServiceInjector(
DiscriminatedUnionMixin, Injector[AppConversationInfoService], ABC
diff --git a/openhands/app_server/app_conversation/app_conversation_models.py b/openhands/app_server/app_conversation/app_conversation_models.py
index dde2ccc8796b..1c0ba914cb35 100644
--- a/openhands/app_server/app_conversation/app_conversation_models.py
+++ b/openhands/app_server/app_conversation/app_conversation_models.py
@@ -1,6 +1,7 @@
from datetime import datetime
from enum import Enum
-from uuid import uuid4
+from typing import Literal
+from uuid import UUID, uuid4
from pydantic import BaseModel, Field
@@ -97,7 +98,9 @@ class AppConversationStartRequest(BaseModel):
"""
sandbox_id: str | None = Field(default=None)
+ conversation_id: UUID | None = Field(default=None)
initial_message: SendMessageRequest | None = None
+ system_message_suffix: str | None = None
processors: list[EventCallbackProcessor] | None = Field(default=None)
llm_model: str | None = None
@@ -159,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 b66d9983621b..a7a0414e3118 100644
--- a/openhands/app_server/app_conversation/app_conversation_router.py
+++ b/openhands/app_server/app_conversation/app_conversation_router.py
@@ -1,9 +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
@@ -26,8 +29,8 @@ async def anext(async_iterator):
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 (
@@ -37,10 +40,14 @@ async def anext(async_iterator):
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,
)
@@ -49,11 +56,25 @@ async def anext(async_iterator):
depends_app_conversation_start_task_service,
depends_db_session,
depends_httpx_client,
+ depends_sandbox_service,
+ depends_sandbox_spec_service,
depends_user_context,
get_app_conversation_service,
)
+from openhands.app_server.sandbox.sandbox_models import (
+ AGENT_SERVER,
+ SandboxStatus,
+)
+from openhands.app_server.sandbox.sandbox_service import SandboxService
+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()
@@ -61,6 +82,8 @@ async def anext(async_iterator):
user_context_dependency = depends_user_context()
db_session_dependency = depends_db_session()
httpx_client_dependency = depends_httpx_client()
+sandbox_service_dependency = depends_sandbox_service()
+sandbox_spec_service_dependency = depends_sandbox_spec_service()
# Read methods
@@ -289,6 +312,240 @@ async def batch_get_app_conversation_start_tasks(
return start_tasks
+@router.get('/{conversation_id}/file')
+async def read_conversation_file(
+ conversation_id: UUID,
+ file_path: Annotated[
+ str,
+ Query(title='Path to the file to read within the sandbox workspace'),
+ ] = '/workspace/project/PLAN.md',
+ app_conversation_service: AppConversationService = (
+ app_conversation_service_dependency
+ ),
+ sandbox_service: SandboxService = sandbox_service_dependency,
+ sandbox_spec_service: SandboxSpecService = sandbox_spec_service_dependency,
+) -> str:
+ """Read a file from a specific conversation's sandbox workspace.
+
+ Returns the content of the file at the specified path if it exists, otherwise returns an empty string.
+
+ Args:
+ conversation_id: The UUID of the conversation
+ file_path: Path to the file to read within the sandbox workspace
+
+ Returns:
+ The content of the file or an empty string if the file doesn't exist
+ """
+ # Get the conversation info
+ conversation = await app_conversation_service.get_app_conversation(conversation_id)
+ if not conversation:
+ return ''
+
+ # Get the sandbox info
+ sandbox = await sandbox_service.get_sandbox(conversation.sandbox_id)
+ if not sandbox or sandbox.status != SandboxStatus.RUNNING:
+ return ''
+
+ # 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 ''
+
+ # Get the agent server URL
+ if not sandbox.exposed_urls:
+ return ''
+
+ 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 ''
+
+ 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,
+ )
+
+ # Read the file at the specified path
+ temp_file_path = None
+ try:
+ # Create a temporary file path to download the remote file
+ with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as temp_file:
+ temp_file_path = temp_file.name
+
+ # Download the file from remote system
+ result = await remote_workspace.file_download(
+ source_path=file_path,
+ destination_path=temp_file_path,
+ )
+
+ if result.success:
+ # Read the content from the temporary file
+ with open(temp_file_path, 'rb') as f:
+ content = f.read()
+ # Decode bytes to string
+ return content.decode('utf-8')
+ except Exception:
+ # If there's any error reading the file, return empty string
+ pass
+ finally:
+ # Clean up the temporary file
+ if temp_file_path:
+ try:
+ os.unlink(temp_file_path)
+ except Exception:
+ # Ignore errors during cleanup
+ pass
+
+ 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 2027426ac22d..aa6add73fe4e 100644
--- a/openhands/app_server/app_conversation/app_conversation_service_base.py
+++ b/openhands/app_server/app_conversation/app_conversation_service_base.py
@@ -4,11 +4,16 @@
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
from openhands.app_server.app_conversation.app_conversation_models import (
+ AgentType,
AppConversationStartTask,
AppConversationStartTaskStatus,
)
@@ -17,6 +22,7 @@
)
from openhands.app_server.app_conversation.skill_loader import (
load_global_skills,
+ load_org_skills,
load_repo_skills,
load_sandbox_skills,
merge_skills,
@@ -25,7 +31,17 @@
from openhands.app_server.user.user_context import UserContext
from openhands.sdk import Agent
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__)
@@ -42,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,
@@ -79,13 +95,20 @@ async def _load_and_merge_all_skills(
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(
@@ -146,7 +169,7 @@ async def _load_skills_and_update_agent(
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
)
@@ -175,13 +198,50 @@ async def run_setup_scripts(
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,
workspace.working_dir,
)
+ async def _configure_git_user_settings(
+ self,
+ workspace: AsyncRemoteWorkspace,
+ ) -> None:
+ """Configure git global user settings from user preferences.
+
+ Reads git_user_name and git_user_email from user settings and
+ configures them as git global settings in the workspace.
+
+ Args:
+ workspace: The remote workspace to configure git settings in.
+ """
+ try:
+ user_info = await self.user_context.get_user_info()
+
+ if user_info.git_user_name:
+ cmd = f'git config --global user.name "{user_info.git_user_name}"'
+ result = await workspace.execute_command(cmd, workspace.working_dir)
+ if result.exit_code:
+ _logger.warning(f'Git config user.name failed: {result.stderr}')
+ else:
+ _logger.info(
+ f'Git configured with user.name={user_info.git_user_name}'
+ )
+
+ if user_info.git_user_email:
+ cmd = f'git config --global user.email "{user_info.git_user_email}"'
+ result = await workspace.execute_command(cmd, workspace.working_dir)
+ if result.exit_code:
+ _logger.warning(f'Git config user.email failed: {result.stderr}')
+ else:
+ _logger.info(
+ f'Git configured with user.email={user_info.git_user_email}'
+ )
+ except Exception as e:
+ _logger.warning(f'Failed to configure git user settings: {e}')
+
async def clone_or_init_git_repo(
self,
task: AppConversationStartTask,
@@ -197,6 +257,9 @@ async def clone_or_init_git_repo(
if result.exit_code:
_logger.warning(f'mkdir failed: {result.stderr}')
+ # Configure git user settings from user preferences
+ await self._configure_git_user_settings(workspace)
+
if not request.selected_repository:
if self.init_git_in_empty_workspace:
_logger.debug('Initializing a new git repository in the workspace.')
@@ -221,7 +284,9 @@ async def clone_or_init_git_repo(
# Clone the repo - this is the slow part!
clone_command = f'git clone {remote_repo_url} {dir_name}'
- result = await workspace.execute_command(clone_command, workspace.working_dir)
+ result = await workspace.execute_command(
+ clone_command, workspace.working_dir, 120
+ )
if result.exit_code:
_logger.warning(f'Git clone failed: {result.stderr}')
@@ -233,7 +298,10 @@ async def clone_or_init_git_repo(
random_str = base62.encodebytes(os.urandom(16))
openhands_workspace_branch = f'openhands-workspace-{random_str}'
checkout_command = f'git checkout -b {openhands_workspace_branch}'
- await workspace.execute_command(checkout_command, workspace.working_dir)
+ git_dir = Path(workspace.working_dir) / dir_name
+ result = await workspace.execute_command(checkout_command, git_dir)
+ if result.exit_code:
+ _logger.warning(f'Git checkout failed: {result.stderr}')
async def maybe_run_setup_script(
self,
@@ -295,3 +363,131 @@ async def maybe_setup_git_hooks(
return
_logger.info('Git pre-commit hook installed successfully')
+
+ def _create_condenser(
+ self,
+ llm: LLM,
+ agent_type: AgentType,
+ condenser_max_size: int | None,
+ ) -> LLMSummarizingCondenser:
+ """Create a condenser based on user settings and agent type.
+
+ Args:
+ llm: The LLM instance to use for condensation
+ agent_type: Type of agent (PLAN or DEFAULT)
+ condenser_max_size: condenser_max_size setting
+
+ Returns:
+ Configured LLMSummarizingCondenser instance
+ """
+ # LLMSummarizingCondenser has defaults: max_size=120, keep_first=4
+ condenser_kwargs = {
+ 'llm': llm.model_copy(
+ update={
+ 'usage_id': (
+ 'condenser'
+ if agent_type == AgentType.DEFAULT
+ else 'planning_condenser'
+ )
+ }
+ ),
+ }
+ # Only override max_size if user has a custom value
+ if condenser_max_size is not None:
+ condenser_kwargs['max_size'] = condenser_max_size
+
+ 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 5c81e4841c06..db30710f7626 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
@@ -4,16 +4,15 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from time import time
-from typing import AsyncGenerator, Sequence
+from typing import Any, AsyncGenerator, Sequence
from uuid import UUID, uuid4
import httpx
from fastapi import Request
-from pydantic import Field, TypeAdapter
+from pydantic import Field, SecretStr, TypeAdapter
from openhands.agent_server.models import (
ConversationInfo,
- NeverConfirm,
SendMessageRequest,
StartConversationRequest,
)
@@ -63,22 +62,27 @@
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.services.jwt_service import JwtService
from openhands.app_server.user.user_context import UserContext
+from openhands.app_server.user.user_models import UserInfo
from openhands.app_server.utils.docker_utils import (
replace_localhost_hostname_for_docker,
)
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import ProviderType
-from openhands.sdk import LocalWorkspace
-from openhands.sdk.conversation.secret_source import LookupSecret, StaticSecret
+from openhands.sdk import Agent, AgentContext, LocalWorkspace
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.tools.preset.default import get_default_agent
-from openhands.tools.preset.planning import get_planning_agent
+from openhands.server.types import AppMode
+from openhands.tools.preset.default import (
+ get_default_tools,
+)
+from openhands.tools.preset.planning import (
+ format_plan_structure,
+ get_planning_tools,
+)
_conversation_info_type_adapter = TypeAdapter(list[ConversationInfo | None])
_logger = logging.getLogger(__name__)
-GIT_TOKEN = 'GIT_TOKEN'
@dataclass
@@ -96,7 +100,11 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
sandbox_startup_poll_frequency: int
httpx_client: httpx.AsyncClient
web_url: str | None
+ openhands_provider_base_url: str | None
access_token_hard_timeout: timedelta | None
+ app_mode: str | None = None
+ keycloak_auth_cookie: str | None = None
+ tavily_api_key: str | None = None
async def search_app_conversations(
self,
@@ -228,10 +236,12 @@ async def _start_app_conversation(
await self._build_start_conversation_request_for_user(
sandbox,
request.initial_message,
+ request.system_message_suffix,
request.git_provider,
sandbox_spec.working_dir,
request.agent_type,
request.llm_model,
+ request.conversation_id,
remote_workspace=remote_workspace,
selected_repository=request.selected_repository,
)
@@ -260,7 +270,7 @@ async def _start_app_conversation(
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,
@@ -277,21 +287,33 @@ async def _start_app_conversation(
)
# Setup default processors
- processors = request.processors
- if processors is None:
- processors = [SetTitleCallbackProcessor()]
+ processors = request.processors or []
+
+ # Always ensure SetTitleCallbackProcessor is included
+ has_set_title_processor = any(
+ isinstance(processor, SetTitleCallbackProcessor)
+ for processor in processors
+ )
+ if not has_set_title_processor:
+ processors.append(SetTitleCallbackProcessor())
# Save processors
- await asyncio.gather(
- *[
- self.event_callback_service.save_event_callback(
- EventCallback(
- conversation_id=info.id,
- processor=processor,
- )
+ for processor in processors:
+ await self.event_callback_service.save_event_callback(
+ EventCallback(
+ conversation_id=info.id,
+ processor=processor,
)
- for processor in processors
- ]
+ )
+
+ # 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
@@ -455,7 +477,11 @@ async def _wait_for_sandbox_start(
if sandbox.status in (None, SandboxStatus.ERROR):
raise SandboxError(f'Sandbox status: {sandbox.status}')
if sandbox.status == SandboxStatus.RUNNING:
- return
+ # There are still bugs in the remote runtime - they report running while still just
+ # starting resulting in a race condition. Manually check that it is actually
+ # running.
+ if await self._check_agent_server_alive(sandbox):
+ return
if sandbox.status != SandboxStatus.STARTING:
raise SandboxError(f'Sandbox not startable: {sandbox.id}')
@@ -468,9 +494,19 @@ async def _wait_for_sandbox_start(
if sandbox.status not in (SandboxStatus.STARTING, SandboxStatus.RUNNING):
raise SandboxError(f'Sandbox not startable: {sandbox.id}')
if sandbox_info.status == SandboxStatus.RUNNING:
- return
+ # There are still bugs in the remote runtime - they report running while still just
+ # starting resulting in a race condition. Manually check that it is actually
+ # running.
+ if await self._check_agent_server_alive(sandbox_info):
+ return
raise SandboxError(f'Sandbox failed to start: {sandbox.id}')
+ async def _check_agent_server_alive(self, sandbox_info: SandboxInfo) -> bool:
+ agent_server_url = self._get_agent_server_url(sandbox_info)
+ url = f'{agent_server_url.rstrip("/")}/alive'
+ response = await self.httpx_client.get(url)
+ return response.is_success
+
def _get_agent_server_url(self, sandbox: SandboxInfo) -> str:
"""Get agent server url for running sandbox."""
exposed_urls = sandbox.exposed_urls
@@ -513,68 +549,376 @@ def _inherit_configuration_from_parent(
if not request.llm_model and parent_info.llm_model:
request.llm_model = parent_info.llm_model
- async def _build_start_conversation_request_for_user(
- self,
- sandbox: SandboxInfo,
- initial_message: SendMessageRequest | None,
- git_provider: ProviderType | None,
- working_dir: str,
- agent_type: AgentType = AgentType.DEFAULT,
- llm_model: str | None = None,
- remote_workspace: AsyncRemoteWorkspace | None = None,
- selected_repository: str | None = None,
- ) -> StartConversationRequest:
- user = await self.user_context.get_user_info()
+ async def _setup_secrets_for_git_providers(self, user: UserInfo) -> dict:
+ """Set up secrets for all git provider authentication.
- # Set up a secret for the git token
+ Args:
+ user: User information containing authentication details
+
+ Returns:
+ Dictionary of secrets for the conversation
+ """
secrets = await self.user_context.get_secrets()
- if git_provider:
+
+ # Get all provider tokens from user authentication
+ provider_tokens = await self.user_context.get_provider_tokens()
+ if not provider_tokens:
+ return secrets
+
+ # Create secrets for each provider token
+ for provider_type, provider_token in provider_tokens.items():
+ if not provider_token.token:
+ continue
+
+ secret_name = f'{provider_type.name}_TOKEN'
+
if self.web_url:
- # If there is a web url, then we create an access token to access it.
- # For security reasons, we are explicit here - only this user, and
- # only this provider, with a timeout
+ # Create an access token for web-based authentication
access_token = self.jwt_service.create_jws_token(
payload={
'user_id': user.id,
- 'provider_type': git_provider.value,
+ 'provider_type': provider_type.value,
},
expires_in=self.access_token_hard_timeout,
)
- secrets[GIT_TOKEN] = LookupSecret(
+ headers = {'X-Access-Token': access_token}
+
+ # Include keycloak_auth cookie in headers if app_mode is SaaS
+ if self.app_mode == 'saas' and self.keycloak_auth_cookie:
+ headers['Cookie'] = f'keycloak_auth={self.keycloak_auth_cookie}'
+
+ secrets[secret_name] = LookupSecret(
url=self.web_url + '/api/v1/webhooks/secrets',
- headers={'X-Access-Token': access_token},
+ headers=headers,
)
else:
- # If there is no URL specified where the sandbox can access the app server
- # then we supply a static secret with the most recent value. Depending
- # on the type, this may eventually expire.
- static_token = await self.user_context.get_latest_token(git_provider)
+ # Use static token for environments without web URL access
+ static_token = await self.user_context.get_latest_token(provider_type)
if static_token:
- secrets[GIT_TOKEN] = StaticSecret(value=static_token)
+ secrets[secret_name] = StaticSecret(value=static_token)
- workspace = LocalWorkspace(working_dir=working_dir)
+ return secrets
- # Use provided llm_model if available, otherwise fall back to user's default
+ 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
- llm = LLM(
+ 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=user.llm_base_url,
+ base_url=base_url,
api_key=user.llm_api_key,
usage_id='agent',
)
- # The agent gets passed initial instructions
- # Select agent based on agent_type
+
+ 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]:
+ """Configure LLM and MCP (Model Context Protocol) settings.
+
+ Args:
+ user: User information containing LLM preferences
+ llm_model: Optional specific model to use, falls back to user default
+
+ Returns:
+ Tuple of (configured LLM instance, MCP config dictionary)
+ """
+ # Configure LLM
+ llm = self._configure_llm(user, llm_model)
+
+ # Configure MCP - SDK expects format: {'mcpServers': {'server_name': {...}}}
+ mcp_servers: dict[str, Any] = {}
+
+ # Add system-generated servers (default + tavily)
+ await self._add_system_mcp_servers(mcp_servers, user)
+
+ # Merge custom servers from user settings
+ self._merge_custom_mcp_config(mcp_servers, user)
+
+ # 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
+
+ def _create_agent_with_context(
+ self,
+ llm: LLM,
+ agent_type: AgentType,
+ 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.
+
+ Args:
+ llm: Configured LLM instance
+ agent_type: Type of agent to create (PLAN or DEFAULT)
+ 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
+ """
+ # Create condenser with user's settings
+ condenser = self._create_condenser(llm, agent_type, condenser_max_size)
+
+ # Create agent based on type
if agent_type == AgentType.PLAN:
- agent = get_planning_agent(llm=llm)
+ agent = Agent(
+ llm=llm,
+ tools=get_planning_tools(),
+ system_prompt_filename='system_prompt_planning.j2',
+ system_prompt_kwargs={'plan_structure': format_plan_structure()},
+ condenser=condenser,
+ security_analyzer=None,
+ mcp_config=mcp_config,
+ )
else:
- agent = get_default_agent(llm=llm)
+ agent = Agent(
+ llm=llm,
+ tools=get_default_tools(enable_browser=True),
+ system_prompt_kwargs={'cli_mode': False},
+ condenser=condenser,
+ mcp_config=mcp_config,
+ )
+
+ # Add agent context
+ agent_context = AgentContext(
+ system_message_suffix=system_message_suffix, secrets=secrets
+ )
+ agent = agent.model_copy(update={'agent_context': agent_context})
+
+ return agent
+
+ async def _finalize_conversation_request(
+ self,
+ agent: Agent,
+ conversation_id: UUID | None,
+ user: UserInfo,
+ workspace: LocalWorkspace,
+ initial_message: SendMessageRequest | None,
+ secrets: dict,
+ sandbox: SandboxInfo,
+ remote_workspace: AsyncRemoteWorkspace | None,
+ selected_repository: str | None,
+ working_dir: str,
+ ) -> StartConversationRequest:
+ """Finalize the conversation request with experiment variants and skills.
- conversation_id = uuid4()
+ Args:
+ agent: The configured agent
+ conversation_id: Optional conversation ID, generates new one if None
+ user: User information
+ workspace: Local workspace instance
+ initial_message: Optional initial message for the conversation
+ secrets: Dictionary of secrets for authentication
+ sandbox: Sandbox information
+ remote_workspace: Optional remote workspace for skills loading
+ selected_repository: Optional repository name
+ working_dir: Working directory path
+
+ Returns:
+ Complete StartConversationRequest ready for use
+ """
+ # Generate conversation ID if not provided
+ conversation_id = conversation_id or uuid4()
+
+ # Apply experiment variants
agent = ExperimentManagerImpl.run_agent_variant_tests__v1(
user.id, conversation_id, agent
)
- # Load and merge all skills if remote_workspace is available
+ # Load and merge skills if remote workspace is available
if remote_workspace:
try:
agent = await self._load_skills_and_update_agent(
@@ -584,17 +928,71 @@ async def _build_start_conversation_request_for_user(
_logger.warning(f'Failed to load skills: {e}', exc_info=True)
# Continue without skills - don't fail conversation startup
- start_conversation_request = StartConversationRequest(
+ # Create and return the final request
+ return StartConversationRequest(
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,
)
- return start_conversation_request
+
+ async def _build_start_conversation_request_for_user(
+ self,
+ sandbox: SandboxInfo,
+ initial_message: SendMessageRequest | None,
+ system_message_suffix: str | None,
+ git_provider: ProviderType | None,
+ working_dir: str,
+ agent_type: AgentType = AgentType.DEFAULT,
+ llm_model: str | None = None,
+ conversation_id: UUID | None = None,
+ remote_workspace: AsyncRemoteWorkspace | None = None,
+ selected_repository: str | None = None,
+ ) -> StartConversationRequest:
+ """Build a complete conversation request for a user.
+
+ This method orchestrates the creation of a conversation request by:
+ 1. Setting up git provider secrets
+ 2. Configuring LLM and MCP settings
+ 3. Creating an agent with appropriate context
+ 4. Finalizing the request with skills and experiment variants
+ """
+ user = await self.user_context.get_user_info()
+ workspace = LocalWorkspace(working_dir=working_dir)
+
+ # Set up secrets for all git providers
+ secrets = await self._setup_secrets_for_git_providers(user)
+
+ # Configure LLM and MCP
+ llm, mcp_config = await self._configure_llm_and_mcp(user, llm_model)
+
+ # Create agent with context
+ agent = self._create_agent_with_context(
+ llm,
+ agent_type,
+ system_message_suffix,
+ mcp_config,
+ user.condenser_max_size,
+ secrets=secrets,
+ )
+
+ # Finalize and return the conversation request
+ return await self._finalize_conversation_request(
+ agent,
+ conversation_id,
+ user,
+ workspace,
+ initial_message,
+ secrets,
+ sandbox,
+ remote_workspace,
+ selected_repository,
+ working_dir,
+ )
async def update_agent_server_conversation_title(
self,
@@ -799,6 +1197,10 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
'be retrieved by a sandboxed conversation.'
),
)
+ tavily_api_key: SecretStr | None = Field(
+ default=None,
+ description='The Tavily Search API key to add to MCP integration',
+ )
async def inject(
self, state: InjectorState, request: Request | None = None
@@ -841,6 +1243,29 @@ async def inject(
if isinstance(sandbox_service, DockerSandboxService):
web_url = f'http://host.docker.internal:{sandbox_service.host_port}'
+ # Get app_mode and keycloak_auth cookie for SaaS mode
+ app_mode = None
+ keycloak_auth_cookie = None
+ try:
+ from openhands.server.shared import server_config
+
+ app_mode = (
+ server_config.app_mode.value if server_config.app_mode else None
+ )
+ if request and server_config.app_mode == AppMode.SAAS:
+ keycloak_auth_cookie = request.cookies.get('keycloak_auth')
+ except (ImportError, AttributeError):
+ # If server_config is not available (e.g., in tests), continue without it
+ pass
+
+ # We supply the global tavily key only if the app mode is not SAAS, where
+ # currently the search endpoints are patched into the app server instead
+ # so the tavily key does not need to be shared
+ if self.tavily_api_key and app_mode != AppMode.SAAS:
+ tavily_api_key = self.tavily_api_key.get_secret_value()
+ else:
+ tavily_api_key = None
+
yield LiveStatusAppConversationService(
init_git_in_empty_workspace=self.init_git_in_empty_workspace,
user_context=user_context,
@@ -854,5 +1279,9 @@ async def inject(
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,
httpx_client=httpx_client,
web_url=web_url,
+ openhands_provider_base_url=config.openhands_provider_base_url,
access_token_hard_timeout=access_token_hard_timeout,
+ app_mode=app_mode,
+ keycloak_auth_cookie=keycloak_auth_cookie,
+ tavily_api_key=tavily_api_key,
)
diff --git a/openhands/app_server/app_conversation/skill_loader.py b/openhands/app_server/app_conversation/skill_loader.py
index d8fca7cfc3a9..d237ff05426b 100644
--- a/openhands/app_server/app_conversation/skill_loader.py
+++ b/openhands/app_server/app_conversation/skill_loader.py
@@ -14,6 +14,9 @@
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/app_conversation/sql_app_conversation_info_service.py b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py
index e411224d5c3e..83e2d1915b47 100644
--- a/openhands/app_server/app_conversation/sql_app_conversation_info_service.py
+++ b/openhands/app_server/app_conversation/sql_app_conversation_info_service.py
@@ -45,6 +45,8 @@
create_json_type_decorator,
)
from openhands.integrations.provider import ProviderType
+from openhands.sdk.conversation.conversation_stats import ConversationStats
+from openhands.sdk.event import ConversationStateUpdateEvent
from openhands.sdk.llm import MetricsSnapshot
from openhands.sdk.llm.utils.metrics import TokenUsage
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
@@ -354,6 +356,130 @@ async def save_app_conversation_info(
await self.db_session.commit()
return info
+ async def update_conversation_statistics(
+ self, conversation_id: UUID, stats: ConversationStats
+ ) -> None:
+ """Update conversation statistics from stats event data.
+
+ Args:
+ conversation_id: The ID of the conversation to update
+ stats: ConversationStats object containing usage_to_metrics data from stats event
+ """
+ # Extract agent metrics from usage_to_metrics
+ usage_to_metrics = stats.usage_to_metrics
+ agent_metrics = usage_to_metrics.get('agent')
+
+ if not agent_metrics:
+ logger.debug(
+ 'No agent metrics found in stats for conversation %s', conversation_id
+ )
+ return
+
+ # Query existing record using secure select (filters for V1 and user if available)
+ query = await self._secure_select()
+ query = query.where(
+ StoredConversationMetadata.conversation_id == str(conversation_id)
+ )
+ result = await self.db_session.execute(query)
+ stored = result.scalar_one_or_none()
+
+ if not stored:
+ logger.debug(
+ 'Conversation %s not found or not accessible, skipping statistics update',
+ conversation_id,
+ )
+ return
+
+ # Extract accumulated_cost and max_budget_per_task from Metrics object
+ accumulated_cost = agent_metrics.accumulated_cost
+ max_budget_per_task = agent_metrics.max_budget_per_task
+
+ # Extract accumulated_token_usage from Metrics object
+ accumulated_token_usage = agent_metrics.accumulated_token_usage
+ if accumulated_token_usage:
+ prompt_tokens = accumulated_token_usage.prompt_tokens
+ completion_tokens = accumulated_token_usage.completion_tokens
+ cache_read_tokens = accumulated_token_usage.cache_read_tokens
+ cache_write_tokens = accumulated_token_usage.cache_write_tokens
+ reasoning_tokens = accumulated_token_usage.reasoning_tokens
+ context_window = accumulated_token_usage.context_window
+ per_turn_token = accumulated_token_usage.per_turn_token
+ else:
+ prompt_tokens = None
+ completion_tokens = None
+ cache_read_tokens = None
+ cache_write_tokens = None
+ reasoning_tokens = None
+ context_window = None
+ per_turn_token = None
+
+ # Update fields only if values are provided (not None)
+ if accumulated_cost is not None:
+ stored.accumulated_cost = accumulated_cost
+ if max_budget_per_task is not None:
+ stored.max_budget_per_task = max_budget_per_task
+ if prompt_tokens is not None:
+ stored.prompt_tokens = prompt_tokens
+ if completion_tokens is not None:
+ stored.completion_tokens = completion_tokens
+ if cache_read_tokens is not None:
+ stored.cache_read_tokens = cache_read_tokens
+ if cache_write_tokens is not None:
+ stored.cache_write_tokens = cache_write_tokens
+ if reasoning_tokens is not None:
+ stored.reasoning_tokens = reasoning_tokens
+ if context_window is not None:
+ stored.context_window = context_window
+ if per_turn_token is not None:
+ stored.per_turn_token = per_turn_token
+
+ # Update last_updated_at timestamp
+ stored.last_updated_at = utc_now()
+
+ await self.db_session.commit()
+
+ async def process_stats_event(
+ self,
+ event: ConversationStateUpdateEvent,
+ conversation_id: UUID,
+ ) -> None:
+ """Process a stats event and update conversation statistics.
+
+ Args:
+ event: The ConversationStateUpdateEvent with key='stats'
+ conversation_id: The ID of the conversation to update
+ """
+ try:
+ # Parse event value into ConversationStats model for type safety
+ # event.value can be a dict (from JSON deserialization) or a ConversationStats object
+ event_value = event.value
+ conversation_stats: ConversationStats | None = None
+
+ if isinstance(event_value, ConversationStats):
+ # Already a ConversationStats object
+ conversation_stats = event_value
+ elif isinstance(event_value, dict):
+ # Parse dict into ConversationStats model
+ # This validates the structure and ensures type safety
+ conversation_stats = ConversationStats.model_validate(event_value)
+ elif hasattr(event_value, 'usage_to_metrics'):
+ # Handle objects with usage_to_metrics attribute (e.g., from tests)
+ # Convert to dict first, then validate
+ stats_dict = {'usage_to_metrics': event_value.usage_to_metrics}
+ conversation_stats = ConversationStats.model_validate(stats_dict)
+
+ if conversation_stats and conversation_stats.usage_to_metrics:
+ # Pass ConversationStats object directly for type safety
+ await self.update_conversation_statistics(
+ conversation_id, conversation_stats
+ )
+ except Exception:
+ logger.exception(
+ 'Error updating conversation statistics for conversation %s',
+ conversation_id,
+ stack_info=True,
+ )
+
async def _secure_select(self):
query = select(StoredConversationMetadata).where(
StoredConversationMetadata.conversation_version == 'V1'
diff --git a/openhands/app_server/config.py b/openhands/app_server/config.py
index 2dd50d7fa714..3c40806af015 100644
--- a/openhands/app_server/config.py
+++ b/openhands/app_server/config.py
@@ -6,9 +6,11 @@
import httpx
from fastapi import Depends, Request
-from pydantic import Field
+from pydantic import Field, SecretStr
from sqlalchemy.ext.asyncio import AsyncSession
+# Import the event_callback module to ensure all processors are registered
+import openhands.app_server.event_callback # noqa: F401
from openhands.agent_server.env_parser import from_env
from openhands.app_server.app_conversation.app_conversation_info_service import (
AppConversationInfoService,
@@ -72,6 +74,11 @@ def get_default_web_url() -> str | None:
return f'https://{web_host}'
+def get_openhands_provider_base_url() -> str | None:
+ """Return the base URL for the OpenHands provider, if configured."""
+ return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or None
+
+
def _get_default_lifespan():
# Check legacy parameters for saas mode. If we are in SAAS mode do not apply
# OSS alembic migrations
@@ -86,6 +93,10 @@ class AppServerConfig(OpenHandsModel):
default_factory=get_default_web_url,
description='The URL where OpenHands is running (e.g., http://localhost:3000)',
)
+ openhands_provider_base_url: str | None = Field(
+ default_factory=get_openhands_provider_base_url,
+ description='Base URL for the OpenHands provider',
+ )
# Dependency Injection Injectors
event: EventServiceInjector | None = None
event_callback: EventCallbackServiceInjector | None = None
@@ -183,7 +194,13 @@ def config_from_env() -> AppServerConfig:
)
if config.app_conversation is None:
- config.app_conversation = LiveStatusAppConversationServiceInjector()
+ tavily_api_key = None
+ tavily_api_key_str = os.getenv('TAVILY_API_KEY') or os.getenv('SEARCH_API_KEY')
+ if tavily_api_key_str:
+ tavily_api_key = SecretStr(tavily_api_key_str)
+ config.app_conversation = LiveStatusAppConversationServiceInjector(
+ tavily_api_key=tavily_api_key
+ )
if config.user is None:
config.user = AuthUserContextInjector()
diff --git a/openhands/app_server/event_callback/__init__.py b/openhands/app_server/event_callback/__init__.py
new file mode 100644
index 000000000000..41be0a732049
--- /dev/null
+++ b/openhands/app_server/event_callback/__init__.py
@@ -0,0 +1,21 @@
+"""Event callback system for OpenHands.
+
+This module provides the event callback system that allows processors to be
+registered and executed when specific events occur during conversations.
+
+All callback processors must be imported here to ensure they are registered
+with the discriminated union system used by Pydantic for validation.
+"""
+
+# Import base classes and processors without circular dependencies
+from .event_callback_models import EventCallbackProcessor, LoggingCallbackProcessor
+from .github_v1_callback_processor import GithubV1CallbackProcessor
+
+# Note: SetTitleCallbackProcessor is not imported here to avoid circular imports
+# It will be registered when imported elsewhere in the application
+
+__all__ = [
+ 'EventCallbackProcessor',
+ 'LoggingCallbackProcessor',
+ 'GithubV1CallbackProcessor',
+]
diff --git a/openhands/app_server/event_callback/github_v1_callback_processor.py b/openhands/app_server/event_callback/github_v1_callback_processor.py
new file mode 100644
index 000000000000..1a83bed9c0cd
--- /dev/null
+++ b/openhands/app_server/event_callback/github_v1_callback_processor.py
@@ -0,0 +1,296 @@
+import logging
+import os
+from typing import Any
+from uuid import UUID
+
+import httpx
+from github import Github, GithubIntegration
+from pydantic import Field
+
+from openhands.agent_server.models import AskAgentRequest, AskAgentResponse
+from openhands.app_server.event_callback.event_callback_models import (
+ EventCallback,
+ EventCallbackProcessor,
+)
+from openhands.app_server.event_callback.event_callback_result_models import (
+ EventCallbackResult,
+ EventCallbackResultStatus,
+)
+from openhands.app_server.event_callback.util import (
+ ensure_conversation_found,
+ ensure_running_sandbox,
+ get_agent_server_url_from_sandbox,
+ get_conversation_url,
+ get_prompt_template,
+)
+from openhands.sdk import Event
+from openhands.sdk.event import ConversationStateUpdateEvent
+
+_logger = logging.getLogger(__name__)
+
+
+class GithubV1CallbackProcessor(EventCallbackProcessor):
+ """Callback processor for GitHub V1 integrations."""
+
+ github_view_data: dict[str, Any] = Field(default_factory=dict)
+ should_request_summary: bool = Field(default=True)
+ should_extract: bool = Field(default=True)
+ inline_pr_comment: bool = Field(default=False)
+
+ async def __call__(
+ self,
+ conversation_id: UUID,
+ callback: EventCallback,
+ event: Event,
+ ) -> EventCallbackResult | None:
+ """Process events for GitHub V1 integration."""
+
+ # Only handle ConversationStateUpdateEvent
+ if not isinstance(event, ConversationStateUpdateEvent):
+ return None
+
+ # Only act when execution has finished
+ if not (event.key == 'execution_status' and event.value == 'finished'):
+ return None
+
+ _logger.info('[GitHub V1] Callback agent state was %s', event)
+ _logger.info(
+ '[GitHub V1] Should request summary: %s', self.should_request_summary
+ )
+
+ if not self.should_request_summary:
+ return None
+
+ self.should_request_summary = False
+
+ try:
+ summary = await self._request_summary(conversation_id)
+ await self._post_summary_to_github(summary)
+
+ return EventCallbackResult(
+ status=EventCallbackResultStatus.SUCCESS,
+ event_callback_id=callback.id,
+ event_id=event.id,
+ conversation_id=conversation_id,
+ detail=summary,
+ )
+ except Exception as e:
+ _logger.exception('[GitHub V1] Error processing callback: %s', e)
+
+ # Only try to post error to GitHub if we have basic requirements
+ try:
+ # Check if we have installation ID and credentials before posting
+ if (
+ self.github_view_data.get('installation_id')
+ and os.getenv('GITHUB_APP_CLIENT_ID')
+ and os.getenv('GITHUB_APP_PRIVATE_KEY')
+ ):
+ await self._post_summary_to_github(
+ f'OpenHands encountered an error: **{str(e)}**.\n\n'
+ f'[See the conversation]({get_conversation_url().format(conversation_id)})'
+ 'for more information.'
+ )
+ except Exception as post_error:
+ _logger.warning(
+ '[GitHub V1] Failed to post error message to GitHub: %s', post_error
+ )
+
+ return EventCallbackResult(
+ status=EventCallbackResultStatus.ERROR,
+ event_callback_id=callback.id,
+ event_id=event.id,
+ conversation_id=conversation_id,
+ detail=str(e),
+ )
+
+ # -------------------------------------------------------------------------
+ # GitHub helpers
+ # -------------------------------------------------------------------------
+
+ def _get_installation_access_token(self) -> str:
+ installation_id = self.github_view_data.get('installation_id')
+
+ if not installation_id:
+ raise ValueError(
+ f'Missing installation ID for GitHub payload: {self.github_view_data}'
+ )
+
+ github_app_client_id = os.getenv('GITHUB_APP_CLIENT_ID', '').strip()
+ github_app_private_key = os.getenv('GITHUB_APP_PRIVATE_KEY', '').replace(
+ '\\n', '\n'
+ )
+
+ if not github_app_client_id or not github_app_private_key:
+ raise ValueError('GitHub App credentials are not configured')
+
+ github_integration = GithubIntegration(
+ github_app_client_id,
+ github_app_private_key,
+ )
+ token_data = github_integration.get_access_token(installation_id)
+ return token_data.token
+
+ async def _post_summary_to_github(self, summary: str) -> None:
+ """Post a summary comment to the configured GitHub issue."""
+ installation_token = self._get_installation_access_token()
+
+ if not installation_token:
+ raise RuntimeError('Missing GitHub credentials')
+
+ full_repo_name = self.github_view_data['full_repo_name']
+ issue_number = self.github_view_data['issue_number']
+
+ if self.inline_pr_comment:
+ with Github(installation_token) as github_client:
+ repo = github_client.get_repo(full_repo_name)
+ pr = repo.get_pull(issue_number)
+ pr.create_review_comment_reply(
+ comment_id=self.github_view_data.get('comment_id', ''), body=summary
+ )
+ return
+
+ with Github(installation_token) as github_client:
+ repo = github_client.get_repo(full_repo_name)
+ issue = repo.get_issue(number=issue_number)
+ issue.create_comment(summary)
+
+ # -------------------------------------------------------------------------
+ # Agent / sandbox helpers
+ # -------------------------------------------------------------------------
+
+ async def _ask_question(
+ self,
+ httpx_client: httpx.AsyncClient,
+ agent_server_url: str,
+ conversation_id: UUID,
+ session_api_key: str,
+ message_content: str,
+ ) -> str:
+ """Send a message to the agent server via the V1 API and return response text."""
+ send_message_request = AskAgentRequest(question=message_content)
+
+ url = (
+ f'{agent_server_url.rstrip("/")}'
+ f'/api/conversations/{conversation_id}/ask_agent'
+ )
+ headers = {'X-Session-API-Key': session_api_key}
+ payload = send_message_request.model_dump()
+
+ try:
+ response = await httpx_client.post(
+ url,
+ json=payload,
+ headers=headers,
+ timeout=30.0,
+ )
+ response.raise_for_status()
+
+ agent_response = AskAgentResponse.model_validate(response.json())
+ return agent_response.response
+
+ except httpx.HTTPStatusError as e:
+ error_detail = f'HTTP {e.response.status_code} error'
+ try:
+ error_body = e.response.text
+ if error_body:
+ error_detail += f': {error_body}'
+ except Exception: # noqa: BLE001
+ pass
+
+ _logger.error(
+ '[GitHub V1] HTTP error sending message to %s: %s. '
+ 'Request payload: %s. Response headers: %s',
+ url,
+ error_detail,
+ payload,
+ dict(e.response.headers),
+ exc_info=True,
+ )
+ raise Exception(f'Failed to send message to agent server: {error_detail}')
+
+ except httpx.TimeoutException:
+ error_detail = f'Request timeout after 30 seconds to {url}'
+ _logger.error(
+ '[GitHub V1] %s. Request payload: %s',
+ error_detail,
+ payload,
+ exc_info=True,
+ )
+ raise Exception(error_detail)
+
+ except httpx.RequestError as e:
+ error_detail = f'Request error to {url}: {str(e)}'
+ _logger.error(
+ '[GitHub V1] %s. Request payload: %s',
+ error_detail,
+ payload,
+ exc_info=True,
+ )
+ raise Exception(error_detail)
+
+ # -------------------------------------------------------------------------
+ # Summary orchestration
+ # -------------------------------------------------------------------------
+
+ async def _request_summary(self, conversation_id: UUID) -> str:
+ """
+ Ask the agent to produce a summary of its work and return the agent response.
+
+ NOTE: This method now returns a string (the agent server's response text)
+ and raises exceptions on errors. The wrapping into EventCallbackResult
+ is handled by __call__.
+ """
+ # Import services within the method to avoid circular imports
+ from openhands.app_server.config import (
+ get_app_conversation_info_service,
+ get_httpx_client,
+ get_sandbox_service,
+ )
+ from openhands.app_server.services.injector import InjectorState
+ from openhands.app_server.user.specifiy_user_context import (
+ ADMIN,
+ USER_CONTEXT_ATTR,
+ )
+
+ # Create injector state for dependency injection
+ state = InjectorState()
+ setattr(state, USER_CONTEXT_ATTR, ADMIN)
+
+ async with (
+ get_app_conversation_info_service(state) as app_conversation_info_service,
+ get_sandbox_service(state) as sandbox_service,
+ get_httpx_client(state) as httpx_client,
+ ):
+ # 1. Conversation lookup
+ app_conversation_info = ensure_conversation_found(
+ await app_conversation_info_service.get_app_conversation_info(
+ conversation_id
+ ),
+ conversation_id,
+ )
+
+ # 2. Sandbox lookup + validation
+ sandbox = ensure_running_sandbox(
+ await sandbox_service.get_sandbox(app_conversation_info.sandbox_id),
+ app_conversation_info.sandbox_id,
+ )
+
+ assert sandbox.session_api_key is not None, (
+ f'No session API key for sandbox: {sandbox.id}'
+ )
+
+ # 3. URL + instruction
+ agent_server_url = get_agent_server_url_from_sandbox(sandbox)
+ agent_server_url = get_agent_server_url_from_sandbox(sandbox)
+
+ # Prepare message based on agent state
+ message_content = get_prompt_template('summary_prompt.j2')
+
+ # Ask the agent and return the response text
+ return await self._ask_question(
+ httpx_client=httpx_client,
+ agent_server_url=agent_server_url,
+ conversation_id=conversation_id,
+ session_api_key=sandbox.session_api_key,
+ message_content=message_content,
+ )
diff --git a/openhands/app_server/event_callback/sql_event_callback_service.py b/openhands/app_server/event_callback/sql_event_callback_service.py
index 37e5bce111d2..c45416c37c78 100644
--- a/openhands/app_server/event_callback/sql_event_callback_service.py
+++ b/openhands/app_server/event_callback/sql_event_callback_service.py
@@ -6,7 +6,6 @@
import asyncio
import logging
from dataclasses import dataclass
-from datetime import datetime
from typing import AsyncGenerator
from uuid import UUID
@@ -15,6 +14,7 @@
from sqlalchemy import Column, Enum, String, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
+from openhands.agent_server.utils import utc_now
from openhands.app_server.event_callback.event_callback_models import (
CreateEventCallbackRequest,
EventCallback,
@@ -177,7 +177,7 @@ async def search_event_callbacks(
return EventCallbackPage(items=callbacks, next_page_id=next_page_id)
async def save_event_callback(self, event_callback: EventCallback) -> EventCallback:
- event_callback.updated_at = datetime.now()
+ event_callback.updated_at = utc_now()
stored_callback = StoredEventCallback(**event_callback.model_dump())
await self.db_session.merge(stored_callback)
return event_callback
@@ -209,6 +209,10 @@ async def execute_callbacks(self, conversation_id: UUID, event: Event) -> None:
for callback in callbacks
]
)
+
+ # Persist any new changes callbacks may have made to itself
+ for callback in callbacks:
+ await self.save_event_callback(callback)
await self.db_session.commit()
async def execute_callback(
diff --git a/openhands/app_server/event_callback/util.py b/openhands/app_server/event_callback/util.py
new file mode 100644
index 000000000000..1c9e56893545
--- /dev/null
+++ b/openhands/app_server/event_callback/util.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from uuid import UUID
+
+from openhands.app_server.sandbox.sandbox_models import (
+ AGENT_SERVER,
+ SandboxInfo,
+ SandboxStatus,
+)
+from openhands.app_server.utils.docker_utils import (
+ replace_localhost_hostname_for_docker,
+)
+
+if TYPE_CHECKING:
+ from openhands.app_server.app_conversation.app_conversation_models import (
+ AppConversationInfo,
+ )
+
+
+def get_conversation_url() -> str:
+ from openhands.app_server.config import get_global_config
+
+ web_url = get_global_config().web_url
+ conversation_prefix = 'conversations/{}'
+ conversation_url = f'{web_url}/{conversation_prefix}'
+ return conversation_url
+
+
+def ensure_conversation_found(
+ app_conversation_info: AppConversationInfo | None, conversation_id: UUID
+) -> AppConversationInfo:
+ """Ensure conversation info exists, otherwise raise a clear error."""
+ if not app_conversation_info:
+ raise RuntimeError(f'Conversation not found: {conversation_id}')
+ return app_conversation_info
+
+
+def ensure_running_sandbox(sandbox: SandboxInfo | None, sandbox_id: str) -> SandboxInfo:
+ """Ensure sandbox exists, is running, and has a session API key."""
+ if not sandbox:
+ raise RuntimeError(f'Sandbox not found: {sandbox_id}')
+
+ if sandbox.status != SandboxStatus.RUNNING:
+ raise RuntimeError(f'Sandbox not running: {sandbox_id}')
+
+ if not sandbox.session_api_key:
+ raise RuntimeError(f'No session API key for sandbox: {sandbox.id}')
+
+ return sandbox
+
+
+def get_agent_server_url_from_sandbox(sandbox: SandboxInfo) -> str:
+ """Return the agent server URL from sandbox exposed URLs."""
+ exposed_urls = sandbox.exposed_urls
+ if not exposed_urls:
+ raise RuntimeError(f'No exposed URLs configured for sandbox {sandbox.id!r}')
+
+ try:
+ agent_server_url = next(
+ exposed_url.url
+ for exposed_url in exposed_urls
+ if exposed_url.name == AGENT_SERVER
+ )
+ except StopIteration:
+ raise RuntimeError(
+ f'No {AGENT_SERVER!r} URL found for sandbox {sandbox.id!r}'
+ ) from None
+
+ return replace_localhost_hostname_for_docker(agent_server_url)
+
+
+def get_prompt_template(template_name: str) -> str:
+ from jinja2 import Environment, FileSystemLoader
+
+ jinja_env = Environment(
+ loader=FileSystemLoader('openhands/integrations/templates/resolver/')
+ )
+ summary_instruction_template = jinja_env.get_template(template_name)
+ summary_instruction = summary_instruction_template.render()
+ return summary_instruction
diff --git a/openhands/app_server/event_callback/webhook_router.py b/openhands/app_server/event_callback/webhook_router.py
index 498ebd2fd26c..28236b732515 100644
--- a/openhands/app_server/event_callback/webhook_router.py
+++ b/openhands/app_server/event_callback/webhook_router.py
@@ -6,9 +6,10 @@
import pkgutil
from uuid import UUID
-from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi import APIRouter, Depends, HTTPException, Response, status
from fastapi.security import APIKeyHeader
from jwt import InvalidTokenError
+from pydantic import SecretStr
from openhands import tools # type: ignore[attr-defined]
from openhands.agent_server.models import ConversationInfo, Success
@@ -33,6 +34,7 @@
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.app_server.services.injector import InjectorState
from openhands.app_server.services.jwt_service import JwtService
+from openhands.app_server.user.auth_user_context import AuthUserContext
from openhands.app_server.user.specifiy_user_context import (
USER_CONTEXT_ATTR,
SpecifyUserContext,
@@ -41,6 +43,11 @@
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderType
from openhands.sdk import Event
+from openhands.sdk.event import ConversationStateUpdateEvent
+from openhands.server.user_auth.default_user_auth import DefaultUserAuth
+from openhands.server.user_auth.user_auth import (
+ get_for_user as get_user_auth_for_user,
+)
router = APIRouter(prefix='/webhooks', tags=['Webhooks'])
sandbox_service_dependency = depends_sandbox_service()
@@ -53,16 +60,22 @@
async def valid_sandbox(
- sandbox_id: str,
user_context: UserContext = Depends(as_admin),
session_api_key: str = Depends(
APIKeyHeader(name='X-Session-API-Key', auto_error=False)
),
sandbox_service: SandboxService = sandbox_service_dependency,
) -> SandboxInfo:
- sandbox_info = await sandbox_service.get_sandbox(sandbox_id)
- if sandbox_info is None or sandbox_info.session_api_key != session_api_key:
- raise HTTPException(status.HTTP_401_UNAUTHORIZED)
+ if session_api_key is None:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, detail='X-Session-API-Key header is required'
+ )
+
+ sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(session_api_key)
+ if sandbox_info is None:
+ raise HTTPException(
+ status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
+ )
return sandbox_info
@@ -87,7 +100,7 @@ async def valid_conversation(
return app_conversation_info
-@router.post('/{sandbox_id}/conversations')
+@router.post('/conversations')
async def on_conversation_update(
conversation_info: ConversationInfo,
sandbox_info: SandboxInfo = Depends(valid_sandbox),
@@ -118,7 +131,7 @@ async def on_conversation_update(
return Success()
-@router.post('/{sandbox_id}/events/{conversation_id}')
+@router.post('/events/{conversation_id}')
async def on_event(
events: list[Event],
conversation_id: UUID,
@@ -138,6 +151,13 @@ async def on_event(
*[event_service.save_event(conversation_id, event) for event in events]
)
+ # Process stats events for V1 conversations
+ for event in events:
+ if isinstance(event, ConversationStateUpdateEvent) and event.key == 'stats':
+ await app_conversation_info_service.process_stats_event(
+ event, conversation_id
+ )
+
asyncio.create_task(
_run_callbacks_in_bg_and_close(
conversation_id, app_conversation_info.created_by_user_id, events
@@ -154,23 +174,34 @@ async def on_event(
async def get_secret(
access_token: str = Depends(APIKeyHeader(name='X-Access-Token', auto_error=False)),
jwt_service: JwtService = jwt_dependency,
-) -> str:
+) -> Response:
"""Given an access token, retrieve a user secret. The access token
is limited by user and provider type, and may include a timeout, limiting
the damage in the event that a token is ever leaked"""
try:
payload = jwt_service.verify_jws_token(access_token)
user_id = payload['user_id']
- provider_type = ProviderType[payload['provider_type']]
- user_injector = config.user
- assert user_injector is not None
- user_context = await user_injector.get_for_user(user_id)
- secret = None
- if user_context:
- secret = await user_context.get_latest_token(provider_type)
+ provider_type = ProviderType(payload['provider_type'])
+
+ # Get UserAuth for the user_id
+ if user_id:
+ user_auth = await get_user_auth_for_user(user_id)
+ else:
+ # OSS mode - use default user auth
+ user_auth = DefaultUserAuth()
+
+ # Create UserContext directly
+ user_context = AuthUserContext(user_auth=user_auth)
+
+ secret = await user_context.get_latest_token(provider_type)
if secret is None:
raise HTTPException(404, 'No such provider')
- return secret
+ if isinstance(secret, SecretStr):
+ secret_value = secret.get_secret_value()
+ else:
+ secret_value = secret
+
+ return Response(content=secret_value, media_type='text/plain')
except InvalidTokenError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
diff --git a/openhands/app_server/sandbox/docker_sandbox_service.py b/openhands/app_server/sandbox/docker_sandbox_service.py
index ff6e0669aeab..a0aeddc0e601 100644
--- a/openhands/app_server/sandbox/docker_sandbox_service.py
+++ b/openhands/app_server/sandbox/docker_sandbox_service.py
@@ -217,7 +217,9 @@ async def search_sandboxes(
sandboxes = []
for container in all_containers:
- if container.name.startswith(self.container_name_prefix):
+ if container.name and container.name.startswith(
+ self.container_name_prefix
+ ):
sandbox_info = await self._container_to_checked_sandbox_info(
container
)
@@ -258,6 +260,29 @@ async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
except (NotFound, APIError):
return None
+ async def get_sandbox_by_session_api_key(
+ self, session_api_key: str
+ ) -> SandboxInfo | None:
+ """Get a single sandbox by session API key."""
+ try:
+ # Get all containers with our prefix
+ all_containers = self.docker_client.containers.list(all=True)
+
+ for container in all_containers:
+ if container.name and container.name.startswith(
+ self.container_name_prefix
+ ):
+ # Check if this container has the matching session API key
+ env_vars = self._get_container_env_vars(container)
+ container_session_key = env_vars.get(SESSION_API_KEY_VARIABLE)
+
+ if container_session_key == session_api_key:
+ return await self._container_to_checked_sandbox_info(container)
+
+ return None
+ except (NotFound, APIError):
+ return None
+
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox."""
# Enforce sandbox limits by cleaning up old sandboxes
@@ -283,8 +308,7 @@ async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo
env_vars = sandbox_spec.initial_env.copy()
env_vars[SESSION_API_KEY_VARIABLE] = session_api_key
env_vars[WEBHOOK_CALLBACK_VARIABLE] = (
- f'http://host.docker.internal:{self.host_port}'
- f'/api/v1/webhooks/{container_name}'
+ f'http://host.docker.internal:{self.host_port}/api/v1/webhooks'
)
# Prepare port mappings and add port environment variables
diff --git a/openhands/app_server/sandbox/docker_sandbox_spec_service.py b/openhands/app_server/sandbox/docker_sandbox_spec_service.py
index 7504cec53749..063b4e8a96c5 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 @@
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',
@@ -42,6 +42,8 @@ def get_default_sandbox_specs():
'LOG_JSON': 'true',
'OH_CONVERSATIONS_PATH': '/workspace/conversations',
'OH_BASH_EVENTS_DIR': '/workspace/bash_events',
+ 'PYTHONUNBUFFERED': '1',
+ 'ENV_LOG_LEVEL': '20',
},
working_dir='/workspace/project',
)
diff --git a/openhands/app_server/sandbox/process_sandbox_service.py b/openhands/app_server/sandbox/process_sandbox_service.py
index 716c2e1b1916..200bf62c442b 100644
--- a/openhands/app_server/sandbox/process_sandbox_service.py
+++ b/openhands/app_server/sandbox/process_sandbox_service.py
@@ -275,6 +275,17 @@ async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
return await self._process_to_sandbox_info(sandbox_id, process_info)
+ async def get_sandbox_by_session_api_key(
+ self, session_api_key: str
+ ) -> SandboxInfo | None:
+ """Get a single sandbox by session API key."""
+ # Search through all processes to find one with matching session_api_key
+ for sandbox_id, process_info in _processes.items():
+ if process_info.session_api_key == session_api_key:
+ return await self._process_to_sandbox_info(sandbox_id, process_info)
+
+ return None
+
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox."""
# Get sandbox spec
diff --git a/openhands/app_server/sandbox/process_sandbox_spec_service.py b/openhands/app_server/sandbox/process_sandbox_spec_service.py
index b5476669f795..4e2e88a2f91d 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 @@
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 @@
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 dfa029462e41..076c47847898 100644
--- a/openhands/app_server/sandbox/remote_sandbox_service.py
+++ b/openhands/app_server/sandbox/remote_sandbox_service.py
@@ -44,6 +44,7 @@
from openhands.app_server.user.specifiy_user_context import ADMIN, USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext
from openhands.app_server.utils.sql_utils import Base, UtcDateTime
+from openhands.sdk.utils.paging import page_iterator
_logger = logging.getLogger(__name__)
WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL'
@@ -121,18 +122,9 @@ async def _send_runtime_api_request(
_logger.error(f'HTTP error for URL {url}: {e}')
raise
- async def _to_sandbox_info(
+ def _to_sandbox_info(
self, stored: StoredRemoteSandbox, runtime: dict[str, Any] | None = None
- ) -> SandboxInfo:
- # If we did not get passsed runtime data, load some
- if runtime is None:
- try:
- runtime = await self._get_runtime(stored.id)
- except Exception:
- _logger.exception(
- f'Error getting runtime: {stored.id}', stack_info=True
- )
-
+ ):
status = self._get_sandbox_status_from_runtime(runtime)
# Get session_api_key and exposed urls
@@ -232,6 +224,40 @@ async def _get_runtime(self, sandbox_id: str) -> dict[str, Any]:
runtime_data = response.json()
return runtime_data
+ async def _get_runtimes_batch(
+ self, sandbox_ids: list[str]
+ ) -> dict[str, dict[str, Any]]:
+ """Get multiple runtimes in a single batch request.
+
+ Args:
+ sandbox_ids: List of sandbox IDs to fetch
+
+ Returns:
+ Dictionary mapping sandbox_id to runtime data
+ """
+ if not sandbox_ids:
+ return {}
+
+ # Build query parameters for the batch endpoint
+ params = [('ids', sandbox_id) for sandbox_id in sandbox_ids]
+
+ response = await self._send_runtime_api_request(
+ 'GET',
+ '/sessions/batch',
+ params=params,
+ )
+ response.raise_for_status()
+ batch_data = response.json()
+
+ # The batch endpoint should return a list of runtimes
+ # Convert to a dictionary keyed by session_id for easy lookup
+ runtimes_by_id = {}
+ for runtime in batch_data:
+ if runtime and 'session_id' in runtime:
+ runtimes_by_id[runtime['session_id']] = runtime
+
+ return runtimes_by_id
+
async def _init_environment(
self, sandbox_spec: SandboxSpecInfo, sandbox_id: str
) -> dict[str, str]:
@@ -240,9 +266,7 @@ async def _init_environment(
# If a public facing url is defined, add a callback to the agent server environment.
if self.web_url:
- environment[WEBHOOK_CALLBACK_VARIABLE] = (
- f'{self.web_url}/api/v1/webhooks/{sandbox_id}'
- )
+ environment[WEBHOOK_CALLBACK_VARIABLE] = f'{self.web_url}/api/v1/webhooks'
# We specify CORS settings only if there is a public facing url - otherwise
# we are probably in local development and the only url in use is localhost
environment[ALLOW_CORS_ORIGINS_VARIABLE] = self.web_url
@@ -284,13 +308,15 @@ async def search_sandboxes(
if has_more:
next_page_id = str(offset + limit)
- # Convert stored callbacks to domain models
- items = await asyncio.gather(
- *[
- self._to_sandbox_info(stored_sandbox)
- for stored_sandbox in stored_sandboxes
- ]
- )
+ # Batch fetch runtime data for all sandboxes
+ sandbox_ids = [stored_sandbox.id for stored_sandbox in stored_sandboxes]
+ runtimes_by_id = await self._get_runtimes_batch(sandbox_ids)
+
+ # Convert stored sandboxes to domain models with runtime data
+ items = [
+ self._to_sandbox_info(stored_sandbox, runtimes_by_id.get(stored_sandbox.id))
+ for stored_sandbox in stored_sandboxes
+ ]
return SandboxPage(items=items, next_page_id=next_page_id)
@@ -299,7 +325,62 @@ async def get_sandbox(self, sandbox_id: str) -> Union[SandboxInfo, None]:
stored_sandbox = await self._get_stored_sandbox(sandbox_id)
if stored_sandbox is None:
return None
- return await self._to_sandbox_info(stored_sandbox)
+
+ runtime = None
+ try:
+ runtime = await self._get_runtime(stored_sandbox.id)
+ except Exception:
+ _logger.exception(
+ f'Error getting runtime: {stored_sandbox.id}', stack_info=True
+ )
+
+ return self._to_sandbox_info(stored_sandbox, runtime)
+
+ async def get_sandbox_by_session_api_key(
+ 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 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)
+ stored_sandboxes = result.scalars().all()
+
+ # Check each sandbox's runtime data for matching session_api_key
+ for stored_sandbox in stored_sandboxes:
+ try:
+ runtime = await self._get_runtime(stored_sandbox.id)
+ if runtime and runtime.get('session_api_key') == session_api_key:
+ return self._to_sandbox_info(stored_sandbox, runtime)
+ except Exception:
+ # Continue checking other sandboxes if one fails
+ continue
+
+ return None
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
"""Start a new sandbox by creating a remote runtime."""
@@ -367,7 +448,7 @@ async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo
# Hack - result doesn't contain this
runtime_data['pod_status'] = 'pending'
- return await self._to_sandbox_info(stored_sandbox, runtime_data)
+ return self._to_sandbox_info(stored_sandbox, runtime_data)
except httpx.HTTPError as e:
_logger.error(f'Failed to start sandbox: {e}')
@@ -435,6 +516,81 @@ async def delete_sandbox(self, sandbox_id: str) -> bool:
_logger.error(f'Error deleting sandbox {sandbox_id}: {e}')
return False
+ async def pause_old_sandboxes(self, max_num_sandboxes: int) -> list[str]:
+ """Pause the oldest sandboxes if there are more than max_num_sandboxes running.
+ In a multi user environment, this will pause sandboxes only for the current user.
+
+ Args:
+ max_num_sandboxes: Maximum number of sandboxes to keep running
+
+ Returns:
+ List of sandbox IDs that were paused
+ """
+ if max_num_sandboxes <= 0:
+ raise ValueError('max_num_sandboxes must be greater than 0')
+
+ response = await self._send_runtime_api_request(
+ 'GET',
+ '/list',
+ )
+ content = response.json()
+ running_session_ids = [
+ runtime.get('session_id') for runtime in content['runtimes']
+ ]
+
+ query = await self._secure_select()
+ query = query.filter(StoredRemoteSandbox.id.in_(running_session_ids)).order_by(
+ StoredRemoteSandbox.created_at.desc()
+ )
+ running_sandboxes = list(await self.db_session.execute(query))
+
+ # If we're within the limit, no cleanup needed
+ if len(running_sandboxes) <= max_num_sandboxes:
+ return []
+
+ # Determine how many to pause
+ num_to_pause = len(running_sandboxes) - max_num_sandboxes
+ sandboxes_to_pause = running_sandboxes[:num_to_pause]
+
+ # Stop the oldest sandboxes
+ paused_sandbox_ids = []
+ for sandbox in sandboxes_to_pause:
+ try:
+ success = await self.pause_sandbox(sandbox.id)
+ if success:
+ paused_sandbox_ids.append(sandbox.id)
+ except Exception:
+ # Continue trying to pause other sandboxes even if one fails
+ pass
+
+ return paused_sandbox_ids
+
+ async def batch_get_sandboxes(
+ self, sandbox_ids: list[str]
+ ) -> list[SandboxInfo | None]:
+ """Get a batch of sandboxes, returning None for any which were not found."""
+ if not sandbox_ids:
+ return []
+ query = await self._secure_select()
+ query = query.filter(StoredRemoteSandbox.id.in_(sandbox_ids))
+ stored_remote_sandboxes = await self.db_session.execute(query)
+ stored_remote_sandboxes_by_id = {
+ stored_remote_sandbox[0].id: stored_remote_sandbox[0]
+ for stored_remote_sandbox in stored_remote_sandboxes
+ }
+ runtimes_by_id = await self._get_runtimes_batch(
+ list(stored_remote_sandboxes_by_id)
+ )
+ results = []
+ for sandbox_id in sandbox_ids:
+ stored_remote_sandbox = stored_remote_sandboxes_by_id.get(sandbox_id)
+ result = None
+ if stored_remote_sandbox:
+ runtime = runtimes_by_id.get(sandbox_id)
+ result = self._to_sandbox_info(stored_remote_sandbox, runtime)
+ results.append(result)
+ return results
+
def _build_service_url(url: str, service_name: str):
scheme, host_and_path = url.split('://')
@@ -485,32 +641,26 @@ async def poll_agent_servers(api_url: str, api_key: str, sleep_interval: int):
get_event_callback_service(state) as event_callback_service,
get_httpx_client(state) as httpx_client,
):
- page_id = None
matches = 0
- while True:
- page = await app_conversation_info_service.search_app_conversation_info(
- page_id=page_id
+ async for app_conversation_info in page_iterator(
+ app_conversation_info_service.search_app_conversation_info
+ ):
+ runtime = runtimes_by_sandbox_id.get(
+ app_conversation_info.sandbox_id
)
- for app_conversation_info in page.items:
- runtime = runtimes_by_sandbox_id.get(
- app_conversation_info.sandbox_id
+ if runtime:
+ matches += 1
+ await refresh_conversation(
+ app_conversation_info_service=app_conversation_info_service,
+ event_service=event_service,
+ event_callback_service=event_callback_service,
+ app_conversation_info=app_conversation_info,
+ runtime=runtime,
+ httpx_client=httpx_client,
)
- if runtime:
- matches += 1
- await refresh_conversation(
- app_conversation_info_service=app_conversation_info_service,
- event_service=event_service,
- event_callback_service=event_callback_service,
- app_conversation_info=app_conversation_info,
- runtime=runtime,
- httpx_client=httpx_client,
- )
- page_id = page.next_page_id
- if page_id is None:
- _logger.debug(
- f'Matched {len(runtimes_by_sandbox_id)} Runtimes with {matches} Conversations.'
- )
- break
+ _logger.debug(
+ f'Matched {len(runtimes_by_sandbox_id)} Runtimes with {matches} Conversations.'
+ )
except Exception as exc:
_logger.exception(
@@ -564,37 +714,29 @@ async def refresh_conversation(
event_url = (
f'{url}/api/conversations/{app_conversation_info.id.hex}/events/search'
)
- page_id = None
- while True:
+
+ async def fetch_events_page(page_id: str | None = None) -> EventPage:
+ """Helper function to fetch a page of events from the agent server."""
params: dict[str, str] = {}
if page_id:
- params['page_id'] = page_id # type: ignore[unreachable]
+ params['page_id'] = page_id
response = await httpx_client.get(
event_url,
params=params,
headers={'X-Session-API-Key': runtime['session_api_key']},
)
response.raise_for_status()
- page = EventPage.model_validate(response.json())
-
- to_process = []
- for event in page.items:
- existing = await event_service.get_event(event.id)
- if existing is None:
- await event_service.save_event(app_conversation_info.id, event)
- to_process.append(event)
+ return EventPage.model_validate(response.json())
- for event in to_process:
+ async for event in page_iterator(fetch_events_page):
+ existing = await event_service.get_event(event.id)
+ if existing is None:
+ await event_service.save_event(app_conversation_info.id, event)
await event_callback_service.execute_callbacks(
app_conversation_info.id, event
)
- page_id = page.next_page_id
- if page_id is None:
- _logger.debug(
- f'Finished Refreshing Conversation {app_conversation_info.id}'
- )
- break
+ _logger.debug(f'Finished Refreshing Conversation {app_conversation_info.id}')
except Exception as exc:
_logger.exception(f'Error Refreshing Conversation: {exc}', stack_info=True)
diff --git a/openhands/app_server/sandbox/remote_sandbox_spec_service.py b/openhands/app_server/sandbox/remote_sandbox_spec_service.py
index a2a7c58099cd..6228338d7287 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 @@
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 @@
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_service.py b/openhands/app_server/sandbox/sandbox_service.py
index 43393dfcf759..45274975d70c 100644
--- a/openhands/app_server/sandbox/sandbox_service.py
+++ b/openhands/app_server/sandbox/sandbox_service.py
@@ -8,6 +8,7 @@
)
from openhands.app_server.services.injector import Injector
from openhands.sdk.utils.models import DiscriminatedUnionMixin
+from openhands.sdk.utils.paging import page_iterator
class SandboxService(ABC):
@@ -25,6 +26,12 @@ async def search_sandboxes(
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
"""Get a single sandbox. Return None if the sandbox was not found."""
+ @abstractmethod
+ async def get_sandbox_by_session_api_key(
+ self, session_api_key: str
+ ) -> SandboxInfo | None:
+ """Get a single sandbox by session API key. Return None if the sandbox was not found."""
+
async def batch_get_sandboxes(
self, sandbox_ids: list[str]
) -> list[SandboxInfo | None]:
@@ -65,7 +72,7 @@ async def delete_sandbox(self, sandbox_id: str) -> bool:
"""
async def pause_old_sandboxes(self, max_num_sandboxes: int) -> list[str]:
- """Stop the oldest sandboxes if there are more than max_num_sandboxes running.
+ """Pause the oldest sandboxes if there are more than max_num_sandboxes running.
In a multi user environment, this will pause sandboxes only for the current user.
Args:
@@ -77,24 +84,11 @@ async def pause_old_sandboxes(self, max_num_sandboxes: int) -> list[str]:
if max_num_sandboxes <= 0:
raise ValueError('max_num_sandboxes must be greater than 0')
- # Get all sandboxes (we'll search through all pages)
- all_sandboxes = []
- page_id = None
-
- while True:
- page = await self.search_sandboxes(page_id=page_id, limit=100)
- all_sandboxes.extend(page.items)
-
- if page.next_page_id is None:
- break
- page_id = page.next_page_id
-
- # Filter to only running sandboxes
- running_sandboxes = [
- sandbox
- for sandbox in all_sandboxes
- if sandbox.status == SandboxStatus.RUNNING
- ]
+ # Get all running sandboxes (iterate through all pages)
+ running_sandboxes = []
+ async for sandbox in page_iterator(self.search_sandboxes, limit=100):
+ if sandbox.status == SandboxStatus.RUNNING:
+ running_sandboxes.append(sandbox)
# If we're within the limit, no cleanup needed
if len(running_sandboxes) <= max_num_sandboxes:
diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py
index 997cbe535149..fe9d1653a99e 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 @@
# 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:15f565b-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 53612364f5a3..4d6488842702 100644
--- a/openhands/app_server/user/auth_user_context.py
+++ b/openhands/app_server/user/auth_user_context.py
@@ -9,8 +9,12 @@
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
from openhands.app_server.user.user_context import UserContext, UserContextInjector
from openhands.app_server.user.user_models import UserInfo
-from openhands.integrations.provider import ProviderHandler, ProviderType
-from openhands.sdk.conversation.secret_source import SecretSource, StaticSecret
+from openhands.integrations.provider import (
+ PROVIDER_TOKEN_TYPE,
+ ProviderHandler,
+ ProviderType,
+)
+from openhands.sdk.secret import SecretSource, StaticSecret
from openhands.server.user_auth.user_auth import UserAuth, get_user_auth
USER_AUTH_ATTR = 'user_auth'
@@ -44,6 +48,9 @@ async def get_user_info(self) -> UserInfo:
self._user_info = user_info
return user_info
+ async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
+ return await self.user_auth.get_provider_tokens()
+
async def get_provider_handler(self):
provider_handler = self._provider_handler
if not provider_handler:
@@ -78,6 +85,10 @@ async def get_secrets(self) -> dict[str, SecretSource]:
return results
+ async def get_mcp_api_key(self) -> str | None:
+ mcp_api_key = await self.user_auth.get_mcp_api_key()
+ return mcp_api_key
+
USER_ID_ATTR = 'user_id'
diff --git a/openhands/app_server/user/specifiy_user_context.py b/openhands/app_server/user/specifiy_user_context.py
index 0855b447bf69..51e62339723e 100644
--- a/openhands/app_server/user/specifiy_user_context.py
+++ b/openhands/app_server/user/specifiy_user_context.py
@@ -5,8 +5,8 @@
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 ProviderType
-from openhands.sdk.conversation.secret_source import SecretSource
+from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
+from openhands.sdk.secret import SecretSource
@dataclass(frozen=True)
@@ -24,12 +24,18 @@ async def get_user_info(self) -> UserInfo:
async def get_authenticated_git_url(self, repository: str) -> str:
raise NotImplementedError()
+ async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
+ raise NotImplementedError()
+
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
raise NotImplementedError()
async def get_secrets(self) -> dict[str, SecretSource]:
raise NotImplementedError()
+ async def get_mcp_api_key(self) -> str | None:
+ raise NotImplementedError()
+
USER_CONTEXT_ATTR = 'user_context'
ADMIN = SpecifyUserContext(user_id=None)
diff --git a/openhands/app_server/user/user_context.py b/openhands/app_server/user/user_context.py
index 75fe957160f7..4102df5cf9c4 100644
--- a/openhands/app_server/user/user_context.py
+++ b/openhands/app_server/user/user_context.py
@@ -4,8 +4,8 @@
from openhands.app_server.user.user_models import (
UserInfo,
)
-from openhands.integrations.provider import ProviderType
-from openhands.sdk.conversation.secret_source import SecretSource
+from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
+from openhands.sdk.secret import SecretSource
from openhands.sdk.utils.models import DiscriminatedUnionMixin
@@ -26,6 +26,10 @@ async def get_user_info(self) -> UserInfo:
async def get_authenticated_git_url(self, repository: str) -> str:
"""Get the provider tokens for the user"""
+ @abstractmethod
+ async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
+ """Get the latest tokens for all provider types"""
+
@abstractmethod
async def get_latest_token(self, provider_type: ProviderType) -> str | None:
"""Get the latest token for the provider type given"""
@@ -34,6 +38,10 @@ async def get_latest_token(self, provider_type: ProviderType) -> str | None:
async def get_secrets(self) -> dict[str, SecretSource]:
"""Get custom secrets and github provider secrets for the conversation."""
+ @abstractmethod
+ async def get_mcp_api_key(self) -> str | None:
+ """Get an MCP API Key."""
+
class UserContextInjector(DiscriminatedUnionMixin, Injector[UserContext], ABC):
"""Injector for user contexts."""
diff --git a/openhands/app_server/utils/encryption_key.py b/openhands/app_server/utils/encryption_key.py
index 5815bce20e58..62224e1da166 100644
--- a/openhands/app_server/utils/encryption_key.py
+++ b/openhands/app_server/utils/encryption_key.py
@@ -1,3 +1,4 @@
+import hashlib
import os
from datetime import datetime
from pathlib import Path
@@ -30,8 +31,14 @@ def get_default_encryption_keys(workspace_dir: Path) -> list[EncryptionKey]:
"""Generate default encryption keys."""
master_key = os.getenv('JWT_SECRET')
if master_key:
+ # Derive a deterministic key ID from the secret itself.
+ # This ensures all pods using the same JWT_SECRET get the same key ID,
+ # which is critical for multi-pod deployments where tokens may be
+ # created by one pod and verified by another.
+ key_id = base62.encodebytes(hashlib.sha256(master_key.encode()).digest())
return [
EncryptionKey(
+ id=key_id,
key=SecretStr(master_key),
active=True,
notes='jwt secret master key',
diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py
index 3f2ad876748b..0753f0a0a1ce 100644
--- a/openhands/controller/agent_controller.py
+++ b/openhands/controller/agent_controller.py
@@ -42,10 +42,6 @@
from openhands.core.logger import LOG_ALL_EVENTS
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import AgentState
-from openhands.utils.posthog_tracker import (
- track_agent_task_completed,
- track_credit_limit_reached,
-)
from openhands.events import (
EventSource,
EventStream,
@@ -713,20 +709,6 @@ async def set_agent_state_to(self, new_state: AgentState) -> None:
EventSource.ENVIRONMENT,
)
- # Track agent task completion in PostHog
- if new_state == AgentState.FINISHED:
- try:
- # Get app_mode from environment, default to 'oss'
- app_mode = os.environ.get('APP_MODE', 'oss')
- track_agent_task_completed(
- conversation_id=self.id,
- user_id=self.user_id,
- app_mode=app_mode,
- )
- except Exception as e:
- # Don't let tracking errors interrupt the agent
- self.log('warning', f'Failed to track agent completion: {e}')
-
# Save state whenever agent state changes to ensure we don't lose state
# in case of crashes or unexpected circumstances
self.save_state()
@@ -905,18 +887,6 @@ async def _step(self) -> None:
self.state_tracker.run_control_flags()
except Exception as e:
logger.warning('Control flag limits hit')
- # Track credit limit reached if it's a budget exception
- if 'budget' in str(e).lower() and self.state.budget_flag:
- try:
- track_credit_limit_reached(
- conversation_id=self.id,
- user_id=self.user_id,
- current_budget=self.state.budget_flag.current_value,
- max_budget=self.state.budget_flag.max_value,
- )
- except Exception as track_error:
- # Don't let tracking errors interrupt the agent
- self.log('warning', f'Failed to track credit limit: {track_error}')
await self._react_to_exception(e)
return
@@ -974,6 +944,23 @@ async def _step(self) -> None:
return
else:
raise LLMContextWindowExceedError()
+ # Check if this is a tool call validation error that should be recoverable
+ elif (
+ isinstance(e, BadRequestError)
+ and 'tool call validation failed' in error_str
+ and (
+ 'missing properties' in error_str
+ or 'missing required' in error_str
+ )
+ ):
+ # Handle tool call validation errors from Groq as recoverable errors
+ self.event_stream.add_event(
+ ErrorObservation(
+ content=f'Tool call validation failed: {str(e)}. Please check the tool parameters and try again.',
+ ),
+ EventSource.AGENT,
+ )
+ return
else:
raise e
diff --git a/openhands/core/config/llm_config.py b/openhands/core/config/llm_config.py
index 0089f9b27985..8a5f704b3695 100644
--- a/openhands/core/config/llm_config.py
+++ b/openhands/core/config/llm_config.py
@@ -50,7 +50,7 @@ class LLMConfig(BaseModel):
completion_kwargs: Custom kwargs to pass to litellm.completion.
"""
- model: str = Field(default='claude-sonnet-4-20250514')
+ model: str = Field(default='claude-opus-4-5-20251101')
api_key: SecretStr | None = Field(default=None)
base_url: str | None = Field(default=None)
api_version: str | None = Field(default=None)
diff --git a/openhands/integrations/provider.py b/openhands/integrations/provider.py
index b4289283e7fd..c260f23ee026 100644
--- a/openhands/integrations/provider.py
+++ b/openhands/integrations/provider.py
@@ -1,8 +1,9 @@
from __future__ import annotations
import os
+from collections.abc import Mapping
from types import MappingProxyType
-from typing import Annotated, Any, Coroutine, Literal, cast, overload
+from typing import Any, Coroutine, Literal, cast, overload
from urllib.parse import quote
import httpx
@@ -11,7 +12,6 @@
ConfigDict,
Field,
SecretStr,
- WithJsonSchema,
)
from openhands.core.logger import openhands_logger as logger
@@ -95,16 +95,8 @@ def from_value(cls, secret_value: CustomSecret | dict[str, str]) -> CustomSecret
raise ValueError('Unsupport Provider token type')
-PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
-CUSTOM_SECRETS_TYPE = MappingProxyType[str, CustomSecret]
-PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Annotated[
- PROVIDER_TOKEN_TYPE,
- WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),
-]
-CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Annotated[
- CUSTOM_SECRETS_TYPE,
- WithJsonSchema({'type': 'object', 'additionalProperties': {'type': 'string'}}),
-]
+PROVIDER_TOKEN_TYPE = Mapping[ProviderType, ProviderToken]
+CUSTOM_SECRETS_TYPE = Mapping[str, CustomSecret]
class ProviderHandler:
diff --git a/openhands/llm/fn_call_converter.py b/openhands/llm/fn_call_converter.py
index 7de88245162e..826b278dc480 100644
--- a/openhands/llm/fn_call_converter.py
+++ b/openhands/llm/fn_call_converter.py
@@ -421,16 +421,12 @@ def convert_tool_call_to_string(tool_call: dict) -> str:
f'Failed to parse arguments as JSON. Arguments: {tool_call["function"]["arguments"]}'
) from e
for param_name, param_value in args.items():
- is_multiline = isinstance(param_value, str) and '\n' in param_value
+ # Don't add extra newlines - keep parameter value as-is
ret += f''
- if is_multiline:
- ret += '\n'
if isinstance(param_value, list) or isinstance(param_value, dict):
ret += json.dumps(param_value)
else:
ret += f'{param_value}'
- if is_multiline:
- ret += '\n'
ret += ' \n'
ret += ''
return ret
diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py
index b94ed3bc2be6..150fa54925c1 100644
--- a/openhands/llm/llm.py
+++ b/openhands/llm/llm.py
@@ -188,12 +188,14 @@ def __init__(
if 'claude-opus-4-1' in self.config.model.lower():
kwargs['thinking'] = {'type': 'disabled'}
- # Anthropic constraint: Opus 4.1 and Sonnet 4 models cannot accept both temperature and top_p
+ # Anthropic constraint: Opus 4.1, Opus 4.5, and Sonnet 4 models cannot accept both temperature and top_p
# Prefer temperature (drop top_p) if both are specified.
_model_lower = self.config.model.lower()
- # Apply to Opus 4.1 and Sonnet 4 models to avoid API errors
+ # Apply to Opus 4.1, Opus 4.5, and Sonnet 4 models to avoid API errors
if (
- ('claude-opus-4-1' in _model_lower) or ('claude-sonnet-4' in _model_lower)
+ ('claude-opus-4-1' in _model_lower)
+ or ('claude-opus-4-5' in _model_lower)
+ or ('claude-sonnet-4' in _model_lower)
) and ('temperature' in kwargs and 'top_p' in kwargs):
kwargs.pop('top_p', None)
diff --git a/openhands/llm/model_features.py b/openhands/llm/model_features.py
index a9857ffaca85..f592f0bb9892 100644
--- a/openhands/llm/model_features.py
+++ b/openhands/llm/model_features.py
@@ -132,6 +132,8 @@ class ModelFeatures:
'grok-code-fast-1',
# DeepSeek R1 family
'deepseek-r1-0528*',
+ # Azure GPT-5 family
+ 'azure/gpt-5*',
]
diff --git a/openhands/memory/conversation_memory.py b/openhands/memory/conversation_memory.py
index 5ff6ec7e584f..5ae1a2cd715c 100644
--- a/openhands/memory/conversation_memory.py
+++ b/openhands/memory/conversation_memory.py
@@ -76,6 +76,7 @@ def process_events(
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 @@ def process_events(
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 @@ def _ensure_system_message(self, events: list[Event]) -> None:
)
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 @@ def _ensure_initial_user_message(
# 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 87a20b6340e5..81dd8bab5d63 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 @@ def from_events(events: list[Event]) -> View:
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 5eb5429f71ca..c7a332166b7d 100644
--- a/openhands/runtime/base.py
+++ b/openhands/runtime/base.py
@@ -76,6 +76,8 @@
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 @@ def __init__(
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 55e3ce18902a..c8d09d9c2bdd 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 @@ def init_browser(self) -> None:
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 @@ def browser_process(self) -> None:
)
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 @@ def browser_process(self) -> None:
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/builder/docker.py b/openhands/runtime/builder/docker.py
index 39a7982cd518..5f0fb2027b26 100644
--- a/openhands/runtime/builder/docker.py
+++ b/openhands/runtime/builder/docker.py
@@ -19,8 +19,11 @@ def __init__(self, docker_client: docker.DockerClient):
version_info = self.docker_client.version()
server_version = version_info.get('Version', '').replace('-', '.')
+ components = version_info.get('Components')
self.is_podman = (
- version_info.get('Components')[0].get('Name').startswith('Podman')
+ components is not None
+ and len(components) > 0
+ and components[0].get('Name', '').startswith('Podman')
)
if (
tuple(map(int, server_version.split('.')[:2])) < (18, 9)
@@ -79,8 +82,11 @@ def build(
self.docker_client = docker.from_env()
version_info = self.docker_client.version()
server_version = version_info.get('Version', '').split('+')[0].replace('-', '.')
+ components = version_info.get('Components')
self.is_podman = (
- version_info.get('Components')[0].get('Name').startswith('Podman')
+ components is not None
+ and len(components) > 0
+ and components[0].get('Name', '').startswith('Podman')
)
if tuple(map(int, server_version.split('.'))) < (18, 9) and not self.is_podman:
raise AgentRuntimeBuildError(
diff --git a/openhands/runtime/impl/docker/containers.py b/openhands/runtime/impl/docker/containers.py
index 25764b027488..32a5ba1353e2 100644
--- a/openhands/runtime/impl/docker/containers.py
+++ b/openhands/runtime/impl/docker/containers.py
@@ -7,7 +7,7 @@ def stop_all_containers(prefix: str) -> None:
containers = docker_client.containers.list(all=True)
for container in containers:
try:
- if container.name.startswith(prefix):
+ if container.name and container.name.startswith(prefix):
container.stop()
except docker.errors.APIError:
pass
diff --git a/openhands/runtime/impl/kubernetes/README.md b/openhands/runtime/impl/kubernetes/README.md
index d16247389da0..36b7452d1ea8 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 ed8d26996ae4..cf81b222ebd5 100644
--- a/openhands/runtime/impl/local/local_runtime.py
+++ b/openhands/runtime/impl/local/local_runtime.py
@@ -45,6 +45,8 @@
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 @@ async def connect(self) -> None:
)
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 @@ def setup(cls, config: OpenHandsConfig, headless_mode: bool = False):
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/server/app.py b/openhands/server/app.py
index d5135f23999d..5cee75b163a4 100644
--- a/openhands/server/app.py
+++ b/openhands/server/app.py
@@ -36,7 +36,7 @@
from openhands.server.types import AppMode
from openhands.version import get_version
-mcp_app = mcp_server.http_app(path='/mcp')
+mcp_app = mcp_server.http_app(path='/mcp', stateless_http=True)
def combine_lifespans(*lifespans):
diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py
index 1401bb0dcd34..a6807a2e2a4a 100644
--- a/openhands/server/routes/git.py
+++ b/openhands/server/routes/git.py
@@ -26,13 +26,11 @@
)
from openhands.server.dependencies import get_dependencies
from openhands.server.shared import server_config
-from openhands.server.types import AppMode
from openhands.server.user_auth import (
get_access_token,
get_provider_tokens,
get_user_id,
)
-from openhands.utils.posthog_tracker import alias_user_identities
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
@@ -119,14 +117,6 @@ async def get_user(
try:
user: User = await client.get_user()
-
- # Alias git provider login with Keycloak user ID in PostHog (SaaS mode only)
- if user_id and user.login and server_config.app_mode == AppMode.SAAS:
- alias_user_identities(
- keycloak_user_id=user_id,
- git_login=user.login,
- )
-
return user
except UnknownException as e:
diff --git a/openhands/server/routes/mcp.py b/openhands/server/routes/mcp.py
index 929c66af5b9e..2d541d637c90 100644
--- a/openhands/server/routes/mcp.py
+++ b/openhands/server/routes/mcp.py
@@ -25,9 +25,7 @@
)
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
-mcp_server = FastMCP(
- 'mcp', stateless_http=True, mask_error_details=True, dependencies=None
-)
+mcp_server = FastMCP('mcp', mask_error_details=True)
HOST = f'https://{os.getenv("WEB_HOST", "app.all-hands.dev").strip()}'
CONVERSATION_URL = HOST + '/conversations/{}'
diff --git a/openhands/server/services/conversation_service.py b/openhands/server/services/conversation_service.py
index 927e55ce5831..ac2e06b8cdd1 100644
--- a/openhands/server/services/conversation_service.py
+++ b/openhands/server/services/conversation_service.py
@@ -7,7 +7,7 @@
from openhands.events.action.message import MessageAction
from openhands.experiments.experiment_manager import ExperimentManagerImpl
from openhands.integrations.provider import (
- CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA,
+ CUSTOM_SECRETS_TYPE,
PROVIDER_TOKEN_TYPE,
ProviderToken,
)
@@ -73,7 +73,7 @@ async def initialize_conversation(
async def start_conversation(
user_id: str | None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
- custom_secrets: CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA | None,
+ custom_secrets: CUSTOM_SECRETS_TYPE | None,
initial_user_msg: str | None,
image_urls: list[str] | None,
replay_json: str | None,
@@ -164,7 +164,7 @@ async def start_conversation(
async def create_new_conversation(
user_id: str | None,
git_provider_tokens: PROVIDER_TOKEN_TYPE | None,
- custom_secrets: CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA | None,
+ custom_secrets: CUSTOM_SECRETS_TYPE | None,
selected_repository: str | None,
selected_branch: str | None,
initial_user_msg: str | None,
diff --git a/openhands/server/session/conversation_init_data.py b/openhands/server/session/conversation_init_data.py
index cdf76db97702..c1bf660c2840 100644
--- a/openhands/server/session/conversation_init_data.py
+++ b/openhands/server/session/conversation_init_data.py
@@ -1,4 +1,7 @@
-from pydantic import ConfigDict, Field
+from collections.abc import Mapping
+from types import MappingProxyType
+
+from pydantic import ConfigDict, Field, field_validator
from openhands.integrations.provider import CUSTOM_SECRETS_TYPE, PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import ProviderType
@@ -19,3 +22,17 @@ class ConversationInitData(Settings):
model_config = ConfigDict(
arbitrary_types_allowed=True,
)
+
+ @field_validator('git_provider_tokens', 'custom_secrets')
+ @classmethod
+ def immutable_validator(cls, value: Mapping | None) -> MappingProxyType | None:
+ """Ensure git_provider_tokens and custom_secrets are always MappingProxyType.
+
+ This validator converts any Mapping (including dict) to MappingProxyType,
+ ensuring type safety and immutability. If the value is None, it returns None.
+ """
+ if value is None:
+ return None
+ if isinstance(value, MappingProxyType):
+ return value
+ return MappingProxyType(value)
diff --git a/openhands/server/user_auth/default_user_auth.py b/openhands/server/user_auth/default_user_auth.py
index 2e0a7b5af992..8bc79af1561e 100644
--- a/openhands/server/user_auth/default_user_auth.py
+++ b/openhands/server/user_auth/default_user_auth.py
@@ -88,6 +88,9 @@ async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
return None
return user_secrets.provider_tokens
+ async def get_mcp_api_key(self) -> str | None:
+ return None
+
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
user_auth = DefaultUserAuth()
diff --git a/openhands/server/user_auth/user_auth.py b/openhands/server/user_auth/user_auth.py
index e370d3247438..c61c9ceb8bfb 100644
--- a/openhands/server/user_auth/user_auth.py
+++ b/openhands/server/user_auth/user_auth.py
@@ -75,6 +75,10 @@ async def get_secrets(self) -> Secrets | None:
def get_auth_type(self) -> AuthType | None:
return None
+ @abstractmethod
+ async def get_mcp_api_key(self) -> str | None:
+ """Get an mcp api key for the user"""
+
@classmethod
@abstractmethod
async def get_instance(cls, request: Request) -> UserAuth:
diff --git a/openhands/storage/data_models/secrets.py b/openhands/storage/data_models/secrets.py
index ce5302e754af..69b60e9730d0 100644
--- a/openhands/storage/data_models/secrets.py
+++ b/openhands/storage/data_models/secrets.py
@@ -1,3 +1,4 @@
+from collections.abc import Mapping
from types import MappingProxyType
from typing import Any
@@ -7,6 +8,7 @@
Field,
SerializationInfo,
field_serializer,
+ field_validator,
model_validator,
)
from pydantic.json import pydantic_encoder
@@ -14,9 +16,7 @@
from openhands.events.stream import EventStream
from openhands.integrations.provider import (
CUSTOM_SECRETS_TYPE,
- CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA,
PROVIDER_TOKEN_TYPE,
- PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA,
CustomSecret,
ProviderToken,
)
@@ -24,11 +24,11 @@
class Secrets(BaseModel):
- provider_tokens: PROVIDER_TOKEN_TYPE_WITH_JSON_SCHEMA = Field(
+ provider_tokens: PROVIDER_TOKEN_TYPE = Field(
default_factory=lambda: MappingProxyType({})
)
- custom_secrets: CUSTOM_SECRETS_TYPE_WITH_JSON_SCHEMA = Field(
+ custom_secrets: CUSTOM_SECRETS_TYPE = Field(
default_factory=lambda: MappingProxyType({})
)
@@ -38,6 +38,11 @@ class Secrets(BaseModel):
arbitrary_types_allowed=True,
)
+ @field_validator('provider_tokens', 'custom_secrets')
+ @classmethod
+ def immutable_validator(cls, value: Mapping) -> MappingProxyType:
+ return MappingProxyType(value)
+
@field_serializer('provider_tokens')
def provider_tokens_serializer(
self, provider_tokens: PROVIDER_TOKEN_TYPE, info: SerializationInfo
diff --git a/openhands/storage/data_models/settings.py b/openhands/storage/data_models/settings.py
index 72785c1822ab..0dc9b99e6238 100644
--- a/openhands/storage/data_models/settings.py
+++ b/openhands/storage/data_models/settings.py
@@ -1,5 +1,7 @@
from __future__ import annotations
+import os
+
from pydantic import (
BaseModel,
ConfigDict,
@@ -48,6 +50,7 @@ class Settings(BaseModel):
email_verified: bool | None = None
git_user_name: str | None = None
git_user_email: str | None = None
+ v1_enabled: bool | None = Field(default=bool(os.getenv('V1_ENABLED') == '1'))
model_config = ConfigDict(
validate_assignment=True,
diff --git a/openhands/storage/settings/file_settings_store.py b/openhands/storage/settings/file_settings_store.py
index 3acedeb16fe3..5b43bf6b80fd 100644
--- a/openhands/storage/settings/file_settings_store.py
+++ b/openhands/storage/settings/file_settings_store.py
@@ -21,6 +21,11 @@ async def load(self) -> Settings | None:
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 9eeb7c539304..876a89000159 100644
--- a/openhands/utils/llm.py
+++ b/openhands/utils/llm.py
@@ -60,6 +60,7 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
'openhands/gpt-5-2025-08-07',
'openhands/gpt-5-mini-2025-08-07',
'openhands/claude-opus-4-20250514',
+ 'openhands/claude-opus-4-5-20251101',
'openhands/gemini-2.5-pro',
'openhands/o3',
'openhands/o4-mini',
@@ -90,4 +91,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/openhands/utils/posthog_tracker.py b/openhands/utils/posthog_tracker.py
deleted file mode 100644
index c0859eddc717..000000000000
--- a/openhands/utils/posthog_tracker.py
+++ /dev/null
@@ -1,270 +0,0 @@
-"""PostHog tracking utilities for OpenHands events."""
-
-import os
-
-from openhands.core.logger import openhands_logger as logger
-
-# Lazy import posthog to avoid import errors in environments where it's not installed
-posthog = None
-
-
-def _init_posthog():
- """Initialize PostHog client lazily."""
- global posthog
- if posthog is None:
- try:
- import posthog as ph
-
- posthog = ph
- posthog.api_key = os.environ.get(
- 'POSTHOG_CLIENT_KEY', 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
- )
- posthog.host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
- except ImportError:
- logger.warning(
- 'PostHog not installed. Analytics tracking will be disabled.'
- )
- posthog = None
-
-
-def track_agent_task_completed(
- conversation_id: str,
- user_id: str | None = None,
- app_mode: str | None = None,
-) -> None:
- """Track when an agent completes a task.
-
- Args:
- conversation_id: The ID of the conversation/session
- user_id: The ID of the user (optional, may be None for unauthenticated users)
- app_mode: The application mode (saas/oss), optional
- """
- _init_posthog()
-
- if posthog is None:
- return
-
- # Use conversation_id as distinct_id if user_id is not available
- # This ensures we can track completions even for anonymous users
- distinct_id = user_id if user_id else f'conversation_{conversation_id}'
-
- try:
- posthog.capture(
- distinct_id=distinct_id,
- event='agent_task_completed',
- properties={
- 'conversation_id': conversation_id,
- 'user_id': user_id,
- 'app_mode': app_mode or 'unknown',
- },
- )
- logger.debug(
- 'posthog_track',
- extra={
- 'event': 'agent_task_completed',
- 'conversation_id': conversation_id,
- 'user_id': user_id,
- },
- )
- except Exception as e:
- logger.warning(
- f'Failed to track agent_task_completed to PostHog: {e}',
- extra={
- 'conversation_id': conversation_id,
- 'error': str(e),
- },
- )
-
-
-def track_user_signup_completed(
- user_id: str,
- signup_timestamp: str,
-) -> None:
- """Track when a user completes signup by accepting TOS.
-
- Args:
- user_id: The ID of the user (Keycloak user ID)
- signup_timestamp: ISO format timestamp of when TOS was accepted
- """
- _init_posthog()
-
- if posthog is None:
- return
-
- try:
- posthog.capture(
- distinct_id=user_id,
- event='user_signup_completed',
- properties={
- 'user_id': user_id,
- 'signup_timestamp': signup_timestamp,
- },
- )
- logger.debug(
- 'posthog_track',
- extra={
- 'event': 'user_signup_completed',
- 'user_id': user_id,
- },
- )
- except Exception as e:
- logger.warning(
- f'Failed to track user_signup_completed to PostHog: {e}',
- extra={
- 'user_id': user_id,
- 'error': str(e),
- },
- )
-
-
-def track_credit_limit_reached(
- conversation_id: str,
- user_id: str | None = None,
- current_budget: float = 0.0,
- max_budget: float = 0.0,
-) -> None:
- """Track when a user reaches their credit limit during a conversation.
-
- Args:
- conversation_id: The ID of the conversation/session
- user_id: The ID of the user (optional, may be None for unauthenticated users)
- current_budget: The current budget spent
- max_budget: The maximum budget allowed
- """
- _init_posthog()
-
- if posthog is None:
- return
-
- distinct_id = user_id if user_id else f'conversation_{conversation_id}'
-
- try:
- posthog.capture(
- distinct_id=distinct_id,
- event='credit_limit_reached',
- properties={
- 'conversation_id': conversation_id,
- 'user_id': user_id,
- 'current_budget': current_budget,
- 'max_budget': max_budget,
- },
- )
- logger.debug(
- 'posthog_track',
- extra={
- 'event': 'credit_limit_reached',
- 'conversation_id': conversation_id,
- 'user_id': user_id,
- 'current_budget': current_budget,
- 'max_budget': max_budget,
- },
- )
- except Exception as e:
- logger.warning(
- f'Failed to track credit_limit_reached to PostHog: {e}',
- extra={
- 'conversation_id': conversation_id,
- 'error': str(e),
- },
- )
-
-
-def track_credits_purchased(
- user_id: str,
- amount_usd: float,
- credits_added: float,
- stripe_session_id: str,
-) -> None:
- """Track when a user successfully purchases credits.
-
- Args:
- user_id: The ID of the user (Keycloak user ID)
- amount_usd: The amount paid in USD (cents converted to dollars)
- credits_added: The number of credits added to the user's account
- stripe_session_id: The Stripe checkout session ID
- """
- _init_posthog()
-
- if posthog is None:
- return
-
- try:
- posthog.capture(
- distinct_id=user_id,
- event='credits_purchased',
- properties={
- 'user_id': user_id,
- 'amount_usd': amount_usd,
- 'credits_added': credits_added,
- 'stripe_session_id': stripe_session_id,
- },
- )
- logger.debug(
- 'posthog_track',
- extra={
- 'event': 'credits_purchased',
- 'user_id': user_id,
- 'amount_usd': amount_usd,
- 'credits_added': credits_added,
- },
- )
- except Exception as e:
- logger.warning(
- f'Failed to track credits_purchased to PostHog: {e}',
- extra={
- 'user_id': user_id,
- 'error': str(e),
- },
- )
-
-
-def alias_user_identities(
- keycloak_user_id: str,
- git_login: str,
-) -> None:
- """Alias a user's Keycloak ID with their git provider login for unified tracking.
-
- This allows PostHog to link events tracked from the frontend (using git provider login)
- with events tracked from the backend (using Keycloak user ID).
-
- PostHog Python alias syntax: alias(previous_id, distinct_id)
- - previous_id: The old/previous distinct ID that will be merged
- - distinct_id: The new/canonical distinct ID to merge into
-
- For our use case:
- - Git provider login is the previous_id (first used in frontend, before backend auth)
- - Keycloak user ID is the distinct_id (canonical backend ID)
- - Result: All events with git login will be merged into Keycloak user ID
-
- Args:
- keycloak_user_id: The Keycloak user ID (canonical distinct_id)
- git_login: The git provider username (GitHub/GitLab/Bitbucket) to merge
-
- Reference:
- https://github.com/PostHog/posthog-python/blob/master/posthog/client.py
- """
- _init_posthog()
-
- if posthog is None:
- return
-
- try:
- # Merge git provider login into Keycloak user ID
- # posthog.alias(previous_id, distinct_id) - official Python SDK signature
- posthog.alias(git_login, keycloak_user_id)
- logger.debug(
- 'posthog_alias',
- extra={
- 'previous_id': git_login,
- 'distinct_id': keycloak_user_id,
- },
- )
- except Exception as e:
- logger.warning(
- f'Failed to alias user identities in PostHog: {e}',
- extra={
- 'keycloak_user_id': keycloak_user_id,
- 'git_login': git_login,
- 'error': str(e),
- },
- )
diff --git a/poetry.lock b/poetry.lock
index 0cc47afea7d7..23789d328587 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -254,14 +254,14 @@ files = [
[[package]]
name = "anthropic"
-version = "0.72.0"
+version = "0.75.0"
description = "The official Python library for the anthropic API"
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
groups = ["main"]
files = [
- {file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
- {file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
+ {file = "anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b"},
+ {file = "anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb"},
]
[package.dependencies]
@@ -1205,34 +1205,37 @@ botocore = ["botocore"]
[[package]]
name = "browser-use"
-version = "0.8.0"
+version = "0.10.1"
description = "Make websites accessible for AI agents"
optional = false
python-versions = "<4.0,>=3.11"
groups = ["main"]
files = [
- {file = "browser_use-0.8.0-py3-none-any.whl", hash = "sha256:b7c299e38ec1c1aec42a236cc6ad2268a366226940d6ff9d88ed461afd5a1cc3"},
- {file = "browser_use-0.8.0.tar.gz", hash = "sha256:2136eb3251424f712a08ee379c9337237c2f93b29b566807db599cf94e6abb5e"},
+ {file = "browser_use-0.10.1-py3-none-any.whl", hash = "sha256:96e603bfc71098175342cdcb0592519e6f244412e740f0254e4389fdd82a977f"},
+ {file = "browser_use-0.10.1.tar.gz", hash = "sha256:5f211ecfdf1f9fd186160f10df70dedd661821231e30f1bce40939787abab223"},
]
[package.dependencies]
aiohttp = "3.12.15"
-anthropic = ">=0.68.1,<1.0.0"
+anthropic = ">=0.72.1,<1.0.0"
anyio = ">=4.9.0"
authlib = ">=1.6.0"
bubus = ">=1.5.6"
-cdp-use = ">=1.4.0"
+cdp-use = ">=1.4.4"
+click = ">=8.1.8"
+cloudpickle = ">=3.1.1"
google-api-core = ">=2.25.0"
google-api-python-client = ">=2.174.0"
google-auth = ">=2.40.3"
google-auth-oauthlib = ">=1.2.2"
-google-genai = ">=1.29.0,<2.0.0"
+google-genai = ">=1.50.0,<2.0.0"
groq = ">=0.30.0"
-html2text = ">=2025.4.15"
httpx = ">=0.28.1"
+inquirerpy = ">=0.3.4"
+markdownify = ">=1.2.0"
mcp = ">=1.10.1"
ollama = ">=0.5.1"
-openai = ">=1.99.2,<2.0.0"
+openai = ">=2.7.2,<3.0.0"
pillow = ">=11.2.1"
portalocker = ">=2.7.0,<3.0.0"
posthog = ">=3.7.0"
@@ -1241,19 +1244,24 @@ pydantic = ">=2.11.5"
pyobjc = {version = ">=11.0", markers = "platform_system == \"darwin\""}
pyotp = ">=2.9.0"
pypdf = ">=5.7.0"
+python-docx = ">=1.2.0"
python-dotenv = ">=1.0.1"
reportlab = ">=4.0.0"
requests = ">=2.32.3"
+rich = ">=14.0.0"
screeninfo = {version = ">=0.8.1", markers = "platform_system != \"darwin\""}
typing-extensions = ">=4.12.2"
uuid7 = ">=0.1.0"
[package.extras]
-all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
+all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "oci (>=2.126.4)", "textual (>=3.2.0)"]
aws = ["boto3 (>=1.38.45)"]
-cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
-eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
+cli = ["textual (>=3.2.0)"]
+cli-oci = ["oci (>=2.126.4)", "textual (>=3.2.0)"]
+code = ["matplotlib (>=3.9.0)", "numpy (>=2.3.2)", "pandas (>=2.2.0)", "tabulate (>=0.9.0)"]
+eval = ["anyio (>=4.9.0)", "datamodel-code-generator (>=0.26.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
+oci = ["oci (>=2.126.4)"]
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
[[package]]
@@ -1494,14 +1502,14 @@ files = [
[[package]]
name = "cdp-use"
-version = "1.4.3"
+version = "1.4.4"
description = "Type safe generator/client library for CDP"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
- {file = "cdp_use-1.4.3-py3-none-any.whl", hash = "sha256:c48664604470c2579aa1e677c3e3e7e24c4f300c54804c093d935abb50479ecd"},
- {file = "cdp_use-1.4.3.tar.gz", hash = "sha256:9029c04bdc49fbd3939d2bf1988ad8d88e260729c7d5e35c2f6c87591f5a10e9"},
+ {file = "cdp_use-1.4.4-py3-none-any.whl", hash = "sha256:e37e80e067db2653d6fdf953d4ff9e5d80d75daa27b7c6d48c0261cccbef73e1"},
+ {file = "cdp_use-1.4.4.tar.gz", hash = "sha256:330a848b517006eb9ad1dc468aa6434d913cf0c6918610760c36c3fdfdba0fab"},
]
[package.dependencies]
@@ -3802,28 +3810,28 @@ testing = ["pytest"]
[[package]]
name = "google-genai"
-version = "1.45.0"
+version = "1.53.0"
description = "GenAI Python SDK"
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
groups = ["main"]
files = [
- {file = "google_genai-1.45.0-py3-none-any.whl", hash = "sha256:e755295063e5fd5a4c44acff782a569e37fa8f76a6c75d0ede3375c70d916b7f"},
- {file = "google_genai-1.45.0.tar.gz", hash = "sha256:96ec32ae99a30b5a1b54cb874b577ec6e41b5d5b808bf0f10ed4620e867f9386"},
+ {file = "google_genai-1.53.0-py3-none-any.whl", hash = "sha256:65a3f99e5c03c372d872cda7419f5940e723374bb12a2f3ffd5e3e56e8eb2094"},
+ {file = "google_genai-1.53.0.tar.gz", hash = "sha256:938a26d22f3fd32c6eeeb4276ef204ef82884e63af9842ce3eac05ceb39cbd8d"},
]
[package.dependencies]
anyio = ">=4.8.0,<5.0.0"
-google-auth = ">=2.14.1,<3.0.0"
+google-auth = {version = ">=2.14.1,<3.0.0", extras = ["requests"]}
httpx = ">=0.28.1,<1.0.0"
-pydantic = ">=2.0.0,<3.0.0"
+pydantic = ">=2.9.0,<3.0.0"
requests = ">=2.28.1,<3.0.0"
tenacity = ">=8.2.3,<9.2.0"
typing-extensions = ">=4.11.0,<5.0.0"
websockets = ">=13.0.0,<15.1.0"
[package.extras]
-aiohttp = ["aiohttp (<4.0.0)"]
+aiohttp = ["aiohttp (<3.13.3)"]
local-tokenizer = ["protobuf", "sentencepiece (>=0.2.0)"]
[[package]]
@@ -3991,67 +3999,71 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
[[package]]
name = "grpcio"
-version = "1.72.1"
+version = "1.67.1"
description = "HTTP/2-based RPC framework"
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.8"
groups = ["main"]
files = [
- {file = "grpcio-1.72.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:ce2706ff37be7a6de68fbc4c3f8dde247cab48cc70fee5fedfbc9cd923b4ee5a"},
- {file = "grpcio-1.72.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:7db9e15ee7618fbea748176a67d347f3100fa92d36acccd0e7eeb741bc82f72a"},
- {file = "grpcio-1.72.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:8d6e7764181ba4a8b74aa78c98a89c9f3441068ebcee5d6f14c44578214e0be3"},
- {file = "grpcio-1.72.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:237bb619ba33594006025e6f114f62e60d9563afd6f8e89633ee384868e26687"},
- {file = "grpcio-1.72.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7f1d8a442fd242aa432c8e1b8411c79ebc409dad2c637614d726e226ce9ed0c"},
- {file = "grpcio-1.72.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f2359bd4bba85bf94fd9ab8802671b9637a6803bb673d221157a11523a52e6a8"},
- {file = "grpcio-1.72.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3269cfca37570a420a57a785f2a5d4234c5b12aced55f8843dafced2d3f8c9a6"},
- {file = "grpcio-1.72.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:06c023d86398714d6257194c21f2bc0b58a53ce45cee87dd3c54c7932c590e17"},
- {file = "grpcio-1.72.1-cp310-cp310-win32.whl", hash = "sha256:06dbe54eeea5f9dfb3e7ca2ff66c715ff5fc96b07a1feb322122fe14cb42f6aa"},
- {file = "grpcio-1.72.1-cp310-cp310-win_amd64.whl", hash = "sha256:ba593aa2cd52f4468ba29668c83f893d88c128198d6b1273ca788ef53e3ae5fe"},
- {file = "grpcio-1.72.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:4e112c083f90c330b0eaa78a633fb206d49c20c443926e827f8cac9eb9d2ea32"},
- {file = "grpcio-1.72.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c6f7e3275832adab7384193f78b8c1a98b82541562fa08d7244e8a6b4b5c78a4"},
- {file = "grpcio-1.72.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:dd03c8847c47ef7ac5455aafdfb5e553ecf84f228282bd6106762b379f27c25c"},
- {file = "grpcio-1.72.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7497dbdf220b88b66004e2630fb2b1627df5e279db970d3cc20f70d39dce978d"},
- {file = "grpcio-1.72.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c2cde3ae8ae901317c049394ed8d3c6964de6b814ae65fc68636a7337b63aa"},
- {file = "grpcio-1.72.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7a66cef4bc1db81a54108a849e95650da640c9bc1901957bf7d3b1eeb3251ee8"},
- {file = "grpcio-1.72.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fc0435ad45d540597f78978e3fd5515b448193f51f9065fb67dda566336e0f5f"},
- {file = "grpcio-1.72.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:524bad78d610fa1f9f316d47b3aab1ff89d438ba952ee34e3e335ca80a27ba96"},
- {file = "grpcio-1.72.1-cp311-cp311-win32.whl", hash = "sha256:409ee0abf7e74bbf88941046142452cf3d1f3863d34e11e8fd2b07375170c730"},
- {file = "grpcio-1.72.1-cp311-cp311-win_amd64.whl", hash = "sha256:ea483e408fac55569c11158c3e6d6d6a8c3b0f798b68f1c10db9b22c5996e19b"},
- {file = "grpcio-1.72.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:65a5ef28e5852bd281c6d01a923906e8036736e95e370acab8626fcbec041e67"},
- {file = "grpcio-1.72.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:9e5c594a6c779d674204fb9bdaa1e7b71666ff10b34a62e7769fc6868b5d7511"},
- {file = "grpcio-1.72.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:d324f4bdb990d852d79b38c59a12d24fcd47cf3b1a38f2e4d2b6d0b1031bc818"},
- {file = "grpcio-1.72.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:841db55dd29cf2f4121b853b2f89813a1b6175163fbb92c5945fb1b0ca259ef2"},
- {file = "grpcio-1.72.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00da930aa2711b955a538e835096aa365a4b7f2701bdc2ce1febb242a103f8a1"},
- {file = "grpcio-1.72.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4b657773480267fbb7ad733fa85abc103c52ab62e5bc97791faf82c53836eefc"},
- {file = "grpcio-1.72.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a08b483f17a6abca2578283a7ae3aa8d4d90347242b0de2898bdb27395c3f20b"},
- {file = "grpcio-1.72.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:299f3ea4e03c1d0548f4a174b48d612412f92c667f2100e30a079ab76fdaa813"},
- {file = "grpcio-1.72.1-cp312-cp312-win32.whl", hash = "sha256:addc721a3708ff789da1bf69876018dc730c1ec9d3d3cb6912776a00c535a5bc"},
- {file = "grpcio-1.72.1-cp312-cp312-win_amd64.whl", hash = "sha256:22ea2aa92a60dff231ba5fcd7f0220a33c2218e556009996f858eeafe294d1c2"},
- {file = "grpcio-1.72.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:294be6e9c323a197434569a41e0fb5b5aa0962fd5d55a3dc890ec5df985f611a"},
- {file = "grpcio-1.72.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:41ec164dac8df2862f67457d9cdf8d8f8b6a4ca475a3ed1ba6547fff98d93717"},
- {file = "grpcio-1.72.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:761736f75c6ddea3732d97eaabe70c616271f5f542a8be95515135fdd1a638f6"},
- {file = "grpcio-1.72.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:082003cb93618964c111c70d69b60ac0dc6566d4c254c9b2a775faa2965ba8f8"},
- {file = "grpcio-1.72.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8660f736da75424949c14f7c8b1ac60a25b2f37cabdec95181834b405373e8a7"},
- {file = "grpcio-1.72.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2ada1abe2ad122b42407b2bfd79d6706a4940d4797f44bd740f5c98ca1ecda9b"},
- {file = "grpcio-1.72.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0db2766d0c482ee740abbe7d00a06cc4fb54f7e5a24d3cf27c3352be18a2b1e8"},
- {file = "grpcio-1.72.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4bdb404d9c2187260b34e2b22783c204fba8a9023a166cf77376190d9cf5a08"},
- {file = "grpcio-1.72.1-cp313-cp313-win32.whl", hash = "sha256:bb64722c3124c906a5b66e50a90fd36442642f653ba88a24f67d08e94bca59f3"},
- {file = "grpcio-1.72.1-cp313-cp313-win_amd64.whl", hash = "sha256:329cc6ff5b431df9614340d3825b066a1ff0a5809a01ba2e976ef48c65a0490b"},
- {file = "grpcio-1.72.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:8941b83addd503c1982090b4631804d0ff1edbbc6c85c9c20ed503b1dc65fef9"},
- {file = "grpcio-1.72.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:d29b80290c5eda561a4c291d6d5b4315a2a5095ab37061118d6e0781858aca0a"},
- {file = "grpcio-1.72.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:4ca56d955564db749c9c6d75e9c4c777854e22b2482d247fb6c5a02d5f28ea78"},
- {file = "grpcio-1.72.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b08a3ef14d2b01eef13882c6d3a2d8fb5fcd73db81bd1e3ab69d4ee75215433a"},
- {file = "grpcio-1.72.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd7df49801b3b323e4a21047979e3834cd286b32ee5ceee46f5217826274721f"},
- {file = "grpcio-1.72.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9717617ba2ff65c058ef53b0d5e50f03e8350f0c5597f93bb5c980a31db990c8"},
- {file = "grpcio-1.72.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:212db80b1e8aa7792d51269bfb32164e2333a9bb273370ace3ed2a378505cb01"},
- {file = "grpcio-1.72.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a0d19947d4480af5f363f077f221e665931f479e2604280ac4eafe6daa71f77"},
- {file = "grpcio-1.72.1-cp39-cp39-win32.whl", hash = "sha256:7622ef647dc911ed010a817d9be501df4ae83495b8e5cdd35b555bdcf3880a3e"},
- {file = "grpcio-1.72.1-cp39-cp39-win_amd64.whl", hash = "sha256:f8d8fa7cd2a7f1b4207e215dec8bc07f1202682d9a216ebe028185c15faece30"},
- {file = "grpcio-1.72.1.tar.gz", hash = "sha256:87f62c94a40947cec1a0f91f95f5ba0aa8f799f23a1d42ae5be667b6b27b959c"},
+ {file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
+ {file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
+ {file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
+ {file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
+ {file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
+ {file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
+ {file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
+ {file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
+ {file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
+ {file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
+ {file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
+ {file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
+ {file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
+ {file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
+ {file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
+ {file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
+ {file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
+ {file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
+ {file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
+ {file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
+ {file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
+ {file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
+ {file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
+ {file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
+ {file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
+ {file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
+ {file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
+ {file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
+ {file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
+ {file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
+ {file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
+ {file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
+ {file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
+ {file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
+ {file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
+ {file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
+ {file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
+ {file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
+ {file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
+ {file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
+ {file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
+ {file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
+ {file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
+ {file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
+ {file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
+ {file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
+ {file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
+ {file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
+ {file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
+ {file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
+ {file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
+ {file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
+ {file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
+ {file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
+ {file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
]
[package.extras]
-protobuf = ["grpcio-tools (>=1.72.1)"]
+protobuf = ["grpcio-tools (>=1.67.1)"]
[[package]]
name = "grpcio-status"
@@ -4434,6 +4446,25 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
+[[package]]
+name = "inquirerpy"
+version = "0.3.4"
+description = "Python port of Inquirer.js (A collection of common interactive command-line user interfaces)"
+optional = false
+python-versions = ">=3.7,<4.0"
+groups = ["main"]
+files = [
+ {file = "InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4"},
+ {file = "InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e"},
+]
+
+[package.dependencies]
+pfzy = ">=0.3.1,<0.4.0"
+prompt-toolkit = ">=3.0.1,<4.0.0"
+
+[package.extras]
+docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
+
[[package]]
name = "installer"
version = "0.7.0"
@@ -5609,25 +5640,26 @@ types-tqdm = "*"
[[package]]
name = "litellm"
-version = "1.77.7"
+version = "1.80.7"
description = "Library to easily interface with LLM API providers"
optional = false
-python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8"
+python-versions = "<4.0,>=3.9"
groups = ["main"]
files = [
- {file = "litellm-1.77.7-py3-none-any.whl", hash = "sha256:1b3a1b17bd521a0ad25226fb62a912602c803922aabb4a16adf83834673be574"},
- {file = "litellm-1.77.7.tar.gz", hash = "sha256:e3398fb2575b98726e787c0a1481daed5938d58cafdcd96fbca80c312221af3e"},
+ {file = "litellm-1.80.7-py3-none-any.whl", hash = "sha256:f7d993f78c1e0e4e1202b2a925cc6540b55b6e5fb055dd342d88b145ab3102ed"},
+ {file = "litellm-1.80.7.tar.gz", hash = "sha256:3977a8d195aef842d01c18bf9e22984829363c6a4b54daf9a43c9dd9f190b42c"},
]
[package.dependencies]
aiohttp = ">=3.10"
click = "*"
fastuuid = ">=0.13.0"
+grpcio = ">=1.62.3,<1.68.0"
httpx = ">=0.23.0"
importlib-metadata = ">=6.8.0"
jinja2 = ">=3.1.2,<4.0.0"
jsonschema = ">=4.22.0,<5.0.0"
-openai = ">=1.99.5"
+openai = ">=2.8.0"
pydantic = ">=2.5.0,<3.0.0"
python-dotenv = ">=0.2.0"
tiktoken = ">=0.7.0"
@@ -5635,22 +5667,22 @@ tokenizers = "*"
[package.extras]
caching = ["diskcache (>=5.6.1,<6.0.0)"]
-extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"]
+extra-proxy = ["azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-iam (>=2.19.1,<3.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0)"]
mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""]
-proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.20)", "litellm-proxy-extras (==0.2.25)", "mcp (>=1.10.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=13.1.0,<14.0.0)"]
-semantic-router = ["semantic-router ; python_version >= \"3.9\""]
+proxy = ["PyJWT (>=2.10.1,<3.0.0) ; python_version >= \"3.9\"", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0) ; python_version >= \"3.9\"", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.36.0)", "cryptography", "fastapi (>=0.120.1)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.22)", "litellm-proxy-extras (==0.4.9)", "mcp (>=1.21.2,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "soundfile (>=0.12.1,<0.13.0)", "uvicorn (>=0.31.1,<0.32.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=15.0.1,<16.0.0)"]
+semantic-router = ["semantic-router (>=0.1.12) ; python_version >= \"3.9\" and python_version < \"3.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]
@@ -5673,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)"]
@@ -5783,8 +5816,11 @@ files = [
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
+ {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
+ {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
+ {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
@@ -5905,14 +5941,14 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
[[package]]
name = "markdownify"
-version = "1.1.0"
+version = "1.2.2"
description = "Convert HTML to markdown."
optional = false
python-versions = "*"
groups = ["main"]
files = [
- {file = "markdownify-1.1.0-py3-none-any.whl", hash = "sha256:32a5a08e9af02c8a6528942224c91b933b4bd2c7d078f9012943776fc313eeef"},
- {file = "markdownify-1.1.0.tar.gz", hash = "sha256:449c0bbbf1401c5112379619524f33b63490a8fa479456d41de9dc9e37560ebd"},
+ {file = "markdownify-1.2.2-py3-none-any.whl", hash = "sha256:3f02d3cc52714084d6e589f70397b6fc9f2f3a8531481bf35e8cc39f975e186a"},
+ {file = "markdownify-1.2.2.tar.gz", hash = "sha256:b274f1b5943180b031b699b199cbaeb1e2ac938b75851849a31fd0c3d6603d09"},
]
[package.dependencies]
@@ -7188,28 +7224,28 @@ pydantic = ">=2.9"
[[package]]
name = "openai"
-version = "1.99.9"
+version = "2.8.0"
description = "The official Python library for the openai API"
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
groups = ["main", "evaluation"]
files = [
- {file = "openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a"},
- {file = "openai-1.99.9.tar.gz", hash = "sha256:f2082d155b1ad22e83247c3de3958eb4255b20ccf4a1de2e6681b6957b554e92"},
+ {file = "openai-2.8.0-py3-none-any.whl", hash = "sha256:ba975e347f6add2fe13529ccb94d54a578280e960765e5224c34b08d7e029ddf"},
+ {file = "openai-2.8.0.tar.gz", hash = "sha256:4851908f6d6fcacbd47ba659c5ac084f7725b752b6bfa1e948b6fbfc111a6bad"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
httpx = ">=0.23.0,<1"
-jiter = ">=0.4.0,<1"
+jiter = ">=0.10.0,<1"
pydantic = ">=1.9.0,<3"
sniffio = "*"
tqdm = ">4"
typing-extensions = ">=4.11,<5"
[package.extras]
-aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
+aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"]
realtime = ["websockets (>=13,<16)"]
voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"]
@@ -7344,54 +7380,46 @@ 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.1.0"
+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.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
- {file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
+ {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"},
]
-develop = false
[package.dependencies]
aiosqlite = ">=0.19"
alembic = ">=1.13"
docker = ">=7.1,<8"
fastapi = ">=0.104"
+openhands-sdk = "*"
pydantic = ">=2"
sqlalchemy = ">=2"
uvicorn = ">=0.31.1"
websockets = ">=12"
wsproto = ">=1.2.0"
-[package.source]
-type = "git"
-url = "https://github.com/OpenHands/agent-sdk.git"
-reference = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d"
-resolved_reference = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d"
-subdirectory = "openhands-agent-server"
-
[[package]]
name = "openhands-sdk"
-version = "1.1.0"
+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.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
- {file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
+ {file = "openhands_sdk-1.6.0-py3-none-any.whl", hash = "sha256:94d2f87fb35406373da6728ae2d88584137f9e9b67fa0e940444c72f2e44e7d3"},
+ {file = "openhands_sdk-1.6.0.tar.gz", hash = "sha256:f45742350e3874a7f5b08befc4a9d5adc7e4454f7ab5f8391c519eee3116090f"},
]
-develop = false
[package.dependencies]
deprecation = ">=2.1.0"
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
-litellm = ">=1.77.7.dev9"
-lmnr = ">=0.7.20"
+litellm = ">=1.80.7"
+lmnr = ">=0.7.24"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
python-json-logger = ">=3.3.0"
@@ -7401,25 +7429,17 @@ websockets = ">=12"
[package.extras]
boto3 = ["boto3 (>=1.35.0)"]
-[package.source]
-type = "git"
-url = "https://github.com/OpenHands/agent-sdk.git"
-reference = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d"
-resolved_reference = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d"
-subdirectory = "openhands-sdk"
-
[[package]]
name = "openhands-tools"
-version = "1.1.0"
+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.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
- {file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
+ {file = "openhands_tools-1.6.0-py3-none-any.whl", hash = "sha256:176556d44186536751b23fe052d3505492cc2afb8d52db20fb7a2cc0169cd57a"},
+ {file = "openhands_tools-1.6.0.tar.gz", hash = "sha256:d07ba31050fd4a7891a4c48388aa53ce9f703e17064ddbd59146d6c77e5980b3"},
]
-develop = false
[package.dependencies]
bashlex = ">=0.18"
@@ -7430,13 +7450,7 @@ func-timeout = ">=4.3.5"
libtmux = ">=0.46.2"
openhands-sdk = "*"
pydantic = ">=2.11.7"
-
-[package.source]
-type = "git"
-url = "https://github.com/OpenHands/agent-sdk.git"
-reference = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d"
-resolved_reference = "15f565b8ac38876e40dc05c08e2b04ccaae4a66d"
-subdirectory = "openhands-tools"
+tom-swe = ">=1.0.3"
[[package]]
name = "openpyxl"
@@ -7949,6 +7963,21 @@ files = [
[package.dependencies]
ptyprocess = ">=0.5"
+[[package]]
+name = "pfzy"
+version = "0.3.4"
+description = "Python port of the fzy fuzzy string matching algorithm"
+optional = false
+python-versions = ">=3.7,<4.0"
+groups = ["main"]
+files = [
+ {file = "pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96"},
+ {file = "pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1"},
+]
+
+[package.extras]
+docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
+
[[package]]
name = "pg8000"
version = "1.31.5"
@@ -14990,6 +15019,31 @@ dev = ["tokenizers[testing]"]
docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"]
testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"]
+[[package]]
+name = "tom-swe"
+version = "1.0.3"
+description = "Theory of Mind modeling for Software Engineering assistants"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "tom_swe-1.0.3-py3-none-any.whl", hash = "sha256:7b1172b29eb5c8fb7f1975016e7b6a238511b9ac2a7a980bd400dcb4e29773f2"},
+ {file = "tom_swe-1.0.3.tar.gz", hash = "sha256:57c97d0104e563f15bd39edaf2aa6ac4c3e9444afd437fb92458700d22c6c0f5"},
+]
+
+[package.dependencies]
+jinja2 = ">=3.0.0"
+json-repair = ">=0.1.0"
+litellm = ">=1.0.0"
+pydantic = ">=2.0.0"
+python-dotenv = ">=1.0.0"
+tiktoken = ">=0.8.0"
+tqdm = ">=4.65.0"
+
+[package.extras]
+dev = ["aiofiles (>=23.0.0)", "black (>=22.0.0)", "datasets (>=2.0.0)", "fastapi (>=0.104.0)", "httpx (>=0.25.0)", "huggingface-hub (>=0.0.0)", "isort (>=5.0.0)", "mypy (>=1.0.0)", "numpy (>=1.24.0)", "pandas (>=2.0.0)", "pre-commit (>=3.6.0)", "pytest (>=7.0.0)", "pytest-cov (>=6.2.1)", "rich (>=13.0.0)", "ruff (>=0.3.0)", "typing-extensions (>=4.0.0)", "uvicorn (>=0.24.0)"]
+search = ["bm25s (>=0.2.0)", "pystemmer (>=2.2.0)"]
+
[[package]]
name = "toml"
version = "0.10.2"
@@ -16769,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 = "44c6c1f432337d216b70a6654fb0cd20410ddeb56485999859032aec53e90458"
+content-hash = "9764f3b69ec8ed35feebd78a826bbc6bfa4ac6d5b56bc999be8bc738b644e538"
diff --git a/pyproject.toml b/pyproject.toml
index 75dada88a24d..c70c110dcc15 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"
@@ -26,8 +26,8 @@ build = "build_vscode.py" # Build VSCode extension during Poetry build
[tool.poetry.dependencies]
python = "^3.12,<3.14"
-litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
-openai = "1.99.9" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711)
+litellm = ">=1.74.3, <=1.80.7, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
+openai = "2.8.0" # Pin due to litellm incompatibility with >=1.100.0 (BerriAI/litellm#13711)
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
google-genai = "*" # To use litellm with Gemini Pro API
google-api-python-client = "^2.164.0" # For Google Sheets API
@@ -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.1.0"
-openhands-agent-server = "1.1.0"
-openhands-tools = "1.1.0"
+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 2a90dcb66830..09f28e991c86 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 60cc0bb16f84..2435b1320ace 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
new file mode 100644
index 000000000000..db31d8d3d200
--- /dev/null
+++ b/tests/unit/app_server/test_app_conversation_service_base.py
@@ -0,0 +1,1266 @@
+"""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
+
+from openhands.app_server.app_conversation.app_conversation_models import AgentType
+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
+
+
+class MockUserInfo:
+ """Mock class for UserInfo to simulate user settings."""
+
+ def __init__(
+ self, git_user_name: str | None = None, git_user_email: str | None = None
+ ):
+ self.git_user_name = git_user_name
+ self.git_user_email = git_user_email
+
+
+class MockCommandResult:
+ """Mock class for command execution result."""
+
+ def __init__(self, exit_code: int = 0, stderr: str = ''):
+ self.exit_code = exit_code
+ self.stderr = stderr
+
+
+class MockWorkspace:
+ """Mock class for AsyncRemoteWorkspace."""
+
+ def __init__(self, working_dir: str = '/workspace'):
+ self.working_dir = working_dir
+ self.execute_command = AsyncMock(return_value=MockCommandResult())
+
+
+class MockAppConversationServiceBase:
+ """Mock class to test git functionality without complex dependencies."""
+
+ def __init__(self):
+ self.logger = MagicMock()
+
+ async def clone_or_init_git_repo(
+ self,
+ workspace_path: str,
+ repo_url: str,
+ branch: str = 'main',
+ timeout: int = 300,
+ ) -> bool:
+ """Clone or initialize a git repository.
+
+ This is a simplified version of the actual method for testing purposes.
+ """
+ try:
+ # Try to clone the repository
+ clone_result = subprocess.run(
+ ['git', 'clone', '--branch', branch, repo_url, workspace_path],
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+
+ if clone_result.returncode == 0:
+ self.logger.info(
+ f'Successfully cloned repository {repo_url} to {workspace_path}'
+ )
+ return True
+
+ # If clone fails, try to checkout the branch
+ checkout_result = subprocess.run(
+ ['git', 'checkout', branch],
+ cwd=workspace_path,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+
+ if checkout_result.returncode == 0:
+ self.logger.info(f'Successfully checked out branch {branch}')
+ return True
+ else:
+ self.logger.error(
+ f'Failed to checkout branch {branch}: {checkout_result.stderr}'
+ )
+ return False
+
+ except subprocess.TimeoutExpired:
+ self.logger.error(f'Git operation timed out after {timeout} seconds')
+ return False
+ except Exception as e:
+ self.logger.error(f'Git operation failed: {str(e)}')
+ return False
+
+
+@pytest.fixture
+def service():
+ """Create a mock service instance for testing."""
+ return MockAppConversationServiceBase()
+
+
+@pytest.mark.asyncio
+async def test_clone_or_init_git_repo_successful_clone(service):
+ """Test successful git clone operation."""
+ with patch('subprocess.run') as mock_run:
+ # Mock successful clone
+ mock_run.return_value = MagicMock(returncode=0, stderr='', stdout='Cloning...')
+
+ result = await service.clone_or_init_git_repo(
+ workspace_path='/tmp/test_repo',
+ repo_url='https://github.com/test/repo.git',
+ branch='main',
+ timeout=300,
+ )
+
+ assert result is True
+ mock_run.assert_called_once_with(
+ [
+ 'git',
+ 'clone',
+ '--branch',
+ 'main',
+ 'https://github.com/test/repo.git',
+ '/tmp/test_repo',
+ ],
+ capture_output=True,
+ text=True,
+ timeout=300,
+ )
+ service.logger.info.assert_called_with(
+ 'Successfully cloned repository https://github.com/test/repo.git to /tmp/test_repo'
+ )
+
+
+@pytest.mark.asyncio
+async def test_clone_or_init_git_repo_clone_fails_checkout_succeeds(service):
+ """Test git clone fails but checkout succeeds."""
+ with patch('subprocess.run') as mock_run:
+ # Mock clone failure, then checkout success
+ mock_run.side_effect = [
+ MagicMock(returncode=1, stderr='Clone failed', stdout=''), # Clone fails
+ MagicMock(
+ returncode=0, stderr='', stdout='Switched to branch'
+ ), # Checkout succeeds
+ ]
+
+ result = await service.clone_or_init_git_repo(
+ workspace_path='/tmp/test_repo',
+ repo_url='https://github.com/test/repo.git',
+ branch='feature-branch',
+ timeout=300,
+ )
+
+ assert result is True
+ assert mock_run.call_count == 2
+
+ # Check clone call
+ mock_run.assert_any_call(
+ [
+ 'git',
+ 'clone',
+ '--branch',
+ 'feature-branch',
+ 'https://github.com/test/repo.git',
+ '/tmp/test_repo',
+ ],
+ capture_output=True,
+ text=True,
+ timeout=300,
+ )
+
+ # Check checkout call
+ mock_run.assert_any_call(
+ ['git', 'checkout', 'feature-branch'],
+ cwd='/tmp/test_repo',
+ capture_output=True,
+ text=True,
+ timeout=300,
+ )
+
+ service.logger.info.assert_called_with(
+ 'Successfully checked out branch feature-branch'
+ )
+
+
+@pytest.mark.asyncio
+async def test_clone_or_init_git_repo_both_operations_fail(service):
+ """Test both git clone and checkout operations fail."""
+ with patch('subprocess.run') as mock_run:
+ # Mock both operations failing
+ mock_run.side_effect = [
+ MagicMock(returncode=1, stderr='Clone failed', stdout=''), # Clone fails
+ MagicMock(
+ returncode=1, stderr='Checkout failed', stdout=''
+ ), # Checkout fails
+ ]
+
+ result = await service.clone_or_init_git_repo(
+ workspace_path='/tmp/test_repo',
+ repo_url='https://github.com/test/repo.git',
+ branch='nonexistent-branch',
+ timeout=300,
+ )
+
+ assert result is False
+ assert mock_run.call_count == 2
+ service.logger.error.assert_called_with(
+ 'Failed to checkout branch nonexistent-branch: Checkout failed'
+ )
+
+
+@pytest.mark.asyncio
+async def test_clone_or_init_git_repo_timeout(service):
+ """Test git operation timeout."""
+ with patch('subprocess.run') as mock_run:
+ # Mock timeout exception
+ mock_run.side_effect = subprocess.TimeoutExpired(
+ cmd=['git', 'clone'], timeout=300
+ )
+
+ result = await service.clone_or_init_git_repo(
+ workspace_path='/tmp/test_repo',
+ repo_url='https://github.com/test/repo.git',
+ branch='main',
+ timeout=300,
+ )
+
+ assert result is False
+ service.logger.error.assert_called_with(
+ 'Git operation timed out after 300 seconds'
+ )
+
+
+@pytest.mark.asyncio
+async def test_clone_or_init_git_repo_exception(service):
+ """Test git operation with unexpected exception."""
+ with patch('subprocess.run') as mock_run:
+ # Mock unexpected exception
+ mock_run.side_effect = Exception('Unexpected error')
+
+ result = await service.clone_or_init_git_repo(
+ workspace_path='/tmp/test_repo',
+ repo_url='https://github.com/test/repo.git',
+ branch='main',
+ timeout=300,
+ )
+
+ assert result is False
+ service.logger.error.assert_called_with(
+ 'Git operation failed: Unexpected error'
+ )
+
+
+@pytest.mark.asyncio
+async def test_clone_or_init_git_repo_custom_timeout(service):
+ """Test git operation with custom timeout."""
+ with patch('subprocess.run') as mock_run:
+ # Mock successful clone with custom timeout
+ mock_run.return_value = MagicMock(returncode=0, stderr='', stdout='Cloning...')
+
+ result = await service.clone_or_init_git_repo(
+ workspace_path='/tmp/test_repo',
+ repo_url='https://github.com/test/repo.git',
+ branch='main',
+ timeout=600, # Custom timeout
+ )
+
+ assert result is True
+ mock_run.assert_called_once_with(
+ [
+ 'git',
+ 'clone',
+ '--branch',
+ 'main',
+ 'https://github.com/test/repo.git',
+ '/tmp/test_repo',
+ ],
+ capture_output=True,
+ text=True,
+ timeout=600, # Verify custom timeout is used
+ )
+
+
+@patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.LLMSummarizingCondenser'
+)
+def test_create_condenser_default_agent_with_none_max_size(mock_condenser_class):
+ """Test _create_condenser for DEFAULT agent with condenser_max_size = None uses default."""
+ # 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,
+ )
+ mock_llm = MagicMock()
+ mock_llm_copy = MagicMock()
+ mock_llm_copy.usage_id = 'condenser'
+ mock_llm.model_copy.return_value = mock_llm_copy
+ mock_condenser_instance = MagicMock()
+ mock_condenser_class.return_value = mock_condenser_instance
+
+ # Act
+ service._create_condenser(mock_llm, AgentType.DEFAULT, None)
+
+ # Assert
+ mock_condenser_class.assert_called_once()
+ call_kwargs = mock_condenser_class.call_args[1]
+ # When condenser_max_size is None, max_size should not be passed (uses SDK default of 120)
+ assert 'max_size' not in call_kwargs
+ # keep_first is never passed (uses SDK default of 4)
+ assert 'keep_first' not in call_kwargs
+ assert call_kwargs['llm'].usage_id == 'condenser'
+ mock_llm.model_copy.assert_called_once()
+
+
+@patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.LLMSummarizingCondenser'
+)
+def test_create_condenser_default_agent_with_custom_max_size(mock_condenser_class):
+ """Test _create_condenser for DEFAULT agent with custom condenser_max_size."""
+ # 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,
+ )
+ mock_llm = MagicMock()
+ mock_llm_copy = MagicMock()
+ mock_llm_copy.usage_id = 'condenser'
+ mock_llm.model_copy.return_value = mock_llm_copy
+ mock_condenser_instance = MagicMock()
+ mock_condenser_class.return_value = mock_condenser_instance
+
+ # Act
+ service._create_condenser(mock_llm, AgentType.DEFAULT, 150)
+
+ # Assert
+ mock_condenser_class.assert_called_once()
+ call_kwargs = mock_condenser_class.call_args[1]
+ assert call_kwargs['max_size'] == 150 # Custom value should be used
+ # keep_first is never passed (uses SDK default of 4)
+ assert 'keep_first' not in call_kwargs
+ assert call_kwargs['llm'].usage_id == 'condenser'
+ mock_llm.model_copy.assert_called_once()
+
+
+@patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.LLMSummarizingCondenser'
+)
+def test_create_condenser_plan_agent_with_none_max_size(mock_condenser_class):
+ """Test _create_condenser for PLAN agent with condenser_max_size = None uses default."""
+ # 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,
+ )
+ mock_llm = MagicMock()
+ mock_llm_copy = MagicMock()
+ mock_llm_copy.usage_id = 'planning_condenser'
+ mock_llm.model_copy.return_value = mock_llm_copy
+ mock_condenser_instance = MagicMock()
+ mock_condenser_class.return_value = mock_condenser_instance
+
+ # Act
+ service._create_condenser(mock_llm, AgentType.PLAN, None)
+
+ # Assert
+ mock_condenser_class.assert_called_once()
+ call_kwargs = mock_condenser_class.call_args[1]
+ # When condenser_max_size is None, max_size should not be passed (uses SDK default of 120)
+ assert 'max_size' not in call_kwargs
+ # keep_first is never passed (uses SDK default of 4)
+ assert 'keep_first' not in call_kwargs
+ assert call_kwargs['llm'].usage_id == 'planning_condenser'
+ mock_llm.model_copy.assert_called_once()
+
+
+@patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.LLMSummarizingCondenser'
+)
+def test_create_condenser_plan_agent_with_custom_max_size(mock_condenser_class):
+ """Test _create_condenser for PLAN agent with custom condenser_max_size."""
+ # 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,
+ )
+ mock_llm = MagicMock()
+ mock_llm_copy = MagicMock()
+ mock_llm_copy.usage_id = 'planning_condenser'
+ mock_llm.model_copy.return_value = mock_llm_copy
+ mock_condenser_instance = MagicMock()
+ mock_condenser_class.return_value = mock_condenser_instance
+
+ # Act
+ service._create_condenser(mock_llm, AgentType.PLAN, 200)
+
+ # Assert
+ mock_condenser_class.assert_called_once()
+ call_kwargs = mock_condenser_class.call_args[1]
+ assert call_kwargs['max_size'] == 200 # Custom value should be used
+ # keep_first is never passed (uses SDK default of 4)
+ assert 'keep_first' not in call_kwargs
+ assert call_kwargs['llm'].usage_id == 'planning_condenser'
+ 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, 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.
+
+ Returns a tuple of (service, mock_user_context) for testing.
+ """
+ mock_user_context = MagicMock()
+ mock_user_context.get_user_info = AsyncMock(return_value=user_info)
+
+ # 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 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
+
+
+@pytest.fixture
+def mock_workspace():
+ """Create a mock workspace instance for testing."""
+ return MockWorkspace(working_dir='/workspace/project')
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_both_name_and_email(mock_workspace):
+ """Test configuring both git user name and email."""
+ user_info = MockUserInfo(
+ git_user_name='Test User', git_user_email='test@example.com'
+ )
+ service, mock_user_context = _create_service_with_mock_user_context(user_info)
+
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Verify get_user_info was called
+ mock_user_context.get_user_info.assert_called_once()
+
+ # Verify both git config commands were executed
+ assert mock_workspace.execute_command.call_count == 2
+
+ # Check git config user.name call
+ mock_workspace.execute_command.assert_any_call(
+ 'git config --global user.name "Test User"', '/workspace/project'
+ )
+
+ # Check git config user.email call
+ mock_workspace.execute_command.assert_any_call(
+ 'git config --global user.email "test@example.com"', '/workspace/project'
+ )
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_only_name(mock_workspace):
+ """Test configuring only git user name."""
+ user_info = MockUserInfo(git_user_name='Test User', git_user_email=None)
+ service, _ = _create_service_with_mock_user_context(user_info)
+
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Verify only user.name was configured
+ assert mock_workspace.execute_command.call_count == 1
+ mock_workspace.execute_command.assert_called_once_with(
+ 'git config --global user.name "Test User"', '/workspace/project'
+ )
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_only_email(mock_workspace):
+ """Test configuring only git user email."""
+ user_info = MockUserInfo(git_user_name=None, git_user_email='test@example.com')
+ service, _ = _create_service_with_mock_user_context(user_info)
+
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Verify only user.email was configured
+ assert mock_workspace.execute_command.call_count == 1
+ mock_workspace.execute_command.assert_called_once_with(
+ 'git config --global user.email "test@example.com"', '/workspace/project'
+ )
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_neither_set(mock_workspace):
+ """Test when neither git user name nor email is set."""
+ user_info = MockUserInfo(git_user_name=None, git_user_email=None)
+ service, _ = _create_service_with_mock_user_context(user_info)
+
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Verify no git config commands were executed
+ mock_workspace.execute_command.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_empty_strings(mock_workspace):
+ """Test when git user name and email are empty strings."""
+ user_info = MockUserInfo(git_user_name='', git_user_email='')
+ service, _ = _create_service_with_mock_user_context(user_info)
+
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Empty strings are falsy, so no commands should be executed
+ mock_workspace.execute_command.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_get_user_info_fails(mock_workspace):
+ """Test handling of exception when get_user_info fails."""
+ user_info = MockUserInfo()
+ service, mock_user_context = _create_service_with_mock_user_context(user_info)
+ mock_user_context.get_user_info = AsyncMock(
+ side_effect=Exception('User info error')
+ )
+
+ # Should not raise exception, just log warning
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Verify no git config commands were executed
+ mock_workspace.execute_command.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_name_command_fails(mock_workspace):
+ """Test handling when git config user.name command fails."""
+ user_info = MockUserInfo(
+ git_user_name='Test User', git_user_email='test@example.com'
+ )
+ service, _ = _create_service_with_mock_user_context(user_info)
+
+ # Make the first command fail (user.name), second succeed (user.email)
+ mock_workspace.execute_command = AsyncMock(
+ side_effect=[
+ MockCommandResult(exit_code=1, stderr='Permission denied'),
+ MockCommandResult(exit_code=0),
+ ]
+ )
+
+ # Should not raise exception
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Verify both commands were still attempted
+ assert mock_workspace.execute_command.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_email_command_fails(mock_workspace):
+ """Test handling when git config user.email command fails."""
+ user_info = MockUserInfo(
+ git_user_name='Test User', git_user_email='test@example.com'
+ )
+ service, _ = _create_service_with_mock_user_context(user_info)
+
+ # Make the first command succeed (user.name), second fail (user.email)
+ mock_workspace.execute_command = AsyncMock(
+ side_effect=[
+ MockCommandResult(exit_code=0),
+ MockCommandResult(exit_code=1, stderr='Permission denied'),
+ ]
+ )
+
+ # Should not raise exception
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Verify both commands were still attempted
+ assert mock_workspace.execute_command.call_count == 2
+
+
+@pytest.mark.asyncio
+async def test_configure_git_user_settings_special_characters_in_name(mock_workspace):
+ """Test git user name with special characters."""
+ user_info = MockUserInfo(
+ git_user_name="Test O'Brien", git_user_email='test@example.com'
+ )
+ service, _ = _create_service_with_mock_user_context(user_info)
+
+ await service._configure_git_user_settings(mock_workspace)
+
+ # Verify the name is passed with special characters
+ 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 000000000000..e84412bcd0aa
--- /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_github_v1_callback_processor.py b/tests/unit/app_server/test_github_v1_callback_processor.py
new file mode 100644
index 000000000000..acf958a8e3d1
--- /dev/null
+++ b/tests/unit/app_server/test_github_v1_callback_processor.py
@@ -0,0 +1,771 @@
+"""
+Tests for the GithubV1CallbackProcessor.
+
+Covers:
+- Event filtering
+- Successful summary + GitHub posting
+- Inline PR comments
+- Error conditions (missing IDs/credentials, conversation/sandbox issues)
+- Agent server HTTP/timeout errors
+- Low-level helper methods
+"""
+
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+from uuid import uuid4
+
+import httpx
+import pytest
+
+from openhands.app_server.app_conversation.app_conversation_models import (
+ AppConversationInfo,
+)
+from openhands.app_server.event_callback.event_callback_models import EventCallback
+from openhands.app_server.event_callback.event_callback_result_models import (
+ EventCallbackResultStatus,
+)
+from openhands.app_server.event_callback.github_v1_callback_processor import (
+ GithubV1CallbackProcessor,
+)
+from openhands.app_server.sandbox.sandbox_models import (
+ ExposedUrl,
+ SandboxInfo,
+ SandboxStatus,
+)
+from openhands.events.action.message import MessageAction
+from openhands.sdk.event import ConversationStateUpdateEvent
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+def github_callback_processor():
+ return GithubV1CallbackProcessor(
+ github_view_data={
+ 'installation_id': 12345,
+ 'full_repo_name': 'test-owner/test-repo',
+ 'issue_number': 42,
+ },
+ should_request_summary=True,
+ should_extract=True,
+ inline_pr_comment=False,
+ )
+
+
+@pytest.fixture
+def github_callback_processor_inline():
+ return GithubV1CallbackProcessor(
+ github_view_data={
+ 'installation_id': 12345,
+ 'full_repo_name': 'test-owner/test-repo',
+ 'issue_number': 42,
+ 'comment_id': 'comment_123',
+ },
+ should_request_summary=True,
+ should_extract=True,
+ inline_pr_comment=True,
+ )
+
+
+@pytest.fixture
+def conversation_state_update_event():
+ return ConversationStateUpdateEvent(key='execution_status', value='finished')
+
+
+@pytest.fixture
+def wrong_event():
+ return MessageAction(content='Hello world')
+
+
+@pytest.fixture
+def wrong_state_event():
+ return ConversationStateUpdateEvent(key='execution_status', value='running')
+
+
+@pytest.fixture
+def event_callback():
+ return EventCallback(
+ id=uuid4(),
+ conversation_id=uuid4(),
+ processor=GithubV1CallbackProcessor(),
+ event_kind='ConversationStateUpdateEvent',
+ )
+
+
+@pytest.fixture
+def mock_app_conversation_info():
+ return AppConversationInfo(
+ conversation_id=uuid4(),
+ sandbox_id='sandbox_123',
+ title='Test Conversation',
+ created_by_user_id='test_user_123',
+ )
+
+
+@pytest.fixture
+def mock_sandbox_info():
+ return SandboxInfo(
+ id='sandbox_123',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test_api_key',
+ created_by_user_id='test_user_123',
+ sandbox_spec_id='spec_123',
+ exposed_urls=[
+ ExposedUrl(name='AGENT_SERVER', url='http://localhost:8000', port=8000),
+ ],
+ )
+
+
+# ---------------------------------------------------------------------------
+# Helper for common service mocks
+# ---------------------------------------------------------------------------
+
+
+async def _setup_happy_path_services(
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ app_conversation_info,
+ sandbox_info,
+ agent_response_text='Test summary from agent',
+):
+ # app_conversation_info_service
+ mock_app_conversation_info_service = AsyncMock()
+ mock_app_conversation_info_service.get_app_conversation_info.return_value = (
+ app_conversation_info
+ )
+ mock_get_app_conversation_info_service.return_value.__aenter__.return_value = (
+ mock_app_conversation_info_service
+ )
+
+ # sandbox_service
+ mock_sandbox_service = AsyncMock()
+ mock_sandbox_service.get_sandbox.return_value = sandbox_info
+ mock_get_sandbox_service.return_value.__aenter__.return_value = mock_sandbox_service
+
+ # httpx_client
+ mock_httpx_client = AsyncMock()
+ mock_response = MagicMock()
+ mock_response.json.return_value = {'response': agent_response_text}
+ mock_response.raise_for_status.return_value = None
+ mock_httpx_client.post.return_value = mock_response
+ mock_get_httpx_client.return_value.__aenter__.return_value = mock_httpx_client
+
+ return mock_httpx_client
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+class TestGithubV1CallbackProcessor:
+ async def test_call_with_wrong_event_type(
+ self, github_callback_processor, wrong_event, event_callback
+ ):
+ result = await github_callback_processor(
+ conversation_id=uuid4(),
+ callback=event_callback,
+ event=wrong_event,
+ )
+ assert result is None
+
+ async def test_call_with_wrong_state_event(
+ self, github_callback_processor, wrong_state_event, event_callback
+ ):
+ result = await github_callback_processor(
+ conversation_id=uuid4(),
+ callback=event_callback,
+ event=wrong_state_event,
+ )
+ assert result is None
+
+ async def test_call_should_request_summary_false(
+ self, github_callback_processor, conversation_state_update_event, event_callback
+ ):
+ github_callback_processor.should_request_summary = False
+
+ result = await github_callback_processor(
+ conversation_id=uuid4(),
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+ assert result is None
+
+ # ------------------------------------------------------------------ #
+ # Successful paths
+ # ------------------------------------------------------------------ #
+
+ @patch.dict(
+ os.environ,
+ {
+ 'GITHUB_APP_CLIENT_ID': 'test_client_id',
+ 'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
+ },
+ )
+ @patch('openhands.app_server.config.get_app_conversation_info_service')
+ @patch('openhands.app_server.config.get_sandbox_service')
+ @patch('openhands.app_server.config.get_httpx_client')
+ @patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
+ )
+ @patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
+ )
+ @patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
+ async def test_successful_callback_execution(
+ self,
+ mock_github,
+ mock_github_integration,
+ mock_get_prompt_template,
+ mock_get_httpx_client,
+ mock_get_sandbox_service,
+ mock_get_app_conversation_info_service,
+ github_callback_processor,
+ conversation_state_update_event,
+ event_callback,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ ):
+ conversation_id = uuid4()
+
+ # Common service mocks
+ mock_httpx_client = await _setup_happy_path_services(
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ )
+
+ mock_get_prompt_template.return_value = 'Please provide a summary'
+
+ # GitHub integration
+ mock_token_data = MagicMock()
+ mock_token_data.token = 'test_access_token'
+ mock_integration_instance = MagicMock()
+ mock_integration_instance.get_access_token.return_value = mock_token_data
+ mock_github_integration.return_value = mock_integration_instance
+
+ # GitHub API
+ mock_github_client = MagicMock()
+ mock_repo = MagicMock()
+ mock_issue = MagicMock()
+ mock_repo.get_issue.return_value = mock_issue
+ mock_github_client.get_repo.return_value = mock_repo
+ mock_github.return_value.__enter__.return_value = mock_github_client
+
+ result = await github_callback_processor(
+ conversation_id=conversation_id,
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+
+ assert result is not None
+ assert result.status == EventCallbackResultStatus.SUCCESS
+ assert result.event_callback_id == event_callback.id
+ assert result.event_id == conversation_state_update_event.id
+ assert result.conversation_id == conversation_id
+ assert result.detail == 'Test summary from agent'
+ assert github_callback_processor.should_request_summary is False
+
+ mock_github_integration.assert_called_once_with(
+ 'test_client_id', 'test_private_key'
+ )
+ mock_integration_instance.get_access_token.assert_called_once_with(12345)
+
+ mock_github.assert_called_once_with('test_access_token')
+ mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
+ mock_repo.get_issue.assert_called_once_with(number=42)
+ mock_issue.create_comment.assert_called_once_with('Test summary from agent')
+
+ mock_httpx_client.post.assert_called_once()
+ url_arg, kwargs = mock_httpx_client.post.call_args
+ url = url_arg[0] if url_arg else kwargs['url']
+ assert 'ask_agent' in url
+ assert kwargs['headers']['X-Session-API-Key'] == 'test_api_key'
+ assert kwargs['json']['question'] == 'Please provide a summary'
+
+ @patch.dict(
+ os.environ,
+ {
+ 'GITHUB_APP_CLIENT_ID': 'test_client_id',
+ 'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
+ },
+ )
+ @patch('openhands.app_server.config.get_app_conversation_info_service')
+ @patch('openhands.app_server.config.get_sandbox_service')
+ @patch('openhands.app_server.config.get_httpx_client')
+ @patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
+ )
+ @patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
+ )
+ @patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
+ async def test_successful_inline_pr_comment(
+ self,
+ mock_github,
+ mock_github_integration,
+ mock_get_prompt_template,
+ mock_get_httpx_client,
+ mock_get_sandbox_service,
+ mock_get_app_conversation_info_service,
+ github_callback_processor_inline,
+ conversation_state_update_event,
+ event_callback,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ ):
+ conversation_id = uuid4()
+
+ await _setup_happy_path_services(
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ )
+
+ mock_get_prompt_template.return_value = 'Please provide a summary'
+
+ mock_token_data = MagicMock()
+ mock_token_data.token = 'test_access_token'
+ mock_integration_instance = MagicMock()
+ mock_integration_instance.get_access_token.return_value = mock_token_data
+ mock_github_integration.return_value = mock_integration_instance
+
+ mock_github_client = MagicMock()
+ mock_repo = MagicMock()
+ mock_pr = MagicMock()
+ mock_repo.get_pull.return_value = mock_pr
+ mock_github_client.get_repo.return_value = mock_repo
+ mock_github.return_value.__enter__.return_value = mock_github_client
+
+ result = await github_callback_processor_inline(
+ conversation_id=conversation_id,
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+
+ assert result is not None
+ assert result.status == EventCallbackResultStatus.SUCCESS
+
+ mock_repo.get_pull.assert_called_once_with(42)
+ mock_pr.create_review_comment_reply.assert_called_once_with(
+ comment_id='comment_123', body='Test summary from agent'
+ )
+
+ # ------------------------------------------------------------------ #
+ # Error paths
+ # ------------------------------------------------------------------ #
+
+ @patch('openhands.app_server.config.get_httpx_client')
+ @patch('openhands.app_server.config.get_sandbox_service')
+ @patch('openhands.app_server.config.get_app_conversation_info_service')
+ async def test_missing_installation_id(
+ self,
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ conversation_state_update_event,
+ event_callback,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ ):
+ processor = GithubV1CallbackProcessor(
+ github_view_data={}, should_request_summary=True
+ )
+ conversation_id = uuid4()
+
+ await _setup_happy_path_services(
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ )
+
+ result = await processor(
+ conversation_id=conversation_id,
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+
+ assert result is not None
+ assert result.status == EventCallbackResultStatus.ERROR
+ assert 'Missing installation ID' in result.detail
+
+ @patch.dict(os.environ, {}, clear=True)
+ @patch('openhands.app_server.config.get_httpx_client')
+ @patch('openhands.app_server.config.get_sandbox_service')
+ @patch('openhands.app_server.config.get_app_conversation_info_service')
+ async def test_missing_github_credentials(
+ self,
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ github_callback_processor,
+ conversation_state_update_event,
+ event_callback,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ ):
+ conversation_id = uuid4()
+
+ await _setup_happy_path_services(
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ )
+
+ result = await github_callback_processor(
+ conversation_id=conversation_id,
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+
+ assert result is not None
+ assert result.status == EventCallbackResultStatus.ERROR
+ assert 'GitHub App credentials are not configured' in result.detail
+
+ @patch.dict(
+ os.environ,
+ {
+ 'GITHUB_APP_CLIENT_ID': 'test_client_id',
+ 'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
+ },
+ )
+ @patch('openhands.app_server.config.get_app_conversation_info_service')
+ @patch('openhands.app_server.config.get_sandbox_service')
+ async def test_sandbox_not_running(
+ self,
+ mock_get_sandbox_service,
+ mock_get_app_conversation_info_service,
+ github_callback_processor,
+ conversation_state_update_event,
+ event_callback,
+ mock_app_conversation_info,
+ ):
+ conversation_id = uuid4()
+
+ mock_app_conversation_info_service = AsyncMock()
+ mock_app_conversation_info_service.get_app_conversation_info.return_value = (
+ mock_app_conversation_info
+ )
+ mock_get_app_conversation_info_service.return_value.__aenter__.return_value = (
+ mock_app_conversation_info_service
+ )
+
+ non_running_sandbox = SandboxInfo(
+ id='sandbox_123',
+ status=SandboxStatus.PAUSED,
+ session_api_key='test_api_key',
+ created_by_user_id='test_user_123',
+ sandbox_spec_id='spec_123',
+ )
+ mock_sandbox_service = AsyncMock()
+ mock_sandbox_service.get_sandbox.return_value = non_running_sandbox
+ mock_get_sandbox_service.return_value.__aenter__.return_value = (
+ mock_sandbox_service
+ )
+
+ result = await github_callback_processor(
+ conversation_id=conversation_id,
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+
+ assert result is not None
+ assert result.status == EventCallbackResultStatus.ERROR
+ assert 'Sandbox not running' in result.detail
+
+ @patch.dict(
+ os.environ,
+ {
+ 'GITHUB_APP_CLIENT_ID': 'test_client_id',
+ 'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
+ },
+ )
+ @patch('openhands.app_server.config.get_app_conversation_info_service')
+ @patch('openhands.app_server.config.get_sandbox_service')
+ @patch('openhands.app_server.config.get_httpx_client')
+ @patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
+ )
+ async def test_agent_server_http_error(
+ self,
+ mock_get_prompt_template,
+ mock_get_httpx_client,
+ mock_get_sandbox_service,
+ mock_get_app_conversation_info_service,
+ github_callback_processor,
+ conversation_state_update_event,
+ event_callback,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ ):
+ conversation_id = uuid4()
+
+ # Set up happy path except httpx
+ await _setup_happy_path_services(
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ )
+
+ mock_get_prompt_template.return_value = 'Please provide a summary'
+
+ mock_httpx_client = mock_get_httpx_client.return_value.__aenter__.return_value
+ mock_response = MagicMock()
+ mock_response.status_code = 500
+ mock_response.text = 'Internal Server Error'
+ mock_response.headers = {}
+ mock_error = httpx.HTTPStatusError(
+ 'HTTP 500 error', request=MagicMock(), response=mock_response
+ )
+ mock_httpx_client.post.side_effect = mock_error
+
+ result = await github_callback_processor(
+ conversation_id=conversation_id,
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+
+ assert result is not None
+ assert result.status == EventCallbackResultStatus.ERROR
+ assert 'Failed to send message to agent server' in result.detail
+
+ @patch.dict(
+ os.environ,
+ {
+ 'GITHUB_APP_CLIENT_ID': 'test_client_id',
+ 'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
+ },
+ )
+ @patch('openhands.app_server.config.get_app_conversation_info_service')
+ @patch('openhands.app_server.config.get_sandbox_service')
+ @patch('openhands.app_server.config.get_httpx_client')
+ @patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.get_prompt_template'
+ )
+ async def test_agent_server_timeout(
+ self,
+ mock_get_prompt_template,
+ mock_get_httpx_client,
+ mock_get_sandbox_service,
+ mock_get_app_conversation_info_service,
+ github_callback_processor,
+ conversation_state_update_event,
+ event_callback,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ ):
+ conversation_id = uuid4()
+
+ await _setup_happy_path_services(
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ )
+
+ mock_get_prompt_template.return_value = 'Please provide a summary'
+
+ mock_httpx_client = mock_get_httpx_client.return_value.__aenter__.return_value
+ mock_httpx_client.post.side_effect = httpx.TimeoutException('Request timeout')
+
+ result = await github_callback_processor(
+ conversation_id=conversation_id,
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+
+ assert result is not None
+ assert result.status == EventCallbackResultStatus.ERROR
+ assert 'Request timeout after 30 seconds' in result.detail
+
+ # ------------------------------------------------------------------ #
+ # Low-level helper tests
+ # ------------------------------------------------------------------ #
+
+ def test_get_installation_access_token_missing_id(self):
+ processor = GithubV1CallbackProcessor(github_view_data={})
+
+ with pytest.raises(ValueError, match='Missing installation ID'):
+ processor._get_installation_access_token()
+
+ @patch.dict(os.environ, {}, clear=True)
+ def test_get_installation_access_token_missing_credentials(
+ self, github_callback_processor
+ ):
+ with pytest.raises(
+ ValueError, match='GitHub App credentials are not configured'
+ ):
+ github_callback_processor._get_installation_access_token()
+
+ @patch.dict(
+ os.environ,
+ {
+ 'GITHUB_APP_CLIENT_ID': 'test_client_id',
+ 'GITHUB_APP_PRIVATE_KEY': 'test_private_key\\nwith_newlines',
+ },
+ )
+ @patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
+ )
+ def test_get_installation_access_token_success(
+ self, mock_github_integration, github_callback_processor
+ ):
+ mock_token_data = MagicMock()
+ mock_token_data.token = 'test_access_token'
+ mock_integration_instance = MagicMock()
+ mock_integration_instance.get_access_token.return_value = mock_token_data
+ mock_github_integration.return_value = mock_integration_instance
+
+ token = github_callback_processor._get_installation_access_token()
+
+ assert token == 'test_access_token'
+ mock_github_integration.assert_called_once_with(
+ 'test_client_id', 'test_private_key\nwith_newlines'
+ )
+ mock_integration_instance.get_access_token.assert_called_once_with(12345)
+
+ @patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
+ async def test_post_summary_to_github_issue_comment(
+ self, mock_github, github_callback_processor
+ ):
+ mock_github_client = MagicMock()
+ mock_repo = MagicMock()
+ mock_issue = MagicMock()
+ mock_repo.get_issue.return_value = mock_issue
+ mock_github_client.get_repo.return_value = mock_repo
+ mock_github.return_value.__enter__.return_value = mock_github_client
+
+ with patch.object(
+ github_callback_processor,
+ '_get_installation_access_token',
+ return_value='test_token',
+ ):
+ await github_callback_processor._post_summary_to_github('Test summary')
+
+ mock_github.assert_called_once_with('test_token')
+ mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
+ mock_repo.get_issue.assert_called_once_with(number=42)
+ mock_issue.create_comment.assert_called_once_with('Test summary')
+
+ @patch('openhands.app_server.event_callback.github_v1_callback_processor.Github')
+ async def test_post_summary_to_github_pr_comment(
+ self, mock_github, github_callback_processor_inline
+ ):
+ mock_github_client = MagicMock()
+ mock_repo = MagicMock()
+ mock_pr = MagicMock()
+ mock_repo.get_pull.return_value = mock_pr
+ mock_github_client.get_repo.return_value = mock_repo
+ mock_github.return_value.__enter__.return_value = mock_github_client
+
+ with patch.object(
+ github_callback_processor_inline,
+ '_get_installation_access_token',
+ return_value='test_token',
+ ):
+ await github_callback_processor_inline._post_summary_to_github(
+ 'Test summary'
+ )
+
+ mock_github.assert_called_once_with('test_token')
+ mock_github_client.get_repo.assert_called_once_with('test-owner/test-repo')
+ mock_repo.get_pull.assert_called_once_with(42)
+ mock_pr.create_review_comment_reply.assert_called_once_with(
+ comment_id='comment_123', body='Test summary'
+ )
+
+ async def test_post_summary_to_github_missing_token(
+ self, github_callback_processor
+ ):
+ with patch.object(
+ github_callback_processor, '_get_installation_access_token', return_value=''
+ ):
+ with pytest.raises(RuntimeError, match='Missing GitHub credentials'):
+ await github_callback_processor._post_summary_to_github('Test summary')
+
+ @patch.dict(
+ os.environ,
+ {
+ 'GITHUB_APP_CLIENT_ID': 'test_client_id',
+ 'GITHUB_APP_PRIVATE_KEY': 'test_private_key',
+ 'WEB_HOST': 'test.example.com',
+ },
+ )
+ @patch('openhands.app_server.config.get_httpx_client')
+ @patch('openhands.app_server.config.get_sandbox_service')
+ @patch('openhands.app_server.config.get_app_conversation_info_service')
+ async def test_exception_handling_posts_error_to_github(
+ self,
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ github_callback_processor,
+ conversation_state_update_event,
+ event_callback,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ ):
+ conversation_id = uuid4()
+
+ # happy-ish path, except httpx error
+ mock_httpx_client = await _setup_happy_path_services(
+ mock_get_app_conversation_info_service,
+ mock_get_sandbox_service,
+ mock_get_httpx_client,
+ mock_app_conversation_info,
+ mock_sandbox_info,
+ )
+ mock_httpx_client.post.side_effect = Exception('Simulated agent server error')
+
+ with (
+ patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.GithubIntegration'
+ ) as mock_github_integration,
+ patch(
+ 'openhands.app_server.event_callback.github_v1_callback_processor.Github'
+ ) as mock_github,
+ ):
+ mock_integration = MagicMock()
+ mock_github_integration.return_value = mock_integration
+ mock_integration.get_access_token.return_value.token = 'test_token'
+
+ mock_gh = MagicMock()
+ mock_github.return_value.__enter__.return_value = mock_gh
+ mock_repo = MagicMock()
+ mock_issue = MagicMock()
+ mock_repo.get_issue.return_value = mock_issue
+ mock_gh.get_repo.return_value = mock_repo
+
+ result = await github_callback_processor(
+ conversation_id=conversation_id,
+ callback=event_callback,
+ event=conversation_state_update_event,
+ )
+
+ assert result is not None
+ assert result.status == EventCallbackResultStatus.ERROR
+ assert 'Simulated agent server error' in result.detail
+
+ mock_issue.create_comment.assert_called_once()
+ call_args = mock_issue.create_comment.call_args
+ error_comment = call_args[1].get('body') or call_args[0][0]
+ assert (
+ 'OpenHands encountered an error: **Simulated agent server error**'
+ in error_comment
+ )
+ assert f'conversations/{conversation_id}' in error_comment
+ assert 'for more information.' in error_comment
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
new file mode 100644
index 000000000000..f662f331465d
--- /dev/null
+++ b/tests/unit/app_server/test_live_status_app_conversation_service.py
@@ -0,0 +1,1356 @@
+"""Unit tests for the methods in LiveStatusAppConversationService."""
+
+from unittest.mock import AsyncMock, Mock, patch
+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,
+ AppConversationStartRequest,
+)
+from openhands.app_server.app_conversation.live_status_app_conversation_service import (
+ LiveStatusAppConversationService,
+)
+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.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
+
+
+class TestLiveStatusAppConversationService:
+ """Test cases for the methods in LiveStatusAppConversationService."""
+
+ def setup_method(self):
+ """Set up test fixtures."""
+ # Create mock dependencies
+ self.mock_user_context = Mock(spec=UserContext)
+ self.mock_user_auth = Mock()
+ self.mock_user_context.user_auth = self.mock_user_auth
+ self.mock_jwt_service = Mock()
+ self.mock_sandbox_service = Mock()
+ self.mock_sandbox_spec_service = Mock()
+ self.mock_app_conversation_info_service = Mock()
+ self.mock_app_conversation_start_task_service = Mock()
+ self.mock_event_callback_service = Mock()
+ self.mock_httpx_client = Mock()
+
+ # Create service instance
+ self.service = LiveStatusAppConversationService(
+ init_git_in_empty_workspace=True,
+ user_context=self.mock_user_context,
+ app_conversation_info_service=self.mock_app_conversation_info_service,
+ app_conversation_start_task_service=self.mock_app_conversation_start_task_service,
+ event_callback_service=self.mock_event_callback_service,
+ sandbox_service=self.mock_sandbox_service,
+ sandbox_spec_service=self.mock_sandbox_spec_service,
+ jwt_service=self.mock_jwt_service,
+ sandbox_startup_timeout=30,
+ sandbox_startup_poll_frequency=1,
+ httpx_client=self.mock_httpx_client,
+ web_url='https://test.example.com',
+ openhands_provider_base_url='https://provider.example.com',
+ access_token_hard_timeout=None,
+ app_mode='test',
+ keycloak_auth_cookie=None,
+ )
+
+ # Mock user info
+ self.mock_user = Mock()
+ self.mock_user.id = 'test_user_123'
+ self.mock_user.llm_model = 'gpt-4'
+ self.mock_user.llm_base_url = 'https://api.openai.com/v1'
+ self.mock_user.llm_api_key = 'test_api_key'
+ self.mock_user.confirmation_mode = False
+ 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)
+ self.mock_sandbox.id = uuid4()
+ self.mock_sandbox.status = SandboxStatus.RUNNING
+
+ @pytest.mark.asyncio
+ async def test_setup_secrets_for_git_providers_no_provider_tokens(self):
+ """Test _setup_secrets_for_git_providers with no provider tokens."""
+ # Arrange
+ base_secrets = {'existing': 'secret'}
+ self.mock_user_context.get_secrets.return_value = base_secrets
+ self.mock_user_context.get_provider_tokens = AsyncMock(return_value=None)
+
+ # Act
+ result = await self.service._setup_secrets_for_git_providers(self.mock_user)
+
+ # Assert
+ assert result == base_secrets
+ self.mock_user_context.get_secrets.assert_called_once()
+ self.mock_user_context.get_provider_tokens.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_setup_secrets_for_git_providers_with_web_url(self):
+ """Test _setup_secrets_for_git_providers with web URL (creates access token)."""
+ # Arrange
+ from pydantic import SecretStr
+
+ from openhands.integrations.provider import ProviderToken
+
+ base_secrets = {}
+ self.mock_user_context.get_secrets.return_value = base_secrets
+ self.mock_jwt_service.create_jws_token.return_value = 'test_access_token'
+
+ # Mock provider tokens
+ provider_tokens = {
+ ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
+ ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
+ }
+ self.mock_user_context.get_provider_tokens = AsyncMock(
+ return_value=provider_tokens
+ )
+
+ # Act
+ result = await self.service._setup_secrets_for_git_providers(self.mock_user)
+
+ # Assert
+ assert 'GITHUB_TOKEN' in result
+ assert 'GITLAB_TOKEN' in result
+ assert isinstance(result['GITHUB_TOKEN'], LookupSecret)
+ assert isinstance(result['GITLAB_TOKEN'], LookupSecret)
+ assert (
+ result['GITHUB_TOKEN'].url
+ == 'https://test.example.com/api/v1/webhooks/secrets'
+ )
+ assert result['GITHUB_TOKEN'].headers['X-Access-Token'] == 'test_access_token'
+
+ # Should be called twice, once for each provider
+ assert self.mock_jwt_service.create_jws_token.call_count == 2
+
+ @pytest.mark.asyncio
+ async def test_setup_secrets_for_git_providers_with_saas_mode(self):
+ """Test _setup_secrets_for_git_providers with SaaS mode (includes keycloak cookie)."""
+ # Arrange
+ from pydantic import SecretStr
+
+ from openhands.integrations.provider import ProviderToken
+
+ self.service.app_mode = 'saas'
+ self.service.keycloak_auth_cookie = 'test_cookie'
+ base_secrets = {}
+ self.mock_user_context.get_secrets.return_value = base_secrets
+ self.mock_jwt_service.create_jws_token.return_value = 'test_access_token'
+
+ # Mock provider tokens
+ provider_tokens = {
+ ProviderType.GITLAB: ProviderToken(token=SecretStr('gitlab_token')),
+ }
+ self.mock_user_context.get_provider_tokens = AsyncMock(
+ return_value=provider_tokens
+ )
+
+ # Act
+ result = await self.service._setup_secrets_for_git_providers(self.mock_user)
+
+ # Assert
+ assert 'GITLAB_TOKEN' in result
+ lookup_secret = result['GITLAB_TOKEN']
+ assert isinstance(lookup_secret, LookupSecret)
+ assert 'Cookie' in lookup_secret.headers
+ assert lookup_secret.headers['Cookie'] == 'keycloak_auth=test_cookie'
+
+ @pytest.mark.asyncio
+ async def test_setup_secrets_for_git_providers_without_web_url(self):
+ """Test _setup_secrets_for_git_providers without web URL (uses static token)."""
+ # Arrange
+ from pydantic import SecretStr
+
+ from openhands.integrations.provider import ProviderToken
+
+ self.service.web_url = None
+ base_secrets = {}
+ self.mock_user_context.get_secrets.return_value = base_secrets
+ self.mock_user_context.get_latest_token.return_value = 'static_token_value'
+
+ # Mock provider tokens
+ provider_tokens = {
+ ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
+ }
+ self.mock_user_context.get_provider_tokens = AsyncMock(
+ return_value=provider_tokens
+ )
+
+ # Act
+ result = await self.service._setup_secrets_for_git_providers(self.mock_user)
+
+ # Assert
+ assert 'GITHUB_TOKEN' in result
+ assert isinstance(result['GITHUB_TOKEN'], StaticSecret)
+ assert result['GITHUB_TOKEN'].value.get_secret_value() == 'static_token_value'
+ self.mock_user_context.get_latest_token.assert_called_once_with(
+ ProviderType.GITHUB
+ )
+
+ @pytest.mark.asyncio
+ async def test_setup_secrets_for_git_providers_no_static_token(self):
+ """Test _setup_secrets_for_git_providers when no static token is available."""
+ # Arrange
+ from pydantic import SecretStr
+
+ from openhands.integrations.provider import ProviderToken
+
+ self.service.web_url = None
+ base_secrets = {}
+ self.mock_user_context.get_secrets.return_value = base_secrets
+ self.mock_user_context.get_latest_token.return_value = None
+
+ # Mock provider tokens
+ provider_tokens = {
+ ProviderType.GITHUB: ProviderToken(token=SecretStr('github_token')),
+ }
+ self.mock_user_context.get_provider_tokens = AsyncMock(
+ return_value=provider_tokens
+ )
+
+ # Act
+ result = await self.service._setup_secrets_for_git_providers(self.mock_user)
+
+ # Assert
+ assert 'GITHUB_TOKEN' not in result
+ assert result == base_secrets
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_with_custom_model(self):
+ """Test _configure_llm_and_mcp with custom LLM model."""
+ # Arrange
+ custom_model = 'gpt-3.5-turbo'
+ 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, custom_model
+ )
+
+ # Assert
+ assert isinstance(llm, LLM)
+ assert llm.model == custom_model
+ assert llm.base_url == self.mock_user.llm_base_url
+ assert llm.api_key.get_secret_value() == self.mock_user.llm_api_key
+ assert llm.usage_id == 'agent'
+
+ 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):
+ """openhands/* model uses user.llm_base_url when provided."""
+ # Arrange
+ self.mock_user.llm_model = 'openhands/special'
+ self.mock_user.llm_base_url = 'https://user-llm.example.com'
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, _ = await self.service._configure_llm_and_mcp(
+ self.mock_user, self.mock_user.llm_model
+ )
+
+ # Assert
+ assert llm.base_url == 'https://user-llm.example.com'
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_openhands_model_uses_provider_default(self):
+ """openhands/* model falls back to configured provider base URL."""
+ # Arrange
+ self.mock_user.llm_model = 'openhands/default'
+ self.mock_user.llm_base_url = None
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, _ = await self.service._configure_llm_and_mcp(
+ self.mock_user, self.mock_user.llm_model
+ )
+
+ # Assert
+ assert llm.base_url == 'https://provider.example.com'
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_openhands_model_no_base_urls(self):
+ """openhands/* model sets base_url to None when no sources available."""
+ # Arrange
+ self.mock_user.llm_model = 'openhands/default'
+ self.mock_user.llm_base_url = None
+ self.service.openhands_provider_base_url = None
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, _ = await self.service._configure_llm_and_mcp(
+ self.mock_user, self.mock_user.llm_model
+ )
+
+ # Assert
+ 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):
+ """Non-openhands model ignores provider base URL and uses user base URL."""
+ # Arrange
+ self.mock_user.llm_model = 'gpt-4'
+ self.mock_user.llm_base_url = 'https://user-llm.example.com'
+ self.service.openhands_provider_base_url = 'https://provider.example.com'
+ self.mock_user_context.get_mcp_api_key.return_value = None
+
+ # Act
+ llm, _ = await self.service._configure_llm_and_mcp(self.mock_user, None)
+
+ # Assert
+ assert llm.base_url == 'https://user-llm.example.com'
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_with_user_default_model(self):
+ """Test _configure_llm_and_mcp using user's default model."""
+ # Arrange
+ 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 llm.model == self.mock_user.llm_model
+ 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):
+ """Test _configure_llm_and_mcp without web URL (no MCP config)."""
+ # Arrange
+ self.service.web_url = None
+
+ # Act
+ llm, mcp_config = await self.service._configure_llm_and_mcp(
+ self.mock_user, None
+ )
+
+ # Assert
+ assert isinstance(llm, LLM)
+ assert mcp_config == {}
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_tavily_with_user_search_api_key(self):
+ """Test _configure_llm_and_mcp adds tavily when user has search_api_key."""
+ # Arrange
+ from pydantic import SecretStr
+
+ self.mock_user.search_api_key = SecretStr('user_search_key')
+ 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
+ assert isinstance(llm, LLM)
+ assert 'mcpServers' in mcp_config
+ assert 'default' in mcp_config['mcpServers']
+ assert 'tavily' in mcp_config['mcpServers']
+ assert (
+ mcp_config['mcpServers']['tavily']['url']
+ == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
+ )
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_tavily_with_env_tavily_key(self):
+ """Test _configure_llm_and_mcp adds tavily when service has tavily_api_key."""
+ # Arrange
+ self.service.tavily_api_key = 'env_tavily_key'
+ 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
+ assert 'default' in mcp_config['mcpServers']
+ assert 'tavily' in mcp_config['mcpServers']
+ assert (
+ mcp_config['mcpServers']['tavily']['url']
+ == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
+ )
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_tavily_user_key_takes_precedence(self):
+ """Test _configure_llm_and_mcp user search_api_key takes precedence over env key."""
+ # Arrange
+ from pydantic import SecretStr
+
+ self.mock_user.search_api_key = SecretStr('user_search_key')
+ self.service.tavily_api_key = 'env_tavily_key'
+ 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
+ assert 'tavily' in mcp_config['mcpServers']
+ assert (
+ mcp_config['mcpServers']['tavily']['url']
+ == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
+ )
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_no_tavily_without_keys(self):
+ """Test _configure_llm_and_mcp does not add tavily when no keys are available."""
+ # Arrange
+ self.mock_user.search_api_key = None
+ self.service.tavily_api_key = None
+ 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
+ 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):
+ """Test _configure_llm_and_mcp does not add tavily in SAAS mode without user search_api_key.
+
+ In SAAS mode, the global tavily_api_key should not be passed to the service instance,
+ so tavily should only be added if the user has their own search_api_key.
+ """
+ # Arrange - simulate SAAS mode where no global tavily key is available
+ self.service.app_mode = AppMode.SAAS.value
+ self.service.tavily_api_key = None # In SAAS mode, this should be None
+ self.mock_user.search_api_key = None
+ 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
+ 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):
+ """Test _configure_llm_and_mcp adds tavily in SAAS mode when user has search_api_key.
+
+ Even in SAAS mode, if the user has their own search_api_key, tavily should be added.
+ """
+ # Arrange - simulate SAAS mode with user having their own search key
+ from pydantic import SecretStr
+
+ self.service.app_mode = AppMode.SAAS.value
+ self.service.tavily_api_key = None # In SAAS mode, this should be None
+ self.mock_user.search_api_key = SecretStr('user_search_key')
+ 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
+ assert 'default' in mcp_config['mcpServers']
+ assert 'tavily' in mcp_config['mcpServers']
+ assert (
+ mcp_config['mcpServers']['tavily']['url']
+ == 'https://mcp.tavily.com/mcp/?tavilyApiKey=user_search_key'
+ )
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_tavily_with_empty_user_search_key(self):
+ """Test _configure_llm_and_mcp handles empty user search_api_key correctly."""
+ # Arrange
+ from pydantic import SecretStr
+
+ self.mock_user.search_api_key = SecretStr('') # Empty string
+ self.service.tavily_api_key = 'env_tavily_key'
+ 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
+ assert 'tavily' in mcp_config['mcpServers']
+ # Should fall back to env key since user key is empty
+ assert (
+ mcp_config['mcpServers']['tavily']['url']
+ == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
+ )
+
+ @pytest.mark.asyncio
+ async def test_configure_llm_and_mcp_tavily_with_whitespace_user_search_key(self):
+ """Test _configure_llm_and_mcp handles whitespace-only user search_api_key correctly."""
+ # Arrange
+ from pydantic import SecretStr
+
+ self.mock_user.search_api_key = SecretStr(' ') # Whitespace only
+ self.service.tavily_api_key = 'env_tavily_key'
+ 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
+ assert 'tavily' in mcp_config['mcpServers']
+ # Should fall back to env key since user key is whitespace only
+ assert (
+ mcp_config['mcpServers']['tavily']['url']
+ == 'https://mcp.tavily.com/mcp/?tavilyApiKey=env_tavily_key'
+ )
+
+ @patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.get_planning_tools'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.AppConversationServiceBase._create_condenser'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.format_plan_structure'
+ )
+ def test_create_agent_with_context_planning_agent(
+ self, mock_format_plan, mock_create_condenser, mock_get_tools
+ ):
+ """Test _create_agent_with_context for planning agent type."""
+ # Arrange
+ mock_llm = Mock(spec=LLM)
+ mock_llm.model_copy.return_value = mock_llm
+ mock_get_tools.return_value = []
+ mock_condenser = Mock()
+ mock_create_condenser.return_value = mock_condenser
+ mock_format_plan.return_value = 'test_plan_structure'
+ mcp_config = {'default': {'url': 'test'}}
+ system_message_suffix = 'Test suffix'
+
+ # Act
+ with patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.Agent'
+ ) as mock_agent_class:
+ mock_agent_instance = Mock()
+ mock_agent_instance.model_copy.return_value = mock_agent_instance
+ mock_agent_class.return_value = mock_agent_instance
+
+ self.service._create_agent_with_context(
+ mock_llm,
+ AgentType.PLAN,
+ system_message_suffix,
+ mcp_config,
+ self.mock_user.condenser_max_size,
+ )
+
+ # Assert
+ mock_agent_class.assert_called_once()
+ call_kwargs = mock_agent_class.call_args[1]
+ assert call_kwargs['llm'] == mock_llm
+ assert call_kwargs['system_prompt_filename'] == 'system_prompt_planning.j2'
+ assert (
+ call_kwargs['system_prompt_kwargs']['plan_structure']
+ == 'test_plan_structure'
+ )
+ assert call_kwargs['mcp_config'] == mcp_config
+ assert call_kwargs['security_analyzer'] is None
+ assert call_kwargs['condenser'] == mock_condenser
+ mock_create_condenser.assert_called_once_with(
+ mock_llm, AgentType.PLAN, self.mock_user.condenser_max_size
+ )
+
+ @patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.get_default_tools'
+ )
+ @patch(
+ 'openhands.app_server.app_conversation.app_conversation_service_base.AppConversationServiceBase._create_condenser'
+ )
+ def test_create_agent_with_context_default_agent(
+ self, mock_create_condenser, mock_get_tools
+ ):
+ """Test _create_agent_with_context for default agent type."""
+ # Arrange
+ mock_llm = Mock(spec=LLM)
+ mock_llm.model_copy.return_value = mock_llm
+ mock_get_tools.return_value = []
+ mock_condenser = Mock()
+ mock_create_condenser.return_value = mock_condenser
+ mcp_config = {'default': {'url': 'test'}}
+
+ # Act
+ with patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.Agent'
+ ) as mock_agent_class:
+ mock_agent_instance = Mock()
+ mock_agent_instance.model_copy.return_value = mock_agent_instance
+ mock_agent_class.return_value = mock_agent_instance
+
+ self.service._create_agent_with_context(
+ mock_llm,
+ AgentType.DEFAULT,
+ None,
+ mcp_config,
+ self.mock_user.condenser_max_size,
+ )
+
+ # Assert
+ mock_agent_class.assert_called_once()
+ call_kwargs = mock_agent_class.call_args[1]
+ assert call_kwargs['llm'] == mock_llm
+ assert call_kwargs['system_prompt_kwargs']['cli_mode'] is False
+ assert call_kwargs['mcp_config'] == mcp_config
+ assert call_kwargs['condenser'] == mock_condenser
+ mock_get_tools.assert_called_once_with(enable_browser=True)
+ mock_create_condenser.assert_called_once_with(
+ mock_llm, AgentType.DEFAULT, self.mock_user.condenser_max_size
+ )
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
+ )
+ async def test_finalize_conversation_request_with_skills(
+ self, mock_experiment_manager
+ ):
+ """Test _finalize_conversation_request with skills loading."""
+ # Arrange
+ mock_agent = Mock(spec=Agent)
+ mock_updated_agent = Mock(spec=Agent)
+ mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
+ mock_updated_agent
+ )
+
+ conversation_id = uuid4()
+ workspace = LocalWorkspace(working_dir='/test')
+ initial_message = Mock(spec=SendMessageRequest)
+ secrets = {'test': StaticSecret(value='secret')}
+ remote_workspace = Mock(spec=AsyncRemoteWorkspace)
+
+ # Mock the skills loading method
+ self.service._load_skills_and_update_agent = AsyncMock(
+ return_value=mock_updated_agent
+ )
+
+ # Act
+ result = await self.service._finalize_conversation_request(
+ mock_agent,
+ conversation_id,
+ self.mock_user,
+ workspace,
+ initial_message,
+ secrets,
+ self.mock_sandbox,
+ remote_workspace,
+ 'test_repo',
+ '/test/dir',
+ )
+
+ # Assert
+ assert isinstance(result, StartConversationRequest)
+ assert result.conversation_id == conversation_id
+ assert result.agent == mock_updated_agent
+ assert result.workspace == workspace
+ assert result.initial_message == initial_message
+ assert result.secrets == secrets
+
+ mock_experiment_manager.run_agent_variant_tests__v1.assert_called_once_with(
+ self.mock_user.id, conversation_id, mock_agent
+ )
+ self.service._load_skills_and_update_agent.assert_called_once_with(
+ self.mock_sandbox,
+ mock_updated_agent,
+ remote_workspace,
+ 'test_repo',
+ '/test/dir',
+ )
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
+ )
+ async def test_finalize_conversation_request_without_skills(
+ self, mock_experiment_manager
+ ):
+ """Test _finalize_conversation_request without remote workspace (no skills)."""
+ # Arrange
+ mock_agent = Mock(spec=Agent)
+ mock_updated_agent = Mock(spec=Agent)
+ mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
+ mock_updated_agent
+ )
+
+ workspace = LocalWorkspace(working_dir='/test')
+ secrets = {'test': StaticSecret(value='secret')}
+
+ # Act
+ result = await self.service._finalize_conversation_request(
+ mock_agent,
+ None,
+ self.mock_user,
+ workspace,
+ None,
+ secrets,
+ self.mock_sandbox,
+ None,
+ None,
+ '/test/dir',
+ )
+
+ # Assert
+ assert isinstance(result, StartConversationRequest)
+ assert isinstance(result.conversation_id, UUID)
+ assert result.agent == mock_updated_agent
+ mock_experiment_manager.run_agent_variant_tests__v1.assert_called_once()
+
+ @pytest.mark.asyncio
+ @patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
+ )
+ async def test_finalize_conversation_request_skills_loading_fails(
+ self, mock_experiment_manager
+ ):
+ """Test _finalize_conversation_request when skills loading fails."""
+ # Arrange
+ mock_agent = Mock(spec=Agent)
+ mock_updated_agent = Mock(spec=Agent)
+ mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
+ mock_updated_agent
+ )
+
+ workspace = LocalWorkspace(working_dir='/test')
+ secrets = {'test': StaticSecret(value='secret')}
+ remote_workspace = Mock(spec=AsyncRemoteWorkspace)
+
+ # Mock skills loading to raise an exception
+ self.service._load_skills_and_update_agent = AsyncMock(
+ side_effect=Exception('Skills loading failed')
+ )
+
+ # Act
+ with patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service._logger'
+ ) as mock_logger:
+ result = await self.service._finalize_conversation_request(
+ mock_agent,
+ None,
+ self.mock_user,
+ workspace,
+ None,
+ secrets,
+ self.mock_sandbox,
+ remote_workspace,
+ 'test_repo',
+ '/test/dir',
+ )
+
+ # Assert
+ assert isinstance(result, StartConversationRequest)
+ assert (
+ result.agent == mock_updated_agent
+ ) # Should still use the experiment-modified agent
+ mock_logger.warning.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_build_start_conversation_request_for_user_integration(self):
+ """Test the main _build_start_conversation_request_for_user method integration."""
+ # Arrange
+ self.mock_user_context.get_user_info.return_value = self.mock_user
+
+ # Mock all the helper methods
+ mock_secrets = {'GITHUB_TOKEN': Mock()}
+ mock_llm = Mock(spec=LLM)
+ mock_mcp_config = {'default': {'url': 'test'}}
+ mock_agent = Mock(spec=Agent)
+ mock_final_request = Mock(spec=StartConversationRequest)
+
+ self.service._setup_secrets_for_git_providers = AsyncMock(
+ return_value=mock_secrets
+ )
+ self.service._configure_llm_and_mcp = AsyncMock(
+ return_value=(mock_llm, mock_mcp_config)
+ )
+ self.service._create_agent_with_context = Mock(return_value=mock_agent)
+ self.service._finalize_conversation_request = AsyncMock(
+ return_value=mock_final_request
+ )
+
+ # Act
+ result = await self.service._build_start_conversation_request_for_user(
+ sandbox=self.mock_sandbox,
+ initial_message=None,
+ system_message_suffix='Test suffix',
+ git_provider=ProviderType.GITHUB,
+ working_dir='/test/dir',
+ agent_type=AgentType.DEFAULT,
+ llm_model='gpt-4',
+ conversation_id=None,
+ remote_workspace=None,
+ selected_repository='test/repo',
+ )
+
+ # Assert
+ assert result == mock_final_request
+
+ self.service._setup_secrets_for_git_providers.assert_called_once_with(
+ self.mock_user
+ )
+ self.service._configure_llm_and_mcp.assert_called_once_with(
+ self.mock_user, 'gpt-4'
+ )
+ self.service._create_agent_with_context.assert_called_once_with(
+ mock_llm,
+ AgentType.DEFAULT,
+ '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_remote_sandbox_service.py b/tests/unit/app_server/test_remote_sandbox_service.py
index 567ecad2e30a..c70ad7d324a1 100644
--- a/tests/unit/app_server/test_remote_sandbox_service.py
+++ b/tests/unit/app_server/test_remote_sandbox_service.py
@@ -291,9 +291,7 @@ async def test_init_environment_with_web_url(self, remote_sandbox_service):
)
# Verify
- expected_webhook_url = (
- 'https://web.example.com/api/v1/webhooks/test-sandbox-123'
- )
+ expected_webhook_url = 'https://web.example.com/api/v1/webhooks'
assert environment['EXISTING_VAR'] == 'existing_value'
assert environment[WEBHOOK_CALLBACK_VARIABLE] == expected_webhook_url
assert environment[ALLOW_CORS_ORIGINS_VARIABLE] == 'https://web.example.com'
@@ -333,7 +331,7 @@ async def test_to_sandbox_info_with_running_runtime(self, remote_sandbox_service
runtime_data = create_runtime_data(status='running', pod_status='ready')
# Execute
- sandbox_info = await remote_sandbox_service._to_sandbox_info(
+ sandbox_info = remote_sandbox_service._to_sandbox_info(
stored_sandbox, runtime_data
)
@@ -360,7 +358,7 @@ async def test_to_sandbox_info_with_starting_runtime(self, remote_sandbox_servic
runtime_data = create_runtime_data(status='running', pod_status='pending')
# Execute
- sandbox_info = await remote_sandbox_service._to_sandbox_info(
+ sandbox_info = remote_sandbox_service._to_sandbox_info(
stored_sandbox, runtime_data
)
@@ -369,23 +367,6 @@ async def test_to_sandbox_info_with_starting_runtime(self, remote_sandbox_servic
assert sandbox_info.session_api_key == 'test-session-key'
assert sandbox_info.exposed_urls is None
- @pytest.mark.asyncio
- async def test_to_sandbox_info_without_runtime(self, remote_sandbox_service):
- """Test conversion to SandboxInfo without runtime data."""
- # Setup
- stored_sandbox = create_stored_sandbox()
- remote_sandbox_service._get_runtime = AsyncMock(
- side_effect=Exception('Runtime not found')
- )
-
- # Execute
- sandbox_info = await remote_sandbox_service._to_sandbox_info(stored_sandbox)
-
- # Verify
- assert sandbox_info.status == SandboxStatus.MISSING
- assert sandbox_info.session_api_key is None
- assert sandbox_info.exposed_urls is None
-
@pytest.mark.asyncio
async def test_to_sandbox_info_loads_runtime_when_none_provided(
self, remote_sandbox_service
@@ -393,15 +374,12 @@ async def test_to_sandbox_info_loads_runtime_when_none_provided(
"""Test that runtime data is loaded when not provided."""
# Setup
stored_sandbox = create_stored_sandbox()
- runtime_data = create_runtime_data()
- remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
# Execute
- sandbox_info = await remote_sandbox_service._to_sandbox_info(stored_sandbox)
+ sandbox_info = remote_sandbox_service._to_sandbox_info(stored_sandbox, None)
# Verify
- remote_sandbox_service._get_runtime.assert_called_once_with('test-sandbox-123')
- assert sandbox_info.status == SandboxStatus.RUNNING
+ assert sandbox_info.status == SandboxStatus.MISSING
class TestSandboxLifecycle:
@@ -679,15 +657,18 @@ async def test_search_sandboxes_basic(self, remote_sandbox_service):
mock_result = MagicMock()
mock_result.scalars.return_value = mock_scalars
remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result)
- remote_sandbox_service._to_sandbox_info = AsyncMock(
- side_effect=lambda stored: SandboxInfo(
- id=stored.id,
- created_by_user_id=stored.created_by_user_id,
- sandbox_spec_id=stored.sandbox_spec_id,
- status=SandboxStatus.RUNNING,
- session_api_key='test-key',
- created_at=stored.created_at,
- )
+
+ # Mock the batch endpoint response
+ mock_batch_response = MagicMock()
+ mock_batch_response.raise_for_status.return_value = None
+ mock_batch_response.json.return_value = {
+ 'runtimes': [
+ create_runtime_data('sb1'),
+ create_runtime_data('sb2'),
+ ]
+ }
+ remote_sandbox_service.httpx_client.request = AsyncMock(
+ return_value=mock_batch_response
)
# Execute
@@ -699,6 +680,14 @@ async def test_search_sandboxes_basic(self, remote_sandbox_service):
assert result.items[0].id == 'sb1'
assert result.items[1].id == 'sb2'
+ # Verify that the batch endpoint was called
+ remote_sandbox_service.httpx_client.request.assert_called_once_with(
+ 'GET',
+ 'https://api.example.com/sessions/batch',
+ headers={'X-API-Key': 'test-api-key'},
+ params=[('ids', 'sb1'), ('ids', 'sb2')],
+ )
+
@pytest.mark.asyncio
async def test_search_sandboxes_with_pagination(self, remote_sandbox_service):
"""Test sandbox search with pagination."""
@@ -712,15 +701,15 @@ async def test_search_sandboxes_with_pagination(self, remote_sandbox_service):
mock_result = MagicMock()
mock_result.scalars.return_value = mock_scalars
remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result)
- remote_sandbox_service._to_sandbox_info = AsyncMock(
- side_effect=lambda stored: SandboxInfo(
- id=stored.id,
- created_by_user_id=stored.created_by_user_id,
- sandbox_spec_id=stored.sandbox_spec_id,
- status=SandboxStatus.RUNNING,
- session_api_key='test-key',
- created_at=stored.created_at,
- )
+
+ # Mock the batch endpoint response
+ mock_batch_response = MagicMock()
+ mock_batch_response.raise_for_status.return_value = None
+ mock_batch_response.json.return_value = {
+ 'runtimes': [create_runtime_data(f'sb{i}') for i in range(6)]
+ }
+ remote_sandbox_service.httpx_client.request = AsyncMock(
+ return_value=mock_batch_response
)
# Execute
@@ -741,15 +730,15 @@ async def test_search_sandboxes_with_page_id(self, remote_sandbox_service):
mock_result = MagicMock()
mock_result.scalars.return_value = mock_scalars
remote_sandbox_service.db_session.execute = AsyncMock(return_value=mock_result)
- remote_sandbox_service._to_sandbox_info = AsyncMock(
- side_effect=lambda stored: SandboxInfo(
- id=stored.id,
- created_by_user_id=stored.created_by_user_id,
- sandbox_spec_id=stored.sandbox_spec_id,
- status=SandboxStatus.RUNNING,
- session_api_key='test-key',
- created_at=stored.created_at,
- )
+
+ # Mock the batch endpoint response
+ mock_batch_response = MagicMock()
+ mock_batch_response.raise_for_status.return_value = None
+ mock_batch_response.json.return_value = {
+ 'runtimes': [create_runtime_data('sb1')]
+ }
+ remote_sandbox_service.httpx_client.request = AsyncMock(
+ return_value=mock_batch_response
)
# Execute
@@ -759,6 +748,76 @@ async def test_search_sandboxes_with_page_id(self, remote_sandbox_service):
# Note: We can't easily verify the exact SQL query, but we can verify the method was called
remote_sandbox_service.db_session.execute.assert_called_once()
+ @pytest.mark.asyncio
+ async def test_get_runtimes_batch_success(self, remote_sandbox_service):
+ """Test successful batch runtime retrieval."""
+ # Setup
+ sandbox_ids = ['sb1', 'sb2', 'sb3']
+ mock_response = MagicMock()
+ mock_response.raise_for_status.return_value = None
+ mock_response.json.return_value = [
+ create_runtime_data('sb1'),
+ create_runtime_data('sb2'),
+ create_runtime_data('sb3'),
+ ]
+ remote_sandbox_service.httpx_client.request = AsyncMock(
+ return_value=mock_response
+ )
+
+ # Execute
+ result = await remote_sandbox_service._get_runtimes_batch(sandbox_ids)
+
+ # Verify
+ assert len(result) == 3
+ assert 'sb1' in result
+ assert 'sb2' in result
+ assert 'sb3' in result
+ assert result['sb1']['session_id'] == 'sb1'
+
+ # Verify the correct API call was made
+ remote_sandbox_service.httpx_client.request.assert_called_once_with(
+ 'GET',
+ 'https://api.example.com/sessions/batch',
+ headers={'X-API-Key': 'test-api-key'},
+ params=[('ids', 'sb1'), ('ids', 'sb2'), ('ids', 'sb3')],
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_runtimes_batch_empty_list(self, remote_sandbox_service):
+ """Test batch runtime retrieval with empty sandbox list."""
+ # Execute
+ result = await remote_sandbox_service._get_runtimes_batch([])
+
+ # Verify
+ assert result == {}
+ # Verify no API call was made
+ remote_sandbox_service.httpx_client.request.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_get_runtimes_batch_partial_results(self, remote_sandbox_service):
+ """Test batch runtime retrieval with partial results (some sandboxes not found)."""
+ # Setup
+ sandbox_ids = ['sb1', 'sb2', 'sb3']
+ mock_response = MagicMock()
+ mock_response.raise_for_status.return_value = None
+ mock_response.json.return_value = [
+ create_runtime_data('sb1'),
+ create_runtime_data('sb3'),
+ # sb2 is missing from the response
+ ]
+ remote_sandbox_service.httpx_client.request = AsyncMock(
+ return_value=mock_response
+ )
+
+ # Execute
+ result = await remote_sandbox_service._get_runtimes_batch(sandbox_ids)
+
+ # Verify
+ assert len(result) == 2
+ assert 'sb1' in result
+ assert 'sb2' not in result # Missing from response
+ assert 'sb3' in result
+
@pytest.mark.asyncio
async def test_get_sandbox_exists(self, remote_sandbox_service):
"""Test getting an existing sandbox."""
@@ -767,7 +826,7 @@ async def test_get_sandbox_exists(self, remote_sandbox_service):
remote_sandbox_service._get_stored_sandbox = AsyncMock(
return_value=stored_sandbox
)
- remote_sandbox_service._to_sandbox_info = AsyncMock(
+ remote_sandbox_service._to_sandbox_info = MagicMock(
return_value=SandboxInfo(
id='test-sandbox-123',
created_by_user_id='test-user-123',
diff --git a/tests/unit/app_server/test_sandbox_service.py b/tests/unit/app_server/test_sandbox_service.py
index 9a651318217e..f3eea1d2eaf6 100644
--- a/tests/unit/app_server/test_sandbox_service.py
+++ b/tests/unit/app_server/test_sandbox_service.py
@@ -27,6 +27,7 @@ class MockSandboxService(SandboxService):
def __init__(self):
self.search_sandboxes_mock = AsyncMock()
self.get_sandbox_mock = AsyncMock()
+ self.get_sandbox_by_session_api_key_mock = AsyncMock()
self.start_sandbox_mock = AsyncMock()
self.resume_sandbox_mock = AsyncMock()
self.pause_sandbox_mock = AsyncMock()
@@ -40,6 +41,11 @@ async def search_sandboxes(
async def get_sandbox(self, sandbox_id: str) -> SandboxInfo | None:
return await self.get_sandbox_mock(sandbox_id)
+ async def get_sandbox_by_session_api_key(
+ self, session_api_key: str
+ ) -> SandboxInfo | None:
+ return await self.get_sandbox_by_session_api_key_mock(session_api_key)
+
async def start_sandbox(self, sandbox_spec_id: str | None = None) -> SandboxInfo:
return await self.start_sandbox_mock(sandbox_spec_id)
diff --git a/tests/unit/app_server/test_skill_loader.py b/tests/unit/app_server/test_skill_loader.py
index c9e54ba5a1a5..e4daadfa1485 100644
--- a/tests/unit/app_server/test_skill_loader.py
+++ b/tests/unit/app_server/test_skill_loader.py
@@ -11,15 +11,27 @@
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 @@ def test_merge_skills_mixed_empty_and_filled(self):
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 @@ async def test_loading_with_override_precedence(
# 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/app_server/test_webhook_router_stats.py b/tests/unit/app_server/test_webhook_router_stats.py
new file mode 100644
index 000000000000..ba5664a196b7
--- /dev/null
+++ b/tests/unit/app_server/test_webhook_router_stats.py
@@ -0,0 +1,615 @@
+"""Tests for stats event processing in webhook_router.
+
+This module tests the stats event processing functionality introduced for
+updating conversation statistics from ConversationStateUpdateEvent events.
+"""
+
+from datetime import datetime, timezone
+from typing import AsyncGenerator
+from unittest.mock import AsyncMock, MagicMock, patch
+from uuid import uuid4
+
+import pytest
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from sqlalchemy.pool import StaticPool
+
+from openhands.app_server.app_conversation.app_conversation_models import (
+ AppConversationInfo,
+)
+from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
+ SQLAppConversationInfoService,
+ StoredConversationMetadata,
+)
+from openhands.app_server.user.specifiy_user_context import SpecifyUserContext
+from openhands.app_server.utils.sql_utils import Base
+from openhands.sdk.conversation.conversation_stats import ConversationStats
+from openhands.sdk.event import ConversationStateUpdateEvent
+from openhands.sdk.llm.utils.metrics import Metrics, TokenUsage
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture
+async def async_engine():
+ """Create an async SQLite engine for testing."""
+ engine = create_async_engine(
+ 'sqlite+aiosqlite:///:memory:',
+ poolclass=StaticPool,
+ connect_args={'check_same_thread': False},
+ echo=False,
+ )
+
+ # Create all tables
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+
+ yield engine
+
+ await engine.dispose()
+
+
+@pytest.fixture
+async def async_session(async_engine) -> AsyncGenerator[AsyncSession, None]:
+ """Create an async session for testing."""
+ async_session_maker = async_sessionmaker(
+ async_engine, class_=AsyncSession, expire_on_commit=False
+ )
+
+ async with async_session_maker() as db_session:
+ yield db_session
+
+
+@pytest.fixture
+def service(async_session) -> SQLAppConversationInfoService:
+ """Create a SQLAppConversationInfoService instance for testing."""
+ return SQLAppConversationInfoService(
+ db_session=async_session, user_context=SpecifyUserContext(user_id=None)
+ )
+
+
+@pytest.fixture
+async def v1_conversation_metadata(async_session, service):
+ """Create a V1 conversation metadata record for testing."""
+ conversation_id = uuid4()
+ stored = StoredConversationMetadata(
+ conversation_id=str(conversation_id),
+ user_id='test_user_123',
+ sandbox_id='sandbox_123',
+ conversation_version='V1',
+ title='Test Conversation',
+ accumulated_cost=0.0,
+ prompt_tokens=0,
+ completion_tokens=0,
+ cache_read_tokens=0,
+ cache_write_tokens=0,
+ reasoning_tokens=0,
+ context_window=0,
+ per_turn_token=0,
+ created_at=datetime.now(timezone.utc),
+ last_updated_at=datetime.now(timezone.utc),
+ )
+ async_session.add(stored)
+ await async_session.commit()
+ return conversation_id, stored
+
+
+@pytest.fixture
+def stats_event_with_dict_value():
+ """Create a ConversationStateUpdateEvent with dict value."""
+ event_value = {
+ 'usage_to_metrics': {
+ 'agent': {
+ 'accumulated_cost': 0.03411525,
+ 'max_budget_per_task': None,
+ 'accumulated_token_usage': {
+ 'prompt_tokens': 8770,
+ 'completion_tokens': 82,
+ 'cache_read_tokens': 0,
+ 'cache_write_tokens': 8767,
+ 'reasoning_tokens': 0,
+ 'context_window': 0,
+ 'per_turn_token': 8852,
+ },
+ },
+ 'condenser': {
+ 'accumulated_cost': 0.0,
+ 'accumulated_token_usage': {
+ 'prompt_tokens': 0,
+ 'completion_tokens': 0,
+ },
+ },
+ }
+ }
+ return ConversationStateUpdateEvent(key='stats', value=event_value)
+
+
+@pytest.fixture
+def stats_event_with_object_value():
+ """Create a ConversationStateUpdateEvent with object value."""
+ event_value = MagicMock()
+ event_value.usage_to_metrics = {
+ 'agent': {
+ 'accumulated_cost': 0.05,
+ 'accumulated_token_usage': {
+ 'prompt_tokens': 1000,
+ 'completion_tokens': 100,
+ },
+ }
+ }
+ return ConversationStateUpdateEvent(key='stats', value=event_value)
+
+
+@pytest.fixture
+def stats_event_no_usage_to_metrics():
+ """Create a ConversationStateUpdateEvent without usage_to_metrics."""
+ event_value = {'some_other_key': 'value'}
+ return ConversationStateUpdateEvent(key='stats', value=event_value)
+
+
+# ---------------------------------------------------------------------------
+# Tests for update_conversation_statistics
+# ---------------------------------------------------------------------------
+
+
+class TestUpdateConversationStatistics:
+ """Test the update_conversation_statistics method."""
+
+ @pytest.mark.asyncio
+ async def test_update_statistics_success(
+ self, service, async_session, v1_conversation_metadata
+ ):
+ """Test successfully updating conversation statistics."""
+ conversation_id, stored = v1_conversation_metadata
+
+ agent_metrics = Metrics(
+ model_name='test-model',
+ accumulated_cost=0.03411525,
+ max_budget_per_task=10.0,
+ accumulated_token_usage=TokenUsage(
+ model='test-model',
+ prompt_tokens=8770,
+ completion_tokens=82,
+ cache_read_tokens=0,
+ cache_write_tokens=8767,
+ reasoning_tokens=0,
+ context_window=0,
+ per_turn_token=8852,
+ ),
+ )
+ stats = ConversationStats(usage_to_metrics={'agent': agent_metrics})
+
+ await service.update_conversation_statistics(conversation_id, stats)
+
+ # Verify the update
+ await async_session.refresh(stored)
+ assert stored.accumulated_cost == 0.03411525
+ assert stored.max_budget_per_task == 10.0
+ assert stored.prompt_tokens == 8770
+ assert stored.completion_tokens == 82
+ assert stored.cache_read_tokens == 0
+ assert stored.cache_write_tokens == 8767
+ assert stored.reasoning_tokens == 0
+ assert stored.context_window == 0
+ assert stored.per_turn_token == 8852
+ assert stored.last_updated_at is not None
+
+ @pytest.mark.asyncio
+ async def test_update_statistics_partial_update(
+ self, service, async_session, v1_conversation_metadata
+ ):
+ """Test updating only some statistics fields."""
+ conversation_id, stored = v1_conversation_metadata
+
+ # Set initial values
+ stored.accumulated_cost = 0.01
+ stored.prompt_tokens = 100
+ await async_session.commit()
+
+ agent_metrics = Metrics(
+ model_name='test-model',
+ accumulated_cost=0.05,
+ accumulated_token_usage=TokenUsage(
+ model='test-model',
+ prompt_tokens=200,
+ completion_tokens=0, # Default value
+ ),
+ )
+ stats = ConversationStats(usage_to_metrics={'agent': agent_metrics})
+
+ await service.update_conversation_statistics(conversation_id, stats)
+
+ # Verify updated fields
+ await async_session.refresh(stored)
+ assert stored.accumulated_cost == 0.05
+ assert stored.prompt_tokens == 200
+ # completion_tokens should remain unchanged (not None in stats)
+ assert stored.completion_tokens == 0
+
+ @pytest.mark.asyncio
+ async def test_update_statistics_no_agent_metrics(
+ self, service, v1_conversation_metadata
+ ):
+ """Test that update is skipped when no agent metrics are present."""
+ conversation_id, stored = v1_conversation_metadata
+ original_cost = stored.accumulated_cost
+
+ condenser_metrics = Metrics(
+ model_name='test-model',
+ accumulated_cost=0.1,
+ )
+ stats = ConversationStats(usage_to_metrics={'condenser': condenser_metrics})
+
+ await service.update_conversation_statistics(conversation_id, stats)
+
+ # Verify no update occurred
+ assert stored.accumulated_cost == original_cost
+
+ @pytest.mark.asyncio
+ async def test_update_statistics_conversation_not_found(self, service):
+ """Test that update is skipped when conversation doesn't exist."""
+ nonexistent_id = uuid4()
+ agent_metrics = Metrics(
+ model_name='test-model',
+ accumulated_cost=0.1,
+ )
+ stats = ConversationStats(usage_to_metrics={'agent': agent_metrics})
+
+ # Should not raise an exception
+ await service.update_conversation_statistics(nonexistent_id, stats)
+
+ @pytest.mark.asyncio
+ async def test_update_statistics_v0_conversation_skipped(
+ self, service, async_session
+ ):
+ """Test that V0 conversations are skipped."""
+ conversation_id = uuid4()
+ stored = StoredConversationMetadata(
+ conversation_id=str(conversation_id),
+ user_id='test_user_123',
+ sandbox_id='sandbox_123',
+ conversation_version='V0', # V0 conversation
+ title='V0 Conversation',
+ accumulated_cost=0.0,
+ created_at=datetime.now(timezone.utc),
+ last_updated_at=datetime.now(timezone.utc),
+ )
+ async_session.add(stored)
+ await async_session.commit()
+
+ original_cost = stored.accumulated_cost
+
+ agent_metrics = Metrics(
+ model_name='test-model',
+ accumulated_cost=0.1,
+ )
+ stats = ConversationStats(usage_to_metrics={'agent': agent_metrics})
+
+ await service.update_conversation_statistics(conversation_id, stats)
+
+ # Verify no update occurred
+ await async_session.refresh(stored)
+ assert stored.accumulated_cost == original_cost
+
+ @pytest.mark.asyncio
+ async def test_update_statistics_with_none_values(
+ self, service, async_session, v1_conversation_metadata
+ ):
+ """Test that None values in stats don't overwrite existing values."""
+ conversation_id, stored = v1_conversation_metadata
+
+ # Set initial values
+ stored.accumulated_cost = 0.01
+ stored.max_budget_per_task = 5.0
+ stored.prompt_tokens = 100
+ await async_session.commit()
+
+ agent_metrics = Metrics(
+ model_name='test-model',
+ accumulated_cost=0.05,
+ max_budget_per_task=None, # None value
+ accumulated_token_usage=TokenUsage(
+ model='test-model',
+ prompt_tokens=200,
+ completion_tokens=0, # Default value (None is not valid for int)
+ ),
+ )
+ stats = ConversationStats(usage_to_metrics={'agent': agent_metrics})
+
+ await service.update_conversation_statistics(conversation_id, stats)
+
+ # Verify updated fields and that None values didn't overwrite
+ await async_session.refresh(stored)
+ assert stored.accumulated_cost == 0.05
+ assert stored.max_budget_per_task == 5.0 # Should remain unchanged
+ assert stored.prompt_tokens == 200
+ assert (
+ stored.completion_tokens == 0
+ ) # Should remain unchanged (was 0, None doesn't update)
+
+
+# ---------------------------------------------------------------------------
+# Tests for process_stats_event
+# ---------------------------------------------------------------------------
+
+
+class TestProcessStatsEvent:
+ """Test the process_stats_event method."""
+
+ @pytest.mark.asyncio
+ async def test_process_stats_event_with_dict_value(
+ self,
+ service,
+ async_session,
+ stats_event_with_dict_value,
+ v1_conversation_metadata,
+ ):
+ """Test processing stats event with dict value."""
+ conversation_id, stored = v1_conversation_metadata
+
+ await service.process_stats_event(stats_event_with_dict_value, conversation_id)
+
+ # Verify the update occurred
+ await async_session.refresh(stored)
+ assert stored.accumulated_cost == 0.03411525
+ assert stored.prompt_tokens == 8770
+ assert stored.completion_tokens == 82
+
+ @pytest.mark.asyncio
+ async def test_process_stats_event_with_object_value(
+ self,
+ service,
+ async_session,
+ stats_event_with_object_value,
+ v1_conversation_metadata,
+ ):
+ """Test processing stats event with object value."""
+ conversation_id, stored = v1_conversation_metadata
+
+ await service.process_stats_event(
+ stats_event_with_object_value, conversation_id
+ )
+
+ # Verify the update occurred
+ await async_session.refresh(stored)
+ assert stored.accumulated_cost == 0.05
+ assert stored.prompt_tokens == 1000
+ assert stored.completion_tokens == 100
+
+ @pytest.mark.asyncio
+ async def test_process_stats_event_no_usage_to_metrics(
+ self,
+ service,
+ async_session,
+ stats_event_no_usage_to_metrics,
+ v1_conversation_metadata,
+ ):
+ """Test processing stats event without usage_to_metrics."""
+ conversation_id, stored = v1_conversation_metadata
+ original_cost = stored.accumulated_cost
+
+ await service.process_stats_event(
+ stats_event_no_usage_to_metrics, conversation_id
+ )
+
+ # Verify update_conversation_statistics was NOT called
+ await async_session.refresh(stored)
+ assert stored.accumulated_cost == original_cost
+
+ @pytest.mark.asyncio
+ async def test_process_stats_event_service_error_handled(
+ self, service, stats_event_with_dict_value
+ ):
+ """Test that errors from service are caught and logged."""
+ conversation_id = uuid4()
+
+ # Should not raise an exception
+ with (
+ patch.object(
+ service,
+ 'update_conversation_statistics',
+ side_effect=Exception('Database error'),
+ ),
+ patch(
+ 'openhands.app_server.app_conversation.sql_app_conversation_info_service.logger'
+ ) as mock_logger,
+ ):
+ await service.process_stats_event(
+ stats_event_with_dict_value, conversation_id
+ )
+
+ # Verify error was logged
+ mock_logger.exception.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_process_stats_event_empty_usage_to_metrics(
+ self, service, async_session, v1_conversation_metadata
+ ):
+ """Test processing stats event with empty usage_to_metrics."""
+ conversation_id, stored = v1_conversation_metadata
+ original_cost = stored.accumulated_cost
+
+ # Create event with empty usage_to_metrics
+ event = ConversationStateUpdateEvent(
+ key='stats', value={'usage_to_metrics': {}}
+ )
+
+ await service.process_stats_event(event, conversation_id)
+
+ # Empty dict is falsy, so update_conversation_statistics should NOT be called
+ await async_session.refresh(stored)
+ assert stored.accumulated_cost == original_cost
+
+
+# ---------------------------------------------------------------------------
+# Integration tests for on_event endpoint
+# ---------------------------------------------------------------------------
+
+
+class TestOnEventStatsProcessing:
+ """Test stats event processing in the on_event endpoint."""
+
+ @pytest.mark.asyncio
+ async def test_on_event_processes_stats_events(self):
+ """Test that on_event processes stats events."""
+ from openhands.app_server.event_callback.webhook_router import on_event
+ from openhands.app_server.sandbox.sandbox_models import (
+ SandboxInfo,
+ SandboxStatus,
+ )
+
+ conversation_id = uuid4()
+ sandbox_id = 'sandbox_123'
+
+ # Create stats event
+ stats_event = ConversationStateUpdateEvent(
+ key='stats',
+ value={
+ 'usage_to_metrics': {
+ 'agent': {
+ 'accumulated_cost': 0.1,
+ 'accumulated_token_usage': {
+ 'prompt_tokens': 1000,
+ },
+ }
+ }
+ },
+ )
+
+ # Create non-stats event
+ other_event = ConversationStateUpdateEvent(
+ key='execution_status', value='running'
+ )
+
+ events = [stats_event, other_event]
+
+ # Mock dependencies
+ mock_sandbox = SandboxInfo(
+ id=sandbox_id,
+ status=SandboxStatus.RUNNING,
+ session_api_key='test_key',
+ created_by_user_id='user_123',
+ sandbox_spec_id='spec_123',
+ )
+
+ mock_app_conversation_info = AppConversationInfo(
+ id=conversation_id,
+ sandbox_id=sandbox_id,
+ created_by_user_id='user_123',
+ )
+
+ mock_event_service = AsyncMock()
+ mock_app_conversation_info_service = AsyncMock()
+ mock_app_conversation_info_service.get_app_conversation_info.return_value = (
+ mock_app_conversation_info
+ )
+
+ # Set up process_stats_event to call update_conversation_statistics
+ async def process_stats_event_side_effect(event, conversation_id):
+ # Simulate what process_stats_event does - call update_conversation_statistics
+ from openhands.sdk.conversation.conversation_stats import ConversationStats
+
+ if isinstance(event.value, dict):
+ stats = ConversationStats.model_validate(event.value)
+ if stats and stats.usage_to_metrics:
+ await mock_app_conversation_info_service.update_conversation_statistics(
+ conversation_id, stats
+ )
+
+ mock_app_conversation_info_service.process_stats_event.side_effect = (
+ process_stats_event_side_effect
+ )
+
+ with (
+ patch(
+ 'openhands.app_server.event_callback.webhook_router.valid_sandbox',
+ return_value=mock_sandbox,
+ ),
+ patch(
+ 'openhands.app_server.event_callback.webhook_router.valid_conversation',
+ return_value=mock_app_conversation_info,
+ ),
+ patch(
+ 'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
+ ) as mock_callbacks,
+ ):
+ await on_event(
+ events=events,
+ conversation_id=conversation_id,
+ sandbox_info=mock_sandbox,
+ app_conversation_info_service=mock_app_conversation_info_service,
+ event_service=mock_event_service,
+ )
+
+ # Verify events were saved
+ assert mock_event_service.save_event.call_count == 2
+
+ # Verify stats event was processed
+ mock_app_conversation_info_service.update_conversation_statistics.assert_called_once()
+
+ # Verify callbacks were scheduled
+ mock_callbacks.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_on_event_skips_non_stats_events(self):
+ """Test that on_event skips non-stats events."""
+ from openhands.app_server.event_callback.webhook_router import on_event
+ from openhands.app_server.sandbox.sandbox_models import (
+ SandboxInfo,
+ SandboxStatus,
+ )
+ from openhands.events.action.message import MessageAction
+
+ conversation_id = uuid4()
+ sandbox_id = 'sandbox_123'
+
+ # Create non-stats events
+ events = [
+ ConversationStateUpdateEvent(key='execution_status', value='running'),
+ MessageAction(content='test'),
+ ]
+
+ mock_sandbox = SandboxInfo(
+ id=sandbox_id,
+ status=SandboxStatus.RUNNING,
+ session_api_key='test_key',
+ created_by_user_id='user_123',
+ sandbox_spec_id='spec_123',
+ )
+
+ mock_app_conversation_info = AppConversationInfo(
+ id=conversation_id,
+ sandbox_id=sandbox_id,
+ created_by_user_id='user_123',
+ )
+
+ mock_event_service = AsyncMock()
+ mock_app_conversation_info_service = AsyncMock()
+ mock_app_conversation_info_service.get_app_conversation_info.return_value = (
+ mock_app_conversation_info
+ )
+
+ with (
+ patch(
+ 'openhands.app_server.event_callback.webhook_router.valid_sandbox',
+ return_value=mock_sandbox,
+ ),
+ patch(
+ 'openhands.app_server.event_callback.webhook_router.valid_conversation',
+ return_value=mock_app_conversation_info,
+ ),
+ patch(
+ 'openhands.app_server.event_callback.webhook_router._run_callbacks_in_bg_and_close'
+ ),
+ ):
+ await on_event(
+ events=events,
+ conversation_id=conversation_id,
+ sandbox_info=mock_sandbox,
+ app_conversation_info_service=mock_app_conversation_info_service,
+ event_service=mock_event_service,
+ )
+
+ # Verify stats update was NOT called
+ mock_app_conversation_info_service.update_conversation_statistics.assert_not_called()
diff --git a/tests/unit/controller/test_agent_controller.py b/tests/unit/controller/test_agent_controller.py
index da12ee8f9e4e..3da87ecfdf36 100644
--- a/tests/unit/controller/test_agent_controller.py
+++ b/tests/unit/controller/test_agent_controller.py
@@ -24,6 +24,7 @@
from openhands.events import Event, EventSource, EventStream, EventStreamSubscriber
from openhands.events.action import ChangeAgentStateAction, CmdRunAction, MessageAction
from openhands.events.action.agent import CondensationAction, RecallAction
+from openhands.events.action.empty import NullAction
from openhands.events.action.message import SystemMessageAction
from openhands.events.event import RecallType
from openhands.events.observation import (
@@ -299,6 +300,64 @@ async def test_react_to_content_policy_violation(
await controller.close()
+@pytest.mark.asyncio
+async def test_tool_call_validation_error_handling(
+ mock_agent_with_stats,
+ test_event_stream,
+):
+ """Test that tool call validation errors from Groq are handled as recoverable errors."""
+ mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
+
+ controller = AgentController(
+ agent=mock_agent,
+ event_stream=test_event_stream,
+ conversation_stats=conversation_stats,
+ iteration_delta=10,
+ sid='test',
+ confirmation_mode=False,
+ headless_mode=True,
+ )
+
+ controller.state.agent_state = AgentState.RUNNING
+
+ # Track call count to only raise error on first call
+ # This prevents a feedback loop where ErrorObservation triggers another step
+ # which raises the same error again (since the mock always raises)
+ call_count = 0
+
+ def mock_step(state):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ raise BadRequestError(
+ message='litellm.BadRequestError: GroqException - {"error":{"message":"tool call validation failed: parameters for tool str_replace_editor did not match schema: errors: [missing properties: \'path\']","type":"invalid_request_error","code":"tool_use_failed"}}',
+ model='groq/llama3-8b-8192',
+ llm_provider='groq',
+ )
+ # Return NullAction on subsequent calls to break the feedback loop
+ return NullAction()
+
+ mock_agent.step = mock_step
+
+ # Call _step which should handle the tool validation error
+ await controller._step()
+
+ # Verify that the agent state is still RUNNING (not ERROR)
+ assert controller.state.agent_state == AgentState.RUNNING
+
+ # Verify that an ErrorObservation was added to the event stream
+ events = list(test_event_stream.get_events())
+ error_observations = [e for e in events if isinstance(e, ErrorObservation)]
+ assert len(error_observations) == 1
+
+ error_obs = error_observations[0]
+ assert 'tool call validation failed' in error_obs.content
+ assert 'missing properties' in error_obs.content
+ assert 'path' in error_obs.content
+
+ await controller.close()
+
+
@pytest.mark.asyncio
async def test_run_controller_with_fatal_error(
test_event_stream, mock_memory, mock_agent_with_stats
diff --git a/tests/unit/controller/test_agent_controller_posthog.py b/tests/unit/controller/test_agent_controller_posthog.py
deleted file mode 100644
index 630c18e3aa53..000000000000
--- a/tests/unit/controller/test_agent_controller_posthog.py
+++ /dev/null
@@ -1,243 +0,0 @@
-"""Integration tests for PostHog tracking in AgentController."""
-
-import asyncio
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from openhands.controller.agent import Agent
-from openhands.controller.agent_controller import AgentController
-from openhands.core.config import OpenHandsConfig
-from openhands.core.config.agent_config import AgentConfig
-from openhands.core.config.llm_config import LLMConfig
-from openhands.core.schema import AgentState
-from openhands.events import EventSource, EventStream
-from openhands.events.action.message import SystemMessageAction
-from openhands.llm.llm_registry import LLMRegistry
-from openhands.server.services.conversation_stats import ConversationStats
-from openhands.storage.memory import InMemoryFileStore
-
-
-@pytest.fixture(scope='function')
-def event_loop():
- """Create event loop for async tests."""
- loop = asyncio.get_event_loop_policy().new_event_loop()
- yield loop
- loop.close()
-
-
-@pytest.fixture
-def mock_agent_with_stats():
- """Create a mock agent with properly connected LLM registry and conversation stats."""
- import uuid
-
- # Create LLM registry
- config = OpenHandsConfig()
- llm_registry = LLMRegistry(config=config)
-
- # Create conversation stats
- file_store = InMemoryFileStore({})
- conversation_id = f'test-conversation-{uuid.uuid4()}'
- conversation_stats = ConversationStats(
- file_store=file_store, conversation_id=conversation_id, user_id='test-user'
- )
-
- # Connect registry to stats
- llm_registry.subscribe(conversation_stats.register_llm)
-
- # Create mock agent
- agent = MagicMock(spec=Agent)
- agent_config = MagicMock(spec=AgentConfig)
- llm_config = LLMConfig(
- model='gpt-4o',
- api_key='test_key',
- num_retries=2,
- retry_min_wait=1,
- retry_max_wait=2,
- )
- agent_config.disabled_microagents = []
- agent_config.enable_mcp = True
- agent_config.enable_stuck_detection = True
- llm_registry.service_to_llm.clear()
- mock_llm = llm_registry.get_llm('agent_llm', llm_config)
- agent.llm = mock_llm
- agent.name = 'test-agent'
- agent.sandbox_plugins = []
- agent.config = agent_config
- agent.llm_registry = llm_registry
- agent.prompt_manager = MagicMock()
-
- # Add a proper system message mock
- system_message = SystemMessageAction(
- content='Test system message', tools=['test_tool']
- )
- system_message._source = EventSource.AGENT
- system_message._id = -1 # Set invalid ID to avoid the ID check
- agent.get_system_message.return_value = system_message
-
- return agent, conversation_stats, llm_registry
-
-
-@pytest.fixture
-def mock_event_stream():
- """Create a mock event stream."""
- mock = MagicMock(
- spec=EventStream,
- event_stream=EventStream(sid='test', file_store=InMemoryFileStore({})),
- )
- mock.get_latest_event_id.return_value = 0
- return mock
-
-
-@pytest.mark.asyncio
-async def test_agent_finish_triggers_posthog_tracking(
- mock_agent_with_stats, mock_event_stream
-):
- """Test that setting agent state to FINISHED triggers PostHog tracking."""
- mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
-
- controller = AgentController(
- agent=mock_agent,
- event_stream=mock_event_stream,
- conversation_stats=conversation_stats,
- iteration_delta=10,
- sid='test-conversation-123',
- user_id='test-user-456',
- confirmation_mode=False,
- headless_mode=True,
- )
-
- with (
- patch('openhands.utils.posthog_tracker.posthog') as mock_posthog,
- patch('os.environ.get') as mock_env_get,
- ):
- # Setup mocks
- mock_posthog.capture = MagicMock()
- mock_env_get.return_value = 'saas'
-
- # Initialize posthog in the tracker module
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- # Set agent state to FINISHED
- await controller.set_agent_state_to(AgentState.FINISHED)
-
- # Verify PostHog tracking was called
- mock_posthog.capture.assert_called_once()
- call_args = mock_posthog.capture.call_args
-
- assert call_args[1]['distinct_id'] == 'test-user-456'
- assert call_args[1]['event'] == 'agent_task_completed'
- assert 'conversation_id' in call_args[1]['properties']
- assert call_args[1]['properties']['user_id'] == 'test-user-456'
- assert call_args[1]['properties']['app_mode'] == 'saas'
-
- await controller.close()
-
-
-@pytest.mark.asyncio
-async def test_agent_finish_without_user_id(mock_agent_with_stats, mock_event_stream):
- """Test tracking when user_id is None."""
- mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
-
- controller = AgentController(
- agent=mock_agent,
- event_stream=mock_event_stream,
- conversation_stats=conversation_stats,
- iteration_delta=10,
- sid='test-conversation-789',
- user_id=None,
- confirmation_mode=False,
- headless_mode=True,
- )
-
- with (
- patch('openhands.utils.posthog_tracker.posthog') as mock_posthog,
- patch('os.environ.get') as mock_env_get,
- ):
- mock_posthog.capture = MagicMock()
- mock_env_get.return_value = 'oss'
-
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- await controller.set_agent_state_to(AgentState.FINISHED)
-
- mock_posthog.capture.assert_called_once()
- call_args = mock_posthog.capture.call_args
-
- # When user_id is None, distinct_id should be conversation_id
- assert call_args[1]['distinct_id'].startswith('conversation_')
- assert call_args[1]['properties']['user_id'] is None
-
- await controller.close()
-
-
-@pytest.mark.asyncio
-async def test_other_states_dont_trigger_tracking(
- mock_agent_with_stats, mock_event_stream
-):
- """Test that non-FINISHED states don't trigger tracking."""
- mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
-
- controller = AgentController(
- agent=mock_agent,
- event_stream=mock_event_stream,
- conversation_stats=conversation_stats,
- iteration_delta=10,
- sid='test-conversation-999',
- confirmation_mode=False,
- headless_mode=True,
- )
-
- with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog:
- mock_posthog.capture = MagicMock()
-
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- # Try different states
- await controller.set_agent_state_to(AgentState.RUNNING)
- await controller.set_agent_state_to(AgentState.PAUSED)
- await controller.set_agent_state_to(AgentState.STOPPED)
-
- # PostHog should not be called for non-FINISHED states
- mock_posthog.capture.assert_not_called()
-
- await controller.close()
-
-
-@pytest.mark.asyncio
-async def test_tracking_error_doesnt_break_agent(
- mock_agent_with_stats, mock_event_stream
-):
- """Test that tracking errors don't interrupt agent operation."""
- mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
-
- controller = AgentController(
- agent=mock_agent,
- event_stream=mock_event_stream,
- conversation_stats=conversation_stats,
- iteration_delta=10,
- sid='test-conversation-error',
- confirmation_mode=False,
- headless_mode=True,
- )
-
- with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog:
- mock_posthog.capture = MagicMock(side_effect=Exception('PostHog error'))
-
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- # Should not raise an exception
- await controller.set_agent_state_to(AgentState.FINISHED)
-
- # Agent state should still be FINISHED despite tracking error
- assert controller.state.agent_state == AgentState.FINISHED
-
- await controller.close()
diff --git a/tests/unit/experiments/test_experiment_manager.py b/tests/unit/experiments/test_experiment_manager.py
index e891b9893a36..c389423cf597 100644
--- a/tests/unit/experiments/test_experiment_manager.py
+++ b/tests/unit/experiments/test_experiment_manager.py
@@ -126,11 +126,8 @@ async def test_experiment_manager_called_with_correct_parameters_in_context__noo
self,
):
"""
- Use the real LiveStatusAppConversationService to build a StartConversationRequest,
- and verify ExperimentManagerImpl.run_agent_variant_tests__v1:
- - is called exactly once with the (user_id, generated conversation_id, agent)
- - returns the *same* agent instance (no copy/mutation)
- - does not tweak agent fields (LLM, system prompt, etc.)
+ Test that ExperimentManagerImpl.run_agent_variant_tests__v1 is called with correct parameters
+ and returns the same agent instance (no copy/mutation) when building a StartConversationRequest.
"""
# --- Arrange: fixed UUID to assert call parameters deterministically
fixed_conversation_id = UUID('00000000-0000-0000-0000-000000000001')
@@ -143,6 +140,7 @@ async def test_experiment_manager_called_with_correct_parameters_in_context__noo
mock_agent = Mock(spec=Agent)
mock_agent.llm = mock_llm
mock_agent.system_prompt_filename = 'default_system_prompt.j2'
+ mock_agent.model_copy = Mock(return_value=mock_agent)
# Minimal, real-ish user context used by the service
class DummyUserContext:
@@ -154,6 +152,8 @@ async def get_user_info(self):
llm_base_url=None,
llm_api_key=None,
confirmation_mode=False,
+ condenser_max_size=None,
+ security_analyzer=None,
)
async def get_secrets(self):
@@ -189,6 +189,7 @@ async def get_user_id(self):
sandbox_startup_poll_frequency=1,
httpx_client=httpx_client,
web_url=None,
+ openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@@ -202,24 +203,56 @@ async def get_user_id(self):
# Patch the pieces invoked by the service
with (
- patch(
- 'openhands.app_server.app_conversation.live_status_app_conversation_service.get_default_agent',
+ patch.object(
+ service,
+ '_setup_secrets_for_git_providers',
+ return_value={},
+ ),
+ patch.object(
+ service,
+ '_configure_llm_and_mcp',
+ return_value=(mock_llm, {}),
+ ),
+ patch.object(
+ service,
+ '_create_agent_with_context',
+ return_value=mock_agent,
+ ),
+ patch.object(
+ service,
+ '_load_skills_and_update_agent',
return_value=mock_agent,
),
patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.uuid4',
return_value=fixed_conversation_id,
),
+ patch(
+ 'openhands.app_server.app_conversation.live_status_app_conversation_service.ExperimentManagerImpl'
+ ) as mock_experiment_manager,
):
+ # Configure the experiment manager mock to return the same agent
+ mock_experiment_manager.run_agent_variant_tests__v1.return_value = (
+ mock_agent
+ )
+
# --- Act: build the start request
start_req = await service._build_start_conversation_request_for_user(
sandbox=sandbox,
initial_message=None,
+ system_message_suffix=None, # No additional system message suffix
git_provider=None, # Keep secrets path simple
working_dir='/tmp/project', # Arbitrary path
)
- # The agent in the StartConversationRequest is the *same* object we provided
+ # --- Assert: verify experiment manager was called with correct parameters
+ mock_experiment_manager.run_agent_variant_tests__v1.assert_called_once_with(
+ 'test_user_123', # user_id
+ fixed_conversation_id, # conversation_id
+ mock_agent, # agent (after model_copy with agent_context)
+ )
+
+ # The agent in the StartConversationRequest is the *same* object returned by experiment manager
assert start_req.agent is mock_agent
# No tweaks to agent fields by the experiment manager (noop)
diff --git a/tests/unit/llm/test_llm.py b/tests/unit/llm/test_llm.py
index dfdb4e05b475..b04425e631da 100644
--- a/tests/unit/llm/test_llm.py
+++ b/tests/unit/llm/test_llm.py
@@ -1255,6 +1255,25 @@ def test_opus_41_keeps_temperature_top_p(mock_completion):
assert 'top_p' not in call_kwargs
+@patch('openhands.llm.llm.litellm_completion')
+def test_opus_45_keeps_temperature_drops_top_p(mock_completion):
+ mock_completion.return_value = {
+ 'choices': [{'message': {'content': 'ok'}}],
+ }
+ config = LLMConfig(
+ model='anthropic/claude-opus-4-5-20251101',
+ api_key='k',
+ temperature=0.7,
+ top_p=0.9,
+ )
+ llm = LLM(config, service_id='svc')
+ llm.completion(messages=[{'role': 'user', 'content': 'hi'}])
+ call_kwargs = mock_completion.call_args[1]
+ assert call_kwargs.get('temperature') == 0.7
+ # Anthropic rejects both temperature and top_p together on Opus 4.5; we keep temperature and drop top_p
+ assert 'top_p' not in call_kwargs
+
+
@patch('openhands.llm.llm.litellm_completion')
def test_sonnet_4_keeps_temperature_drops_top_p(mock_completion):
mock_completion.return_value = {
diff --git a/tests/unit/llm/test_llm_fncall_converter.py b/tests/unit/llm/test_llm_fncall_converter.py
index ff4b7961efe6..b4270f89b023 100644
--- a/tests/unit/llm/test_llm_fncall_converter.py
+++ b/tests/unit/llm/test_llm_fncall_converter.py
@@ -701,6 +701,8 @@ def test_get_example_for_tools_all_tools():
""",
),
# Test case with indented code block to verify indentation is preserved
+ # Note: multiline parameter values should NOT have extra newlines before/after
+ # to prevent newline accumulation across multiple LLM response cycles
(
[
{
@@ -716,16 +718,12 @@ def test_get_example_for_tools_all_tools():
"""
str_replace
/test/file.py
-
-def example():
- pass
-
-
-def example():
+def example():
+ pass
+def example():
# This is indented
print("hello")
- return True
-
+ return True
""",
),
# Test case with list parameter value
diff --git a/tests/unit/memory/test_conversation_memory.py b/tests/unit/memory/test_conversation_memory.py
index abaa8d9a3d17..50fd48f49a35 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
diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py
index 98cf9b09ea98..79ff91fa7fd8 100644
--- a/tests/unit/server/data_models/test_conversation.py
+++ b/tests/unit/server/data_models/test_conversation.py
@@ -15,6 +15,9 @@
AppConversation,
AppConversationPage,
)
+from openhands.app_server.app_conversation.app_conversation_router import (
+ read_conversation_file,
+)
from openhands.app_server.app_conversation.live_status_app_conversation_service import (
LiveStatusAppConversationService,
)
@@ -27,6 +30,7 @@
SandboxInfo,
SandboxStatus,
)
+from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
from openhands.app_server.user.user_context import UserContext
from openhands.integrations.service_types import (
AuthenticationError,
@@ -37,6 +41,10 @@
)
from openhands.runtime.runtime_status import RuntimeStatus
from openhands.sdk.conversation.state import ConversationExecutionStatus
+from openhands.sdk.workspace.models import FileOperationResult
+from openhands.sdk.workspace.remote.async_remote_workspace import (
+ AsyncRemoteWorkspace,
+)
from openhands.server.data_models.conversation_info import ConversationInfo
from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
@@ -980,14 +988,6 @@ async def test_delete_conversation():
@pytest.mark.asyncio
async def test_delete_v1_conversation_success():
"""Test successful deletion of a V1 conversation."""
- from uuid import uuid4
-
- from openhands.app_server.app_conversation.app_conversation_models import (
- AppConversation,
- )
- from openhands.app_server.sandbox.sandbox_models import SandboxStatus
- from openhands.sdk.conversation.state import ConversationExecutionStatus
-
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
@@ -1060,8 +1060,6 @@ async def test_delete_v1_conversation_success():
@pytest.mark.asyncio
async def test_delete_v1_conversation_not_found():
"""Test deletion of a V1 conversation that doesn't exist."""
- from uuid import uuid4
-
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
@@ -1198,8 +1196,6 @@ async def test_delete_v1_conversation_invalid_uuid():
@pytest.mark.asyncio
async def test_delete_v1_conversation_service_error():
"""Test deletion when app conversation service raises an error."""
- from uuid import uuid4
-
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
@@ -1293,14 +1289,6 @@ async def test_delete_v1_conversation_service_error():
@pytest.mark.asyncio
async def test_delete_v1_conversation_with_agent_server():
"""Test V1 conversation deletion with agent server integration."""
- from uuid import uuid4
-
- from openhands.app_server.app_conversation.app_conversation_models import (
- AppConversation,
- )
- from openhands.app_server.sandbox.sandbox_models import SandboxStatus
- from openhands.sdk.conversation.state import ConversationExecutionStatus
-
conversation_uuid = uuid4()
conversation_id = str(conversation_uuid)
@@ -2178,6 +2166,7 @@ async def mock_get_app_conversation(conv_id):
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
+ openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@@ -2299,6 +2288,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations():
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
+ openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@@ -2450,6 +2440,7 @@ def mock_delete_info(conv_id: uuid.UUID):
sandbox_startup_poll_frequency=2,
httpx_client=mock_httpx_client,
web_url=None,
+ openhands_provider_base_url=None,
access_token_hard_timeout=None,
)
@@ -2475,3 +2466,919 @@ def mock_delete_info(conv_id: uuid.UUID):
assert sub2_uuid in delete_calls
assert parent_uuid in delete_calls
assert sub1_uuid not in delete_calls # Failed before deletion
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_success():
+ """Test successfully retrieving file content from conversation workspace."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+ file_content = '# Project Plan\n\n## Phase 1\n- Task 1\n- Task 2\n'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
+ ],
+ )
+
+ # Mock sandbox spec
+ mock_sandbox_spec = SandboxSpecInfo(
+ id='test-spec-id',
+ command=None,
+ working_dir='/workspace',
+ created_at=datetime.now(timezone.utc),
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ 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
+ )
+
+ # Mock tempfile and file operations
+ temp_file_path = '/tmp/test_file_12345'
+ mock_file_result = FileOperationResult(
+ success=True,
+ source_path=file_path,
+ destination_path=temp_file_path,
+ file_size=len(file_content.encode('utf-8')),
+ )
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace'
+ ) as mock_workspace_class:
+ mock_workspace = MagicMock(spec=AsyncRemoteWorkspace)
+ mock_workspace.file_download = AsyncMock(return_value=mock_file_result)
+ mock_workspace_class.return_value = mock_workspace
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile'
+ ) as mock_tempfile:
+ mock_temp_file = MagicMock()
+ mock_temp_file.name = temp_file_path
+ mock_tempfile.return_value.__enter__ = MagicMock(
+ return_value=mock_temp_file
+ )
+ mock_tempfile.return_value.__exit__ = MagicMock(return_value=None)
+
+ with patch('builtins.open', create=True) as mock_open:
+ mock_file_handle = MagicMock()
+ mock_file_handle.read.return_value = file_content.encode('utf-8')
+ mock_open.return_value.__enter__ = MagicMock(
+ return_value=mock_file_handle
+ )
+ mock_open.return_value.__exit__ = MagicMock(return_value=None)
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.os.unlink'
+ ) as mock_unlink:
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result
+ assert result == file_content
+
+ # Verify services were called correctly
+ mock_app_conversation_service.get_app_conversation.assert_called_once_with(
+ conversation_id
+ )
+ mock_sandbox_service.get_sandbox.assert_called_once_with(
+ 'test-sandbox-id'
+ )
+ mock_sandbox_spec_service.get_sandbox_spec.assert_called_once_with(
+ 'test-spec-id'
+ )
+
+ # Verify workspace was created and file_download was called
+ mock_workspace_class.assert_called_once()
+ mock_workspace.file_download.assert_called_once_with(
+ source_path=file_path,
+ destination_path=temp_file_path,
+ )
+
+ # Verify file was read and cleaned up
+ mock_open.assert_called_once_with(temp_file_path, 'rb')
+ mock_unlink.assert_called_once_with(temp_file_path)
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_different_path():
+ """Test successfully retrieving file content from a different file path."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/src/main.py'
+ file_content = 'def main():\n print("Hello, World!")\n'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
+ ],
+ )
+
+ # Mock sandbox spec
+ mock_sandbox_spec = SandboxSpecInfo(
+ id='test-spec-id',
+ command=None,
+ working_dir='/workspace',
+ created_at=datetime.now(timezone.utc),
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ 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
+ )
+
+ # Mock tempfile and file operations
+ temp_file_path = '/tmp/test_file_67890'
+ mock_file_result = FileOperationResult(
+ success=True,
+ source_path=file_path,
+ destination_path=temp_file_path,
+ file_size=len(file_content.encode('utf-8')),
+ )
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace'
+ ) as mock_workspace_class:
+ mock_workspace = MagicMock(spec=AsyncRemoteWorkspace)
+ mock_workspace.file_download = AsyncMock(return_value=mock_file_result)
+ mock_workspace_class.return_value = mock_workspace
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile'
+ ) as mock_tempfile:
+ mock_temp_file = MagicMock()
+ mock_temp_file.name = temp_file_path
+ mock_tempfile.return_value.__enter__ = MagicMock(
+ return_value=mock_temp_file
+ )
+ mock_tempfile.return_value.__exit__ = MagicMock(return_value=None)
+
+ with patch('builtins.open', create=True) as mock_open:
+ mock_file_handle = MagicMock()
+ mock_file_handle.read.return_value = file_content.encode('utf-8')
+ mock_open.return_value.__enter__ = MagicMock(
+ return_value=mock_file_handle
+ )
+ mock_open.return_value.__exit__ = MagicMock(return_value=None)
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.os.unlink'
+ ) as mock_unlink:
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result
+ assert result == file_content
+
+ # Verify workspace was created and file_download was called
+ mock_workspace_class.assert_called_once()
+ mock_workspace.file_download.assert_called_once_with(
+ source_path=file_path,
+ destination_path=temp_file_path,
+ )
+
+ # Verify file was read and cleaned up
+ mock_open.assert_called_once_with(temp_file_path, 'rb')
+ mock_unlink.assert_called_once_with(temp_file_path)
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_conversation_not_found():
+ """Test when conversation doesn't exist."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(return_value=None)
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_spec_service = MagicMock()
+
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result
+ assert result == ''
+
+ # Verify only conversation service was called
+ mock_app_conversation_service.get_app_conversation.assert_called_once_with(
+ conversation_id
+ )
+ mock_sandbox_service.get_sandbox.assert_not_called()
+ mock_sandbox_spec_service.get_sandbox_spec.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_sandbox_not_found():
+ """Test when sandbox doesn't exist."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_service.get_sandbox = AsyncMock(return_value=None)
+
+ mock_sandbox_spec_service = MagicMock()
+
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result
+ assert result == ''
+
+ # Verify services were called
+ mock_app_conversation_service.get_app_conversation.assert_called_once_with(
+ conversation_id
+ )
+ mock_sandbox_service.get_sandbox.assert_called_once_with('test-sandbox-id')
+ mock_sandbox_spec_service.get_sandbox_spec.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_sandbox_not_running():
+ """Test when sandbox is not in RUNNING status."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.PAUSED,
+ execution_status=None,
+ session_api_key=None,
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.PAUSED,
+ session_api_key=None,
+ exposed_urls=None,
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ mock_sandbox_service = MagicMock()
+ mock_sandbox_service.get_sandbox = AsyncMock(return_value=mock_sandbox)
+
+ mock_sandbox_spec_service = MagicMock()
+
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result
+ assert result == ''
+
+ # Verify services were called
+ mock_app_conversation_service.get_app_conversation.assert_called_once_with(
+ conversation_id
+ )
+ mock_sandbox_service.get_sandbox.assert_called_once_with('test-sandbox-id')
+ mock_sandbox_spec_service.get_sandbox_spec.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_sandbox_spec_not_found():
+ """Test when sandbox spec doesn't exist."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
+ ],
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ 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=None)
+
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result
+ assert result == ''
+
+ # Verify services were called
+ mock_app_conversation_service.get_app_conversation.assert_called_once_with(
+ conversation_id
+ )
+ mock_sandbox_service.get_sandbox.assert_called_once_with('test-sandbox-id')
+ mock_sandbox_spec_service.get_sandbox_spec.assert_called_once_with('test-spec-id')
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_no_exposed_urls():
+ """Test when sandbox has no exposed URLs."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox with no exposed URLs
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test-api-key',
+ exposed_urls=None,
+ )
+
+ # Mock sandbox spec
+ mock_sandbox_spec = SandboxSpecInfo(
+ id='test-spec-id',
+ command=None,
+ working_dir='/workspace',
+ created_at=datetime.now(timezone.utc),
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ 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
+ )
+
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result
+ assert result == ''
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_no_agent_server_url():
+ """Test when sandbox has exposed URLs but no AGENT_SERVER."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox with exposed URLs but no AGENT_SERVER
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name='OTHER_SERVICE', url='http://other:9000', port=9000)
+ ],
+ )
+
+ # Mock sandbox spec
+ mock_sandbox_spec = SandboxSpecInfo(
+ id='test-spec-id',
+ command=None,
+ working_dir='/workspace',
+ created_at=datetime.now(timezone.utc),
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ 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
+ )
+
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result
+ assert result == ''
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_file_not_found():
+ """Test when file doesn't exist."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
+ ],
+ )
+
+ # Mock sandbox spec
+ mock_sandbox_spec = SandboxSpecInfo(
+ id='test-spec-id',
+ command=None,
+ working_dir='/workspace',
+ created_at=datetime.now(timezone.utc),
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ 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
+ )
+
+ # Mock tempfile and file operations for file not found
+ temp_file_path = '/tmp/test_file_not_found'
+ mock_file_result = FileOperationResult(
+ success=False,
+ source_path=file_path,
+ destination_path=temp_file_path,
+ error=f'File not found: {file_path}',
+ )
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace'
+ ) as mock_workspace_class:
+ mock_workspace = MagicMock(spec=AsyncRemoteWorkspace)
+ mock_workspace.file_download = AsyncMock(return_value=mock_file_result)
+ mock_workspace_class.return_value = mock_workspace
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile'
+ ) as mock_tempfile:
+ mock_temp_file = MagicMock()
+ mock_temp_file.name = temp_file_path
+ mock_tempfile.return_value.__enter__ = MagicMock(
+ return_value=mock_temp_file
+ )
+ mock_tempfile.return_value.__exit__ = MagicMock(return_value=None)
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.os.unlink'
+ ) as mock_unlink:
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result (empty string when file_download fails)
+ assert result == ''
+
+ # Verify cleanup still happens
+ mock_unlink.assert_called_once_with(temp_file_path)
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_empty_file():
+ """Test when file exists but is empty."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
+ ],
+ )
+
+ # Mock sandbox spec
+ mock_sandbox_spec = SandboxSpecInfo(
+ id='test-spec-id',
+ command=None,
+ working_dir='/workspace',
+ created_at=datetime.now(timezone.utc),
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ 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
+ )
+
+ # Mock tempfile and file operations for empty file
+ temp_file_path = '/tmp/test_file_empty'
+ empty_content = ''
+ mock_file_result = FileOperationResult(
+ success=True,
+ source_path=file_path,
+ destination_path=temp_file_path,
+ file_size=0,
+ )
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace'
+ ) as mock_workspace_class:
+ mock_workspace = MagicMock(spec=AsyncRemoteWorkspace)
+ mock_workspace.file_download = AsyncMock(return_value=mock_file_result)
+ mock_workspace_class.return_value = mock_workspace
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile'
+ ) as mock_tempfile:
+ mock_temp_file = MagicMock()
+ mock_temp_file.name = temp_file_path
+ mock_tempfile.return_value.__enter__ = MagicMock(
+ return_value=mock_temp_file
+ )
+ mock_tempfile.return_value.__exit__ = MagicMock(return_value=None)
+
+ with patch('builtins.open', create=True) as mock_open:
+ mock_file_handle = MagicMock()
+ mock_file_handle.read.return_value = empty_content.encode('utf-8')
+ mock_open.return_value.__enter__ = MagicMock(
+ return_value=mock_file_handle
+ )
+ mock_open.return_value.__exit__ = MagicMock(return_value=None)
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.os.unlink'
+ ) as mock_unlink:
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result (empty string when file is empty)
+ assert result == ''
+
+ # Verify cleanup happens
+ mock_unlink.assert_called_once_with(temp_file_path)
+
+
+@pytest.mark.asyncio
+async def test_read_conversation_file_command_exception():
+ """Test when command execution raises an exception."""
+ conversation_id = uuid4()
+ file_path = '/workspace/project/PLAN.md'
+
+ # Mock conversation
+ mock_conversation = AppConversation(
+ id=conversation_id,
+ created_by_user_id='test_user',
+ sandbox_id='test-sandbox-id',
+ title='Test Conversation',
+ sandbox_status=SandboxStatus.RUNNING,
+ execution_status=ConversationExecutionStatus.RUNNING,
+ session_api_key='test-api-key',
+ selected_repository='test/repo',
+ selected_branch='main',
+ git_provider=ProviderType.GITHUB,
+ trigger=ConversationTrigger.GUI,
+ created_at=datetime.now(timezone.utc),
+ updated_at=datetime.now(timezone.utc),
+ )
+
+ # Mock sandbox
+ mock_sandbox = SandboxInfo(
+ id='test-sandbox-id',
+ created_by_user_id='test_user',
+ sandbox_spec_id='test-spec-id',
+ status=SandboxStatus.RUNNING,
+ session_api_key='test-api-key',
+ exposed_urls=[
+ ExposedUrl(name=AGENT_SERVER, url='http://agent:8000', port=8000)
+ ],
+ )
+
+ # Mock sandbox spec
+ mock_sandbox_spec = SandboxSpecInfo(
+ id='test-spec-id',
+ command=None,
+ working_dir='/workspace',
+ created_at=datetime.now(timezone.utc),
+ )
+
+ # Mock services
+ mock_app_conversation_service = MagicMock()
+ mock_app_conversation_service.get_app_conversation = AsyncMock(
+ return_value=mock_conversation
+ )
+
+ 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
+ )
+
+ # Mock tempfile and file operations for exception case
+ temp_file_path = '/tmp/test_file_exception'
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.AsyncRemoteWorkspace'
+ ) as mock_workspace_class:
+ mock_workspace = MagicMock(spec=AsyncRemoteWorkspace)
+ mock_workspace.file_download = AsyncMock(
+ side_effect=Exception('Connection timeout')
+ )
+ mock_workspace_class.return_value = mock_workspace
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.tempfile.NamedTemporaryFile'
+ ) as mock_tempfile:
+ mock_temp_file = MagicMock()
+ mock_temp_file.name = temp_file_path
+ mock_tempfile.return_value.__enter__ = MagicMock(
+ return_value=mock_temp_file
+ )
+ mock_tempfile.return_value.__exit__ = MagicMock(return_value=None)
+
+ with patch(
+ 'openhands.app_server.app_conversation.app_conversation_router.os.unlink'
+ ) as mock_unlink:
+ # Call the endpoint
+ result = await read_conversation_file(
+ conversation_id=conversation_id,
+ file_path=file_path,
+ app_conversation_service=mock_app_conversation_service,
+ sandbox_service=mock_sandbox_service,
+ sandbox_spec_service=mock_sandbox_spec_service,
+ )
+
+ # Verify result (empty string on exception)
+ assert result == ''
+
+ # Verify cleanup still happens even on exception
+ mock_unlink.assert_called_once_with(temp_file_path)
diff --git a/tests/unit/server/routes/test_mcp_routes.py b/tests/unit/server/routes/test_mcp_routes.py
index 1a55cc0a39e4..8677b8c85c37 100644
--- a/tests/unit/server/routes/test_mcp_routes.py
+++ b/tests/unit/server/routes/test_mcp_routes.py
@@ -1,3 +1,4 @@
+import warnings
from unittest.mock import AsyncMock, patch
import pytest
@@ -7,6 +8,38 @@
from openhands.server.types import AppMode
+def test_mcp_server_no_stateless_http_deprecation_warning():
+ """Test that mcp_server is created without stateless_http deprecation warning.
+
+ This test verifies the fix for the fastmcp deprecation warning:
+ 'Providing `stateless_http` when creating a server is deprecated.
+ Provide it when calling `run` or as a global setting instead.'
+
+ The fix moves the stateless_http parameter from FastMCP() constructor
+ to the http_app() method call.
+ """
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter('always')
+
+ # Import the mcp_server which triggers FastMCP creation
+ from openhands.server.routes.mcp import mcp_server
+
+ # Check that no deprecation warning about stateless_http was raised
+ stateless_http_warnings = [
+ warning
+ for warning in w
+ if issubclass(warning.category, DeprecationWarning)
+ and 'stateless_http' in str(warning.message)
+ ]
+
+ assert len(stateless_http_warnings) == 0, (
+ f'Unexpected stateless_http deprecation warning: {stateless_http_warnings}'
+ )
+
+ # Verify mcp_server was created successfully
+ assert mcp_server is not None
+
+
@pytest.mark.asyncio
async def test_get_conversation_link_non_saas_mode():
"""Test get_conversation_link in non-SAAS mode."""
diff --git a/tests/unit/server/routes/test_settings_api.py b/tests/unit/server/routes/test_settings_api.py
index f01b1d77df3a..6ea408038810 100644
--- a/tests/unit/server/routes/test_settings_api.py
+++ b/tests/unit/server/routes/test_settings_api.py
@@ -46,6 +46,9 @@ async def get_secrets_store(self) -> SecretsStore | None:
async def get_secrets(self) -> Secrets | None:
return None
+ async def get_mcp_api_key(self) -> str | None:
+ return None
+
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
return MockUserAuth()
diff --git a/tests/unit/server/routes/test_settings_store_functions.py b/tests/unit/server/routes/test_settings_store_functions.py
index 6296a8e354cf..c6eb6f5628c8 100644
--- a/tests/unit/server/routes/test_settings_store_functions.py
+++ b/tests/unit/server/routes/test_settings_store_functions.py
@@ -2,13 +2,16 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
+from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.server.routes.secrets import (
- app,
+ app as secrets_router,
+)
+from openhands.server.routes.secrets import (
check_provider_tokens,
)
from openhands.server.routes.settings import store_llm_settings
@@ -27,7 +30,12 @@ async def get_settings_store(request):
@pytest.fixture
def test_client():
- # Create a test client
+ # Create a test client with a FastAPI app that includes the secrets router
+ # This is necessary because TestClient with APIRouter directly doesn't set up
+ # the full middleware stack in newer FastAPI versions (0.118.0+)
+ test_app = FastAPI()
+ test_app.include_router(secrets_router)
+
with (
patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False),
patch('openhands.server.dependencies._SESSION_API_KEY', None),
@@ -36,7 +44,7 @@ def test_client():
AsyncMock(return_value=''),
),
):
- client = TestClient(app)
+ client = TestClient(test_app)
yield client
diff --git a/tests/unit/server/session/test_conversation_init_data.py b/tests/unit/server/session/test_conversation_init_data.py
new file mode 100644
index 000000000000..3c5d7d97f792
--- /dev/null
+++ b/tests/unit/server/session/test_conversation_init_data.py
@@ -0,0 +1,272 @@
+"""Unit tests for ConversationInitData - specifically testing the field validator.
+
+These tests verify that the immutable_validator correctly converts dict to MappingProxyType
+for git_provider_tokens and custom_secrets fields, ensuring type safety.
+"""
+
+from types import MappingProxyType
+
+import pytest
+from pydantic import SecretStr
+
+from openhands.integrations.provider import CustomSecret, ProviderToken, ProviderType
+from openhands.server.session.conversation_init_data import ConversationInitData
+from openhands.storage.data_models.settings import Settings
+
+
+@pytest.fixture
+def base_settings():
+ """Create a base Settings object with minimal required fields."""
+ return Settings(
+ language='en',
+ agent='CodeActAgent',
+ max_iterations=100,
+ llm_model='anthropic/claude-3-5-sonnet-20241022',
+ llm_api_key=SecretStr('test_api_key_12345'),
+ llm_base_url=None,
+ )
+
+
+class TestConversationInitDataValidator:
+ """Test suite for ConversationInitData field validator."""
+
+ def test_git_provider_tokens_dict_converted_to_mappingproxy(self, base_settings):
+ """Test that dict passed as git_provider_tokens is converted to MappingProxyType."""
+ # Create provider tokens as a regular dict
+ provider_tokens_dict = {
+ ProviderType.GITHUB: ProviderToken(
+ token=SecretStr('ghp_test_token_123'), user_id='test_user'
+ ),
+ ProviderType.GITLAB: ProviderToken(
+ token=SecretStr('glpat_test_token_456'), user_id='test_user_2'
+ ),
+ }
+
+ # Create ConversationInitData with dict
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ git_provider_tokens=provider_tokens_dict,
+ )
+
+ # Verify it's now a MappingProxyType
+ assert isinstance(init_data.git_provider_tokens, MappingProxyType)
+ assert ProviderType.GITHUB in init_data.git_provider_tokens
+ assert ProviderType.GITLAB in init_data.git_provider_tokens
+ assert (
+ init_data.git_provider_tokens[ProviderType.GITHUB].token.get_secret_value()
+ == 'ghp_test_token_123'
+ )
+
+ def test_git_provider_tokens_mappingproxy_preserved(self, base_settings):
+ """Test that MappingProxyType passed as git_provider_tokens is converted to MappingProxyType."""
+ # Create provider tokens as MappingProxyType
+ provider_token = ProviderToken(
+ token=SecretStr('ghp_test_token_789'), user_id='test_user_3'
+ )
+ provider_tokens_proxy = MappingProxyType({ProviderType.GITHUB: provider_token})
+
+ # Create ConversationInitData with MappingProxyType
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ git_provider_tokens=provider_tokens_proxy,
+ )
+
+ # Verify it's a MappingProxyType (Pydantic may create a new one, but type is preserved)
+ assert isinstance(init_data.git_provider_tokens, MappingProxyType)
+ assert (
+ init_data.git_provider_tokens[ProviderType.GITHUB].token.get_secret_value()
+ == 'ghp_test_token_789'
+ )
+ assert (
+ init_data.git_provider_tokens[ProviderType.GITHUB].user_id == 'test_user_3'
+ )
+
+ def test_git_provider_tokens_none_preserved(self, base_settings):
+ """Test that None passed as git_provider_tokens is preserved."""
+ # Create ConversationInitData with None
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ git_provider_tokens=None,
+ )
+
+ # Verify it's still None
+ assert init_data.git_provider_tokens is None
+
+ def test_custom_secrets_dict_converted_to_mappingproxy(self, base_settings):
+ """Test that dict passed as custom_secrets is converted to MappingProxyType."""
+ # Create custom secrets as a regular dict
+ custom_secrets_dict = {
+ 'API_KEY': CustomSecret(
+ secret=SecretStr('api_key_123'), description='API key for service'
+ ),
+ 'DATABASE_URL': CustomSecret(
+ secret=SecretStr('postgres://localhost'), description='Database URL'
+ ),
+ }
+
+ # Create ConversationInitData with dict
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ custom_secrets=custom_secrets_dict,
+ )
+
+ # Verify it's now a MappingProxyType
+ assert isinstance(init_data.custom_secrets, MappingProxyType)
+ assert 'API_KEY' in init_data.custom_secrets
+ assert 'DATABASE_URL' in init_data.custom_secrets
+ assert (
+ init_data.custom_secrets['API_KEY'].secret.get_secret_value()
+ == 'api_key_123'
+ )
+
+ def test_custom_secrets_mappingproxy_preserved(self, base_settings):
+ """Test that MappingProxyType passed as custom_secrets is converted to MappingProxyType."""
+ # Create custom secrets as MappingProxyType
+ custom_secret = CustomSecret(
+ secret=SecretStr('api_key_456'), description='API key'
+ )
+ custom_secrets_proxy = MappingProxyType({'API_KEY': custom_secret})
+
+ # Create ConversationInitData with MappingProxyType
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ custom_secrets=custom_secrets_proxy,
+ )
+
+ # Verify it's a MappingProxyType (Pydantic may create a new one, but type is preserved)
+ assert isinstance(init_data.custom_secrets, MappingProxyType)
+ assert (
+ init_data.custom_secrets['API_KEY'].secret.get_secret_value()
+ == 'api_key_456'
+ )
+ assert init_data.custom_secrets['API_KEY'].description == 'API key'
+
+ def test_custom_secrets_none_preserved(self, base_settings):
+ """Test that None passed as custom_secrets is preserved."""
+ # Create ConversationInitData with None
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ custom_secrets=None,
+ )
+
+ # Verify it's still None
+ assert init_data.custom_secrets is None
+
+ def test_both_fields_dict_converted(self, base_settings):
+ """Test that both fields are converted when passed as dicts."""
+ provider_tokens_dict = {
+ ProviderType.GITHUB: ProviderToken(
+ token=SecretStr('ghp_token'), user_id='user1'
+ )
+ }
+ custom_secrets_dict = {
+ 'SECRET': CustomSecret(
+ secret=SecretStr('secret_value'), description='A secret'
+ )
+ }
+
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ git_provider_tokens=provider_tokens_dict,
+ custom_secrets=custom_secrets_dict,
+ )
+
+ # Both should be MappingProxyType
+ assert isinstance(init_data.git_provider_tokens, MappingProxyType)
+ assert isinstance(init_data.custom_secrets, MappingProxyType)
+
+ def test_empty_dict_converted_to_mappingproxy(self, base_settings):
+ """Test that empty dict is converted to empty MappingProxyType."""
+ # Create ConversationInitData with empty dicts
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ git_provider_tokens={},
+ custom_secrets={},
+ )
+
+ # Both should be MappingProxyType (even if empty)
+ assert isinstance(init_data.git_provider_tokens, MappingProxyType)
+ assert isinstance(init_data.custom_secrets, MappingProxyType)
+ assert len(init_data.git_provider_tokens) == 0
+ assert len(init_data.custom_secrets) == 0
+
+ def test_validator_prevents_mutation(self, base_settings):
+ """Test that MappingProxyType prevents mutation of the underlying data."""
+ provider_tokens_dict = {
+ ProviderType.GITHUB: ProviderToken(
+ token=SecretStr('ghp_token'), user_id='user1'
+ )
+ }
+
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ git_provider_tokens=provider_tokens_dict,
+ )
+
+ # Verify it's a MappingProxyType (which is immutable)
+ assert isinstance(init_data.git_provider_tokens, MappingProxyType)
+
+ # Verify that attempting to modify would raise (MappingProxyType is read-only)
+ with pytest.raises(TypeError):
+ # MappingProxyType doesn't support item assignment
+ init_data.git_provider_tokens[ProviderType.GITLAB] = ProviderToken(
+ token=SecretStr('new_token')
+ )
+
+ def test_validator_with_settings_dict_unpacking(self, base_settings):
+ """Test validator works when creating from unpacked settings dict.
+
+ This simulates the real-world usage in conversation_service.py where
+ session_init_args is created from settings.__dict__.
+ """
+ # Simulate the pattern used in conversation_service.py
+ session_init_args = {**base_settings.__dict__}
+ session_init_args['git_provider_tokens'] = {
+ ProviderType.GITHUB: ProviderToken(
+ token=SecretStr('ghp_from_dict'), user_id='user_from_dict'
+ )
+ }
+
+ # Create ConversationInitData from unpacked dict
+ init_data = ConversationInitData(**session_init_args)
+
+ # Verify it's converted to MappingProxyType
+ assert isinstance(init_data.git_provider_tokens, MappingProxyType)
+ assert (
+ init_data.git_provider_tokens[ProviderType.GITHUB].token.get_secret_value()
+ == 'ghp_from_dict'
+ )
+
+ def test_validator_with_mixed_types(self, base_settings):
+ """Test validator with one field as dict and one as MappingProxyType."""
+ # git_provider_tokens as dict
+ provider_tokens_dict = {
+ ProviderType.GITHUB: ProviderToken(
+ token=SecretStr('ghp_dict_token'), user_id='user_dict'
+ )
+ }
+
+ # custom_secrets as MappingProxyType
+ custom_secret = CustomSecret(
+ secret=SecretStr('secret_proxy'), description='From proxy'
+ )
+ custom_secrets_proxy = MappingProxyType({'SECRET': custom_secret})
+
+ init_data = ConversationInitData(
+ **base_settings.__dict__,
+ git_provider_tokens=provider_tokens_dict,
+ custom_secrets=custom_secrets_proxy,
+ )
+
+ # Both should be MappingProxyType
+ assert isinstance(init_data.git_provider_tokens, MappingProxyType)
+ assert isinstance(init_data.custom_secrets, MappingProxyType)
+ # Verify the content is preserved (Pydantic may create new MappingProxyType instances)
+ assert (
+ init_data.git_provider_tokens[ProviderType.GITHUB].token.get_secret_value()
+ == 'ghp_dict_token'
+ )
+ assert (
+ init_data.custom_secrets['SECRET'].secret.get_secret_value()
+ == 'secret_proxy'
+ )
diff --git a/tests/unit/server/test_openapi_schema_generation.py b/tests/unit/server/test_openapi_schema_generation.py
index 2aa798e1e650..eb967e496c68 100644
--- a/tests/unit/server/test_openapi_schema_generation.py
+++ b/tests/unit/server/test_openapi_schema_generation.py
@@ -46,6 +46,9 @@ async def get_secrets_store(self) -> SecretsStore | None:
async def get_secrets(self) -> Secrets | None:
return None
+ async def get_mcp_api_key(self) -> str | None:
+ return None
+
@classmethod
async def get_instance(cls, request: Request) -> UserAuth:
return MockUserAuth()
diff --git a/tests/unit/utils/test_posthog_tracker.py b/tests/unit/utils/test_posthog_tracker.py
deleted file mode 100644
index cec0eff0ccc0..000000000000
--- a/tests/unit/utils/test_posthog_tracker.py
+++ /dev/null
@@ -1,356 +0,0 @@
-"""Unit tests for PostHog tracking utilities."""
-
-from unittest.mock import MagicMock, patch
-
-import pytest
-
-from openhands.utils.posthog_tracker import (
- alias_user_identities,
- track_agent_task_completed,
- track_credit_limit_reached,
- track_credits_purchased,
- track_user_signup_completed,
-)
-
-
-@pytest.fixture
-def mock_posthog():
- """Mock the posthog module."""
- with patch('openhands.utils.posthog_tracker.posthog') as mock_ph:
- mock_ph.capture = MagicMock()
- yield mock_ph
-
-
-def test_track_agent_task_completed_with_user_id(mock_posthog):
- """Test tracking agent task completion with user ID."""
- # Initialize posthog manually in the test
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- track_agent_task_completed(
- conversation_id='test-conversation-123',
- user_id='user-456',
- app_mode='saas',
- )
-
- mock_posthog.capture.assert_called_once_with(
- distinct_id='user-456',
- event='agent_task_completed',
- properties={
- 'conversation_id': 'test-conversation-123',
- 'user_id': 'user-456',
- 'app_mode': 'saas',
- },
- )
-
-
-def test_track_agent_task_completed_without_user_id(mock_posthog):
- """Test tracking agent task completion without user ID (anonymous)."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- track_agent_task_completed(
- conversation_id='test-conversation-789',
- user_id=None,
- app_mode='oss',
- )
-
- mock_posthog.capture.assert_called_once_with(
- distinct_id='conversation_test-conversation-789',
- event='agent_task_completed',
- properties={
- 'conversation_id': 'test-conversation-789',
- 'user_id': None,
- 'app_mode': 'oss',
- },
- )
-
-
-def test_track_agent_task_completed_default_app_mode(mock_posthog):
- """Test tracking with default app_mode."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- track_agent_task_completed(
- conversation_id='test-conversation-999',
- user_id='user-111',
- )
-
- mock_posthog.capture.assert_called_once_with(
- distinct_id='user-111',
- event='agent_task_completed',
- properties={
- 'conversation_id': 'test-conversation-999',
- 'user_id': 'user-111',
- 'app_mode': 'unknown',
- },
- )
-
-
-def test_track_agent_task_completed_handles_errors(mock_posthog):
- """Test that tracking errors are handled gracefully."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
- mock_posthog.capture.side_effect = Exception('PostHog API error')
-
- # Should not raise an exception
- track_agent_task_completed(
- conversation_id='test-conversation-error',
- user_id='user-error',
- app_mode='saas',
- )
-
-
-def test_track_agent_task_completed_when_posthog_not_installed():
- """Test tracking when posthog is not installed."""
- import openhands.utils.posthog_tracker as tracker
-
- # Simulate posthog not being installed
- tracker.posthog = None
-
- # Should not raise an exception
- track_agent_task_completed(
- conversation_id='test-conversation-no-ph',
- user_id='user-no-ph',
- app_mode='oss',
- )
-
-
-def test_track_user_signup_completed(mock_posthog):
- """Test tracking user signup completion."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- track_user_signup_completed(
- user_id='test-user-123',
- signup_timestamp='2025-01-15T10:30:00Z',
- )
-
- mock_posthog.capture.assert_called_once_with(
- distinct_id='test-user-123',
- event='user_signup_completed',
- properties={
- 'user_id': 'test-user-123',
- 'signup_timestamp': '2025-01-15T10:30:00Z',
- },
- )
-
-
-def test_track_user_signup_completed_handles_errors(mock_posthog):
- """Test that user signup tracking errors are handled gracefully."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
- mock_posthog.capture.side_effect = Exception('PostHog API error')
-
- # Should not raise an exception
- track_user_signup_completed(
- user_id='test-user-error',
- signup_timestamp='2025-01-15T12:00:00Z',
- )
-
-
-def test_track_user_signup_completed_when_posthog_not_installed():
- """Test user signup tracking when posthog is not installed."""
- import openhands.utils.posthog_tracker as tracker
-
- # Simulate posthog not being installed
- tracker.posthog = None
-
- # Should not raise an exception
- track_user_signup_completed(
- user_id='test-user-no-ph',
- signup_timestamp='2025-01-15T13:00:00Z',
- )
-
-
-def test_track_credit_limit_reached_with_user_id(mock_posthog):
- """Test tracking credit limit reached with user ID."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- track_credit_limit_reached(
- conversation_id='test-conversation-456',
- user_id='user-789',
- current_budget=10.50,
- max_budget=10.00,
- )
-
- mock_posthog.capture.assert_called_once_with(
- distinct_id='user-789',
- event='credit_limit_reached',
- properties={
- 'conversation_id': 'test-conversation-456',
- 'user_id': 'user-789',
- 'current_budget': 10.50,
- 'max_budget': 10.00,
- },
- )
-
-
-def test_track_credit_limit_reached_without_user_id(mock_posthog):
- """Test tracking credit limit reached without user ID (anonymous)."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- track_credit_limit_reached(
- conversation_id='test-conversation-999',
- user_id=None,
- current_budget=5.25,
- max_budget=5.00,
- )
-
- mock_posthog.capture.assert_called_once_with(
- distinct_id='conversation_test-conversation-999',
- event='credit_limit_reached',
- properties={
- 'conversation_id': 'test-conversation-999',
- 'user_id': None,
- 'current_budget': 5.25,
- 'max_budget': 5.00,
- },
- )
-
-
-def test_track_credit_limit_reached_handles_errors(mock_posthog):
- """Test that credit limit tracking errors are handled gracefully."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
- mock_posthog.capture.side_effect = Exception('PostHog API error')
-
- # Should not raise an exception
- track_credit_limit_reached(
- conversation_id='test-conversation-error',
- user_id='user-error',
- current_budget=15.00,
- max_budget=10.00,
- )
-
-
-def test_track_credit_limit_reached_when_posthog_not_installed():
- """Test credit limit tracking when posthog is not installed."""
- import openhands.utils.posthog_tracker as tracker
-
- # Simulate posthog not being installed
- tracker.posthog = None
-
- # Should not raise an exception
- track_credit_limit_reached(
- conversation_id='test-conversation-no-ph',
- user_id='user-no-ph',
- current_budget=8.00,
- max_budget=5.00,
- )
-
-
-def test_track_credits_purchased(mock_posthog):
- """Test tracking credits purchased."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
-
- track_credits_purchased(
- user_id='test-user-999',
- amount_usd=50.00,
- credits_added=50.00,
- stripe_session_id='cs_test_abc123',
- )
-
- mock_posthog.capture.assert_called_once_with(
- distinct_id='test-user-999',
- event='credits_purchased',
- properties={
- 'user_id': 'test-user-999',
- 'amount_usd': 50.00,
- 'credits_added': 50.00,
- 'stripe_session_id': 'cs_test_abc123',
- },
- )
-
-
-def test_track_credits_purchased_handles_errors(mock_posthog):
- """Test that credits purchased tracking errors are handled gracefully."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
- mock_posthog.capture.side_effect = Exception('PostHog API error')
-
- # Should not raise an exception
- track_credits_purchased(
- user_id='test-user-error',
- amount_usd=100.00,
- credits_added=100.00,
- stripe_session_id='cs_test_error',
- )
-
-
-def test_track_credits_purchased_when_posthog_not_installed():
- """Test credits purchased tracking when posthog is not installed."""
- import openhands.utils.posthog_tracker as tracker
-
- # Simulate posthog not being installed
- tracker.posthog = None
-
- # Should not raise an exception
- track_credits_purchased(
- user_id='test-user-no-ph',
- amount_usd=25.00,
- credits_added=25.00,
- stripe_session_id='cs_test_no_ph',
- )
-
-
-def test_alias_user_identities(mock_posthog):
- """Test aliasing user identities.
-
- Verifies that posthog.alias(previous_id, distinct_id) is called correctly
- where git_login is the previous_id and keycloak_user_id is the distinct_id.
- """
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
- mock_posthog.alias = MagicMock()
-
- alias_user_identities(
- keycloak_user_id='keycloak-123',
- git_login='git-user',
- )
-
- # Verify: posthog.alias(previous_id='git-user', distinct_id='keycloak-123')
- mock_posthog.alias.assert_called_once_with('git-user', 'keycloak-123')
-
-
-def test_alias_user_identities_handles_errors(mock_posthog):
- """Test that aliasing errors are handled gracefully."""
- import openhands.utils.posthog_tracker as tracker
-
- tracker.posthog = mock_posthog
- mock_posthog.alias = MagicMock(side_effect=Exception('PostHog API error'))
-
- # Should not raise an exception
- alias_user_identities(
- keycloak_user_id='keycloak-error',
- git_login='git-error',
- )
-
-
-def test_alias_user_identities_when_posthog_not_installed():
- """Test aliasing when posthog is not installed."""
- import openhands.utils.posthog_tracker as tracker
-
- # Simulate posthog not being installed
- tracker.posthog = None
-
- # Should not raise an exception
- alias_user_identities(
- keycloak_user_id='keycloak-no-ph',
- git_login='git-no-ph',
- )
diff --git a/trigger_commit.txt b/trigger_commit.txt
deleted file mode 100644
index 402f8bb0e55f..000000000000
--- a/trigger_commit.txt
+++ /dev/null
@@ -1 +0,0 @@
-# Trigger E2E test run