diff --git a/.github/scripts/check_version_consistency.py b/.github/scripts/check_version_consistency.py
new file mode 100755
index 000000000000..daf78a8d2c1a
--- /dev/null
+++ b/.github/scripts/check_version_consistency.py
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+import os
+import re
+import sys
+from typing import Set, Tuple
+
+
+def find_version_references(directory: str) -> Tuple[Set[str], Set[str]]:
+ openhands_versions = set()
+ runtime_versions = set()
+
+ version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
+ version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
+
+ for root, _, files in os.walk(directory):
+ # Skip .git directory
+ if '.git' in root:
+ continue
+
+ for file in files:
+ if file.endswith(
+ ('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
+ ):
+ file_path = os.path.join(root, file)
+ try:
+ with open(file_path, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Find all openhands version references
+ matches = version_pattern_openhands.findall(content)
+ openhands_versions.update(matches)
+
+ # Find all runtime version references
+ matches = version_pattern_runtime.findall(content)
+ runtime_versions.update(matches)
+ except Exception as e:
+ print(f'Error reading {file_path}: {e}', file=sys.stderr)
+
+ return openhands_versions, runtime_versions
+
+
+def main():
+ repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+ openhands_versions, runtime_versions = find_version_references(repo_root)
+
+ exit_code = 0
+
+ if len(openhands_versions) > 1:
+ print('Error: Multiple openhands versions found:', file=sys.stderr)
+ print('Found versions:', sorted(openhands_versions), file=sys.stderr)
+ exit_code = 1
+ elif len(openhands_versions) == 0:
+ print('Warning: No openhands version references found', file=sys.stderr)
+
+ if len(runtime_versions) > 1:
+ print('Error: Multiple runtime versions found:', file=sys.stderr)
+ print('Found versions:', sorted(runtime_versions), file=sys.stderr)
+ exit_code = 1
+ elif len(runtime_versions) == 0:
+ print('Warning: No runtime version references found', file=sys.stderr)
+
+ sys.exit(exit_code)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 1bfc8c91c6a4..789a938e1d7e 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -53,3 +53,16 @@ jobs:
run: pip install pre-commit==3.7.0
- name: Run pre-commit hooks
run: pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
+
+ # Check version consistency across documentation
+ check-version-consistency:
+ name: Check version consistency
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up python
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.12
+ - name: Run version consistency check
+ run: .github/scripts/check_version_consistency.py
diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml
index 2db2907eba9a..028316ee05d5 100644
--- a/.github/workflows/openhands-resolver.yml
+++ b/.github/workflows/openhands-resolver.yml
@@ -185,12 +185,17 @@ jobs:
- name: Install OpenHands
uses: actions/github-script@v7
+ env:
+ COMMENT_BODY: ${{ github.event.comment.body || '' }}
+ REVIEW_BODY: ${{ github.event.review.body || '' }}
+ LABEL_NAME: ${{ github.event.label.name || '' }}
+ EVENT_NAME: ${{ github.event_name }}
with:
script: |
- const commentBody = `${{ github.event.comment.body || '' }}`.trim();
- const reviewBody = `${{ github.event.review.body || '' }}`.trim();
- const labelName = `${{ github.event.label.name || '' }}`.trim();
- const eventName = `${{ github.event_name }}`.trim();
+ const commentBody = process.env.COMMENT_BODY.trim();
+ const reviewBody = process.env.REVIEW_BODY.trim();
+ const labelName = process.env.LABEL_NAME.trim();
+ const eventName = process.env.EVENT_NAME.trim();
// Check conditions
const isExperimentalLabel = labelName === "fix-me-experimental";
diff --git a/Development.md b/Development.md
index 1eaa3054773b..fbdaac497e91 100644
--- a/Development.md
+++ b/Development.md
@@ -8,7 +8,7 @@ Otherwise, you can clone the OpenHands project directly.
* Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu <= 22.04]
* [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
* [Python](https://www.python.org/downloads/) = 3.12
-* [NodeJS](https://nodejs.org/en/download/package-manager) >= 18.17.1
+* [NodeJS](https://nodejs.org/en/download/package-manager) >= 20.x
* [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
* OS-specific dependencies:
- Ubuntu: build-essential => `sudo apt-get install build-essential`
diff --git a/Makefile b/Makefile
index c4e1949b9621..e1f14c410f43 100644
--- a/Makefile
+++ b/Makefile
@@ -81,10 +81,10 @@ check-nodejs:
@if command -v node > /dev/null; then \
NODE_VERSION=$(shell node --version | sed -E 's/v//g'); \
IFS='.' read -r -a NODE_VERSION_ARRAY <<< "$$NODE_VERSION"; \
- if [ "$${NODE_VERSION_ARRAY[0]}" -gt 18 ] || ([ "$${NODE_VERSION_ARRAY[0]}" -eq 18 ] && [ "$${NODE_VERSION_ARRAY[1]}" -gt 17 ]) || ([ "$${NODE_VERSION_ARRAY[0]}" -eq 18 ] && [ "$${NODE_VERSION_ARRAY[1]}" -eq 17 ] && [ "$${NODE_VERSION_ARRAY[2]}" -ge 1 ]); then \
+ if [ "$${NODE_VERSION_ARRAY[0]}" -ge 20 ]; then \
echo "$(BLUE)Node.js $$NODE_VERSION is already installed.$(RESET)"; \
else \
- echo "$(RED)Node.js 18.17.1 or later is required. Please install Node.js 18.17.1 or later to continue.$(RESET)"; \
+ echo "$(RED)Node.js 20.x or later is required. Please install Node.js 20.x or later to continue.$(RESET)"; \
exit 1; \
fi; \
else \
diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md
index b00df2c1f2a2..9156d7ac46ff 100644
--- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md
+++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
- -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
+ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -61,7 +61,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--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.16 \
+ docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.cli
```
diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md
index a1610bb11562..9d1172770549 100644
--- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md
+++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
- -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
+ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -56,6 +56,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--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.16 \
+ docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```
diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx
index 87b7afb07cd2..ddfef195b661 100644
--- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx
+++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/installation.mdx
@@ -13,16 +13,16 @@
La façon la plus simple d'exécuter OpenHands est avec Docker.
```bash
-docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
+docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
docker run -it --rm --pull=always \
- -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
+ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
- docker.all-hands.dev/all-hands-ai/openhands:0.16
+ docker.all-hands.dev/all-hands-ai/openhands:0.17
```
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
diff --git a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md
index d12524fc012c..67d054c4791f 100644
--- a/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md
+++ b/docs/i18n/fr/docusaurus-plugin-content-docs/current/usage/runtimes.md
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
```
docker run # ...
- -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
+ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md
index cf0bbdd10dd6..e6760ee2d63b 100644
--- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md
+++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/cli-mode.md
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
- -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
+ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -59,7 +59,7 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--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.16 \
+ docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.cli
```
diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md
index 6a95fa0fe5a5..c38831e4a462 100644
--- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md
+++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/how-to/headless-mode.md
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
```bash
docker run -it \
--pull=always \
- -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
+ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -57,6 +57,6 @@ docker run -it \
-v /var/run/docker.sock:/var/run/docker.sock \
--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.16 \
+ docker.all-hands.dev/all-hands-ai/openhands:0.17 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```
diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx
index 432eaeeff0e4..6de97bfc3bc5 100644
--- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx
+++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/installation.mdx
@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
-docker pull docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik
+docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
docker run -it --rm --pull=always \
- -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
+ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
- docker.all-hands.dev/all-hands-ai/openhands:0.16
+ docker.all-hands.dev/all-hands-ai/openhands:0.17
```
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
diff --git a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md
index d93207442a07..c6a7fc29053c 100644
--- a/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md
+++ b/docs/i18n/zh-Hans/docusaurus-plugin-content-docs/current/usage/runtimes.md
@@ -11,7 +11,7 @@
```
docker run # ...
- -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.16-nikolaik \
+ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
diff --git a/docs/modules/usage/how-to/headless-mode.md b/docs/modules/usage/how-to/headless-mode.md
index dd5cee783f0a..2d085d813c3c 100644
--- a/docs/modules/usage/how-to/headless-mode.md
+++ b/docs/modules/usage/how-to/headless-mode.md
@@ -12,7 +12,7 @@ To run OpenHands in headless mode with Python,
and then run:
```bash
-poetry run python -m openhands.core.main -t "write a bash script that prints hi"
+poetry run python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```
You'll need to be sure to set your model, API key, and other settings via environment variables
diff --git a/evaluation/benchmarks/EDA/run_infer.py b/evaluation/benchmarks/EDA/run_infer.py
index c866b5090bdd..e8cee3df3e20 100644
--- a/evaluation/benchmarks/EDA/run_infer.py
+++ b/evaluation/benchmarks/EDA/run_infer.py
@@ -63,7 +63,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/agent_bench/run_infer.py b/evaluation/benchmarks/agent_bench/run_infer.py
index f008c9dc8a8a..a64c66f22cdc 100644
--- a/evaluation/benchmarks/agent_bench/run_infer.py
+++ b/evaluation/benchmarks/agent_bench/run_infer.py
@@ -43,7 +43,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime=os.environ.get('RUNTIME', 'eventstream'),
+ runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-slim',
diff --git a/evaluation/benchmarks/aider_bench/run_infer.py b/evaluation/benchmarks/aider_bench/run_infer.py
index e059a6b46f6a..bc850dbc6261 100644
--- a/evaluation/benchmarks/aider_bench/run_infer.py
+++ b/evaluation/benchmarks/aider_bench/run_infer.py
@@ -50,7 +50,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime=os.environ.get('RUNTIME', 'eventstream'),
+ runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.11-bookworm',
diff --git a/evaluation/benchmarks/biocoder/run_infer.py b/evaluation/benchmarks/biocoder/run_infer.py
index 2da7b09f0fcf..c33c75e5a221 100644
--- a/evaluation/benchmarks/biocoder/run_infer.py
+++ b/evaluation/benchmarks/biocoder/run_infer.py
@@ -61,7 +61,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image=BIOCODER_BENCH_CONTAINER_IMAGE,
diff --git a/evaluation/benchmarks/bird/run_infer.py b/evaluation/benchmarks/bird/run_infer.py
index d35084fdbc82..14946ebacb2f 100644
--- a/evaluation/benchmarks/bird/run_infer.py
+++ b/evaluation/benchmarks/bird/run_infer.py
@@ -74,7 +74,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/browsing_delegation/run_infer.py b/evaluation/benchmarks/browsing_delegation/run_infer.py
index 38fb6cae25ce..016b6c3f582e 100644
--- a/evaluation/benchmarks/browsing_delegation/run_infer.py
+++ b/evaluation/benchmarks/browsing_delegation/run_infer.py
@@ -39,7 +39,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/commit0_bench/run_infer.py b/evaluation/benchmarks/commit0_bench/run_infer.py
index 1ef347931feb..d8f1f64b1a6b 100644
--- a/evaluation/benchmarks/commit0_bench/run_infer.py
+++ b/evaluation/benchmarks/commit0_bench/run_infer.py
@@ -124,7 +124,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
- runtime=os.environ.get('RUNTIME', 'eventstream'),
+ runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
diff --git a/evaluation/benchmarks/discoverybench/run_infer.py b/evaluation/benchmarks/discoverybench/run_infer.py
index 55e958d9fd9c..0d5b47410c2d 100644
--- a/evaluation/benchmarks/discoverybench/run_infer.py
+++ b/evaluation/benchmarks/discoverybench/run_infer.py
@@ -65,7 +65,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/gaia/run_infer.py b/evaluation/benchmarks/gaia/run_infer.py
index 99c29b211dc4..8aaa479e92be 100644
--- a/evaluation/benchmarks/gaia/run_infer.py
+++ b/evaluation/benchmarks/gaia/run_infer.py
@@ -50,7 +50,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/gorilla/run_infer.py b/evaluation/benchmarks/gorilla/run_infer.py
index 64263242d751..e453b1f570ba 100644
--- a/evaluation/benchmarks/gorilla/run_infer.py
+++ b/evaluation/benchmarks/gorilla/run_infer.py
@@ -43,7 +43,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/gpqa/run_infer.py b/evaluation/benchmarks/gpqa/run_infer.py
index d9e1caec7768..08e66827924e 100644
--- a/evaluation/benchmarks/gpqa/run_infer.py
+++ b/evaluation/benchmarks/gpqa/run_infer.py
@@ -64,7 +64,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/humanevalfix/run_infer.py b/evaluation/benchmarks/humanevalfix/run_infer.py
index 3b5a5bca2ff8..b2fb6d677a9c 100644
--- a/evaluation/benchmarks/humanevalfix/run_infer.py
+++ b/evaluation/benchmarks/humanevalfix/run_infer.py
@@ -85,7 +85,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/logic_reasoning/run_infer.py b/evaluation/benchmarks/logic_reasoning/run_infer.py
index 0a1447f06171..d84c5f8ca8cb 100644
--- a/evaluation/benchmarks/logic_reasoning/run_infer.py
+++ b/evaluation/benchmarks/logic_reasoning/run_infer.py
@@ -48,7 +48,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-logic-reasoning:v1.0',
diff --git a/evaluation/benchmarks/miniwob/run_infer.py b/evaluation/benchmarks/miniwob/run_infer.py
index dd93fbaf0a75..acc1431c81f1 100644
--- a/evaluation/benchmarks/miniwob/run_infer.py
+++ b/evaluation/benchmarks/miniwob/run_infer.py
@@ -58,7 +58,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime=os.environ.get('RUNTIME', 'eventstream'),
+ runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
diff --git a/evaluation/benchmarks/mint/run_infer.py b/evaluation/benchmarks/mint/run_infer.py
index 7106f4a59d86..a98fa8d91805 100644
--- a/evaluation/benchmarks/mint/run_infer.py
+++ b/evaluation/benchmarks/mint/run_infer.py
@@ -106,7 +106,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='xingyaoww/od-eval-mint:v1.0',
diff --git a/evaluation/benchmarks/ml_bench/run_infer.py b/evaluation/benchmarks/ml_bench/run_infer.py
index ab94b925ab14..1c084fc14916 100644
--- a/evaluation/benchmarks/ml_bench/run_infer.py
+++ b/evaluation/benchmarks/ml_bench/run_infer.py
@@ -80,7 +80,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='public.ecr.aws/i5g0m1f6/ml-bench',
diff --git a/evaluation/benchmarks/scienceagentbench/run_infer.py b/evaluation/benchmarks/scienceagentbench/run_infer.py
index db4abf0f4828..ebe1b783cfed 100644
--- a/evaluation/benchmarks/scienceagentbench/run_infer.py
+++ b/evaluation/benchmarks/scienceagentbench/run_infer.py
@@ -62,7 +62,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime=os.environ.get('RUNTIME', 'eventstream'),
+ runtime=os.environ.get('RUNTIME', 'docker'),
max_budget_per_task=4,
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
diff --git a/evaluation/benchmarks/swe_bench/eval_infer.py b/evaluation/benchmarks/swe_bench/eval_infer.py
index 95f65245f22f..c5d479dd50d5 100644
--- a/evaluation/benchmarks/swe_bench/eval_infer.py
+++ b/evaluation/benchmarks/swe_bench/eval_infer.py
@@ -76,7 +76,7 @@ def get_config(instance: pd.Series) -> AppConfig:
)
config = AppConfig(
run_as_openhands=False,
- runtime=os.environ.get('RUNTIME', 'eventstream'),
+ runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
use_host_network=False,
diff --git a/evaluation/benchmarks/swe_bench/run_infer.py b/evaluation/benchmarks/swe_bench/run_infer.py
index be4761da13e7..61c045037bbb 100644
--- a/evaluation/benchmarks/swe_bench/run_infer.py
+++ b/evaluation/benchmarks/swe_bench/run_infer.py
@@ -121,7 +121,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
- runtime=os.environ.get('RUNTIME', 'eventstream'),
+ runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
diff --git a/evaluation/benchmarks/the_agent_company/scripts/summarise_results.py b/evaluation/benchmarks/the_agent_company/scripts/summarise_results.py
new file mode 100644
index 000000000000..e30ae9de3cc9
--- /dev/null
+++ b/evaluation/benchmarks/the_agent_company/scripts/summarise_results.py
@@ -0,0 +1,316 @@
+###########################################################################################################
+# Adapted from https://github.com/TheAgentCompany/TheAgentCompany/blob/main/evaluation/summarise_results.py
+###########################################################################################################
+
+
+import glob
+import json
+import os
+import re
+import sys
+from typing import Dict, Tuple
+
+
+def calculate_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
+ """
+ Calculate the cost of the model call.
+ """
+ if 'claude-3-5-sonnet' in model.lower():
+ # https://www.anthropic.com/pricing#anthropic-api, accessed 12/11/2024
+ return 0.000003 * prompt_tokens + 0.000015 * completion_tokens
+ elif 'gpt-4o' in model.lower():
+ # https://openai.com/api/pricing/, accessed 12/11/2024
+ return 0.0000025 * prompt_tokens + 0.00001 * completion_tokens
+ elif 'gemini-1.5-pro' in model.lower():
+ # https://ai.google.dev/pricing#1_5pro, accessed 12/11/2024
+ # assuming prompts up to 128k tokens
+ cost = 0.00000125 * prompt_tokens + 0.000005 * completion_tokens
+ if prompt_tokens > 128000:
+ cost *= 2
+ return cost
+ elif 'gemini-2.0-flash-exp' in model.lower():
+ # price unknown for gemini-2.0-flash-exp, assuming same price as gemini-1.5-flash
+ cost = 0.000000075 * prompt_tokens + 0.0000003 * completion_tokens
+ if prompt_tokens > 128000:
+ cost *= 2
+ return cost
+ elif 'qwen2-72b' in model.lower():
+ # assuming hosted on Together
+ # https://www.together.ai/pricing, accessed 12/11/2024
+ return 0.0000009 * (prompt_tokens + completion_tokens)
+ elif 'qwen2p5-72b' in model.lower():
+ # assuming hosted on Together
+ # https://www.together.ai/pricing, accessed 12/14/2024
+ return 0.0000012 * (prompt_tokens + completion_tokens)
+ elif 'llama-v3p1-405b-instruct' in model.lower():
+ # assuming hosted on Fireworks AI
+ # https://fireworks.ai/pricing, accessed 12/11/2024
+ return 0.000003 * (prompt_tokens + completion_tokens)
+ elif 'llama-v3p1-70b-instruct' in model.lower():
+ # assuming hosted on Fireworks AI
+ return 0.0000009 * (prompt_tokens + completion_tokens)
+ elif 'llama-v3p3-70b-instruct' in model.lower():
+ # assuming hosted on Fireworks AI
+ return 0.0000009 * (prompt_tokens + completion_tokens)
+ elif 'amazon.nova-pro-v1:0' in model.lower():
+ # assuming hosted on Amazon Bedrock
+ # https://aws.amazon.com/bedrock/pricing/, accessed 12/11/2024
+ return 0.0000008 * prompt_tokens + 0.0000032 * completion_tokens
+ else:
+ raise ValueError(f'Unknown model: {model}')
+
+
+def analyze_eval_json_file(filepath: str) -> Tuple[int, int]:
+ """
+ Analyze a single eval JSON file and extract the total and result from final_score.
+
+ Args:
+ filepath: Path to the JSON file
+
+ Returns:
+ Tuple containing (total, result) from final_score
+ """
+ try:
+ with open(filepath, 'r') as f:
+ data = json.load(f)
+
+ final_score = data.get('final_score', {})
+ return (final_score.get('total', 0), final_score.get('result', 0))
+ except json.JSONDecodeError as e:
+ print(f'Error decoding JSON in {filepath}: {e}')
+ return (0, 0)
+ except Exception as e:
+ print(f'Error processing {filepath}: {e}')
+ return (0, 0)
+
+
+def analyze_traj_json_file(filepath: str) -> Tuple[int, float]:
+ """
+ Analyze a single trajectory JSON file and extract the steps and tokens
+ for each step. Then estimate the cost based on the tokens and the model type.
+ Note: this is assuming there's no prompt caching at all.
+ """
+ steps: int = 0
+ cost: float = 0.0
+ with open(filepath, 'r') as f:
+ data = json.load(f)
+ response_id = None
+ for action in data:
+ if 'tool_call_metadata' in action:
+ if action['tool_call_metadata']['model_response']['id'] != response_id:
+ response_id = action['tool_call_metadata']['model_response']['id']
+ else:
+ # openhands displays the same model response meta data multiple times, when
+ # a single LLM call leads to multiple actions and observations.
+ continue
+ steps += 1
+ usage = action['tool_call_metadata']['model_response']['usage']
+ model: str = action['tool_call_metadata']['model_response']['model']
+ prompt_tokens = usage['prompt_tokens']
+ completion_tokens = usage['completion_tokens']
+ cost += calculate_cost(model, prompt_tokens, completion_tokens)
+
+ return (steps, cost)
+
+
+def analyze_folder(
+ folder_path: str,
+) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, Tuple[int, float]]]:
+ """
+ Analyze all eval_*.json & traj_*.json files in the specified folder.
+
+ Args:
+ folder_path: Path to the folder containing JSON files
+
+ Returns:
+ dictionaries:
+ - eval_results: Dictionary with filename as key and (total, result) tuple as value
+ - traj_results: Dictionary with filename as key and (steps, cost) tuple as value
+ """
+ eval_results = {}
+ traj_results = {}
+
+ eval_pattern = os.path.join(folder_path, 'eval_*.json')
+ traj_pattern = os.path.join(folder_path, 'traj_*.json')
+
+ for filepath in glob.glob(eval_pattern):
+ filename = os.path.basename(filepath)
+ total, result = analyze_eval_json_file(filepath)
+ key = re.search(r'eval_(.+)\.json', filename).group(1)
+ eval_results[key] = (total, result)
+
+ for filepath in glob.glob(traj_pattern):
+ filename = os.path.basename(filepath)
+ steps, cost = analyze_traj_json_file(filepath)
+ key = re.search(r'traj_(.+)\.json', filename).group(1)
+ traj_results[key] = (steps, cost)
+
+ return eval_results, traj_results
+
+
+def get_task_nature_category(task_name: str) -> str:
+ """
+ Get the nature category of the task.
+ """
+ task_nature = task_name.split('-')[0]
+ if task_nature.lower() in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance']:
+ return task_nature
+ else:
+ return 'other'
+
+
+def calculate_score(total: int, result: int) -> float:
+ """
+ Calculate the score as a number between 0 and 1.
+
+ Formula: score = (result / total) * 0.5 + (result // total) * 0.5
+ Explanation:
+ - (result / total) * 0.5: This is the completion ratio, scaled down to a 0-0.5 range.
+ - (result // total) * 0.5: This is a binary score indicating whether the task was completed or not.
+
+ Args:
+ total: Total possible points
+ result: Actual points achieved
+
+ Returns:
+ Score as a number between 0 and 1
+ """
+ return (result / total * 0.5) + (result // total * 0.5)
+
+
+def is_perfect_completion(total: int, result: int) -> bool:
+ """
+ Check if the task achieved perfect completion.
+
+ Args:
+ total: Total possible points
+ result: Actual points achieved
+
+ Returns:
+ True if result equals total, False otherwise
+ """
+ return total > 0 and total == result
+
+
+def main():
+ if len(sys.argv) != 2:
+ print('Usage: poetry run python summarise_results.py ')
+ sys.exit(1)
+
+ folder_path = sys.argv[1]
+
+ if not os.path.isdir(folder_path):
+ print(f"Error: '{folder_path}' is not a valid directory")
+ sys.exit(1)
+
+ eval_results, traj_results = analyze_folder(folder_path)
+
+ if not eval_results:
+ print(f'No eval_*.json files found in {folder_path}')
+ return
+
+ # Create list of results with completion ratios for sorting
+ detailed_results = [
+ (
+ task_name,
+ total,
+ result,
+ calculate_score(total, result),
+ is_perfect_completion(total, result),
+ get_task_nature_category(task_name),
+ )
+ for task_name, (total, result) in eval_results.items()
+ ]
+
+ # Sort by score in descending order
+ detailed_results.sort(key=lambda x: (-x[3], x[0]))
+
+ # Calculate perfect completion stats
+ perfect_completions = sum(
+ 1 for _, _, _, _, is_perfect, _ in detailed_results if is_perfect
+ )
+
+ # Print header
+ print('\n# Evaluation Results Report')
+ print('\n## Results per File')
+ print('\n*Sorted by score (⭐ indicates perfect completion)*\n')
+
+ # Print table header
+ print(
+ '| Filename | Total | Result | Score | Steps | Cost (assuming no prompt caching)|'
+ )
+ print('|----------|--------|---------|-------|-------|------|')
+
+ # Print individual file results
+ for task_name, total, result, score, is_perfect, task_nature in detailed_results:
+ perfect_marker = ' ⭐' if is_perfect else ''
+ print(
+ f'| {task_name} | {total:,} | {result:,} | {score:.2f}{perfect_marker} | {traj_results[task_name][0]} | {traj_results[task_name][1]:.2f} |'
+ )
+
+ # Print summary section
+ print('\n## Summary\n')
+ print(f'**Tasks Evaluated:** {len(eval_results)}\n')
+ print(
+ f'**Perfect Completions:** {perfect_completions}/{len(eval_results)} ({(perfect_completions/len(eval_results)*100):.2f}%)\n'
+ )
+
+ overall_score = (
+ sum(score for _, _, _, score, _, _ in detailed_results)
+ / len(detailed_results)
+ * 100
+ )
+ avg_steps = sum(steps for steps, _ in traj_results.values()) / len(traj_results)
+ avg_cost = sum(cost for _, cost in traj_results.values()) / len(traj_results)
+ print(f'**Overall Score:** {overall_score:.2f}%\n')
+ print(f'**Average Steps:** {avg_steps:.2f}\n')
+ print(f'**Average Cost (USD):** {avg_cost:.2f}\n')
+
+ # Additional statistics
+ if detailed_results:
+ highest_score = max(score for _, _, _, score, _, _ in detailed_results)
+ lowest_score = min(score for _, _, _, score, _, _ in detailed_results)
+ median_score = detailed_results[len(detailed_results) // 2][3]
+ avg_score = sum(score for _, _, _, score, _, _ in detailed_results) / len(
+ detailed_results
+ )
+
+ print('\n## Statistics\n')
+ print('| Metric | Value |')
+ print('|---------|--------|')
+ print(f'| Highest Task Score | {highest_score*100:.2f}% |')
+ print(f'| Lowest Task Score | {lowest_score*100:.2f}% |')
+ print(f'| Median Task Score | {median_score*100:.2f}% |')
+ print(f'| Average Task Score | {avg_score*100:.2f}% |')
+
+ # compute avg score per nature category
+ print('\n## Statistics per Nature Category\n')
+ print('| Metric | Value |')
+ print('|---------|--------|')
+ for task_nature in ['sde', 'pm', 'ds', 'admin', 'hr', 'finance', 'other']:
+ num_of_tasks = sum(
+ 1
+ for _, _, _, _, _, nature_category in detailed_results
+ if nature_category == task_nature
+ )
+ task_nature_score = (
+ sum(
+ score
+ for _, _, _, score, _, nature_category in detailed_results
+ if nature_category == task_nature
+ )
+ / num_of_tasks
+ )
+ perfect_completions = sum(
+ 1
+ for _, _, _, _, is_perfect, nature_category in detailed_results
+ if nature_category == task_nature and is_perfect
+ )
+ print(
+ f'| Perfect Completions for {task_nature} | {perfect_completions}/{num_of_tasks} ({perfect_completions/num_of_tasks*100:.2f}%) |'
+ )
+ print(f'| Average Score for {task_nature} | {task_nature_score*100:.2f}% |')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/evaluation/benchmarks/toolqa/run_infer.py b/evaluation/benchmarks/toolqa/run_infer.py
index f88163a048f5..6f6f1a0e2048 100644
--- a/evaluation/benchmarks/toolqa/run_infer.py
+++ b/evaluation/benchmarks/toolqa/run_infer.py
@@ -44,7 +44,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/benchmarks/webarena/run_infer.py b/evaluation/benchmarks/webarena/run_infer.py
index d18918cf969f..ac51a201a712 100644
--- a/evaluation/benchmarks/webarena/run_infer.py
+++ b/evaluation/benchmarks/webarena/run_infer.py
@@ -53,7 +53,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime='eventstream',
+ runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
diff --git a/evaluation/integration_tests/run_infer.py b/evaluation/integration_tests/run_infer.py
index 2da68b9b82b9..fe85d23bf585 100644
--- a/evaluation/integration_tests/run_infer.py
+++ b/evaluation/integration_tests/run_infer.py
@@ -42,7 +42,7 @@ def get_config(
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
- runtime=os.environ.get('RUNTIME', 'eventstream'),
+ runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
# use default base_container_image
diff --git a/frontend/__tests__/api/github.test.ts b/frontend/__tests__/api/github.test.ts
new file mode 100644
index 000000000000..5a659d4b71e6
--- /dev/null
+++ b/frontend/__tests__/api/github.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it, vi } from "vitest";
+import { retrieveLatestGitHubCommit } from "../../src/api/github";
+
+describe("retrieveLatestGitHubCommit", () => {
+ const { githubGetMock } = vi.hoisted(() => ({
+ githubGetMock: vi.fn(),
+ }));
+
+ vi.mock("../../src/api/github-axios-instance", () => ({
+ github: {
+ get: githubGetMock,
+ },
+ }));
+
+ it("should return the latest commit when repository has commits", async () => {
+ const mockCommit = {
+ sha: "123abc",
+ commit: {
+ message: "Initial commit",
+ },
+ };
+
+ githubGetMock.mockResolvedValueOnce({
+ data: [mockCommit],
+ });
+
+ const result = await retrieveLatestGitHubCommit("user/repo");
+ expect(result).toEqual(mockCommit);
+ });
+
+ it("should return null when repository is empty", async () => {
+ const error = new Error("Repository is empty");
+ (error as any).response = { status: 409 };
+ githubGetMock.mockRejectedValueOnce(error);
+
+ const result = await retrieveLatestGitHubCommit("user/empty-repo");
+ expect(result).toBeNull();
+ });
+
+ it("should throw error for other error cases", async () => {
+ const error = new Error("Network error");
+ (error as any).response = { status: 500 };
+ githubGetMock.mockRejectedValueOnce(error);
+
+ await expect(retrieveLatestGitHubCommit("user/repo")).rejects.toThrow();
+ });
+});
diff --git a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx b/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx
index ad5b6a0a3443..89780e07aef7 100644
--- a/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx
+++ b/frontend/__tests__/components/context-menu/account-settings-context-menu.test.tsx
@@ -28,8 +28,8 @@ describe("AccountSettingsContextMenu", () => {
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
- expect(screen.getByText("Account Settings")).toBeInTheDocument();
- expect(screen.getByText("Logout")).toBeInTheDocument();
+ expect(screen.getByText("ACCOUNT_SETTINGS$SETTINGS")).toBeInTheDocument();
+ expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
});
it("should call onClickAccountSettings when the account settings option is clicked", async () => {
@@ -42,7 +42,7 @@ describe("AccountSettingsContextMenu", () => {
/>,
);
- const accountSettingsOption = screen.getByText("Account Settings");
+ const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
await user.click(accountSettingsOption);
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
@@ -58,7 +58,7 @@ describe("AccountSettingsContextMenu", () => {
/>,
);
- const logoutOption = screen.getByText("Logout");
+ const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
@@ -74,7 +74,7 @@ describe("AccountSettingsContextMenu", () => {
/>,
);
- const logoutOption = screen.getByText("Logout");
+ const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).not.toHaveBeenCalled();
@@ -90,7 +90,7 @@ describe("AccountSettingsContextMenu", () => {
/>,
);
- const accountSettingsButton = screen.getByText("Account Settings");
+ const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
await user.click(accountSettingsButton);
await user.click(document.body);
diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx
new file mode 100644
index 000000000000..749bc6c48de0
--- /dev/null
+++ b/frontend/__tests__/components/features/conversation-panel/conversation-card.test.tsx
@@ -0,0 +1,274 @@
+import { render, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, it, test, vi } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { formatTimeDelta } from "#/utils/format-time-delta";
+import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
+
+describe("ConversationCard", () => {
+ const onClick = vi.fn();
+ const onDelete = vi.fn();
+ const onChangeTitle = vi.fn();
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render the conversation card", () => {
+ render(
+ ,
+ );
+ const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
+
+ const card = screen.getByTestId("conversation-card");
+ const title = within(card).getByTestId("conversation-card-title");
+
+ expect(title).toHaveValue("Conversation 1");
+ within(card).getByText(expectedDate);
+ });
+
+ it("should render the repo if available", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(
+ screen.queryByTestId("conversation-card-repo"),
+ ).not.toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+
+ screen.getByTestId("conversation-card-repo");
+ });
+
+ it("should call onClick when the card is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const card = screen.getByTestId("conversation-card");
+ await user.click(card);
+
+ expect(onClick).toHaveBeenCalled();
+ });
+
+ it("should toggle a context menu when clicking the ellipsis button", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
+
+ const ellipsisButton = screen.getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ screen.getByTestId("context-menu");
+
+ await user.click(ellipsisButton);
+
+ expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
+ });
+
+ it("should call onDelete when the delete button is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const ellipsisButton = screen.getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const menu = screen.getByTestId("context-menu");
+ const deleteButton = within(menu).getByTestId("delete-button");
+
+ await user.click(deleteButton);
+
+ expect(onDelete).toHaveBeenCalled();
+ });
+
+ test("clicking the repo should not trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const repo = screen.getByTestId("conversation-card-repo");
+ await user.click(repo);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ test("conversation title should call onChangeTitle when changed and blurred", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const title = screen.getByTestId("conversation-card-title");
+
+ await user.clear(title);
+ await user.type(title, "New Conversation Name ");
+ await user.tab();
+
+ expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
+ expect(title).toHaveValue("New Conversation Name");
+ });
+
+ it("should reset title and not call onChangeTitle when the title is empty", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const title = screen.getByTestId("conversation-card-title");
+
+ await user.clear(title);
+ await user.tab();
+
+ expect(onChangeTitle).not.toHaveBeenCalled();
+ expect(title).toHaveValue("Conversation 1");
+ });
+
+ test("clicking the title should not trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const title = screen.getByTestId("conversation-card-title");
+ await user.click(title);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ test("clicking the delete button should not trigger the onClick handler", async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ const ellipsisButton = screen.getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+
+ const menu = screen.getByTestId("context-menu");
+ const deleteButton = within(menu).getByTestId("delete-button");
+
+ await user.click(deleteButton);
+
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ describe("state indicator", () => {
+ it("should render the 'cold' indicator by default", () => {
+ render(
+ ,
+ );
+
+ screen.getByTestId("cold-indicator");
+ });
+
+ it("should render the other indicators when provided", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
+ screen.getByTestId("warm-indicator");
+ });
+ });
+});
diff --git a/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx
new file mode 100644
index 000000000000..5a1d703b22ce
--- /dev/null
+++ b/frontend/__tests__/components/features/conversation-panel/conversation-panel.test.tsx
@@ -0,0 +1,267 @@
+import { render, screen, within } from "@testing-library/react";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ QueryClientProvider,
+ QueryClient,
+ QueryClientConfig,
+} from "@tanstack/react-query";
+import userEvent from "@testing-library/user-event";
+import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
+import OpenHands from "#/api/open-hands";
+import { AuthProvider } from "#/context/auth-context";
+
+describe("ConversationPanel", () => {
+ const onCloseMock = vi.fn();
+
+ const renderConversationPanel = (config?: QueryClientConfig) =>
+ render(, {
+ wrapper: ({ children }) => (
+
+
+ {children}
+
+
+ ),
+ });
+
+ const { endSessionMock } = vi.hoisted(() => ({
+ endSessionMock: vi.fn(),
+ }));
+
+ beforeAll(() => {
+ vi.mock("react-router", async (importOriginal) => ({
+ ...(await importOriginal()),
+ Link: ({ children }: React.PropsWithChildren) => children,
+ useNavigate: vi.fn(() => vi.fn()),
+ useLocation: vi.fn(() => ({ pathname: "/conversation" })),
+ useParams: vi.fn(() => ({ conversationId: "2" })),
+ }));
+
+ vi.mock("#/hooks/use-end-session", async (importOriginal) => ({
+ ...(await importOriginal()),
+ useEndSession: vi.fn(() => endSessionMock),
+ }));
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+ });
+
+ it("should render the conversations", async () => {
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+
+ expect(cards).toHaveLength(3);
+ });
+
+ it("should display an empty state when there are no conversations", async () => {
+ const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
+ getUserConversationsSpy.mockResolvedValue([]);
+
+ renderConversationPanel();
+
+ const emptyState = await screen.findByText("No conversations found");
+ expect(emptyState).toBeInTheDocument();
+ });
+
+ it("should handle an error when fetching conversations", async () => {
+ const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
+ getUserConversationsSpy.mockRejectedValue(
+ new Error("Failed to fetch conversations"),
+ );
+
+ renderConversationPanel({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ const error = await screen.findByText("Failed to fetch conversations");
+ expect(error).toBeInTheDocument();
+ });
+
+ it("should cancel deleting a conversation", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ expect(
+ within(cards[0]).queryByTestId("delete-button"),
+ ).not.toBeInTheDocument();
+
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+
+ // Click the first delete button
+ await user.click(deleteButton);
+
+ // Cancel the deletion
+ const cancelButton = screen.getByText("Cancel");
+ await user.click(cancelButton);
+
+ expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
+
+ // Ensure the conversation is not deleted
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(3);
+ });
+
+ it("should call endSession after deleting a conversation that is the current session", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ const ellipsisButton = within(cards[1]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+
+ // Click the second delete button
+ await user.click(deleteButton);
+
+ // Confirm the deletion
+ const confirmButton = screen.getByText("Confirm");
+ await user.click(confirmButton);
+
+ expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
+
+ // Ensure the conversation is deleted
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(2);
+
+ expect(endSessionMock).toHaveBeenCalledOnce();
+ });
+
+ it("should delete a conversation", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ let cards = await screen.findAllByTestId("conversation-card");
+ const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
+ await user.click(ellipsisButton);
+ const deleteButton = screen.getByTestId("delete-button");
+
+ // Click the first delete button
+ await user.click(deleteButton);
+
+ // Confirm the deletion
+ const confirmButton = screen.getByText("Confirm");
+ await user.click(confirmButton);
+
+ expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
+
+ // Ensure the conversation is deleted
+ cards = await screen.findAllByTestId("conversation-card");
+ expect(cards).toHaveLength(1);
+ });
+
+ it("should rename a conversation", async () => {
+ const updateUserConversationSpy = vi.spyOn(
+ OpenHands,
+ "updateUserConversation",
+ );
+
+ const user = userEvent.setup();
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ const title = within(cards[0]).getByTestId("conversation-card-title");
+
+ await user.clear(title);
+ await user.type(title, "Conversation 1 Renamed");
+ await user.tab();
+
+ // Ensure the conversation is renamed
+ expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
+ name: "Conversation 1 Renamed",
+ });
+ });
+
+ it("should not rename a conversation when the name is unchanged", async () => {
+ const updateUserConversationSpy = vi.spyOn(
+ OpenHands,
+ "updateUserConversation",
+ );
+
+ const user = userEvent.setup();
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ const title = within(cards[0]).getByTestId("conversation-card-title");
+
+ await user.click(title);
+ await user.tab();
+
+ // Ensure the conversation is not renamed
+ expect(updateUserConversationSpy).not.toHaveBeenCalled();
+
+ await user.type(title, "Conversation 1");
+ await user.click(title);
+ await user.tab();
+
+ expect(updateUserConversationSpy).toHaveBeenCalledTimes(1);
+
+ await user.click(title);
+ await user.tab();
+
+ expect(updateUserConversationSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call onClose after clicking a card", async () => {
+ renderConversationPanel();
+ const cards = await screen.findAllByTestId("conversation-card");
+ const firstCard = cards[0];
+
+ await userEvent.click(firstCard);
+
+ expect(onCloseMock).toHaveBeenCalledOnce();
+ });
+
+ describe("New Conversation Button", () => {
+ it("should display a confirmation modal when clicking", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ expect(
+ screen.queryByTestId("confirm-new-conversation-modal"),
+ ).not.toBeInTheDocument();
+
+ const newProjectButton = screen.getByTestId("new-conversation-button");
+ await user.click(newProjectButton);
+
+ const modal = screen.getByTestId("confirm-new-conversation-modal");
+ expect(modal).toBeInTheDocument();
+ });
+
+ it("should call endSession and close panel after confirming", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const newProjectButton = screen.getByTestId("new-conversation-button");
+ await user.click(newProjectButton);
+
+ const confirmButton = screen.getByText("Confirm");
+ await user.click(confirmButton);
+
+ expect(endSessionMock).toHaveBeenCalledOnce();
+ expect(onCloseMock).toHaveBeenCalledOnce();
+ });
+
+ it("should close the modal when cancelling", async () => {
+ const user = userEvent.setup();
+ renderConversationPanel();
+
+ const newProjectButton = screen.getByTestId("new-conversation-button");
+ await user.click(newProjectButton);
+
+ const cancelButton = screen.getByText("Cancel");
+ await user.click(cancelButton);
+
+ expect(endSessionMock).not.toHaveBeenCalled();
+ expect(
+ screen.queryByTestId("confirm-new-conversation-modal"),
+ ).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx
new file mode 100644
index 000000000000..40d0ea4a48bc
--- /dev/null
+++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx
@@ -0,0 +1,46 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { createRoutesStub } from "react-router";
+import { Sidebar } from "#/components/features/sidebar/sidebar";
+import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
+
+const renderSidebar = () => {
+ const RouterStub = createRoutesStub([
+ {
+ path: "/conversation/:conversationId",
+ Component: Sidebar,
+ },
+ ]);
+
+ renderWithProviders();
+};
+
+describe("Sidebar", () => {
+ it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
+ "should have the conversation panel open by default",
+ () => {
+ renderSidebar();
+ expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
+ },
+ );
+
+ it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
+ "should toggle the conversation panel",
+ async () => {
+ const user = userEvent.setup();
+ renderSidebar();
+
+ const projectPanelButton = screen.getByTestId(
+ "toggle-conversation-panel",
+ );
+
+ await user.click(projectPanelButton);
+
+ expect(
+ screen.queryByTestId("conversation-panel"),
+ ).not.toBeInTheDocument();
+ },
+ );
+});
diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx
index a83b88a38923..143af7d7113f 100644
--- a/frontend/__tests__/components/user-actions.test.tsx
+++ b/frontend/__tests__/components/user-actions.test.tsx
@@ -58,7 +58,7 @@ describe("UserActions", () => {
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
- const accountSettingsOption = screen.getByText("Account Settings");
+ const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
await user.click(accountSettingsOption);
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
@@ -79,7 +79,7 @@ describe("UserActions", () => {
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
- const logoutOption = screen.getByText("Logout");
+ const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
@@ -99,7 +99,7 @@ describe("UserActions", () => {
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
- const logoutOption = screen.getByText("Logout");
+ const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
expect(onLogoutMock).not.toHaveBeenCalled();
diff --git a/frontend/__tests__/routes/_oh.app.test.tsx b/frontend/__tests__/routes/_oh.app.test.tsx
new file mode 100644
index 000000000000..2addbc5fe604
--- /dev/null
+++ b/frontend/__tests__/routes/_oh.app.test.tsx
@@ -0,0 +1,83 @@
+import { createRoutesStub } from "react-router";
+import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { renderWithProviders } from "test-utils";
+import { screen, waitFor } from "@testing-library/react";
+import toast from "react-hot-toast";
+import App from "#/routes/_oh.app/route";
+import OpenHands from "#/api/open-hands";
+import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
+
+describe("App", () => {
+ const RouteStub = createRoutesStub([
+ { Component: App, path: "/conversation/:conversationId" },
+ ]);
+
+ const { endSessionMock } = vi.hoisted(() => ({
+ endSessionMock: vi.fn(),
+ }));
+
+ beforeAll(() => {
+ vi.mock("#/hooks/use-end-session", () => ({
+ useEndSession: vi.fn(() => endSessionMock),
+ }));
+
+ vi.mock("#/hooks/use-terminal", () => ({
+ useTerminal: vi.fn(),
+ }));
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should render", async () => {
+ renderWithProviders();
+ await screen.findByTestId("app-route");
+ });
+
+ it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
+ "should call endSession if the user does not have permission to view conversation",
+ async () => {
+ const errorToastSpy = vi.spyOn(toast, "error");
+ const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
+
+ getConversationSpy.mockResolvedValue(null);
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(endSessionMock).toHaveBeenCalledOnce();
+ expect(errorToastSpy).toHaveBeenCalledOnce();
+ });
+ },
+ );
+
+ it("should not call endSession if the user has permission", async () => {
+ const errorToastSpy = vi.spyOn(toast, "error");
+ const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
+
+ getConversationSpy.mockResolvedValue({
+ conversation_id: "9999",
+ lastUpdated: "",
+ name: "",
+ repo: "",
+ state: "cold",
+ });
+ const { rerender } = renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(endSessionMock).not.toHaveBeenCalled();
+ expect(errorToastSpy).not.toHaveBeenCalled();
+ });
+
+ rerender();
+
+ await waitFor(() => {
+ expect(endSessionMock).not.toHaveBeenCalled();
+ expect(errorToastSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 62ed141b8fa8..973a16d01049 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -14,7 +14,7 @@
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.5.0",
- "@tanstack/react-query": "^5.62.10",
+ "@tanstack/react-query": "^5.62.11",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
@@ -27,12 +27,12 @@
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
- "posthog-js": "^1.203.1",
+ "posthog-js": "^1.203.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.4.1",
- "react-i18next": "^15.2.0",
+ "react-i18next": "^15.4.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
@@ -43,11 +43,12 @@
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.6.0",
- "vite": "^5.4.9",
+ "vite": "^5.4.11",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
},
"devDependencies": {
+ "@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
@@ -77,7 +78,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
- "lint-staged": "^15.2.11",
+ "lint-staged": "^15.3.0",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
@@ -1626,6 +1627,21 @@
"node": ">=18"
}
},
+ "node_modules/@mswjs/socket.io-binding": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@mswjs/socket.io-binding/-/socket.io-binding-0.1.1.tgz",
+ "integrity": "sha512-mtFDHC5XMeti43toe3HBynD4uBxvUA2GfJVC6TDfhOQlH+G2hf5znNTSa75A30XdWL0P6aNqUKpcNo6L0Wop+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@mswjs/interceptors": "^0.37.1",
+ "engine.io-parser": "^5.2.3",
+ "socket.io-parser": "^4.2.4"
+ },
+ "peerDependencies": {
+ "@mswjs/interceptors": "*"
+ }
+ },
"node_modules/@nextui-org/accordion": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@nextui-org/accordion/-/accordion-2.2.6.tgz",
@@ -5355,9 +5371,9 @@
}
},
"node_modules/@tanstack/react-query": {
- "version": "5.62.10",
- "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.10.tgz",
- "integrity": "sha512-1e1WpHM5oGf27nWM/NWLY62/X9pbMBWa6ErWYmeuK0OqB9/g9UzA59ogiWbxCmS2wtAFQRhOdHhfSofrkhPl2g==",
+ "version": "5.62.11",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.11.tgz",
+ "integrity": "sha512-Xb1nw0cYMdtFmwkvH9+y5yYFhXvLRCnXoqlzSw7UkqtCVFq3cG8q+rHZ2Yz1XrC+/ysUaTqbLKJqk95mCgC1oQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.62.9"
@@ -8134,9 +8150,9 @@
"license": "MIT"
},
"node_modules/es-abstract": {
- "version": "1.23.7",
- "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.7.tgz",
- "integrity": "sha512-OygGC8kIcDhXX+6yAZRGLqwi2CmEXCbLQixeGUgYeR+Qwlppqmo7DIDr8XibtEBZp+fJcoYpoatp5qwLMEdcqQ==",
+ "version": "1.23.8",
+ "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.8.tgz",
+ "integrity": "sha512-lfab8IzDn6EpI1ibZakcgS6WsfEBiB+43cuJo+wgylx1xKXf+Sp+YR3vFuQwC/u3sxYwV8Cxe3B0DpVUu/WiJQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8175,8 +8191,10 @@
"object-inspect": "^1.13.3",
"object-keys": "^1.1.1",
"object.assign": "^4.1.7",
+ "own-keys": "^1.0.0",
"regexp.prototype.flags": "^1.5.3",
"safe-array-concat": "^1.1.3",
+ "safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0",
"string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9",
@@ -11189,13 +11207,13 @@
"license": "MIT"
},
"node_modules/lint-staged": {
- "version": "15.2.11",
- "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.11.tgz",
- "integrity": "sha512-Ev6ivCTYRTGs9ychvpVw35m/bcNDuBN+mnTeObCL5h+boS5WzBEC6LHI4I9F/++sZm1m+J2LEiy0gxL/R9TBqQ==",
+ "version": "15.3.0",
+ "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.3.0.tgz",
+ "integrity": "sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "chalk": "~5.3.0",
+ "chalk": "~5.4.1",
"commander": "~12.1.0",
"debug": "~4.4.0",
"execa": "~8.0.1",
@@ -11217,9 +11235,9 @@
}
},
"node_modules/lint-staged/node_modules/chalk": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
- "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
+ "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -13280,6 +13298,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/own-keys": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
+ "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-intrinsic": "^1.2.6",
+ "object-keys": "^1.1.1",
+ "safe-push-apply": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -13563,14 +13599,14 @@
}
},
"node_modules/pkg-types": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.1.tgz",
- "integrity": "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.0.tgz",
+ "integrity": "sha512-kS7yWjVFCkIw9hqdJBoMxDdzEngmkr5FXeWZZfQ6GoYacjVnsW6l2CcYW/0ThD0vF4LPJgVYnrg4d0uuhwYQbg==",
"dev": true,
"license": "MIT",
"dependencies": {
"confbox": "^0.1.8",
- "mlly": "^1.7.2",
+ "mlly": "^1.7.3",
"pathe": "^1.1.2"
}
},
@@ -13774,9 +13810,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
- "version": "1.203.1",
- "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.203.1.tgz",
- "integrity": "sha512-r/WiSyz6VNbIKEV/30+aD5gdrYkFtmZwvqNa6h9frl8hG638v098FrXaq3EYzMcCdkQf3phaZTDIAFKegpiTjw==",
+ "version": "1.203.2",
+ "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.203.2.tgz",
+ "integrity": "sha512-3aLpEhM4i9sQQtobRmDttJ3rTW1+gwQ9HL7QiOeDueE2T7CguYibYS7weY1UhXMerx5lh1A7+szlOJTTibifLQ==",
"license": "MIT",
"dependencies": {
"core-js": "^3.38.1",
@@ -13792,9 +13828,9 @@
"license": "Apache-2.0"
},
"node_modules/preact": {
- "version": "10.25.3",
- "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.3.tgz",
- "integrity": "sha512-dzQmIFtM970z+fP9ziQ3yG4e3ULIbwZzJ734vaMVUTaKQ2+Ru1Ou/gjshOYVHCcd1rpAelC6ngjvjDXph98unQ==",
+ "version": "10.25.4",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.25.4.tgz",
+ "integrity": "sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -14124,9 +14160,9 @@
}
},
"node_modules/react-i18next": {
- "version": "15.2.0",
- "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.2.0.tgz",
- "integrity": "sha512-iJNc8111EaDtVTVMKigvBtPHyrJV+KblWG73cUxqp+WmJCcwkzhWNFXmkAD5pwP2Z4woeDj/oXDdbjDsb3Gutg==",
+ "version": "15.4.0",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.0.tgz",
+ "integrity": "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
@@ -14895,6 +14931,30 @@
],
"license": "MIT"
},
+ "node_modules/safe-push-apply": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
+ "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "isarray": "^2.0.5"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/safe-push-apply/node_modules/isarray": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
+ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 16434074e8d9..1048fea0df75 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,7 +13,7 @@
"@react-router/serve": "^7.1.1",
"@react-types/shared": "^3.25.0",
"@reduxjs/toolkit": "^2.5.0",
- "@tanstack/react-query": "^5.62.10",
+ "@tanstack/react-query": "^5.62.11",
"@vitejs/plugin-react": "^4.3.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
@@ -26,12 +26,12 @@
"isbot": "^5.1.19",
"jose": "^5.9.4",
"monaco-editor": "^0.52.2",
- "posthog-js": "^1.203.1",
+ "posthog-js": "^1.203.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.4.1",
- "react-i18next": "^15.2.0",
+ "react-i18next": "^15.4.0",
"react-icons": "^5.4.0",
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
@@ -42,7 +42,7 @@
"sirv-cli": "^3.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^2.6.0",
- "vite": "^5.4.9",
+ "vite": "^5.4.11",
"web-vitals": "^3.5.2",
"ws": "^8.18.0"
},
@@ -75,6 +75,7 @@
]
},
"devDependencies": {
+ "@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.49.1",
"@react-router/dev": "^7.1.1",
"@tailwindcss/typography": "^0.5.15",
@@ -104,7 +105,7 @@
"eslint-plugin-react-hooks": "^4.6.2",
"husky": "^9.1.6",
"jsdom": "^25.0.1",
- "lint-staged": "^15.2.11",
+ "lint-staged": "^15.3.0",
"msw": "^2.6.6",
"postcss": "^8.4.47",
"prettier": "^3.4.2",
diff --git a/frontend/src/api/github.ts b/frontend/src/api/github.ts
index b315e2d930a7..492955ae69d9 100644
--- a/frontend/src/api/github.ts
+++ b/frontend/src/api/github.ts
@@ -106,15 +106,28 @@ export const retrieveGitHubUser = async () => {
export const retrieveLatestGitHubCommit = async (
repository: string,
-): Promise => {
- const response = await github.get(
- `/repos/${repository}/commits`,
- {
- params: {
- per_page: 1,
+): Promise => {
+ try {
+ const response = await github.get(
+ `/repos/${repository}/commits`,
+ {
+ params: {
+ per_page: 1,
+ },
},
- },
- );
-
- return response.data[0];
+ );
+ return response.data[0] || null;
+ } catch (error) {
+ if (!error || typeof error !== "object") {
+ throw new Error("Unknown error occurred");
+ }
+ const axiosError = error as { response?: { status: number } };
+ if (axiosError.response?.status === 409) {
+ // Repository is empty, no commits yet
+ return null;
+ }
+ throw new Error(
+ error instanceof Error ? error.message : "Unknown error occurred",
+ );
+ }
};
diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts
index 84d254102c08..1534be879ca5 100644
--- a/frontend/src/api/open-hands.ts
+++ b/frontend/src/api/open-hands.ts
@@ -8,8 +8,10 @@ import {
GetConfigResponse,
GetVSCodeUrlResponse,
AuthenticateResponse,
+ Conversation,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
+import { ApiSettings } from "#/services/settings";
class OpenHands {
/**
@@ -219,6 +221,52 @@ class OpenHands {
return data;
}
+ static async getUserConversations(): Promise {
+ const { data } = await openHands.get("/api/conversations");
+ return data;
+ }
+
+ static async deleteUserConversation(conversationId: string): Promise {
+ await openHands.delete(`/api/conversations/${conversationId}`);
+ }
+
+ static async updateUserConversation(
+ conversationId: string,
+ conversation: Partial>,
+ ): Promise {
+ await openHands.put(`/api/conversations/${conversationId}`, conversation);
+ }
+
+ static async createConversation(
+ githubToken?: string,
+ selectedRepository?: string,
+ ): Promise {
+ const body = {
+ github_token: githubToken,
+ selected_repository: selectedRepository,
+ };
+
+ const { data } = await openHands.post(
+ "/api/conversations",
+ body,
+ );
+
+ // TODO: remove this once we have a multi-conversation UI
+ localStorage.setItem("latest_conversation_id", data.conversation_id);
+
+ return data;
+ }
+
+ static async getConversation(
+ conversationId: string,
+ ): Promise {
+ const { data } = await openHands.get(
+ `/api/conversations/${conversationId}`,
+ );
+
+ return data;
+ }
+
static async searchEvents(
conversationId: string,
params: {
@@ -248,20 +296,22 @@ class OpenHands {
return data;
}
- static async newConversation(params: {
- githubToken?: string;
- selectedRepository?: string;
- }): Promise<{ conversation_id: string }> {
- const { data } = await openHands.post<{
- conversation_id: string;
- }>("/api/conversations", {
- github_token: params.githubToken,
- selected_repository: params.selectedRepository,
- });
- // TODO: remove this once we have a multi-conversation UI
- localStorage.setItem("latest_conversation_id", data.conversation_id);
+ /**
+ * Get the settings from the server or use the default settings if not found
+ */
+ static async getSettings(): Promise {
+ const { data } = await openHands.get("/api/settings");
return data;
}
+
+ /**
+ * Save the settings to the server. Only valid settings are saved.
+ * @param settings - the settings to save
+ */
+ static async saveSettings(settings: Partial): Promise {
+ const data = await openHands.post("/api/settings", settings);
+ return data.status === 200;
+ }
}
export default OpenHands;
diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts
index 919d370751ca..c17d2016816d 100644
--- a/frontend/src/api/open-hands.types.ts
+++ b/frontend/src/api/open-hands.types.ts
@@ -1,3 +1,5 @@
+import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
+
export interface ErrorResponse {
error: string;
}
@@ -57,3 +59,11 @@ export interface AuthenticateResponse {
message?: string;
error?: string;
}
+
+export interface Conversation {
+ conversation_id: string;
+ name: string;
+ repo: string | null;
+ lastUpdated: string;
+ state: ProjectState;
+}
diff --git a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx
index 960eb2d7a8c6..bf695cbc6ba7 100644
--- a/frontend/src/components/features/context-menu/account-settings-context-menu.tsx
+++ b/frontend/src/components/features/context-menu/account-settings-context-menu.tsx
@@ -1,7 +1,9 @@
+import { useTranslation } from "react-i18next";
import { ContextMenu } from "./context-menu";
import { ContextMenuListItem } from "./context-menu-list-item";
import { ContextMenuSeparator } from "./context-menu-separator";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
+import { I18nKey } from "#/i18n/declaration";
interface AccountSettingsContextMenuProps {
onClickAccountSettings: () => void;
@@ -17,6 +19,7 @@ export function AccountSettingsContextMenu({
isLoggedIn,
}: AccountSettingsContextMenuProps) {
const ref = useClickOutsideElement(onClose);
+ const { t } = useTranslation();
return (
- Account Settings
+ {t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
- Logout
+ {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
);
diff --git a/frontend/src/components/features/context-menu/context-menu-list-item.tsx b/frontend/src/components/features/context-menu/context-menu-list-item.tsx
index 606090229cd9..b35ca44395a1 100644
--- a/frontend/src/components/features/context-menu/context-menu-list-item.tsx
+++ b/frontend/src/components/features/context-menu/context-menu-list-item.tsx
@@ -1,18 +1,20 @@
import { cn } from "#/utils/utils";
interface ContextMenuListItemProps {
- onClick: () => void;
+ testId?: string;
+ onClick: (event: React.MouseEvent) => void;
isDisabled?: boolean;
}
export function ContextMenuListItem({
children,
+ testId,
onClick,
isDisabled,
}: React.PropsWithChildren) {
return (
- }
- />
- );
- }
-
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
return (
diff --git a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
index f9a0e1686c23..bebf4a5ae921 100644
--- a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
+++ b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
@@ -1,5 +1,6 @@
import React from "react";
import posthog from "posthog-js";
+import { useTranslation } from "react-i18next";
import EllipsisH from "#/icons/ellipsis-h.svg?react";
import { ProjectMenuCardContextMenu } from "./project.menu-card-context-menu";
import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholder";
@@ -7,6 +8,7 @@ import { ProjectMenuDetails } from "./project-menu-details";
import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { DownloadModal } from "#/components/shared/download-modal";
+import { I18nKey } from "#/i18n/declaration";
interface ProjectMenuCardProps {
isConnectedToGitHub: boolean;
@@ -21,6 +23,8 @@ export function ProjectMenuCard({
isConnectedToGitHub,
githubData,
}: ProjectMenuCardProps) {
+ const { t } = useTranslation();
+
const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
React.useState(false);
@@ -71,7 +75,7 @@ export function ProjectMenuCard({
diff --git a/frontend/src/components/features/project-menu/project-menu-details-placeholder.tsx b/frontend/src/components/features/project-menu/project-menu-details-placeholder.tsx
index f9556d8c0a39..47a6a381809c 100644
--- a/frontend/src/components/features/project-menu/project-menu-details-placeholder.tsx
+++ b/frontend/src/components/features/project-menu/project-menu-details-placeholder.tsx
@@ -30,7 +30,9 @@ export function ProjectMenuDetailsPlaceholder({
"hover:underline hover:underline-offset-2",
)}
>
- {!isConnectedToGitHub ? "Connect to GitHub" : "Connected"}
+ {!isConnectedToGitHub
+ ? t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB)
+ : t(I18nKey.PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED)}
diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx
index 5afc1aa9d24e..3d2e9a3e60b0 100644
--- a/frontend/src/components/features/sidebar/sidebar.tsx
+++ b/frontend/src/components/features/sidebar/sidebar.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { useLocation } from "react-router";
+import FolderIcon from "#/icons/docs.svg?react";
import { useAuth } from "#/context/auth-context";
-import { useSettings } from "#/context/settings-context";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { UserActions } from "./user-actions";
@@ -13,21 +13,28 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
+import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
+import { useSettings } from "#/hooks/query/use-settings";
+import { ConversationPanel } from "../conversation-panel/conversation-panel";
+import { cn } from "#/utils/utils";
+import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
export function Sidebar() {
const location = useLocation();
-
const user = useGitHubUser();
const { data: isAuthed } = useIsAuthed();
-
const { logout } = useAuth();
- const { settingsAreUpToDate } = useSettings();
+ const { data: settings, isError: settingsIsError } = useSettings();
+ const { isUpToDate: settingsAreUpToDate } = useSettingsUpToDate();
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
React.useState(false);
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
React.useState(false);
+ const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(
+ MULTI_CONVO_UI_IS_ENABLED,
+ );
React.useEffect(() => {
// If the github token is invalid, open the account settings modal again
@@ -54,7 +61,7 @@ export function Sidebar() {
return (
<>
-