mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
feat: Add configurable sandbox reuse with grouping strategies (#11922)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
"""Add sandbox_grouping_strategy column to user, org, and user_settings tables.
|
||||
|
||||
Revision ID: 100
|
||||
Revises: 099
|
||||
Create Date: 2025-03-12
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '100'
|
||||
down_revision = '099'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user_settings', 'sandbox_grouping_strategy')
|
||||
op.drop_column('org', 'sandbox_grouping_strategy')
|
||||
op.drop_column('user', 'sandbox_grouping_strategy')
|
||||
@@ -47,6 +47,7 @@ class Org(Base): # type: ignore
|
||||
conversation_expiration = Column(Integer, nullable=True)
|
||||
condenser_max_size = Column(Integer, nullable=True)
|
||||
byor_export_enabled = Column(Boolean, nullable=False, default=False)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
|
||||
@@ -117,6 +117,9 @@ class SaasSettingsStore(SettingsStore):
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
# Apply default if sandbox_grouping_strategy is None in the database
|
||||
if kwargs.get('sandbox_grouping_strategy') is None:
|
||||
kwargs.pop('sandbox_grouping_strategy', None)
|
||||
|
||||
settings = Settings(**kwargs)
|
||||
return settings
|
||||
|
||||
@@ -33,6 +33,7 @@ class User(Base): # type: ignore
|
||||
email_verified = Column(Boolean, nullable=True)
|
||||
git_user_name = Column(String, nullable=True)
|
||||
git_user_email = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
role = relationship('Role', back_populates='users')
|
||||
|
||||
@@ -27,6 +27,7 @@ class UserSettings(Base): # type: ignore
|
||||
)
|
||||
sandbox_base_container_image = Column(String, nullable=True)
|
||||
sandbox_runtime_container_image = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
user_version = Column(Integer, nullable=False, default=0)
|
||||
accepted_tos = Column(DateTime, nullable=True)
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
|
||||
@@ -2,27 +2,29 @@ import { describe, it, expect } from "vitest";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
|
||||
describe("getGitPath", () => {
|
||||
it("should return /workspace/project when no repository is selected", () => {
|
||||
expect(getGitPath(null)).toBe("/workspace/project");
|
||||
expect(getGitPath(undefined)).toBe("/workspace/project");
|
||||
const conversationId = "abc123";
|
||||
|
||||
it("should return /workspace/project/{conversationId} when no repository is selected", () => {
|
||||
expect(getGitPath(conversationId, null)).toBe(`/workspace/project/${conversationId}`);
|
||||
expect(getGitPath(conversationId, undefined)).toBe(`/workspace/project/${conversationId}`);
|
||||
});
|
||||
|
||||
it("should handle standard owner/repo format (GitHub)", () => {
|
||||
expect(getGitPath("OpenHands/OpenHands")).toBe("/workspace/project/OpenHands");
|
||||
expect(getGitPath("facebook/react")).toBe("/workspace/project/react");
|
||||
expect(getGitPath(conversationId, "OpenHands/OpenHands")).toBe(`/workspace/project/${conversationId}/OpenHands`);
|
||||
expect(getGitPath(conversationId, "facebook/react")).toBe(`/workspace/project/${conversationId}/react`);
|
||||
});
|
||||
|
||||
it("should handle nested group paths (GitLab)", () => {
|
||||
expect(getGitPath("modernhealth/frontend-guild/pan")).toBe("/workspace/project/pan");
|
||||
expect(getGitPath("group/subgroup/repo")).toBe("/workspace/project/repo");
|
||||
expect(getGitPath("a/b/c/d/repo")).toBe("/workspace/project/repo");
|
||||
expect(getGitPath(conversationId, "modernhealth/frontend-guild/pan")).toBe(`/workspace/project/${conversationId}/pan`);
|
||||
expect(getGitPath(conversationId, "group/subgroup/repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
expect(getGitPath(conversationId, "a/b/c/d/repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
});
|
||||
|
||||
it("should handle single segment paths", () => {
|
||||
expect(getGitPath("repo")).toBe("/workspace/project/repo");
|
||||
expect(getGitPath(conversationId, "repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(getGitPath("")).toBe("/workspace/project");
|
||||
expect(getGitPath(conversationId, "")).toBe(`/workspace/project/${conversationId}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,9 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email,
|
||||
is_new_user: false,
|
||||
v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled,
|
||||
sandbox_grouping_strategy:
|
||||
settings.sandbox_grouping_strategy ??
|
||||
DEFAULT_SETTINGS.sandbox_grouping_strategy,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useUnifiedGetGitChanges = () => {
|
||||
|
||||
// Calculate git path based on selected repository
|
||||
const gitPath = React.useMemo(
|
||||
() => getGitPath(selectedRepository),
|
||||
() => getGitPath(conversationId, selectedRepository),
|
||||
[selectedRepository],
|
||||
);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
|
||||
const absoluteFilePath = React.useMemo(() => {
|
||||
if (!isV1Conversation) return config.filePath;
|
||||
|
||||
const gitPath = getGitPath(selectedRepository);
|
||||
const gitPath = getGitPath(conversationId, selectedRepository);
|
||||
return `${gitPath}/${config.filePath}`;
|
||||
}, [isV1Conversation, selectedRepository, config.filePath]);
|
||||
|
||||
|
||||
@@ -175,6 +175,12 @@ export enum I18nKey {
|
||||
SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION",
|
||||
SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS",
|
||||
SETTINGS$SOLVABILITY_ANALYSIS = "SETTINGS$SOLVABILITY_ANALYSIS",
|
||||
SETTINGS$SANDBOX_GROUPING_STRATEGY = "SETTINGS$SANDBOX_GROUPING_STRATEGY",
|
||||
SETTINGS$SANDBOX_GROUPING_NO_GROUPING = "SETTINGS$SANDBOX_GROUPING_NO_GROUPING",
|
||||
SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST = "SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST",
|
||||
SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED = "SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED",
|
||||
SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS = "SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS",
|
||||
SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY = "SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY",
|
||||
SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY",
|
||||
SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL",
|
||||
SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS",
|
||||
|
||||
@@ -2799,6 +2799,102 @@
|
||||
"tr": "Çözünürlük Analizini Etkinleştir",
|
||||
"uk": "Увімкнути аналіз розв'язності"
|
||||
},
|
||||
"SETTINGS$SANDBOX_GROUPING_STRATEGY": {
|
||||
"en": "Sandbox Grouping Strategy",
|
||||
"ja": "サンドボックスグループ化戦略",
|
||||
"zh-CN": "沙盒分组策略",
|
||||
"zh-TW": "沙盒分組策略",
|
||||
"ko-KR": "샌드박스 그룹화 전략",
|
||||
"de": "Sandbox-Gruppierungsstrategie",
|
||||
"no": "Sandkasse-grupperingsstrategi",
|
||||
"it": "Strategia di raggruppamento sandbox",
|
||||
"pt": "Estratégia de agrupamento de sandbox",
|
||||
"es": "Estrategia de agrupación de sandbox",
|
||||
"ar": "استراتيجية تجميع صندوق الرمل",
|
||||
"fr": "Stratégie de regroupement sandbox",
|
||||
"tr": "Sandbox Gruplama Stratejisi",
|
||||
"uk": "Стратегія групування пісочниці"
|
||||
},
|
||||
"SETTINGS$SANDBOX_GROUPING_NO_GROUPING": {
|
||||
"en": "No Grouping (new sandbox per conversation)",
|
||||
"ja": "グループ化なし (会話ごとに新しいサンドボックス)",
|
||||
"zh-CN": "不分组 (每个对话使用新沙盒)",
|
||||
"zh-TW": "不分組 (每個對話使用新沙盒)",
|
||||
"ko-KR": "그룹화 없음 (대화마다 새 샌드박스)",
|
||||
"de": "Keine Gruppierung (neue Sandbox pro Gespräch)",
|
||||
"no": "Ingen gruppering (ny sandkasse per samtale)",
|
||||
"it": "Nessun raggruppamento (nuova sandbox per conversazione)",
|
||||
"pt": "Sem agrupamento (novo sandbox por conversa)",
|
||||
"es": "Sin agrupación (nuevo sandbox por conversación)",
|
||||
"ar": "بدون تجميع (صندوق رمل جديد لكل محادثة)",
|
||||
"fr": "Pas de regroupement (nouveau sandbox par conversation)",
|
||||
"tr": "Gruplama Yok (konuşma başına yeni sandbox)",
|
||||
"uk": "Без групування (нова пісочниця для кожної розмови)"
|
||||
},
|
||||
"SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST": {
|
||||
"en": "Group by Newest (add to most recent sandbox)",
|
||||
"ja": "最新でグループ化 (最新のサンドボックスに追加)",
|
||||
"zh-CN": "按最新分组 (添加到最近的沙盒)",
|
||||
"zh-TW": "按最新分組 (添加到最近的沙盒)",
|
||||
"ko-KR": "최신으로 그룹화 (가장 최근 샌드박스에 추가)",
|
||||
"de": "Nach neuester gruppieren (zur neuesten Sandbox hinzufügen)",
|
||||
"no": "Grupper etter nyeste (legg til i nyeste sandkasse)",
|
||||
"it": "Raggruppa per più recente (aggiungi alla sandbox più recente)",
|
||||
"pt": "Agrupar por mais recente (adicionar ao sandbox mais recente)",
|
||||
"es": "Agrupar por más reciente (agregar al sandbox más reciente)",
|
||||
"ar": "التجميع حسب الأحدث (إضافة إلى أحدث صندوق رمل)",
|
||||
"fr": "Regrouper par le plus récent (ajouter au sandbox le plus récent)",
|
||||
"tr": "En Yeniye Göre Grupla (en yeni sandbox'a ekle)",
|
||||
"uk": "Групувати за найновішим (додати до найновішої пісочниці)"
|
||||
},
|
||||
"SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED": {
|
||||
"en": "Least Recently Used (add to oldest sandbox)",
|
||||
"ja": "最も古い (最も古いサンドボックスに追加)",
|
||||
"zh-CN": "最近最少使用 (添加到最旧的沙盒)",
|
||||
"zh-TW": "最近最少使用 (添加到最舊的沙盒)",
|
||||
"ko-KR": "가장 오래된 것 (가장 오래된 샌드박스에 추가)",
|
||||
"de": "Am ältesten (zur ältesten Sandbox hinzufügen)",
|
||||
"no": "Eldst (legg til i eldste sandkasse)",
|
||||
"it": "Meno usato di recente (aggiungi alla sandbox più vecchia)",
|
||||
"pt": "Menos usado recentemente (adicionar ao sandbox mais antigo)",
|
||||
"es": "Menos usado recientemente (agregar al sandbox más antiguo)",
|
||||
"ar": "الأقل استخدامًا مؤخرًا (إضافة إلى أقدم صندوق رمل)",
|
||||
"fr": "Le moins récemment utilisé (ajouter au sandbox le plus ancien)",
|
||||
"tr": "En Az Kullanılan (en eski sandbox'a ekle)",
|
||||
"uk": "Найменш нещодавно використана (додати до найстаршої пісочниці)"
|
||||
},
|
||||
"SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS": {
|
||||
"en": "Fewest Conversations (add to least busy sandbox)",
|
||||
"ja": "会話数が最少 (最も空いているサンドボックスに追加)",
|
||||
"zh-CN": "最少对话 (添加到最空闲的沙盒)",
|
||||
"zh-TW": "最少對話 (添加到最空閒的沙盒)",
|
||||
"ko-KR": "대화 수가 가장 적은 (가장 한가한 샌드박스에 추가)",
|
||||
"de": "Wenigste Gespräche (zur am wenigsten beschäftigten Sandbox hinzufügen)",
|
||||
"no": "Færrest samtaler (legg til i minst opptatt sandkasse)",
|
||||
"it": "Meno conversazioni (aggiungi alla sandbox meno occupata)",
|
||||
"pt": "Menos conversas (adicionar ao sandbox menos ocupado)",
|
||||
"es": "Menos conversaciones (agregar al sandbox menos ocupado)",
|
||||
"ar": "أقل محادثات (إضافة إلى صندوق الرمل الأقل انشغالاً)",
|
||||
"fr": "Moins de conversations (ajouter au sandbox le moins occupé)",
|
||||
"tr": "En Az Konuşma (en az meşgul sandbox'a ekle)",
|
||||
"uk": "Найменше розмов (додати до найменш зайнятої пісочниці)"
|
||||
},
|
||||
"SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY": {
|
||||
"en": "Add to Any (use first available sandbox)",
|
||||
"ja": "任意に追加 (最初に利用可能なサンドボックスを使用)",
|
||||
"zh-CN": "添加到任意 (使用第一个可用的沙盒)",
|
||||
"zh-TW": "添加到任意 (使用第一個可用的沙盒)",
|
||||
"ko-KR": "아무 곳에나 추가 (첫 번째 사용 가능한 샌드박스 사용)",
|
||||
"de": "Zu beliebig hinzufügen (erste verfügbare Sandbox verwenden)",
|
||||
"no": "Legg til i hvilken som helst (bruk første tilgjengelige sandkasse)",
|
||||
"it": "Aggiungi a qualsiasi (usa la prima sandbox disponibile)",
|
||||
"pt": "Adicionar a qualquer (usar o primeiro sandbox disponível)",
|
||||
"es": "Agregar a cualquiera (usar el primer sandbox disponible)",
|
||||
"ar": "إضافة إلى أي (استخدام أول صندوق رمل متاح)",
|
||||
"fr": "Ajouter à n'importe lequel (utiliser le premier sandbox disponible)",
|
||||
"tr": "Herhangi Birine Ekle (ilk uygun sandbox'ı kullan)",
|
||||
"uk": "Додати до будь-якої (використовувати першу доступну пісочницю)"
|
||||
},
|
||||
"SETTINGS$SEARCH_API_KEY": {
|
||||
"en": "Search API Key (Tavily)",
|
||||
"ja": "検索APIキー (Tavily)",
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
@@ -19,6 +20,11 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"
|
||||
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
|
||||
import {
|
||||
SandboxGroupingStrategy,
|
||||
SandboxGroupingStrategyOptions,
|
||||
} from "#/types/settings";
|
||||
import { ENABLE_SANDBOX_GROUPING } from "#/utils/feature-flags";
|
||||
import { createPermissionGuard } from "#/utils/org/permission-guard";
|
||||
|
||||
export const clientLoader = createPermissionGuard(
|
||||
@@ -49,6 +55,12 @@ function AppSettingsScreen() {
|
||||
solvabilityAnalysisSwitchHasChanged,
|
||||
setSolvabilityAnalysisSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
const [
|
||||
sandboxGroupingStrategyHasChanged,
|
||||
setSandboxGroupingStrategyHasChanged,
|
||||
] = React.useState(false);
|
||||
const [selectedSandboxGroupingStrategy, setSelectedSandboxGroupingStrategy] =
|
||||
React.useState<SandboxGroupingStrategy | null>(null);
|
||||
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
|
||||
React.useState(false);
|
||||
const [gitUserNameHasChanged, setGitUserNameHasChanged] =
|
||||
@@ -75,6 +87,11 @@ function AppSettingsScreen() {
|
||||
const enableSolvabilityAnalysis =
|
||||
formData.get("enable-solvability-analysis-switch")?.toString() === "on";
|
||||
|
||||
const sandboxGroupingStrategy =
|
||||
selectedSandboxGroupingStrategy ||
|
||||
settings?.sandbox_grouping_strategy ||
|
||||
DEFAULT_SETTINGS.sandbox_grouping_strategy;
|
||||
|
||||
const maxBudgetPerTaskValue = formData
|
||||
.get("max-budget-per-task-input")
|
||||
?.toString();
|
||||
@@ -94,6 +111,7 @@ function AppSettingsScreen() {
|
||||
enable_sound_notifications: enableSoundNotifications,
|
||||
enable_proactive_conversation_starters: enableProactiveConversations,
|
||||
enable_solvability_analysis: enableSolvabilityAnalysis,
|
||||
sandbox_grouping_strategy: sandboxGroupingStrategy,
|
||||
max_budget_per_task: maxBudgetPerTask,
|
||||
git_user_name: gitUserName,
|
||||
git_user_email: gitUserEmail,
|
||||
@@ -112,6 +130,8 @@ function AppSettingsScreen() {
|
||||
setAnalyticsSwitchHasChanged(false);
|
||||
setSoundNotificationsSwitchHasChanged(false);
|
||||
setProactiveConversationsSwitchHasChanged(false);
|
||||
setSandboxGroupingStrategyHasChanged(false);
|
||||
setSelectedSandboxGroupingStrategy(null);
|
||||
setMaxBudgetPerTaskHasChanged(false);
|
||||
setGitUserNameHasChanged(false);
|
||||
setGitUserEmailHasChanged(false);
|
||||
@@ -159,6 +179,15 @@ function AppSettingsScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSandboxGroupingStrategyChange = (key: React.Key | null) => {
|
||||
const newStrategy = key?.toString() as SandboxGroupingStrategy | undefined;
|
||||
setSelectedSandboxGroupingStrategy(newStrategy || null);
|
||||
const currentStrategy =
|
||||
settings?.sandbox_grouping_strategy ||
|
||||
DEFAULT_SETTINGS.sandbox_grouping_strategy;
|
||||
setSandboxGroupingStrategyHasChanged(newStrategy !== currentStrategy);
|
||||
};
|
||||
|
||||
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
|
||||
const newValue = parseMaxBudgetPerTask(value);
|
||||
const currentValue = settings?.max_budget_per_task;
|
||||
@@ -181,6 +210,7 @@ function AppSettingsScreen() {
|
||||
!soundNotificationsSwitchHasChanged &&
|
||||
!proactiveConversationsSwitchHasChanged &&
|
||||
!solvabilityAnalysisSwitchHasChanged &&
|
||||
!sandboxGroupingStrategyHasChanged &&
|
||||
!maxBudgetPerTaskHasChanged &&
|
||||
!gitUserNameHasChanged &&
|
||||
!gitUserEmailHasChanged;
|
||||
@@ -244,6 +274,26 @@ function AppSettingsScreen() {
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
{ENABLE_SANDBOX_GROUPING() && (
|
||||
<SettingsDropdownInput
|
||||
testId="sandbox-grouping-strategy-input"
|
||||
name="sandbox-grouping-strategy-input"
|
||||
label={t(I18nKey.SETTINGS$SANDBOX_GROUPING_STRATEGY)}
|
||||
items={Object.keys(SandboxGroupingStrategyOptions).map((key) => ({
|
||||
key,
|
||||
label: t(`SETTINGS$SANDBOX_GROUPING_${key}` as I18nKey),
|
||||
}))}
|
||||
selectedKey={
|
||||
selectedSandboxGroupingStrategy ||
|
||||
settings.sandbox_grouping_strategy ||
|
||||
DEFAULT_SETTINGS.sandbox_grouping_strategy
|
||||
}
|
||||
isClearable={false}
|
||||
onSelectionChange={handleSandboxGroupingStrategyChange}
|
||||
wrapperClassName="w-full max-w-[680px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!settings?.v1_enabled && (
|
||||
<SettingsInput
|
||||
testId="max-budget-per-task-input"
|
||||
|
||||
@@ -33,6 +33,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
git_user_name: "openhands",
|
||||
git_user_email: "openhands@all-hands.dev",
|
||||
v1_enabled: false,
|
||||
sandbox_grouping_strategy: "NO_GROUPING",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,17 @@ export const ProviderOptions = {
|
||||
enterprise_sso: "enterprise_sso",
|
||||
} as const;
|
||||
|
||||
export const SandboxGroupingStrategyOptions = {
|
||||
NO_GROUPING: "NO_GROUPING",
|
||||
GROUP_BY_NEWEST: "GROUP_BY_NEWEST",
|
||||
LEAST_RECENTLY_USED: "LEAST_RECENTLY_USED",
|
||||
FEWEST_CONVERSATIONS: "FEWEST_CONVERSATIONS",
|
||||
ADD_TO_ANY: "ADD_TO_ANY",
|
||||
} as const;
|
||||
|
||||
export type SandboxGroupingStrategy =
|
||||
keyof typeof SandboxGroupingStrategyOptions;
|
||||
|
||||
export type Provider = keyof typeof ProviderOptions;
|
||||
|
||||
export type ProviderToken = {
|
||||
@@ -67,4 +78,5 @@ export type Settings = {
|
||||
git_user_name?: string;
|
||||
git_user_email?: string;
|
||||
v1_enabled?: boolean;
|
||||
sandbox_grouping_strategy?: SandboxGroupingStrategy;
|
||||
};
|
||||
|
||||
@@ -18,3 +18,5 @@ export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
|
||||
export const ENABLE_TRAJECTORY_REPLAY = () =>
|
||||
loadFeatureFlag("TRAJECTORY_REPLAY");
|
||||
export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING");
|
||||
export const ENABLE_SANDBOX_GROUPING = () =>
|
||||
loadFeatureFlag("SANDBOX_GROUPING");
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
* @returns The git path to use
|
||||
*/
|
||||
export function getGitPath(
|
||||
conversationId: string,
|
||||
selectedRepository: string | null | undefined,
|
||||
): string {
|
||||
if (!selectedRepository) {
|
||||
return "/workspace/project";
|
||||
return `/workspace/project/${conversationId}`;
|
||||
}
|
||||
|
||||
// Extract the repository name from the path
|
||||
@@ -18,5 +19,5 @@ export function getGitPath(
|
||||
const parts = selectedRepository.split("/");
|
||||
const repoName = parts[parts.length - 1];
|
||||
|
||||
return `/workspace/project/${repoName}`;
|
||||
return `/workspace/project/${conversationId}/${repoName}`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ from openhands.sdk.conversation.state import ConversationExecutionStatus
|
||||
from openhands.sdk.llm import MetricsSnapshot
|
||||
from openhands.sdk.plugin import PluginSource
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.storage.data_models.settings import SandboxGroupingStrategy
|
||||
|
||||
# Re-export SandboxGroupingStrategy for backward compatibility
|
||||
__all__ = ['SandboxGroupingStrategy']
|
||||
|
||||
|
||||
class AgentType(Enum):
|
||||
|
||||
@@ -88,6 +88,7 @@ from openhands.sdk.utils.paging import page_iterator
|
||||
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.storage.data_models.settings import SandboxGroupingStrategy
|
||||
from openhands.tools.preset.default import (
|
||||
get_default_tools,
|
||||
)
|
||||
@@ -128,6 +129,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
jwt_service: JwtService
|
||||
sandbox_startup_timeout: int
|
||||
sandbox_startup_poll_frequency: int
|
||||
max_num_conversations_per_sandbox: int
|
||||
httpx_client: httpx.AsyncClient
|
||||
web_url: str | None
|
||||
openhands_provider_base_url: str | None
|
||||
@@ -135,6 +137,11 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
app_mode: str | None = None
|
||||
tavily_api_key: str | None = None
|
||||
|
||||
async def _get_sandbox_grouping_strategy(self) -> SandboxGroupingStrategy:
|
||||
"""Get the sandbox grouping strategy from user settings."""
|
||||
user_info = await self.user_context.get_user_info()
|
||||
return user_info.sandbox_grouping_strategy
|
||||
|
||||
async def search_app_conversations(
|
||||
self,
|
||||
title__contains: str | None = None,
|
||||
@@ -255,11 +262,20 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
)
|
||||
assert sandbox_spec is not None
|
||||
|
||||
# Set up conversation id
|
||||
conversation_id = request.conversation_id or uuid4()
|
||||
|
||||
# Setup working dir based on grouping
|
||||
working_dir = sandbox_spec.working_dir
|
||||
sandbox_grouping_strategy = await self._get_sandbox_grouping_strategy()
|
||||
if sandbox_grouping_strategy != SandboxGroupingStrategy.NO_GROUPING:
|
||||
working_dir = f'{working_dir}/{conversation_id.hex}'
|
||||
|
||||
# Run setup scripts
|
||||
remote_workspace = AsyncRemoteWorkspace(
|
||||
host=agent_server_url,
|
||||
api_key=sandbox.session_api_key,
|
||||
working_dir=sandbox_spec.working_dir,
|
||||
working_dir=working_dir,
|
||||
)
|
||||
async for updated_task in self.run_setup_scripts(
|
||||
task, sandbox, remote_workspace, agent_server_url
|
||||
@@ -270,13 +286,13 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
start_conversation_request = (
|
||||
await self._build_start_conversation_request_for_user(
|
||||
sandbox,
|
||||
conversation_id,
|
||||
request.initial_message,
|
||||
request.system_message_suffix,
|
||||
request.git_provider,
|
||||
sandbox_spec.working_dir,
|
||||
working_dir,
|
||||
request.agent_type,
|
||||
request.llm_model,
|
||||
request.conversation_id,
|
||||
remote_workspace=remote_workspace,
|
||||
selected_repository=request.selected_repository,
|
||||
plugins=request.plugins,
|
||||
@@ -495,21 +511,157 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
result[stored_conversation.sandbox_id].append(stored_conversation.id)
|
||||
return result
|
||||
|
||||
async def _find_running_sandbox_for_user(self) -> SandboxInfo | None:
|
||||
"""Find a running sandbox for the current user based on the grouping strategy.
|
||||
|
||||
Returns:
|
||||
SandboxInfo if a running sandbox is found, None otherwise.
|
||||
"""
|
||||
try:
|
||||
user_id = await self.user_context.get_user_id()
|
||||
sandbox_grouping_strategy = await self._get_sandbox_grouping_strategy()
|
||||
|
||||
# If no grouping, return None to force creation of a new sandbox
|
||||
if sandbox_grouping_strategy == SandboxGroupingStrategy.NO_GROUPING:
|
||||
return None
|
||||
|
||||
# Collect all running sandboxes for this user
|
||||
running_sandboxes = []
|
||||
page_id = None
|
||||
while True:
|
||||
page = await self.sandbox_service.search_sandboxes(
|
||||
page_id=page_id, limit=100
|
||||
)
|
||||
|
||||
for sandbox in page.items:
|
||||
if (
|
||||
sandbox.status == SandboxStatus.RUNNING
|
||||
and sandbox.created_by_user_id == user_id
|
||||
):
|
||||
running_sandboxes.append(sandbox)
|
||||
|
||||
if page.next_page_id is None:
|
||||
break
|
||||
page_id = page.next_page_id
|
||||
|
||||
if not running_sandboxes:
|
||||
return None
|
||||
|
||||
# Apply the grouping strategy
|
||||
return await self._select_sandbox_by_strategy(
|
||||
running_sandboxes, sandbox_grouping_strategy
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
f'Error finding running sandbox for user: {e}', exc_info=True
|
||||
)
|
||||
return None
|
||||
|
||||
async def _select_sandbox_by_strategy(
|
||||
self,
|
||||
running_sandboxes: list[SandboxInfo],
|
||||
sandbox_grouping_strategy: SandboxGroupingStrategy,
|
||||
) -> SandboxInfo | None:
|
||||
"""Select a sandbox from the list based on the configured grouping strategy.
|
||||
|
||||
Args:
|
||||
running_sandboxes: List of running sandboxes for the user
|
||||
sandbox_grouping_strategy: The strategy to use for selection
|
||||
|
||||
Returns:
|
||||
Selected sandbox based on the strategy, or None if no sandbox is available
|
||||
(e.g., all sandboxes have reached max_num_conversations_per_sandbox)
|
||||
"""
|
||||
# Get conversation counts for filtering by max_num_conversations_per_sandbox
|
||||
sandbox_conversation_counts = await self._get_conversation_counts_by_sandbox(
|
||||
[s.id for s in running_sandboxes]
|
||||
)
|
||||
|
||||
# Filter out sandboxes that have reached the max number of conversations
|
||||
available_sandboxes = [
|
||||
s
|
||||
for s in running_sandboxes
|
||||
if sandbox_conversation_counts.get(s.id, 0)
|
||||
< self.max_num_conversations_per_sandbox
|
||||
]
|
||||
|
||||
if not available_sandboxes:
|
||||
# All sandboxes have reached the max - need to create a new one
|
||||
return None
|
||||
|
||||
if sandbox_grouping_strategy == SandboxGroupingStrategy.ADD_TO_ANY:
|
||||
# Return the first available sandbox
|
||||
return available_sandboxes[0]
|
||||
|
||||
elif sandbox_grouping_strategy == SandboxGroupingStrategy.GROUP_BY_NEWEST:
|
||||
# Return the most recently created sandbox
|
||||
return max(available_sandboxes, key=lambda s: s.created_at)
|
||||
|
||||
elif sandbox_grouping_strategy == SandboxGroupingStrategy.LEAST_RECENTLY_USED:
|
||||
# Return the least recently created sandbox (oldest)
|
||||
return min(available_sandboxes, key=lambda s: s.created_at)
|
||||
|
||||
elif sandbox_grouping_strategy == SandboxGroupingStrategy.FEWEST_CONVERSATIONS:
|
||||
# Return the one with fewest conversations
|
||||
return min(
|
||||
available_sandboxes,
|
||||
key=lambda s: sandbox_conversation_counts.get(s.id, 0),
|
||||
)
|
||||
|
||||
else:
|
||||
# Default fallback - return first sandbox
|
||||
return available_sandboxes[0]
|
||||
|
||||
async def _get_conversation_counts_by_sandbox(
|
||||
self, sandbox_ids: list[str]
|
||||
) -> dict[str, int]:
|
||||
"""Get the count of conversations for each sandbox.
|
||||
|
||||
Args:
|
||||
sandbox_ids: List of sandbox IDs to count conversations for
|
||||
|
||||
Returns:
|
||||
Dictionary mapping sandbox_id to conversation count
|
||||
"""
|
||||
try:
|
||||
# Query count for each sandbox individually
|
||||
# This is efficient since there are at most ~8 running sandboxes per user
|
||||
counts: dict[str, int] = {}
|
||||
for sandbox_id in sandbox_ids:
|
||||
count = await self.app_conversation_info_service.count_app_conversation_info(
|
||||
sandbox_id__eq=sandbox_id
|
||||
)
|
||||
counts[sandbox_id] = count
|
||||
return counts
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
f'Error counting conversations by sandbox: {e}', exc_info=True
|
||||
)
|
||||
# Return empty counts on error - will default to first sandbox
|
||||
return {}
|
||||
|
||||
async def _wait_for_sandbox_start(
|
||||
self, task: AppConversationStartTask
|
||||
) -> AsyncGenerator[AppConversationStartTask, None]:
|
||||
"""Wait for sandbox to start and return info."""
|
||||
# Get or create the sandbox
|
||||
if not task.request.sandbox_id:
|
||||
# Convert conversation_id to hex string if present
|
||||
sandbox_id_str = (
|
||||
task.request.conversation_id.hex
|
||||
if task.request.conversation_id is not None
|
||||
else None
|
||||
)
|
||||
sandbox = await self.sandbox_service.start_sandbox(
|
||||
sandbox_id=sandbox_id_str
|
||||
)
|
||||
# First try to find a running sandbox for the current user
|
||||
sandbox = await self._find_running_sandbox_for_user()
|
||||
if sandbox is None:
|
||||
# No running sandbox found, start a new one
|
||||
|
||||
# Convert conversation_id to hex string if present
|
||||
sandbox_id_str = (
|
||||
task.request.conversation_id.hex
|
||||
if task.request.conversation_id is not None
|
||||
else None
|
||||
)
|
||||
|
||||
sandbox = await self.sandbox_service.start_sandbox(
|
||||
sandbox_id=sandbox_id_str
|
||||
)
|
||||
task.sandbox_id = sandbox.id
|
||||
else:
|
||||
sandbox_info = await self.sandbox_service.get_sandbox(
|
||||
@@ -1133,7 +1285,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
async def _finalize_conversation_request(
|
||||
self,
|
||||
agent: Agent,
|
||||
conversation_id: UUID | None,
|
||||
conversation_id: UUID,
|
||||
user: UserInfo,
|
||||
workspace: LocalWorkspace,
|
||||
initial_message: SendMessageRequest | None,
|
||||
@@ -1211,13 +1363,13 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
async def _build_start_conversation_request_for_user(
|
||||
self,
|
||||
sandbox: SandboxInfo,
|
||||
conversation_id: UUID,
|
||||
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,
|
||||
plugins: list[PluginSpec] | None = None,
|
||||
@@ -1614,6 +1766,10 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||
sandbox_startup_poll_frequency: int = Field(
|
||||
default=2, description='The frequency to poll for sandbox readiness'
|
||||
)
|
||||
max_num_conversations_per_sandbox: int = Field(
|
||||
default=20,
|
||||
description='The maximum number of conversations allowed per sandbox',
|
||||
)
|
||||
init_git_in_empty_workspace: bool = Field(
|
||||
default=True,
|
||||
description='Whether to initialize a git repo when the workspace is empty',
|
||||
@@ -1705,6 +1861,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||
jwt_service=jwt_service,
|
||||
sandbox_startup_timeout=self.sandbox_startup_timeout,
|
||||
sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency,
|
||||
max_num_conversations_per_sandbox=self.max_num_conversations_per_sandbox,
|
||||
httpx_client=httpx_client,
|
||||
web_url=web_url,
|
||||
openhands_provider_base_url=config.openhands_provider_base_url,
|
||||
|
||||
@@ -41,7 +41,7 @@ from openhands.app_server.config import (
|
||||
depends_httpx_client,
|
||||
depends_sandbox_service,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.app_server.sandbox.sandbox_models import AGENT_SERVER, SandboxStatus
|
||||
from openhands.app_server.sandbox.sandbox_service import SandboxService
|
||||
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
|
||||
from openhands.app_server.services.httpx_client_injector import (
|
||||
@@ -614,7 +614,7 @@ async def _try_delete_v1_conversation(
|
||||
|
||||
# Delete the sandbox in the background
|
||||
asyncio.create_task(
|
||||
_delete_sandbox_and_close_connections(
|
||||
_finalize_delete_and_close_connections(
|
||||
sandbox_service,
|
||||
app_conversation_info.sandbox_id,
|
||||
db_session,
|
||||
@@ -628,14 +628,18 @@ async def _try_delete_v1_conversation(
|
||||
return result
|
||||
|
||||
|
||||
async def _delete_sandbox_and_close_connections(
|
||||
async def _finalize_delete_and_close_connections(
|
||||
sandbox_service: SandboxService,
|
||||
sandbox_id: str,
|
||||
db_session: AsyncSession,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
):
|
||||
try:
|
||||
await sandbox_service.delete_sandbox(sandbox_id)
|
||||
num_conversations_in_sandbox = await _get_num_conversations_in_sandbox(
|
||||
sandbox_service, sandbox_id, httpx_client
|
||||
)
|
||||
if num_conversations_in_sandbox == 0:
|
||||
await sandbox_service.delete_sandbox(sandbox_id)
|
||||
await db_session.commit()
|
||||
finally:
|
||||
await asyncio.gather(
|
||||
@@ -646,6 +650,28 @@ async def _delete_sandbox_and_close_connections(
|
||||
)
|
||||
|
||||
|
||||
async def _get_num_conversations_in_sandbox(
|
||||
sandbox_service: SandboxService,
|
||||
sandbox_id: str,
|
||||
httpx_client: httpx.AsyncClient,
|
||||
) -> int:
|
||||
try:
|
||||
sandbox = await sandbox_service.get_sandbox(sandbox_id)
|
||||
if not sandbox or not sandbox.exposed_urls:
|
||||
return 0
|
||||
agent_server_url = next(
|
||||
u for u in sandbox.exposed_urls if u.name == AGENT_SERVER
|
||||
)
|
||||
response = await httpx_client.get(
|
||||
f'{agent_server_url.url}/api/conversations/count',
|
||||
headers={'X-Session-API-Key': sandbox.session_api_key},
|
||||
)
|
||||
result = int(response.content)
|
||||
return result
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
async def _delete_v0_conversation(conversation_id: str, user_id: str | None) -> bool:
|
||||
"""Delete a V0 conversation using the legacy logic."""
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import (
|
||||
@@ -19,6 +20,20 @@ from openhands.core.config.utils import load_openhands_config
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
class SandboxGroupingStrategy(str, Enum):
|
||||
"""Strategy for grouping conversations within sandboxes."""
|
||||
|
||||
NO_GROUPING = 'NO_GROUPING' # Default - each conversation gets its own sandbox
|
||||
GROUP_BY_NEWEST = 'GROUP_BY_NEWEST' # Add to the most recently created sandbox
|
||||
LEAST_RECENTLY_USED = (
|
||||
'LEAST_RECENTLY_USED' # Add to the least recently used sandbox
|
||||
)
|
||||
FEWEST_CONVERSATIONS = (
|
||||
'FEWEST_CONVERSATIONS' # Add to sandbox with fewest conversations
|
||||
)
|
||||
ADD_TO_ANY = 'ADD_TO_ANY' # Add to any available sandbox (first found)
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Persisted settings for OpenHands sessions"""
|
||||
|
||||
@@ -54,6 +69,9 @@ class Settings(BaseModel):
|
||||
git_user_name: str | None = None
|
||||
git_user_email: str | None = None
|
||||
v1_enabled: bool = True
|
||||
sandbox_grouping_strategy: SandboxGroupingStrategy = (
|
||||
SandboxGroupingStrategy.NO_GROUPING
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
validate_assignment=True,
|
||||
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from uuid import UUID, uuid4
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
@@ -29,6 +29,7 @@ from openhands.app_server.sandbox.sandbox_models import (
|
||||
AGENT_SERVER,
|
||||
ExposedUrl,
|
||||
SandboxInfo,
|
||||
SandboxPage,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
|
||||
@@ -42,6 +43,7 @@ from openhands.sdk.workspace import LocalWorkspace
|
||||
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
from openhands.storage.data_models.settings import SandboxGroupingStrategy
|
||||
|
||||
# Env var used by openhands SDK LLM to skip context-window validation (e.g. for gpt-4 in tests)
|
||||
_ALLOW_SHORT_CONTEXT_WINDOWS = 'ALLOW_SHORT_CONTEXT_WINDOWS'
|
||||
@@ -92,6 +94,7 @@ class TestLiveStatusAppConversationService:
|
||||
jwt_service=self.mock_jwt_service,
|
||||
sandbox_startup_timeout=30,
|
||||
sandbox_startup_poll_frequency=1,
|
||||
max_num_conversations_per_sandbox=20,
|
||||
httpx_client=self.mock_httpx_client,
|
||||
web_url='https://test.example.com',
|
||||
openhands_provider_base_url='https://provider.example.com',
|
||||
@@ -105,6 +108,8 @@ class TestLiveStatusAppConversationService:
|
||||
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'
|
||||
# Use ADD_TO_ANY for tests to maintain old behavior
|
||||
self.mock_user.sandbox_grouping_strategy = SandboxGroupingStrategy.ADD_TO_ANY
|
||||
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
|
||||
@@ -1091,11 +1096,12 @@ class TestLiveStatusAppConversationService:
|
||||
|
||||
workspace = LocalWorkspace(working_dir='/test')
|
||||
secrets = {'test': StaticSecret(value='secret')}
|
||||
test_conversation_id = uuid4()
|
||||
|
||||
# Act
|
||||
result = await self.service._finalize_conversation_request(
|
||||
mock_agent,
|
||||
None,
|
||||
test_conversation_id,
|
||||
self.mock_user,
|
||||
workspace,
|
||||
None,
|
||||
@@ -1108,7 +1114,7 @@ class TestLiveStatusAppConversationService:
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, StartConversationRequest)
|
||||
assert isinstance(result.conversation_id, UUID)
|
||||
assert result.conversation_id == test_conversation_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finalize_conversation_request_skills_loading_fails(self):
|
||||
@@ -1179,13 +1185,13 @@ class TestLiveStatusAppConversationService:
|
||||
# Act
|
||||
result = await self.service._build_start_conversation_request_for_user(
|
||||
sandbox=self.mock_sandbox,
|
||||
conversation_id=uuid4(),
|
||||
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',
|
||||
)
|
||||
@@ -1215,6 +1221,98 @@ class TestLiveStatusAppConversationService:
|
||||
self.service._finalize_conversation_request.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_running_sandbox_for_user_found(self):
|
||||
"""Test _find_running_sandbox_for_user when a running sandbox is found."""
|
||||
# Arrange
|
||||
user_id = 'test_user_123'
|
||||
self.mock_user_context.get_user_id.return_value = user_id
|
||||
|
||||
# Create mock sandboxes
|
||||
running_sandbox = Mock(spec=SandboxInfo)
|
||||
running_sandbox.id = 'sandbox_1'
|
||||
running_sandbox.status = SandboxStatus.RUNNING
|
||||
running_sandbox.created_by_user_id = user_id
|
||||
|
||||
other_user_sandbox = Mock(spec=SandboxInfo)
|
||||
other_user_sandbox.id = 'sandbox_2'
|
||||
other_user_sandbox.status = SandboxStatus.RUNNING
|
||||
other_user_sandbox.created_by_user_id = 'other_user'
|
||||
|
||||
paused_sandbox = Mock(spec=SandboxInfo)
|
||||
paused_sandbox.id = 'sandbox_3'
|
||||
paused_sandbox.status = SandboxStatus.PAUSED
|
||||
paused_sandbox.created_by_user_id = user_id
|
||||
|
||||
# Mock sandbox service search
|
||||
mock_page = Mock(spec=SandboxPage)
|
||||
mock_page.items = [other_user_sandbox, running_sandbox, paused_sandbox]
|
||||
mock_page.next_page_id = None
|
||||
self.mock_sandbox_service.search_sandboxes = AsyncMock(return_value=mock_page)
|
||||
|
||||
# Act
|
||||
result = await self.service._find_running_sandbox_for_user()
|
||||
|
||||
# Assert
|
||||
assert result == running_sandbox
|
||||
self.mock_user_context.get_user_id.assert_called_once()
|
||||
self.mock_sandbox_service.search_sandboxes.assert_called_once_with(
|
||||
page_id=None, limit=100
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_running_sandbox_for_user_not_found(self):
|
||||
"""Test _find_running_sandbox_for_user when no running sandbox is found."""
|
||||
# Arrange
|
||||
user_id = 'test_user_123'
|
||||
self.mock_user_context.get_user_id.return_value = user_id
|
||||
|
||||
# Create mock sandboxes (none running for this user)
|
||||
other_user_sandbox = Mock(spec=SandboxInfo)
|
||||
other_user_sandbox.id = 'sandbox_1'
|
||||
other_user_sandbox.status = SandboxStatus.RUNNING
|
||||
other_user_sandbox.created_by_user_id = 'other_user'
|
||||
|
||||
paused_sandbox = Mock(spec=SandboxInfo)
|
||||
paused_sandbox.id = 'sandbox_2'
|
||||
paused_sandbox.status = SandboxStatus.PAUSED
|
||||
paused_sandbox.created_by_user_id = user_id
|
||||
|
||||
# Mock sandbox service search
|
||||
mock_page = Mock(spec=SandboxPage)
|
||||
mock_page.items = [other_user_sandbox, paused_sandbox]
|
||||
mock_page.next_page_id = None
|
||||
self.mock_sandbox_service.search_sandboxes = AsyncMock(return_value=mock_page)
|
||||
|
||||
# Act
|
||||
result = await self.service._find_running_sandbox_for_user()
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
self.mock_user_context.get_user_id.assert_called_once()
|
||||
self.mock_sandbox_service.search_sandboxes.assert_called_once_with(
|
||||
page_id=None, limit=100
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_running_sandbox_for_user_exception_handling(self):
|
||||
"""Test _find_running_sandbox_for_user handles exceptions gracefully."""
|
||||
# Arrange
|
||||
self.mock_user_context.get_user_id.side_effect = Exception('User context error')
|
||||
|
||||
# Act
|
||||
with patch(
|
||||
'openhands.app_server.app_conversation.live_status_app_conversation_service._logger'
|
||||
) as mock_logger:
|
||||
result = await self.service._find_running_sandbox_for_user()
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert (
|
||||
'Error finding running sandbox for user'
|
||||
in mock_logger.warning.call_args[0][0]
|
||||
)
|
||||
|
||||
async def test_export_conversation_success(self):
|
||||
"""Test successful download of conversation trajectory."""
|
||||
# Arrange
|
||||
@@ -2052,6 +2150,7 @@ class TestLiveStatusAppConversationService:
|
||||
|
||||
await self.service._build_start_conversation_request_for_user(
|
||||
sandbox=self.mock_sandbox,
|
||||
conversation_id=uuid4(),
|
||||
initial_message=None,
|
||||
system_message_suffix=None,
|
||||
git_provider=None,
|
||||
@@ -2088,6 +2187,7 @@ class TestLiveStatusAppConversationService:
|
||||
|
||||
await self.service._build_start_conversation_request_for_user(
|
||||
sandbox=self.mock_sandbox,
|
||||
conversation_id=uuid4(),
|
||||
initial_message=None,
|
||||
system_message_suffix=None,
|
||||
git_provider=None,
|
||||
@@ -2243,6 +2343,7 @@ class TestPluginHandling:
|
||||
jwt_service=self.mock_jwt_service,
|
||||
sandbox_startup_timeout=30,
|
||||
sandbox_startup_poll_frequency=1,
|
||||
max_num_conversations_per_sandbox=20,
|
||||
httpx_client=self.mock_httpx_client,
|
||||
web_url='https://test.example.com',
|
||||
openhands_provider_base_url='https://provider.example.com',
|
||||
@@ -2726,11 +2827,12 @@ class TestPluginHandling:
|
||||
|
||||
# Act
|
||||
await self.service._build_start_conversation_request_for_user(
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
'/workspace',
|
||||
sandbox=self.mock_sandbox,
|
||||
conversation_id=uuid4(),
|
||||
initial_message=None,
|
||||
system_message_suffix=None,
|
||||
git_provider=None,
|
||||
working_dir='/workspace',
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
@@ -2754,11 +2856,12 @@ class TestPluginHandling:
|
||||
|
||||
# Act
|
||||
await self.service._build_start_conversation_request_for_user(
|
||||
self.mock_sandbox,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
'/workspace',
|
||||
sandbox=self.mock_sandbox,
|
||||
conversation_id=uuid4(),
|
||||
initial_message=None,
|
||||
system_message_suffix=None,
|
||||
git_provider=None,
|
||||
working_dir='/workspace',
|
||||
)
|
||||
|
||||
# Assert
|
||||
|
||||
@@ -2189,6 +2189,7 @@ async def test_delete_v1_conversation_with_sub_conversations():
|
||||
jwt_service=MagicMock(),
|
||||
sandbox_startup_timeout=120,
|
||||
sandbox_startup_poll_frequency=2,
|
||||
max_num_conversations_per_sandbox=20,
|
||||
httpx_client=mock_httpx_client,
|
||||
web_url=None,
|
||||
openhands_provider_base_url=None,
|
||||
@@ -2312,6 +2313,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations():
|
||||
jwt_service=MagicMock(),
|
||||
sandbox_startup_timeout=120,
|
||||
sandbox_startup_poll_frequency=2,
|
||||
max_num_conversations_per_sandbox=20,
|
||||
httpx_client=mock_httpx_client,
|
||||
web_url=None,
|
||||
openhands_provider_base_url=None,
|
||||
@@ -2465,6 +2467,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error():
|
||||
jwt_service=MagicMock(),
|
||||
sandbox_startup_timeout=120,
|
||||
sandbox_startup_poll_frequency=2,
|
||||
max_num_conversations_per_sandbox=20,
|
||||
httpx_client=mock_httpx_client,
|
||||
web_url=None,
|
||||
openhands_provider_base_url=None,
|
||||
|
||||
Reference in New Issue
Block a user