Add setup.sh script execution to event stream (#9427)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Graham Neubig 2025-07-01 10:37:21 -04:00 committed by GitHub
parent 11ae4f96c2
commit ed58858e03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 272 additions and 10 deletions

View File

@ -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<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
(
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => null),
});
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
getErrorMessage: vi.fn(() => null),
setErrorMessage: vi.fn(),
removeErrorMessage: vi.fn(),
});
(useParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
conversationId: "test-id",
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { APP_MODE: "local" },
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
isLoading: false,
});
});
// Helper function to render with QueryClientProvider
const renderWithQueryClient = (ui: React.ReactElement) =>
render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
test("should show chat suggestions when there are no events", () => {
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
renderWithQueryClient(<ChatInterface />);
// 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<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [environmentEvent],
});
renderWithQueryClient(<ChatInterface />);
// 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<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [userEvent],
});
renderWithQueryClient(<ChatInterface />);
// 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<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is not rendered with optimistic user message
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
});
});

View File

@ -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 (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between">
{events.length === 0 && !optimisticUserMessage && (
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
)}
{!hasSubstantiveAgentActions &&
!optimisticUserMessage &&
!events.some(
(event) => isOpenHandsAction(event) && event.source === "user",
) && <ChatSuggestions onSuggestionsClick={setMessageToSend} />}
{/* Note: We only hide chat suggestions when there's a user message */}
<div
ref={scrollRef}
@ -192,7 +208,7 @@ export function ChatInterface() {
)}
{isWaitingForUserInput &&
events.length > 0 &&
hasSubstantiveAgentActions &&
!optimisticUserMessage && (
<ActionSuggestions
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}

View File

@ -12,7 +12,10 @@ export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
<div
data-testid="chat-suggestions"
className="flex flex-col gap-6 h-full px-4 items-center justify-center"
>
<div className="flex flex-col items-center p-4 bg-tertiary rounded-xl w-full">
<BuildIt width={45} height={54} />
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">

View File

@ -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<Record<string, unknown>> | null;

View File

@ -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:

73
tests/unit/test_setup.py Normal file
View File

@ -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()