From 6710a39621b8f7c1d24af89441741a661c4ce672 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:26:37 +0400 Subject: [PATCH 01/13] hotfix(frontend): add unified conversation config hook with V1 support (#11547) --- .../conversation-service.api.ts | 2 +- .../v1-conversation-service.api.ts | 13 +++ .../hooks/query/use-conversation-config.ts | 87 ++++++++++++++++++- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/conversation-service/conversation-service.api.ts b/frontend/src/api/conversation-service/conversation-service.api.ts index 9f4f12081d..ed0ce8b678 100644 --- a/frontend/src/api/conversation-service/conversation-service.api.ts +++ b/frontend/src/api/conversation-service/conversation-service.api.ts @@ -187,7 +187,7 @@ class ConversationService { static async getRuntimeId( conversationId: string, ): Promise<{ runtime_id: string }> { - const url = `/api/conversations/${conversationId}/config`; + const url = `${this.getConversationUrl(conversationId)}/config`; const { data } = await openHands.get<{ runtime_id: string }>(url, { headers: this.getConversationHeaders(), }); diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index def026ba6c..e638b14ba5 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -319,6 +319,19 @@ class V1ConversationService { }, }); } + + /** + * Get the conversation config (runtime_id) for a V1 conversation + * @param conversationId The conversation ID + * @returns Object containing runtime_id + */ + static async getConversationConfig( + conversationId: string, + ): Promise<{ runtime_id: string }> { + const url = `/api/conversations/${conversationId}/config`; + const { data } = await openHands.get<{ runtime_id: string }>(url); + return data; + } } export default V1ConversationService; diff --git a/frontend/src/hooks/query/use-conversation-config.ts b/frontend/src/hooks/query/use-conversation-config.ts index 7f851f78e2..705fb02791 100644 --- a/frontend/src/hooks/query/use-conversation-config.ts +++ b/frontend/src/hooks/query/use-conversation-config.ts @@ -2,14 +2,20 @@ import { useQuery } from "@tanstack/react-query"; import React from "react"; import { useConversationId } from "#/hooks/use-conversation-id"; import ConversationService from "#/api/conversation-service/conversation-service.api"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; import { useRuntimeIsReady } from "../use-runtime-is-ready"; +import { useActiveConversation } from "./use-active-conversation"; -export const useConversationConfig = () => { +/** + * @deprecated This hook is for V0 conversations only. Use useUnifiedConversationConfig instead, + * or useV1ConversationConfig once we fully migrate to V1. + */ +export const useV0ConversationConfig = () => { const { conversationId } = useConversationId(); const runtimeIsReady = useRuntimeIsReady(); const query = useQuery({ - queryKey: ["conversation_config", conversationId], + queryKey: ["v0_conversation_config", conversationId], queryFn: () => { if (!conversationId) throw new Error("No conversation ID"); return ConversationService.getRuntimeId(conversationId); @@ -34,3 +40,80 @@ export const useConversationConfig = () => { return query; }; + +export const useV1ConversationConfig = () => { + const { conversationId } = useConversationId(); + const runtimeIsReady = useRuntimeIsReady(); + + const query = useQuery({ + queryKey: ["v1_conversation_config", conversationId], + queryFn: () => { + if (!conversationId) throw new Error("No conversation ID"); + return V1ConversationService.getConversationConfig(conversationId); + }, + enabled: runtimeIsReady && !!conversationId, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); + + React.useEffect(() => { + if (query.data) { + const { runtime_id: runtimeId } = query.data; + + // eslint-disable-next-line no-console + console.log( + "Runtime ID: %c%s", + "background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;", + runtimeId, + ); + } + }, [query.data]); + + return query; +}; + +/** + * Unified hook that switches between V0 and V1 conversation config endpoints based on conversation version. + * + * @temporary This hook is temporary during the V0 to V1 migration period. + * Once we fully migrate to V1, all code should use useV1ConversationConfig directly. + */ +export const useUnifiedConversationConfig = () => { + const { conversationId } = useConversationId(); + const { data: conversation } = useActiveConversation(); + const runtimeIsReady = useRuntimeIsReady(); + const isV1Conversation = conversation?.conversation_version === "V1"; + + const query = useQuery({ + queryKey: ["conversation_config", conversationId, isV1Conversation], + queryFn: () => { + if (!conversationId) throw new Error("No conversation ID"); + + if (isV1Conversation) { + return V1ConversationService.getConversationConfig(conversationId); + } + return ConversationService.getRuntimeId(conversationId); + }, + enabled: runtimeIsReady && !!conversationId && conversation !== undefined, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); + + React.useEffect(() => { + if (query.data) { + const { runtime_id: runtimeId } = query.data; + + // eslint-disable-next-line no-console + console.log( + "Runtime ID: %c%s", + "background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;", + runtimeId, + ); + } + }, [query.data]); + + return query; +}; + +// Keep the old export name for backward compatibility (uses unified approach) +export const useConversationConfig = useUnifiedConversationConfig; From aba5d54a86466a63a623e53168a3b616b2ab9836 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:29:27 +0700 Subject: [PATCH 02/13] feat(frontend): V1 confirmation's call the right API (#11542) --- .../v1-conversation-service.api.ts | 24 +-- .../api/event-service/event-service.api.ts | 41 +++++ .../api/event-service/event-service.types.ts | 8 + .../features/chat/chat-interface.tsx | 9 +- .../buttons/v1-confirmation-buttons.tsx | 141 ++++++++++++++++++ .../generic-event-message-wrapper.tsx | 9 +- .../user-assistant-event-message.tsx | 8 +- .../src/components/v1/chat/event-message.tsx | 12 +- frontend/src/components/v1/chat/messages.tsx | 4 +- .../mutation/use-respond-to-confirmation.ts | 32 ++++ frontend/src/stores/event-message-store.ts | 14 ++ frontend/src/utils/utils.ts | 15 ++ 12 files changed, 268 insertions(+), 49 deletions(-) create mode 100644 frontend/src/api/event-service/event-service.api.ts create mode 100644 frontend/src/api/event-service/event-service.types.ts create mode 100644 frontend/src/components/shared/buttons/v1-confirmation-buttons.tsx create mode 100644 frontend/src/hooks/mutation/use-respond-to-confirmation.ts diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index e638b14ba5..4ec039ee2d 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -3,6 +3,7 @@ import { openHands } from "../open-hands-axios"; import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types"; import { Provider } from "#/types/settings"; import { buildHttpBaseUrl } from "#/utils/websocket-url"; +import { buildSessionHeaders } from "#/utils/utils"; import type { V1SendMessageRequest, V1SendMessageResponse, @@ -13,21 +14,6 @@ import type { } from "./v1-conversation-service.types"; class V1ConversationService { - /** - * Build headers for V1 API requests that require session authentication - * @param sessionApiKey Session API key for authentication - * @returns Headers object with X-Session-API-Key if provided - */ - private static buildSessionHeaders( - sessionApiKey?: string | null, - ): Record { - const headers: Record = {}; - if (sessionApiKey) { - headers["X-Session-API-Key"] = sessionApiKey; - } - return headers; - } - /** * Build the full URL for V1 runtime-specific endpoints * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") @@ -160,7 +146,7 @@ class V1ConversationService { sessionApiKey?: string | null, ): Promise { const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url"); - const headers = this.buildSessionHeaders(sessionApiKey); + const headers = buildSessionHeaders(sessionApiKey); // V1 API returns {url: '...'} instead of {vscode_url: '...'} // Map it to match the expected interface @@ -188,7 +174,7 @@ class V1ConversationService { conversationUrl, `/api/conversations/${conversationId}/pause`, ); - const headers = this.buildSessionHeaders(sessionApiKey); + const headers = buildSessionHeaders(sessionApiKey); const { data } = await axios.post<{ success: boolean }>( url, @@ -216,7 +202,7 @@ class V1ConversationService { conversationUrl, `/api/conversations/${conversationId}/run`, ); - const headers = this.buildSessionHeaders(sessionApiKey); + const headers = buildSessionHeaders(sessionApiKey); const { data } = await axios.post<{ success: boolean }>( url, @@ -305,7 +291,7 @@ class V1ConversationService { conversationUrl, `/api/file/upload/${encodedPath}`, ); - const headers = this.buildSessionHeaders(sessionApiKey); + const headers = buildSessionHeaders(sessionApiKey); // Create FormData with the file const formData = new FormData(); diff --git a/frontend/src/api/event-service/event-service.api.ts b/frontend/src/api/event-service/event-service.api.ts new file mode 100644 index 0000000000..90a1d4e64e --- /dev/null +++ b/frontend/src/api/event-service/event-service.api.ts @@ -0,0 +1,41 @@ +import axios from "axios"; +import { buildHttpBaseUrl } from "#/utils/websocket-url"; +import { buildSessionHeaders } from "#/utils/utils"; +import type { + ConfirmationResponseRequest, + ConfirmationResponseResponse, +} from "./event-service.types"; + +class EventService { + /** + * Respond to a confirmation request in a V1 conversation + * @param conversationId The conversation ID + * @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...") + * @param request The confirmation response request + * @param sessionApiKey Session API key for authentication (required for V1) + * @returns The confirmation response + */ + static async respondToConfirmation( + conversationId: string, + conversationUrl: string, + request: ConfirmationResponseRequest, + sessionApiKey?: string | null, + ): Promise { + // Build the runtime URL using the conversation URL + const runtimeUrl = buildHttpBaseUrl(conversationUrl); + + // Build session headers for authentication + const headers = buildSessionHeaders(sessionApiKey); + + // Make the API call to the runtime endpoint + const { data } = await axios.post( + `${runtimeUrl}/api/conversations/${conversationId}/events/respond_to_confirmation`, + request, + { headers }, + ); + + return data; + } +} + +export default EventService; diff --git a/frontend/src/api/event-service/event-service.types.ts b/frontend/src/api/event-service/event-service.types.ts new file mode 100644 index 0000000000..84c447f3e7 --- /dev/null +++ b/frontend/src/api/event-service/event-service.types.ts @@ -0,0 +1,8 @@ +export interface ConfirmationResponseRequest { + accept: boolean; + reason?: string; +} + +export interface ConfirmationResponseResponse { + success: boolean; +} diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index a6dacc9cda..800bb37762 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -237,14 +237,7 @@ export function ChatInterface() { /> )} - {v1UserEventsExist && ( - - )} + {v1UserEventsExist && }
diff --git a/frontend/src/components/shared/buttons/v1-confirmation-buttons.tsx b/frontend/src/components/shared/buttons/v1-confirmation-buttons.tsx new file mode 100644 index 0000000000..bba1bad4f3 --- /dev/null +++ b/frontend/src/components/shared/buttons/v1-confirmation-buttons.tsx @@ -0,0 +1,141 @@ +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { I18nKey } from "#/i18n/declaration"; +import { AgentState } from "#/types/agent-state"; +import { ActionTooltip } from "../action-tooltip"; +import { RiskAlert } from "#/components/shared/risk-alert"; +import WarningIcon from "#/icons/u-warning.svg?react"; +import { useEventMessageStore } from "#/stores/event-message-store"; +import { useEventStore } from "#/stores/use-event-store"; +import { isV1Event, isActionEvent } from "#/types/v1/type-guards"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useAgentState } from "#/hooks/use-agent-state"; +import { useRespondToConfirmation } from "#/hooks/mutation/use-respond-to-confirmation"; +import { SecurityRisk } from "#/types/v1/core/base/common"; + +export function V1ConfirmationButtons() { + const v1SubmittedEventIds = useEventMessageStore( + (state) => state.v1SubmittedEventIds, + ); + const addV1SubmittedEventId = useEventMessageStore( + (state) => state.addV1SubmittedEventId, + ); + + const { t } = useTranslation(); + const { data: conversation } = useActiveConversation(); + const { curAgentState } = useAgentState(); + const { mutate: respondToConfirmation } = useRespondToConfirmation(); + const events = useEventStore((state) => state.events); + + // Find the most recent V1 action awaiting confirmation + const awaitingAction = events + .filter(isV1Event) + .slice() + .reverse() + .find((ev) => { + if (ev.source !== "agent") return false; + // For V1, we check if the agent state is waiting for confirmation + return curAgentState === AgentState.AWAITING_USER_CONFIRMATION; + }); + + const handleConfirmation = useCallback( + (accept: boolean) => { + if (!awaitingAction || !conversation) { + return; + } + + // Mark event as submitted to prevent duplicate submissions + addV1SubmittedEventId(awaitingAction.id); + + // Call the V1 API endpoint + respondToConfirmation({ + conversationId: conversation.conversation_id, + conversationUrl: conversation.url || "", + sessionApiKey: conversation.session_api_key, + accept, + }); + }, + [ + awaitingAction, + conversation, + addV1SubmittedEventId, + respondToConfirmation, + ], + ); + + // Handle keyboard shortcuts + useEffect(() => { + if (!awaitingAction) { + return undefined; + } + + const handleCancelShortcut = (event: KeyboardEvent) => { + if (event.shiftKey && event.metaKey && event.key === "Backspace") { + event.preventDefault(); + handleConfirmation(false); + } + }; + + const handleContinueShortcut = (event: KeyboardEvent) => { + if (event.metaKey && event.key === "Enter") { + event.preventDefault(); + handleConfirmation(true); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + // Cancel: Shift+Cmd+Backspace (⇧⌘⌫) + handleCancelShortcut(event); + // Continue: Cmd+Enter (⌘↩) + handleContinueShortcut(event); + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => document.removeEventListener("keydown", handleKeyDown); + }, [awaitingAction, handleConfirmation]); + + // Only show if agent is waiting for confirmation and we haven't already submitted + if ( + curAgentState !== AgentState.AWAITING_USER_CONFIRMATION || + !awaitingAction || + v1SubmittedEventIds.includes(awaitingAction.id) + ) { + return null; + } + + // Get security risk from the action (only ActionEvent has security_risk) + const risk = isActionEvent(awaitingAction) + ? awaitingAction.security_risk + : SecurityRisk.UNKNOWN; + + const isHighRisk = risk === SecurityRisk.HIGH; + + return ( +
+ {isHighRisk && ( + } + severity="high" + title={t(I18nKey.COMMON$HIGH_RISK)} + /> + )} +
+

+ {t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)} +

+
+ handleConfirmation(false)} + /> + handleConfirmation(true)} + /> +
+
+
+ ); +} diff --git a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx index c2ac1d9a73..ecb33e7c11 100644 --- a/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx +++ b/frontend/src/components/v1/chat/event-message-components/generic-event-message-wrapper.tsx @@ -1,19 +1,18 @@ -import React from "react"; import { OpenHandsEvent } from "#/types/v1/core"; import { GenericEventMessage } from "../../../features/chat/generic-event-message"; import { getEventContent } from "../event-content-helpers/get-event-content"; import { getObservationResult } from "../event-content-helpers/get-observation-result"; import { isObservationEvent } from "#/types/v1/type-guards"; -import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons"; +import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons"; interface GenericEventMessageWrapperProps { event: OpenHandsEvent; - shouldShowConfirmationButtons: boolean; + isLastMessage: boolean; } export function GenericEventMessageWrapper({ event, - shouldShowConfirmationButtons, + isLastMessage, }: GenericEventMessageWrapperProps) { const { title, details } = getEventContent(event); @@ -27,7 +26,7 @@ export function GenericEventMessageWrapper({ } initiallyExpanded={false} /> - {shouldShowConfirmationButtons && } + {isLastMessage && }
); } diff --git a/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx index 260f9688ef..6455dadbe3 100644 --- a/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx +++ b/frontend/src/components/v1/chat/event-message-components/user-assistant-event-message.tsx @@ -4,7 +4,7 @@ import { ChatMessage } from "../../../features/chat/chat-message"; import { ImageCarousel } from "../../../features/images/image-carousel"; // TODO: Implement file_urls support for V1 messages // import { FileList } from "../../../features/files/file-list"; -import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons"; +import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons"; import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper"; // TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs // import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper"; @@ -13,7 +13,6 @@ import { MicroagentStatus } from "#/types/microagent-status"; interface UserAssistantEventMessageProps { event: MessageEvent; - shouldShowConfirmationButtons: boolean; microagentStatus?: MicroagentStatus | null; microagentConversationId?: string; microagentPRUrl?: string; @@ -22,15 +21,16 @@ interface UserAssistantEventMessageProps { onClick: () => void; tooltip?: string; }>; + isLastMessage: boolean; } export function UserAssistantEventMessage({ event, - shouldShowConfirmationButtons, microagentStatus, microagentConversationId, microagentPRUrl, actions, + isLastMessage, }: UserAssistantEventMessageProps) { const message = parseMessageFromEvent(event); @@ -51,7 +51,7 @@ export function UserAssistantEventMessage({ )} {/* TODO: Handle file_urls if V1 messages support them */} - {shouldShowConfirmationButtons && } + {isLastMessage && } ); } // Generic fallback for all other events (including observation events) return ( - + ); } diff --git a/frontend/src/components/v1/chat/messages.tsx b/frontend/src/components/v1/chat/messages.tsx index b6b7a1ca1d..d6cc018090 100644 --- a/frontend/src/components/v1/chat/messages.tsx +++ b/frontend/src/components/v1/chat/messages.tsx @@ -10,11 +10,10 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message- interface MessagesProps { messages: OpenHandsEvent[]; - isAwaitingUserConfirmation: boolean; } export const Messages: React.FC = React.memo( - ({ messages, isAwaitingUserConfirmation }) => { + ({ messages }) => { const { getOptimisticUserMessage } = useOptimisticUserMessageStore(); const optimisticUserMessage = getOptimisticUserMessage(); @@ -43,7 +42,6 @@ export const Messages: React.FC = React.memo( key={message.id} event={message} hasObservationPair={actionHasObservationPair(message)} - isAwaitingUserConfirmation={isAwaitingUserConfirmation} isLastMessage={messages.length - 1 === index} isInLast10Actions={messages.length - 1 - index < 10} // Microagent props - not implemented yet for V1 diff --git a/frontend/src/hooks/mutation/use-respond-to-confirmation.ts b/frontend/src/hooks/mutation/use-respond-to-confirmation.ts new file mode 100644 index 0000000000..26c267ce99 --- /dev/null +++ b/frontend/src/hooks/mutation/use-respond-to-confirmation.ts @@ -0,0 +1,32 @@ +import { useMutation } from "@tanstack/react-query"; +import EventService from "#/api/event-service/event-service.api"; +import type { ConfirmationResponseRequest } from "#/api/event-service/event-service.types"; + +interface UseRespondToConfirmationVariables { + conversationId: string; + conversationUrl: string; + sessionApiKey?: string | null; + accept: boolean; +} + +export const useRespondToConfirmation = () => + useMutation({ + mutationKey: ["respond-to-confirmation"], + mutationFn: async ({ + conversationId, + conversationUrl, + sessionApiKey, + accept, + }: UseRespondToConfirmationVariables) => { + const request: ConfirmationResponseRequest = { + accept, + }; + + return EventService.respondToConfirmation( + conversationId, + conversationUrl, + request, + sessionApiKey, + ); + }, + }); diff --git a/frontend/src/stores/event-message-store.ts b/frontend/src/stores/event-message-store.ts index 09315b7e5e..08ba465bac 100644 --- a/frontend/src/stores/event-message-store.ts +++ b/frontend/src/stores/event-message-store.ts @@ -2,15 +2,19 @@ import { create } from "zustand"; interface EventMessageState { submittedEventIds: number[]; // Avoid the flashing issue of the confirmation buttons + v1SubmittedEventIds: string[]; // V1 event IDs (V1 uses string IDs) } interface EventMessageStore extends EventMessageState { addSubmittedEventId: (id: number) => void; removeSubmittedEventId: (id: number) => void; + addV1SubmittedEventId: (id: string) => void; + removeV1SubmittedEventId: (id: string) => void; } export const useEventMessageStore = create((set) => ({ submittedEventIds: [], + v1SubmittedEventIds: [], addSubmittedEventId: (id: number) => set((state) => ({ submittedEventIds: [...state.submittedEventIds, id], @@ -21,4 +25,14 @@ export const useEventMessageStore = create((set) => ({ (eventId) => eventId !== id, ), })), + addV1SubmittedEventId: (id: string) => + set((state) => ({ + v1SubmittedEventIds: [...state.v1SubmittedEventIds, id], + })), + removeV1SubmittedEventId: (id: string) => + set((state) => ({ + v1SubmittedEventIds: state.v1SubmittedEventIds.filter( + (eventId) => eventId !== id, + ), + })), })); diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index baf6b85d1a..8603804ecc 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -594,3 +594,18 @@ export const hasOpenHandsSuffix = ( } return repo.full_name.endsWith("/.openhands"); }; + +/** + * Build headers for V1 API requests that require session authentication + * @param sessionApiKey Session API key for authentication + * @returns Headers object with X-Session-API-Key if provided + */ +export const buildSessionHeaders = ( + sessionApiKey?: string | null, +): Record => { + const headers: Record = {}; + if (sessionApiKey) { + headers["X-Session-API-Key"] = sessionApiKey; + } + return headers; +}; From 2fdd4d084ae4c7e608a49053df116d95699745a1 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:31:05 +0700 Subject: [PATCH 03/13] =?UTF-8?q?feat(frontend):=20display=20=E2=80=9Cwait?= =?UTF-8?q?ing=20for=20user=20confirmation=E2=80=9D=20when=20agent=20statu?= =?UTF-8?q?s=20is=20=E2=80=9Cawaiting=5Fuser=5Fconfirmation=E2=80=9D=20(#1?= =?UTF-8?q?1539)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/i18n/declaration.ts | 1 + frontend/src/i18n/translation.json | 16 ++++++++++++++++ frontend/src/utils/status.ts | 2 +- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 5cd751a9c4..fb0e17537e 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -930,4 +930,5 @@ export enum I18nKey { TOAST$STOPPING_CONVERSATION = "TOAST$STOPPING_CONVERSATION", TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION", TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED", + AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION", } diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 3b0ee7bcc2..ff51ba06f0 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -14878,5 +14878,21 @@ "tr": "Konuşma durduruldu", "de": "Konversation gestoppt", "uk": "Розмову зупинено" + }, + "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION": { + "en": "Waiting for user confirmation", + "ja": "ユーザーの確認を待っています", + "zh-CN": "等待用户确认", + "zh-TW": "等待使用者確認", + "ko-KR": "사용자 확인 대기 중", + "no": "Venter på brukerbekreftelse", + "it": "In attesa di conferma dell'utente", + "pt": "Aguardando confirmação do usuário", + "es": "Esperando confirmación del usuario", + "ar": "في انتظار تأكيد المستخدم", + "fr": "En attente de la confirmation de l'utilisateur", + "tr": "Kullanıcı onayı bekleniyor", + "de": "Warte auf Benutzerbestätigung", + "uk": "Очікується підтвердження користувача" } } diff --git a/frontend/src/utils/status.ts b/frontend/src/utils/status.ts index 11c0314824..b450892fa1 100644 --- a/frontend/src/utils/status.ts +++ b/frontend/src/utils/status.ts @@ -24,7 +24,7 @@ export const AGENT_STATUS_MAP: { // Ready/Idle/Waiting for user input states [AgentState.AWAITING_USER_INPUT]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK, [AgentState.AWAITING_USER_CONFIRMATION]: - I18nKey.AGENT_STATUS$WAITING_FOR_TASK, + I18nKey.AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION, [AgentState.USER_CONFIRMED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK, [AgentState.USER_REJECTED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK, [AgentState.FINISHED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK, From 4020448d64ca1dfe29a677bd7a98a46c2f2cd2b1 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 29 Oct 2025 18:52:31 +0400 Subject: [PATCH 04/13] chore(frontend): Add unified hooks for V1 sandbox URLs (VSCode and served hosts) (#11511) Co-authored-by: openhands --- .../v1-conversation-service.api.ts | 27 ++++ .../v1-conversation-service.types.ts | 15 +++ .../query/use-batch-app-conversations.ts | 11 ++ .../src/hooks/query/use-batch-sandboxes.ts | 11 ++ .../hooks/query/use-unified-active-host.ts | 99 ++++++++++++++ .../src/hooks/query/use-unified-vscode-url.ts | 122 ++++++++++++++++++ frontend/src/routes/served-tab.tsx | 4 +- frontend/src/routes/vscode-tab.tsx | 14 +- 8 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 frontend/src/hooks/query/use-batch-app-conversations.ts create mode 100644 frontend/src/hooks/query/use-batch-sandboxes.ts create mode 100644 frontend/src/hooks/query/use-unified-active-host.ts create mode 100644 frontend/src/hooks/query/use-unified-vscode-url.ts diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index 4ec039ee2d..59bf44b1d4 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -11,6 +11,7 @@ import type { V1AppConversationStartTask, V1AppConversationStartTaskPage, V1AppConversation, + V1SandboxInfo, } from "./v1-conversation-service.types"; class V1ConversationService { @@ -268,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} diff --git a/frontend/src/api/conversation-service/v1-conversation-service.types.ts b/frontend/src/api/conversation-service/v1-conversation-service.types.ts index 9ff3499652..f1206fc382 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.types.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.types.ts @@ -98,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; +} diff --git a/frontend/src/hooks/query/use-batch-app-conversations.ts b/frontend/src/hooks/query/use-batch-app-conversations.ts new file mode 100644 index 0000000000..0218359450 --- /dev/null +++ b/frontend/src/hooks/query/use-batch-app-conversations.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; + +export const useBatchAppConversations = (ids: string[]) => + useQuery({ + queryKey: ["v1-batch-get-app-conversations", ids], + queryFn: () => V1ConversationService.batchGetAppConversations(ids), + enabled: ids.length > 0, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); diff --git a/frontend/src/hooks/query/use-batch-sandboxes.ts b/frontend/src/hooks/query/use-batch-sandboxes.ts new file mode 100644 index 0000000000..bf4f456114 --- /dev/null +++ b/frontend/src/hooks/query/use-batch-sandboxes.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; + +export const useBatchSandboxes = (ids: string[]) => + useQuery({ + queryKey: ["sandboxes", "batch", ids], + queryFn: () => V1ConversationService.batchGetSandboxes(ids), + enabled: ids.length > 0, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 15, // 15 minutes + }); diff --git a/frontend/src/hooks/query/use-unified-active-host.ts b/frontend/src/hooks/query/use-unified-active-host.ts new file mode 100644 index 0000000000..cc9b8a1a3d --- /dev/null +++ b/frontend/src/hooks/query/use-unified-active-host.ts @@ -0,0 +1,99 @@ +import { useQueries, useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import React from "react"; +import ConversationService from "#/api/conversation-service/conversation-service.api"; +import { useConversationId } from "#/hooks/use-conversation-id"; +import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useBatchSandboxes } from "./use-batch-sandboxes"; +import { useConversationConfig } from "./use-conversation-config"; + +/** + * Unified hook to get active web host for both legacy (V0) and V1 conversations + * - V0: Uses the legacy getWebHosts API endpoint and polls them + * - V1: Gets worker URLs from sandbox exposed_urls (WORKER_1, WORKER_2, etc.) + */ +export const useUnifiedActiveHost = () => { + const [activeHost, setActiveHost] = React.useState(null); + const { conversationId } = useConversationId(); + const runtimeIsReady = useRuntimeIsReady(); + const { data: conversation } = useActiveConversation(); + const { data: conversationConfig, isLoading: isLoadingConfig } = + useConversationConfig(); + + const isV1Conversation = conversation?.conversation_version === "V1"; + const sandboxId = conversationConfig?.runtime_id; + + // Fetch sandbox data for V1 conversations + const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []); + + // Get worker URLs from V1 sandbox or legacy web hosts from V0 + const { data, isLoading: hostsQueryLoading } = useQuery({ + queryKey: [conversationId, "unified", "hosts", isV1Conversation, sandboxId], + queryFn: async () => { + // V1: Get worker URLs from sandbox exposed_urls + if (isV1Conversation) { + if ( + !sandboxesQuery.data || + sandboxesQuery.data.length === 0 || + !sandboxesQuery.data[0] + ) { + return { hosts: [] }; + } + + const sandbox = sandboxesQuery.data[0]; + const workerUrls = + sandbox.exposed_urls + ?.filter((url) => url.name.startsWith("WORKER_")) + .map((url) => url.url) || []; + + return { hosts: workerUrls }; + } + + // V0 (Legacy): Use the legacy API endpoint + const hosts = await ConversationService.getWebHosts(conversationId); + return { hosts }; + }, + enabled: + runtimeIsReady && + !!conversationId && + (!isV1Conversation || !!sandboxesQuery.data), + initialData: { hosts: [] }, + meta: { + disableToast: true, + }, + }); + + // Poll all hosts to find which one is active + const apps = useQueries({ + queries: data.hosts.map((host) => ({ + queryKey: [conversationId, "unified", "hosts", host], + queryFn: async () => { + try { + await axios.get(host); + return host; + } catch (e) { + return ""; + } + }, + refetchInterval: 3000, + meta: { + disableToast: true, + }, + })), + }); + + const appsData = apps.map((app) => app.data); + + React.useEffect(() => { + const successfulApp = appsData.find((app) => app); + setActiveHost(successfulApp || ""); + }, [appsData]); + + // Calculate overall loading state including dependent queries for V1 + const isLoading = isV1Conversation + ? isLoadingConfig || sandboxesQuery.isLoading || hostsQueryLoading + : hostsQueryLoading; + + return { activeHost, isLoading }; +}; diff --git a/frontend/src/hooks/query/use-unified-vscode-url.ts b/frontend/src/hooks/query/use-unified-vscode-url.ts new file mode 100644 index 0000000000..3355cf5cd9 --- /dev/null +++ b/frontend/src/hooks/query/use-unified-vscode-url.ts @@ -0,0 +1,122 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import ConversationService from "#/api/conversation-service/conversation-service.api"; +import { useConversationId } from "#/hooks/use-conversation-id"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { I18nKey } from "#/i18n/declaration"; +import { transformVSCodeUrl } from "#/utils/vscode-url-helper"; +import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready"; +import { useBatchAppConversations } from "./use-batch-app-conversations"; +import { useBatchSandboxes } from "./use-batch-sandboxes"; + +interface VSCodeUrlResult { + url: string | null; + error: string | null; +} + +/** + * Unified hook to get VSCode URL for both legacy (V0) and V1 conversations + * - V0: Uses the legacy getVSCodeUrl API endpoint + * - V1: Gets the VSCode URL from sandbox exposed_urls + */ +export const useUnifiedVSCodeUrl = () => { + const { t } = useTranslation(); + const { conversationId } = useConversationId(); + const { data: conversation } = useActiveConversation(); + const runtimeIsReady = useRuntimeIsReady(); + + const isV1Conversation = conversation?.conversation_version === "V1"; + + // Fetch V1 app conversation to get sandbox_id + const appConversationsQuery = useBatchAppConversations( + isV1Conversation && conversationId ? [conversationId] : [], + ); + const appConversation = appConversationsQuery.data?.[0]; + const sandboxId = appConversation?.sandbox_id; + + // Fetch sandbox data for V1 conversations + const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []); + + const mainQuery = useQuery({ + queryKey: [ + "unified", + "vscode_url", + conversationId, + isV1Conversation, + sandboxId, + ], + queryFn: async () => { + if (!conversationId) throw new Error("No conversation ID"); + + // V1: Get VSCode URL from sandbox exposed_urls + if (isV1Conversation) { + if ( + !sandboxesQuery.data || + sandboxesQuery.data.length === 0 || + !sandboxesQuery.data[0] + ) { + return { + url: null, + error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE), + }; + } + + const sandbox = sandboxesQuery.data[0]; + const vscodeUrl = sandbox.exposed_urls?.find( + (url) => url.name === "VSCODE", + ); + + if (!vscodeUrl) { + return { + url: null, + error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE), + }; + } + + return { + url: transformVSCodeUrl(vscodeUrl.url), + error: null, + }; + } + + // V0 (Legacy): Use the legacy API endpoint + const data = await ConversationService.getVSCodeUrl(conversationId); + + if (data.vscode_url) { + return { + url: transformVSCodeUrl(data.vscode_url), + error: null, + }; + } + + return { + url: null, + error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE), + }; + }, + enabled: + runtimeIsReady && + !!conversationId && + (!isV1Conversation || !!sandboxesQuery.data), + refetchOnMount: true, + retry: 3, + }); + + // Calculate overall loading state including dependent queries for V1 + const isLoading = isV1Conversation + ? appConversationsQuery.isLoading || + sandboxesQuery.isLoading || + mainQuery.isLoading + : mainQuery.isLoading; + + // Explicitly destructure to avoid excessive re-renders from spreading the entire query object + return { + data: mainQuery.data, + error: mainQuery.error, + isLoading, + isError: mainQuery.isError, + isSuccess: mainQuery.isSuccess, + status: mainQuery.status, + refetch: mainQuery.refetch, + }; +}; diff --git a/frontend/src/routes/served-tab.tsx b/frontend/src/routes/served-tab.tsx index 74a5f7c2c1..f2f6b26883 100644 --- a/frontend/src/routes/served-tab.tsx +++ b/frontend/src/routes/served-tab.tsx @@ -2,14 +2,14 @@ import React from "react"; import { FaArrowRotateRight } from "react-icons/fa6"; import { FaExternalLinkAlt, FaHome } from "react-icons/fa"; import { useTranslation } from "react-i18next"; -import { useActiveHost } from "#/hooks/query/use-active-host"; +import { useUnifiedActiveHost } from "#/hooks/query/use-unified-active-host"; import { PathForm } from "#/components/features/served-host/path-form"; import { I18nKey } from "#/i18n/declaration"; import ServerProcessIcon from "#/icons/server-process.svg?react"; function ServedApp() { const { t } = useTranslation(); - const { activeHost } = useActiveHost(); + const { activeHost } = useUnifiedActiveHost(); const [refreshKey, setRefreshKey] = React.useState(0); const [currentActiveHost, setCurrentActiveHost] = React.useState< string | null diff --git a/frontend/src/routes/vscode-tab.tsx b/frontend/src/routes/vscode-tab.tsx index fe72079e6f..0d64180c1d 100644 --- a/frontend/src/routes/vscode-tab.tsx +++ b/frontend/src/routes/vscode-tab.tsx @@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; -import { useVSCodeUrl } from "#/hooks/query/use-vscode-url"; +import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url"; import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags"; import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message"; import { useAgentState } from "#/hooks/use-agent-state"; function VSCodeTab() { const { t } = useTranslation(); - const { data, isLoading, error } = useVSCodeUrl(); + const { data, isLoading, error } = useUnifiedVSCodeUrl(); const { curAgentState } = useAgentState(); const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState); const iframeRef = React.useRef(null); @@ -39,10 +39,18 @@ function VSCodeTab() { } }; - if (isRuntimeInactive || isLoading) { + if (isRuntimeInactive) { return ; } + if (isLoading) { + return ( +
+ {t(I18nKey.VSCODE$LOADING)} +
+ ); + } + if (error || (data && data.error) || !data?.url || iframeError) { return (
From 0e7fefca7e6621e422e9652aec54cddd5b078415 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:02:32 +0700 Subject: [PATCH 05/13] fix(frontend): displaying observation result statuses (#11559) --- .../v1/chat/event-content-helpers/get-observation-result.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-result.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-result.ts index 032e8823de..e5a52bfe95 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-result.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-result.ts @@ -11,9 +11,10 @@ export const getObservationResult = ( switch (observationType) { case "ExecuteBashObservation": { const exitCode = observation.exit_code; + const { metadata } = observation; - if (exitCode === -1) return "timeout"; // Command timed out - if (exitCode === 0) return "success"; // Command executed successfully + if (exitCode === -1 || metadata.exit_code === -1) return "timeout"; // Command timed out + if (exitCode === 0 || metadata.exit_code === 0) return "success"; // Command executed successfully return "error"; // Command failed } case "FileEditorObservation": From 6630d5dc4e3fe26c36d02b7ca9042c74dc9200c6 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 29 Oct 2025 23:03:25 +0700 Subject: [PATCH 06/13] fix(frontend): display error content when FileEditorAction encounters an error (#11560) --- .../v1/chat/event-content-helpers/get-observation-content.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts index 03e35ea9e9..39db76cd2e 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts @@ -19,6 +19,10 @@ const getFileEditorObservationContent = ( ): string => { const { observation } = event; + if (observation.error) { + return `**Error:**\n${observation.error}`; + } + const successMessage = getObservationResult(event) === "success"; // For view commands or successful edits with content changes, format as code block From 704fc6dd69a05760f384d7279baaeb3a121f534d Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:11:25 +0400 Subject: [PATCH 07/13] chore(frontend): Add history loading state for V1 conversations (#11536) --- .../conversation-websocket-handler.test.tsx | 208 +++++++++++++++++- .../__tests__/helpers/msw-websocket-setup.ts | 5 +- .../v1-conversation-service.api.ts | 18 ++ .../features/chat/chat-interface.tsx | 33 ++- .../conversation-websocket-context.tsx | 72 +++++- 5 files changed, 325 insertions(+), 11 deletions(-) diff --git a/frontend/__tests__/conversation-websocket-handler.test.tsx b/frontend/__tests__/conversation-websocket-handler.test.tsx index 3dac31a8e0..f7d67d82b5 100644 --- a/frontend/__tests__/conversation-websocket-handler.test.tsx +++ b/frontend/__tests__/conversation-websocket-handler.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; import { screen, waitFor, render, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { createMockMessageEvent, @@ -13,8 +14,12 @@ import { OptimisticUserMessageStoreComponent, ErrorMessageStoreComponent, } from "./helpers/websocket-test-components"; -import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context"; +import { + ConversationWebSocketProvider, + useConversationWebSocket, +} from "#/contexts/conversation-websocket-context"; import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup"; +import { useEventStore } from "#/stores/use-event-store"; // MSW WebSocket mock setup const { wsLink, server: mswServer } = conversationWebSocketTestSetup(); @@ -417,7 +422,206 @@ describe("Conversation WebSocket Handler", () => { it.todo("should handle send attempts when disconnected"); }); - // 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation) + // 8. History Loading State Tests + describe("History Loading State", () => { + it("should track history loading state using event count from API", async () => { + const conversationId = "test-conversation-with-history"; + + // Mock the event count API to return 3 events + const expectedEventCount = 3; + + // Create 3 mock events to simulate history + const mockHistoryEvents = [ + createMockUserMessageEvent({ id: "history-event-1" }), + createMockMessageEvent({ id: "history-event-2" }), + createMockMessageEvent({ id: "history-event-3" }), + ]; + + // Set up MSW to mock both the HTTP API and WebSocket connection + mswServer.use( + http.get("/api/v1/events/count", ({ request }) => { + const url = new URL(request.url); + const conversationIdParam = url.searchParams.get( + "conversation_id__eq", + ); + + if (conversationIdParam === conversationId) { + return HttpResponse.json(expectedEventCount); + } + + return HttpResponse.json(0); + }), + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + // Send all history events + mockHistoryEvents.forEach((event) => { + client.send(JSON.stringify(event)); + }); + }), + ); + + // Create a test component that displays loading state + const HistoryLoadingComponent = () => { + const context = useConversationWebSocket(); + const { events } = useEventStore(); + + return ( +
+
+ {context?.isLoadingHistory ? "true" : "false"} +
+
{events.length}
+
{expectedEventCount}
+
+ ); + }; + + // Render with WebSocket context + renderWithWebSocketContext( + , + conversationId, + `http://localhost:3000/api/conversations/${conversationId}`, + ); + + // Initially should be loading history + expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true"); + + // Wait for all events to be received + await waitFor(() => { + expect(screen.getByTestId("events-received")).toHaveTextContent("3"); + }); + + // Once all events are received, loading should be complete + await waitFor(() => { + expect(screen.getByTestId("is-loading-history")).toHaveTextContent( + "false", + ); + }); + }); + + it("should handle empty conversation history", async () => { + const conversationId = "test-conversation-empty"; + + // Set up MSW to mock both the HTTP API and WebSocket connection + mswServer.use( + http.get("/api/v1/events/count", ({ request }) => { + const url = new URL(request.url); + const conversationIdParam = url.searchParams.get( + "conversation_id__eq", + ); + + if (conversationIdParam === conversationId) { + return HttpResponse.json(0); + } + + return HttpResponse.json(0); + }), + wsLink.addEventListener("connection", ({ server }) => { + server.connect(); + // No events sent for empty history + }), + ); + + // Create a test component that displays loading state + const HistoryLoadingComponent = () => { + const context = useConversationWebSocket(); + + return ( +
+
+ {context?.isLoadingHistory ? "true" : "false"} +
+
+ ); + }; + + // Render with WebSocket context + renderWithWebSocketContext( + , + conversationId, + `http://localhost:3000/api/conversations/${conversationId}`, + ); + + // Should quickly transition from loading to not loading when count is 0 + await waitFor(() => { + expect(screen.getByTestId("is-loading-history")).toHaveTextContent( + "false", + ); + }); + }); + + it("should handle history loading with large event count", async () => { + const conversationId = "test-conversation-large-history"; + + // Create 50 mock events to simulate large history + const expectedEventCount = 50; + const mockHistoryEvents = Array.from({ length: 50 }, (_, i) => + createMockMessageEvent({ id: `history-event-${i + 1}` }), + ); + + // Set up MSW to mock both the HTTP API and WebSocket connection + mswServer.use( + http.get("/api/v1/events/count", ({ request }) => { + const url = new URL(request.url); + const conversationIdParam = url.searchParams.get( + "conversation_id__eq", + ); + + if (conversationIdParam === conversationId) { + return HttpResponse.json(expectedEventCount); + } + + return HttpResponse.json(0); + }), + wsLink.addEventListener("connection", ({ client, server }) => { + server.connect(); + // Send all history events + mockHistoryEvents.forEach((event) => { + client.send(JSON.stringify(event)); + }); + }), + ); + + // Create a test component that displays loading state + const HistoryLoadingComponent = () => { + const context = useConversationWebSocket(); + const { events } = useEventStore(); + + return ( +
+
+ {context?.isLoadingHistory ? "true" : "false"} +
+
{events.length}
+
+ ); + }; + + // Render with WebSocket context + renderWithWebSocketContext( + , + conversationId, + `http://localhost:3000/api/conversations/${conversationId}`, + ); + + // Initially should be loading history + expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true"); + + // Wait for all events to be received + await waitFor(() => { + expect(screen.getByTestId("events-received")).toHaveTextContent("50"); + }); + + // Once all events are received, loading should be complete + await waitFor(() => { + expect(screen.getByTestId("is-loading-history")).toHaveTextContent( + "false", + ); + }); + }); + }); + + // 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation) describe("Terminal I/O Integration", () => { it("should append command to store when ExecuteBashAction event is received", async () => { const { createMockExecuteBashActionEvent } = await import( diff --git a/frontend/__tests__/helpers/msw-websocket-setup.ts b/frontend/__tests__/helpers/msw-websocket-setup.ts index 76fe22d30c..114903e91a 100644 --- a/frontend/__tests__/helpers/msw-websocket-setup.ts +++ b/frontend/__tests__/helpers/msw-websocket-setup.ts @@ -38,8 +38,7 @@ export const createWebSocketTestSetup = ( /** * Standard WebSocket test setup for conversation WebSocket handler tests * Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId} + * Uses a wildcard pattern to match any conversation ID */ export const conversationWebSocketTestSetup = () => - createWebSocketTestSetup( - "ws://localhost:3000/sockets/events/test-conversation-default", - ); + createWebSocketTestSetup("ws://localhost:3000/sockets/events/*"); diff --git a/frontend/src/api/conversation-service/v1-conversation-service.api.ts b/frontend/src/api/conversation-service/v1-conversation-service.api.ts index 59bf44b1d4..5343ded874 100644 --- a/frontend/src/api/conversation-service/v1-conversation-service.api.ts +++ b/frontend/src/api/conversation-service/v1-conversation-service.api.ts @@ -345,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 { + const params = new URLSearchParams(); + params.append("conversation_id__eq", conversationId); + + const { data } = await openHands.get( + `/api/v1/events/count?${params.toString()}`, + ); + + return data; + } } export default V1ConversationService; diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 800bb37762..040cd8f522 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -48,6 +48,7 @@ import { } from "#/types/v1/type-guards"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; function getEntryPoint( hasRepository: boolean | null, @@ -64,6 +65,7 @@ export function ChatInterface() { const { errorMessage } = useErrorMessageStore(); const { isLoadingMessages } = useWsClient(); const { isTask } = useTaskPolling(); + const conversationWebSocket = useConversationWebSocket(); const { send } = useSendMessage(); const storeEvents = useEventStore((state) => state.events); const { setOptimisticUserMessage, getOptimisticUserMessage } = @@ -94,6 +96,25 @@ export function ChatInterface() { const isV1Conversation = conversation?.conversation_version === "V1"; + // Instantly scroll to bottom when history loading completes + const prevLoadingHistoryRef = React.useRef( + conversationWebSocket?.isLoadingHistory, + ); + React.useEffect(() => { + const wasLoading = prevLoadingHistoryRef.current; + const isLoading = conversationWebSocket?.isLoadingHistory; + + // When history loading transitions from true to false, instantly scroll to bottom + if (wasLoading && !isLoading && scrollRef.current) { + scrollRef.current.scrollTo({ + top: scrollRef.current.scrollHeight, + behavior: "instant", + }); + } + + prevLoadingHistoryRef.current = isLoading; + }, [conversationWebSocket?.isLoadingHistory, scrollRef]); + // Filter V0 events const v0Events = storeEvents .filter(isV0Event) @@ -228,6 +249,14 @@ export function ChatInterface() {
)} + {conversationWebSocket?.isLoadingHistory && + isV1Conversation && + !isTask && ( +
+ +
+ )} + {!isLoadingMessages && v0UserEventsExist && ( )} - {v1UserEventsExist && } + {!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && ( + + )}
diff --git a/frontend/src/contexts/conversation-websocket-context.tsx b/frontend/src/contexts/conversation-websocket-context.tsx index 3de57ad8d0..0be6e75393 100644 --- a/frontend/src/contexts/conversation-websocket-context.tsx +++ b/frontend/src/contexts/conversation-websocket-context.tsx @@ -5,6 +5,7 @@ import React, { useState, useCallback, useMemo, + useRef, } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket"; @@ -27,6 +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 V1ConversationService from "#/api/conversation-service/v1-conversation-service.api"; // eslint-disable-next-line @typescript-eslint/naming-convention export type V1_WebSocketConnectionState = @@ -38,6 +40,7 @@ export type V1_WebSocketConnectionState = interface ConversationWebSocketContextType { connectionState: V1_WebSocketConnectionState; sendMessage: (message: V1SendMessageRequest) => Promise; + isLoadingHistory: boolean; } const ConversationWebSocketContext = createContext< @@ -67,6 +70,13 @@ export function ConversationWebSocketProvider({ const { setAgentStatus } = useV1ConversationStateStore(); const { appendInput, appendOutput } = useCommandStore(); + // History loading state + const [isLoadingHistory, setIsLoadingHistory] = useState(true); + const [expectedEventCount, setExpectedEventCount] = useState( + null, + ); + const receivedEventCountRef = useRef(0); + // Build WebSocket URL from props // Only build URL if we have both conversationId and conversationUrl // This prevents connection attempts during task polling phase @@ -78,16 +88,43 @@ export function ConversationWebSocketProvider({ return buildWebSocketUrl(conversationId, conversationUrl); }, [conversationId, conversationUrl]); - // Reset hasConnected flag when conversation changes + // Reset hasConnected flag and history loading state when conversation changes useEffect(() => { hasConnectedRef.current = false; + setIsLoadingHistory(true); + setExpectedEventCount(null); + receivedEventCountRef.current = 0; }, [conversationId]); + // Check if we've received all events when expectedEventCount becomes available + useEffect(() => { + if ( + expectedEventCount !== null && + receivedEventCountRef.current >= expectedEventCount && + isLoadingHistory + ) { + setIsLoadingHistory(false); + } + }, [expectedEventCount, isLoadingHistory]); + const handleMessage = useCallback( (messageEvent: MessageEvent) => { try { const event = JSON.parse(messageEvent.data); + // Track received events for history loading (count ALL events from WebSocket) + // Always count when loading, even if we don't have the expected count yet + if (isLoadingHistory) { + receivedEventCountRef.current += 1; + + if ( + expectedEventCount !== null && + receivedEventCountRef.current >= expectedEventCount + ) { + setIsLoadingHistory(false); + } + } + // Use type guard to validate v1 event structure if (isV1Event(event)) { addEvent(event); @@ -141,6 +178,8 @@ export function ConversationWebSocketProvider({ }, [ addEvent, + isLoadingHistory, + expectedEventCount, setErrorMessage, removeOptimisticUserMessage, queryClient, @@ -164,10 +203,27 @@ export function ConversationWebSocketProvider({ return { queryParams, reconnect: { enabled: true }, - onOpen: () => { + onOpen: async () => { setConnectionState("OPEN"); hasConnectedRef.current = true; // Mark that we've successfully connected removeErrorMessage(); // Clear any previous error messages on successful connection + + // Fetch expected event count for history loading detection + if (conversationId) { + try { + const count = + await V1ConversationService.getEventCount(conversationId); + setExpectedEventCount(count); + + // If no events expected, mark as loaded immediately + if (count === 0) { + setIsLoadingHistory(false); + } + } catch (error) { + // Fall back to marking as loaded to avoid infinite loading state + setIsLoadingHistory(false); + } + } }, onClose: (event: CloseEvent) => { setConnectionState("CLOSED"); @@ -188,7 +244,13 @@ export function ConversationWebSocketProvider({ }, onMessage: handleMessage, }; - }, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]); + }, [ + handleMessage, + setErrorMessage, + removeErrorMessage, + sessionApiKey, + conversationId, + ]); // Only attempt WebSocket connection when we have a valid URL // This prevents connection attempts during task polling phase @@ -246,8 +308,8 @@ export function ConversationWebSocketProvider({ }, [socket, wsUrl]); const contextValue = useMemo( - () => ({ connectionState, sendMessage }), - [connectionState, sendMessage], + () => ({ connectionState, sendMessage, isLoadingHistory }), + [connectionState, sendMessage, isLoadingHistory], ); return ( From ca2c9546ad1d017313182e6027e34c5fe1098426 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Wed, 29 Oct 2025 13:11:06 -0400 Subject: [PATCH 08/13] CLI: add unit test for default agent (#11562) Co-authored-by: openhands --- .../test_default_agent_security_analyzer.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 openhands-cli/tests/settings/test_default_agent_security_analyzer.py diff --git a/openhands-cli/tests/settings/test_default_agent_security_analyzer.py b/openhands-cli/tests/settings/test_default_agent_security_analyzer.py new file mode 100644 index 0000000000..0c23afb866 --- /dev/null +++ b/openhands-cli/tests/settings/test_default_agent_security_analyzer.py @@ -0,0 +1,104 @@ +"""Test that first-time settings screen usage creates a default agent with security analyzer.""" + +from unittest.mock import patch +import pytest +from openhands_cli.tui.settings.settings_screen import SettingsScreen +from openhands_cli.user_actions.settings_action import SettingsType +from openhands.sdk import LLM +from pydantic import SecretStr + + +def test_first_time_settings_creates_default_agent_with_security_analyzer(): + """Test that using the settings screen for the first time creates a default agent with a non-None security analyzer.""" + + # Create a settings screen instance (no conversation initially) + screen = SettingsScreen(conversation=None) + + # Mock all the user interaction steps to simulate first-time setup + with ( + patch( + 'openhands_cli.tui.settings.settings_screen.settings_type_confirmation', + return_value=SettingsType.BASIC, + ), + patch( + 'openhands_cli.tui.settings.settings_screen.choose_llm_provider', + return_value='openai', + ), + patch( + 'openhands_cli.tui.settings.settings_screen.choose_llm_model', + return_value='gpt-4o-mini', + ), + patch( + 'openhands_cli.tui.settings.settings_screen.prompt_api_key', + return_value='sk-test-key-123', + ), + patch( + 'openhands_cli.tui.settings.settings_screen.save_settings_confirmation', + return_value=True, + ), + ): + # Run the settings configuration workflow + screen.configure_settings(first_time=True) + + # Load the saved agent from the store + saved_agent = screen.agent_store.load() + + # Verify that an agent was created and saved + assert saved_agent is not None, "Agent should be created and saved after first-time settings configuration" + + # Verify that the agent has the expected LLM configuration + assert saved_agent.llm.model == 'openai/gpt-4o-mini', f"Expected model 'openai/gpt-4o-mini', got '{saved_agent.llm.model}'" + assert saved_agent.llm.api_key.get_secret_value() == 'sk-test-key-123', "API key should match the provided value" + + # Verify that the agent has a security analyzer and it's not None + assert hasattr(saved_agent, 'security_analyzer'), "Agent should have a security_analyzer attribute" + assert saved_agent.security_analyzer is not None, "Security analyzer should not be None" + + # Verify the security analyzer has the expected type/kind + assert hasattr(saved_agent.security_analyzer, 'kind'), "Security analyzer should have a 'kind' attribute" + assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{saved_agent.security_analyzer.kind}'" + + +def test_first_time_settings_with_advanced_configuration(): + """Test that advanced settings also create a default agent with security analyzer.""" + + screen = SettingsScreen(conversation=None) + + with ( + patch( + 'openhands_cli.tui.settings.settings_screen.settings_type_confirmation', + return_value=SettingsType.ADVANCED, + ), + patch( + 'openhands_cli.tui.settings.settings_screen.prompt_custom_model', + return_value='anthropic/claude-3-5-sonnet', + ), + patch( + 'openhands_cli.tui.settings.settings_screen.prompt_base_url', + return_value='https://api.anthropic.com', + ), + patch( + 'openhands_cli.tui.settings.settings_screen.prompt_api_key', + return_value='sk-ant-test-key', + ), + patch( + 'openhands_cli.tui.settings.settings_screen.choose_memory_condensation', + return_value=True, + ), + patch( + 'openhands_cli.tui.settings.settings_screen.save_settings_confirmation', + return_value=True, + ), + ): + screen.configure_settings(first_time=True) + + saved_agent = screen.agent_store.load() + + # Verify agent creation and security analyzer + assert saved_agent is not None, "Agent should be created with advanced settings" + assert saved_agent.security_analyzer is not None, "Security analyzer should not be None in advanced settings" + assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer" + + # Verify advanced settings were applied + assert saved_agent.llm.model == 'anthropic/claude-3-5-sonnet', "Custom model should be set" + assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set" \ No newline at end of file From a196881ab0202439f2a2ff50f927b5403bba6d18 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:30:10 +0400 Subject: [PATCH 09/13] chore(frontend): Make terminal read-only by removing user input handlers (#11546) --- .../components/terminal/terminal.test.tsx | 3 +- .../__tests__/hooks/use-terminal.test.tsx | 3 +- frontend/src/hooks/use-terminal.ts | 127 ++---------------- frontend/src/i18n/translation.json | 28 ++-- 4 files changed, 26 insertions(+), 135 deletions(-) diff --git a/frontend/__tests__/components/terminal/terminal.test.tsx b/frontend/__tests__/components/terminal/terminal.test.tsx index 8224bd6251..15fb6357b2 100644 --- a/frontend/__tests__/components/terminal/terminal.test.tsx +++ b/frontend/__tests__/components/terminal/terminal.test.tsx @@ -11,6 +11,7 @@ const renderTerminal = (commands: Command[] = []) => { }; describe.skip("Terminal", () => { + // Terminal is now read-only - no user input functionality global.ResizeObserver = vi.fn().mockImplementation(() => ({ observe: vi.fn(), disconnect: vi.fn(), @@ -21,8 +22,6 @@ describe.skip("Terminal", () => { write: vi.fn(), writeln: vi.fn(), dispose: vi.fn(), - onKey: vi.fn(), - attachCustomKeyEventHandler: vi.fn(), loadAddon: vi.fn(), }; diff --git a/frontend/__tests__/hooks/use-terminal.test.tsx b/frontend/__tests__/hooks/use-terminal.test.tsx index 3988c43102..4f110df171 100644 --- a/frontend/__tests__/hooks/use-terminal.test.tsx +++ b/frontend/__tests__/hooks/use-terminal.test.tsx @@ -35,13 +35,12 @@ function TestTerminalComponent() { } describe("useTerminal", () => { + // Terminal is read-only - no longer tests user input functionality const mockTerminal = vi.hoisted(() => ({ loadAddon: vi.fn(), open: vi.fn(), write: vi.fn(), writeln: vi.fn(), - onKey: vi.fn(), - attachCustomKeyEventHandler: vi.fn(), dispose: vi.fn(), })); diff --git a/frontend/src/hooks/use-terminal.ts b/frontend/src/hooks/use-terminal.ts index ccc53e5a01..224feac1bf 100644 --- a/frontend/src/hooks/use-terminal.ts +++ b/frontend/src/hooks/use-terminal.ts @@ -2,11 +2,7 @@ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import React from "react"; import { Command, useCommandStore } from "#/state/command-store"; -import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; -import { getTerminalCommand } from "#/services/terminal-service"; import { parseTerminalOutput } from "#/utils/parse-terminal-output"; -import { useSendMessage } from "#/hooks/use-send-message"; -import { useAgentState } from "#/hooks/use-agent-state"; /* NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component. @@ -38,15 +34,11 @@ const renderCommand = ( const persistentLastCommandIndex = { current: 0 }; export const useTerminal = () => { - const { send } = useSendMessage(); - const { curAgentState } = useAgentState(); const commands = useCommandStore((state) => state.commands); const terminal = React.useRef(null); const fitAddon = React.useRef(null); const ref = React.useRef(null); const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference - const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null); - const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState); const createTerminal = () => new Terminal({ @@ -57,6 +49,7 @@ export const useTerminal = () => { fastScrollModifier: "alt", fastScrollSensitivity: 5, allowTransparency: true, + disableStdin: true, // Make terminal read-only theme: { background: "transparent", }, @@ -65,55 +58,12 @@ export const useTerminal = () => { const initializeTerminal = () => { if (terminal.current) { if (fitAddon.current) terminal.current.loadAddon(fitAddon.current); - if (ref.current) terminal.current.open(ref.current); - } - }; - - const copySelection = (selection: string) => { - const clipboardItem = new ClipboardItem({ - "text/plain": new Blob([selection], { type: "text/plain" }), - }); - - navigator.clipboard.write([clipboardItem]); - }; - - const pasteSelection = (callback: (text: string) => void) => { - navigator.clipboard.readText().then(callback); - }; - - const pasteHandler = (event: KeyboardEvent, cb: (text: string) => void) => { - const isControlOrMetaPressed = - event.type === "keydown" && (event.ctrlKey || event.metaKey); - - if (isControlOrMetaPressed) { - if (event.code === "KeyV") { - pasteSelection((text: string) => { - terminal.current?.write(text); - cb(text); - }); - } - - if (event.code === "KeyC") { - const selection = terminal.current?.getSelection(); - if (selection) copySelection(selection); + if (ref.current) { + terminal.current.open(ref.current); + // Hide cursor for read-only terminal using ANSI escape sequence + terminal.current.write("\x1b[?25l"); } } - - return true; - }; - - const handleEnter = (command: string) => { - terminal.current?.write("\r\n"); - // Don't write the command again as it will be added to the commands array - // and rendered by the useEffect that watches commands - send(getTerminalCommand(command)); - // Don't add the prompt here as it will be added when the command is processed - // and the commands array is updated - }; - - const handleBackspace = (command: string) => { - terminal.current?.write("\b \b"); - return command.slice(0, -1); }; // Initialize terminal and handle cleanup @@ -136,7 +86,7 @@ export const useTerminal = () => { } lastCommandIndex.current = commands.length; } - terminal.current.write("$ "); + // Don't show prompt in read-only terminal } return () => { @@ -150,19 +100,17 @@ export const useTerminal = () => { commands.length > 0 && lastCommandIndex.current < commands.length ) { - let lastCommandType = ""; for (let i = lastCommandIndex.current; i < commands.length; i += 1) { - lastCommandType = commands[i].type; + if (commands[i].type === "input") { + terminal.current.write("$ "); + } // Pass true for isUserInput to skip rendering user input commands // that have already been displayed as the user typed renderCommand(commands[i], terminal.current, false); } lastCommandIndex.current = commands.length; - if (lastCommandType === "output") { - terminal.current.write("$ "); - } } - }, [commands, disabled]); + }, [commands]); React.useEffect(() => { let resizeObserver: ResizeObserver | null = null; @@ -180,60 +128,5 @@ export const useTerminal = () => { }; }, []); - React.useEffect(() => { - if (terminal.current) { - // Dispose of existing listeners if they exist - if (keyEventDisposable.current) { - keyEventDisposable.current.dispose(); - keyEventDisposable.current = null; - } - - let commandBuffer = ""; - - if (!disabled) { - // Add new key event listener and store the disposable - keyEventDisposable.current = terminal.current.onKey( - ({ key, domEvent }) => { - if (domEvent.key === "Enter") { - handleEnter(commandBuffer); - commandBuffer = ""; - } else if (domEvent.key === "Backspace") { - if (commandBuffer.length > 0) { - commandBuffer = handleBackspace(commandBuffer); - } - } else { - // Ignore paste event - if (key.charCodeAt(0) === 22) { - return; - } - commandBuffer += key; - terminal.current?.write(key); - } - }, - ); - - // Add custom key handler and store the disposable - terminal.current.attachCustomKeyEventHandler((event) => - pasteHandler(event, (text) => { - commandBuffer += text; - }), - ); - } else { - // Add a noop handler when disabled - keyEventDisposable.current = terminal.current.onKey((e) => { - e.domEvent.preventDefault(); - e.domEvent.stopPropagation(); - }); - } - } - - return () => { - if (keyEventDisposable.current) { - keyEventDisposable.current.dispose(); - keyEventDisposable.current = null; - } - }; - }, [disabled]); - return ref; }; diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index ff51ba06f0..c8b36276f7 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -14320,20 +14320,20 @@ "uk": "Зупинити сервер" }, "COMMON$TERMINAL": { - "en": "Terminal", - "ja": "ターミナル", - "zh-CN": "终端", - "zh-TW": "終端機", - "ko-KR": "터미널", - "no": "Terminal", - "it": "Terminale", - "pt": "Terminal", - "es": "Terminal", - "ar": "الطرفية", - "fr": "Terminal", - "tr": "Terminal", - "de": "Terminal", - "uk": "Термінал" + "en": "Terminal (read-only)", + "ja": "ターミナル (読み取り専用)", + "zh-CN": "终端(只读)", + "zh-TW": "終端機(唯讀)", + "ko-KR": "터미널 (읽기 전용)", + "no": "Terminal (skrivebeskyttet)", + "it": "Terminale (sola lettura)", + "pt": "Terminal (somente leitura)", + "es": "Terminal (solo lectura)", + "ar": "الطرفية (للقراءة فقط)", + "fr": "Terminal (lecture seule)", + "tr": "Terminal (salt okunur)", + "de": "Terminal (schreibgeschützt)", + "uk": "Термінал (тільки читання)" }, "COMMON$UNKNOWN": { "en": "Unknown", From fab48fe864a1b0504dd71d3fdca50e7bafa92103 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 29 Oct 2025 21:57:48 +0400 Subject: [PATCH 10/13] chore(frontend): Remove Jupyter tab and features (#11563) --- .../components/jupyter/jupyter.test.tsx | 47 -------------- frontend/__tests__/services/actions.test.tsx | 18 +----- .../conversation-tab-content.tsx | 11 ---- .../conversation-tabs/conversation-tabs.tsx | 8 --- .../features/jupyter/jupyter-cell-input.tsx | 22 ------- .../features/jupyter/jupyter-cell-output.tsx | 55 ---------------- .../features/jupyter/jupyter-cell.tsx | 23 ------- .../components/features/jupyter/jupyter.tsx | 63 ------------------- frontend/src/icons/jupyter-large.svg | 3 - frontend/src/icons/jupyter.svg | 9 --- frontend/src/routes/conversation.tsx | 4 -- frontend/src/routes/jupyter-tab.tsx | 44 ------------- frontend/src/services/actions.ts | 5 -- frontend/src/services/observations.ts | 9 --- frontend/src/state/conversation-store.ts | 1 - frontend/src/state/jupyter-store.ts | 40 ------------ frontend/src/types/tab-option.tsx | 15 +---- frontend/src/utils/parse-cell-content.ts | 32 ---------- 18 files changed, 4 insertions(+), 405 deletions(-) delete mode 100644 frontend/__tests__/components/jupyter/jupyter.test.tsx delete mode 100644 frontend/src/components/features/jupyter/jupyter-cell-input.tsx delete mode 100644 frontend/src/components/features/jupyter/jupyter-cell-output.tsx delete mode 100644 frontend/src/components/features/jupyter/jupyter-cell.tsx delete mode 100644 frontend/src/components/features/jupyter/jupyter.tsx delete mode 100644 frontend/src/icons/jupyter-large.svg delete mode 100644 frontend/src/icons/jupyter.svg delete mode 100644 frontend/src/routes/jupyter-tab.tsx delete mode 100644 frontend/src/state/jupyter-store.ts delete mode 100644 frontend/src/utils/parse-cell-content.ts diff --git a/frontend/__tests__/components/jupyter/jupyter.test.tsx b/frontend/__tests__/components/jupyter/jupyter.test.tsx deleted file mode 100644 index bf6c746963..0000000000 --- a/frontend/__tests__/components/jupyter/jupyter.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { JupyterEditor } from "#/components/features/jupyter/jupyter"; -import { vi, describe, it, expect, beforeEach } from "vitest"; -import { AgentState } from "#/types/agent-state"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { useJupyterStore } from "#/state/jupyter-store"; - -// Mock the agent state hook -vi.mock("#/hooks/use-agent-state", () => ({ - useAgentState: vi.fn(), -})); - -// Mock react-i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), -})); - -describe("JupyterEditor", () => { - beforeEach(() => { - // Reset the Zustand store before each test - useJupyterStore.setState({ - cells: Array(20).fill({ - content: "Test cell content", - type: "input", - imageUrls: undefined, - }), - }); - }); - - it("should have a scrollable container", () => { - // Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES) - vi.mocked(useAgentState).mockReturnValue({ - curAgentState: AgentState.RUNNING, - }); - - render( -
- -
, - ); - - const container = screen.getByTestId("jupyter-container"); - expect(container).toHaveClass("flex-1 overflow-y-auto"); - }); -}); diff --git a/frontend/__tests__/services/actions.test.tsx b/frontend/__tests__/services/actions.test.tsx index 259f0544dd..05473dcb35 100644 --- a/frontend/__tests__/services/actions.test.tsx +++ b/frontend/__tests__/services/actions.test.tsx @@ -5,7 +5,6 @@ import { ActionMessage } from "#/types/message"; // Mock the store and actions const mockDispatch = vi.fn(); const mockAppendInput = vi.fn(); -const mockAppendJupyterInput = vi.fn(); vi.mock("#/store", () => ({ default: { @@ -21,14 +20,6 @@ vi.mock("#/state/command-store", () => ({ }, })); -vi.mock("#/state/jupyter-store", () => ({ - useJupyterStore: { - getState: () => ({ - appendJupyterInput: mockAppendJupyterInput, - }), - }, -})); - vi.mock("#/state/metrics-slice", () => ({ setMetrics: vi.fn(), })); @@ -63,10 +54,9 @@ describe("handleActionMessage", () => { // Check that appendInput was called with the command expect(mockAppendInput).toHaveBeenCalledWith("ls -la"); expect(mockDispatch).not.toHaveBeenCalled(); - expect(mockAppendJupyterInput).not.toHaveBeenCalled(); }); - it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => { + it("should handle RUN_IPYTHON actions as no-op (Jupyter removed)", async () => { const { handleActionMessage } = await import("#/services/actions"); const ipythonAction: ActionMessage = { @@ -84,10 +74,7 @@ describe("handleActionMessage", () => { // Handle the action handleActionMessage(ipythonAction); - // Check that appendJupyterInput was called with the code - expect(mockAppendJupyterInput).toHaveBeenCalledWith( - "print('Hello from Jupyter!')", - ); + // Jupyter functionality has been removed, so nothing should be called expect(mockAppendInput).not.toHaveBeenCalled(); }); @@ -112,6 +99,5 @@ describe("handleActionMessage", () => { // Check that nothing was dispatched or called expect(mockDispatch).not.toHaveBeenCalled(); expect(mockAppendInput).not.toHaveBeenCalled(); - expect(mockAppendJupyterInput).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx index 271e7a750a..f8c9e35887 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx @@ -12,7 +12,6 @@ import { useConversationStore } from "#/state/conversation-store"; // Lazy load all tab components const EditorTab = lazy(() => import("#/routes/changes-tab")); const BrowserTab = lazy(() => import("#/routes/browser-tab")); -const JupyterTab = lazy(() => import("#/routes/jupyter-tab")); const ServedTab = lazy(() => import("#/routes/served-tab")); const VSCodeTab = lazy(() => import("#/routes/vscode-tab")); @@ -24,7 +23,6 @@ export function ConversationTabContent() { // Determine which tab is active based on the current path const isEditorActive = selectedTab === "editor"; const isBrowserActive = selectedTab === "browser"; - const isJupyterActive = selectedTab === "jupyter"; const isServedActive = selectedTab === "served"; const isVSCodeActive = selectedTab === "vscode"; const isTerminalActive = selectedTab === "terminal"; @@ -37,11 +35,6 @@ export function ConversationTabContent() { component: BrowserTab, isActive: isBrowserActive, }, - { - key: "jupyter", - component: JupyterTab, - isActive: isJupyterActive, - }, { key: "served", component: ServedTab, isActive: isServedActive }, { key: "vscode", component: VSCodeTab, isActive: isVSCodeActive }, { @@ -58,9 +51,6 @@ export function ConversationTabContent() { if (isBrowserActive) { return t(I18nKey.COMMON$BROWSER); } - if (isJupyterActive) { - return t(I18nKey.COMMON$JUPYTER); - } if (isServedActive) { return t(I18nKey.COMMON$APP); } @@ -74,7 +64,6 @@ export function ConversationTabContent() { }, [ isEditorActive, isBrowserActive, - isJupyterActive, isServedActive, isVSCodeActive, isTerminalActive, diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx index 7a72305c3b..818ea658a2 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx @@ -1,7 +1,6 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useLocalStorage } from "@uidotdev/usehooks"; -import JupyterIcon from "#/icons/jupyter.svg?react"; import TerminalIcon from "#/icons/terminal.svg?react"; import GlobeIcon from "#/icons/globe.svg?react"; import ServerIcon from "#/icons/server.svg?react"; @@ -108,13 +107,6 @@ export function ConversationTabs() { tooltipContent: t(I18nKey.COMMON$TERMINAL), tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL), }, - { - isActive: isTabActive("jupyter"), - icon: JupyterIcon, - onClick: () => onTabSelected("jupyter"), - tooltipContent: t(I18nKey.COMMON$JUPYTER), - tooltipAriaLabel: t(I18nKey.COMMON$JUPYTER), - }, { isActive: isTabActive("served"), icon: ServerIcon, diff --git a/frontend/src/components/features/jupyter/jupyter-cell-input.tsx b/frontend/src/components/features/jupyter/jupyter-cell-input.tsx deleted file mode 100644 index c69651d105..0000000000 --- a/frontend/src/components/features/jupyter/jupyter-cell-input.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import SyntaxHighlighter from "react-syntax-highlighter"; -import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; - -interface JupytrerCellInputProps { - code: string; -} - -export function JupytrerCellInput({ code }: JupytrerCellInputProps) { - return ( -
-
EXECUTE
-
-        
-          {code}
-        
-      
-
- ); -} diff --git a/frontend/src/components/features/jupyter/jupyter-cell-output.tsx b/frontend/src/components/features/jupyter/jupyter-cell-output.tsx deleted file mode 100644 index be2c5e3a1f..0000000000 --- a/frontend/src/components/features/jupyter/jupyter-cell-output.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import Markdown from "react-markdown"; -import SyntaxHighlighter from "react-syntax-highlighter"; -import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import { JupyterLine } from "#/utils/parse-cell-content"; -import { paragraph } from "../markdown/paragraph"; - -interface JupyterCellOutputProps { - lines: JupyterLine[]; -} - -export function JupyterCellOutput({ lines }: JupyterCellOutputProps) { - const { t } = useTranslation(); - return ( -
-
- {t(I18nKey.JUPYTER$OUTPUT_LABEL)} -
-
-        {/* display the lines as plaintext or image */}
-        {lines.map((line, index) => {
-          if (line.type === "image") {
-            // Use markdown to display the image
-            const imageMarkdown = line.url
-              ? `![image](${line.url})`
-              : line.content;
-            return (
-              
- value} - > - {imageMarkdown} - -
- ); - } - return ( -
- - {line.content} - -
- ); - })} -
-
- ); -} diff --git a/frontend/src/components/features/jupyter/jupyter-cell.tsx b/frontend/src/components/features/jupyter/jupyter-cell.tsx deleted file mode 100644 index afd429f6c6..0000000000 --- a/frontend/src/components/features/jupyter/jupyter-cell.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { Cell } from "#/state/jupyter-store"; -import { JupyterLine, parseCellContent } from "#/utils/parse-cell-content"; -import { JupytrerCellInput } from "./jupyter-cell-input"; -import { JupyterCellOutput } from "./jupyter-cell-output"; - -interface JupyterCellProps { - cell: Cell; -} - -export function JupyterCell({ cell }: JupyterCellProps) { - const [lines, setLines] = React.useState([]); - - React.useEffect(() => { - setLines(parseCellContent(cell.content, cell.imageUrls)); - }, [cell.content, cell.imageUrls]); - - if (cell.type === "input") { - return ; - } - - return ; -} diff --git a/frontend/src/components/features/jupyter/jupyter.tsx b/frontend/src/components/features/jupyter/jupyter.tsx deleted file mode 100644 index 5ff84c7f2f..0000000000 --- a/frontend/src/components/features/jupyter/jupyter.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; -import { JupyterCell } from "./jupyter-cell"; -import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button"; -import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state"; -import { I18nKey } from "#/i18n/declaration"; -import JupyterLargeIcon from "#/icons/jupyter-large.svg?react"; -import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message"; -import { useAgentState } from "#/hooks/use-agent-state"; -import { useJupyterStore } from "#/state/jupyter-store"; - -interface JupyterEditorProps { - maxWidth: number; -} - -export function JupyterEditor({ maxWidth }: JupyterEditorProps) { - const { curAgentState } = useAgentState(); - - const cells = useJupyterStore((state) => state.cells); - - const jupyterRef = React.useRef(null); - - const { t } = useTranslation(); - - const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState); - - const { hitBottom, scrollDomToBottom, onChatBodyScroll } = - useScrollToBottom(jupyterRef); - - return ( - <> - {isRuntimeInactive && } - {!isRuntimeInactive && cells.length > 0 && ( -
-
onChatBodyScroll(e.currentTarget)} - > - {cells.map((cell, index) => ( - - ))} -
- {!hitBottom && ( -
- -
- )} -
- )} - {!isRuntimeInactive && cells.length === 0 && ( -
- - - {t(I18nKey.COMMON$JUPYTER_EMPTY_MESSAGE)} - -
- )} - - ); -} diff --git a/frontend/src/icons/jupyter-large.svg b/frontend/src/icons/jupyter-large.svg deleted file mode 100644 index 7643ce165e..0000000000 --- a/frontend/src/icons/jupyter-large.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/frontend/src/icons/jupyter.svg b/frontend/src/icons/jupyter.svg deleted file mode 100644 index 0dc18c0fd3..0000000000 --- a/frontend/src/icons/jupyter.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/frontend/src/routes/conversation.tsx b/frontend/src/routes/conversation.tsx index c1d7330f41..16edacde66 100644 --- a/frontend/src/routes/conversation.tsx +++ b/frontend/src/routes/conversation.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import { useConversationId } from "#/hooks/use-conversation-id"; import { useCommandStore } from "#/state/command-store"; -import { useJupyterStore } from "#/state/jupyter-store"; import { useConversationStore } from "#/state/conversation-store"; import { useAgentStore } from "#/stores/agent-store"; import { AgentState } from "#/types/agent-state"; @@ -53,7 +52,6 @@ function AppContent() { const setCurrentAgentState = useAgentStore( (state) => state.setCurrentAgentState, ); - const clearJupyter = useJupyterStore((state) => state.clearJupyter); const removeErrorMessage = useErrorMessageStore( (state) => state.removeErrorMessage, ); @@ -70,7 +68,6 @@ function AppContent() { // 1. Cleanup Effect - runs when navigating to a different conversation React.useEffect(() => { clearTerminal(); - clearJupyter(); resetConversationState(); setCurrentAgentState(AgentState.LOADING); removeErrorMessage(); @@ -84,7 +81,6 @@ function AppContent() { }, [ conversationId, clearTerminal, - clearJupyter, resetConversationState, setCurrentAgentState, removeErrorMessage, diff --git a/frontend/src/routes/jupyter-tab.tsx b/frontend/src/routes/jupyter-tab.tsx deleted file mode 100644 index 05a2caaeaf..0000000000 --- a/frontend/src/routes/jupyter-tab.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from "react"; -import { JupyterEditor } from "#/components/features/jupyter/jupyter"; - -function Jupyter() { - const parentRef = React.useRef(null); - const [parentWidth, setParentWidth] = React.useState(0); - - // This is a hack to prevent the editor from overflowing - // Should be removed after revising the parent and containers - // Use ResizeObserver to properly track parent width changes - React.useEffect(() => { - let resizeObserver: ResizeObserver | null = null; - - resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - // Use contentRect.width for more accurate measurements - const { width } = entry.contentRect; - if (width > 0) { - setParentWidth(width); - } - } - }); - - if (parentRef.current) { - resizeObserver.observe(parentRef.current); - } - - return () => { - resizeObserver?.disconnect(); - }; - }, []); - - // Provide a fallback width to prevent the editor from being hidden - // Use parentWidth if available, otherwise use a large default - const maxWidth = parentWidth > 0 ? parentWidth : 9999; - - return ( -
- -
- ); -} - -export default Jupyter; diff --git a/frontend/src/services/actions.ts b/frontend/src/services/actions.ts index 2c40959778..986a292779 100644 --- a/frontend/src/services/actions.ts +++ b/frontend/src/services/actions.ts @@ -8,7 +8,6 @@ import { StatusMessage, } from "#/types/message"; import { handleObservationMessage } from "./observations"; -import { useJupyterStore } from "#/state/jupyter-store"; import { useCommandStore } from "#/state/command-store"; import { queryClient } from "#/query-client-config"; import { @@ -35,10 +34,6 @@ export function handleActionMessage(message: ActionMessage) { useCommandStore.getState().appendInput(message.args.command); } - if (message.action === ActionType.RUN_IPYTHON) { - useJupyterStore.getState().appendJupyterInput(message.args.code); - } - if ("args" in message && "security_risk" in message.args) { useSecurityAnalyzerStore.getState().appendSecurityAnalyzerInput({ id: message.id, diff --git a/frontend/src/services/observations.ts b/frontend/src/services/observations.ts index 0994eebcd2..40cc1daa8a 100644 --- a/frontend/src/services/observations.ts +++ b/frontend/src/services/observations.ts @@ -1,5 +1,4 @@ import { ObservationMessage } from "#/types/message"; -import { useJupyterStore } from "#/state/jupyter-store"; import { useCommandStore } from "#/state/command-store"; import ObservationType from "#/types/observation-type"; import { useBrowserStore } from "#/stores/browser-store"; @@ -22,14 +21,6 @@ export function handleObservationMessage(message: ObservationMessage) { useCommandStore.getState().appendOutput(content); break; } - case ObservationType.RUN_IPYTHON: - useJupyterStore.getState().appendJupyterOutput({ - content: message.content, - imageUrls: Array.isArray(message.extras?.image_urls) - ? message.extras.image_urls - : undefined, - }); - break; case ObservationType.BROWSE: case ObservationType.BROWSE_INTERACTIVE: if ( diff --git a/frontend/src/state/conversation-store.ts b/frontend/src/state/conversation-store.ts index 645968adfb..dc6424044f 100644 --- a/frontend/src/state/conversation-store.ts +++ b/frontend/src/state/conversation-store.ts @@ -4,7 +4,6 @@ import { devtools } from "zustand/middleware"; export type ConversationTab = | "editor" | "browser" - | "jupyter" | "served" | "vscode" | "terminal"; diff --git a/frontend/src/state/jupyter-store.ts b/frontend/src/state/jupyter-store.ts deleted file mode 100644 index 15d8be0ad3..0000000000 --- a/frontend/src/state/jupyter-store.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { create } from "zustand"; - -export type Cell = { - content: string; - type: "input" | "output"; - imageUrls?: string[]; -}; - -interface JupyterState { - cells: Cell[]; - appendJupyterInput: (content: string) => void; - appendJupyterOutput: (payload: { - content: string; - imageUrls?: string[]; - }) => void; - clearJupyter: () => void; -} - -export const useJupyterStore = create((set) => ({ - cells: [], - appendJupyterInput: (content: string) => - set((state) => ({ - cells: [...state.cells, { content, type: "input" }], - })), - appendJupyterOutput: (payload: { content: string; imageUrls?: string[] }) => - set((state) => ({ - cells: [ - ...state.cells, - { - content: payload.content, - type: "output", - imageUrls: payload.imageUrls, - }, - ], - })), - clearJupyter: () => - set(() => ({ - cells: [], - })), -})); diff --git a/frontend/src/types/tab-option.tsx b/frontend/src/types/tab-option.tsx index bd6d3cc9ef..0c90786448 100644 --- a/frontend/src/types/tab-option.tsx +++ b/frontend/src/types/tab-option.tsx @@ -1,21 +1,10 @@ enum TabOption { PLANNER = "planner", BROWSER = "browser", - JUPYTER = "jupyter", VSCODE = "vscode", } -type TabType = - | TabOption.PLANNER - | TabOption.BROWSER - | TabOption.JUPYTER - | TabOption.VSCODE; - -const AllTabs = [ - TabOption.VSCODE, - TabOption.BROWSER, - TabOption.PLANNER, - TabOption.JUPYTER, -]; +type TabType = TabOption.PLANNER | TabOption.BROWSER | TabOption.VSCODE; +const AllTabs = [TabOption.VSCODE, TabOption.BROWSER, TabOption.PLANNER]; export { AllTabs, TabOption, type TabType }; diff --git a/frontend/src/utils/parse-cell-content.ts b/frontend/src/utils/parse-cell-content.ts deleted file mode 100644 index faa566a05c..0000000000 --- a/frontend/src/utils/parse-cell-content.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type JupyterLine = { - type: "plaintext" | "image"; - content: string; - url?: string; -}; - -export const parseCellContent = (content: string, imageUrls?: string[]) => { - const lines: JupyterLine[] = []; - let currentText = ""; - - // First, process the text content - for (const line of content.split("\n")) { - currentText += `${line}\n`; - } - - if (currentText) { - lines.push({ type: "plaintext", content: currentText }); - } - - // Then, add image lines if we have image URLs - if (imageUrls && imageUrls.length > 0) { - imageUrls.forEach((url) => { - lines.push({ - type: "image", - content: `![image](${url})`, - url, - }); - }); - } - - return lines; -}; From 38f2728cfa6114e72002ebe8075cb6ea64e4edf5 Mon Sep 17 00:00:00 2001 From: mamoodi Date: Wed, 29 Oct 2025 16:17:46 -0400 Subject: [PATCH 11/13] Release 0.60.0 (#11544) Co-authored-by: rohitvinodmalhotra@gmail.com --- .github/scripts/update_pr_description.sh | 4 +- Development.md | 2 +- README.md | 6 +- containers/dev/compose.yml | 2 +- docker-compose.yml | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- openhands/runtime/impl/kubernetes/README.md | 2 +- poetry.lock | 75 +++++++++------------ pyproject.toml | 11 +-- third_party/runtime/impl/daytona/README.md | 8 +-- 11 files changed, 55 insertions(+), 63 deletions(-) diff --git a/.github/scripts/update_pr_description.sh b/.github/scripts/update_pr_description.sh index f1a092d6cf..4457b74955 100755 --- a/.github/scripts/update_pr_description.sh +++ b/.github/scripts/update_pr_description.sh @@ -13,9 +13,9 @@ DOCKER_RUN_COMMAND="docker run -it --rm \ -p 3000:3000 \ -v /var/run/docker.sock:/var/run/docker.sock \ --add-host host.docker.internal:host-gateway \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \ --name openhands-app-${SHORT_SHA} \ - docker.all-hands.dev/openhands/openhands:${SHORT_SHA}" + docker.openhands.dev/openhands/openhands:${SHORT_SHA}" # Define the uvx command UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/OpenHands/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands" diff --git a/Development.md b/Development.md index 31451091bb..62ac14ae45 100644 --- a/Development.md +++ b/Development.md @@ -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.59-nikolaik` +Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.60-nikolaik` ## Develop inside Docker container diff --git a/README.md b/README.md index 96fe34aee9..edb48cda0b 100644 --- a/README.md +++ b/README.md @@ -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.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik +docker pull docker.openhands.dev/openhands/runtime:0.60-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.59-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.all-hands.dev/all-hands-ai/openhands:0.59 + docker.openhands.dev/openhands/openhands:0.60 ``` diff --git a/containers/dev/compose.yml b/containers/dev/compose.yml index 0adcbd7a6a..8b472216f4 100644 --- a/containers/dev/compose.yml +++ b/containers/dev/compose.yml @@ -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.59-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: diff --git a/docker-compose.yml b/docker-compose.yml index f88a2d1c7f..d6fae391c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: image: openhands:latest container_name: openhands-app-${DATE:-} environment: - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/openhands/runtime:0.59-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: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cc54334209..ca51978802 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "0.59.0", + "version": "0.60.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "0.59.0", + "version": "0.60.0", "dependencies": { "@heroui/react": "^2.8.4", "@heroui/use-infinite-scroll": "^2.2.11", diff --git a/frontend/package.json b/frontend/package.json index d2fa43d469..7ef94e1a05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "0.59.0", + "version": "0.60.0", "private": true, "type": "module", "engines": { diff --git a/openhands/runtime/impl/kubernetes/README.md b/openhands/runtime/impl/kubernetes/README.md index 67788fcaaf..6730231879 100644 --- a/openhands/runtime/impl/kubernetes/README.md +++ b/openhands/runtime/impl/kubernetes/README.md @@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime: 2. **Runtime Container Image**: Specify the container image to use for the runtime environment ```toml [sandbox] - runtime_container_image = "docker.all-hands.dev/openhands/runtime:0.59-nikolaik" + runtime_container_image = "docker.openhands.dev/openhands/runtime:0.60-nikolaik" ``` #### Additional Kubernetes Options diff --git a/poetry.lock b/poetry.lock index 4f3bd5ad3a..254e75a16d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -254,19 +254,20 @@ files = [ [[package]] name = "anthropic" -version = "0.59.0" +version = "0.72.0" description = "The official Python library for the anthropic API" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "anthropic-0.59.0-py3-none-any.whl", hash = "sha256:cbc8b3dccef66ad6435c4fa1d317e5ebb092399a4b88b33a09dc4bf3944c3183"}, - {file = "anthropic-0.59.0.tar.gz", hash = "sha256:d710d1ef0547ebbb64b03f219e44ba078e83fc83752b96a9b22e9726b523fd8f"}, + {file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"}, + {file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"}, ] [package.dependencies] anyio = ">=3.5.0,<5" distro = ">=1.7.0,<2" +docstring-parser = ">=0.15,<1" google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""} httpx = ">=0.25.0,<1" jiter = ">=0.4.0,<1" @@ -275,7 +276,7 @@ sniffio = "*" typing-extensions = ">=4.10,<5" [package.extras] -aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"] bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] vertex = ["google-auth[requests] (>=2,<3)"] @@ -1204,19 +1205,19 @@ botocore = ["botocore"] [[package]] name = "browser-use" -version = "0.7.10" +version = "0.8.0" description = "Make websites accessible for AI agents" optional = false python-versions = "<4.0,>=3.11" groups = ["main"] files = [ - {file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"}, - {file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"}, + {file = "browser_use-0.8.0-py3-none-any.whl", hash = "sha256:b7c299e38ec1c1aec42a236cc6ad2268a366226940d6ff9d88ed461afd5a1cc3"}, + {file = "browser_use-0.8.0.tar.gz", hash = "sha256:2136eb3251424f712a08ee379c9337237c2f93b29b566807db599cf94e6abb5e"}, ] [package.dependencies] aiohttp = "3.12.15" -anthropic = ">=0.58.2,<1.0.0" +anthropic = ">=0.68.1,<1.0.0" anyio = ">=4.9.0" authlib = ">=1.6.0" bubus = ">=1.5.6" @@ -1248,11 +1249,11 @@ typing-extensions = ">=4.12.2" uuid7 = ">=0.1.0" [package.extras] -all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"] +all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"] aws = ["boto3 (>=1.38.45)"] cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"] -eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"] -examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"] +eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"] +examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"] video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"] [[package]] @@ -5711,8 +5712,11 @@ files = [ {file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"}, {file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"}, + {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"}, + {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"}, {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"}, + {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"}, {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"}, {file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"}, {file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"}, @@ -7272,13 +7276,15 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0 [[package]] name = "openhands-agent-server" -version = "1.0.0a4" +version = "1.0.0a5" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] -files = [] -develop = false +files = [ + {file = "openhands_agent_server-1.0.0a5-py3-none-any.whl", hash = "sha256:823fecd33fd45ba64acc6960beda24df2af6520c26c8c110564d0e3679c53186"}, + {file = "openhands_agent_server-1.0.0a5.tar.gz", hash = "sha256:65458923905f215666e59654e47f124e4c597fe982ede7d54184c8795d810a35"}, +] [package.dependencies] aiosqlite = ">=0.19" @@ -7291,22 +7297,17 @@ uvicorn = ">=0.31.1" websockets = ">=12" wsproto = ">=1.2.0" -[package.source] -type = "git" -url = "https://github.com/OpenHands/agent-sdk.git" -reference = "ce0a71af55dfce101f7419fbdb0116178f01e109" -resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109" -subdirectory = "openhands-agent-server" - [[package]] name = "openhands-sdk" -version = "1.0.0a4" +version = "1.0.0a5" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] -files = [] -develop = false +files = [ + {file = "openhands_sdk-1.0.0a5-py3-none-any.whl", hash = "sha256:db20272b04cf03627f9f7d1e87992078ac4ce15d188955a2962aa9e754d0af03"}, + {file = "openhands_sdk-1.0.0a5.tar.gz", hash = "sha256:8888d6892d58cf9b11a71fa80086156c0b6c9a0b50df6839c0a9cafffba2338c"}, +] [package.dependencies] fastmcp = ">=2.11.3" @@ -7321,40 +7322,28 @@ websockets = ">=12" [package.extras] boto3 = ["boto3 (>=1.35.0)"] -[package.source] -type = "git" -url = "https://github.com/OpenHands/agent-sdk.git" -reference = "ce0a71af55dfce101f7419fbdb0116178f01e109" -resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109" -subdirectory = "openhands-sdk" - [[package]] name = "openhands-tools" -version = "1.0.0a4" +version = "1.0.0a5" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] -files = [] -develop = false +files = [ + {file = "openhands_tools-1.0.0a5-py3-none-any.whl", hash = "sha256:74c27e23e6adc9a0bad00e32448bd4872019ce0786474e8de2fbf2d7c0887e8e"}, + {file = "openhands_tools-1.0.0a5.tar.gz", hash = "sha256:6c67454e612596e95c5151267659ddd3b633a5d4a1b70b348f7f913c62146562"}, +] [package.dependencies] bashlex = ">=0.18" binaryornot = ">=0.4.4" -browser-use = ">=0.7.7" +browser-use = ">=0.8.0" cachetools = "*" func-timeout = ">=4.3.5" libtmux = ">=0.46.2" openhands-sdk = "*" pydantic = ">=2.11.7" -[package.source] -type = "git" -url = "https://github.com/OpenHands/agent-sdk.git" -reference = "ce0a71af55dfce101f7419fbdb0116178f01e109" -resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109" -subdirectory = "openhands-tools" - [[package]] name = "openpyxl" version = "3.1.5" @@ -16521,4 +16510,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api [metadata] lock-version = "2.1" python-versions = "^3.12,<3.14" -content-hash = "aed9fa5020f1fdda19cf8191ac75021f2617e10e49757bcec23586b2392fd596" +content-hash = "f2234ef5fb5e97bc187d433eae9fcab8903a830d6557fb3926b0c3f37730dd17" diff --git a/pyproject.toml b/pyproject.toml index 48bfb51731..0fdb907b18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ requires = [ [tool.poetry] name = "openhands-ai" -version = "0.59.0" +version = "0.60.0" description = "OpenHands: Code Less, Make More" authors = [ "OpenHands" ] license = "MIT" @@ -113,9 +113,12 @@ 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 = "ce0a71af55dfce101f7419fbdb0116178f01e109" } -openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" } -openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" } +#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" } +#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" } +#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109" } +openhands-sdk = "1.0.0a5" +openhands-agent-server = "1.0.0a5" +openhands-tools = "1.0.0a5" python-jose = { version = ">=3.3", extras = [ "cryptography" ] } sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" } pg8000 = "^1.31.5" diff --git a/third_party/runtime/impl/daytona/README.md b/third_party/runtime/impl/daytona/README.md index 53dc30a8c6..2222954499 100644 --- a/third_party/runtime/impl/daytona/README.md +++ b/third_party/runtime/impl/daytona/README.md @@ -85,14 +85,14 @@ This command pulls and runs the OpenHands container using Docker. Once executed, #### Mac/Linux: ```bash docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${OPENHANDS_VERSION}-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${OPENHANDS_VERSION}-nikolaik \ -e LOG_ALL_EVENTS=true \ -e RUNTIME=daytona \ -e DAYTONA_API_KEY=${DAYTONA_API_KEY} \ -v ~/.openhands:/.openhands \ -p 3000:3000 \ --name openhands-app \ - docker.all-hands.dev/openhands/openhands:${OPENHANDS_VERSION} + docker.openhands.dev/openhands/openhands:${OPENHANDS_VERSION} ``` > **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location. @@ -100,14 +100,14 @@ docker run -it --rm --pull=always \ #### Windows: ```powershell docker run -it --rm --pull=always ` - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${env:OPENHANDS_VERSION}-nikolaik ` + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${env:OPENHANDS_VERSION}-nikolaik ` -e LOG_ALL_EVENTS=true ` -e RUNTIME=daytona ` -e DAYTONA_API_KEY=${env:DAYTONA_API_KEY} ` -v ~/.openhands:/.openhands ` -p 3000:3000 ` --name openhands-app ` - docker.all-hands.dev/openhands/openhands:${env:OPENHANDS_VERSION} + docker.openhands.dev/openhands/openhands:${env:OPENHANDS_VERSION} ``` > **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location. From 12d6da8130501b06aa7777c14932fbedd9364b09 Mon Sep 17 00:00:00 2001 From: Kevin Musgrave Date: Wed, 29 Oct 2025 22:30:19 -0400 Subject: [PATCH 12/13] feat(evaluation): Filter task ids by difficulty for SWE Gym rollouts (#11490) Co-authored-by: Graham Neubig Co-authored-by: openhands --- .../multi_swe_bench/compute_skip_ids.py | 79 +++++++++++++++++++ .../benchmarks/multi_swe_bench/run_infer.py | 39 +++++++-- .../scripts/rollout_multi_swegym.sh | 51 ++++++++++-- evaluation/utils/shared.py | 9 ++- 4 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py diff --git a/evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py b/evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py new file mode 100644 index 0000000000..1b2b6d36bb --- /dev/null +++ b/evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py @@ -0,0 +1,79 @@ +import argparse +import fnmatch +import json +from collections import Counter +from pathlib import Path + + +def find_final_reports(base_dir, pattern=None): + base_path = Path(base_dir) + if not base_path.exists(): + raise FileNotFoundError(f'Base directory does not exist: {base_dir}') + + # Find all final_report.json files + all_reports = list(base_path.rglob('final_report.json')) + + if pattern is None: + return all_reports + + # Filter by pattern + filtered_reports = [] + for report in all_reports: + # Get relative path from base_dir for matching + rel_path = report.relative_to(base_path) + if fnmatch.fnmatch(str(rel_path), pattern): + filtered_reports.append(report) + + return filtered_reports + + +def collect_resolved_ids(report_files): + id_counter = Counter() + + for report_file in report_files: + with open(report_file, 'r') as f: + data = json.load(f) + if 'resolved_ids' not in data: + raise KeyError(f"'resolved_ids' key not found in {report_file}") + resolved_ids = data['resolved_ids'] + id_counter.update(resolved_ids) + + return id_counter + + +def get_skip_ids(id_counter, threshold): + return [id_str for id_str, count in id_counter.items() if count >= threshold] + + +def main(): + parser = argparse.ArgumentParser( + description='Compute SKIP_IDS from resolved IDs in final_report.json files' + ) + parser.add_argument( + 'threshold', + type=int, + help='Minimum number of times an ID must be resolved to be skipped', + ) + parser.add_argument( + '--base-dir', + default='evaluation/evaluation_outputs/outputs', + help='Base directory to search for final_report.json files (default: evaluation/evaluation_outputs/outputs)', + ) + parser.add_argument( + '--pattern', + default=None, + help='Glob pattern to filter paths (e.g., "*Multi-SWE-RL*/**/*gpt*")', + ) + + args = parser.parse_args() + report_files = find_final_reports(args.base_dir, args.pattern) + id_counter = collect_resolved_ids(report_files) + + skip_ids = get_skip_ids(id_counter, args.threshold) + skip_ids = [s.replace('/', '__').replace(':pr-', '-') for s in skip_ids] + skip_ids = ','.join(sorted(skip_ids)) + print(skip_ids) + + +if __name__ == '__main__': + main() diff --git a/evaluation/benchmarks/multi_swe_bench/run_infer.py b/evaluation/benchmarks/multi_swe_bench/run_infer.py index d42879d7f8..333c235e90 100644 --- a/evaluation/benchmarks/multi_swe_bench/run_infer.py +++ b/evaluation/benchmarks/multi_swe_bench/run_infer.py @@ -747,10 +747,14 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame: subset = dataset[dataset[filter_column].isin(selected_ids)] logger.info(f'Retained {subset.shape[0]} tasks after filtering') return subset - skip_ids = os.environ.get('SKIP_IDS', '').split(',') + skip_ids = [id for id in os.environ.get('SKIP_IDS', '').split(',') if id] if len(skip_ids) > 0: + logger.info(f'Dataset size before filtering: {dataset.shape[0]} tasks') logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...') - return dataset[~dataset[filter_column].isin(skip_ids)] + logger.info(f'SKIP_IDS:\n{skip_ids}') + filtered_dataset = dataset[~dataset[filter_column].isin(skip_ids)] + logger.info(f'Dataset size after filtering: {filtered_dataset.shape[0]} tasks') + return filtered_dataset return dataset @@ -768,6 +772,11 @@ if __name__ == '__main__': default='test', help='split to evaluate on', ) + parser.add_argument( + '--filter_dataset_after_sampling', + action='store_true', + help='if provided, filter dataset after sampling instead of before', + ) args, _ = parser.parse_known_args() # NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing @@ -777,10 +786,24 @@ if __name__ == '__main__': logger.info(f'Loading dataset {args.dataset} with split {args.split} ') dataset = load_dataset('json', data_files=args.dataset) dataset = dataset[args.split] - swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id') - logger.info( - f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks' - ) + swe_bench_tests = dataset.to_pandas() + + # Determine filter strategy based on flag + filter_func = None + if args.filter_dataset_after_sampling: + # Pass filter as callback to apply after sampling + def filter_func(df): + return filter_dataset(df, 'instance_id') + + logger.info( + f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks (filtering will occur after sampling)' + ) + else: + # Apply filter before sampling + swe_bench_tests = filter_dataset(swe_bench_tests, 'instance_id') + logger.info( + f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks' + ) llm_config = None if args.llm_config: @@ -810,7 +833,9 @@ if __name__ == '__main__': output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl') print(f'### OUTPUT FILE: {output_file} ###') - instances = prepare_dataset(swe_bench_tests, output_file, args.eval_n_limit) + instances = prepare_dataset( + swe_bench_tests, output_file, args.eval_n_limit, filter_func=filter_func + ) if len(instances) > 0 and not isinstance( instances['FAIL_TO_PASS'][instances['FAIL_TO_PASS'].index[0]], str diff --git a/evaluation/benchmarks/multi_swe_bench/scripts/rollout_multi_swegym.sh b/evaluation/benchmarks/multi_swe_bench/scripts/rollout_multi_swegym.sh index ed132432e3..cad572300b 100755 --- a/evaluation/benchmarks/multi_swe_bench/scripts/rollout_multi_swegym.sh +++ b/evaluation/benchmarks/multi_swe_bench/scripts/rollout_multi_swegym.sh @@ -8,8 +8,14 @@ MODEL=$1 # eg your llm config name in config.toml (eg: "llm.claude-3-5-sonnet-20241022-t05") EXP_NAME=$2 # "train-t05" EVAL_DATASET=$3 # path to original dataset (jsonl file) -N_WORKERS=${4:-64} -N_RUNS=${5:-1} +MAX_ITER=$4 +N_WORKERS=${5:-64} +N_RUNS=${6:-1} +EVAL_LIMIT=${7:-} +SKIP_IDS_THRESHOLD=$8 +SKIP_IDS_PATTERN=$9 +INPUT_SKIP_IDS=${10} +FILTER_DATASET_AFTER_SAMPLING=${11:-} export EXP_NAME=$EXP_NAME # use 2x resources for rollout since some codebases are pretty resource-intensive @@ -17,6 +23,7 @@ export DEFAULT_RUNTIME_RESOURCE_FACTOR=2 echo "MODEL: $MODEL" echo "EXP_NAME: $EXP_NAME" echo "EVAL_DATASET: $EVAL_DATASET" +echo "INPUT_SKIP_IDS: $INPUT_SKIP_IDS" # Generate DATASET path by adding _with_runtime_ before .jsonl extension DATASET="${EVAL_DATASET%.jsonl}_with_runtime_.jsonl" # path to converted dataset @@ -35,9 +42,6 @@ else export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" fi -#EVAL_LIMIT=3000 -MAX_ITER=100 - # ===== Run inference ===== source "evaluation/utils/version_control.sh" @@ -69,17 +73,52 @@ function run_eval() { --dataset $DATASET \ --split $SPLIT" + # Conditionally add filter flag + if [ "$FILTER_DATASET_AFTER_SAMPLING" = "true" ]; then + COMMAND="$COMMAND --filter_dataset_after_sampling" + fi + echo "Running command: $COMMAND" if [ -n "$EVAL_LIMIT" ]; then echo "EVAL_LIMIT: $EVAL_LIMIT" COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT" fi - # Run the command eval $COMMAND } for run_idx in $(seq 1 $N_RUNS); do + if [ -n "$SKIP_IDS_THRESHOLD" ]; then + echo "Computing SKIP_IDS for run $run_idx..." + SKIP_CMD="poetry run python evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py $SKIP_IDS_THRESHOLD" + if [ -n "$SKIP_IDS_PATTERN" ]; then + SKIP_CMD="$SKIP_CMD --pattern \"$SKIP_IDS_PATTERN\"" + fi + COMPUTED_SKIP_IDS=$(eval $SKIP_CMD) + SKIP_STATUS=$? + if [ $SKIP_STATUS -ne 0 ]; then + echo "ERROR: Skip IDs computation failed with exit code $SKIP_STATUS" + exit $SKIP_STATUS + fi + echo "COMPUTED_SKIP_IDS: $COMPUTED_SKIP_IDS" + else + echo "SKIP_IDS_THRESHOLD not provided, skipping SKIP_IDS computation" + COMPUTED_SKIP_IDS="" + fi + + # Concatenate COMPUTED_SKIP_IDS and INPUT_SKIP_IDS + if [ -n "$COMPUTED_SKIP_IDS" ] && [ -n "$INPUT_SKIP_IDS" ]; then + export SKIP_IDS="${COMPUTED_SKIP_IDS},${INPUT_SKIP_IDS}" + elif [ -n "$COMPUTED_SKIP_IDS" ]; then + export SKIP_IDS="$COMPUTED_SKIP_IDS" + elif [ -n "$INPUT_SKIP_IDS" ]; then + export SKIP_IDS="$INPUT_SKIP_IDS" + else + unset SKIP_IDS + fi + + echo "FINAL SKIP_IDS: $SKIP_IDS" + echo "" while true; do echo "### Running inference... ###" diff --git a/evaluation/utils/shared.py b/evaluation/utils/shared.py index a3d9c125af..287b0aa95e 100644 --- a/evaluation/utils/shared.py +++ b/evaluation/utils/shared.py @@ -9,7 +9,7 @@ import time import traceback from contextlib import contextmanager from inspect import signature -from typing import Any, Awaitable, Callable, TextIO +from typing import Any, Awaitable, Callable, Optional, TextIO import pandas as pd from pydantic import BaseModel @@ -222,6 +222,7 @@ def prepare_dataset( eval_n_limit: int, eval_ids: list[str] | None = None, skip_num: int | None = None, + filter_func: Optional[Callable[[pd.DataFrame], pd.DataFrame]] = None, ): assert 'instance_id' in dataset.columns, ( "Expected 'instance_id' column in the dataset. You should define your own unique identifier for each instance and use it as the 'instance_id' column." @@ -265,6 +266,12 @@ def prepare_dataset( f'Randomly sampling {eval_n_limit} unique instances with random seed 42.' ) + if filter_func is not None: + dataset = filter_func(dataset) + logger.info( + f'Applied filter after sampling: {len(dataset)} instances remaining' + ) + def make_serializable(instance_dict: dict) -> dict: import numpy as np From 6558b4f97d7e9e8eae697fca95b76b945d5bffeb Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Wed, 29 Oct 2025 23:38:36 -0400 Subject: [PATCH 13/13] CLI: bump agent-sdk version (#11566) Co-authored-by: openhands --- openhands-cli/build.py | 7 +++---- openhands-cli/openhands_cli/runner.py | 1 + .../tui/settings/settings_screen.py | 6 ++---- .../openhands_cli/tui/settings/store.py | 2 +- .../openhands_cli/{llm_utils.py => utils.py} | 21 ++++++++++++++++++- openhands-cli/pyproject.toml | 4 ++-- .../tests/settings/test_settings_workflow.py | 4 ++-- .../tests/test_conversation_runner.py | 14 ++++++------- openhands-cli/uv.lock | 18 ++++++++-------- 9 files changed, 47 insertions(+), 30 deletions(-) rename openhands-cli/openhands_cli/{llm_utils.py => utils.py} (79%) diff --git a/openhands-cli/build.py b/openhands-cli/build.py index f4b3cd83b4..3b85de946a 100755 --- a/openhands-cli/build.py +++ b/openhands-cli/build.py @@ -15,13 +15,12 @@ import sys import time from pathlib import Path -from openhands_cli.llm_utils import get_llm_metadata -from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR +from openhands_cli.utils import get_llm_metadata, get_default_cli_agent +from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR from openhands.sdk import LLM -from openhands.tools.preset.default import get_default_agent -dummy_agent = get_default_agent( +dummy_agent = get_default_cli_agent( llm=LLM( model='dummy-model', api_key='dummy-key', diff --git a/openhands-cli/openhands_cli/runner.py b/openhands-cli/openhands_cli/runner.py index 816c3f4c2e..40be26c576 100644 --- a/openhands-cli/openhands_cli/runner.py +++ b/openhands-cli/openhands_cli/runner.py @@ -120,6 +120,7 @@ class ConversationRunner: else: raise Exception('Infinite loop') + def _handle_confirmation_request(self) -> UserConfirmation: """Handle confirmation request from user. diff --git a/openhands-cli/openhands_cli/tui/settings/settings_screen.py b/openhands-cli/openhands_cli/tui/settings/settings_screen.py index 35cd76e1de..983af5a39a 100644 --- a/openhands-cli/openhands_cli/tui/settings/settings_screen.py +++ b/openhands-cli/openhands_cli/tui/settings/settings_screen.py @@ -1,13 +1,11 @@ import os from openhands.sdk import LLM, BaseConversation, LocalFileStore -from openhands.sdk.security.confirmation_policy import NeverConfirm -from openhands.tools.preset.default import get_default_agent from prompt_toolkit import HTML, print_formatted_text from prompt_toolkit.shortcuts import print_container from prompt_toolkit.widgets import Frame, TextArea -from openhands_cli.llm_utils import get_llm_metadata +from openhands_cli.utils import get_llm_metadata, get_default_cli_agent from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR from openhands_cli.pt_style import COLOR_GREY from openhands_cli.tui.settings.store import AgentStore @@ -182,7 +180,7 @@ class SettingsScreen: agent = self.agent_store.load() if not agent: - agent = get_default_agent(llm=llm, cli_mode=True) + agent = get_default_cli_agent(llm=llm) agent = agent.model_copy(update={'llm': llm}) self.agent_store.save(agent) diff --git a/openhands-cli/openhands_cli/tui/settings/store.py b/openhands-cli/openhands_cli/tui/settings/store.py index 2a4f7f8321..1cd43fd74e 100644 --- a/openhands-cli/openhands_cli/tui/settings/store.py +++ b/openhands-cli/openhands_cli/tui/settings/store.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any from fastmcp.mcp_config import MCPConfig -from openhands_cli.llm_utils import get_llm_metadata +from openhands_cli.utils import get_llm_metadata from openhands_cli.locations import ( AGENT_SETTINGS_PATH, MCP_CONFIG_FILE, diff --git a/openhands-cli/openhands_cli/llm_utils.py b/openhands-cli/openhands_cli/utils.py similarity index 79% rename from openhands-cli/openhands_cli/llm_utils.py rename to openhands-cli/openhands_cli/utils.py index 35a485575a..b5bbc44104 100644 --- a/openhands-cli/openhands_cli/llm_utils.py +++ b/openhands-cli/openhands_cli/utils.py @@ -2,7 +2,9 @@ import os from typing import Any - +from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer +from openhands.tools.preset import get_default_agent +from openhands.sdk import LLM def get_llm_metadata( model_name: str, @@ -55,3 +57,20 @@ def get_llm_metadata( if user_id is not None: metadata['trace_user_id'] = user_id return metadata + + +def get_default_cli_agent( + llm: LLM +): + agent = get_default_agent( + llm=llm, + cli_mode=True + ) + + agent = agent.model_copy( + update={ + 'security_analyzer': LLMSecurityAnalyzer() + } + ) + + return agent diff --git a/openhands-cli/pyproject.toml b/openhands-cli/pyproject.toml index 17df247fd3..d365d98fc2 100644 --- a/openhands-cli/pyproject.toml +++ b/openhands-cli/pyproject.toml @@ -18,8 +18,8 @@ classifiers = [ # Using Git URLs for dependencies so installs from PyPI pull from GitHub # TODO: pin package versions once agent-sdk has published PyPI packages dependencies = [ - "openhands-sdk==1.0.0a3", - "openhands-tools==1.0.0a3", + "openhands-sdk==1.0.0a5", + "openhands-tools==1.0.0a5", "prompt-toolkit>=3", "typer>=0.17.4", ] diff --git a/openhands-cli/tests/settings/test_settings_workflow.py b/openhands-cli/tests/settings/test_settings_workflow.py index 891fe29ecd..940a9a802d 100644 --- a/openhands-cli/tests/settings/test_settings_workflow.py +++ b/openhands-cli/tests/settings/test_settings_workflow.py @@ -6,10 +6,10 @@ import pytest from openhands_cli.tui.settings.settings_screen import SettingsScreen from openhands_cli.tui.settings.store import AgentStore from openhands_cli.user_actions.settings_action import SettingsType +from openhands_cli.utils import get_default_cli_agent from pydantic import SecretStr from openhands.sdk import LLM, Conversation, LocalFileStore -from openhands.tools.preset.default import get_default_agent def read_json(path: Path) -> dict: @@ -30,7 +30,7 @@ def make_screen_with_conversation(model='openai/gpt-4o-mini', api_key='sk-xyz'): def seed_file(path: Path, model: str = 'openai/gpt-4o-mini', api_key: str = 'sk-old'): store = AgentStore() store.file_store = LocalFileStore(root=str(path)) - agent = get_default_agent( + agent = get_default_cli_agent( llm=LLM(model=model, api_key=SecretStr(api_key), service_id='test-service') ) store.save(agent) diff --git a/openhands-cli/tests/test_conversation_runner.py b/openhands-cli/tests/test_conversation_runner.py index 447c0edd17..cdedb3e552 100644 --- a/openhands-cli/tests/test_conversation_runner.py +++ b/openhands-cli/tests/test_conversation_runner.py @@ -6,13 +6,13 @@ from openhands_cli.runner import ConversationRunner from openhands_cli.user_actions.types import UserConfirmation from pydantic import ConfigDict, SecretStr, model_validator -from openhands.sdk import Conversation, ConversationCallbackType +from openhands.sdk import Conversation, ConversationCallbackType, LocalConversation from openhands.sdk.agent.base import AgentBase from openhands.sdk.conversation import ConversationState from openhands.sdk.conversation.state import AgentExecutionStatus from openhands.sdk.llm import LLM from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm - +from unittest.mock import MagicMock class FakeLLM(LLM): @model_validator(mode='after') @@ -41,11 +41,11 @@ class FakeAgent(AgentBase): pass def step( - self, state: ConversationState, on_event: ConversationCallbackType + self, conversation: LocalConversation, on_event: ConversationCallbackType ) -> None: self.step_count += 1 if self.step_count == self.finish_on_step: - state.agent_status = AgentExecutionStatus.FINISHED + conversation.state.agent_status = AgentExecutionStatus.FINISHED @pytest.fixture() @@ -102,15 +102,15 @@ class TestConversationRunner: """ if final_status == AgentExecutionStatus.FINISHED: agent.finish_on_step = 1 - + # Add a mock security analyzer to enable confirmation mode - from unittest.mock import MagicMock agent.security_analyzer = MagicMock() - + convo = Conversation(agent) convo.state.agent_status = AgentExecutionStatus.WAITING_FOR_CONFIRMATION cr = ConversationRunner(convo) cr.set_confirmation_policy(AlwaysConfirm()) + with patch.object( cr, '_handle_confirmation_request', return_value=confirmation ) as mock_confirmation_request: diff --git a/openhands-cli/uv.lock b/openhands-cli/uv.lock index dc00353f9b..d24303f214 100644 --- a/openhands-cli/uv.lock +++ b/openhands-cli/uv.lock @@ -1828,7 +1828,7 @@ wheels = [ [[package]] name = "openhands" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "openhands-sdk" }, @@ -1855,8 +1855,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "openhands-sdk", specifier = "==1.0.0a3" }, - { name = "openhands-tools", specifier = "==1.0.0a3" }, + { name = "openhands-sdk", specifier = "==1.0.0a5" }, + { name = "openhands-tools", specifier = "==1.0.0a5" }, { name = "prompt-toolkit", specifier = ">=3" }, { name = "typer", specifier = ">=0.17.4" }, ] @@ -1879,7 +1879,7 @@ dev = [ [[package]] name = "openhands-sdk" -version = "1.0.0a3" +version = "1.0.0a5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp" }, @@ -1891,14 +1891,14 @@ dependencies = [ { name = "tenacity" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/82/33b3e3560e259803b773eee9cb377fce63b56c4252f3036126e225171926/openhands_sdk-1.0.0a3.tar.gz", hash = "sha256:c2cf6ab2ac105d257a31fde0e502a81faa969c7e64e0b2364d0634d2ce8e93b4", size = 144940, upload-time = "2025-10-20T15:38:39.647Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/90/d40f6716641a95a61d2042f00855e0eadc0b2558167078324576cc5a3c22/openhands_sdk-1.0.0a5.tar.gz", hash = "sha256:8888d6892d58cf9b11a71fa80086156c0b6c9a0b50df6839c0a9cafffba2338c", size = 152810, upload-time = "2025-10-29T16:19:52.086Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/ab/4464d2470ef1e02334f9ade094dfefa2cfc5bb761b201663a3e4121e1892/openhands_sdk-1.0.0a3-py3-none-any.whl", hash = "sha256:c8ab45160b67e7de391211ae5607ccfdf44e39781f74d115a2a22df35a2f4311", size = 191937, upload-time = "2025-10-20T15:38:38.668Z" }, + { url = "https://files.pythonhosted.org/packages/00/6b/d3aa28019163f22f4b589ad818b83e3bea23d0a50b0c51ecc070ffdec139/openhands_sdk-1.0.0a5-py3-none-any.whl", hash = "sha256:db20272b04cf03627f9f7d1e87992078ac4ce15d188955a2962aa9e754d0af03", size = 204063, upload-time = "2025-10-29T16:19:50.684Z" }, ] [[package]] name = "openhands-tools" -version = "1.0.0a3" +version = "1.0.0a5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bashlex" }, @@ -1910,9 +1910,9 @@ dependencies = [ { name = "openhands-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/93/53cf1a5ae97e0c23d7e024db5bbb1ba1da9855c6352cc91d6b65fc6f5e13/openhands_tools-1.0.0a3.tar.gz", hash = "sha256:2a15fff3749ee5856906ffce999fec49c8305e7f9911f05e01dbcf4ea772e385", size = 59103, upload-time = "2025-10-20T15:38:43.705Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/8d/d62bc5e6c986676363692743688f10b6a922fd24dd525e5c6e87bd6fc08e/openhands_tools-1.0.0a5.tar.gz", hash = "sha256:6c67454e612596e95c5151267659ddd3b633a5d4a1b70b348f7f913c62146562", size = 63012, upload-time = "2025-10-29T16:19:53.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/251ce4ecd560cad295e1c81def9efadfd1009cec3b7e79bd41357c6a0670/openhands_tools-1.0.0a3-py3-none-any.whl", hash = "sha256:f4c81df682c2a1a1c0bfa450bfe25ba9de5a6a3b56d6bab90f7541bf149bb3ed", size = 78814, upload-time = "2025-10-20T15:38:42.795Z" }, + { url = "https://files.pythonhosted.org/packages/07/9d/4da48258f0af73d017b61ed3f12786fae4caccc7e7cd97d77ef2bb25f00c/openhands_tools-1.0.0a5-py3-none-any.whl", hash = "sha256:74c27e23e6adc9a0bad00e32448bd4872019ce0786474e8de2fbf2d7c0887e8e", size = 84724, upload-time = "2025-10-29T16:19:52.84Z" }, ] [[package]]