mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5ea2ac478 |
@@ -7,8 +7,5 @@ git config --global --add safe.directory "$(realpath .)"
|
||||
# Install `nc`
|
||||
sudo apt update && sudo apt install netcat -y
|
||||
|
||||
# Install `uv` and `uvx`
|
||||
wget -qO- https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Do common setup tasks
|
||||
source .openhands/setup.sh
|
||||
|
||||
@@ -71,14 +71,6 @@ jobs:
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
|
||||
- name: Verify binary files exist
|
||||
run: |
|
||||
if ! ls openhands-cli/dist/openhands* 1> /dev/null 2>&1; then
|
||||
echo "❌ No binaries found to upload!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Found binaries to upload."
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.61-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.60-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -82,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.openhands.dev/openhands/runtime:0.61-nikolaik
|
||||
docker pull docker.openhands.dev/openhands/runtime:0.60-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.60-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.openhands.dev/openhands/openhands:0.61
|
||||
docker.openhands.dev/openhands/openhands:0.60
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.61-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.60-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.61-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.60-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
Generated
+10
-10
@@ -5759,13 +5759,13 @@ wsproto = ">=1.2.0"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
resolved_reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.0.0-post.5477+727520f6c"
|
||||
version = "0.59.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -5805,9 +5805,9 @@ memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0", subdirectory = "openhands-tools"}
|
||||
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-tools"}
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5887,8 +5887,8 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
resolved_reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-sdk"
|
||||
|
||||
[[package]]
|
||||
@@ -5914,8 +5914,8 @@ pydantic = ">=2.11.7"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
resolved_reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-tools"
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -50,7 +50,7 @@ SUBSCRIPTION_PRICE_DATA = {
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '20'))
|
||||
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
|
||||
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', None)
|
||||
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')
|
||||
|
||||
@@ -35,7 +35,6 @@ class SaasConversationStore(ConversationStore):
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.user_id == self.user_id)
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
.filter(StoredConversationMetadata.conversation_version == 'V0')
|
||||
)
|
||||
|
||||
def _to_external_model(self, conversation_metadata: StoredConversationMetadata):
|
||||
@@ -124,7 +123,6 @@ class SaasConversationStore(ConversationStore):
|
||||
conversations = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.user_id == self.user_id)
|
||||
.filter(StoredConversationMetadata.conversation_version == 'V0')
|
||||
.order_by(StoredConversationMetadata.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
|
||||
@@ -243,7 +243,7 @@ async def test_update_settings_with_litellm_default(
|
||||
# Check that the URL and most of the JSON payload match what we expect
|
||||
assert call_args['json']['user_email'] == 'testy@tester.com'
|
||||
assert call_args['json']['models'] == []
|
||||
assert call_args['json']['max_budget'] == 10.0
|
||||
assert call_args['json']['max_budget'] == 20.0
|
||||
assert call_args['json']['user_id'] == 'user-id'
|
||||
assert call_args['json']['teams'] == ['test_team']
|
||||
assert call_args['json']['auto_create_key'] is True
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.60.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.60.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.60.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
V1SandboxInfo,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
@@ -212,6 +213,36 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/pause endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to pause
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/resume endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to resume
|
||||
* @returns Success response
|
||||
*/
|
||||
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/resume`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 app conversations by their IDs
|
||||
* Returns null for any missing conversations
|
||||
@@ -238,6 +269,32 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 sandboxes by their IDs
|
||||
* Returns null for any missing sandboxes
|
||||
*
|
||||
* @param ids Array of sandbox IDs (max 100)
|
||||
* @returns Array of sandboxes or null for missing ones
|
||||
*/
|
||||
static async batchGetSandboxes(
|
||||
ids: string[],
|
||||
): Promise<(V1SandboxInfo | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 sandboxes at once");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("id", id));
|
||||
|
||||
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
|
||||
`/api/v1/sandboxes?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single file to the V1 conversation workspace
|
||||
* V1 API endpoint: POST /api/file/upload/{path}
|
||||
@@ -288,6 +345,24 @@ class V1ConversationService {
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(url);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of events for a conversation
|
||||
* Uses the V1 API endpoint: GET /api/v1/events/count
|
||||
*
|
||||
* @param conversationId The conversation ID to get event count for
|
||||
* @returns The number of events in the conversation
|
||||
*/
|
||||
static async getEventCount(conversationId: string): Promise<number> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("conversation_id__eq", conversationId);
|
||||
|
||||
const { data } = await openHands.get<number>(
|
||||
`/api/v1/events/count?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ConversationTrigger } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types";
|
||||
|
||||
// V1 API Types for requests
|
||||
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
|
||||
@@ -65,6 +64,13 @@ export interface V1AppConversationStartTaskPage {
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
export type V1SandboxStatus =
|
||||
| "MISSING"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "PAUSED";
|
||||
|
||||
export type V1AgentExecutionStatus =
|
||||
| "RUNNING"
|
||||
| "AWAITING_USER_INPUT"
|
||||
@@ -92,3 +98,18 @@ export interface V1AppConversation {
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
}
|
||||
|
||||
export interface V1ExposedUrl {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface V1SandboxInfo {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
sandbox_spec_id: string;
|
||||
status: V1SandboxStatus;
|
||||
session_api_key: string | null;
|
||||
exposed_urls: V1ExposedUrl[] | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
ConfirmationResponseRequest,
|
||||
ConfirmationResponseResponse,
|
||||
} from "./event-service.types";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
class EventService {
|
||||
/**
|
||||
@@ -37,14 +36,6 @@ class EventService {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getEventCount(conversationId: string): Promise<number> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("conversation_id__eq", conversationId);
|
||||
const { data } = await openHands.get<number>(
|
||||
`/api/v1/events/count?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default EventService;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// sandbox-service.api.ts
|
||||
// This file contains API methods for /api/v1/sandboxes endpoints.
|
||||
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import type { V1SandboxInfo } from "./sandbox-service.types";
|
||||
|
||||
export class SandboxService {
|
||||
/**
|
||||
* Pause a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/pause endpoint
|
||||
*/
|
||||
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/resume endpoint
|
||||
*/
|
||||
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/resume`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 sandboxes by their IDs
|
||||
* Returns null for any missing sandboxes
|
||||
*/
|
||||
static async batchGetSandboxes(
|
||||
ids: string[],
|
||||
): Promise<(V1SandboxInfo | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 sandboxes at once");
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("id", id));
|
||||
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
|
||||
`/api/v1/sandboxes?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// sandbox-service.types.ts
|
||||
// This file contains types for Sandbox API.
|
||||
|
||||
export type V1SandboxStatus =
|
||||
| "MISSING"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "PAUSED";
|
||||
|
||||
export interface V1ExposedUrl {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface V1SandboxInfo {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
sandbox_spec_id: string;
|
||||
status: V1SandboxStatus;
|
||||
session_api_key: string | null;
|
||||
exposed_urls: V1ExposedUrl[] | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -68,7 +68,6 @@ export function ChatInterface() {
|
||||
const conversationWebSocket = useConversationWebSocket();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const uiEvents = useEventStore((state) => state.uiEvents);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -122,13 +121,11 @@ export function ChatInterface() {
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
|
||||
const v1UiEvents = uiEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
// Keep full v1 events for lookups (includes both actions and observations)
|
||||
const v1FullEvents = storeEvents.filter(isV1Event);
|
||||
// Filter V1 events
|
||||
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1UiEvents.length;
|
||||
const totalEvents = v0Events.length || v1Events.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
@@ -226,7 +223,7 @@ export function ChatInterface() {
|
||||
};
|
||||
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1Events);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
return (
|
||||
@@ -270,7 +267,7 @@ export function ChatInterface() {
|
||||
)}
|
||||
|
||||
{!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
|
||||
<V1Messages messages={v1UiEvents} allEvents={v1FullEvents} />
|
||||
<V1Messages messages={v1Events} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+1
-7
@@ -8,7 +8,6 @@ import { TabContentArea } from "./tab-content-area";
|
||||
import { ConversationTabTitle } from "../conversation-tab-title";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
// Lazy load all tab components
|
||||
const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
@@ -18,7 +17,6 @@ const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
|
||||
export function ConversationTabContent() {
|
||||
const { selectedTab, shouldShownAgentLoading } = useConversationStore();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -80,11 +78,7 @@ export function ConversationTabContent() {
|
||||
<ConversationTabTitle title={conversationTabTitle} />
|
||||
<TabContentArea>
|
||||
{tabs.map(({ key, component: Component, isActive }) => (
|
||||
<TabWrapper
|
||||
// Force Terminal tab remount to reset XTerm buffer/state when conversationId changes
|
||||
key={key === "terminal" ? `${key}-${conversationId}` : key}
|
||||
isActive={isActive}
|
||||
>
|
||||
<TabWrapper key={key} isActive={isActive}>
|
||||
<Component />
|
||||
</TabWrapper>
|
||||
))}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function RepositorySelectionForm({
|
||||
onBranchSelect={handleBranchSelection}
|
||||
defaultBranch={defaultBranch}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-full"
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository || isLoadingSettings}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,5 +7,5 @@ export function paragraph({
|
||||
}: React.ClassAttributes<HTMLParagraphElement> &
|
||||
React.HTMLAttributes<HTMLParagraphElement> &
|
||||
ExtraProps) {
|
||||
return <p className="py-2.5 first:pt-0 last:pb-0">{children}</p>;
|
||||
return <p className="pb-[10px] last:pb-0">{children}</p>;
|
||||
}
|
||||
|
||||
@@ -134,16 +134,9 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
case "BrowserObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$BROWSE";
|
||||
break;
|
||||
case "TaskTrackerObservation": {
|
||||
const { command } = event.observation;
|
||||
if (command === "plan") {
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN";
|
||||
} else {
|
||||
// command === "view"
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW";
|
||||
}
|
||||
case "TaskTrackerObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// For unknown observations, use the type name
|
||||
return observationType.replace("Observation", "").toUpperCase();
|
||||
|
||||
@@ -7,6 +7,17 @@ import {
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
|
||||
// V1 events that should not be rendered
|
||||
const NO_RENDER_ACTION_TYPES = [
|
||||
"ThinkAction",
|
||||
// Add more action types that should not be rendered
|
||||
];
|
||||
|
||||
const NO_RENDER_OBSERVATION_TYPES = [
|
||||
"ThinkObservation",
|
||||
// Add more observation types that should not be rendered
|
||||
];
|
||||
|
||||
export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
// Explicitly exclude system events that should not be rendered in chat
|
||||
if (isConversationStateUpdateEvent(event)) {
|
||||
@@ -23,12 +34,18 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return !NO_RENDER_ACTION_TYPES.includes(actionType);
|
||||
}
|
||||
|
||||
// Render observation events
|
||||
// Render observation events (with filtering)
|
||||
if (isObservationEvent(event)) {
|
||||
return true;
|
||||
// For V1, observation is an object with kind property
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
// Note: ObservationEvent source is always "environment", not "user"
|
||||
// So no need to check for user source here
|
||||
|
||||
return !NO_RENDER_OBSERVATION_TYPES.includes(observationType);
|
||||
}
|
||||
|
||||
// Render message events (user and assistant messages)
|
||||
|
||||
@@ -3,4 +3,3 @@ export { ObservationPairEventMessage } from "./observation-pair-event-message";
|
||||
export { ErrorEventMessage } from "./error-event-message";
|
||||
export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { ThoughtEventMessage } from "./thought-event-message";
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
|
||||
interface ThoughtEventMessageProps {
|
||||
event: ActionEvent;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ThoughtEventMessage({
|
||||
event,
|
||||
actions,
|
||||
}: ThoughtEventMessageProps) {
|
||||
// Extract thought content from the action event
|
||||
const thoughtContent = event.thought
|
||||
.filter((t) => t.type === "text")
|
||||
.map((t) => t.text)
|
||||
.join("\n");
|
||||
|
||||
// If there's no thought content, don't render anything
|
||||
if (!thoughtContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
|
||||
);
|
||||
}
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
ErrorEventMessage,
|
||||
UserAssistantEventMessage,
|
||||
FinishEventMessage,
|
||||
ObservationPairEventMessage,
|
||||
GenericEventMessageWrapper,
|
||||
ThoughtEventMessage,
|
||||
} from "./event-message-components";
|
||||
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
messages: OpenHandsEvent[];
|
||||
hasObservationPair: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
@@ -36,7 +36,7 @@ interface EventMessageProps {
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
export function EventMessage({
|
||||
event,
|
||||
messages,
|
||||
hasObservationPair,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
@@ -69,6 +69,19 @@ export function EventMessage({
|
||||
return <ErrorEventMessage event={event} {...commonProps} />;
|
||||
}
|
||||
|
||||
// Observation pairs with actions
|
||||
if (hasObservationPair && isActionEvent(event)) {
|
||||
return (
|
||||
<ObservationPairEventMessage
|
||||
event={event}
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Finish actions
|
||||
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
||||
return (
|
||||
@@ -79,39 +92,6 @@ export function EventMessage({
|
||||
);
|
||||
}
|
||||
|
||||
// Action events - render thought + action (will be replaced by thought + observation)
|
||||
if (isActionEvent(event)) {
|
||||
return (
|
||||
<>
|
||||
<ThoughtEventMessage event={event} actions={actions} />
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Observation events - find the corresponding action and render thought + observation
|
||||
if (isObservationEvent(event)) {
|
||||
// Find the action that this observation is responding to
|
||||
const correspondingAction = messages.find(
|
||||
(msg) => isActionEvent(msg) && msg.id === event.action_id,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{correspondingAction && isActionEvent(correspondingAction) && (
|
||||
<ThoughtEventMessage event={correspondingAction} actions={actions} />
|
||||
)}
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Message events (user and assistant messages)
|
||||
if (!isActionEvent(event) && !isObservationEvent(event)) {
|
||||
// This is a MessageEvent
|
||||
@@ -124,7 +104,7 @@ export function EventMessage({
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback for all other events
|
||||
// Generic fallback for all other events (including observation events)
|
||||
return (
|
||||
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "../../features/chat/chat-message";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
@@ -8,16 +9,29 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
|
||||
// import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: OpenHandsEvent[]; // UI events (actions replaced by observations)
|
||||
allEvents: OpenHandsEvent[]; // Full event history (for action lookup)
|
||||
messages: OpenHandsEvent[];
|
||||
}
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, allEvents }) => {
|
||||
({ messages }) => {
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsEvent): boolean => {
|
||||
if (isActionEvent(event)) {
|
||||
// Check if there's a corresponding observation event
|
||||
return !!messages.some(
|
||||
(msg) => isObservationEvent(msg) && msg.action_id === event.id,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
// TODO: Implement microagent functionality for V1 if needed
|
||||
// For now, we'll skip microagent features
|
||||
|
||||
@@ -27,7 +41,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
<EventMessage
|
||||
key={message.id}
|
||||
event={message}
|
||||
messages={allEvents}
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
// Microagent props - not implemented yet for V1
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V1_WebSocketConnectionState =
|
||||
@@ -211,7 +211,8 @@ export function ConversationWebSocketProvider({
|
||||
// Fetch expected event count for history loading detection
|
||||
if (conversationId) {
|
||||
try {
|
||||
const count = await EventService.getEventCount(conversationId);
|
||||
const count =
|
||||
await V1ConversationService.getEventCount(conversationId);
|
||||
setExpectedEventCount(count);
|
||||
|
||||
// If no events expected, mark as loaded immediately
|
||||
|
||||
@@ -2,7 +2,6 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { Provider } from "#/types/settings";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
|
||||
|
||||
/**
|
||||
* Gets the conversation version from the cache
|
||||
@@ -49,7 +48,7 @@ const fetchV1ConversationData = async (
|
||||
*/
|
||||
export const pauseV1ConversationSandbox = async (conversationId: string) => {
|
||||
const { sandboxId } = await fetchV1ConversationData(conversationId);
|
||||
return SandboxService.pauseSandbox(sandboxId);
|
||||
return V1ConversationService.pauseSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -76,7 +75,7 @@ export const stopV0Conversation = async (conversationId: string) =>
|
||||
*/
|
||||
export const resumeV1ConversationSandbox = async (conversationId: string) => {
|
||||
const { sandboxId } = await fetchV1ConversationData(conversationId);
|
||||
return SandboxService.resumeSandbox(sandboxId);
|
||||
return V1ConversationService.resumeSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
export const useBatchSandboxes = (ids: string[]) =>
|
||||
useQuery({
|
||||
queryKey: ["sandboxes", "batch", ids],
|
||||
queryFn: () => SandboxService.batchGetSandboxes(ids),
|
||||
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
|
||||
@@ -91,7 +91,6 @@ export const useTerminal = () => {
|
||||
|
||||
return () => {
|
||||
terminal.current?.dispose();
|
||||
lastCommandIndex.current = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from "react";
|
||||
import { useWsClient, V0_WebSocketStatus } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
/**
|
||||
* Unified hook that returns the current WebSocket status
|
||||
@@ -10,15 +9,11 @@ import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
* - For V1 conversations: Returns status from ConversationWebSocketProvider
|
||||
*/
|
||||
export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const v0Status = useWsClient();
|
||||
const v1Context = useConversationWebSocket();
|
||||
|
||||
// Check if this is a V1 conversation:
|
||||
const isV1Conversation =
|
||||
conversationId.startsWith("task-") ||
|
||||
conversation?.conversation_version === "V1";
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const webSocketStatus = useMemo(() => {
|
||||
if (isV1Conversation) {
|
||||
@@ -38,13 +33,7 @@ export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
|
||||
}
|
||||
}
|
||||
return v0Status.webSocketStatus;
|
||||
}, [
|
||||
isV1Conversation,
|
||||
v1Context,
|
||||
v0Status.webSocketStatus,
|
||||
conversationId,
|
||||
conversation,
|
||||
]);
|
||||
}, [isV1Conversation, v1Context, v0Status.webSocketStatus]);
|
||||
|
||||
return webSocketStatus;
|
||||
}
|
||||
|
||||
@@ -19,4 +19,3 @@ 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");
|
||||
|
||||
@@ -2,8 +2,7 @@ import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
|
||||
/**
|
||||
* Handles adding an event to the UI events array
|
||||
* Replaces actions with observations when they arrive (so UI shows observation instead of action)
|
||||
* Handles adding an event to the UI events array, with special logic for observation events
|
||||
*/
|
||||
export const handleEventForUI = (
|
||||
event: OpenHandsEvent,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# OpenHands V1 CLI
|
||||
|
||||
A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [OpenHands software-agent-sdk](https://github.com/OpenHands/software-agent-sdk)).
|
||||
A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [agent-sdk](https://github.com/OpenHands/agent-sdk)).
|
||||
|
||||
The [OpenHands V0 CLI (legacy)](https://github.com/OpenHands/OpenHands/tree/main/openhands/cli) is being deprecated.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,4 +33,4 @@ uv run openhands
|
||||
# The binary will be in dist/
|
||||
./dist/openhands # macOS/Linux
|
||||
# dist/openhands.exe # Windows
|
||||
```
|
||||
```
|
||||
+12
-16
@@ -20,6 +20,15 @@ from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
|
||||
|
||||
from openhands.sdk import LLM
|
||||
|
||||
dummy_agent = get_default_cli_agent(
|
||||
llm=LLM(
|
||||
model='dummy-model',
|
||||
api_key='dummy-key',
|
||||
metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
|
||||
),
|
||||
cli_mode=True,
|
||||
)
|
||||
|
||||
# =================================================
|
||||
# SECTION: Build Binary
|
||||
# =================================================
|
||||
@@ -117,7 +126,7 @@ def _is_welcome(line: str) -> bool:
|
||||
return any(marker in s for marker in WELCOME_MARKERS)
|
||||
|
||||
|
||||
def test_executable(dummy_agent) -> bool:
|
||||
def test_executable() -> bool:
|
||||
"""Test the built executable, measuring boot time and total test time."""
|
||||
print('🧪 Testing the built executable...')
|
||||
|
||||
@@ -265,14 +274,7 @@ def main() -> int:
|
||||
|
||||
# Test the executable
|
||||
if not args.no_test:
|
||||
dummy_agent = get_default_cli_agent(
|
||||
llm=LLM(
|
||||
model='dummy-model',
|
||||
api_key='dummy-key',
|
||||
metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
|
||||
)
|
||||
)
|
||||
if not test_executable(dummy_agent):
|
||||
if not test_executable():
|
||||
print('❌ Executable test failed, build process failed')
|
||||
return 1
|
||||
|
||||
@@ -283,10 +285,4 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print('❌ Executable test failed')
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(main())
|
||||
|
||||
@@ -127,7 +127,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
break
|
||||
|
||||
elif command == '/settings':
|
||||
settings_screen = SettingsScreen(runner.conversation if runner else None)
|
||||
settings_screen = SettingsScreen(conversation)
|
||||
settings_screen.display_settings()
|
||||
continue
|
||||
|
||||
@@ -143,9 +143,8 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
elif command == '/new':
|
||||
try:
|
||||
# Start a fresh conversation (no resume ID = new conversation)
|
||||
conversation_id = uuid.uuid4()
|
||||
runner = None
|
||||
conversation = None
|
||||
conversation = setup_conversation(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
display_welcome(conversation_id, resume=False)
|
||||
print_formatted_text(
|
||||
HTML('<green>✓ Started fresh conversation</green>')
|
||||
@@ -196,7 +195,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
# Resume without new message
|
||||
message = None
|
||||
|
||||
if not runner or not conversation:
|
||||
if not runner:
|
||||
conversation = setup_conversation(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
runner.process_message(message)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from openhands_cli.listeners.loading_listener import LoadingContext
|
||||
from openhands_cli.listeners.pause_listener import PauseListener
|
||||
|
||||
__all__ = ['PauseListener']
|
||||
__all__ = ['PauseListener', 'LoadingContext']
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Loading animation utilities for OpenHands CLI.
|
||||
Provides animated loading screens during agent initialization.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def display_initialization_animation(text: str, is_loaded: threading.Event) -> None:
|
||||
"""Display a spinning animation while agent is being initialized.
|
||||
|
||||
Args:
|
||||
text: The text to display alongside the animation
|
||||
is_loaded: Threading event that signals when loading is complete
|
||||
"""
|
||||
ANIMATION_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
|
||||
i = 0
|
||||
while not is_loaded.is_set():
|
||||
sys.stdout.write('\n')
|
||||
sys.stdout.write(
|
||||
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
class LoadingContext:
|
||||
"""Context manager for displaying loading animations in a separate thread."""
|
||||
|
||||
def __init__(self, text: str):
|
||||
"""Initialize the loading context.
|
||||
|
||||
Args:
|
||||
text: The text to display during loading
|
||||
"""
|
||||
self.text = text
|
||||
self.is_loaded = threading.Event()
|
||||
self.loading_thread: threading.Thread | None = None
|
||||
|
||||
def __enter__(self) -> 'LoadingContext':
|
||||
"""Start the loading animation in a separate thread."""
|
||||
self.loading_thread = threading.Thread(
|
||||
target=display_initialization_animation,
|
||||
args=(self.text, self.is_loaded),
|
||||
daemon=True,
|
||||
)
|
||||
self.loading_thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
"""Stop the loading animation and clean up the thread."""
|
||||
self.is_loaded.set()
|
||||
if self.loading_thread:
|
||||
self.loading_thread.join(
|
||||
timeout=1.0
|
||||
) # Wait up to 1 second for thread to finish
|
||||
@@ -6,6 +6,7 @@ from openhands.sdk import Agent, BaseConversation, Conversation, Workspace, regi
|
||||
from openhands.tools.execute_bash import BashTool
|
||||
from openhands.tools.file_editor import FileEditorTool
|
||||
from openhands.tools.task_tracker import TaskTrackerTool
|
||||
from openhands_cli.listeners import LoadingContext
|
||||
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
from openhands.sdk.security.confirmation_policy import (
|
||||
@@ -69,29 +70,26 @@ def setup_conversation(
|
||||
MissingAgentSpec: If agent specification is not found or invalid.
|
||||
"""
|
||||
|
||||
print_formatted_text(
|
||||
HTML(f'<white>Initializing agent...</white>')
|
||||
)
|
||||
with LoadingContext('Initializing OpenHands agent...'):
|
||||
agent = load_agent_specs(str(conversation_id))
|
||||
|
||||
agent = load_agent_specs(str(conversation_id))
|
||||
if not include_security_analyzer:
|
||||
# Remove security analyzer from agent spec
|
||||
agent = agent.model_copy(
|
||||
update={"security_analyzer": None}
|
||||
)
|
||||
|
||||
if not include_security_analyzer:
|
||||
# Remove security analyzer from agent spec
|
||||
agent = agent.model_copy(
|
||||
update={"security_analyzer": None}
|
||||
# Create conversation - agent context is now set in AgentStore.load()
|
||||
conversation: BaseConversation = Conversation(
|
||||
agent=agent,
|
||||
workspace=Workspace(working_dir=WORK_DIR),
|
||||
# Conversation will add /<conversation_id> to this path
|
||||
persistence_dir=CONVERSATIONS_DIR,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
# Create conversation - agent context is now set in AgentStore.load()
|
||||
conversation: BaseConversation = Conversation(
|
||||
agent=agent,
|
||||
workspace=Workspace(working_dir=WORK_DIR),
|
||||
# Conversation will add /<conversation_id> to this path
|
||||
persistence_dir=CONVERSATIONS_DIR,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
if include_security_analyzer:
|
||||
conversation.set_confirmation_policy(AlwaysConfirm())
|
||||
if include_security_analyzer:
|
||||
conversation.set_confirmation_policy(AlwaysConfirm())
|
||||
|
||||
print_formatted_text(
|
||||
HTML(f'<green>✓ Agent initialized with model: {agent.llm.model}</green>')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
|
||||
from openhands.sdk import LLM, BaseConversation, LLMSummarizingCondenser, LocalFileStore
|
||||
from openhands.sdk import LLM, BaseConversation, LocalFileStore
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from prompt_toolkit.shortcuts import print_container
|
||||
from prompt_toolkit.widgets import Frame, TextArea
|
||||
@@ -33,6 +33,9 @@ class SettingsScreen:
|
||||
agent_spec = self.agent_store.load()
|
||||
if not agent_spec:
|
||||
return
|
||||
assert self.conversation is not None, (
|
||||
'Conversation must be set to display settings.'
|
||||
)
|
||||
|
||||
llm = agent_spec.llm
|
||||
advanced_llm_settings = True if llm.base_url else False
|
||||
@@ -59,20 +62,12 @@ class SettingsScreen:
|
||||
labels_and_values.extend(
|
||||
[
|
||||
(' API Key', '********' if llm.api_key else 'Not Set'),
|
||||
]
|
||||
)
|
||||
|
||||
if self.conversation:
|
||||
labels_and_values.extend([
|
||||
(
|
||||
' Confirmation Mode',
|
||||
'Enabled'
|
||||
if self.conversation.is_confirmation_mode_active
|
||||
else 'Disabled',
|
||||
)
|
||||
])
|
||||
|
||||
labels_and_values.extend([
|
||||
),
|
||||
(
|
||||
' Memory Condensation',
|
||||
'Enabled' if agent_spec.condenser else 'Disabled',
|
||||
@@ -158,7 +153,7 @@ class SettingsScreen:
|
||||
api_key = prompt_api_key(
|
||||
step_counter,
|
||||
custom_model.split('/')[0] if len(custom_model.split('/')) > 1 else '',
|
||||
self.conversation.state.agent.llm.api_key if self.conversation else None,
|
||||
self.conversation.agent.llm.api_key if self.conversation else None,
|
||||
escapable=escapable,
|
||||
)
|
||||
memory_condensation = choose_memory_condensation(step_counter)
|
||||
@@ -187,14 +182,7 @@ class SettingsScreen:
|
||||
if not agent:
|
||||
agent = get_default_cli_agent(llm=llm)
|
||||
|
||||
# Must update all LLMs
|
||||
agent = agent.model_copy(update={'llm': llm})
|
||||
condenser = LLMSummarizingCondenser(
|
||||
llm=llm.model_copy(
|
||||
update={"usage_id": "condenser"}
|
||||
)
|
||||
)
|
||||
agent = agent.model_copy(update={'condenser': condenser})
|
||||
self.agent_store.save(agent)
|
||||
|
||||
def _save_advanced_settings(
|
||||
|
||||
@@ -45,7 +45,9 @@ class AgentStore:
|
||||
system_message_suffix=f'You current working directory is: {WORK_DIR}',
|
||||
)
|
||||
|
||||
mcp_config: dict = self.load_mcp_configuration()
|
||||
additional_mcp_config = self.load_mcp_configuration()
|
||||
mcp_config: dict = agent.mcp_config.copy().get('mcpServers', {})
|
||||
mcp_config.update(additional_mcp_config)
|
||||
|
||||
# Update LLM metadata with current information
|
||||
agent_llm_metadata = get_llm_metadata(
|
||||
|
||||
@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
|
||||
|
||||
[project]
|
||||
name = "openhands"
|
||||
version = "1.0.5"
|
||||
version = "1.0.4"
|
||||
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output.defaults import DummyOutput
|
||||
|
||||
from openhands_cli.setup import (
|
||||
MissingAgentSpec,
|
||||
verify_agent_exists_or_setup_agent,
|
||||
)
|
||||
from openhands_cli.setup import MissingAgentSpec, verify_agent_exists_or_setup_agent, setup_conversation
|
||||
from openhands_cli.user_actions import UserConfirmation
|
||||
|
||||
|
||||
@patch("openhands_cli.setup.load_agent_specs")
|
||||
@patch('openhands_cli.setup.load_agent_specs')
|
||||
def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
|
||||
"""Test that verify_agent_exists_or_setup_agent returns agent successfully."""
|
||||
# Mock the agent object
|
||||
@@ -28,10 +22,11 @@ def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
|
||||
mock_load_agent_specs.assert_called_once_with()
|
||||
|
||||
|
||||
@patch("openhands_cli.setup.SettingsScreen")
|
||||
@patch("openhands_cli.setup.load_agent_specs")
|
||||
@patch('openhands_cli.setup.SettingsScreen')
|
||||
@patch('openhands_cli.setup.load_agent_specs')
|
||||
def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
|
||||
mock_load_agent_specs, mock_settings_screen_class
|
||||
mock_load_agent_specs,
|
||||
mock_settings_screen_class
|
||||
):
|
||||
"""Test that verify_agent_exists_or_setup_agent handles MissingAgentSpec exception."""
|
||||
# Mock the SettingsScreen instance
|
||||
@@ -42,7 +37,7 @@ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
|
||||
mock_agent = MagicMock()
|
||||
mock_load_agent_specs.side_effect = [
|
||||
MissingAgentSpec("Agent spec missing"),
|
||||
mock_agent,
|
||||
mock_agent
|
||||
]
|
||||
|
||||
# Call the function
|
||||
@@ -56,11 +51,14 @@ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
|
||||
mock_settings_screen.configure_settings.assert_called_once_with(first_time=True)
|
||||
|
||||
|
||||
@patch("openhands_cli.agent_chat.exit_session_confirmation")
|
||||
@patch("openhands_cli.agent_chat.get_session_prompter")
|
||||
@patch("openhands_cli.agent_chat.setup_conversation")
|
||||
@patch("openhands_cli.agent_chat.verify_agent_exists_or_setup_agent")
|
||||
@patch("openhands_cli.agent_chat.ConversationRunner")
|
||||
|
||||
|
||||
|
||||
@patch('openhands_cli.agent_chat.exit_session_confirmation')
|
||||
@patch('openhands_cli.agent_chat.get_session_prompter')
|
||||
@patch('openhands_cli.agent_chat.setup_conversation')
|
||||
@patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
|
||||
@patch('openhands_cli.agent_chat.ConversationRunner')
|
||||
def test_new_command_resets_confirmation_mode(
|
||||
mock_runner_cls,
|
||||
mock_verify_agent,
|
||||
@@ -76,35 +74,27 @@ def test_new_command_resets_confirmation_mode(
|
||||
mock_verify_agent.return_value = mock_agent
|
||||
|
||||
# Mock conversation - only one is created when /new is called
|
||||
conv1 = MagicMock()
|
||||
conv1.id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||
mock_setup_conversation.return_value = conv1
|
||||
|
||||
# One runner instance for the conversation
|
||||
runner1 = MagicMock()
|
||||
runner1.is_confirmation_mode_active = True
|
||||
runner1 = MagicMock(); runner1.is_confirmation_mode_active = True
|
||||
mock_runner_cls.return_value = runner1
|
||||
|
||||
# Real session fed by a pipe (no interactive confirmation now)
|
||||
from openhands_cli.user_actions.utils import (
|
||||
get_session_prompter as real_get_session_prompter,
|
||||
)
|
||||
|
||||
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
|
||||
with create_pipe_input() as pipe:
|
||||
output = DummyOutput()
|
||||
session = real_get_session_prompter(input=pipe, output=output)
|
||||
mock_get_session_prompter.return_value = session
|
||||
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
|
||||
# Trigger /new
|
||||
# First user message should trigger runner creation
|
||||
# Then /exit (exit will be auto-accepted)
|
||||
for ch in "/new\rhello\r/exit\r":
|
||||
# Trigger /new, then /exit (exit will be auto-accepted)
|
||||
for ch in "/new\r/exit\r":
|
||||
pipe.send_text(ch)
|
||||
|
||||
run_cli_entry(None)
|
||||
|
||||
# Assert we created one runner for the conversation when a message was processed after /new
|
||||
# Assert we created one runner for the conversation when /new was called
|
||||
assert mock_runner_cls.call_count == 1
|
||||
assert mock_runner_cls.call_args_list[0].args[0] is conv1
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Test for the /settings command functionality."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output.defaults import DummyOutput
|
||||
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
from openhands_cli.user_actions import UserConfirmation
|
||||
|
||||
|
||||
@patch('openhands_cli.agent_chat.exit_session_confirmation')
|
||||
@patch('openhands_cli.agent_chat.get_session_prompter')
|
||||
@patch('openhands_cli.agent_chat.setup_conversation')
|
||||
@patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
|
||||
@patch('openhands_cli.agent_chat.ConversationRunner')
|
||||
@patch('openhands_cli.agent_chat.SettingsScreen')
|
||||
def test_settings_command_works_without_conversation(
|
||||
mock_settings_screen_class,
|
||||
mock_runner_cls,
|
||||
mock_verify_agent,
|
||||
mock_setup_conversation,
|
||||
mock_get_session_prompter,
|
||||
mock_exit_confirm,
|
||||
):
|
||||
"""Test that /settings command works when no conversation is active (bug fix scenario)."""
|
||||
# Auto-accept the exit prompt to avoid interactive UI
|
||||
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
|
||||
|
||||
# Mock agent verification to succeed
|
||||
mock_agent = MagicMock()
|
||||
mock_verify_agent.return_value = mock_agent
|
||||
|
||||
# Mock the SettingsScreen instance
|
||||
mock_settings_screen = MagicMock()
|
||||
mock_settings_screen_class.return_value = mock_settings_screen
|
||||
|
||||
# No runner initially (simulates starting CLI without a conversation)
|
||||
mock_runner_cls.return_value = None
|
||||
|
||||
# Real session fed by a pipe
|
||||
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
|
||||
with create_pipe_input() as pipe:
|
||||
output = DummyOutput()
|
||||
session = real_get_session_prompter(input=pipe, output=output)
|
||||
mock_get_session_prompter.return_value = session
|
||||
|
||||
# Trigger /settings, then /exit (exit will be auto-accepted)
|
||||
for ch in "/settings\r/exit\r":
|
||||
pipe.send_text(ch)
|
||||
|
||||
run_cli_entry(None)
|
||||
|
||||
# Assert SettingsScreen was created with None conversation (the bug fix)
|
||||
mock_settings_screen_class.assert_called_once_with(None)
|
||||
|
||||
# Assert display_settings was called (settings screen was shown)
|
||||
mock_settings_screen.display_settings.assert_called_once()
|
||||
@@ -1,114 +0,0 @@
|
||||
"""Minimal tests: mcp.json overrides persisted agent MCP servers."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.sdk import Agent, LLM
|
||||
from openhands_cli.locations import MCP_CONFIG_FILE, AGENT_SETTINGS_PATH
|
||||
from openhands_cli.tui.settings.store import AgentStore
|
||||
|
||||
|
||||
# ---------------------- tiny helpers ----------------------
|
||||
|
||||
def write_json(path: Path, obj: dict) -> None:
|
||||
path.write_text(json.dumps(obj))
|
||||
|
||||
|
||||
def write_agent(root: Path, agent: Agent) -> None:
|
||||
(root / AGENT_SETTINGS_PATH).write_text(
|
||||
agent.model_dump_json(context={"expose_secrets": True})
|
||||
)
|
||||
|
||||
|
||||
# ---------------------- fixtures ----------------------
|
||||
|
||||
@pytest.fixture
|
||||
def persistence_dir(tmp_path, monkeypatch) -> Path:
|
||||
# Create root dir and point AgentStore at it
|
||||
root = tmp_path / "openhands"
|
||||
root.mkdir()
|
||||
monkeypatch.setattr("openhands_cli.tui.settings.store.PERSISTENCE_DIR", str(root))
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_store() -> AgentStore:
|
||||
return AgentStore()
|
||||
|
||||
|
||||
# ---------------------- tests ----------------------
|
||||
|
||||
@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[])
|
||||
@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={})
|
||||
def test_load_overrides_persisted_mcp_with_mcp_json_file(
|
||||
mock_meta,
|
||||
mock_tools,
|
||||
persistence_dir,
|
||||
agent_store
|
||||
):
|
||||
"""If agent has MCP servers, mcp.json must replace them entirely."""
|
||||
# Persist an agent that already contains MCP servers
|
||||
persisted_agent = Agent(
|
||||
llm=LLM(model="gpt-4", api_key=SecretStr("k"), usage_id="svc"),
|
||||
tools=[],
|
||||
mcp_config={
|
||||
"mcpServers": {
|
||||
"persistent_server": {"command": "python", "args": ["-m", "old_server"]}
|
||||
}
|
||||
},
|
||||
)
|
||||
write_agent(persistence_dir, persisted_agent)
|
||||
|
||||
# Create mcp.json with different servers (this must fully override)
|
||||
write_json(
|
||||
persistence_dir / MCP_CONFIG_FILE,
|
||||
{
|
||||
"mcpServers": {
|
||||
"file_server": {"command": "uvx", "args": ["mcp-server-fetch"]}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
loaded = agent_store.load()
|
||||
assert loaded is not None
|
||||
# Expect ONLY the MCP json file's config
|
||||
assert loaded.mcp_config == {
|
||||
"mcpServers": {
|
||||
"file_server": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"env": {},
|
||||
"transport": "stdio",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[])
|
||||
@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={})
|
||||
def test_load_when_mcp_file_missing_ignores_persisted_mcp(
|
||||
mock_meta,
|
||||
mock_tools,
|
||||
persistence_dir,
|
||||
agent_store
|
||||
):
|
||||
"""If mcp.json is absent, loaded agent.mcp_config should be empty (persisted MCP ignored)."""
|
||||
persisted_agent = Agent(
|
||||
llm=LLM(model="gpt-4", api_key=SecretStr("k"), usage_id="svc"),
|
||||
tools=[],
|
||||
mcp_config={
|
||||
"mcpServers": {
|
||||
"persistent_server": {"command": "python", "args": ["-m", "old_server"]}
|
||||
}
|
||||
},
|
||||
)
|
||||
write_agent(persistence_dir, persisted_agent)
|
||||
|
||||
# No mcp.json created
|
||||
|
||||
loaded = agent_store.load()
|
||||
assert loaded is not None
|
||||
assert loaded.mcp_config == {} # persisted MCP is ignored if file is missin
|
||||
@@ -121,38 +121,6 @@ def test_update_existing_settings_workflow(tmp_path: Path):
|
||||
assert True # If we get here, the workflow completed successfully
|
||||
|
||||
|
||||
def test_all_llms_in_agent_are_updated():
|
||||
"""Test that modifying LLM settings creates multiple LLMs with same API key but different usage_ids."""
|
||||
# Create a screen with existing agent settings
|
||||
screen = SettingsScreen(conversation=None)
|
||||
initial_llm = LLM(model='openai/gpt-3.5-turbo', api_key=SecretStr('sk-initial'), usage_id='test-service')
|
||||
initial_agent = get_default_cli_agent(llm=initial_llm)
|
||||
|
||||
# Mock the agent store to return the initial agent and capture the save call
|
||||
with (
|
||||
patch.object(screen.agent_store, 'load', return_value=initial_agent),
|
||||
patch.object(screen.agent_store, 'save') as mock_save
|
||||
):
|
||||
# Modify the LLM settings with new API key
|
||||
screen._save_llm_settings(model='openai/gpt-4o-mini', api_key='sk-updated-123')
|
||||
mock_save.assert_called_once()
|
||||
|
||||
# Get the saved agent from the mock
|
||||
saved_agent = mock_save.call_args[0][0]
|
||||
all_llms = list(saved_agent.get_all_llms())
|
||||
assert len(all_llms) >= 2, f"Expected at least 2 LLMs, got {len(all_llms)}"
|
||||
|
||||
# Verify all LLMs have the same API key
|
||||
api_keys = [llm.api_key.get_secret_value() for llm in all_llms]
|
||||
assert all(api_key == 'sk-updated-123' for api_key in api_keys), \
|
||||
f"Not all LLMs have the same API key: {api_keys}"
|
||||
|
||||
# Verify none of the usage_id attributes match
|
||||
usage_ids = [llm.usage_id for llm in all_llms]
|
||||
assert len(set(usage_ids)) == len(usage_ids), \
|
||||
f"Some usage_ids are duplicated: {usage_ids}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'step_to_cancel',
|
||||
['type', 'provider', 'model', 'apikey', 'save'],
|
||||
|
||||
@@ -73,6 +73,8 @@ class TestConfirmationMode:
|
||||
persistence_dir=ANY,
|
||||
conversation_id=mock_conversation_id,
|
||||
)
|
||||
# Verify print_formatted_text was called
|
||||
mock_print.assert_called_once()
|
||||
|
||||
def test_setup_conversation_raises_missing_agent_spec(self) -> None:
|
||||
"""Test that setup_conversation raises MissingAgentSpec when agent is not found."""
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for the loading animation functionality.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from openhands_cli.listeners.loading_listener import (
|
||||
LoadingContext,
|
||||
display_initialization_animation,
|
||||
)
|
||||
|
||||
|
||||
class TestLoadingAnimation(unittest.TestCase):
|
||||
"""Test cases for loading animation functionality."""
|
||||
|
||||
def test_loading_context_manager(self):
|
||||
"""Test that LoadingContext works as a context manager."""
|
||||
with LoadingContext('Test loading...') as ctx:
|
||||
self.assertIsInstance(ctx, LoadingContext)
|
||||
self.assertEqual(ctx.text, 'Test loading...')
|
||||
self.assertIsInstance(ctx.is_loaded, threading.Event)
|
||||
self.assertIsNotNone(ctx.loading_thread)
|
||||
# Give the thread a moment to start
|
||||
time.sleep(0.1)
|
||||
self.assertTrue(ctx.loading_thread.is_alive())
|
||||
|
||||
# After exiting context, thread should be stopped
|
||||
time.sleep(0.1)
|
||||
self.assertFalse(ctx.loading_thread.is_alive())
|
||||
|
||||
@patch('sys.stdout')
|
||||
def test_animation_writes_while_running_and_stops_after(self, mock_stdout):
|
||||
"""Ensure stdout is written while animation runs and stops after it ends."""
|
||||
is_loaded = threading.Event()
|
||||
|
||||
animation_thread = threading.Thread(
|
||||
target=display_initialization_animation,
|
||||
args=('Test output', is_loaded),
|
||||
daemon=True,
|
||||
)
|
||||
animation_thread.start()
|
||||
|
||||
# Let it run a bit and check calls
|
||||
time.sleep(0.2)
|
||||
calls_while_running = mock_stdout.write.call_count
|
||||
self.assertGreater(calls_while_running, 0, 'Expected writes while spinner runs')
|
||||
|
||||
# Stop animation
|
||||
is_loaded.set()
|
||||
time.sleep(0.2)
|
||||
|
||||
animation_thread.join(timeout=1.0)
|
||||
calls_after_stop = mock_stdout.write.call_count
|
||||
|
||||
# Wait a moment to detect any stray writes after thread finished
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(
|
||||
calls_after_stop,
|
||||
mock_stdout.write.call_count,
|
||||
'No extra writes should occur after animation stops',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Generated
+1
-1
@@ -1828,7 +1828,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands"
|
||||
version = "1.0.5"
|
||||
version = "1.0.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openhands-sdk" },
|
||||
|
||||
@@ -57,16 +57,6 @@ class AppConversationInfoService(ABC):
|
||||
]
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def delete_app_conversation_info(self, conversation_id: UUID) -> bool:
|
||||
"""Delete a conversation info from the database.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to delete.
|
||||
|
||||
Returns True if the conversation was deleted successfully, False otherwise.
|
||||
"""
|
||||
|
||||
# Mutators
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -95,21 +95,6 @@ class AppConversationService(ABC):
|
||||
"""Run the setup scripts for the project and yield status updates"""
|
||||
yield task
|
||||
|
||||
@abstractmethod
|
||||
async def delete_app_conversation(self, conversation_id: UUID) -> bool:
|
||||
"""Delete a V1 conversation and all its associated data.
|
||||
|
||||
Args:
|
||||
conversation_id: The UUID of the conversation to delete.
|
||||
|
||||
This method should:
|
||||
1. Delete the conversation from the database
|
||||
2. Call the agent server to delete the conversation
|
||||
3. Clean up any related data
|
||||
|
||||
Returns True if the conversation was deleted successfully, False otherwise.
|
||||
"""
|
||||
|
||||
|
||||
class AppConversationServiceInjector(
|
||||
DiscriminatedUnionMixin, Injector[AppConversationService], ABC
|
||||
|
||||
@@ -56,16 +56,6 @@ class AppConversationStartTaskService(ABC):
|
||||
Return the stored task
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def delete_app_conversation_start_tasks(self, conversation_id: UUID) -> bool:
|
||||
"""Delete all start tasks associated with a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to delete tasks for.
|
||||
|
||||
Returns True if any tasks were deleted successfully, False otherwise.
|
||||
"""
|
||||
|
||||
|
||||
class AppConversationStartTaskServiceInjector(
|
||||
DiscriminatedUnionMixin, Injector[AppConversationStartTaskService], ABC
|
||||
|
||||
@@ -39,9 +39,6 @@ from openhands.app_server.app_conversation.app_conversation_start_task_service i
|
||||
from openhands.app_server.app_conversation.git_app_conversation_service import (
|
||||
GitAppConversationService,
|
||||
)
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
SQLAppConversationInfoService,
|
||||
)
|
||||
from openhands.app_server.errors import SandboxError
|
||||
from openhands.app_server.sandbox.docker_sandbox_service import DockerSandboxService
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
@@ -532,101 +529,6 @@ class LiveStatusAppConversationService(GitAppConversationService):
|
||||
f'Successfully updated agent-server conversation {conversation_id} title to "{new_title}"'
|
||||
)
|
||||
|
||||
async def delete_app_conversation(self, conversation_id: UUID) -> bool:
|
||||
"""Delete a V1 conversation and all its associated data.
|
||||
|
||||
Args:
|
||||
conversation_id: The UUID of the conversation to delete.
|
||||
"""
|
||||
# Check if we have the required SQL implementation for transactional deletion
|
||||
if not isinstance(
|
||||
self.app_conversation_info_service, SQLAppConversationInfoService
|
||||
):
|
||||
_logger.error(
|
||||
f'Cannot delete V1 conversation {conversation_id}: SQL implementation required for transactional deletion',
|
||||
extra={'conversation_id': str(conversation_id)},
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
# First, fetch the conversation to get the full object needed for agent server deletion
|
||||
app_conversation = await self.get_app_conversation(conversation_id)
|
||||
if not app_conversation:
|
||||
_logger.warning(
|
||||
f'V1 conversation {conversation_id} not found for deletion',
|
||||
extra={'conversation_id': str(conversation_id)},
|
||||
)
|
||||
return False
|
||||
|
||||
# Delete from agent server if sandbox is running
|
||||
await self._delete_from_agent_server(app_conversation)
|
||||
|
||||
# Delete from database using the conversation info from app_conversation
|
||||
# AppConversation extends AppConversationInfo, so we can use it directly
|
||||
return await self._delete_from_database(app_conversation)
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
f'Error deleting V1 conversation {conversation_id}: {e}',
|
||||
extra={'conversation_id': str(conversation_id)},
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
async def _delete_from_agent_server(
|
||||
self, app_conversation: AppConversation
|
||||
) -> None:
|
||||
"""Delete conversation from agent server if sandbox is running."""
|
||||
conversation_id = app_conversation.id
|
||||
if not (
|
||||
app_conversation.sandbox_status == SandboxStatus.RUNNING
|
||||
and app_conversation.session_api_key
|
||||
):
|
||||
return
|
||||
|
||||
try:
|
||||
# Get sandbox info to find agent server URL
|
||||
sandbox = await self.sandbox_service.get_sandbox(
|
||||
app_conversation.sandbox_id
|
||||
)
|
||||
if sandbox and sandbox.exposed_urls:
|
||||
agent_server_url = self._get_agent_server_url(sandbox)
|
||||
|
||||
# Call agent server delete API
|
||||
response = await self.httpx_client.delete(
|
||||
f'{agent_server_url}/api/conversations/{conversation_id}',
|
||||
headers={'X-Session-API-Key': app_conversation.session_api_key},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
f'Failed to delete conversation from agent server: {e}',
|
||||
extra={'conversation_id': str(conversation_id)},
|
||||
)
|
||||
# Continue with database cleanup even if agent server call fails
|
||||
|
||||
async def _delete_from_database(
|
||||
self, app_conversation_info: AppConversationInfo
|
||||
) -> bool:
|
||||
"""Delete conversation from database.
|
||||
|
||||
Args:
|
||||
app_conversation_info: The app conversation info to delete (already fetched).
|
||||
"""
|
||||
# The session is already managed by the dependency injection system
|
||||
# No need for explicit transaction management here
|
||||
deleted_info = (
|
||||
await self.app_conversation_info_service.delete_app_conversation_info(
|
||||
app_conversation_info.id
|
||||
)
|
||||
)
|
||||
deleted_tasks = await self.app_conversation_start_task_service.delete_app_conversation_start_tasks(
|
||||
app_conversation_info.id
|
||||
)
|
||||
|
||||
return deleted_info or deleted_tasks
|
||||
|
||||
|
||||
class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector):
|
||||
sandbox_startup_timeout: int = Field(
|
||||
|
||||
@@ -356,9 +356,9 @@ class SQLAppConversationInfoService(AppConversationInfoService):
|
||||
sandbox_id=stored.sandbox_id,
|
||||
selected_repository=stored.selected_repository,
|
||||
selected_branch=stored.selected_branch,
|
||||
git_provider=(
|
||||
ProviderType(stored.git_provider) if stored.git_provider else None
|
||||
),
|
||||
git_provider=ProviderType(stored.git_provider)
|
||||
if stored.git_provider
|
||||
else None,
|
||||
title=stored.title,
|
||||
trigger=ConversationTrigger(stored.trigger) if stored.trigger else None,
|
||||
pr_number=stored.pr_number,
|
||||
@@ -375,34 +375,6 @@ class SQLAppConversationInfoService(AppConversationInfoService):
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
async def delete_app_conversation_info(self, conversation_id: UUID) -> bool:
|
||||
"""Delete a conversation info from the database.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to delete.
|
||||
|
||||
Returns True if the conversation was deleted successfully, False otherwise.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
# Build secure delete query with user context filtering
|
||||
delete_query = delete(StoredConversationMetadata).where(
|
||||
StoredConversationMetadata.conversation_id == str(conversation_id)
|
||||
)
|
||||
|
||||
# Apply user security filtering - only allow deletion of conversations owned by the current user
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if user_id:
|
||||
delete_query = delete_query.where(
|
||||
StoredConversationMetadata.user_id == user_id
|
||||
)
|
||||
|
||||
# Execute the secure delete query
|
||||
result = await self.db_session.execute(delete_query)
|
||||
await self.db_session.commit()
|
||||
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
class SQLAppConversationInfoServiceInjector(AppConversationInfoServiceInjector):
|
||||
async def inject(
|
||||
|
||||
@@ -180,11 +180,9 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
|
||||
|
||||
# Return tasks in the same order as requested, with None for missing ones
|
||||
return [
|
||||
(
|
||||
AppConversationStartTask(**row2dict(tasks_by_id[task_id]))
|
||||
if task_id in tasks_by_id
|
||||
else None
|
||||
)
|
||||
AppConversationStartTask(**row2dict(tasks_by_id[task_id]))
|
||||
if task_id in tasks_by_id
|
||||
else None
|
||||
for task_id in task_ids
|
||||
]
|
||||
|
||||
@@ -221,29 +219,6 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
|
||||
await self.session.commit()
|
||||
return task
|
||||
|
||||
async def delete_app_conversation_start_tasks(self, conversation_id: UUID) -> bool:
|
||||
"""Delete all start tasks associated with a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to delete tasks for.
|
||||
"""
|
||||
from sqlalchemy import delete
|
||||
|
||||
# Build secure delete query with user filter if user_id is set
|
||||
delete_query = delete(StoredAppConversationStartTask).where(
|
||||
StoredAppConversationStartTask.app_conversation_id == conversation_id
|
||||
)
|
||||
|
||||
if self.user_id:
|
||||
delete_query = delete_query.where(
|
||||
StoredAppConversationStartTask.created_by_user_id == self.user_id
|
||||
)
|
||||
|
||||
result = await self.session.execute(delete_query)
|
||||
|
||||
# Return True if any rows were affected
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
class SQLAppConversationStartTaskServiceInjector(
|
||||
AppConversationStartTaskServiceInjector
|
||||
|
||||
@@ -47,7 +47,6 @@ from openhands.app_server.utils.sql_utils import Base, UtcDateTime
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
WEBHOOK_CALLBACK_VARIABLE = 'OH_WEBHOOKS_0_BASE_URL'
|
||||
ALLOW_CORS_ORIGINS_VARIABLE = 'OH_ALLOW_CORS_ORIGINS_0'
|
||||
polling_task: asyncio.Task | None = None
|
||||
POD_STATUS_MAPPING = {
|
||||
'ready': SandboxStatus.RUNNING,
|
||||
@@ -129,10 +128,22 @@ class RemoteSandboxService(SandboxService):
|
||||
f'Error getting runtime: {stored.id}', stack_info=True
|
||||
)
|
||||
|
||||
status = self._get_sandbox_status_from_runtime(runtime)
|
||||
|
||||
# Get session_api_key and exposed urls
|
||||
if runtime:
|
||||
# Translate status
|
||||
status = None
|
||||
pod_status = runtime['pod_status'].lower()
|
||||
if pod_status:
|
||||
status = POD_STATUS_MAPPING.get(pod_status, None)
|
||||
|
||||
# If we failed to get the status from the pod status, fall back to status
|
||||
if status is None:
|
||||
runtime_status = runtime.get('status')
|
||||
if runtime_status:
|
||||
status = STATUS_MAPPING.get(runtime_status.lower(), None)
|
||||
|
||||
if status is None:
|
||||
status = SandboxStatus.MISSING
|
||||
|
||||
session_api_key = runtime['session_api_key']
|
||||
if status == SandboxStatus.RUNNING:
|
||||
exposed_urls = []
|
||||
@@ -154,6 +165,7 @@ class RemoteSandboxService(SandboxService):
|
||||
exposed_urls = None
|
||||
else:
|
||||
session_api_key = None
|
||||
status = SandboxStatus.MISSING
|
||||
exposed_urls = None
|
||||
|
||||
sandbox_spec_id = stored.sandbox_spec_id
|
||||
@@ -167,32 +179,6 @@ class RemoteSandboxService(SandboxService):
|
||||
created_at=stored.created_at,
|
||||
)
|
||||
|
||||
def _get_sandbox_status_from_runtime(
|
||||
self, runtime: dict[str, Any] | None
|
||||
) -> SandboxStatus:
|
||||
"""Derive a SandboxStatus from the runtime info. The legacy logic for getting
|
||||
the status of a runtime is inconsistent. It is divided between a "status" which
|
||||
cannot be trusted (It sometimes returns "running" for cases when the pod is
|
||||
still starting) and a "pod_status" which is not returned for list
|
||||
operations."""
|
||||
if not runtime:
|
||||
return SandboxStatus.MISSING
|
||||
|
||||
status = None
|
||||
pod_status = runtime['pod_status'].lower()
|
||||
if pod_status:
|
||||
status = POD_STATUS_MAPPING.get(pod_status, None)
|
||||
|
||||
# If we failed to get the status from the pod status, fall back to status
|
||||
if status is None:
|
||||
runtime_status = runtime.get('status')
|
||||
if runtime_status:
|
||||
status = STATUS_MAPPING.get(runtime_status.lower(), None)
|
||||
|
||||
if status is None:
|
||||
return SandboxStatus.MISSING
|
||||
return status
|
||||
|
||||
async def _secure_select(self):
|
||||
query = select(StoredRemoteSandbox)
|
||||
user_id = await self.user_context.get_user_id()
|
||||
@@ -227,9 +213,6 @@ class RemoteSandboxService(SandboxService):
|
||||
environment[WEBHOOK_CALLBACK_VARIABLE] = (
|
||||
f'{self.web_url}/api/v1/webhooks/{sandbox_id}'
|
||||
)
|
||||
# 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
|
||||
|
||||
return environment
|
||||
|
||||
@@ -631,7 +614,6 @@ class RemoteSandboxServiceInjector(SandboxServiceInjector):
|
||||
)
|
||||
|
||||
# If no public facing web url is defined, poll for changes as callbacks will be unavailable.
|
||||
# This is primarily used for local development rather than production
|
||||
config = get_global_config()
|
||||
web_url = config.web_url
|
||||
if web_url is None:
|
||||
|
||||
@@ -66,7 +66,6 @@ class SandboxService(ABC):
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
# The version of the agent server to use for deployments.
|
||||
# Typically this will be the same as the values from the pyproject.toml
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:be9725b-python'
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:3d8af53-python'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
@@ -148,10 +148,7 @@ def load_from_toml(cfg: OpenHandsConfig, toml_file: str = 'config.toml') -> None
|
||||
try:
|
||||
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
|
||||
toml_config = toml.load(toml_contents)
|
||||
except FileNotFoundError as e:
|
||||
logger.openhands_logger.error(
|
||||
f'{toml_file} not found: {e}. Toml values have not been applied.'
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except toml.TomlDecodeError as e:
|
||||
logger.openhands_logger.warning(
|
||||
@@ -604,10 +601,7 @@ def get_llms_for_routing_config(toml_file: str = 'config.toml') -> dict[str, LLM
|
||||
try:
|
||||
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
|
||||
toml_config = toml.load(toml_contents)
|
||||
except FileNotFoundError as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Config file not found: {e}. Toml values have not been applied.'
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return llms_for_routing
|
||||
except toml.TomlDecodeError as e:
|
||||
logger.openhands_logger.error(
|
||||
|
||||
@@ -57,8 +57,8 @@ if TYPE_CHECKING:
|
||||
# Import Windows PowerShell support if on Windows
|
||||
if sys.platform == 'win32':
|
||||
try:
|
||||
from openhands.runtime.utils.windows_bash import WindowsPowershellSession
|
||||
from openhands.runtime.utils.windows_exceptions import DotNetMissingError
|
||||
from openhands.runtime.utils.windows_bash import WindowsPowershellSession # isort: skip
|
||||
except (ImportError, DotNetMissingError) as err:
|
||||
# Print a user-friendly error message without stack trace
|
||||
friendly_message = """
|
||||
|
||||
@@ -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.61-nikolaik"
|
||||
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.60-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
"workbench.startupEditor": "none",
|
||||
"chat.commandCenter.enabled": false
|
||||
"workbench.startupEditor": "none"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ way to manage PowerShell processes compared to using temporary script files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
@@ -47,121 +45,39 @@ except Exception as coreclr_ex:
|
||||
logger.error(f'{error_msg} Details: {details}')
|
||||
raise DotNetMissingError(error_msg, details)
|
||||
|
||||
|
||||
def find_latest_pwsh_sdk_path(
|
||||
executable_name='pwsh.exe',
|
||||
dll_name='System.Management.Automation.dll',
|
||||
min_version=(7, 0, 0),
|
||||
env_var='PWSH_DIR',
|
||||
):
|
||||
"""
|
||||
Checks PWSH_DIR environment variable first to find pwsh and DLL.
|
||||
If not found or not suitable, scans all pwsh executables in PATH, runs --version to find latest >= min_version.
|
||||
Returns full DLL path if found, else None.
|
||||
"""
|
||||
|
||||
def parse_version(output):
|
||||
# Extract semantic version from pwsh --version output
|
||||
match = re.search(r'(\d+)\.(\d+)\.(\d+)', output)
|
||||
if match:
|
||||
return tuple(map(int, match.groups()))
|
||||
return None
|
||||
|
||||
# Try environment variable override first
|
||||
pwsh_dir = os.environ.get(env_var)
|
||||
if pwsh_dir:
|
||||
pwsh_path = Path(pwsh_dir) / executable_name
|
||||
dll_path = Path(pwsh_dir) / dll_name
|
||||
if pwsh_path.is_file() and dll_path.is_file():
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[str(pwsh_path), '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
ver = parse_version(completed.stdout)
|
||||
if ver and ver >= min_version:
|
||||
logger.info(f'Found pwsh from env variable "{env_var}"')
|
||||
return str(dll_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Adjust executable_name for Windows if needed
|
||||
if os.name == 'nt' and not executable_name.lower().endswith('.exe'):
|
||||
executable_name += '.exe'
|
||||
|
||||
# Search PATH for all pwsh executables
|
||||
paths = os.environ.get('PATH', '').split(os.pathsep)
|
||||
candidates = []
|
||||
for p in paths:
|
||||
exe_path = Path(p) / executable_name
|
||||
if exe_path.is_file() and os.access(str(exe_path), os.X_OK):
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[str(exe_path), '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
ver = parse_version(completed.stdout)
|
||||
if ver:
|
||||
candidates.append((ver, exe_path.resolve()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sort candidates by version descending
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
for ver, exe_path in candidates:
|
||||
if ver >= min_version:
|
||||
dll_path = exe_path.parent / dll_name
|
||||
if dll_path.is_file():
|
||||
return str(dll_path)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Attempt to load the PowerShell SDK assembly only if clr and System loaded
|
||||
ps_sdk_path = None
|
||||
try:
|
||||
# Attempt primary detection via helper function
|
||||
ps_sdk_path = find_latest_pwsh_sdk_path()
|
||||
if ps_sdk_path:
|
||||
# Prioritize PowerShell 7+ if available (adjust path if necessary)
|
||||
pwsh7_path = (
|
||||
Path(os.environ.get('ProgramFiles', 'C:\\Program Files'))
|
||||
/ 'PowerShell'
|
||||
/ '7'
|
||||
/ 'System.Management.Automation.dll'
|
||||
)
|
||||
if pwsh7_path.exists():
|
||||
ps_sdk_path = str(pwsh7_path)
|
||||
clr.AddReference(ps_sdk_path)
|
||||
logger.info(f'Loaded PowerShell SDK dynamically detected: {ps_sdk_path}')
|
||||
logger.info(f'Loaded PowerShell SDK (Core): {ps_sdk_path}')
|
||||
else:
|
||||
pwsh7_path = (
|
||||
Path(os.environ.get('ProgramFiles', 'C:\\Program Files'))
|
||||
/ 'PowerShell'
|
||||
/ '7'
|
||||
# Fallback to Windows PowerShell 5.1 bundled with Windows
|
||||
winps_path = (
|
||||
Path(os.environ.get('SystemRoot', 'C:\\Windows'))
|
||||
/ 'System32'
|
||||
/ 'WindowsPowerShell'
|
||||
/ 'v1.0'
|
||||
/ 'System.Management.Automation.dll'
|
||||
)
|
||||
if pwsh7_path.exists():
|
||||
ps_sdk_path = str(pwsh7_path)
|
||||
if winps_path.exists():
|
||||
ps_sdk_path = str(winps_path)
|
||||
clr.AddReference(ps_sdk_path)
|
||||
logger.info(f'Loaded PowerShell SDK (Core): {ps_sdk_path}')
|
||||
logger.debug(f'Loaded PowerShell SDK (Desktop): {ps_sdk_path}')
|
||||
else:
|
||||
# Fallback to Windows PowerShell 5.1 bundled with Windows
|
||||
winps_path = (
|
||||
Path(os.environ.get('SystemRoot', 'C:\\Windows'))
|
||||
/ 'System32'
|
||||
/ 'WindowsPowerShell'
|
||||
/ 'v1.0'
|
||||
/ 'System.Management.Automation.dll'
|
||||
# Last resort: try loading by assembly name (might work if in GAC or path)
|
||||
clr.AddReference('System.Management.Automation')
|
||||
logger.info(
|
||||
'Attempted to load PowerShell SDK by name (System.Management.Automation)'
|
||||
)
|
||||
if winps_path.exists():
|
||||
ps_sdk_path = str(winps_path)
|
||||
clr.AddReference(ps_sdk_path)
|
||||
logger.debug(f'Loaded PowerShell SDK (Desktop): {ps_sdk_path}')
|
||||
else:
|
||||
# Last resort: try loading by assembly name (might work if in GAC or path)
|
||||
clr.AddReference('System.Management.Automation')
|
||||
logger.info(
|
||||
'Attempted to load PowerShell SDK by name (System.Management.Automation)'
|
||||
)
|
||||
|
||||
from System.Management.Automation import JobState, PowerShell
|
||||
from System.Management.Automation.Language import Parser
|
||||
|
||||
@@ -465,59 +465,15 @@ async def get_conversation(
|
||||
async def delete_conversation(
|
||||
conversation_id: str = Depends(validate_conversation_id),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
app_conversation_service: AppConversationService = app_conversation_service_dependency,
|
||||
) -> bool:
|
||||
# Try V1 conversation first
|
||||
v1_result = await _try_delete_v1_conversation(
|
||||
conversation_id, app_conversation_service
|
||||
)
|
||||
if v1_result is not None:
|
||||
return v1_result
|
||||
|
||||
# V0 conversation logic
|
||||
return await _delete_v0_conversation(conversation_id, user_id)
|
||||
|
||||
|
||||
async def _try_delete_v1_conversation(
|
||||
conversation_id: str, app_conversation_service: AppConversationService
|
||||
) -> bool | None:
|
||||
"""Try to delete a V1 conversation. Returns None if not a V1 conversation."""
|
||||
try:
|
||||
conversation_uuid = uuid.UUID(conversation_id)
|
||||
# Check if it's a V1 conversation by trying to get it
|
||||
app_conversation = await app_conversation_service.get_app_conversation(
|
||||
conversation_uuid
|
||||
)
|
||||
if app_conversation:
|
||||
# This is a V1 conversation, delete it using the app conversation service
|
||||
# Pass the conversation ID for secure deletion
|
||||
return await app_conversation_service.delete_app_conversation(
|
||||
app_conversation.id
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
# Not a valid UUID, continue with V0 logic
|
||||
pass
|
||||
except Exception:
|
||||
# Some other error, continue with V0 logic
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
try:
|
||||
await conversation_store.get_metadata(conversation_id)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
# Stop the conversation if it's running
|
||||
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
|
||||
if is_running:
|
||||
await conversation_manager.close_session(conversation_id)
|
||||
|
||||
# Clean up runtime and metadata
|
||||
runtime_cls = get_runtime_cls(config.runtime)
|
||||
await runtime_cls.delete(conversation_id)
|
||||
await conversation_store.delete_metadata(conversation_id)
|
||||
@@ -1104,154 +1060,47 @@ def add_experiment_config_for_conversation(
|
||||
return False
|
||||
|
||||
|
||||
def _parse_combined_page_id(page_id: str | None) -> tuple[str | None, str | None]:
|
||||
"""Parse combined page_id to extract separate V0 and V1 page_ids.
|
||||
|
||||
Args:
|
||||
page_id: Combined page_id (base64-encoded JSON) or legacy V0 page_id
|
||||
|
||||
Returns:
|
||||
Tuple of (v0_page_id, v1_page_id)
|
||||
"""
|
||||
v0_page_id = None
|
||||
v1_page_id = None
|
||||
|
||||
if page_id:
|
||||
try:
|
||||
# Try to parse as JSON first
|
||||
page_data = json.loads(base64.b64decode(page_id))
|
||||
v0_page_id = page_data.get('v0')
|
||||
v1_page_id = page_data.get('v1')
|
||||
except (json.JSONDecodeError, TypeError, Exception):
|
||||
# Fallback: treat as v0 page_id for backward compatibility
|
||||
# This catches base64 decode errors and any other parsing issues
|
||||
v0_page_id = page_id
|
||||
|
||||
return v0_page_id, v1_page_id
|
||||
|
||||
|
||||
async def _fetch_v1_conversations_safe(
|
||||
app_conversation_service: AppConversationService,
|
||||
v1_page_id: str | None,
|
||||
limit: int,
|
||||
) -> tuple[list[ConversationInfo], str | None]:
|
||||
"""Safely fetch V1 conversations with error handling.
|
||||
|
||||
Args:
|
||||
app_conversation_service: App conversation service for V1
|
||||
v1_page_id: Page ID for V1 pagination
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
Tuple of (v1_conversations, v1_next_page_id)
|
||||
"""
|
||||
v1_conversations = []
|
||||
v1_next_page_id = None
|
||||
|
||||
try:
|
||||
age_filter_date = None
|
||||
if config.conversation_max_age_seconds:
|
||||
age_filter_date = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=config.conversation_max_age_seconds
|
||||
)
|
||||
|
||||
app_conversation_page = await app_conversation_service.search_app_conversations(
|
||||
page_id=v1_page_id,
|
||||
limit=limit,
|
||||
created_at__gte=age_filter_date,
|
||||
)
|
||||
|
||||
v1_conversations = [
|
||||
_to_conversation_info(app_conv) for app_conv in app_conversation_page.items
|
||||
]
|
||||
v1_next_page_id = app_conversation_page.next_page_id
|
||||
except Exception as e:
|
||||
# V1 system might not be available or initialized yet
|
||||
logger.debug(f'V1 conversation service not available: {str(e)}')
|
||||
|
||||
return v1_conversations, v1_next_page_id
|
||||
|
||||
|
||||
async def _process_v0_conversations(
|
||||
conversation_metadata_result_set,
|
||||
) -> list[ConversationInfo]:
|
||||
"""Process V0 conversations with age filtering and agent loop info.
|
||||
|
||||
Args:
|
||||
conversation_metadata_result_set: Result set from V0 conversation store
|
||||
|
||||
Returns:
|
||||
List of processed ConversationInfo objects
|
||||
"""
|
||||
# Apply age filter to V0 conversations
|
||||
v0_filtered_results = _filter_conversations_by_age(
|
||||
conversation_metadata_result_set.results,
|
||||
config.conversation_max_age_seconds,
|
||||
)
|
||||
|
||||
v0_conversation_ids = set(
|
||||
conversation.conversation_id for conversation in v0_filtered_results
|
||||
)
|
||||
|
||||
# Get agent loop info for V0 conversations
|
||||
await conversation_manager.get_connections(filter_to_sids=v0_conversation_ids)
|
||||
v0_agent_loop_info = await conversation_manager.get_agent_loop_info(
|
||||
filter_to_sids=v0_conversation_ids
|
||||
)
|
||||
v0_agent_loop_info_by_conversation_id = {
|
||||
info.conversation_id: info for info in v0_agent_loop_info
|
||||
}
|
||||
|
||||
# Convert to ConversationInfo objects
|
||||
v0_conversations = await wait_all(
|
||||
_get_conversation_info(
|
||||
conversation=conversation,
|
||||
num_connections=sum(
|
||||
1
|
||||
for conversation_id in v0_agent_loop_info_by_conversation_id.values()
|
||||
if conversation_id == conversation.conversation_id
|
||||
),
|
||||
agent_loop_info=v0_agent_loop_info_by_conversation_id.get(
|
||||
conversation.conversation_id
|
||||
),
|
||||
)
|
||||
for conversation in v0_filtered_results
|
||||
)
|
||||
|
||||
return v0_conversations
|
||||
|
||||
|
||||
async def _apply_microagent_filters(
|
||||
conversations: list[ConversationInfo],
|
||||
@app.get('/microagent-management/conversations')
|
||||
async def get_microagent_management_conversations(
|
||||
selected_repository: str,
|
||||
provider_handler: ProviderHandler,
|
||||
) -> list[ConversationInfo]:
|
||||
"""Apply microagent management specific filters to conversations.
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
) -> ConversationInfoResultSet:
|
||||
"""Get conversations for the microagent management page with pagination support.
|
||||
|
||||
Filters conversations by:
|
||||
- Trigger type (MICROAGENT_MANAGEMENT)
|
||||
- Repository match
|
||||
- PR status (only open PRs)
|
||||
This endpoint returns conversations with conversation_trigger = 'microagent_management'
|
||||
and only includes conversations with active PRs. Pagination is supported.
|
||||
|
||||
Args:
|
||||
conversations: List of conversations to filter
|
||||
selected_repository: Repository to filter by
|
||||
provider_handler: Handler for checking PR status
|
||||
|
||||
Returns:
|
||||
Filtered list of conversations
|
||||
page_id: Optional page ID for pagination
|
||||
limit: Maximum number of results per page (default: 20)
|
||||
selected_repository: Optional repository filter to limit results to a specific repository
|
||||
conversation_store: Conversation store dependency
|
||||
provider_tokens: Provider tokens for checking PR status
|
||||
"""
|
||||
filtered = []
|
||||
for conversation in conversations:
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
# Apply age filter first using common function
|
||||
filtered_results = _filter_conversations_by_age(
|
||||
conversation_metadata_result_set.results, config.conversation_max_age_seconds
|
||||
)
|
||||
|
||||
# Check if the last PR is active (not closed/merged)
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
|
||||
# Apply additional filters
|
||||
final_filtered_results = []
|
||||
for conversation in filtered_results:
|
||||
# Only include microagent_management conversations
|
||||
if conversation.trigger != ConversationTrigger.MICROAGENT_MANAGEMENT:
|
||||
continue
|
||||
|
||||
# Apply repository filter
|
||||
# Apply repository filter if specified
|
||||
if conversation.selected_repository != selected_repository:
|
||||
continue
|
||||
|
||||
# Check if PR is still open
|
||||
if (
|
||||
conversation.pr_number
|
||||
and len(conversation.pr_number) > 0
|
||||
@@ -1266,101 +1115,12 @@ async def _apply_microagent_filters(
|
||||
# Skip this conversation if the PR is closed/merged
|
||||
continue
|
||||
|
||||
filtered.append(conversation)
|
||||
final_filtered_results.append(conversation)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def _create_combined_page_id(
|
||||
v0_next_page_id: str | None, v1_next_page_id: str | None
|
||||
) -> str | None:
|
||||
"""Create a combined page_id from V0 and V1 page_ids.
|
||||
|
||||
Args:
|
||||
v0_next_page_id: Next page ID for V0 conversations
|
||||
v1_next_page_id: Next page ID for V1 conversations
|
||||
|
||||
Returns:
|
||||
Base64-encoded JSON combining both page_ids, or None if no next pages
|
||||
"""
|
||||
if not v0_next_page_id and not v1_next_page_id:
|
||||
return None
|
||||
|
||||
next_page_data = {
|
||||
'v0': v0_next_page_id,
|
||||
'v1': v1_next_page_id,
|
||||
}
|
||||
|
||||
return base64.b64encode(json.dumps(next_page_data).encode()).decode()
|
||||
|
||||
|
||||
@app.get('/microagent-management/conversations')
|
||||
async def get_microagent_management_conversations(
|
||||
selected_repository: str,
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
app_conversation_service: AppConversationService = app_conversation_service_dependency,
|
||||
) -> ConversationInfoResultSet:
|
||||
"""Get conversations for the microagent management page with pagination support.
|
||||
|
||||
This endpoint returns conversations with conversation_trigger = 'microagent_management'
|
||||
and only includes conversations with active PRs. Pagination is supported.
|
||||
|
||||
Args:
|
||||
page_id: Optional page ID for pagination
|
||||
limit: Maximum number of results per page (default: 20)
|
||||
selected_repository: Repository filter to limit results to a specific repository
|
||||
conversation_store: Conversation store dependency
|
||||
provider_tokens: Provider tokens for checking PR status
|
||||
app_conversation_service: App conversation service for V1 conversations
|
||||
|
||||
Returns:
|
||||
ConversationInfoResultSet with filtered and paginated results
|
||||
"""
|
||||
# Parse page_id to extract V0 and V1 components
|
||||
v0_page_id, v1_page_id = _parse_combined_page_id(page_id)
|
||||
|
||||
# Fetch V0 conversations
|
||||
conversation_metadata_result_set = await conversation_store.search(
|
||||
v0_page_id, limit
|
||||
return await _build_conversation_result_set(
|
||||
final_filtered_results, conversation_metadata_result_set.next_page_id
|
||||
)
|
||||
|
||||
# Fetch V1 conversations (with graceful error handling)
|
||||
v1_conversations, v1_next_page_id = await _fetch_v1_conversations_safe(
|
||||
app_conversation_service, v1_page_id, limit
|
||||
)
|
||||
|
||||
# Process V0 conversations
|
||||
v0_conversations = await _process_v0_conversations(conversation_metadata_result_set)
|
||||
|
||||
# Apply microagent-specific filters
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
v0_filtered = await _apply_microagent_filters(
|
||||
v0_conversations, selected_repository, provider_handler
|
||||
)
|
||||
v1_filtered = await _apply_microagent_filters(
|
||||
v1_conversations, selected_repository, provider_handler
|
||||
)
|
||||
|
||||
# Combine and sort results
|
||||
all_conversations = v0_filtered + v1_filtered
|
||||
all_conversations.sort(
|
||||
key=lambda x: x.created_at or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
# Limit to requested number of results
|
||||
final_results = all_conversations[:limit]
|
||||
|
||||
# Create combined page_id for pagination
|
||||
next_page_id = _create_combined_page_id(
|
||||
conversation_metadata_result_set.next_page_id, v1_next_page_id
|
||||
)
|
||||
|
||||
return ConversationInfoResultSet(results=final_results, next_page_id=next_page_id)
|
||||
|
||||
|
||||
def _to_conversation_info(app_conversation: AppConversation) -> ConversationInfo:
|
||||
"""Convert a V1 AppConversation into an old style ConversationInfo"""
|
||||
|
||||
Generated
+7
-7
@@ -7294,8 +7294,8 @@ wsproto = ">=1.2.0"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
resolved_reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
@@ -7324,8 +7324,8 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
resolved_reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-sdk"
|
||||
|
||||
[[package]]
|
||||
@@ -7351,8 +7351,8 @@ pydantic = ">=2.11.7"
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
resolved_reference = "be9725b459c0afabc18cfba89acf11dc756b42f0"
|
||||
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
|
||||
subdirectory = "openhands-tools"
|
||||
|
||||
[[package]]
|
||||
@@ -16521,4 +16521,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "f626e21812a520df4f46c9b8464f5d06edf232681826ba8c83f478da7835d5c0"
|
||||
content-hash = "88c894ef3b6bb22b5e0f0dd92f3cede5f4145cb5b52d1970ff0e1d1780e7a4c9"
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.61.0"
|
||||
version = "0.60.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
@@ -113,9 +113,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
|
||||
pybase62 = "^1.0.0"
|
||||
|
||||
# V1 dependencies
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
|
||||
openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
|
||||
openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
|
||||
openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce" }
|
||||
#openhands-sdk = "1.0.0a5"
|
||||
#openhands-agent-server = "1.0.0a5"
|
||||
#openhands-tools = "1.0.0a5"
|
||||
|
||||
@@ -51,7 +51,6 @@ def get_platform_command(linux_cmd, windows_cmd):
|
||||
return windows_cmd if is_windows() else linux_cmd
|
||||
|
||||
|
||||
@pytest.mark.skip(reason='This test is flaky')
|
||||
def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
try:
|
||||
|
||||
@@ -1,947 +0,0 @@
|
||||
"""Tests for RemoteSandboxService.
|
||||
|
||||
This module tests the RemoteSandboxService implementation, focusing on:
|
||||
- Remote runtime API communication and error handling
|
||||
- Sandbox lifecycle management (start, pause, resume, delete)
|
||||
- Status mapping from remote runtime to internal sandbox statuses
|
||||
- Environment variable injection for CORS and webhooks
|
||||
- Data transformation from remote runtime to SandboxInfo objects
|
||||
- User-scoped sandbox operations and security
|
||||
- Pagination and search functionality
|
||||
- Error handling for HTTP failures and edge cases
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from openhands.app_server.errors import SandboxError
|
||||
from openhands.app_server.sandbox.remote_sandbox_service import (
|
||||
ALLOW_CORS_ORIGINS_VARIABLE,
|
||||
POD_STATUS_MAPPING,
|
||||
STATUS_MAPPING,
|
||||
WEBHOOK_CALLBACK_VARIABLE,
|
||||
RemoteSandboxService,
|
||||
StoredRemoteSandbox,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import (
|
||||
AGENT_SERVER,
|
||||
VSCODE,
|
||||
WORKER_1,
|
||||
WORKER_2,
|
||||
SandboxInfo,
|
||||
SandboxStatus,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_spec_models import SandboxSpecInfo
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sandbox_spec_service():
|
||||
"""Mock SandboxSpecService for testing."""
|
||||
mock_service = AsyncMock()
|
||||
mock_spec = SandboxSpecInfo(
|
||||
id='test-image:latest',
|
||||
command=['/usr/local/bin/openhands-agent-server', '--port', '60000'],
|
||||
initial_env={'TEST_VAR': 'test_value'},
|
||||
working_dir='/workspace/project',
|
||||
)
|
||||
mock_service.get_default_sandbox_spec.return_value = mock_spec
|
||||
mock_service.get_sandbox_spec.return_value = mock_spec
|
||||
return mock_service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_context():
|
||||
"""Mock UserContext for testing."""
|
||||
mock_context = AsyncMock(spec=UserContext)
|
||||
mock_context.get_user_id.return_value = 'test-user-123'
|
||||
return mock_context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_httpx_client():
|
||||
"""Mock httpx.AsyncClient for testing."""
|
||||
return AsyncMock(spec=httpx.AsyncClient)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db_session():
|
||||
"""Mock database session for testing."""
|
||||
return AsyncMock(spec=AsyncSession)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote_sandbox_service(
|
||||
mock_sandbox_spec_service, mock_user_context, mock_httpx_client, mock_db_session
|
||||
):
|
||||
"""Create RemoteSandboxService instance with mocked dependencies."""
|
||||
return RemoteSandboxService(
|
||||
sandbox_spec_service=mock_sandbox_spec_service,
|
||||
api_url='https://api.example.com',
|
||||
api_key='test-api-key',
|
||||
web_url='https://web.example.com',
|
||||
resource_factor=1,
|
||||
runtime_class='gvisor',
|
||||
start_sandbox_timeout=120,
|
||||
max_num_sandboxes=10,
|
||||
user_context=mock_user_context,
|
||||
httpx_client=mock_httpx_client,
|
||||
db_session=mock_db_session,
|
||||
)
|
||||
|
||||
|
||||
def create_runtime_data(
|
||||
session_id: str = 'test-sandbox-123',
|
||||
status: str = 'running',
|
||||
pod_status: str = 'ready',
|
||||
url: str = 'https://sandbox.example.com',
|
||||
session_api_key: str = 'test-session-key',
|
||||
runtime_id: str = 'runtime-456',
|
||||
) -> dict[str, Any]:
|
||||
"""Helper function to create runtime data for testing."""
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'status': status,
|
||||
'pod_status': pod_status,
|
||||
'url': url,
|
||||
'session_api_key': session_api_key,
|
||||
'runtime_id': runtime_id,
|
||||
}
|
||||
|
||||
|
||||
def create_stored_sandbox(
|
||||
sandbox_id: str = 'test-sandbox-123',
|
||||
user_id: str = 'test-user-123',
|
||||
spec_id: str = 'test-image:latest',
|
||||
created_at: datetime | None = None,
|
||||
) -> StoredRemoteSandbox:
|
||||
"""Helper function to create StoredRemoteSandbox for testing."""
|
||||
if created_at is None:
|
||||
created_at = datetime.now(timezone.utc)
|
||||
|
||||
return StoredRemoteSandbox(
|
||||
id=sandbox_id,
|
||||
created_by_user_id=user_id,
|
||||
sandbox_spec_id=spec_id,
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
class TestRemoteSandboxService:
|
||||
"""Test cases for RemoteSandboxService core functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_runtime_api_request_success(self, remote_sandbox_service):
|
||||
"""Test successful API request to remote runtime."""
|
||||
# Setup
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {'result': 'success'}
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
response = await remote_sandbox_service._send_runtime_api_request(
|
||||
'GET', '/test-endpoint', json={'test': 'data'}
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert response == mock_response
|
||||
remote_sandbox_service.httpx_client.request.assert_called_once_with(
|
||||
'GET',
|
||||
'https://api.example.com/test-endpoint',
|
||||
headers={'X-API-Key': 'test-api-key'},
|
||||
json={'test': 'data'},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_runtime_api_request_timeout(self, remote_sandbox_service):
|
||||
"""Test API request timeout handling."""
|
||||
# Setup
|
||||
remote_sandbox_service.httpx_client.request.side_effect = (
|
||||
httpx.TimeoutException('Request timeout')
|
||||
)
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(httpx.TimeoutException):
|
||||
await remote_sandbox_service._send_runtime_api_request('GET', '/test')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_runtime_api_request_http_error(self, remote_sandbox_service):
|
||||
"""Test API request HTTP error handling."""
|
||||
# Setup
|
||||
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
|
||||
'HTTP error'
|
||||
)
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(httpx.HTTPError):
|
||||
await remote_sandbox_service._send_runtime_api_request('GET', '/test')
|
||||
|
||||
|
||||
class TestStatusMapping:
|
||||
"""Test cases for status mapping functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sandbox_status_from_runtime_with_pod_status(
|
||||
self, remote_sandbox_service
|
||||
):
|
||||
"""Test status mapping using pod_status."""
|
||||
runtime_data = create_runtime_data(pod_status='ready')
|
||||
|
||||
status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data)
|
||||
|
||||
assert status == SandboxStatus.RUNNING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sandbox_status_from_runtime_fallback_to_status(
|
||||
self, remote_sandbox_service
|
||||
):
|
||||
"""Test status mapping fallback to status field."""
|
||||
runtime_data = create_runtime_data(
|
||||
pod_status='unknown_pod_status', status='running'
|
||||
)
|
||||
|
||||
status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data)
|
||||
|
||||
assert status == SandboxStatus.RUNNING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sandbox_status_from_runtime_no_runtime(
|
||||
self, remote_sandbox_service
|
||||
):
|
||||
"""Test status mapping with no runtime data."""
|
||||
status = remote_sandbox_service._get_sandbox_status_from_runtime(None)
|
||||
|
||||
assert status == SandboxStatus.MISSING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sandbox_status_from_runtime_unknown_status(
|
||||
self, remote_sandbox_service
|
||||
):
|
||||
"""Test status mapping with unknown status values."""
|
||||
runtime_data = create_runtime_data(
|
||||
pod_status='unknown_pod', status='unknown_status'
|
||||
)
|
||||
|
||||
status = remote_sandbox_service._get_sandbox_status_from_runtime(runtime_data)
|
||||
|
||||
assert status == SandboxStatus.MISSING
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pod_status_mapping_coverage(self, remote_sandbox_service):
|
||||
"""Test all pod status mappings are handled correctly."""
|
||||
test_cases = [
|
||||
('ready', SandboxStatus.RUNNING),
|
||||
('pending', SandboxStatus.STARTING),
|
||||
('running', SandboxStatus.STARTING),
|
||||
('failed', SandboxStatus.ERROR),
|
||||
('unknown', SandboxStatus.ERROR),
|
||||
('crashloopbackoff', SandboxStatus.ERROR),
|
||||
]
|
||||
|
||||
for pod_status, expected_status in test_cases:
|
||||
runtime_data = create_runtime_data(pod_status=pod_status)
|
||||
status = remote_sandbox_service._get_sandbox_status_from_runtime(
|
||||
runtime_data
|
||||
)
|
||||
assert status == expected_status, f'Failed for pod_status: {pod_status}'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_mapping_coverage(self, remote_sandbox_service):
|
||||
"""Test all status mappings are handled correctly."""
|
||||
test_cases = [
|
||||
('running', SandboxStatus.RUNNING),
|
||||
('paused', SandboxStatus.PAUSED),
|
||||
('stopped', SandboxStatus.MISSING),
|
||||
('starting', SandboxStatus.STARTING),
|
||||
('error', SandboxStatus.ERROR),
|
||||
]
|
||||
|
||||
for status, expected_status in test_cases:
|
||||
# Use empty pod_status to force fallback to status field
|
||||
runtime_data = create_runtime_data(pod_status='', status=status)
|
||||
result = remote_sandbox_service._get_sandbox_status_from_runtime(
|
||||
runtime_data
|
||||
)
|
||||
assert result == expected_status, f'Failed for status: {status}'
|
||||
|
||||
|
||||
class TestEnvironmentInitialization:
|
||||
"""Test cases for environment variable initialization."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_environment_with_web_url(self, remote_sandbox_service):
|
||||
"""Test environment initialization with web_url set."""
|
||||
# Setup
|
||||
sandbox_spec = SandboxSpecInfo(
|
||||
id='test-image',
|
||||
command=['test'],
|
||||
initial_env={'EXISTING_VAR': 'existing_value'},
|
||||
working_dir='/workspace',
|
||||
)
|
||||
sandbox_id = 'test-sandbox-123'
|
||||
|
||||
# Execute
|
||||
environment = await remote_sandbox_service._init_environment(
|
||||
sandbox_spec, sandbox_id
|
||||
)
|
||||
|
||||
# Verify
|
||||
expected_webhook_url = (
|
||||
'https://web.example.com/api/v1/webhooks/test-sandbox-123'
|
||||
)
|
||||
assert environment['EXISTING_VAR'] == 'existing_value'
|
||||
assert environment[WEBHOOK_CALLBACK_VARIABLE] == expected_webhook_url
|
||||
assert environment[ALLOW_CORS_ORIGINS_VARIABLE] == 'https://web.example.com'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_environment_without_web_url(self, remote_sandbox_service):
|
||||
"""Test environment initialization without web_url."""
|
||||
# Setup
|
||||
remote_sandbox_service.web_url = None
|
||||
sandbox_spec = SandboxSpecInfo(
|
||||
id='test-image',
|
||||
command=['test'],
|
||||
initial_env={'EXISTING_VAR': 'existing_value'},
|
||||
working_dir='/workspace',
|
||||
)
|
||||
sandbox_id = 'test-sandbox-123'
|
||||
|
||||
# Execute
|
||||
environment = await remote_sandbox_service._init_environment(
|
||||
sandbox_spec, sandbox_id
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert environment['EXISTING_VAR'] == 'existing_value'
|
||||
assert WEBHOOK_CALLBACK_VARIABLE not in environment
|
||||
assert ALLOW_CORS_ORIGINS_VARIABLE not in environment
|
||||
|
||||
|
||||
class TestSandboxInfoConversion:
|
||||
"""Test cases for converting stored sandbox and runtime data to SandboxInfo."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_sandbox_info_with_running_runtime(self, remote_sandbox_service):
|
||||
"""Test conversion to SandboxInfo with running runtime."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data(status='running', pod_status='ready')
|
||||
|
||||
# Execute
|
||||
sandbox_info = await remote_sandbox_service._to_sandbox_info(
|
||||
stored_sandbox, runtime_data
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert sandbox_info.id == 'test-sandbox-123'
|
||||
assert sandbox_info.created_by_user_id == 'test-user-123'
|
||||
assert sandbox_info.sandbox_spec_id == 'test-image:latest'
|
||||
assert sandbox_info.status == SandboxStatus.RUNNING
|
||||
assert sandbox_info.session_api_key == 'test-session-key'
|
||||
assert len(sandbox_info.exposed_urls) == 4
|
||||
|
||||
# Check exposed URLs
|
||||
url_names = [url.name for url in sandbox_info.exposed_urls]
|
||||
assert AGENT_SERVER in url_names
|
||||
assert VSCODE in url_names
|
||||
assert WORKER_1 in url_names
|
||||
assert WORKER_2 in url_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_to_sandbox_info_with_starting_runtime(self, remote_sandbox_service):
|
||||
"""Test conversion to SandboxInfo with starting runtime."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data(status='running', pod_status='pending')
|
||||
|
||||
# Execute
|
||||
sandbox_info = await remote_sandbox_service._to_sandbox_info(
|
||||
stored_sandbox, runtime_data
|
||||
)
|
||||
|
||||
# Verify
|
||||
assert sandbox_info.status == SandboxStatus.STARTING
|
||||
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
|
||||
):
|
||||
"""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)
|
||||
|
||||
# Verify
|
||||
remote_sandbox_service._get_runtime.assert_called_once_with('test-sandbox-123')
|
||||
assert sandbox_info.status == SandboxStatus.RUNNING
|
||||
|
||||
|
||||
class TestSandboxLifecycle:
|
||||
"""Test cases for sandbox lifecycle operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sandbox_success(
|
||||
self, remote_sandbox_service, mock_sandbox_spec_service
|
||||
):
|
||||
"""Test successful sandbox start."""
|
||||
# Setup
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = create_runtime_data()
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
|
||||
# Mock database operations
|
||||
remote_sandbox_service.db_session.add = MagicMock()
|
||||
remote_sandbox_service.db_session.commit = AsyncMock()
|
||||
|
||||
# Execute
|
||||
with patch('base62.encodebytes', return_value='test-sandbox-123'):
|
||||
sandbox_info = await remote_sandbox_service.start_sandbox()
|
||||
|
||||
# Verify
|
||||
assert sandbox_info.id == 'test-sandbox-123'
|
||||
assert (
|
||||
sandbox_info.status == SandboxStatus.STARTING
|
||||
) # pod_status is 'pending' by default
|
||||
remote_sandbox_service.pause_old_sandboxes.assert_called_once_with(
|
||||
9
|
||||
) # max_num_sandboxes - 1
|
||||
remote_sandbox_service.db_session.add.assert_called_once()
|
||||
remote_sandbox_service.db_session.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sandbox_with_specific_spec(
|
||||
self, remote_sandbox_service, mock_sandbox_spec_service
|
||||
):
|
||||
"""Test starting sandbox with specific sandbox spec."""
|
||||
# Setup
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = create_runtime_data()
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
remote_sandbox_service.db_session.add = MagicMock()
|
||||
remote_sandbox_service.db_session.commit = AsyncMock()
|
||||
|
||||
# Execute
|
||||
with patch('base62.encodebytes', return_value='test-sandbox-123'):
|
||||
await remote_sandbox_service.start_sandbox('custom-spec-id')
|
||||
|
||||
# Verify
|
||||
mock_sandbox_spec_service.get_sandbox_spec.assert_called_once_with(
|
||||
'custom-spec-id'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sandbox_spec_not_found(
|
||||
self, remote_sandbox_service, mock_sandbox_spec_service
|
||||
):
|
||||
"""Test starting sandbox with non-existent spec."""
|
||||
# Setup
|
||||
mock_sandbox_spec_service.get_sandbox_spec.return_value = None
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
|
||||
# Execute & Verify
|
||||
with pytest.raises(ValueError, match='Sandbox Spec not found'):
|
||||
await remote_sandbox_service.start_sandbox('non-existent-spec')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sandbox_http_error(self, remote_sandbox_service):
|
||||
"""Test sandbox start with HTTP error."""
|
||||
# Setup
|
||||
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
|
||||
'API Error'
|
||||
)
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
remote_sandbox_service.db_session.add = MagicMock()
|
||||
remote_sandbox_service.db_session.commit = AsyncMock()
|
||||
|
||||
# Execute & Verify
|
||||
with patch('base62.encodebytes', return_value='test-sandbox-123'):
|
||||
with pytest.raises(SandboxError, match='Failed to start sandbox'):
|
||||
await remote_sandbox_service.start_sandbox()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sandbox_with_sysbox_runtime(self, remote_sandbox_service):
|
||||
"""Test sandbox start with sysbox runtime class."""
|
||||
# Setup
|
||||
remote_sandbox_service.runtime_class = 'sysbox'
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = create_runtime_data()
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
remote_sandbox_service.db_session.add = MagicMock()
|
||||
remote_sandbox_service.db_session.commit = AsyncMock()
|
||||
|
||||
# Execute
|
||||
with patch('base62.encodebytes', return_value='test-sandbox-123'):
|
||||
await remote_sandbox_service.start_sandbox()
|
||||
|
||||
# Verify runtime_class is included in request
|
||||
call_args = remote_sandbox_service.httpx_client.request.call_args
|
||||
request_data = call_args[1]['json']
|
||||
assert request_data['runtime_class'] == 'sysbox-runc'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_sandbox_success(self, remote_sandbox_service):
|
||||
"""Test successful sandbox resume."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data()
|
||||
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.resume_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is True
|
||||
remote_sandbox_service.pause_old_sandboxes.assert_called_once_with(9)
|
||||
remote_sandbox_service.httpx_client.request.assert_called_once_with(
|
||||
'POST',
|
||||
'https://api.example.com/resume',
|
||||
headers={'X-API-Key': 'test-api-key'},
|
||||
json={'runtime_id': 'runtime-456'},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_sandbox_not_found(self, remote_sandbox_service):
|
||||
"""Test resuming non-existent sandbox."""
|
||||
# Setup
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(return_value=None)
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.resume_sandbox('non-existent')
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_sandbox_runtime_not_found(self, remote_sandbox_service):
|
||||
"""Test resuming sandbox when runtime returns 404."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data()
|
||||
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.resume_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_sandbox_success(self, remote_sandbox_service):
|
||||
"""Test successful sandbox pause."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data()
|
||||
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.pause_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is True
|
||||
remote_sandbox_service.httpx_client.request.assert_called_once_with(
|
||||
'POST',
|
||||
'https://api.example.com/pause',
|
||||
headers={'X-API-Key': 'test-api-key'},
|
||||
json={'runtime_id': 'runtime-456'},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_sandbox_success(self, remote_sandbox_service):
|
||||
"""Test successful sandbox deletion."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data()
|
||||
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
|
||||
remote_sandbox_service.db_session.delete = AsyncMock()
|
||||
remote_sandbox_service.db_session.commit = AsyncMock()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.delete_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is True
|
||||
remote_sandbox_service.db_session.delete.assert_called_once_with(stored_sandbox)
|
||||
remote_sandbox_service.db_session.commit.assert_called_once()
|
||||
remote_sandbox_service.httpx_client.request.assert_called_once_with(
|
||||
'POST',
|
||||
'https://api.example.com/stop',
|
||||
headers={'X-API-Key': 'test-api-key'},
|
||||
json={'runtime_id': 'runtime-456'},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_sandbox_runtime_not_found_ignored(
|
||||
self, remote_sandbox_service
|
||||
):
|
||||
"""Test sandbox deletion when runtime returns 404 (should be ignored)."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data()
|
||||
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
|
||||
remote_sandbox_service.db_session.delete = AsyncMock()
|
||||
remote_sandbox_service.db_session.commit = AsyncMock()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
remote_sandbox_service.httpx_client.request.return_value = mock_response
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.delete_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is True # 404 should be ignored for delete operations
|
||||
|
||||
|
||||
class TestSandboxSearch:
|
||||
"""Test cases for sandbox search and retrieval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sandboxes_basic(self, remote_sandbox_service):
|
||||
"""Test basic sandbox search functionality."""
|
||||
# Setup
|
||||
stored_sandboxes = [
|
||||
create_stored_sandbox('sb1'),
|
||||
create_stored_sandbox('sb2'),
|
||||
]
|
||||
|
||||
mock_scalars = MagicMock()
|
||||
mock_scalars.all.return_value = stored_sandboxes
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.search_sandboxes()
|
||||
|
||||
# Verify
|
||||
assert len(result.items) == 2
|
||||
assert result.next_page_id is None
|
||||
assert result.items[0].id == 'sb1'
|
||||
assert result.items[1].id == 'sb2'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sandboxes_with_pagination(self, remote_sandbox_service):
|
||||
"""Test sandbox search with pagination."""
|
||||
# Setup - return limit + 1 items to trigger pagination
|
||||
stored_sandboxes = [
|
||||
create_stored_sandbox(f'sb{i}') for i in range(6)
|
||||
] # limit=5, so 6 items
|
||||
|
||||
mock_scalars = MagicMock()
|
||||
mock_scalars.all.return_value = stored_sandboxes
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.search_sandboxes(limit=5)
|
||||
|
||||
# Verify
|
||||
assert len(result.items) == 5 # Should be limited to 5
|
||||
assert result.next_page_id == '5' # Next page offset
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_sandboxes_with_page_id(self, remote_sandbox_service):
|
||||
"""Test sandbox search with page_id offset."""
|
||||
# Setup
|
||||
stored_sandboxes = [create_stored_sandbox('sb1')]
|
||||
|
||||
mock_scalars = MagicMock()
|
||||
mock_scalars.all.return_value = stored_sandboxes
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
# Execute
|
||||
await remote_sandbox_service.search_sandboxes(page_id='10', limit=5)
|
||||
|
||||
# Verify that offset was applied to the query
|
||||
# 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_sandbox_exists(self, remote_sandbox_service):
|
||||
"""Test getting an existing sandbox."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._to_sandbox_info = AsyncMock(
|
||||
return_value=SandboxInfo(
|
||||
id='test-sandbox-123',
|
||||
created_by_user_id='test-user-123',
|
||||
sandbox_spec_id='test-image:latest',
|
||||
status=SandboxStatus.RUNNING,
|
||||
session_api_key='test-key',
|
||||
created_at=stored_sandbox.created_at,
|
||||
)
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.get_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is not None
|
||||
assert result.id == 'test-sandbox-123'
|
||||
remote_sandbox_service._get_stored_sandbox.assert_called_once_with(
|
||||
'test-sandbox-123'
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sandbox_not_exists(self, remote_sandbox_service):
|
||||
"""Test getting a non-existent sandbox."""
|
||||
# Setup
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(return_value=None)
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.get_sandbox('non-existent')
|
||||
|
||||
# Verify
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestUserSecurity:
|
||||
"""Test cases for user-scoped operations and security."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secure_select_with_user_id(self, remote_sandbox_service):
|
||||
"""Test that _secure_select filters by user ID."""
|
||||
# Setup
|
||||
remote_sandbox_service.user_context.get_user_id.return_value = 'test-user-123'
|
||||
|
||||
# Execute
|
||||
await remote_sandbox_service._secure_select()
|
||||
|
||||
# Verify
|
||||
# Note: We can't easily test the exact SQL query structure, but we can verify
|
||||
# that get_user_id was called, which means user filtering should be applied
|
||||
remote_sandbox_service.user_context.get_user_id.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secure_select_without_user_id(self, remote_sandbox_service):
|
||||
"""Test that _secure_select works when user ID is None."""
|
||||
# Setup
|
||||
remote_sandbox_service.user_context.get_user_id.return_value = None
|
||||
|
||||
# Execute
|
||||
await remote_sandbox_service._secure_select()
|
||||
|
||||
# Verify
|
||||
remote_sandbox_service.user_context.get_user_id.assert_called_once()
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
"""Test cases for error handling scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_sandbox_http_error(self, remote_sandbox_service):
|
||||
"""Test resume sandbox with HTTP error."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data()
|
||||
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
|
||||
remote_sandbox_service.pause_old_sandboxes = AsyncMock(return_value=[])
|
||||
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
|
||||
'API Error'
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.resume_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pause_sandbox_http_error(self, remote_sandbox_service):
|
||||
"""Test pause sandbox with HTTP error."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data()
|
||||
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
|
||||
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
|
||||
'API Error'
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.pause_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_sandbox_http_error(self, remote_sandbox_service):
|
||||
"""Test delete sandbox with HTTP error."""
|
||||
# Setup
|
||||
stored_sandbox = create_stored_sandbox()
|
||||
runtime_data = create_runtime_data()
|
||||
|
||||
remote_sandbox_service._get_stored_sandbox = AsyncMock(
|
||||
return_value=stored_sandbox
|
||||
)
|
||||
remote_sandbox_service._get_runtime = AsyncMock(return_value=runtime_data)
|
||||
remote_sandbox_service.db_session.delete = AsyncMock()
|
||||
remote_sandbox_service.db_session.commit = AsyncMock()
|
||||
remote_sandbox_service.httpx_client.request.side_effect = httpx.HTTPError(
|
||||
'API Error'
|
||||
)
|
||||
|
||||
# Execute
|
||||
result = await remote_sandbox_service.delete_sandbox('test-sandbox-123')
|
||||
|
||||
# Verify
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestUtilityFunctions:
|
||||
"""Test cases for utility functions."""
|
||||
|
||||
def test_build_service_url(self):
|
||||
"""Test _build_service_url function."""
|
||||
from openhands.app_server.sandbox.remote_sandbox_service import (
|
||||
_build_service_url,
|
||||
)
|
||||
|
||||
# Test HTTPS URL
|
||||
result = _build_service_url('https://sandbox.example.com/path', 'vscode')
|
||||
assert result == 'https://vscode-sandbox.example.com/path'
|
||||
|
||||
# Test HTTP URL
|
||||
result = _build_service_url('http://localhost:8000', 'work-1')
|
||||
assert result == 'http://work-1-localhost:8000'
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Test cases for constants and mappings."""
|
||||
|
||||
def test_pod_status_mapping_completeness(self):
|
||||
"""Test that POD_STATUS_MAPPING covers expected statuses."""
|
||||
expected_statuses = [
|
||||
'ready',
|
||||
'pending',
|
||||
'running',
|
||||
'failed',
|
||||
'unknown',
|
||||
'crashloopbackoff',
|
||||
]
|
||||
for status in expected_statuses:
|
||||
assert status in POD_STATUS_MAPPING, f'Missing pod status: {status}'
|
||||
|
||||
def test_status_mapping_completeness(self):
|
||||
"""Test that STATUS_MAPPING covers expected statuses."""
|
||||
expected_statuses = ['running', 'paused', 'stopped', 'starting', 'error']
|
||||
for status in expected_statuses:
|
||||
assert status in STATUS_MAPPING, f'Missing status: {status}'
|
||||
|
||||
def test_environment_variable_constants(self):
|
||||
"""Test that environment variable constants are defined."""
|
||||
assert WEBHOOK_CALLBACK_VARIABLE == 'OH_WEBHOOKS_0_BASE_URL'
|
||||
assert ALLOW_CORS_ORIGINS_VARIABLE == 'OH_ALLOW_CORS_ORIGINS_0'
|
||||
@@ -909,12 +909,6 @@ async def test_delete_conversation():
|
||||
# Return the mock store from get_instance
|
||||
mock_get_instance.return_value = mock_store
|
||||
|
||||
# Create a mock app conversation service
|
||||
mock_app_conversation_service = MagicMock()
|
||||
mock_app_conversation_service.get_app_conversation = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
|
||||
# Mock the conversation manager
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
@@ -932,9 +926,7 @@ async def test_delete_conversation():
|
||||
|
||||
# Call delete_conversation
|
||||
result = await delete_conversation(
|
||||
conversation_id='some_conversation_id',
|
||||
user_id='12345',
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
'some_conversation_id', user_id='12345'
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
@@ -951,288 +943,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 AgentExecutionStatus
|
||||
|
||||
conversation_uuid = uuid4()
|
||||
conversation_id = str(conversation_uuid)
|
||||
|
||||
# Mock the app conversation service
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
|
||||
) as mock_service_dep:
|
||||
mock_service = MagicMock()
|
||||
mock_service_dep.return_value = mock_service
|
||||
|
||||
# Mock the conversation exists
|
||||
mock_app_conversation = AppConversation(
|
||||
id=conversation_uuid,
|
||||
created_by_user_id='test_user',
|
||||
sandbox_id='test-sandbox-id',
|
||||
title='Test V1 Conversation',
|
||||
sandbox_status=SandboxStatus.RUNNING,
|
||||
agent_status=AgentExecutionStatus.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_service.get_app_conversation = AsyncMock(
|
||||
return_value=mock_app_conversation
|
||||
)
|
||||
mock_service.delete_app_conversation = AsyncMock(return_value=True)
|
||||
|
||||
# Call delete_conversation with V1 conversation ID
|
||||
result = await delete_conversation(
|
||||
conversation_id=conversation_id,
|
||||
user_id='test_user',
|
||||
app_conversation_service=mock_service,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify that get_app_conversation was called
|
||||
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
|
||||
|
||||
# Verify that delete_app_conversation was called with the conversation ID
|
||||
mock_service.delete_app_conversation.assert_called_once_with(conversation_uuid)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
# Mock the app conversation service
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
|
||||
) as mock_service_dep:
|
||||
mock_service = MagicMock()
|
||||
mock_service_dep.return_value = mock_service
|
||||
|
||||
# Mock the conversation doesn't exist
|
||||
mock_service.get_app_conversation = AsyncMock(return_value=None)
|
||||
mock_service.delete_app_conversation = AsyncMock(return_value=False)
|
||||
|
||||
# Call delete_conversation with V1 conversation ID
|
||||
result = await delete_conversation(
|
||||
conversation_id=conversation_id,
|
||||
user_id='test_user',
|
||||
app_conversation_service=mock_service,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is False
|
||||
|
||||
# Verify that get_app_conversation was called
|
||||
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
|
||||
|
||||
# Verify that delete_app_conversation was NOT called
|
||||
mock_service.delete_app_conversation.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_v1_conversation_invalid_uuid():
|
||||
"""Test deletion with invalid UUID falls back to V0 logic."""
|
||||
conversation_id = 'invalid-uuid-format'
|
||||
|
||||
# Mock the app conversation service
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
|
||||
) as mock_service_dep:
|
||||
mock_service = MagicMock()
|
||||
mock_service_dep.return_value = mock_service
|
||||
|
||||
# Mock V0 conversation logic
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
|
||||
) as mock_get_instance:
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_metadata = AsyncMock(
|
||||
return_value=ConversationMetadata(
|
||||
conversation_id=conversation_id,
|
||||
title='Test V0 Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
selected_repository='test/repo',
|
||||
user_id='test_user',
|
||||
)
|
||||
)
|
||||
mock_store.delete_metadata = AsyncMock()
|
||||
mock_get_instance.return_value = mock_store
|
||||
|
||||
# Mock conversation manager
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
|
||||
mock_manager.get_connections = AsyncMock(return_value={})
|
||||
|
||||
# Mock runtime
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.get_runtime_cls'
|
||||
) as mock_get_runtime_cls:
|
||||
mock_runtime_cls = MagicMock()
|
||||
mock_runtime_cls.delete = AsyncMock()
|
||||
mock_get_runtime_cls.return_value = mock_runtime_cls
|
||||
|
||||
# Call delete_conversation
|
||||
result = await delete_conversation(
|
||||
conversation_id=conversation_id,
|
||||
user_id='test_user',
|
||||
app_conversation_service=mock_service,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify V0 logic was used
|
||||
mock_store.delete_metadata.assert_called_once_with(conversation_id)
|
||||
mock_runtime_cls.delete.assert_called_once_with(conversation_id)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
# Mock the app conversation service
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
|
||||
) as mock_service_dep:
|
||||
mock_service = MagicMock()
|
||||
mock_service_dep.return_value = mock_service
|
||||
|
||||
# Mock service error
|
||||
mock_service.get_app_conversation = AsyncMock(
|
||||
side_effect=Exception('Service error')
|
||||
)
|
||||
|
||||
# Mock V0 conversation logic as fallback
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.ConversationStoreImpl.get_instance'
|
||||
) as mock_get_instance:
|
||||
mock_store = MagicMock()
|
||||
mock_store.get_metadata = AsyncMock(
|
||||
return_value=ConversationMetadata(
|
||||
conversation_id=conversation_id,
|
||||
title='Test V0 Conversation',
|
||||
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
|
||||
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
|
||||
selected_repository='test/repo',
|
||||
user_id='test_user',
|
||||
)
|
||||
)
|
||||
mock_store.delete_metadata = AsyncMock()
|
||||
mock_get_instance.return_value = mock_store
|
||||
|
||||
# Mock conversation manager
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
|
||||
mock_manager.get_connections = AsyncMock(return_value={})
|
||||
|
||||
# Mock runtime
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.get_runtime_cls'
|
||||
) as mock_get_runtime_cls:
|
||||
mock_runtime_cls = MagicMock()
|
||||
mock_runtime_cls.delete = AsyncMock()
|
||||
mock_get_runtime_cls.return_value = mock_runtime_cls
|
||||
|
||||
# Call delete_conversation
|
||||
result = await delete_conversation(
|
||||
conversation_id=conversation_id,
|
||||
user_id='test_user',
|
||||
app_conversation_service=mock_service,
|
||||
)
|
||||
|
||||
# Verify the result (should fallback to V0)
|
||||
assert result is True
|
||||
|
||||
# Verify V0 logic was used
|
||||
mock_store.delete_metadata.assert_called_once_with(conversation_id)
|
||||
mock_runtime_cls.delete.assert_called_once_with(conversation_id)
|
||||
|
||||
|
||||
@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 AgentExecutionStatus
|
||||
|
||||
conversation_uuid = uuid4()
|
||||
conversation_id = str(conversation_uuid)
|
||||
|
||||
# Mock the app conversation service
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.app_conversation_service_dependency'
|
||||
) as mock_service_dep:
|
||||
mock_service = MagicMock()
|
||||
mock_service_dep.return_value = mock_service
|
||||
|
||||
# Mock the conversation exists with running sandbox
|
||||
mock_app_conversation = AppConversation(
|
||||
id=conversation_uuid,
|
||||
created_by_user_id='test_user',
|
||||
sandbox_id='test-sandbox-id',
|
||||
title='Test V1 Conversation',
|
||||
sandbox_status=SandboxStatus.RUNNING,
|
||||
agent_status=AgentExecutionStatus.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_service.get_app_conversation = AsyncMock(
|
||||
return_value=mock_app_conversation
|
||||
)
|
||||
mock_service.delete_app_conversation = AsyncMock(return_value=True)
|
||||
|
||||
# Call delete_conversation with V1 conversation ID
|
||||
result = await delete_conversation(
|
||||
conversation_id=conversation_id,
|
||||
user_id='test_user',
|
||||
app_conversation_service=mock_service,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert result is True
|
||||
|
||||
# Verify that get_app_conversation was called
|
||||
mock_service.get_app_conversation.assert_called_once_with(conversation_uuid)
|
||||
|
||||
# Verify that delete_app_conversation was called with the conversation ID
|
||||
mock_service.delete_app_conversation.assert_called_once_with(conversation_uuid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_conversation_with_bearer_auth(provider_handler_mock):
|
||||
"""Test creating a new conversation with bearer authentication."""
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_service import (
|
||||
AppConversationService,
|
||||
)
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
@@ -22,54 +17,6 @@ from openhands.storage.data_models.conversation_metadata import (
|
||||
)
|
||||
|
||||
|
||||
def _create_mock_app_conversation_service():
|
||||
"""Create a mock AppConversationService that returns empty V1 results."""
|
||||
mock_service = MagicMock(spec=AppConversationService)
|
||||
mock_service.search_app_conversations = AsyncMock(
|
||||
return_value=MagicMock(items=[], next_page_id=None)
|
||||
)
|
||||
return mock_service
|
||||
|
||||
|
||||
def _decode_combined_page_id(page_id: str | None) -> dict:
|
||||
"""Decode a combined page_id to get v0 and v1 components."""
|
||||
if not page_id:
|
||||
return {'v0': None, 'v1': None}
|
||||
try:
|
||||
return json.loads(base64.b64decode(page_id))
|
||||
except Exception:
|
||||
# Legacy format - just v0
|
||||
return {'v0': page_id, 'v1': None}
|
||||
|
||||
|
||||
async def _mock_wait_all(coros):
|
||||
"""Mock implementation of wait_all that properly awaits coroutines."""
|
||||
results = []
|
||||
for coro in coros:
|
||||
if hasattr(coro, '__await__'):
|
||||
results.append(await coro)
|
||||
else:
|
||||
results.append(coro)
|
||||
return results
|
||||
|
||||
|
||||
def _setup_common_mocks():
|
||||
"""Set up common mocks used by all tests."""
|
||||
return {
|
||||
'config': patch('openhands.server.routes.manage_conversations.config'),
|
||||
'conversation_manager': patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
),
|
||||
'wait_all': patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
'provider_handler': patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_success():
|
||||
"""Test successful retrieval of microagent management conversations."""
|
||||
@@ -117,30 +64,24 @@ async def test_get_microagent_management_conversations_success():
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
# Mock app conversation service
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id='next_page_456'
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400 # 24 hours
|
||||
|
||||
# Mock conversation manager
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function with correct parameter order
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository=selected_repository,
|
||||
@@ -148,16 +89,11 @@ async def test_get_microagent_management_conversations_success():
|
||||
limit=limit,
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(result, ConversationInfoResultSet)
|
||||
|
||||
# Decode the combined page_id to verify v0 component
|
||||
decoded_page_id = _decode_combined_page_id(result.next_page_id)
|
||||
assert decoded_page_id['v0'] == 'next_page_456'
|
||||
assert decoded_page_id['v1'] is None
|
||||
assert result.next_page_id == 'next_page_456'
|
||||
|
||||
# Verify conversation store was called correctly
|
||||
mock_conversation_store.search.assert_called_once_with(page_id, limit)
|
||||
@@ -178,31 +114,26 @@ async def test_get_microagent_management_conversations_no_results():
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function with required selected_repository parameter
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
@@ -253,34 +184,29 @@ async def test_get_microagent_management_conversations_filter_by_repository():
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function - only repo1 should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function with repository filter
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo1',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify only conversations from the specified repository are returned
|
||||
@@ -331,34 +257,29 @@ async def test_get_microagent_management_conversations_filter_by_trigger():
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function - only microagent_management should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify only microagent_management conversations are returned
|
||||
@@ -409,34 +330,29 @@ async def test_get_microagent_management_conversations_filter_inactive_pr():
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(side_effect=[True, False])
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function - only active PR should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify only conversations with active PRs are returned
|
||||
@@ -477,34 +393,29 @@ async def test_get_microagent_management_conversations_no_pr_number():
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=mock_conversations, next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify conversation without PR number is included
|
||||
@@ -545,34 +456,29 @@ async def test_get_microagent_management_conversations_no_repository():
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function - conversation should be filtered out due to repository mismatch
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify conversation without repository is filtered out
|
||||
@@ -626,34 +532,29 @@ async def test_get_microagent_management_conversations_age_filter():
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function - only recent conversation should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[recent_conversation], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config with short max age
|
||||
mock_config.conversation_max_age_seconds = 3600 # 1 hour
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify only recent conversation is returned
|
||||
@@ -673,25 +574,21 @@ async def test_get_microagent_management_conversations_pagination():
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id='next_page_789'
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function with pagination parameters
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
@@ -699,15 +596,11 @@ async def test_get_microagent_management_conversations_pagination():
|
||||
limit=5,
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify pagination parameters were passed correctly
|
||||
mock_conversation_store.search.assert_called_once_with('test_page', 5)
|
||||
|
||||
# Decode and verify the next_page_id
|
||||
decoded_page_id = _decode_combined_page_id(result.next_page_id)
|
||||
assert decoded_page_id['v0'] == 'next_page_789'
|
||||
assert result.next_page_id == 'next_page_789'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -722,31 +615,26 @@ async def test_get_microagent_management_conversations_default_parameters():
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
mock_app_conversation_service = _create_mock_app_conversation_service()
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_conv_mgr,
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.wait_all',
|
||||
side_effect=_mock_wait_all,
|
||||
),
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
mock_conv_mgr.get_connections = AsyncMock(return_value=[])
|
||||
mock_conv_mgr.get_agent_loop_info = AsyncMock(return_value=[])
|
||||
|
||||
# Call the function without parameters (selected_repository is required)
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
app_conversation_service=mock_app_conversation_service,
|
||||
)
|
||||
|
||||
# Verify default values were used
|
||||
|
||||
Reference in New Issue
Block a user