diff --git a/frontend/src/components/features/chat/chat-interface.test.tsx b/frontend/src/components/features/chat/chat-interface.test.tsx new file mode 100644 index 0000000000..8d9fd5444b --- /dev/null +++ b/frontend/src/components/features/chat/chat-interface.test.tsx @@ -0,0 +1,164 @@ +import { render, screen } from "@testing-library/react"; +import { useParams } from "react-router"; +import { vi, describe, test, expect, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ChatInterface } from "./chat-interface"; +import { useWsClient } from "#/context/ws-client-provider"; +import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message"; +import { useWSErrorMessage } from "#/hooks/use-ws-error-message"; +import { useConfig } from "#/hooks/query/use-config"; +import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory"; +import { useUploadFiles } from "#/hooks/mutation/use-upload-files"; +import { OpenHandsAction } from "#/types/core/actions"; + +// Mock the hooks +vi.mock("#/context/ws-client-provider"); +vi.mock("#/hooks/use-optimistic-user-message"); +vi.mock("#/hooks/use-ws-error-message"); +vi.mock("react-router"); +vi.mock("#/hooks/query/use-config"); +vi.mock("#/hooks/mutation/use-get-trajectory"); +vi.mock("#/hooks/mutation/use-upload-files"); +vi.mock("react-redux", () => ({ + useSelector: vi.fn(() => ({ + curAgentState: "AWAITING_USER_INPUT", + selectedRepository: null, + replayJson: null, + })), +})); + +describe("ChatInterface", () => { + // Create a new QueryClient for each test + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + // Default mock implementations + (useWsClient as unknown as ReturnType).mockReturnValue({ + send: vi.fn(), + isLoadingMessages: false, + parsedEvents: [], + }); + ( + useOptimisticUserMessage as unknown as ReturnType + ).mockReturnValue({ + setOptimisticUserMessage: vi.fn(), + getOptimisticUserMessage: vi.fn(() => null), + }); + (useWSErrorMessage as unknown as ReturnType).mockReturnValue({ + getErrorMessage: vi.fn(() => null), + setErrorMessage: vi.fn(), + removeErrorMessage: vi.fn(), + }); + (useParams as unknown as ReturnType).mockReturnValue({ + conversationId: "test-id", + }); + (useConfig as unknown as ReturnType).mockReturnValue({ + data: { APP_MODE: "local" }, + }); + (useGetTrajectory as unknown as ReturnType).mockReturnValue({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isLoading: false, + }); + (useUploadFiles as unknown as ReturnType).mockReturnValue({ + mutateAsync: vi + .fn() + .mockResolvedValue({ skipped_files: [], uploaded_files: [] }), + isLoading: false, + }); + }); + + // Helper function to render with QueryClientProvider + const renderWithQueryClient = (ui: React.ReactElement) => + render( + {ui}, + ); + + test("should show chat suggestions when there are no events", () => { + (useWsClient as unknown as ReturnType).mockReturnValue({ + send: vi.fn(), + isLoadingMessages: false, + parsedEvents: [], + }); + + renderWithQueryClient(); + + // Check if ChatSuggestions is rendered + expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument(); + }); + + test("should show chat suggestions when there are only environment events", () => { + const environmentEvent: OpenHandsAction = { + id: 1, + source: "environment", + action: "system", + args: { + content: "source .openhands/setup.sh", + tools: null, + openhands_version: null, + agent_class: null, + }, + message: "Running setup script", + timestamp: "2025-07-01T00:00:00Z", + }; + + (useWsClient as unknown as ReturnType).mockReturnValue({ + send: vi.fn(), + isLoadingMessages: false, + parsedEvents: [environmentEvent], + }); + + renderWithQueryClient(); + + // Check if ChatSuggestions is still rendered with environment events + expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument(); + }); + + test("should hide chat suggestions when there is a user message", () => { + const userEvent: OpenHandsAction = { + id: 1, + source: "user", + action: "message", + args: { + content: "Hello", + image_urls: [], + file_urls: [], + }, + message: "Hello", + timestamp: "2025-07-01T00:00:00Z", + }; + + (useWsClient as unknown as ReturnType).mockReturnValue({ + send: vi.fn(), + isLoadingMessages: false, + parsedEvents: [userEvent], + }); + + renderWithQueryClient(); + + // Check if ChatSuggestions is not rendered with user events + expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); + }); + + test("should hide chat suggestions when there is an optimistic user message", () => { + ( + useOptimisticUserMessage as unknown as ReturnType + ).mockReturnValue({ + setOptimisticUserMessage: vi.fn(), + getOptimisticUserMessage: vi.fn(() => "Optimistic message"), + }); + + renderWithQueryClient(); + + // Check if ChatSuggestions is not rendered with optimistic user message + expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index 9862df8506..9cb72864b3 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -10,6 +10,7 @@ import { createChatMessage } from "#/services/chat-service"; import { InteractiveChatBox } from "./interactive-chat-box"; import { RootState } from "#/store"; import { AgentState } from "#/types/agent-state"; +import { isOpenHandsAction } from "#/types/core/guards"; import { generateAgentStateChangeEvent } from "#/services/agent-state-service"; import { FeedbackModal } from "../feedback/feedback-modal"; import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom"; @@ -77,6 +78,18 @@ export function ChatInterface() { const events = parsedEvents.filter(shouldRenderEvent); + // Check if there are any substantive agent actions (not just system messages) + const hasSubstantiveAgentActions = React.useMemo( + () => + parsedEvents.some( + (event) => + isOpenHandsAction(event) && + event.source === "agent" && + event.action !== "system", + ), + [parsedEvents], + ); + const handleSendMessage = async ( content: string, images: File[], @@ -167,9 +180,12 @@ export function ChatInterface() { return (
- {events.length === 0 && !optimisticUserMessage && ( - - )} + {!hasSubstantiveAgentActions && + !optimisticUserMessage && + !events.some( + (event) => isOpenHandsAction(event) && event.source === "user", + ) && } + {/* Note: We only hide chat suggestions when there's a user message */}
0 && + hasSubstantiveAgentActions && !optimisticUserMessage && ( handleSendMessage(value, [], [])} diff --git a/frontend/src/components/features/chat/chat-suggestions.tsx b/frontend/src/components/features/chat/chat-suggestions.tsx index 397f1b1486..75a9bfdcfe 100644 --- a/frontend/src/components/features/chat/chat-suggestions.tsx +++ b/frontend/src/components/features/chat/chat-suggestions.tsx @@ -12,7 +12,10 @@ export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) { const { t } = useTranslation(); return ( -
+
diff --git a/frontend/src/types/core/actions.ts b/frontend/src/types/core/actions.ts index eb6e919e65..50ae0cc6e0 100644 --- a/frontend/src/types/core/actions.ts +++ b/frontend/src/types/core/actions.ts @@ -11,7 +11,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> { } export interface SystemMessageAction extends OpenHandsActionEvent<"system"> { - source: "agent"; + source: "agent" | "environment"; args: { content: string; tools: Array> | null; diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 83021fbf3f..4ddf040289 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -443,12 +443,18 @@ class Runtime(FileEditRuntimeMixin): # setup scripts time out after 10 minutes action = CmdRunAction( - f'chmod +x {setup_script} && source {setup_script}', blocking=True + f'chmod +x {setup_script} && source {setup_script}', + blocking=True, + hidden=True, ) action.set_hard_timeout(600) - obs = self.run_action(action) - if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: - self.log('error', f'Setup script failed: {obs.content}') + + # Add the action to the event stream as an ENVIRONMENT event + source = EventSource.ENVIRONMENT + self.event_stream.add_event(action, source) + + # Execute the action + self.run_action(action) @property def workspace_root(self) -> Path: diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py new file mode 100644 index 0000000000..d773f223e4 --- /dev/null +++ b/tests/unit/test_setup.py @@ -0,0 +1,73 @@ +"""Unit tests for the setup script functionality.""" + +from unittest.mock import MagicMock, patch + +from openhands.events.action import CmdRunAction, FileReadAction +from openhands.events.event import EventSource +from openhands.events.observation import ErrorObservation, FileReadObservation +from openhands.runtime.base import Runtime + + +def test_maybe_run_setup_script_executes_action(): + """Test that maybe_run_setup_script executes the action after adding it to the event stream.""" + # Create mock runtime + runtime = MagicMock(spec=Runtime) + runtime.read.return_value = FileReadObservation( + content="#!/bin/bash\necho 'test'", path='.openhands/setup.sh' + ) + + # Mock the event stream + runtime.event_stream = MagicMock() + + # Add required attributes + runtime.status_callback = None + + # Call the actual implementation + with patch.object( + Runtime, 'maybe_run_setup_script', Runtime.maybe_run_setup_script + ): + Runtime.maybe_run_setup_script(runtime) + + # Verify that read was called with the correct action + runtime.read.assert_called_once_with(FileReadAction(path='.openhands/setup.sh')) + + # Verify that add_event was called with the correct action and source + runtime.event_stream.add_event.assert_called_once() + args, kwargs = runtime.event_stream.add_event.call_args + action, source = args + assert isinstance(action, CmdRunAction) + assert source == EventSource.ENVIRONMENT + + # Verify that run_action was called with the correct action + runtime.run_action.assert_called_once() + args, kwargs = runtime.run_action.call_args + action = args[0] + assert isinstance(action, CmdRunAction) + assert ( + action.command == 'chmod +x .openhands/setup.sh && source .openhands/setup.sh' + ) + + +def test_maybe_run_setup_script_skips_when_file_not_found(): + """Test that maybe_run_setup_script skips execution when the setup script is not found.""" + # Create mock runtime + runtime = MagicMock(spec=Runtime) + runtime.read.return_value = ErrorObservation(content='File not found', error_id='') + + # Mock the event stream + runtime.event_stream = MagicMock() + + # Call the actual implementation + with patch.object( + Runtime, 'maybe_run_setup_script', Runtime.maybe_run_setup_script + ): + Runtime.maybe_run_setup_script(runtime) + + # Verify that read was called with the correct action + runtime.read.assert_called_once_with(FileReadAction(path='.openhands/setup.sh')) + + # Verify that add_event was not called + runtime.event_stream.add_event.assert_not_called() + + # Verify that run_action was not called + runtime.run_action.assert_not_called()