mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Add setup.sh script execution to event stream (#9427)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
11ae4f96c2
commit
ed58858e03
164
frontend/src/components/features/chat/chat-interface.test.tsx
Normal file
164
frontend/src/components/features/chat/chat-interface.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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, [], [])}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
73
tests/unit/test_setup.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user