mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
7 Commits
0.61.0
...
cli-ctrl-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb348a5f3d | ||
|
|
099dcb787f | ||
|
|
b3034a0d75 | ||
|
|
459e224d37 | ||
|
|
97f13b7100 | ||
|
|
6ecaca5b3c | ||
|
|
5351702d3a |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
20
enterprise/poetry.lock
generated
20
enterprise/poetry.lock
generated
@@ -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.0.0-post.5456+15c207c40"
|
||||
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
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
101
openhands-cli/CTRL_C_IMPLEMENTATION.md
Normal file
101
openhands-cli/CTRL_C_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Ctrl+C Implementation for OpenHands CLI
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation adds improved Ctrl+C handling to the OpenHands CLI where:
|
||||
1. **First Ctrl+C**: Attempts graceful pause of the agent
|
||||
2. **Second Ctrl+C** (within 3 seconds): Immediately kills the process
|
||||
|
||||
## Architecture
|
||||
|
||||
### Signal Handling (`signal_handler.py`)
|
||||
|
||||
**SignalHandler Class:**
|
||||
- Tracks Ctrl+C presses with a 3-second timeout
|
||||
- First press: calls graceful shutdown callback
|
||||
- Second press: forces immediate exit with `os._exit(1)`
|
||||
|
||||
**ProcessSignalHandler Class:**
|
||||
- Manages conversation runner processes
|
||||
- Implements graceful shutdown by terminating the process
|
||||
- Provides clean installation/uninstallation of signal handlers
|
||||
|
||||
### Process Management (`process_runner.py`)
|
||||
|
||||
**ProcessBasedConversationRunner Class:**
|
||||
- Runs conversation in a separate process using `multiprocessing`
|
||||
- Provides inter-process communication via queues
|
||||
- Supports commands: process_message, get_status, toggle_confirmation_mode, resume
|
||||
- Handles process lifecycle (start, stop, cleanup)
|
||||
|
||||
### Modified Components
|
||||
|
||||
**Pause Listener (`listeners/pause_listener.py`):**
|
||||
- Removed Ctrl+C and Ctrl+D handling (now handled by signal handler)
|
||||
- Only handles Ctrl+P for pause functionality
|
||||
|
||||
**Agent Chat (`agent_chat.py`):**
|
||||
- Integrated ProcessSignalHandler for Ctrl+C management
|
||||
- Updated to use ProcessBasedConversationRunner
|
||||
- All commands (/new, /status, /confirm, /resume) work with process-based approach
|
||||
- Proper cleanup in finally block
|
||||
|
||||
**Simple Main (`simple_main.py`):**
|
||||
- Added basic SignalHandler installation for graceful shutdown
|
||||
|
||||
## Key Features
|
||||
|
||||
### Graceful Shutdown
|
||||
- First Ctrl+C sends SIGTERM to conversation process
|
||||
- Gives 2 seconds for graceful shutdown
|
||||
- Shows appropriate user feedback
|
||||
|
||||
### Immediate Termination
|
||||
- Second Ctrl+C within 3 seconds forces immediate exit
|
||||
- Uses `os._exit(1)` to bypass Python cleanup
|
||||
- Ensures agent stops immediately
|
||||
|
||||
### Process Communication
|
||||
- Queue-based communication between main and conversation processes
|
||||
- Status queries work across process boundaries
|
||||
- Command handling preserved for all CLI features
|
||||
|
||||
### Error Handling
|
||||
- Proper exception handling in both processes
|
||||
- Cleanup of resources in finally blocks
|
||||
- Fallback KeyboardInterrupt handlers
|
||||
|
||||
## Usage
|
||||
|
||||
The implementation is transparent to users:
|
||||
- Press Ctrl+C once to pause the agent gracefully
|
||||
- Press Ctrl+C again within 3 seconds to force immediate termination
|
||||
- All existing CLI commands continue to work
|
||||
|
||||
## Testing
|
||||
|
||||
A test script `test_ctrl_c.py` is provided to verify the signal handling behavior:
|
||||
```bash
|
||||
uv run python test_ctrl_c.py
|
||||
```
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
**New Files:**
|
||||
- `openhands_cli/signal_handler.py` - Signal handling classes
|
||||
- `openhands_cli/process_runner.py` - Process-based conversation runner
|
||||
- `test_ctrl_c.py` - Test script for Ctrl+C behavior
|
||||
|
||||
**Modified Files:**
|
||||
- `openhands_cli/listeners/pause_listener.py` - Removed Ctrl+C handling
|
||||
- `openhands_cli/agent_chat.py` - Integrated new signal handling and process runner
|
||||
- `openhands_cli/simple_main.py` - Added basic signal handler
|
||||
|
||||
## Dependencies
|
||||
|
||||
Uses standard Python libraries:
|
||||
- `signal` - For signal handling
|
||||
- `multiprocessing` - For separate process execution
|
||||
- `queue` - For inter-process communication
|
||||
- `threading` - For thread-safe signal counting
|
||||
- `time` - For timeout management
|
||||
88
openhands-cli/CTRL_C_IMPROVEMENTS.md
Normal file
88
openhands-cli/CTRL_C_IMPROVEMENTS.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Ctrl+C Handling Improvements
|
||||
|
||||
## Summary
|
||||
|
||||
Simplified the overly complex Ctrl+C handling implementation in the OpenHands CLI to make it more reliable and easier to understand.
|
||||
|
||||
## Problems Addressed
|
||||
|
||||
1. **Second Ctrl+C not registering properly** - The original implementation had complex queue-based communication that could miss signals
|
||||
2. **Overly complex multiprocessing** - Many methods were unnecessarily wrapped in separate processes
|
||||
3. **No reset of Ctrl+C count** - The count wasn't reset when starting new message processing
|
||||
4. **Unnecessary queue communication** - Status and settings methods didn't need separate processes
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Simplified Signal Handler (`simple_signal_handler.py`)
|
||||
|
||||
- **Direct signal handling** in the main process instead of complex queue communication
|
||||
- **Simple Ctrl+C counting** with immediate force kill on second press within 3 seconds
|
||||
- **Clear process management** with direct process termination
|
||||
- **Reset functionality** to clear count when starting new operations
|
||||
|
||||
Key features:
|
||||
- First Ctrl+C: Graceful termination (SIGTERM)
|
||||
- Second Ctrl+C (within 3 seconds): Force kill (SIGKILL)
|
||||
- Automatic count reset after 3 seconds
|
||||
- Manual count reset via `reset_count()`
|
||||
|
||||
### 2. Simplified Process Runner (`simple_process_runner.py`)
|
||||
|
||||
- **Minimal multiprocessing** - Only the `process_message` method runs in a subprocess
|
||||
- **Direct method calls** for status, settings, and other operations
|
||||
- **Simple API** with clear process lifecycle management
|
||||
- **No queue communication** for methods that don't need it
|
||||
|
||||
Key features:
|
||||
- `process_message()`: Runs in subprocess for isolation
|
||||
- `get_status()`, `get_settings()`, etc.: Run directly in main process
|
||||
- `cleanup()`: Simple process termination
|
||||
- `current_process` property for signal handler integration
|
||||
|
||||
### 3. Updated Main CLI (`agent_chat.py`)
|
||||
|
||||
- **Simplified imports** using the new signal handler and process runner
|
||||
- **Reset Ctrl+C count** when starting new message processing
|
||||
- **Direct method calls** for commands that don't need process isolation
|
||||
- **Cleaner error handling** and resource cleanup
|
||||
|
||||
## Files Modified
|
||||
|
||||
### New Files
|
||||
- `openhands_cli/simple_signal_handler.py` - Simplified signal handling
|
||||
- `openhands_cli/simple_process_runner.py` - Minimal process wrapper
|
||||
|
||||
### Modified Files
|
||||
- `openhands_cli/agent_chat.py` - Updated to use simplified components
|
||||
- `openhands_cli/simple_main.py` - Updated imports
|
||||
|
||||
### Test Files
|
||||
- `test_basic_signal.py` - Basic signal handler test
|
||||
- `manual_test_ctrl_c.py` - Manual Ctrl+C testing
|
||||
|
||||
## Key Improvements
|
||||
|
||||
1. **Reliability**: Direct signal handling eliminates race conditions
|
||||
2. **Simplicity**: Removed complex queue-based communication
|
||||
3. **Performance**: Most operations run directly in main process
|
||||
4. **Maintainability**: Clear, simple code that's easy to understand
|
||||
5. **User Experience**: Consistent Ctrl+C behavior with immediate force kill option
|
||||
|
||||
## Testing
|
||||
|
||||
The implementation includes test scripts to verify:
|
||||
- Basic signal handler functionality
|
||||
- Ctrl+C counting and reset behavior
|
||||
- Process termination (graceful and force)
|
||||
- Integration with the CLI
|
||||
|
||||
## Usage
|
||||
|
||||
The simplified implementation maintains the same external API:
|
||||
- First Ctrl+C: Attempts graceful pause/termination
|
||||
- Second Ctrl+C (within 3 seconds): Force kills the process immediately
|
||||
- Count resets automatically or when starting new operations
|
||||
|
||||
## Migration
|
||||
|
||||
The changes are backward compatible with the existing CLI interface. The complex `ProcessSignalHandler` and `ProcessBasedConversationRunner` classes are replaced with simpler equivalents that provide the same functionality with better reliability.
|
||||
@@ -17,6 +17,8 @@ from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
from openhands_cli.simple_process_runner import SimpleProcessRunner
|
||||
from openhands_cli.simple_signal_handler import SimpleSignalHandler
|
||||
from openhands_cli.setup import (
|
||||
MissingAgentSpec,
|
||||
setup_conversation,
|
||||
@@ -95,120 +97,144 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
# Track session start time for uptime calculation
|
||||
session_start_time = datetime.now()
|
||||
|
||||
# Create conversation runner to handle state machine logic
|
||||
runner = None
|
||||
# Create simple signal handler and session
|
||||
signal_handler = SimpleSignalHandler()
|
||||
signal_handler.install()
|
||||
session = get_session_prompter()
|
||||
|
||||
# Set up conversation
|
||||
conversation = setup_conversation(conversation_id)
|
||||
|
||||
# Create simple process runner
|
||||
process_runner = SimpleProcessRunner(conversation)
|
||||
|
||||
# Main chat loop
|
||||
while True:
|
||||
try:
|
||||
# Get user input
|
||||
user_input = session.prompt(
|
||||
HTML('<gold>> </gold>'),
|
||||
multiline=False,
|
||||
)
|
||||
try:
|
||||
# Main chat loop
|
||||
while True:
|
||||
try:
|
||||
# Get user input
|
||||
user_input = session.prompt(
|
||||
HTML('<gold>> </gold>'),
|
||||
multiline=False,
|
||||
)
|
||||
|
||||
if not user_input.strip():
|
||||
continue
|
||||
if not user_input.strip():
|
||||
continue
|
||||
|
||||
# Handle commands
|
||||
command = user_input.strip().lower()
|
||||
# Handle commands
|
||||
command = user_input.strip().lower()
|
||||
|
||||
message = Message(
|
||||
role='user',
|
||||
content=[TextContent(text=user_input)],
|
||||
)
|
||||
message = Message(
|
||||
role='user',
|
||||
content=[TextContent(text=user_input)],
|
||||
)
|
||||
|
||||
if command == '/exit':
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation_id)
|
||||
break
|
||||
if command == '/exit':
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation_id)
|
||||
break
|
||||
|
||||
elif command == '/settings':
|
||||
settings_screen = SettingsScreen(runner.conversation if runner else None)
|
||||
settings_screen.display_settings()
|
||||
continue
|
||||
elif command == '/settings':
|
||||
# For process-based runner, we can't directly access the conversation
|
||||
# TODO: Implement settings access through process communication if needed
|
||||
settings_screen = SettingsScreen(None)
|
||||
settings_screen.display_settings()
|
||||
continue
|
||||
|
||||
elif command == '/mcp':
|
||||
mcp_screen = MCPScreen()
|
||||
mcp_screen.display_mcp_info(initialized_agent)
|
||||
continue
|
||||
elif command == '/mcp':
|
||||
mcp_screen = MCPScreen()
|
||||
mcp_screen.display_mcp_info(initialized_agent)
|
||||
continue
|
||||
|
||||
elif command == '/clear':
|
||||
display_welcome(conversation_id)
|
||||
continue
|
||||
elif command == '/clear':
|
||||
display_welcome(conversation_id)
|
||||
continue
|
||||
|
||||
elif command == '/new':
|
||||
elif command == '/new':
|
||||
try:
|
||||
# Clean up existing process runner
|
||||
if process_runner:
|
||||
process_runner.cleanup()
|
||||
|
||||
# Create fresh conversation with new process runner
|
||||
conversation_id = uuid.uuid4()
|
||||
conversation = setup_conversation(conversation_id)
|
||||
process_runner = SimpleProcessRunner(conversation)
|
||||
display_welcome(conversation_id, resume=False)
|
||||
print_formatted_text(
|
||||
HTML('<green>✓ Started fresh conversation</green>')
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
print_formatted_text(
|
||||
HTML(f'<red>Error starting fresh conversation: {e}</red>')
|
||||
)
|
||||
continue
|
||||
|
||||
elif command == '/help':
|
||||
display_help()
|
||||
continue
|
||||
|
||||
elif command == '/status':
|
||||
status = process_runner.get_status()
|
||||
print_formatted_text(HTML(f'<yellow>Conversation ID:</yellow> {status["conversation_id"]}'))
|
||||
print_formatted_text(HTML(f'<yellow>Agent State:</yellow> {status.get("agent_state", "Unknown")}'))
|
||||
print_formatted_text(HTML(f'<yellow>Process Running:</yellow> {status["is_running"]}'))
|
||||
continue
|
||||
|
||||
elif command == '/confirm':
|
||||
result = process_runner.toggle_confirmation_mode()
|
||||
mode_text = "Enabled" if result else "Disabled"
|
||||
print_formatted_text(HTML(f'<yellow>Confirmation mode: {mode_text}</yellow>'))
|
||||
continue
|
||||
|
||||
elif command == '/resume':
|
||||
try:
|
||||
process_runner.resume()
|
||||
print_formatted_text(HTML('<green>Agent resumed</green>'))
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Failed to resume: {e}</red>'))
|
||||
continue
|
||||
|
||||
# Reset Ctrl+C count when starting new message processing
|
||||
signal_handler.reset_count()
|
||||
|
||||
# Process the message
|
||||
try:
|
||||
# Start a fresh conversation (no resume ID = new conversation)
|
||||
conversation_id = uuid.uuid4()
|
||||
runner = None
|
||||
conversation = None
|
||||
display_welcome(conversation_id, resume=False)
|
||||
print_formatted_text(
|
||||
HTML('<green>✓ Started fresh conversation</green>')
|
||||
)
|
||||
continue
|
||||
# Set the current process for signal handling
|
||||
signal_handler.set_process(process_runner.current_process)
|
||||
|
||||
# Create message object
|
||||
message = Message(role='user', content=[TextContent(text=user_input)])
|
||||
result = process_runner.process_message(message)
|
||||
print() # Add spacing for successful processing
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(
|
||||
HTML(f'<red>Error starting fresh conversation: {e}</red>')
|
||||
)
|
||||
continue
|
||||
print_formatted_text(HTML(f'<red>Failed to process message: {e}</red>'))
|
||||
finally:
|
||||
# Clear the process reference
|
||||
signal_handler.set_process(None)
|
||||
|
||||
elif command == '/help':
|
||||
display_help()
|
||||
except KeyboardInterrupt:
|
||||
# KeyboardInterrupt should be handled by the signal handler now
|
||||
# Just continue the loop - the signal handler manages the process
|
||||
continue
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error in chat loop: {e}</red>'))
|
||||
continue
|
||||
|
||||
elif command == '/status':
|
||||
display_status(conversation, session_start_time=session_start_time)
|
||||
continue
|
||||
except KeyboardInterrupt:
|
||||
# Final fallback for KeyboardInterrupt - only exit if we're not in the main loop
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation_id)
|
||||
|
||||
elif command == '/confirm':
|
||||
runner.toggle_confirmation_mode()
|
||||
new_status = (
|
||||
'enabled' if runner.is_confirmation_mode_active else 'disabled'
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(f'<yellow>Confirmation mode {new_status}</yellow>')
|
||||
)
|
||||
continue
|
||||
|
||||
elif command == '/resume':
|
||||
if not runner:
|
||||
print_formatted_text(
|
||||
HTML('<yellow>No active conversation running...</yellow>')
|
||||
)
|
||||
continue
|
||||
|
||||
conversation = runner.conversation
|
||||
if not (
|
||||
conversation.state.agent_status == AgentExecutionStatus.PAUSED
|
||||
or conversation.state.agent_status
|
||||
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
):
|
||||
print_formatted_text(
|
||||
HTML('<red>No paused conversation to resume...</red>')
|
||||
)
|
||||
continue
|
||||
|
||||
# Resume without new message
|
||||
message = None
|
||||
|
||||
if not runner or not conversation:
|
||||
conversation = setup_conversation(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
runner.process_message(message)
|
||||
|
||||
print() # Add spacing
|
||||
|
||||
except KeyboardInterrupt:
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation_id)
|
||||
break
|
||||
|
||||
# Clean up terminal state
|
||||
_restore_tty()
|
||||
finally:
|
||||
# Clean up resources
|
||||
if process_runner:
|
||||
process_runner.cleanup()
|
||||
signal_handler.uninstall()
|
||||
|
||||
# Clean up terminal state
|
||||
_restore_tty()
|
||||
|
||||
@@ -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']
|
||||
|
||||
63
openhands-cli/openhands_cli/listeners/loading_listener.py
Normal file
63
openhands-cli/openhands_cli/listeners/loading_listener.py
Normal file
@@ -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
|
||||
@@ -31,8 +31,9 @@ class PauseListener(threading.Thread):
|
||||
|
||||
for key_press in self._input.read_keys():
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlP
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlC
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlD
|
||||
# Note: Ctrl+C and Ctrl+D are now handled by the signal handler
|
||||
# pause_detected = pause_detected or key_press.key == Keys.ControlC
|
||||
# pause_detected = pause_detected or key_press.key == Keys.ControlD
|
||||
|
||||
return pause_detected
|
||||
|
||||
|
||||
314
openhands-cli/openhands_cli/process_runner.py
Normal file
314
openhands-cli/openhands_cli/process_runner.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Process-based conversation runner for handling agent execution in a separate process.
|
||||
|
||||
This allows for immediate termination of the agent when needed while maintaining
|
||||
the ability to gracefully pause on the first Ctrl+C.
|
||||
"""
|
||||
|
||||
import multiprocessing
|
||||
import queue
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from openhands.sdk import BaseConversation, Message
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
|
||||
|
||||
class ProcessCommand(Enum):
|
||||
"""Commands that can be sent to the conversation process."""
|
||||
PROCESS_MESSAGE = "process_message"
|
||||
PAUSE = "pause"
|
||||
RESUME = "resume"
|
||||
TOGGLE_CONFIRMATION = "toggle_confirmation"
|
||||
GET_STATUS = "get_status"
|
||||
SHUTDOWN = "shutdown"
|
||||
|
||||
|
||||
class ProcessResponse(Enum):
|
||||
"""Response types from the conversation process."""
|
||||
SUCCESS = "success"
|
||||
ERROR = "error"
|
||||
STATUS = "status"
|
||||
|
||||
|
||||
def conversation_worker(
|
||||
conversation_id: str,
|
||||
command_queue: multiprocessing.Queue,
|
||||
response_queue: multiprocessing.Queue,
|
||||
setup_conversation_func: Any, # Function to setup conversation
|
||||
) -> None:
|
||||
"""Worker function that runs in a separate process to handle conversation."""
|
||||
|
||||
# Set up signal handling in the worker process
|
||||
def signal_handler(signum, frame):
|
||||
print_formatted_text(HTML('<yellow>Conversation process received termination signal.</yellow>'))
|
||||
return
|
||||
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN) # Ignore SIGINT in worker process
|
||||
|
||||
try:
|
||||
# Setup conversation in the worker process
|
||||
conversation = setup_conversation_func(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": "Conversation process initialized"
|
||||
})
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check for commands with timeout
|
||||
try:
|
||||
command_data = command_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
command = command_data.get("command")
|
||||
args = command_data.get("args", {})
|
||||
|
||||
if command == ProcessCommand.SHUTDOWN:
|
||||
break
|
||||
|
||||
elif command == ProcessCommand.PROCESS_MESSAGE:
|
||||
message = args.get("message")
|
||||
try:
|
||||
runner.process_message(message)
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": "Message processed"
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error processing message: {e}"
|
||||
})
|
||||
|
||||
elif command == ProcessCommand.PAUSE:
|
||||
try:
|
||||
runner.conversation.pause()
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": "Conversation paused"
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error pausing conversation: {e}"
|
||||
})
|
||||
|
||||
elif command == ProcessCommand.RESUME:
|
||||
try:
|
||||
runner.process_message(None) # Resume without new message
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": "Conversation resumed"
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error resuming conversation: {e}"
|
||||
})
|
||||
|
||||
elif command == ProcessCommand.TOGGLE_CONFIRMATION:
|
||||
try:
|
||||
runner.toggle_confirmation_mode()
|
||||
new_status = 'enabled' if runner.is_confirmation_mode_active else 'disabled'
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": f"Confirmation mode {new_status}"
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error toggling confirmation mode: {e}"
|
||||
})
|
||||
|
||||
elif command == ProcessCommand.GET_STATUS:
|
||||
try:
|
||||
status = {
|
||||
"agent_status": runner.conversation.state.agent_status,
|
||||
"confirmation_mode": runner.is_confirmation_mode_active
|
||||
}
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.STATUS,
|
||||
"data": status
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error getting status: {e}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Unexpected error in conversation worker: {e}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Failed to initialize conversation process: {e}"
|
||||
})
|
||||
|
||||
|
||||
class ProcessBasedConversationRunner:
|
||||
"""Manages a conversation runner in a separate process."""
|
||||
|
||||
def __init__(self, conversation_id: str, setup_conversation_func: Any):
|
||||
self.conversation_id = conversation_id
|
||||
self.setup_conversation_func = setup_conversation_func
|
||||
self.process: Optional[multiprocessing.Process] = None
|
||||
self.command_queue: Optional[multiprocessing.Queue] = None
|
||||
self.response_queue: Optional[multiprocessing.Queue] = None
|
||||
self.is_running = False
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the conversation process."""
|
||||
if self.is_running:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Create queues for communication
|
||||
self.command_queue = multiprocessing.Queue()
|
||||
self.response_queue = multiprocessing.Queue()
|
||||
|
||||
# Start the worker process
|
||||
self.process = multiprocessing.Process(
|
||||
target=conversation_worker,
|
||||
args=(
|
||||
self.conversation_id,
|
||||
self.command_queue,
|
||||
self.response_queue,
|
||||
self.setup_conversation_func
|
||||
)
|
||||
)
|
||||
self.process.start()
|
||||
|
||||
# Wait for initialization confirmation
|
||||
try:
|
||||
response = self.response_queue.get(timeout=10.0)
|
||||
if response["type"] == ProcessResponse.SUCCESS:
|
||||
self.is_running = True
|
||||
return True
|
||||
else:
|
||||
print_formatted_text(HTML(f'<red>Failed to initialize conversation process: {response.get("message", "Unknown error")}</red>'))
|
||||
self.stop()
|
||||
return False
|
||||
except queue.Empty:
|
||||
print_formatted_text(HTML('<red>Timeout waiting for conversation process to initialize</red>'))
|
||||
self.stop()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error starting conversation process: {e}</red>'))
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the conversation process."""
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
try:
|
||||
if self.command_queue:
|
||||
self.command_queue.put({"command": ProcessCommand.SHUTDOWN})
|
||||
|
||||
if self.process:
|
||||
self.process.join(timeout=2.0)
|
||||
if self.process.is_alive():
|
||||
self.process.terminate()
|
||||
self.process.join(timeout=1.0)
|
||||
if self.process.is_alive():
|
||||
self.process.kill()
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<yellow>Warning: Error stopping conversation process: {e}</yellow>'))
|
||||
|
||||
finally:
|
||||
self.is_running = False
|
||||
self.process = None
|
||||
self.command_queue = None
|
||||
self.response_queue = None
|
||||
|
||||
def send_command(self, command: ProcessCommand, args: Optional[Dict] = None, timeout: float = 5.0) -> Optional[Dict]:
|
||||
"""Send a command to the conversation process and wait for response."""
|
||||
if not self.is_running or not self.command_queue or not self.response_queue:
|
||||
return None
|
||||
|
||||
try:
|
||||
command_data = {"command": command, "args": args or {}}
|
||||
self.command_queue.put(command_data)
|
||||
|
||||
response = self.response_queue.get(timeout=timeout)
|
||||
return response
|
||||
|
||||
except queue.Empty:
|
||||
print_formatted_text(HTML(f'<yellow>Timeout waiting for response to {command.value}</yellow>'))
|
||||
return None
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error sending command {command.value}: {e}</red>'))
|
||||
return None
|
||||
|
||||
def process_message(self, message: Optional[Message]) -> bool:
|
||||
"""Process a message through the conversation."""
|
||||
response = self.send_command(ProcessCommand.PROCESS_MESSAGE, {"message": message})
|
||||
if response and response["type"] == ProcessResponse.SUCCESS:
|
||||
return True
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return False
|
||||
|
||||
def pause(self) -> bool:
|
||||
"""Pause the conversation."""
|
||||
response = self.send_command(ProcessCommand.PAUSE)
|
||||
if response and response["type"] == ProcessResponse.SUCCESS:
|
||||
return True
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return False
|
||||
|
||||
def resume(self) -> bool:
|
||||
"""Resume the conversation."""
|
||||
response = self.send_command(ProcessCommand.RESUME)
|
||||
if response and response["type"] == ProcessResponse.SUCCESS:
|
||||
return True
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return False
|
||||
|
||||
def toggle_confirmation_mode(self) -> Optional[str]:
|
||||
"""Toggle confirmation mode and return the new status."""
|
||||
response = self.send_command(ProcessCommand.TOGGLE_CONFIRMATION)
|
||||
if response and response["type"] == ProcessResponse.SUCCESS:
|
||||
return response.get("message")
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return None
|
||||
|
||||
def get_status(self) -> Optional[Dict]:
|
||||
"""Get the current status of the conversation."""
|
||||
response = self.send_command(ProcessCommand.GET_STATUS)
|
||||
if response and response["type"] == ProcessResponse.STATUS:
|
||||
return response.get("data")
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return None
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if the conversation process is alive."""
|
||||
return self.is_running and self.process and self.process.is_alive()
|
||||
|
||||
def force_terminate(self) -> None:
|
||||
"""Force terminate the conversation process immediately."""
|
||||
if self.process and self.process.is_alive():
|
||||
self.process.kill()
|
||||
self.process.join(timeout=1.0)
|
||||
self.is_running = False
|
||||
@@ -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>')
|
||||
|
||||
113
openhands-cli/openhands_cli/signal_handler.py
Normal file
113
openhands-cli/openhands_cli/signal_handler.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""
|
||||
Signal handling for graceful shutdown and immediate termination.
|
||||
|
||||
This module provides a signal handler that tracks Ctrl+C presses:
|
||||
- First Ctrl+C: Attempt graceful pause of the agent
|
||||
- Second Ctrl+C: Immediately terminate the process
|
||||
"""
|
||||
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
|
||||
class SignalHandler:
|
||||
"""Handles SIGINT (Ctrl+C) with graceful shutdown on first press and immediate termination on second."""
|
||||
|
||||
def __init__(self, graceful_shutdown_callback: Optional[Callable] = None):
|
||||
self.graceful_shutdown_callback = graceful_shutdown_callback
|
||||
self.sigint_count = 0
|
||||
self.last_sigint_time = 0.0
|
||||
self.sigint_timeout = 3.0 # Reset counter after 3 seconds
|
||||
self.lock = threading.Lock()
|
||||
self.original_handler = None
|
||||
|
||||
def install(self) -> None:
|
||||
"""Install the signal handler."""
|
||||
self.original_handler = signal.signal(signal.SIGINT, self._handle_sigint)
|
||||
|
||||
def uninstall(self) -> None:
|
||||
"""Restore the original signal handler."""
|
||||
if self.original_handler is not None:
|
||||
signal.signal(signal.SIGINT, self.original_handler)
|
||||
self.original_handler = None
|
||||
|
||||
def _handle_sigint(self, signum: int, frame) -> None:
|
||||
"""Handle SIGINT (Ctrl+C) signal."""
|
||||
current_time = time.time()
|
||||
|
||||
with self.lock:
|
||||
# Reset counter if too much time has passed since last Ctrl+C
|
||||
if current_time - self.last_sigint_time > self.sigint_timeout:
|
||||
self.sigint_count = 0
|
||||
|
||||
self.sigint_count += 1
|
||||
self.last_sigint_time = current_time
|
||||
|
||||
if self.sigint_count == 1:
|
||||
# First Ctrl+C: attempt graceful shutdown
|
||||
print_formatted_text(HTML('\n<yellow>Received Ctrl+C. Attempting to pause agent gracefully...</yellow>'))
|
||||
print_formatted_text(HTML('<grey>Press Ctrl+C again within 3 seconds to force immediate termination.</grey>'))
|
||||
|
||||
if self.graceful_shutdown_callback:
|
||||
try:
|
||||
self.graceful_shutdown_callback()
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error during graceful shutdown: {e}</red>'))
|
||||
|
||||
elif self.sigint_count >= 2:
|
||||
# Second Ctrl+C: immediate termination
|
||||
print_formatted_text(HTML('\n<red>Received second Ctrl+C. Terminating immediately...</red>'))
|
||||
self.uninstall()
|
||||
# Force immediate exit
|
||||
import os
|
||||
os._exit(1)
|
||||
|
||||
|
||||
class ProcessSignalHandler:
|
||||
"""Signal handler for managing conversation runner processes."""
|
||||
|
||||
def __init__(self):
|
||||
self.conversation_process = None
|
||||
self.signal_handler = None
|
||||
|
||||
def set_conversation_process(self, process) -> None:
|
||||
"""Set the conversation process to manage."""
|
||||
self.conversation_process = process
|
||||
|
||||
def graceful_shutdown(self) -> None:
|
||||
"""Attempt graceful shutdown of the conversation process."""
|
||||
if hasattr(self, 'conversation_process') and self.conversation_process and self.conversation_process.is_alive():
|
||||
print_formatted_text(HTML('<yellow>Pausing agent once current step is completed...</yellow>'))
|
||||
# Send SIGTERM to the process for graceful shutdown
|
||||
self.conversation_process.terminate()
|
||||
|
||||
# Give it a moment to shut down gracefully
|
||||
self.conversation_process.join(timeout=2.0)
|
||||
|
||||
if self.conversation_process.is_alive():
|
||||
print_formatted_text(HTML('<yellow>Agent is taking time to pause. Press Ctrl+C again to force termination.</yellow>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<green>Agent paused successfully.</green>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<yellow>No active conversation process to pause.</yellow>'))
|
||||
|
||||
def install_handler(self) -> None:
|
||||
"""Install the signal handler."""
|
||||
self.signal_handler = SignalHandler(graceful_shutdown_callback=self.graceful_shutdown)
|
||||
self.signal_handler.install()
|
||||
|
||||
def uninstall_handler(self) -> None:
|
||||
"""Uninstall the signal handler."""
|
||||
if self.signal_handler:
|
||||
self.signal_handler.uninstall()
|
||||
self.signal_handler = None
|
||||
|
||||
def force_terminate(self) -> None:
|
||||
"""Force terminate the conversation process."""
|
||||
if self.conversation_process and self.conversation_process.is_alive():
|
||||
self.conversation_process.kill()
|
||||
self.conversation_process.join(timeout=1.0)
|
||||
@@ -18,6 +18,7 @@ from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands_cli.argparsers.main_parser import create_main_parser
|
||||
from openhands_cli.simple_signal_handler import SimpleSignalHandler
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -30,8 +31,15 @@ def main() -> None:
|
||||
parser = create_main_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Install basic signal handler for the main process
|
||||
# The agent_chat module will install its own more sophisticated handler
|
||||
signal_handler = SimpleSignalHandler()
|
||||
|
||||
try:
|
||||
if args.command == 'serve':
|
||||
# For GUI mode, use basic signal handling
|
||||
signal_handler.install()
|
||||
|
||||
# Import gui_launcher only when needed
|
||||
from openhands_cli.gui_launcher import launch_gui_server
|
||||
|
||||
@@ -41,7 +49,7 @@ def main() -> None:
|
||||
# Import agent_chat only when needed
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
|
||||
# Start agent chat
|
||||
# Start agent chat (it will install its own signal handler)
|
||||
run_cli_entry(resume_conversation_id=args.resume)
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
@@ -53,6 +61,8 @@ def main() -> None:
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
signal_handler.uninstall()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
160
openhands-cli/openhands_cli/simple_process_runner.py
Normal file
160
openhands-cli/openhands_cli/simple_process_runner.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Simple process-based conversation runner for OpenHands CLI.
|
||||
|
||||
Only the actual conversation running (process_message) is wrapped in a separate process.
|
||||
All other methods run in the main process.
|
||||
"""
|
||||
|
||||
import multiprocessing
|
||||
from typing import Any, Optional
|
||||
|
||||
from openhands.sdk import BaseConversation, Message
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
|
||||
|
||||
def _run_conversation_in_process(conversation_id: str, message_data: Optional[dict], result_queue: multiprocessing.Queue):
|
||||
"""Run the conversation in a separate process."""
|
||||
try:
|
||||
from openhands_cli.setup import setup_conversation
|
||||
from openhands.sdk import Message, TextContent
|
||||
import uuid
|
||||
|
||||
# Recreate conversation in this process
|
||||
conv_id = uuid.UUID(conversation_id)
|
||||
conversation = setup_conversation(conv_id)
|
||||
|
||||
# Create conversation runner
|
||||
runner = ConversationRunner(conversation)
|
||||
|
||||
if message_data:
|
||||
# Recreate message from data
|
||||
message = Message(
|
||||
role=message_data['role'],
|
||||
content=[TextContent(text=message_data['content_text'])]
|
||||
)
|
||||
# Process the message
|
||||
runner.process_message(message)
|
||||
|
||||
# Put success result in the queue
|
||||
result_queue.put(('success', None))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
result_queue.put(('interrupted', None))
|
||||
except Exception as e:
|
||||
result_queue.put(('error', str(e)))
|
||||
|
||||
|
||||
class SimpleProcessRunner:
|
||||
"""Simple conversation runner that only uses multiprocessing for the actual conversation."""
|
||||
|
||||
def __init__(self, conversation: BaseConversation):
|
||||
"""Initialize the process runner.
|
||||
|
||||
Args:
|
||||
conversation: The conversation instance
|
||||
"""
|
||||
self.conversation = conversation
|
||||
self.conversation_id = str(conversation.conversation_id)
|
||||
self.current_process: Optional[multiprocessing.Process] = None
|
||||
self.result_queue: Optional[multiprocessing.Queue] = None
|
||||
|
||||
# Create a runner for main process operations
|
||||
self.runner = ConversationRunner(conversation)
|
||||
|
||||
def process_message(self, message: Optional[Message]) -> bool:
|
||||
"""Process a message in a separate process.
|
||||
|
||||
Args:
|
||||
message: The user message to process
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Create queue for result
|
||||
self.result_queue = multiprocessing.Queue()
|
||||
|
||||
# Prepare message data for serialization
|
||||
message_data = None
|
||||
if message:
|
||||
# Extract text content from the message
|
||||
content_text = ""
|
||||
for content in message.content:
|
||||
if hasattr(content, 'text'):
|
||||
content_text += content.text
|
||||
|
||||
message_data = {
|
||||
'role': message.role,
|
||||
'content_text': content_text
|
||||
}
|
||||
|
||||
# Create and start process
|
||||
self.current_process = multiprocessing.Process(
|
||||
target=_run_conversation_in_process,
|
||||
args=(self.conversation_id, message_data, self.result_queue)
|
||||
)
|
||||
self.current_process.start()
|
||||
|
||||
# Wait for result
|
||||
try:
|
||||
result_type, result_data = self.result_queue.get()
|
||||
self.current_process.join()
|
||||
|
||||
if result_type == 'success':
|
||||
return True
|
||||
elif result_type == 'interrupted':
|
||||
print("Agent was interrupted by user")
|
||||
return False
|
||||
else:
|
||||
print(f"Process error: {result_data}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
# Check if process was killed by signal handler
|
||||
if self.current_process and not self.current_process.is_alive():
|
||||
# Process was killed, likely by Ctrl+C handler
|
||||
return False
|
||||
|
||||
# Clean up if process is still alive
|
||||
if self.current_process and self.current_process.is_alive():
|
||||
self.current_process.terminate()
|
||||
self.current_process.join(timeout=2)
|
||||
if self.current_process.is_alive():
|
||||
self.current_process.kill()
|
||||
self.current_process.join()
|
||||
raise e
|
||||
finally:
|
||||
self.current_process = None
|
||||
self.result_queue = None
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get conversation status (runs in main process)."""
|
||||
return {
|
||||
'conversation_id': self.conversation.id,
|
||||
'agent_status': self.conversation.state.agent_status.value if self.conversation.state else 'unknown',
|
||||
'is_running': self.current_process is not None and self.current_process.is_alive()
|
||||
}
|
||||
|
||||
def toggle_confirmation_mode(self) -> bool:
|
||||
"""Toggle confirmation mode (runs in main process)."""
|
||||
self.runner.toggle_confirmation_mode()
|
||||
# Update our conversation reference
|
||||
self.conversation = self.runner.conversation
|
||||
return self.conversation.is_confirmation_mode_active
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the agent (runs in main process)."""
|
||||
# This would be handled by the conversation state
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self.current_process and self.current_process.is_alive():
|
||||
self.current_process.terminate()
|
||||
self.current_process.join(timeout=2)
|
||||
if self.current_process.is_alive():
|
||||
self.current_process.kill()
|
||||
self.current_process.join()
|
||||
|
||||
# Clean up conversation resources if needed
|
||||
if hasattr(self.conversation, 'close'):
|
||||
self.conversation.close()
|
||||
68
openhands-cli/openhands_cli/simple_signal_handler.py
Normal file
68
openhands-cli/openhands_cli/simple_signal_handler.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Simple signal handling for Ctrl+C behavior in OpenHands CLI.
|
||||
|
||||
- First Ctrl+C: Attempt graceful pause of the agent
|
||||
- Second Ctrl+C: Immediately kill the process
|
||||
"""
|
||||
|
||||
import signal
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
|
||||
class SimpleSignalHandler:
|
||||
"""Simple signal handler that tracks Ctrl+C presses and manages a subprocess."""
|
||||
|
||||
def __init__(self):
|
||||
self.ctrl_c_count = 0
|
||||
self.last_ctrl_c_time = 0.0
|
||||
self.timeout = 3.0 # Reset counter after 3 seconds
|
||||
self.original_handler = None
|
||||
self.current_process: Optional[object] = None
|
||||
|
||||
def install(self) -> None:
|
||||
"""Install the signal handler."""
|
||||
self.original_handler = signal.signal(signal.SIGINT, self._handle_ctrl_c)
|
||||
|
||||
def uninstall(self) -> None:
|
||||
"""Restore the original signal handler."""
|
||||
if self.original_handler is not None:
|
||||
signal.signal(signal.SIGINT, self.original_handler)
|
||||
self.original_handler = None
|
||||
|
||||
def reset_count(self) -> None:
|
||||
"""Reset the Ctrl+C count (called when starting new message processing)."""
|
||||
self.ctrl_c_count = 0
|
||||
self.last_ctrl_c_time = 0.0
|
||||
|
||||
def set_process(self, process) -> None:
|
||||
"""Set the current process to manage."""
|
||||
self.current_process = process
|
||||
|
||||
def _handle_ctrl_c(self, signum: int, frame) -> None:
|
||||
"""Handle Ctrl+C signal."""
|
||||
current_time = time.time()
|
||||
|
||||
# Reset counter if too much time has passed
|
||||
if current_time - self.last_ctrl_c_time > self.timeout:
|
||||
self.ctrl_c_count = 0
|
||||
|
||||
self.ctrl_c_count += 1
|
||||
self.last_ctrl_c_time = current_time
|
||||
|
||||
if self.ctrl_c_count == 1:
|
||||
print_formatted_text(HTML('<yellow>Received Ctrl+C. Attempting to pause agent...</yellow>'))
|
||||
if self.current_process and self.current_process.is_alive():
|
||||
self.current_process.terminate()
|
||||
print_formatted_text(HTML('<yellow>Press Ctrl+C again within 3 seconds to force kill.</yellow>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<yellow>No active process to pause.</yellow>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<red>Received second Ctrl+C. Force killing process...</red>'))
|
||||
if self.current_process and self.current_process.is_alive():
|
||||
self.current_process.kill()
|
||||
# Reset the counter so user can continue with new messages
|
||||
self.reset_count()
|
||||
print_formatted_text(HTML('<green>Process stopped. You can continue sending messages.</green>'))
|
||||
@@ -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(
|
||||
|
||||
74
openhands-cli/test_ctrl_c.py
Normal file
74
openhands-cli/test_ctrl_c.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify Ctrl+C behavior in the OpenHands CLI.
|
||||
|
||||
This script simulates the signal handling behavior to test:
|
||||
1. First Ctrl+C attempts graceful pause
|
||||
2. Second Ctrl+C (within 3 seconds) kills process immediately
|
||||
"""
|
||||
|
||||
import signal
|
||||
import time
|
||||
import multiprocessing
|
||||
from openhands_cli.signal_handler import ProcessSignalHandler
|
||||
|
||||
|
||||
def mock_conversation_process():
|
||||
"""Mock conversation process that runs indefinitely"""
|
||||
print("Mock conversation process started...")
|
||||
try:
|
||||
while True:
|
||||
print("Agent is working...")
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print("Mock conversation process received KeyboardInterrupt")
|
||||
except Exception as e:
|
||||
print(f"Mock conversation process error: {e}")
|
||||
finally:
|
||||
print("Mock conversation process ending")
|
||||
|
||||
|
||||
def test_signal_handling():
|
||||
"""Test the signal handling behavior"""
|
||||
print("Testing Ctrl+C signal handling...")
|
||||
print("Instructions:")
|
||||
print("1. Press Ctrl+C once - should attempt graceful pause")
|
||||
print("2. Press Ctrl+C again within 3 seconds - should kill immediately")
|
||||
print("3. Wait more than 3 seconds between presses to test timeout reset")
|
||||
print()
|
||||
|
||||
# Create and start mock process
|
||||
process = multiprocessing.Process(target=mock_conversation_process)
|
||||
process.start()
|
||||
|
||||
# Install signal handler
|
||||
signal_handler = ProcessSignalHandler()
|
||||
signal_handler.install_handler()
|
||||
signal_handler.set_conversation_process(process)
|
||||
|
||||
try:
|
||||
print("Process started. Press Ctrl+C to test signal handling...")
|
||||
print("Process PID:", process.pid)
|
||||
|
||||
# Wait for process to finish or be killed
|
||||
while process.is_alive():
|
||||
time.sleep(0.5)
|
||||
|
||||
print(f"Process finished with exit code: {process.exitcode}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Main process received KeyboardInterrupt")
|
||||
finally:
|
||||
# Clean up
|
||||
signal_handler.uninstall_handler()
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
process.join(timeout=2)
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join()
|
||||
print("Test completed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_signal_handling()
|
||||
@@ -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,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
|
||||
@@ -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."""
|
||||
|
||||
69
openhands-cli/tests/test_loading.py
Normal file
69
openhands-cli/tests/test_loading.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
14
poetry.lock
generated
14
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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