Compare commits

..

8 Commits

Author SHA1 Message Date
Engel Nyst 70ad153c2c Add comprehensive unit tests for Ctrl+C behavior
- test_single_ctrl_c_stops_agent: Verifies first Ctrl+C stops agent gracefully with helpful message
- test_double_ctrl_c_raises_keyboard_interrupt: Verifies second Ctrl+C within 2 seconds raises KeyboardInterrupt for CLI cleanup
- test_ctrl_p_pauses_agent: Verifies Ctrl+P still pauses agent as expected

Tests use proper mocking of prompt_toolkit's create_input, raw_mode, and attach context managers.
All tests pass and validate the improved Ctrl+C behavior implementation.

Co-authored-by: OpenHands-Claude <openhands-claude@all-hands.dev>
2025-06-28 16:17:48 +02:00
Engel Nyst bded599449 Fix Ctrl+C behavior: use KeyboardInterrupt instead of signals
The previous approach using os.kill(os.getpid(), signal.SIGTERM) was too
aggressive and caused runtime crashes. The proper solution is to raise
KeyboardInterrupt and let the CLI main function handle it gracefully.

Key insights:
- CLI main function already has proper KeyboardInterrupt handling
- shutdown_listener is designed for server mode (uvicorn) primarily
- Raw input mode intercepts Ctrl+C before it becomes SIGINT
- Raising KeyboardInterrupt allows normal CLI shutdown flow

This approach:
- First Ctrl+C: stops agent gracefully with helpful message
- Second Ctrl+C: raises KeyboardInterrupt for clean application exit
- No more runtime crashes or 'system crashed and restarted' errors

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-06-28 16:02:22 +02:00
Engel Nyst 0bb43193d0 Use proper signal mechanism for double Ctrl+C shutdown
Instead of directly setting shutdown_listener._should_exit = True,
use os.kill(os.getpid(), signal.SIGTERM) to trigger the proper
shutdown signal handler.

This follows the established pattern where:
- shutdown_listener registers signal handlers for SIGINT/SIGTERM
- Signal handler sets _should_exit = True and calls shutdown listeners
- Components check should_continue()/should_exit() for coordinated shutdown

Benefits:
- Follows OpenHands' established shutdown architecture
- Proper signal handling instead of direct flag manipulation
- Consistent with how other shutdown scenarios work
- Cleaner separation of concerns

Co-authored-by: OpenHands-Claude <openhands-claude@all-hands.dev>
2025-06-28 15:41:02 +02:00
Engel Nyst 72b1aa6154 Fix double Ctrl+C to use global shutdown mechanism
Instead of raising KeyboardInterrupt which interrupts pending actions
and causes 'runtime system crashed' errors, use the existing global
shutdown_listener mechanism that gracefully shuts down all components.

This prevents:
- [Errno 21] Is a directory errors
- 'runtime system crashed and restarted' messages
- Delayed action execution after restart
- Missing 'Force quitting...' message

The shutdown_listener._should_exit flag is checked by EventStream
and other components for clean shutdown coordination.
2025-06-28 15:24:03 +02:00
Engel Nyst db5f7a5744 Implement double Ctrl+C behavior for graceful vs force quit
Changed to a more user-friendly approach:
- First Ctrl+C: Stops agent gracefully (sets STOPPED state)
- Second Ctrl+C within 2 seconds: Force quits application

This provides better UX by allowing users to:
1. Stop the current agent task without quitting the CLI
2. Force quit if they really want to exit the application

Messages shown:
- First: 'Stopping agent... (press Ctrl+C again within 2 seconds to force quit)'
- Second: 'Force quitting...'

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-06-28 14:46:54 +02:00
Engel Nyst fbe253f9e9 Fix Ctrl+C to cleanly stop agent instead of hard interrupt
Changed approach from raising KeyboardInterrupt to setting agent state
to STOPPED, which allows for clean shutdown without race conditions.

Changes:
- Ctrl+C now sets AgentState.STOPPED and signals done event
- Shows 'Keyboard interrupt, shutting down...' message
- Avoids 'cannot schedule new futures after interpreter shutdown' error
- Ctrl+P and Ctrl+D continue to pause the agent as before

This approach prevents the race condition where background tasks try to
schedule futures after the interpreter begins shutdown.

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-06-28 14:30:01 +02:00
Engel Nyst f70f07e19a Fix Ctrl+C to raise KeyboardInterrupt instead of ignoring it
The previous fix removed Ctrl+C handling entirely, which caused it to be
consumed by the input handler but do nothing. This fix makes Ctrl+C
explicitly raise KeyboardInterrupt, which will properly terminate the
application.

Changes:
- Ctrl+C now raises KeyboardInterrupt in process_agent_pause
- Ctrl+P and Ctrl+D continue to pause the agent as before
- Application will properly terminate when Ctrl+C is pressed

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-06-28 14:21:48 +02:00
Engel Nyst c9a2dec194 Fix Ctrl+C behavior in CLI to properly interrupt and stop application
Previously, Ctrl+C was intercepted by the process_agent_pause function
and treated the same as Ctrl+P (pause agent). This prevented normal
KeyboardInterrupt handling and made it impossible to stop the application
with Ctrl+C.

Changes:
- Remove Keys.ControlC from process_agent_pause function
- Now only Ctrl+P and Ctrl+D pause the agent
- Ctrl+C properly propagates as KeyboardInterrupt to main function
- Application can now be terminated normally with Ctrl+C

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-06-28 14:08:01 +02:00
65 changed files with 1206 additions and 3278 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 30
exempt-issue-labels: 'roadmap'
exempt-issue-labels: 'tracked'
close-issue-message: 'This issue was closed because it has been stalled for over 30 days with no activity.'
close-pr-message: 'This PR was closed because it has been stalled for over 30 days with no activity.'
days-before-close: 7
+1 -1
View File
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.48-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.47-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
> **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.
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
+1 -1
View File
@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:24.3.0-bookworm-slim AS frontend-builder
FROM node:22.16.0-bookworm-slim AS frontend-builder
WORKDIR /app
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.48-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.47-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.47-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:
+2 -2
View File
@@ -64,7 +64,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
python -m openhands.cli.main --override-cli-mode true
```
+16 -52
View File
@@ -18,78 +18,42 @@ poetry run python -m openhands.core.main -t "write a bash script that prints hi"
You'll need to be sure to set your model, API key, and other settings via environment variables
[or the `config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml).
### Working with Repositories
You can specify a repository for OpenHands to work with using `--selected-repo` or the `SANDBOX_SELECTED_REPO` environment variable:
> **Note**: Currently, authentication tokens (GITHUB_TOKEN, GITLAB_TOKEN, or BITBUCKET_TOKEN) are required for all repository operations, including public repositories. This is a known limitation that may be addressed in future versions to allow tokenless access to public repositories.
```bash
# Using command-line argument
poetry run python -m openhands.core.main \
--selected-repo "owner/repo-name" \
-t "analyze the codebase and suggest improvements"
# Using environment variable
export SANDBOX_SELECTED_REPO="owner/repo-name"
poetry run python -m openhands.core.main -t "fix any linting issues"
# Authentication tokens are currently required for ALL repository operations (public and private)
# This includes GitHub, GitLab, and Bitbucket repositories
export GITHUB_TOKEN="your-token" # or GITLAB_TOKEN, BITBUCKET_TOKEN
poetry run python -m openhands.core.main \
--selected-repo "owner/repo-name" \
-t "review the security implementation"
# Using task files instead of inline task
echo "Review the README and suggest improvements" > task.txt
poetry run python -m openhands.core.main -f task.txt --selected-repo "owner/repo"
```
## With Docker
Set environment variables and run the Docker command:
To run OpenHands in Headless mode with Docker:
1. Set the following environment variables in your terminal:
- `SANDBOX_VOLUMES` to specify the directory you want OpenHands to access ([See using SANDBOX_VOLUMES for more info](../runtimes/docker#using-sandbox_volumes))
- `LLM_MODEL` - the LLM model to use (e.g. `export LLM_MODEL="anthropic/claude-sonnet-4-20250514"`)
- `LLM_API_KEY` - your API key (e.g. `export LLM_API_KEY="sk_test_12345"`)
2. Run the following Docker command:
```bash
# Set required environment variables
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
export LLM_API_KEY="your-api-key"
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
-e LLM_MODEL=$LLM_MODEL \
-e SANDBOX_SELECTED_REPO=$SANDBOX_SELECTED_REPO \
-e GITHUB_TOKEN=$GITHUB_TOKEN \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
> **Note**: If you used OpenHands before version 0.44, run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history.
> **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.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
## Additional Options
## Advanced Headless Configurations
Common command-line options:
- `-d "/path/to/workspace"` - Set working directory
- `-f task.txt` - Load task from file
- `-i 50` - Set max iterations
- `-b 10.0` - Set budget limit (USD)
- `--no-auto-continue` - Interactive mode
To view all available configuration options for headless mode, run the Python command with the `--help` flag.
Run `poetry run python -m openhands.core.main --help` for all options, or use a [`config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) for more flexibility.
### Additional Logs
Set `export LOG_ALL_EVENTS=true` to log all agent actions.
For the headless mode to log all the agent actions, in the terminal run: `export LOG_ALL_EVENTS=true`
+4 -4
View File
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.48
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.47
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
+3 -3
View File
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
> **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.
@@ -295,238 +295,4 @@ describe("ConversationPanel", () => {
const newCards = await screen.findAllByTestId("conversation-card");
expect(newCards).toHaveLength(3);
});
it("should cancel stopping a conversation", async () => {
const user = userEvent.setup();
// Create mock data with a RUNNING conversation
const mockRunningConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Stopped Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Click ellipsis on the first card (RUNNING status)
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Stop button should be available for RUNNING conversation
const stopButton = screen.getByTestId("stop-button");
expect(stopButton).toBeInTheDocument();
// Click the stop button
await user.click(stopButton);
// Cancel the stopping action
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
expect(
screen.queryByRole("button", { name: /cancel/i }),
).not.toBeInTheDocument();
// Ensure the conversation status hasn't changed
const updatedCards = await screen.findAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(3);
});
it("should stop a conversation", async () => {
const user = userEvent.setup();
const mockData: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
stopConversationSpy.mockImplementation(async (id: string) => {
const conversation = mockData.find((conv) => conv.conversation_id === id);
if (conversation) {
conversation.status = "STOPPED";
return conversation;
}
return null;
});
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(2);
// Click ellipsis on the first card (RUNNING status)
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const stopButton = screen.getByTestId("stop-button");
// Click the stop button
await user.click(stopButton);
// Confirm the stopping action
const confirmButton = screen.getByRole("button", { name: /confirm/i });
await user.click(confirmButton);
expect(
screen.queryByRole("button", { name: /confirm/i }),
).not.toBeInTheDocument();
// Verify the API was called
expect(stopConversationSpy).toHaveBeenCalledWith("1");
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
});
it("should only show stop button for STARTING or RUNNING conversations", async () => {
const user = userEvent.setup();
const mockMixedStatusConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Stopped Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Test RUNNING conversation - should show stop button
const runningEllipsisButton = within(cards[0]).getByTestId(
"ellipsis-button",
);
await user.click(runningEllipsisButton);
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
// Click outside to close the menu
await user.click(document.body);
// Test STARTING conversation - should show stop button
const startingEllipsisButton = within(cards[1]).getByTestId(
"ellipsis-button",
);
await user.click(startingEllipsisButton);
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
// Click outside to close the menu
await user.click(document.body);
// Test STOPPED conversation - should NOT show stop button
const stoppedEllipsisButton = within(cards[2]).getByTestId(
"ellipsis-button",
);
await user.click(stoppedEllipsisButton);
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
});
});
@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "react-redux";
@@ -7,21 +7,6 @@ import { setupStore } from "test-utils";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
import userEvent from "@testing-library/user-event";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...(actual as object),
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
const renderTaskSuggestions = () => {
const RouterStub = createRoutesStub([
@@ -108,26 +93,4 @@ describe("TaskSuggestions", () => {
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
});
it("should render the tooltip button", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toBeInTheDocument();
});
it("should have the correct aria-label", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toHaveAttribute(
"aria-label",
"TASKS$TASK_SUGGESTIONS_INFO",
);
});
it("should render the info icon", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
const icon = tooltipButton.querySelector("svg");
expect(icon).toBeInTheDocument();
});
});
@@ -21,12 +21,7 @@ describe("UserActions", () => {
});
it("should toggle the user menu when the user avatar is clicked", async () => {
render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -62,102 +57,15 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
test("logout button is always enabled", async () => {
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should NOT appear because user is undefined
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
it("should show context menu even when user has no avatar_url", async () => {
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu SHOULD appear because user object exists (even with empty avatar_url)
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should NOT be able to access logout when no user is provided", async () => {
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Logout option should not be accessible because context menu doesn't appear
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(onLogoutMock).not.toHaveBeenCalled();
});
it("should handle user prop changing from undefined to defined", () => {
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
// Initially no user - context menu shouldn't work
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Add user prop
rerender(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
// Component should still render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should handle user prop changing from defined to undefined", async () => {
const { rerender } = render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
// Click to open menu
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
// Remove user prop - menu should disappear
rerender(<UserActions onLogout={onLogoutMock} />);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
isLoading={true}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should still appear even when loading
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
expect(onLogoutMock).toHaveBeenCalledOnce();
});
});
@@ -90,7 +90,7 @@ describe("HomeScreen", () => {
const mainContainer = screen
.getByTestId("home-screen")
.querySelector("main");
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
});
it("should filter the suggested tasks based on the selected repository", async () => {
+835 -1078
View File
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.48.0",
"version": "0.47.0",
"private": true,
"type": "module",
"engines": {
@@ -25,15 +25,15 @@
"axios": "^1.10.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.22.0",
"i18next": "^25.3.0",
"framer-motion": "^12.19.2",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.28",
"jose": "^6.0.11",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.256.0",
"posthog-js": "^1.255.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -84,7 +84,7 @@
"@babel/traverse": "^7.27.7",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.53.2",
"@playwright/test": "^1.53.1",
"@react-router/dev": "^7.6.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
@@ -92,7 +92,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.8",
"@types/node": "^24.0.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -1,164 +0,0 @@
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,7 +10,6 @@ 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";
@@ -78,18 +77,6 @@ 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[],
@@ -180,12 +167,9 @@ export function ChatInterface() {
return (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between">
{!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 */}
{events.length === 0 && !optimisticUserMessage && (
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
)}
<div
ref={scrollRef}
@@ -208,7 +192,7 @@ export function ChatInterface() {
)}
{isWaitingForUserInput &&
hasSubstantiveAgentActions &&
events.length > 0 &&
!optimisticUserMessage && (
<ActionSuggestions
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}
@@ -12,10 +12,7 @@ export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
const { t } = useTranslation();
return (
<div
data-testid="chat-suggestions"
className="flex flex-col gap-6 h-full px-4 items-center justify-center"
>
<div 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">
@@ -1,57 +0,0 @@
import { useTranslation } from "react-i18next";
import {
BaseModalDescription,
BaseModalTitle,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
interface ConfirmStopModalProps {
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmStopModal({
onConfirm,
onCancel,
}: ConfirmStopModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<ModalBody className="items-start border border-tertiary">
<div className="flex flex-col gap-2">
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_STOP)} />
<BaseModalDescription
description={t(I18nKey.CONVERSATION$STOP_WARNING)}
/>
</div>
<div
className="flex flex-col gap-2 w-full"
onClick={(event) => event.stopPropagation()}
>
<BrandButton
type="button"
variant="primary"
onClick={onConfirm}
className="w-full"
data-testid="confirm-button"
>
{t(I18nKey.ACTION$CONFIRM)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
onClick={onCancel}
className="w-full"
data-testid="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>
);
}
@@ -8,7 +8,6 @@ import { I18nKey } from "#/i18n/declaration";
interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
@@ -20,7 +19,6 @@ interface ConversationCardContextMenuProps {
export function ConversationCardContextMenu({
onClose,
onDelete,
onStop,
onEdit,
onDisplayCost,
onShowAgentTools,
@@ -46,11 +44,6 @@ export function ConversationCardContextMenu({
Delete
</ContextMenuListItem>
)}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
Stop
</ContextMenuListItem>
)}
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
Edit Title
@@ -23,7 +23,6 @@ import { ConversationStatus } from "#/types/conversation-status";
interface ConversationCardProps {
onClick?: () => void;
onDelete?: () => void;
onStop?: () => void;
onChangeTitle?: (title: string) => void;
showOptions?: boolean;
isActive?: boolean;
@@ -41,7 +40,6 @@ const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
export function ConversationCard({
onClick,
onDelete,
onStop,
onChangeTitle,
showOptions,
isActive,
@@ -103,13 +101,6 @@ export function ConversationCard({
setContextMenuVisible(false);
};
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onStop?.();
setContextMenuVisible(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -233,11 +224,6 @@ export function ConversationCard({
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"
? onStop && handleStop
: undefined
}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={
conversationId && showOptions
@@ -5,9 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
import { ConversationCard } from "./conversation-card";
import { useUserConversations } from "#/hooks/query/use-user-conversations";
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { ConfirmStopModal } from "./confirm-stop-modal";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ExitConversationModal } from "./exit-conversation-modal";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
@@ -24,8 +22,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
const [confirmStopModalVisible, setConfirmStopModalVisible] =
React.useState(false);
const [
confirmExitConversationModalVisible,
setConfirmExitConversationModalVisible,
@@ -37,18 +33,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { data: conversations, isFetching, error } = useUserConversations();
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const handleDeleteProject = (conversationId: string) => {
setConfirmDeleteModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleStopConversation = (conversationId: string) => {
setConfirmStopModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleConfirmDelete = () => {
if (selectedConversationId) {
deleteConversation(
@@ -64,21 +54,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
}
};
const handleConfirmStop = () => {
if (selectedConversationId) {
stopConversation(
{ conversationId: selectedConversationId },
{
onSuccess: () => {
if (selectedConversationId === currentConversationId) {
navigate("/");
}
},
},
);
}
};
return (
<div
ref={ref}
@@ -112,7 +87,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
<ConversationCard
isActive={isActive}
onDelete={() => handleDeleteProject(project.conversation_id)}
onStop={() => handleStopConversation(project.conversation_id)}
title={project.title}
selectedRepository={project.selected_repository}
lastUpdatedAt={project.last_updated_at}
@@ -134,16 +108,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
/>
)}
{confirmStopModalVisible && (
<ConfirmStopModal
onConfirm={() => {
handleConfirmStop();
setConfirmStopModalVisible(false);
}}
onCancel={() => setConfirmStopModalVisible(false)}
/>
)}
{confirmExitConversationModalVisible && (
<ExitConversationModal
onConfirm={() => {
@@ -6,12 +6,7 @@ interface EllipsisButtonProps {
export function EllipsisButton({ onClick }: EllipsisButtonProps) {
return (
<button
data-testid="ellipsis-button"
type="button"
onClick={onClick}
className="cursor-pointer"
>
<button data-testid="ellipsis-button" type="button" onClick={onClick}>
<FaEllipsisV fill="#a3a3a3" />
</button>
);
@@ -118,7 +118,7 @@ export function SystemMessageModal({
)}
</div>
<div className="max-h-[51vh] overflow-auto rounded-md">
<div className="h-[60vh] overflow-auto rounded-md">
{activeTab === "system" && (
<div className="p-4 whitespace-pre-wrap font-mono text-sm leading-relaxed text-gray-300 shadow-inner">
{systemMessage.content}
@@ -1,11 +1,9 @@
import { useTranslation } from "react-i18next";
import { FaInfoCircle } from "react-icons/fa";
import { TaskGroup } from "./task-group";
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface TaskSuggestionsProps {
filterFor?: string | null;
@@ -25,19 +23,7 @@ export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
data-testid="task-suggestions"
className={cn("flex flex-col w-full", !hasSuggestedTasks && "gap-6")}
>
<div className="flex items-center gap-2">
<h2 className="heading">{t(I18nKey.TASKS$SUGGESTED_TASKS)}</h2>
<TooltipButton
testId="task-suggestions-info"
tooltip={t(I18nKey.TASKS$TASK_SUGGESTIONS_TOOLTIP)}
ariaLabel={t(I18nKey.TASKS$TASK_SUGGESTIONS_INFO)}
className="text-[#9099AC] hover:text-white"
placement="bottom"
tooltipClassName="max-w-[348px]"
>
<FaInfoCircle size={16} />
</TooltipButton>
</div>
<h2 className="heading">{t(I18nKey.TASKS$SUGGESTED_TASKS)}</h2>
<div className="flex flex-col gap-6">
{isLoading && <TaskSuggestionsSkeleton />}
@@ -32,7 +32,7 @@ export function BrandButton({
type={type}
onClick={onClick}
className={cn(
"w-fit p-2 text-sm rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80 cursor-pointer",
"w-fit p-2 text-sm rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80",
variant === "primary" && "bg-primary text-[#0D0F11]",
variant === "secondary" && "border border-primary text-primary",
variant === "danger" && "bg-red-600 text-white hover:bg-red-700",
@@ -26,14 +26,14 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
};
return (
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
<div data-testid="user-actions" className="w-8 h-8 relative">
<UserAvatar
avatarUrl={user?.avatar_url}
onClick={toggleAccountMenu}
isLoading={isLoading}
/>
{accountContextMenuIsVisible && !!user && (
{accountContextMenuIsVisible && (
<AccountSettingsContextMenu
onLogout={handleLogout}
onClose={closeAccountMenu}
@@ -21,7 +21,7 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)}
onClick={onClick}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center cursor-pointer",
"w-8 h-8 rounded-full flex items-center justify-center",
isLoading && "bg-transparent",
)}
>
@@ -16,7 +16,7 @@ export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
type="button"
data-testid="suggestion"
onClick={() => onClick(suggestion.value)}
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold cursor-pointer"
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold"
>
{t(suggestion.label)}
</button>
@@ -20,12 +20,13 @@ export function ActionButton({
<button
onClick={() => handleAction(action)}
disabled={isDisabled}
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-colors duration-300 ease-in-out border border-transparent hover:border-red-400/40 rounded-full p-1"
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
type="button"
>
<span className="relative group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
{children}
</span>
<span className="absolute -inset-[5px] border-2 border-red-400/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-in-out" />
</button>
</Tooltip>
);
@@ -29,7 +29,6 @@ export function ConversationPanelButton({
<FaListUl
size={22}
className={cn(
"cursor-pointer",
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}
@@ -15,7 +15,7 @@ export function StopButton({ isDisabled, onClick }: StopButtonProps) {
disabled={isDisabled}
onClick={onClick}
type="button"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center cursor-pointer"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
>
<div className="w-[10px] h-[10px] bg-white" />
</button>
@@ -15,7 +15,7 @@ export function SubmitButton({ isDisabled, onClick }: SubmitButtonProps) {
disabled={isDisabled}
onClick={onClick}
type="submit"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center cursor-pointer"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
>
<ArrowSendIcon />
</button>
@@ -1,4 +1,4 @@
import { Tooltip, TooltipProps } from "@heroui/react";
import { Tooltip } from "@heroui/react";
import React, { ReactNode } from "react";
import { NavLink } from "react-router";
import { cn } from "#/utils/utils";
@@ -12,9 +12,7 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
tooltipClassName?: React.HTMLAttributes<HTMLDivElement>["className"];
disabled?: boolean;
placement?: TooltipProps["placement"];
}
export function TooltipButton({
@@ -26,9 +24,7 @@ export function TooltipButton({
ariaLabel,
testId,
className,
tooltipClassName,
disabled = false,
placement = "right",
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick && !disabled) {
@@ -122,12 +118,7 @@ export function TooltipButton({
}
return (
<Tooltip
content={tooltip}
closeDelay={100}
placement={placement}
className={tooltipClassName}
>
<Tooltip content={tooltip} closeDelay={100} placement="right">
{content}
</Tooltip>
);
@@ -18,7 +18,7 @@ export function TrajectoryActionButton({
type="button"
data-testid={testId}
onClick={onClick}
className="button-base p-1 hover:bg-neutral-500 cursor-pointer"
className="button-base p-1 hover:bg-neutral-500"
>
{icon}
</button>
@@ -68,7 +68,7 @@ export function ModelSelector({
const { t } = useTranslation();
return (
<div className="flex flex-col md:flex-row w-[full] max-w-[680px] justify-between gap-4 md:gap-[46px]">
<div className="flex flex-col md:flex-row w-[full] md:w-[680px] justify-between gap-4 md:gap-[46px]">
<fieldset className="flex flex-col gap-2.5 w-full">
<label className="text-sm">{t(I18nKey.LLM$PROVIDER)}</label>
<Autocomplete
@@ -1,31 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useStopConversation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (variables: { conversationId: string }) =>
OpenHands.stopConversation(variables.conversationId),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
"user",
"conversations",
]);
return { previousConversations };
},
onError: (_, __, context) => {
if (context?.previousConversations) {
queryClient.setQueryData(
["user", "conversations"],
context.previousConversations,
);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
},
});
};
-5
View File
@@ -296,8 +296,6 @@ export enum I18nKey {
LANDING$UPLOAD_TRAJECTORY = "LANDING$UPLOAD_TRAJECTORY",
LANDING$RECENT_CONVERSATION = "LANDING$RECENT_CONVERSATION",
CONVERSATION$CONFIRM_DELETE = "CONVERSATION$CONFIRM_DELETE",
CONVERSATION$CONFIRM_STOP = "CONVERSATION$CONFIRM_STOP",
CONVERSATION$STOP_WARNING = "CONVERSATION$STOP_WARNING",
CONVERSATION$METRICS_INFO = "CONVERSATION$METRICS_INFO",
CONVERSATION$CREATED = "CONVERSATION$CREATED",
CONVERSATION$AGO = "CONVERSATION$AGO",
@@ -347,7 +345,6 @@ export enum I18nKey {
SETTINGS$DONT_KNOW_API_KEY = "SETTINGS$DONT_KNOW_API_KEY",
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
SETTINGS$SAVED = "SETTINGS$SAVED",
SETTINGS$SAVED_WARNING = "SETTINGS$SAVED_WARNING",
SETTINGS$RESET = "SETTINGS$RESET",
SETTINGS$API_KEYS = "SETTINGS$API_KEYS",
SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION",
@@ -614,8 +611,6 @@ export enum I18nKey {
REPOSITORY$SELECT_REPO = "REPOSITORY$SELECT_REPO",
TASKS$SUGGESTED_TASKS = "TASKS$SUGGESTED_TASKS",
TASKS$NO_TASKS_AVAILABLE = "TASKS$NO_TASKS_AVAILABLE",
TASKS$TASK_SUGGESTIONS_INFO = "TASKS$TASK_SUGGESTIONS_INFO",
TASKS$TASK_SUGGESTIONS_TOOLTIP = "TASKS$TASK_SUGGESTIONS_TOOLTIP",
PAYMENT$SPECIFY_AMOUNT_USD = "PAYMENT$SPECIFY_AMOUNT_USD",
GIT$BITBUCKET_TOKEN_HELP_LINK = "GIT$BITBUCKET_TOKEN_HELP_LINK",
GIT$BITBUCKET_TOKEN_SEE_MORE_LINK = "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK",
-80
View File
@@ -4735,38 +4735,6 @@
"de": "Löschen bestätigen",
"uk": "Підтвердити видалення"
},
"CONVERSATION$CONFIRM_STOP": {
"en": "Confirm Stop",
"ja": "停止の確認",
"zh-CN": "确认停止",
"zh-TW": "確認停止",
"ko-KR": "중지 확인",
"no": "Bekreft stopp",
"it": "Conferma arresto",
"pt": "Confirmar parada",
"es": "Confirmar detención",
"ar": "تأكيد الإيقاف",
"fr": "Confirmer l'arrêt",
"tr": "Durdurmayı Onayla",
"de": "Stopp bestätigen",
"uk": "Підтвердити зупинку"
},
"CONVERSATION$STOP_WARNING": {
"en": "Are you sure you want to stop this conversation?",
"ja": "この会話を停止してもよろしいですか?",
"zh-CN": "您确定要停止此对话吗?",
"zh-TW": "您確定要停止此對話嗎?",
"ko-KR": "이 대화를 중지하시겠습니까?",
"no": "Er du sikker på at du vil stoppe denne samtalen?",
"it": "Sei sicuro di voler fermare questa conversazione?",
"pt": "Tem certeza de que deseja parar esta conversa?",
"es": "¿Está seguro de que desea detener esta conversación?",
"ar": "هل أنت متأكد أنك تريد إيقاف هذه المحادثة؟",
"fr": "Êtes-vous sûr de vouloir arrêter cette conversation ?",
"tr": "Bu konuşmayı durdurmak istediğinizden emin misiniz?",
"de": "Sind Sie sicher, dass Sie dieses Gespräch stoppen möchten?",
"uk": "Ви впевнені, що хочете зупинити цю розмову?"
},
"CONVERSATION$METRICS_INFO": {
"en": "Conversation Metrics",
"ja": "会話メトリクス",
@@ -5551,22 +5519,6 @@
"de": "Einstellungen gespeichert",
"uk": "Налаштування збережено"
},
"SETTINGS$SAVED_WARNING": {
"en": "Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes.",
"ja": "設定が保存されました。古い会話では、変更を確認するために会話を停止して再開する必要があります。",
"zh-CN": "设置已保存。对于旧对话,您需要停止并重新开始对话才能看到更改。",
"zh-TW": "設置已保存。對於舊對話,您需要停止並重新開始對話才能看到更改。",
"ko-KR": "설정이 저장되었습니다. 기존 대화의 경우 변경사항을 보려면 대화를 중지하고 다시 시작해야 합니다.",
"no": "Innstillinger lagret. For gamle samtaler må du stoppe og starte samtalen på nytt for å se endringene.",
"it": "Impostazioni salvate. Per le conversazioni precedenti, dovrai fermare e riavviare la conversazione per vedere le modifiche.",
"pt": "Configurações salvas. Para conversas antigas, você precisará parar e reiniciar a conversa para ver as alterações.",
"es": "Configuración guardada. Para conversaciones antiguas, necesitarás detener y reiniciar la conversación para ver los cambios.",
"ar": "تم حفظ الإعدادات. بالنسبة للمحادثات القديمة، ستحتاج إلى إيقاف وإعادة تشغيل المحادثة لرؤية التغييرات.",
"fr": "Paramètres enregistrés. Pour les anciennes conversations, vous devrez arrêter et redémarrer la conversation pour voir les changements.",
"tr": "Ayarlar kaydedildi. Eski konuşmalar için değişiklikleri görmek üzere konuşmayı durdurup yeniden başlatmanız gerekecek.",
"de": "Einstellungen gespeichert. Für alte Gespräche müssen Sie das Gespräch stoppen und neu starten, um die Änderungen zu sehen.",
"uk": "Налаштування збережено. Для старих розмов вам потрібно буде зупинити та перезапустити розмову, щоб побачити зміни."
},
"SETTINGS$RESET": {
"en": "Settings reset",
"ja": "設定がリセットされました",
@@ -9823,38 +9775,6 @@
"de": "Keine Aufgaben verfügbar",
"uk": "Немає доступних завдань"
},
"TASKS$TASK_SUGGESTIONS_INFO": {
"en": "Task suggestions information",
"ja": "タスク提案情報",
"zh-CN": "任务建议信息",
"zh-TW": "任務建議資訊",
"ko-KR": "작업 제안 정보",
"no": "Oppgaveforslag informasjon",
"it": "Informazioni sui suggerimenti di attività",
"pt": "Informações de sugestões de tarefas",
"es": "Información de sugerencias de tareas",
"ar": "معلومات اقتراحات المهام",
"fr": "Informations sur les suggestions de tâches",
"tr": "Görev önerisi bilgileri",
"de": "Aufgabenvorschlag-Informationen",
"uk": "Інформація про пропозиції завдань"
},
"TASKS$TASK_SUGGESTIONS_TOOLTIP": {
"en": "These are AI-curated task suggestions to help you get started with common development activities and best practices for your repository.",
"ja": "これらは、リポジトリの一般的な開発活動とベストプラクティスを始めるのに役立つAIによってキュレーションされたタスク提案です。",
"zh-CN": "这些是AI策划的任务建议,帮助您开始进行常见的开发活动和存储库的最佳实践。",
"zh-TW": "這些是AI策劃的任務建議,幫助您開始進行常見的開發活動和存儲庫的最佳實踐。",
"ko-KR": "이것은 저장소의 일반적인 개발 활동과 모범 사례를 시작할 수 있도록 도와주는 AI가 선별한 작업 제안입니다.",
"no": "Dette er AI-kuraterte oppgaveforslag som hjelper deg å komme i gang med vanlige utviklingsaktiviteter og beste praksis for ditt depot.",
"it": "Questi sono suggerimenti di attività curati dall'IA per aiutarti a iniziare con le attività di sviluppo comuni e le migliori pratiche per il tuo repository.",
"pt": "Estas são sugestões de tarefas curadas por IA para ajudá-lo a começar com atividades de desenvolvimento comuns e melhores práticas para seu repositório.",
"es": "Estas son sugerencias de tareas curadas por IA para ayudarte a comenzar con actividades de desarrollo comunes y mejores prácticas para tu repositorio.",
"ar": "هذه اقتراحات مهام منسقة بواسطة الذكاء الاصطناعي لمساعدتك على البدء بأنشطة التطوير الشائعة وأفضل الممارسات لمستودعك.",
"fr": "Ce sont des suggestions de tâches curées par l'IA pour vous aider à commencer avec les activités de développement courantes et les meilleures pratiques pour votre dépôt.",
"tr": "Bunlar, deponuz için yaygın geliştirme faaliyetleri ve en iyi uygulamalarla başlamanıza yardımcı olmak için AI tarafından düzenlenmiş görev önerileridir.",
"de": "Dies sind KI-kuratierte Aufgabenvorschläge, die Ihnen helfen, mit gängigen Entwicklungsaktivitäten und bewährten Praktiken für Ihr Repository zu beginnen.",
"uk": "Це AI-курировані пропозиції завдань, які допоможуть вам розпочати з поширеними діяльностями розробки та найкращими практиками для вашого репозиторію."
},
"PAYMENT$SPECIFY_AMOUNT_USD": {
"en": "Specify an amount in USD to add - min $10",
"ja": "追加するUSD金額を指定してください - 最小$10",
+1 -1
View File
@@ -192,7 +192,7 @@ function AppSettingsScreen() {
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
step={1}
className="w-full max-w-[680px]" // Match the width of the language field
className="w-[680px]" // Match the width of the language field
/>
</div>
)}
+1 -1
View File
@@ -24,7 +24,7 @@ function HomeScreen() {
<hr className="border-[#717888]" />
<main className="flex flex-col lg:flex-row justify-between gap-8">
<main className="flex flex-col md:flex-row justify-between gap-8">
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>
+1 -1
View File
@@ -75,7 +75,7 @@ function LlmSettingsScreen() {
}, [settings, resources]);
const handleSuccessfulMutation = () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING));
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setDirtyInputs({
model: false,
apiKey: false,
+1 -1
View File
@@ -197,7 +197,7 @@ export default function MainApp() {
return (
<div
data-testid="root-layout"
className="bg-base p-3 h-screen lg:min-w-[1024px] flex flex-col md:flex-row gap-3"
className="bg-base p-3 h-screen md:min-w-[1024px] flex flex-col md:flex-row gap-3"
>
<Sidebar />
+1 -1
View File
@@ -11,7 +11,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
}
export interface SystemMessageAction extends OpenHandsActionEvent<"system"> {
source: "agent" | "environment";
source: "agent";
args: {
content: string;
tools: Array<Record<string, unknown>> | null;
@@ -1,111 +0,0 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<ROLE>
Your primary role is to assist users by executing commands, modifying code, and solving technical problems effectively. You should be thorough, methodical, and prioritize quality over speed.
* If the user asks a question, like "why is X happening", don't try to fix the problem. Just give an answer to the question.
</ROLE>
<EFFICIENCY>
* Each action you take is somewhat expensive. Wherever possible, combine multiple actions into a single action, e.g. combine multiple bash commands into one, using sed and grep to edit/view multiple files at once.
* When exploring the codebase, use efficient tools like find, grep, and git commands with appropriate filters to minimize unnecessary operations.
</EFFICIENCY>
<FILE_SYSTEM_GUIDELINES>
* When a user provides a file path, do NOT assume it's relative to the current working directory. First explore the file system to locate the file before working on it.
* If asked to edit a file, edit the file directly, rather than creating a new file with a different filename.
* For global search-and-replace operations, consider using `sed` instead of opening file editors multiple times.
</FILE_SYSTEM_GUIDELINES>
<CODE_QUALITY>
* Write clean, efficient code with minimal comments. Avoid redundancy in comments: Do not repeat information that can be easily inferred from the code itself.
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
* Before implementing any changes, first thoroughly understand the codebase through exploration.
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
</CODE_QUALITY>
<VERSION_CONTROL>
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
* If unsure about committing certain files, check for the presence of .gitignore files or ask the user for clarification.
</VERSION_CONTROL>
<PULL_REQUESTS>
* When creating pull requests, create only ONE per session/issue unless explicitly instructed otherwise.
* When working with an existing PR, update it with new commits rather than creating additional PRs for the same issue.
* When updating a PR, preserve the original PR title and purpose, updating description only when necessary.
</PULL_REQUESTS>
<PROBLEM_SOLVING_WORKFLOW>
1. EXPLORATION: Thoroughly explore relevant files and understand the context before proposing solutions
2. ANALYSIS: Consider multiple approaches and select the most promising one
3. TESTING:
* For bug fixes: Create tests to verify issues before implementing fixes
* For new features: Consider test-driven development when appropriate
* If the repository lacks testing infrastructure and implementing tests would require extensive setup, consult with the user before investing time in building testing infrastructure
* If the environment is not set up to run tests, consult with the user first before investing time to install all dependencies
4. IMPLEMENTATION: Make focused, minimal changes to address the problem
5. VERIFICATION: If the environment is set up to run tests, test your implementation thoroughly, including edge cases. If the environment is not set up to run tests, consult with the user first before investing time to run tests.
</PROBLEM_SOLVING_WORKFLOW>
<TASK_MANAGEMENT>
* For complex, long-horizon tasks, create a TODO.md file to track progress:
1. Start by creating a detailed plan in TODO.md with clear steps
2. Check TODO.md before each new action to maintain context and track progress
3. Update TODO.md as you complete steps or discover new requirements
4. Mark completed items with ✓ or [x] to maintain a clear record of progress
5. For each major step, add sub-tasks as needed to break down complex work
6. If you discover the plan needs significant changes, propose updates and confirm with the user before proceeding and update TODO.md
7. IMPORTANT: Do NOT add TODO.md to git commits or version control systems
* Example TODO.md format:
```markdown
# Task: [Brief description of the overall task]
## Plan
- [ ] Step 1: [Description]
- [ ] Sub-task 1.1
- [ ] Sub-task 1.2
- [ ] Step 2: [Description]
- [x] Step 3: [Description] (Completed)
## Notes
- Important discovery: [Details about something you learned]
- Potential issue: [Description of a potential problem]
```
* When working on a task:
- Read the README to understand how the system works
- Create TODO.md with every major step unchecked
- Add TODO.md to .gitignore if it's not already ignored
- Until every item in TODO.md is checked:
a. Pick the next unchecked item and work on it
b. Run appropriate tests to verify your work
c. If issues arise, fix them until tests pass
d. Once complete, check off the item in TODO.md
e. Proceed to the next unchecked item
</TASK_MANAGEMENT>
<SECURITY>
* Only use GITHUB_TOKEN and other credentials in ways the user has explicitly requested and would expect.
* Use APIs to work with GitHub or other platforms, unless the user asks otherwise or your task requires browsing.
</SECURITY>
<ENVIRONMENT_SETUP>
* When user asks you to run an application, don't stop if the application is not installed. Instead, please install the application and run the command again.
* If you encounter missing dependencies:
1. First, look around in the repository for existing dependency files (requirements.txt, pyproject.toml, package.json, Gemfile, etc.)
2. If dependency files exist, use them to install all dependencies at once (e.g., `pip install -r requirements.txt`, `npm install`, etc.)
3. Only install individual packages directly if no dependency files are found or if only specific packages are needed
* Similarly, if you encounter missing dependencies for essential tools requested by the user, install them when possible.
</ENVIRONMENT_SETUP>
<TROUBLESHOOTING>
* If you've made repeated attempts to solve a problem but tests still fail or the user reports it's still broken:
1. Step back and reflect on 5-7 different possible sources of the problem
2. Assess the likelihood of each possible cause
3. Methodically address the most likely causes, starting with the highest probability
4. Document your reasoning process
* When you run into any major issue while executing a plan from the user, please don't try to directly work around it. Instead, propose a new plan and confirm with the user before proceeding.
</TROUBLESHOOTING>
+2 -8
View File
@@ -28,7 +28,6 @@ from openhands.core.config import (
OpenHandsConfig,
)
from openhands.core.schema import AgentState
from openhands.core.schema.exit_reason import ExitReason
from openhands.events import EventSource
from openhands.events.action import (
ChangeAgentStateAction,
@@ -46,11 +45,10 @@ async def handle_commands(
config: OpenHandsConfig,
current_dir: str,
settings_store: FileSettingsStore,
) -> tuple[bool, bool, bool, ExitReason]:
) -> tuple[bool, bool, bool]:
close_repl = False
reload_microagents = False
new_session_requested = False
exit_reason = ExitReason.ERROR
if command == '/exit':
close_repl = handle_exit_command(
@@ -59,8 +57,6 @@ async def handle_commands(
usage_metrics,
sid,
)
if close_repl:
exit_reason = ExitReason.INTENTIONAL
elif command == '/help':
handle_help_command()
elif command == '/init':
@@ -73,8 +69,6 @@ async def handle_commands(
close_repl, new_session_requested = handle_new_command(
config, event_stream, usage_metrics, sid
)
if close_repl:
exit_reason = ExitReason.INTENTIONAL
elif command == '/settings':
await handle_settings_command(config, settings_store)
elif command == '/resume':
@@ -84,7 +78,7 @@ async def handle_commands(
action = MessageAction(content=command)
event_stream.add_event(action, EventSource.USER)
return close_repl, reload_microagents, new_session_requested, exit_reason
return close_repl, reload_microagents, new_session_requested
def handle_exit_command(
+9 -22
View File
@@ -23,10 +23,9 @@ from openhands.cli.tui import (
display_initialization_animation,
display_runtime_initialization_message,
display_welcome_message,
process_agent_pause,
read_confirmation_input,
read_prompt_input,
start_pause_listener,
stop_pause_listener,
update_streaming_output,
)
from openhands.cli.utils import (
@@ -41,11 +40,9 @@ from openhands.core.config import (
)
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
from openhands.core.config.utils import finalize_config
from openhands.core.logger import openhands_logger as logger
from openhands.core.loop import run_agent_until_done
from openhands.core.schema import AgentState
from openhands.core.schema.exit_reason import ExitReason
from openhands.core.setup import (
create_agent,
create_controller,
@@ -119,11 +116,11 @@ async def run_session(
) -> bool:
reload_microagents = False
new_session_requested = False
exit_reason = ExitReason.INTENTIONAL
sid = generate_sid(config, session_name)
is_loaded = asyncio.Event()
is_paused = asyncio.Event() # Event to track agent pause requests
pause_task: asyncio.Task | None = None # No more than one pause task
always_confirm_mode = False # Flag to enable always confirm mode
# Show runtime initialization message
@@ -155,7 +152,7 @@ async def run_session(
usage_metrics = UsageMetrics()
async def prompt_for_next_task(agent_state: str) -> None:
nonlocal reload_microagents, new_session_requested, exit_reason
nonlocal reload_microagents, new_session_requested
while True:
next_message = await read_prompt_input(
config, agent_state, multiline=config.cli_multiline_input
@@ -168,7 +165,6 @@ async def run_session(
close_repl,
reload_microagents,
new_session_requested,
exit_reason,
) = await handle_commands(
next_message,
event_stream,
@@ -187,10 +183,6 @@ async def run_session(
display_event(event, config)
update_usage_metrics(event, usage_metrics)
if isinstance(event, AgentStateChangedObservation):
if event.agent_state not in [AgentState.RUNNING, AgentState.PAUSED]:
await stop_pause_listener()
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [
AgentState.AWAITING_USER_INPUT,
@@ -244,7 +236,11 @@ async def run_session(
if event.agent_state == AgentState.RUNNING:
display_agent_running_message()
start_pause_listener(loop, is_paused, event_stream)
nonlocal pause_task
if pause_task is None or pause_task.done():
pause_task = loop.create_task(
process_agent_pause(is_paused, event_stream)
) # Create a task to track agent pause requests from the user
def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))
@@ -334,11 +330,6 @@ async def run_session(
await cleanup_session(loop, agent, runtime, controller)
if exit_reason == ExitReason.INTENTIONAL:
print_formatted_text('✅ Session terminated successfully.\n')
else:
print_formatted_text(f'⚠️ Session was interrupted: {exit_reason.value}\n')
return new_session_requested
@@ -434,10 +425,6 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
config.workspace_base = os.getcwd()
config.security.confirmation_mode = True
# Need to finalize config again after setting runtime to 'cli'
# This ensures Jupyter plugin is disabled for CLI runtime
finalize_config(config)
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base
@@ -491,7 +478,7 @@ def main():
try:
loop.run_until_complete(main_with_loop(loop))
except KeyboardInterrupt:
print_formatted_text('⚠️ Session was interrupted: interrupted\n')
print('Received keyboard interrupt, shutting down...')
except ConnectionRefusedError as e:
print(f'Connection refused: {e}')
sys.exit(1)
+31 -36
View File
@@ -3,7 +3,6 @@
# CLI Settings are handled separately in cli_settings.py
import asyncio
import contextlib
import sys
import threading
import time
@@ -76,8 +75,6 @@ COMMANDS = {
print_lock = threading.Lock()
pause_task: asyncio.Task | None = None # No more than one pause task
class UsageMetrics:
def __init__(self) -> None:
@@ -588,38 +585,39 @@ async def read_confirmation_input(config: OpenHandsConfig) -> str:
return 'no'
def start_pause_listener(
loop: asyncio.AbstractEventLoop,
done_event: asyncio.Event,
event_stream,
) -> None:
global pause_task
if pause_task is None or pause_task.done():
pause_task = loop.create_task(
process_agent_pause(done_event, event_stream)
) # Create a task to track agent pause requests from the user
async def stop_pause_listener() -> None:
global pause_task
if pause_task and not pause_task.done():
pause_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pause_task
await asyncio.sleep(0)
pause_task = None
async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) -> None:
import time
input = create_input()
ctrl_c_pressed_time = None
def keys_ready() -> None:
nonlocal ctrl_c_pressed_time
for key_press in input.read_keys():
if (
key_press.key == Keys.ControlP
or key_press.key == Keys.ControlC
or key_press.key == Keys.ControlD
):
if key_press.key == Keys.ControlC:
current_time = time.time()
if ctrl_c_pressed_time and (current_time - ctrl_c_pressed_time) < 2.0:
# Double Ctrl+C within 2 seconds - force quit
print_formatted_text('')
print_formatted_text(HTML('<red>Force quitting...</red>'))
# Let the CLI main function handle the KeyboardInterrupt properly
raise KeyboardInterrupt()
else:
# First Ctrl+C - stop agent gracefully
ctrl_c_pressed_time = current_time
print_formatted_text('')
print_formatted_text(
HTML(
'<yellow>Stopping agent... (press Ctrl+C again within 2 seconds to force quit)</yellow>'
)
)
event_stream.add_event(
ChangeAgentStateAction(AgentState.STOPPED),
EventSource.USER,
)
done.set()
elif key_press.key == Keys.ControlP or key_press.key == Keys.ControlD:
print_formatted_text('')
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
event_stream.add_event(
@@ -628,12 +626,9 @@ async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) ->
)
done.set()
try:
with input.raw_mode():
with input.attach(keys_ready):
await done.wait()
finally:
input.close()
with input.raw_mode():
with input.attach(keys_ready):
await done.wait()
def cli_confirm(
-7
View File
@@ -1,7 +0,0 @@
from enum import Enum
class ExitReason(Enum):
INTENTIONAL = 'intentional'
INTERRUPTED = 'interrupted'
ERROR = 'error'
+22 -19
View File
@@ -482,26 +482,24 @@ class LLM(RetryMixin, DebugMixin):
)
self.config.top_p = 0.9 if self.config.top_p == 1 else self.config.top_p
# Set max_input_tokens from model info if not explicitly set
if (
self.config.max_input_tokens is None
and self.model_info is not None
and 'max_input_tokens' in self.model_info
and isinstance(self.model_info['max_input_tokens'], int)
):
self.config.max_input_tokens = self.model_info['max_input_tokens']
# Set max_output_tokens from model info if not explicitly set
if self.config.max_output_tokens is None:
# Special case for Claude 3.7 Sonnet models
if any(
model in self.config.model
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
# Set the max tokens in an LM-specific way if not set
if self.config.max_input_tokens is None:
if (
self.model_info is not None
and 'max_input_tokens' in self.model_info
and isinstance(self.model_info['max_input_tokens'], int)
):
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
# Try to get from model info
elif self.model_info is not None:
# max_output_tokens has precedence over max_tokens
self.config.max_input_tokens = self.model_info['max_input_tokens']
else:
# Safe fallback for any potentially viable model
self.config.max_input_tokens = 4096
if self.config.max_output_tokens is None:
# Safe default for any potentially viable model
self.config.max_output_tokens = 4096
if self.model_info is not None:
# max_output_tokens has precedence over max_tokens, if either exists.
# litellm has models with both, one or none of these 2 parameters!
if 'max_output_tokens' in self.model_info and isinstance(
self.model_info['max_output_tokens'], int
):
@@ -510,6 +508,11 @@ class LLM(RetryMixin, DebugMixin):
self.model_info['max_tokens'], int
):
self.config.max_output_tokens = self.model_info['max_tokens']
if any(
model in self.config.model
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
):
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
# Initialize function calling capability
# Check if model name is in our supported list
+1
View File
@@ -296,6 +296,7 @@ class Memory:
self.knowledge_microagents[name] = agent_knowledge
for name, agent_repo in repo_agents.items():
self.repo_microagents[name] = agent_repo
except Exception as e:
logger.warning(
f'Failed to load user microagents from {USER_MICROAGENTS_DIR}: {str(e)}'
+4 -10
View File
@@ -443,18 +443,12 @@ class Runtime(FileEditRuntimeMixin):
# setup scripts time out after 10 minutes
action = CmdRunAction(
f'chmod +x {setup_script} && source {setup_script}',
blocking=True,
hidden=True,
f'chmod +x {setup_script} && source {setup_script}', blocking=True
)
action.set_hard_timeout(600)
# 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)
obs = self.run_action(action)
if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0:
self.log('error', f'Setup script failed: {obs.content}')
@property
def workspace_root(self) -> Path:
+1 -1
View File
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik"
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik"
```
#### Additional Kubernetes Options
+1 -1
View File
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.48.0"
version = "0.47.0"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
+13 -805
View File
@@ -1,7 +1,6 @@
"""Browsing-related tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox."""
import os
import re
import pytest
from conftest import _close_test_runtime, _load_runtime
@@ -24,104 +23,10 @@ from openhands.events.observation import (
# ============================================================================================================================
# Skip all tests in this module for CLI runtime
pytestmark = pytest.mark.skipif(
@pytest.mark.skipif(
os.environ.get('TEST_RUNTIME') == 'cli',
reason='CLIRuntime does not support browsing actions',
)
def parse_axtree_content(content: str) -> dict[str, str]:
"""Parse the accessibility tree content to extract bid -> element description mapping."""
elements = {}
current_bid = None
description_lines = []
# Find the accessibility tree section
lines = content.split('\n')
in_axtree = False
for line in lines:
line = line.strip()
# Check if we're entering the accessibility tree section
if 'BEGIN accessibility tree' in line:
in_axtree = True
continue
elif 'END accessibility tree' in line:
break
if not in_axtree or not line:
continue
# Check for bid line format: [bid] element description
bid_match = re.match(r'\[([a-zA-Z0-9]+)\]\s*(.*)', line)
if bid_match:
# Save previous element if it exists
if current_bid and description_lines:
elements[current_bid] = ' '.join(description_lines)
# Start new element
current_bid = bid_match.group(1)
description_lines = [bid_match.group(2).strip()]
else:
# Add to current description if we have a bid
if current_bid:
description_lines.append(line)
# Save last element
if current_bid and description_lines:
elements[current_bid] = ' '.join(description_lines)
return elements
def find_element_by_text(axtree_elements: dict[str, str], text: str) -> str | None:
"""Find an element bid by searching for text in the element description."""
text = text.lower().strip()
for bid, description in axtree_elements.items():
if text in description.lower():
return bid
return None
def find_element_by_id(axtree_elements: dict[str, str], element_id: str) -> str | None:
"""Find an element bid by searching for HTML id attribute."""
for bid, description in axtree_elements.items():
# Look for id="element_id" or id='element_id' patterns
if f'id="{element_id}"' in description or f"id='{element_id}'" in description:
return bid
return None
def find_element_by_tag_and_attributes(
axtree_elements: dict[str, str], tag: str, **attributes
) -> str | None:
"""Find an element bid by tag name and attributes."""
tag = tag.lower()
for bid, description in axtree_elements.items():
description_lower = description.lower()
# Check if this is the right tag
if not description_lower.startswith(tag):
continue
# Check all required attributes
match = True
for attr_name, attr_value in attributes.items():
attr_pattern = f'{attr_name}="{attr_value}"'
if attr_pattern not in description:
attr_pattern = f"{attr_name}='{attr_value}'"
if attr_pattern not in description:
match = False
break
if match:
return bid
return None
def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
@@ -166,715 +71,10 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
_close_test_runtime(runtime)
def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser navigation actions: goto, go_back, go_forward, noop."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create test HTML pages
page1_content = """
<!DOCTYPE html>
<html>
<head><title>Page 1</title></head>
<body>
<h1>Page 1</h1>
<a href="page2.html" id="link-to-page2">Go to Page 2</a>
</body>
</html>
"""
page2_content = """
<!DOCTYPE html>
<html>
<head><title>Page 2</title></head>
<body>
<h1>Page 2</h1>
<a href="page1.html" id="link-to-page1">Go to Page 1</a>
</body>
</html>
"""
# Create HTML files in temp directory
page1_path = os.path.join(temp_dir, 'page1.html')
page2_path = os.path.join(temp_dir, 'page2.html')
with open(page1_path, 'w') as f:
f.write(page1_content)
with open(page2_path, 'w') as f:
f.write(page2_content)
# Copy files to sandbox
sandbox_dir = config.workspace_mount_path_in_sandbox
runtime.copy_to(page1_path, sandbox_dir)
runtime.copy_to(page2_path, sandbox_dir)
# Start HTTP server
action_cmd = CmdRunAction(
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
# Wait for server to start
action_cmd = CmdRunAction(command='sleep 3')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Test goto action
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/page1.html")',
return_axtree=False,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert 'http://localhost:8000/page1.html' in obs.url
# Test noop action (should not change page)
action_browse = BrowseInteractiveAction(
browser_actions='noop(500)', return_axtree=False
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert 'http://localhost:8000/page1.html' in obs.url
# Navigate to page 2
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/page2.html")',
return_axtree=False,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 2' in obs.content
assert 'http://localhost:8000/page2.html' in obs.url
# Test go_back action
action_browse = BrowseInteractiveAction(
browser_actions='go_back()', return_axtree=False
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 1' in obs.content
assert 'http://localhost:8000/page1.html' in obs.url
# Test go_forward action
action_browse = BrowseInteractiveAction(
browser_actions='go_forward()', return_axtree=False
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Page 2' in obs.content
assert 'http://localhost:8000/page2.html' in obs.url
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
finally:
_close_test_runtime(runtime)
def test_browser_form_interactions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser form interaction actions: fill, click, select_option, clear."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test form page
form_content = """
<!DOCTYPE html>
<html>
<head><title>Test Form</title></head>
<body>
<h1>Test Form</h1>
<form id="test-form">
<input type="text" id="text-input" name="text" placeholder="Enter text">
<textarea id="textarea-input" name="message" placeholder="Enter message"></textarea>
<select id="select-input" name="option">
<option value="">Select an option</option>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
<button type="button" id="test-button">Test Button</button>
<input type="submit" id="submit-button" value="Submit">
</form>
<div id="result"></div>
<script>
document.getElementById('test-button').onclick = function() {
document.getElementById('result').innerHTML = 'Button clicked!';
};
</script>
</body>
</html>
"""
# Create HTML file
form_path = os.path.join(temp_dir, 'form.html')
with open(form_path, 'w') as f:
f.write(form_content)
# Copy to sandbox
sandbox_dir = config.workspace_mount_path_in_sandbox
runtime.copy_to(form_path, sandbox_dir)
# Start HTTP server
action_cmd = CmdRunAction(
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'ACTION'})
assert obs.exit_code == 0
# Wait for server to start
action_cmd = CmdRunAction(command='sleep 3')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Navigate to form page
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/form.html")',
return_axtree=True, # Need axtree to get element bids
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Test Form' in obs.content
# Parse the axtree to get actual bid values
axtree_elements = parse_axtree_content(obs.content)
# Find elements by their characteristics visible in the axtree
text_input_bid = find_element_by_text(axtree_elements, 'Enter text')
textarea_bid = find_element_by_text(axtree_elements, 'Enter message')
select_bid = find_element_by_text(axtree_elements, 'combobox')
button_bid = find_element_by_text(axtree_elements, 'Test Button')
# Verify we found the correct elements
assert text_input_bid is not None, (
f'Could not find text input element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert textarea_bid is not None, (
f'Could not find textarea element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert button_bid is not None, (
f'Could not find button element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert select_bid is not None, (
f'Could not find select element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert text_input_bid != button_bid, (
'Text input bid should be different from button bid'
)
# Test fill action with real bid values
action_browse = BrowseInteractiveAction(
browser_actions=f"""
fill("{text_input_bid}", "Hello World")
fill("{textarea_bid}", "This is a test message")
""".strip(),
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
# Verify the action executed successfully
assert not obs.error, (
f'Browser action failed with error: {obs.last_browser_action_error}'
)
# Parse the updated axtree to verify the text was actually filled
updated_axtree_elements = parse_axtree_content(obs.content)
# Check that the text input now contains our text
assert text_input_bid in updated_axtree_elements, (
f'Text input element {text_input_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
text_input_desc = updated_axtree_elements[text_input_bid]
# The filled value should appear in the element description (axtree shows values differently)
assert 'Hello World' in text_input_desc or "'Hello World'" in text_input_desc, (
f"Text input should contain 'Hello World' but description is: {text_input_desc}"
)
assert textarea_bid in updated_axtree_elements, (
f'Textarea element {textarea_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
textarea_desc = updated_axtree_elements[textarea_bid]
assert (
'This is a test message' in textarea_desc
or "'This is a test message'" in textarea_desc
), f'Textarea should contain test message but description is: {textarea_desc}'
# Test select_option action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'select_option("{select_bid}", "option2")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, (
f'Select option action failed: {obs.last_browser_action_error}'
)
# Verify that option2 is now selected
updated_axtree_elements = parse_axtree_content(obs.content)
assert select_bid in updated_axtree_elements, (
f'Select element {select_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
select_desc = updated_axtree_elements[select_bid]
# The selected option should be reflected in the select element description
assert 'option2' in select_desc or 'Option 2' in select_desc, (
f"Select element should show 'option2' as selected but description is: {select_desc}"
)
# Test click action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'click("{button_bid}")', return_axtree=True
)
obs = runtime.run_action(action_browse)
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Click action failed: {obs.last_browser_action_error}'
# Verify that the button click triggered the JavaScript and updated the result div
updated_axtree_elements = parse_axtree_content(obs.content)
# Look for the "Button clicked!" text that should appear in the result div
result_found = any(
'Button clicked!' in desc for desc in updated_axtree_elements.values()
)
assert result_found, (
f"Button click should have triggered JavaScript to show 'Button clicked!' but not found in: {dict(list(updated_axtree_elements.items())[:10])}"
)
# Test clear action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'clear("{text_input_bid}")', return_axtree=True
)
obs = runtime.run_action(action_browse)
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Clear action failed: {obs.last_browser_action_error}'
# Verify that the text input is now empty/cleared
updated_axtree_elements = parse_axtree_content(obs.content)
assert text_input_bid in updated_axtree_elements
text_input_desc = updated_axtree_elements[text_input_bid]
# After clearing, the input should not contain the previous text
assert 'Hello World' not in text_input_desc, (
f'Text input should be cleared but still contains text: {text_input_desc}'
)
# Check that it's back to showing placeholder text or is empty
assert (
'Enter text' in text_input_desc # placeholder text
or 'textbox' in text_input_desc.lower() # generic textbox description
or text_input_desc.strip() == '' # empty description
), (
f'Cleared text input should show placeholder or be empty but description is: {text_input_desc}'
)
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
finally:
_close_test_runtime(runtime)
def test_browser_interactive_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser interactive actions: scroll, hover, fill, press, focus."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test page with scrollable content
scroll_content = """
<!DOCTYPE html>
<html>
<head>
<title>Scroll Test</title>
<style>
body { margin: 0; padding: 20px; }
.content { height: 2000px; background: linear-gradient(to bottom, #ff0000, #0000ff); }
.hover-target {
width: 200px; height: 100px; background: #ccc; margin: 20px;
border: 2px solid #000; cursor: pointer;
}
.hover-target:hover { background: #ffff00; }
#focus-input { margin: 20px; padding: 10px; font-size: 16px; }
</style>
</head>
<body>
<h1>Interactive Test Page</h1>
<div class="hover-target" id="hover-div">Hover over me</div>
<input type="text" id="focus-input" placeholder="Focus me and type">
<div class="content">
<p>This is a long scrollable page...</p>
<p style="margin-top: 500px;">Middle content</p>
<p style="margin-top: 500px;" id="bottom-content">Bottom content</p>
</div>
</body>
</html>
"""
# Create HTML file
scroll_path = os.path.join(temp_dir, 'scroll.html')
with open(scroll_path, 'w') as f:
f.write(scroll_content)
# Copy to sandbox
sandbox_dir = config.workspace_mount_path_in_sandbox
runtime.copy_to(scroll_path, sandbox_dir)
# Start HTTP server
action_cmd = CmdRunAction(
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
# Wait for server to start
action_cmd = CmdRunAction(command='sleep 3')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Navigate to scroll page
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/scroll.html")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'Interactive Test Page' in obs.content
# Test scroll action
action_browse = BrowseInteractiveAction(
browser_actions='scroll(0, 300)', # Scroll down 300 pixels
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Scroll action failed: {obs.last_browser_action_error}'
# Verify the scroll action was recorded correctly
assert 'scroll(0, 300)' in obs.last_browser_action, (
f'Expected scroll action in browser history but got: {obs.last_browser_action}'
)
# Parse the axtree to get actual bid values for interactive elements
axtree_elements = parse_axtree_content(obs.content)
# Find elements by their characteristics visible in the axtree
hover_div_bid = find_element_by_text(axtree_elements, 'Hover over me')
focus_input_bid = find_element_by_text(axtree_elements, 'Focus me and type')
# Verify we found the required elements
assert hover_div_bid is not None, (
f'Could not find hover div element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
assert focus_input_bid is not None, (
f'Could not find focus input element in axtree. Available elements: {dict(list(axtree_elements.items())[:5])}'
)
# Test hover action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'hover("{hover_div_bid}")', return_axtree=True
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Hover action failed: {obs.last_browser_action_error}'
# Test focus action with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'focus("{focus_input_bid}")', return_axtree=True
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Focus action failed: {obs.last_browser_action_error}'
# Verify that the input element is now focused
assert obs.focused_element_bid == focus_input_bid, (
f'Expected focused element to be {focus_input_bid}, but got {obs.focused_element_bid}'
)
# Test fill action (type in focused input) with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'fill("{focus_input_bid}", "TestValue123")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Fill action failed: {obs.last_browser_action_error}'
# Verify that the text was actually entered
updated_axtree_elements = parse_axtree_content(obs.content)
assert focus_input_bid in updated_axtree_elements, (
f'Focus input element {focus_input_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
input_desc = updated_axtree_elements[focus_input_bid]
assert 'TestValue123' in input_desc or "'TestValue123'" in input_desc, (
f"Input should contain 'TestValue123' but description is: {input_desc}"
)
# Test press action (for pressing individual keys) with real bid
action_browse = BrowseInteractiveAction(
browser_actions=f'press("{focus_input_bid}", "Backspace")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, f'Press action failed: {obs.last_browser_action_error}'
# Verify the backspace removed the last character (3 from TestValue123)
updated_axtree_elements = parse_axtree_content(obs.content)
assert focus_input_bid in updated_axtree_elements, (
f'Focus input element {focus_input_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
input_desc = updated_axtree_elements[focus_input_bid]
assert 'TestValue12' in input_desc or "'TestValue12'" in input_desc, (
f"Input should contain 'TestValue12' after backspace but description is: {input_desc}"
)
# Test multiple actions in sequence
action_browse = BrowseInteractiveAction(
browser_actions="""
scroll(0, -200)
noop(1000)
scroll(0, 400)
""".strip(),
return_axtree=False,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, (
f'Multiple actions sequence failed: {obs.last_browser_action_error}'
)
# Verify the last action in the sequence was recorded
assert (
'scroll(0, 400)' in obs.last_browser_action
or 'noop(1000)' in obs.last_browser_action
), f'Expected final action from sequence but got: {obs.last_browser_action}'
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
finally:
_close_test_runtime(runtime)
def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
"""Test browser file upload action."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create a test file to upload
test_file_content = 'This is a test file for upload testing.'
test_file_path = os.path.join(temp_dir, 'upload_test.txt')
with open(test_file_path, 'w') as f:
f.write(test_file_content)
# Create an upload form page
upload_content = """
<!DOCTYPE html>
<html>
<head><title>File Upload Test</title></head>
<body>
<h1>File Upload Test</h1>
<form enctype="multipart/form-data">
<input type="file" id="file-input" name="file" accept=".txt,.pdf,.png">
<button type="button" onclick="handleUpload()">Upload File</button>
</form>
<div id="upload-result"></div>
<script>
function handleUpload() {
const fileInput = document.getElementById('file-input');
if (fileInput.files.length > 0) {
document.getElementById('upload-result').innerHTML =
'File selected: ' + fileInput.files[0].name;
} else {
document.getElementById('upload-result').innerHTML = 'No file selected';
}
}
</script>
</body>
</html>
"""
# Create HTML file
upload_path = os.path.join(temp_dir, 'upload.html')
with open(upload_path, 'w') as f:
f.write(upload_content)
# Copy files to sandbox
sandbox_dir = config.workspace_mount_path_in_sandbox
runtime.copy_to(upload_path, sandbox_dir)
runtime.copy_to(test_file_path, sandbox_dir)
# Start HTTP server
action_cmd = CmdRunAction(
command='python3 -m http.server 8000 > server.log 2>&1 &'
)
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.exit_code == 0
# Wait for server to start
action_cmd = CmdRunAction(command='sleep 3')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Navigate to upload page
action_browse = BrowseInteractiveAction(
browser_actions='goto("http://localhost:8000/upload.html")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error
assert 'File Upload Test' in obs.content
# Parse the axtree to get the file input bid
axtree_elements = parse_axtree_content(obs.content)
# File inputs often show up as buttons in axtree, try multiple strategies
file_input_bid = (
find_element_by_text(axtree_elements, 'Choose File')
or find_element_by_text(axtree_elements, 'No file chosen')
or find_element_by_text(axtree_elements, 'Browse')
or find_element_by_text(axtree_elements, 'file')
or find_element_by_id(axtree_elements, 'file-input')
)
# Also look for button near the file input (Upload File button)
upload_button_bid = find_element_by_text(axtree_elements, 'Upload File')
# Test upload_file action with real bid
assert file_input_bid is not None, (
f'Could not find file input element in axtree. Available elements: {dict(list(axtree_elements.items())[:10])}'
)
action_browse = BrowseInteractiveAction(
browser_actions=f'upload_file("{file_input_bid}", "/workspace/upload_test.txt")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, (
f'File upload action failed: {obs.last_browser_action_error}'
)
# Verify the file input now shows the selected file
updated_axtree_elements = parse_axtree_content(obs.content)
assert file_input_bid in updated_axtree_elements, (
f'File input element {file_input_bid} should be present in updated axtree. Available elements: {list(updated_axtree_elements.keys())[:10]}'
)
file_input_desc = updated_axtree_elements[file_input_bid]
# File inputs typically show the filename when a file is selected
assert (
'upload_test.txt' in file_input_desc
or 'upload_test' in file_input_desc
or 'txt' in file_input_desc
), f'File input should show selected file but description is: {file_input_desc}'
# Test clicking the upload button to trigger the JavaScript function
if upload_button_bid:
action_browse = BrowseInteractiveAction(
browser_actions=f'click("{upload_button_bid}")',
return_axtree=True,
)
logger.info(action_browse, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_browse)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert isinstance(obs, BrowserOutputObservation)
assert not obs.error, (
f'Upload button click failed: {obs.last_browser_action_error}'
)
# Check if the JavaScript function executed and updated the result div
final_axtree_elements = parse_axtree_content(obs.content)
# Look for the result text that should be set by JavaScript
result_found = any(
'File selected:' in desc or 'upload_test.txt' in desc
for desc in final_axtree_elements.values()
)
assert result_found, (
f'JavaScript upload handler should have updated the page but no result found in: {dict(list(final_axtree_elements.items())[:10])}'
)
# Clean up
action_cmd = CmdRunAction(command='pkill -f "python3 -m http.server" || true')
logger.info(action_cmd, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action_cmd)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
finally:
_close_test_runtime(runtime)
@pytest.mark.skipif(
os.environ.get('TEST_RUNTIME') == 'cli',
reason='CLIRuntime does not support browsing actions',
)
def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
@@ -947,6 +147,10 @@ def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
_close_test_runtime(runtime)
@pytest.mark.skipif(
os.environ.get('TEST_RUNTIME') == 'cli',
reason='CLIRuntime does not support browsing actions',
)
def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
@@ -1014,6 +218,10 @@ def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
_close_test_runtime(runtime)
@pytest.mark.skipif(
os.environ.get('TEST_RUNTIME') == 'cli',
reason='CLIRuntime does not support browsing actions',
)
def test_download_file(temp_dir, runtime_cls, run_as_openhands):
"""Test downloading a file using the browser."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
-26
View File
@@ -125,12 +125,6 @@ def mock_config():
)
config.search_api_key = search_api_key_mock
# Mock sandbox with volumes attribute to prevent finalize_config issues
config.sandbox = MagicMock()
config.sandbox.volumes = (
None # This prevents finalize_config from overriding workspace_base
)
return config
@@ -333,9 +327,7 @@ async def test_run_session_with_initial_action(
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_main_without_task(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
@@ -419,9 +411,7 @@ async def test_main_without_task(
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_main_with_task(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
@@ -516,9 +506,7 @@ async def test_main_with_task(
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_main_with_session_name_passes_name_to_run_session(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
@@ -612,9 +600,7 @@ async def test_main_with_session_name_passes_name_to_run_session(
@patch('openhands.cli.main.display_initialization_animation') # Cosmetic
@patch('openhands.cli.main.initialize_repository_for_runtime') # Cosmetic / setup
@patch('openhands.cli.main.display_initial_user_prompt') # Cosmetic
@patch('openhands.cli.main.finalize_config')
async def test_run_session_with_name_attempts_state_restore(
mock_finalize_config,
mock_display_initial_user_prompt,
mock_initialize_repo,
mock_display_init_anim,
@@ -698,17 +684,11 @@ async def test_run_session_with_name_attempts_state_restore(
@patch('openhands.cli.main.setup_config_from_args')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.check_folder_security_agreement')
@patch('openhands.cli.main.read_task')
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_main_security_check_fails(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
mock_read_task,
mock_check_security,
mock_get_settings_store,
mock_setup_config,
@@ -763,9 +743,7 @@ async def test_main_security_check_fails(
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
async def test_config_loading_order(
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
@@ -863,19 +841,15 @@ async def test_config_loading_order(
@patch('openhands.cli.main.setup_config_from_args')
@patch('openhands.cli.main.FileSettingsStore.get_instance')
@patch('openhands.cli.main.check_folder_security_agreement')
@patch('openhands.cli.main.read_task')
@patch('openhands.cli.main.run_session')
@patch('openhands.cli.main.LLMSummarizingCondenserConfig')
@patch('openhands.cli.main.NoOpCondenserConfig')
@patch('openhands.cli.main.finalize_config')
@patch('builtins.open', new_callable=MagicMock)
async def test_main_with_file_option(
mock_open,
mock_finalize_config,
mock_noop_condenser,
mock_llm_condenser,
mock_run_session,
mock_read_task,
mock_check_security,
mock_get_settings_store,
mock_setup_config,
+7 -7
View File
@@ -45,7 +45,7 @@ class TestHandleCommands:
async def test_handle_exit_command(self, mock_handle_exit, mock_dependencies):
mock_handle_exit.return_value = True
close_repl, reload_microagents, new_session, _ = await handle_commands(
close_repl, reload_microagents, new_session = await handle_commands(
'/exit', **mock_dependencies
)
@@ -64,7 +64,7 @@ class TestHandleCommands:
async def test_handle_help_command(self, mock_handle_help, mock_dependencies):
mock_handle_help.return_value = (False, False, False)
close_repl, reload_microagents, new_session, _ = await handle_commands(
close_repl, reload_microagents, new_session = await handle_commands(
'/help', **mock_dependencies
)
@@ -78,7 +78,7 @@ class TestHandleCommands:
async def test_handle_init_command(self, mock_handle_init, mock_dependencies):
mock_handle_init.return_value = (True, True)
close_repl, reload_microagents, new_session, _ = await handle_commands(
close_repl, reload_microagents, new_session = await handle_commands(
'/init', **mock_dependencies
)
@@ -96,7 +96,7 @@ class TestHandleCommands:
async def test_handle_status_command(self, mock_handle_status, mock_dependencies):
mock_handle_status.return_value = (False, False, False)
close_repl, reload_microagents, new_session, _ = await handle_commands(
close_repl, reload_microagents, new_session = await handle_commands(
'/status', **mock_dependencies
)
@@ -112,7 +112,7 @@ class TestHandleCommands:
async def test_handle_new_command(self, mock_handle_new, mock_dependencies):
mock_handle_new.return_value = (True, True)
close_repl, reload_microagents, new_session, _ = await handle_commands(
close_repl, reload_microagents, new_session = await handle_commands(
'/new', **mock_dependencies
)
@@ -131,7 +131,7 @@ class TestHandleCommands:
async def test_handle_settings_command(
self, mock_handle_settings, mock_dependencies
):
close_repl, reload_microagents, new_session, _ = await handle_commands(
close_repl, reload_microagents, new_session = await handle_commands(
'/settings', **mock_dependencies
)
@@ -147,7 +147,7 @@ class TestHandleCommands:
async def test_handle_unknown_command(self, mock_dependencies):
user_message = 'Hello, this is not a command'
close_repl, reload_microagents, new_session, _ = await handle_commands(
close_repl, reload_microagents, new_session = await handle_commands(
user_message, **mock_dependencies
)
+2 -7
View File
@@ -253,12 +253,7 @@ class TestCliCommandsPauseResume:
mock_handle_resume.return_value = (False, False)
# Call handle_commands
(
close_repl,
reload_microagents,
new_session_requested,
_,
) = await handle_commands(
close_repl, reload_microagents, new_session_requested = await handle_commands(
message,
event_stream,
usage_metrics,
@@ -280,7 +275,7 @@ class TestCliCommandsPauseResume:
class TestAgentStatePauseResume:
@pytest.mark.asyncio
@patch('openhands.cli.main.display_agent_running_message')
@patch('openhands.cli.tui.process_agent_pause')
@patch('openhands.cli.main.process_agent_pause')
async def test_agent_running_enables_pause(
self, mock_process_agent_pause, mock_display_message
):
+198
View File
@@ -16,6 +16,7 @@ from openhands.cli.tui import (
display_usage_metrics,
display_welcome_message,
get_session_duration,
process_agent_pause,
read_confirmation_input,
)
from openhands.core.config import OpenHandsConfig
@@ -385,3 +386,200 @@ class TestReadConfirmationInput:
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
class TestProcessAgentPause:
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_input')
@patch('openhands.cli.tui.print_formatted_text')
async def test_single_ctrl_c_stops_agent(self, mock_print, mock_create_input):
"""Test that a single Ctrl+C stops the agent gracefully."""
import asyncio
from prompt_toolkit.keys import Keys
# Mock the input to simulate a single Ctrl+C
mock_input = Mock()
mock_key_press = Mock()
mock_key_press.key = Keys.ControlC
mock_input.read_keys.return_value = [mock_key_press]
# Mock the context managers and simulate immediate key press
mock_input.raw_mode.return_value.__enter__ = Mock(return_value=None)
mock_input.raw_mode.return_value.__exit__ = Mock(return_value=None)
# Mock attach to immediately call the keys_ready function
def mock_attach(keys_ready_func):
# Simulate the key press by calling the function immediately
keys_ready_func()
# Return a mock context manager
mock_context = Mock()
mock_context.__enter__ = Mock(return_value=None)
mock_context.__exit__ = Mock(return_value=None)
return mock_context
mock_input.attach.side_effect = mock_attach
mock_create_input.return_value = mock_input
# Mock event stream
mock_event_stream = Mock()
mock_event_stream.add_event = Mock()
# Create done event
done = asyncio.Event()
# Run the function
await process_agent_pause(done, mock_event_stream)
# Verify agent was stopped gracefully
mock_event_stream.add_event.assert_called_once()
call_args = mock_event_stream.add_event.call_args[0]
assert call_args[0].agent_state.value == 'stopped'
# Verify the helpful message was displayed
mock_print.assert_called()
print_calls = [call.args[0] for call in mock_print.call_args_list]
helpful_message_found = any(
'Stopping agent' in str(call) and 'press Ctrl+C again' in str(call)
for call in print_calls
)
assert helpful_message_found, (
f'Expected helpful message not found in: {print_calls}'
)
# Verify done event was set
assert done.is_set()
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_input')
@patch('openhands.cli.tui.print_formatted_text')
async def test_double_ctrl_c_raises_keyboard_interrupt(
self, mock_print, mock_create_input
):
"""Test that double Ctrl+C within 2 seconds raises KeyboardInterrupt."""
import asyncio
from prompt_toolkit.keys import Keys
# Mock the input to simulate double Ctrl+C
mock_input = Mock()
mock_key_press = Mock()
mock_key_press.key = Keys.ControlC
# Simulate two Ctrl+C presses within 2 seconds
call_count = 0
def mock_read_keys():
nonlocal call_count
call_count += 1
if call_count == 1:
# First call returns first Ctrl+C
return [mock_key_press]
elif call_count == 2:
# Second call returns second Ctrl+C (within 2 seconds)
return [mock_key_press]
else:
# Subsequent calls return empty to avoid infinite loop
return []
mock_input.read_keys.side_effect = mock_read_keys
# Mock the context managers and simulate double key press
mock_input.raw_mode.return_value.__enter__ = Mock(return_value=None)
mock_input.raw_mode.return_value.__exit__ = Mock(return_value=None)
# Mock attach to call the keys_ready function twice (simulating double Ctrl+C)
def mock_attach(keys_ready_func):
# Simulate first Ctrl+C
keys_ready_func()
# Simulate second Ctrl+C immediately (within 2 seconds)
keys_ready_func()
# Return a mock context manager
mock_context = Mock()
mock_context.__enter__ = Mock(return_value=None)
mock_context.__exit__ = Mock(return_value=None)
return mock_context
mock_input.attach.side_effect = mock_attach
mock_create_input.return_value = mock_input
# Mock event stream
mock_event_stream = Mock()
mock_event_stream.add_event = Mock()
# Create done event
done = asyncio.Event()
# Run the function and expect KeyboardInterrupt
with pytest.raises(KeyboardInterrupt):
await process_agent_pause(done, mock_event_stream)
# Verify force quit message was displayed
mock_print.assert_called()
print_calls = [call.args[0] for call in mock_print.call_args_list]
force_quit_message_found = any(
'Force quitting' in str(call) for call in print_calls
)
assert force_quit_message_found, (
f'Expected force quit message not found in: {print_calls}'
)
@pytest.mark.asyncio
@patch('openhands.cli.tui.create_input')
@patch('openhands.cli.tui.print_formatted_text')
async def test_ctrl_p_pauses_agent(self, mock_print, mock_create_input):
"""Test that Ctrl+P pauses the agent."""
import asyncio
from prompt_toolkit.keys import Keys
# Mock the input to simulate Ctrl+P
mock_input = Mock()
mock_key_press = Mock()
mock_key_press.key = Keys.ControlP
mock_input.read_keys.return_value = [mock_key_press]
# Mock the context managers and simulate immediate key press
mock_input.raw_mode.return_value.__enter__ = Mock(return_value=None)
mock_input.raw_mode.return_value.__exit__ = Mock(return_value=None)
# Mock attach to immediately call the keys_ready function
def mock_attach(keys_ready_func):
# Simulate the key press by calling the function immediately
keys_ready_func()
# Return a mock context manager
mock_context = Mock()
mock_context.__enter__ = Mock(return_value=None)
mock_context.__exit__ = Mock(return_value=None)
return mock_context
mock_input.attach.side_effect = mock_attach
mock_create_input.return_value = mock_input
# Mock event stream
mock_event_stream = Mock()
mock_event_stream.add_event = Mock()
# Create done event
done = asyncio.Event()
# Run the function
await process_agent_pause(done, mock_event_stream)
# Verify agent was paused
mock_event_stream.add_event.assert_called_once()
call_args = mock_event_stream.add_event.call_args[0]
assert call_args[0].agent_state.value == 'paused'
# Verify the pause message was displayed
mock_print.assert_called()
print_calls = [call.args[0] for call in mock_print.call_args_list]
pause_message_found = any(
'Pausing the agent' in str(call) for call in print_calls
)
assert pause_message_found, (
f'Expected pause message not found in: {print_calls}'
)
# Verify done event was set
assert done.is_set()
-56
View File
@@ -1,56 +0,0 @@
import time
from unittest.mock import MagicMock
import pytest
from openhands.cli.commands import handle_commands
from openhands.core.schema.exit_reason import ExitReason
def test_exit_reason_enum_values():
assert ExitReason.INTENTIONAL.value == 'intentional'
assert ExitReason.INTERRUPTED.value == 'interrupted'
assert ExitReason.ERROR.value == 'error'
def test_exit_reason_enum_names():
assert ExitReason['INTENTIONAL'] == ExitReason.INTENTIONAL
assert ExitReason['INTERRUPTED'] == ExitReason.INTERRUPTED
assert ExitReason['ERROR'] == ExitReason.ERROR
def test_exit_reason_str_representation():
assert str(ExitReason.INTENTIONAL) == 'ExitReason.INTENTIONAL'
assert repr(ExitReason.ERROR) == "<ExitReason.ERROR: 'error'>"
@pytest.mark.asyncio
async def test_handle_exit_command_returns_intentional(monkeypatch):
monkeypatch.setattr('openhands.cli.commands.cli_confirm', lambda *a, **k: 0)
mock_usage_metrics = MagicMock()
mock_usage_metrics.session_init_time = time.time() - 3600
mock_usage_metrics.metrics.accumulated_cost = 0.123456
# Mock all token counts used in display formatting
mock_usage_metrics.metrics.accumulated_token_usage.prompt_tokens = 1234
mock_usage_metrics.metrics.accumulated_token_usage.cache_read_tokens = 5678
mock_usage_metrics.metrics.accumulated_token_usage.cache_write_tokens = 9012
mock_usage_metrics.metrics.accumulated_token_usage.completion_tokens = 3456
(
close_repl,
reload_microagents,
new_session_requested,
exit_reason,
) = await handle_commands(
'/exit',
MagicMock(),
mock_usage_metrics,
'test-session',
MagicMock(),
'/tmp/test',
MagicMock(),
)
assert exit_reason == ExitReason.INTENTIONAL
+2 -93
View File
@@ -140,8 +140,8 @@ def test_llm_init_without_model_info(mock_get_model_info, default_config):
mock_get_model_info.side_effect = Exception('Model info not available')
llm = LLM(default_config)
llm.init_model_info()
assert llm.config.max_input_tokens is None
assert llm.config.max_output_tokens is None
assert llm.config.max_input_tokens == 4096
assert llm.config.max_output_tokens == 4096
def test_llm_init_with_custom_config():
@@ -981,94 +981,3 @@ def test_llm_base_url_auto_protocol_patch(mock_get):
called_url = mock_get.call_args[0][0]
assert called_url.startswith('http://') or called_url.startswith('https://')
# Tests for max_output_tokens configuration and usage
def test_unknown_model_token_limits():
"""Test that models without known token limits get None for both max_output_tokens and max_input_tokens."""
# Create LLM instance with a non-existent model to avoid litellm having model info for it
config = LLMConfig(model='non-existent-model', api_key='test_key')
llm = LLM(config)
# Verify max_output_tokens and max_input_tokens are initialized to None (default value)
assert llm.config.max_output_tokens is None
assert llm.config.max_input_tokens is None
def test_max_tokens_from_model_info():
"""Test that max_output_tokens and max_input_tokens are correctly initialized from model info."""
# Create LLM instance with GPT-4 model which has known token limits
config = LLMConfig(model='gpt-4', api_key='test_key')
llm = LLM(config)
# GPT-4 has specific token limits
# These are the expected values from litellm
assert llm.config.max_output_tokens == 4096
assert llm.config.max_input_tokens == 8192
def test_claude_3_7_sonnet_max_output_tokens():
"""Test that Claude 3.7 Sonnet models get the special 64000 max_output_tokens value and default max_input_tokens."""
# Create LLM instance with Claude 3.7 Sonnet model
config = LLMConfig(model='claude-3-7-sonnet', api_key='test_key')
llm = LLM(config)
# Verify max_output_tokens is set to 64000 for Claude 3.7 Sonnet
assert llm.config.max_output_tokens == 64000
# Verify max_input_tokens is set to None (default value)
assert llm.config.max_input_tokens is None
def test_claude_sonnet_4_max_output_tokens():
"""Test that Claude Sonnet 4 models get the correct max_output_tokens and max_input_tokens values."""
# Create LLM instance with a Claude Sonnet 4 model
config = LLMConfig(model='claude-sonnet-4-20250514', api_key='test_key')
llm = LLM(config)
# Verify max_output_tokens is set to the expected value
assert llm.config.max_output_tokens == 64000
# Verify max_input_tokens is set to the expected value
# For Claude models, we expect a specific value from litellm
assert llm.config.max_input_tokens == 200000
def test_sambanova_deepseek_model_max_output_tokens():
"""Test that SambaNova DeepSeek-V3-0324 model gets the correct max_output_tokens value."""
# Create LLM instance with SambaNova DeepSeek model
config = LLMConfig(model='sambanova/DeepSeek-V3-0324', api_key='test_key')
llm = LLM(config)
# SambaNova DeepSeek model has specific token limits
# This is the expected value from litellm
assert llm.config.max_output_tokens == 32768
def test_max_output_tokens_override_in_config():
"""Test that max_output_tokens can be overridden in the config."""
# Create LLM instance with minimal config and overridden max_output_tokens
config = LLMConfig(
model='claude-sonnet-4-20250514', api_key='test_key', max_output_tokens=2048
)
llm = LLM(config)
# Verify the config has the overridden max_output_tokens value
assert llm.config.max_output_tokens == 2048
def test_azure_model_default_max_tokens():
"""Test that Azure models have the default max_output_tokens value."""
# Create minimal config for Azure model (without specifying max_output_tokens)
azure_config = LLMConfig(
model='azure/non-existent-model', # Use a non-existent model to avoid litellm having model info for it
api_key='test_key',
base_url='https://test.openai.azure.com/',
api_version='2024-12-01-preview',
)
# Create LLM instance with Azure model
llm = LLM(azure_config)
# Verify the config has the default max_output_tokens value
assert llm.config.max_output_tokens is None # Default value
-73
View File
@@ -1,73 +0,0 @@
"""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()