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 ( + ); +} diff --git a/frontend/src/components/features/conversation-panel/exit-conversation-modal.tsx b/frontend/src/components/features/conversation-panel/exit-conversation-modal.tsx new file mode 100644 index 000000000000..6442598cbbc4 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/exit-conversation-modal.tsx @@ -0,0 +1,34 @@ +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { ModalBody } from "#/components/shared/modals/modal-body"; +import { ModalButton } from "#/components/shared/buttons/modal-button"; +import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal"; + +interface ExitConversationModalProps { + onConfirm: () => void; + onClose: () => void; +} + +export function ExitConversationModal({ + onConfirm, + onClose, +}: ExitConversationModalProps) { + return ( + + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/features/conversation-panel/new-conversation-button.tsx b/frontend/src/components/features/conversation-panel/new-conversation-button.tsx new file mode 100644 index 000000000000..b7563952cfce --- /dev/null +++ b/frontend/src/components/features/conversation-panel/new-conversation-button.tsx @@ -0,0 +1,16 @@ +interface NewConversationButtonProps { + onClick: () => void; +} + +export function NewConversationButton({ onClick }: NewConversationButtonProps) { + return ( + + ); +} diff --git a/frontend/src/components/features/conversation-panel/state-indicators/cold.svg b/frontend/src/components/features/conversation-panel/state-indicators/cold.svg new file mode 100644 index 000000000000..95b513851439 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/cold.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/cooling.svg b/frontend/src/components/features/conversation-panel/state-indicators/cooling.svg new file mode 100644 index 000000000000..ef65bfa11c06 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/cooling.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/finished.svg b/frontend/src/components/features/conversation-panel/state-indicators/finished.svg new file mode 100644 index 000000000000..311d524d1774 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/finished.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/running.svg b/frontend/src/components/features/conversation-panel/state-indicators/running.svg new file mode 100644 index 000000000000..5537583da544 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/running.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/waiting.svg b/frontend/src/components/features/conversation-panel/state-indicators/waiting.svg new file mode 100644 index 000000000000..a73aa2b27653 --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/waiting.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/conversation-panel/state-indicators/warm.svg b/frontend/src/components/features/conversation-panel/state-indicators/warm.svg new file mode 100644 index 000000000000..e7432e75315d --- /dev/null +++ b/frontend/src/components/features/conversation-panel/state-indicators/warm.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx index 227ef8a5bdcb..f5bd2a740068 100644 --- a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx +++ b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx @@ -31,17 +31,6 @@ export function GitHubRepositoriesSuggestionBox({ } }; - if (isGitHubErrorReponse(repositories)) { - return ( - {repositories.message}

- } - /> - ); - } - 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 ( <> -