Compare commits

..

1 Commits

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