mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70ad153c2c | |||
| bded599449 | |||
| 0bb43193d0 | |||
| 72b1aa6154 | |||
| db5f7a5744 | |||
| fbe253f9e9 | |||
| f70f07e19a | |||
| c9a2dec194 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 user’s
|
||||
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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
-234
@@ -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 () => {
|
||||
|
||||
Generated
+835
-1078
File diff suppressed because it is too large
Load Diff
@@ -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"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ExitReason(Enum):
|
||||
INTENTIONAL = 'intentional'
|
||||
INTERRUPTED = 'interrupted'
|
||||
ERROR = 'error'
|
||||
+22
-19
@@ -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
|
||||
|
||||
@@ -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)}'
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user