diff --git a/.github/workflows/ghcr-build.yml b/.github/workflows/ghcr-build.yml index a317c9a7ab03..53439e0e7ea7 100644 --- a/.github/workflows/ghcr-build.yml +++ b/.github/workflows/ghcr-build.yml @@ -42,7 +42,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.3.0 + uses: docker/setup-qemu-action@v3.4.0 with: image: tonistiigi/binfmt:latest - name: Login to GHCR @@ -91,7 +91,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v3.3.0 + uses: docker/setup-qemu-action@v3.4.0 with: image: tonistiigi/binfmt:latest - name: Login to GHCR diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml index 2fd06e24d98b..a69c320e5c2e 100644 --- a/.github/workflows/openhands-resolver.yml +++ b/.github/workflows/openhands-resolver.yml @@ -177,7 +177,7 @@ jobs: echo "SANDBOX_ENV_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV # Set branch variables - echo "TARGET_BRANCH=${{ inputs.target_branch }}" >> $GITHUB_ENV + echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV - name: Comment on issue with start message uses: actions/github-script@v7 @@ -232,6 +232,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }} GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }} + GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }} LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }} LLM_API_KEY: ${{ secrets.LLM_API_KEY }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} @@ -268,6 +269,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }} GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }} + GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }} LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }} LLM_API_KEY: ${{ secrets.LLM_API_KEY }} LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} @@ -277,6 +279,7 @@ jobs: if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then cd /tmp && python -m openhands.resolver.send_pull_request \ --issue-number ${{ env.ISSUE_NUMBER }} \ + --target-branch ${{ env.TARGET_BRANCH }} \ --pr-type draft \ --reviewer ${{ github.actor }} | tee pr_result.txt && \ grep "draft created" pr_result.txt | sed 's/.*\///g' > pr_number.txt diff --git a/.openhands/microagents/glossary.md b/.openhands/microagents/glossary.md new file mode 100644 index 000000000000..718ce01ab350 --- /dev/null +++ b/.openhands/microagents/glossary.md @@ -0,0 +1,172 @@ +# OpenHands Glossary + +### Agent +The core AI entity in OpenHands that can perform software development tasks by interacting with tools, browsing the web, and modifying code. + +#### Agent Controller +A component that manages the agent's lifecycle, handles its state, and coordinates interactions between the agent and various tools. + +#### Agent Delegation +The ability of an agent to hand off specific tasks to other specialized agents for better task completion. + +#### Agent Hub +A central registry of different agent types and their capabilities, allowing for easy agent selection and instantiation. + +#### Agent Skill +A specific capability or function that an agent can perform, such as file manipulation, web browsing, or code editing. + +#### Agent State +The current context and status of an agent, including its memory, active tools, and ongoing tasks. + +#### CodeAct Agent +[A generalist agent in OpenHands](https://arxiv.org/abs/2407.16741) designed to perform tasks by editing and executing code. + +### Browser +A system for web-based interactions and tasks. + +#### Browser Gym +A testing and evaluation environment for browser-based agent interactions and tasks. + +#### Web Browser Tool +A tool that enables agents to interact with web pages and perform web-based tasks. + +### Commands +Terminal and execution related functionality. + +#### Bash Session +A persistent terminal session that maintains state and history for bash command execution. +This uses tmux under the hood. + +### Configuration +System-wide settings and options. + +#### Agent Configuration +Settings that define an agent's behavior, capabilities, and limitations, including available tools and runtime settings. + +#### Configuration Options +Settings that control various aspects of OpenHands behavior, including runtime, security, and agent settings. + +#### LLM Config +Configuration settings for language models used by agents, including model selection and parameters. + +#### LLM Draft Config +Settings for draft mode operations with language models, typically used for faster, lower-quality responses. + +#### Runtime Configuration +Settings that define how the runtime environment should be set up and operated. + +#### Security Options +Configuration settings that control security features and restrictions. + +### Conversation +A sequence of interactions between a user and an agent, including messages, actions, and their results. + +#### Conversation Info +Metadata about a conversation, including its status, participants, and timeline. + +#### Conversation Manager +A component that handles the creation, storage, and retrieval of conversations. + +#### Conversation Metadata +Additional information about conversations, such as tags, timestamps, and related resources. + +#### Conversation Status +The current state of a conversation, including whether it's active, completed, or failed. + +#### Conversation Store +A storage system for maintaining conversation history and related data. + +### Events + +#### Event +Every Conversation comprises a series of Events. Each Event is either an Action or an Observation. + +#### Event Stream +A continuous flow of events that represents the ongoing activities and interactions in the system. + +#### Action +A specific operation or command that an agent executes through available tools, such as running a command or editing a file. + +#### Observation +The response or result returned by a tool after an agent's action, providing feedback about the action's outcome. + +### Interface +Different ways to interact with OpenHands. + +#### CLI Mode +A command-line interface mode for interacting with OpenHands agents without a graphical interface. + +#### GUI Mode +A graphical user interface mode for interacting with OpenHands agents through a web interface. + +#### Headless Mode +A mode of operation where OpenHands runs without a user interface, suitable for automation and scripting. + +### Agent Memory +The system that decides which parts of the Event Stream (i.e. the conversation history) should be passed into each LLM prompt. + +#### Memory Store +A storage system for maintaining agent memory and context across sessions. + +#### Condenser +A component that processes and summarizes conversation history to maintain context while staying within token limits. + +#### Truncation +A very simple Condenser strategy. Reduces conversation history or content to stay within token limits. + +### Microagent +A specialized prompt that enhances OpenHands with domain-specific knowledge, repository-specific context, and task-specific workflows. + +#### Microagent Registry +A central repository of available microagents and their configurations. + +#### Public Microagent +A general-purpose microagent available to all OpenHands users, triggered by specific keywords. + +#### Repository Microagent +A type of microagent that provides repository-specific context and guidelines, stored in the `.openhands/microagents/` directory. + +### Prompt +Components for managing and processing prompts. + +#### Prompt Caching +A system for caching and reusing common prompts to improve performance. + +#### Prompt Manager +A component that handles the loading, processing, and management of prompts used by agents, including microagents. + +#### Response Parsing +The process of interpreting and structuring responses from language models and tools. + +### Runtime +The execution environment where agents perform their tasks, which can be local, remote, or containerized. + +#### Action Execution Server +A REST API that receives agent actions (e.g. bash commands, python code, browsing actions), executes them in the runtime environment, and returns the results. + +#### Action Execution Client +A component that handles the execution of actions in the runtime environment, managing the communication between the agent and the runtime. + +#### Docker Runtime +A containerized runtime environment that provides isolation and reproducibility for agent operations. + +#### E2B Runtime +A specialized runtime environment built on E2B for secure and isolated code execution. + +#### Local Runtime +A runtime environment that executes on the local machine, suitable for development and testing. + +#### Modal Runtime +A runtime environment built on Modal for scalable and distributed agent operations. + +#### Remote Runtime +A sandboxed environment that executes code and commands remotely, providing isolation and security for agent operations. + +#### Runtime Builder +A component that builds a Docker image for the Action Execution Server based on a user-specified base image. + +### Security +Security-related components and features. + +#### Security Analyzer +A component that checks agent actions for potential security risks. diff --git a/Development.md b/Development.md index 996d88807b74..8d6e35751879 100644 --- a/Development.md +++ b/Development.md @@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image. -Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.23-nikolaik` +Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.24-nikolaik` ## Develop inside Docker container diff --git a/README.md b/README.md index a034a64faef7..22caad34c99f 100644 --- a/README.md +++ b/README.md @@ -43,17 +43,17 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio system requirements and more information. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.23 + docker.all-hands.dev/all-hands-ai/openhands:0.24 ``` You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)! diff --git a/containers/dev/compose.yml b/containers/dev/compose.yml index 500129f14d52..50c8ed04563f 100644 --- a/containers/dev/compose.yml +++ b/containers/dev/compose.yml @@ -11,7 +11,7 @@ services: - BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"} - SANDBOX_API_HOSTNAME=host.docker.internal # - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.23-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.24-nikolaik} - SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: diff --git a/dev_config/python/ruff.toml b/dev_config/python/ruff.toml index af56e7e9d342..2ffc222fd980 100644 --- a/dev_config/python/ruff.toml +++ b/dev_config/python/ruff.toml @@ -24,3 +24,6 @@ inline-quotes = "single" [format] quote-style = "single" + +[lint.flake8-bugbear] +extend-immutable-calls = ["Depends", "fastapi.Depends", "fastapi.params.Depends"] diff --git a/docker-compose.yml b/docker-compose.yml index f20945ecdeaa..4353b7b6bb5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: image: openhands:latest container_name: openhands-app-${DATE:-} environment: - - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik} + - SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik} #- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user - WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace} ports: 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 89fdbc9e4124..6a666e91f8d3 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.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \ + docker.all-hands.dev/all-hands-ai/openhands:0.24 \ 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 f50bce6a2fdd..a72cd57f0cc1 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.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \ + docker.all-hands.dev/all-hands-ai/openhands:0.24 \ 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 2d92e0d202a3..6a1789214923 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.23-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 + docker.all-hands.dev/all-hands-ai/openhands:0.24 ``` 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 03c172540daf..865489d34841 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.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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 92b3e07891fe..57b95b719570 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.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \ + docker.all-hands.dev/all-hands-ai/openhands:0.24 \ 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 a5909345ffa1..44a4b5bc6f63 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.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 \ + docker.all-hands.dev/all-hands-ai/openhands:0.24 \ 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 4dde1f31a525..2d20773af4bc 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.23-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-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.23 + docker.all-hands.dev/all-hands-ai/openhands:0.24 ``` 你也可以在可脚本化的[无头模式](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 4f51c50ff69c..5786ce571c81 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.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/docs/modules/usage/architecture/runtime.md b/docs/modules/usage/architecture/runtime.md index b08a1ed99bbf..f682f249acb3 100644 --- a/docs/modules/usage/architecture/runtime.md +++ b/docs/modules/usage/architecture/runtime.md @@ -54,14 +54,13 @@ graph TD 6. Action Execution: The runtime client receives actions from the backend, executes them in the sandboxed environment, and sends back observations 7. Observation Return: The action execution server sends execution results back to the OpenHands backend as observations - The role of the client: + - It acts as an intermediary between the OpenHands backend and the sandboxed environment - It executes various types of actions (shell commands, file operations, Python code, etc.) safely within the container - It manages the state of the sandboxed environment, including the current working directory and loaded plugins - It formats and returns observations to the backend, ensuring a consistent interface for processing results - ## How OpenHands builds and maintains OH Runtime images OpenHands' approach to building and managing runtime images ensures efficiency, consistency, and flexibility in creating and maintaining Docker images for both production and development environments. @@ -78,16 +77,15 @@ Tags may be in one of 2 formats: - **Source Tag**: `oh_v{openhands_version}_{16_digit_lock_hash}_{16_digit_source_hash}` (e.g.: `oh_v0.9.9_1234567890abcdef_1234567890abcdef`) - #### Source Tag - Most Specific This is the first 16 digits of the MD5 of the directory hash for the source directory. This gives a hash for only the openhands source - #### Lock Tag This hash is built from the first 16 digits of the MD5 of: + - The name of the base image upon which the image was built (e.g.: `nikolaik/python-nodejs:python3.12-nodejs22`) - The content of the `pyproject.toml` included in the image. - The content of the `poetry.lock` included in the image. diff --git a/docs/modules/usage/how-to/cli-mode.md b/docs/modules/usage/how-to/cli-mode.md index d3dfb6d05173..612f1590eac9 100644 --- a/docs/modules/usage/how-to/cli-mode.md +++ b/docs/modules/usage/how-to/cli-mode.md @@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -45,7 +45,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --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.23 \ + docker.all-hands.dev/all-hands-ai/openhands:0.24 \ python -m openhands.core.cli ``` diff --git a/docs/modules/usage/how-to/github-action.md b/docs/modules/usage/how-to/github-action.md index 3ba0227ddaaf..a734a8b70a7d 100644 --- a/docs/modules/usage/how-to/github-action.md +++ b/docs/modules/usage/how-to/github-action.md @@ -42,9 +42,10 @@ You can provide custom directions for OpenHands by following the [README for the Github resolver will automatically check for valid [repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions?tool=webui#creating-secrets-for-a-repository) or [repository variables](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#creating-configuration-variables-for-a-repository) to customize its behavior. The customization options you can set are: -| **Attribute name** | **Type** | **Purpose** | **Example** | -|----------------------------------| -------- |-------------------------------------------------------------------------------------------------------------|------------------------------------------------------| -| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` | -| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` | -| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` | -| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` | +| **Attribute name** | **Type** | **Purpose** | **Example** | +| -------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` | +| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` | +| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` | +| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` | +| `TARGET_BRANCH` | Variable | Merge to branch other than `main` | `TARGET_BRANCH="dev"` | diff --git a/docs/modules/usage/how-to/headless-mode.md b/docs/modules/usage/how-to/headless-mode.md index 1bcb5c71ff13..b751dc3000d1 100644 --- a/docs/modules/usage/how-to/headless-mode.md +++ b/docs/modules/usage/how-to/headless-mode.md @@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker: ```bash docker run -it \ --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \ -e SANDBOX_USER_ID=$(id -u) \ -e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \ -e LLM_API_KEY=$LLM_API_KEY \ @@ -43,7 +43,7 @@ docker run -it \ -v ~/.openhands-state:/.openhands-state \ --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.23 \ + docker.all-hands.dev/all-hands-ai/openhands:0.24 \ python -m openhands.core.main -t "write a bash script that prints hi" ``` diff --git a/docs/modules/usage/installation.mdx b/docs/modules/usage/installation.mdx index b088f9579ca0..6a65befc38f6 100644 --- a/docs/modules/usage/installation.mdx +++ b/docs/modules/usage/installation.mdx @@ -54,17 +54,17 @@ The easiest way to run OpenHands is in Docker. ```bash -docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik +docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik docker run -it --rm --pull=always \ - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \ -e LOG_ALL_EVENTS=true \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ~/.openhands-state:/.openhands-state \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ - docker.all-hands.dev/all-hands-ai/openhands:0.23 + docker.all-hands.dev/all-hands-ai/openhands:0.24 ``` You'll find OpenHands running at http://localhost:3000! diff --git a/docs/modules/usage/runtimes.md b/docs/modules/usage/runtimes.md index 9205879a1b9a..740a53b00482 100644 --- a/docs/modules/usage/runtimes.md +++ b/docs/modules/usage/runtimes.md @@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible: ``` docker run # ... - -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \ + -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \ -v /var/run/docker.sock:/var/run/docker.sock \ # ... ``` diff --git a/evaluation/benchmarks/gaia/run_infer.py b/evaluation/benchmarks/gaia/run_infer.py index a8b442819267..2fdab0b2927a 100644 --- a/evaluation/benchmarks/gaia/run_infer.py +++ b/evaluation/benchmarks/gaia/run_infer.py @@ -25,6 +25,7 @@ get_llm_config_arg, get_parser, ) +from openhands.core.config.utils import get_agent_config_arg from openhands.core.logger import openhands_logger as logger from openhands.core.main import create_runtime, run_controller from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction @@ -63,8 +64,12 @@ def get_config( workspace_mount_path=None, ) config.set_llm_config(metadata.llm_config) - agent_config = config.get_agent_config(metadata.agent_class) - agent_config.enable_prompt_extensions = False + if metadata.agent_config: + config.set_agent_config(metadata.agent_config, metadata.agent_class) + else: + logger.info('Agent config not provided, using default settings') + agent_config = config.get_agent_config(metadata.agent_class) + agent_config.enable_prompt_extensions = False return config @@ -238,6 +243,10 @@ def process_instance( ) args, _ = parser.parse_known_args() + agent_config = None + if args.agent_config: + agent_config = get_agent_config_arg(args.agent_config) + llm_config = None if args.llm_config: llm_config = get_llm_config_arg(args.llm_config) @@ -256,6 +265,7 @@ def process_instance( eval_output_dir=args.eval_output_dir, data_split=args.data_split, details={'gaia-level': args.level}, + agent_config=agent_config, ) dataset = load_dataset('gaia-benchmark/GAIA', args.level) diff --git a/evaluation/benchmarks/gaia/scripts/run_infer.sh b/evaluation/benchmarks/gaia/scripts/run_infer.sh index 4b2f8f73dffa..217809880d40 100755 --- a/evaluation/benchmarks/gaia/scripts/run_infer.sh +++ b/evaluation/benchmarks/gaia/scripts/run_infer.sh @@ -9,6 +9,7 @@ AGENT=$3 EVAL_LIMIT=$4 LEVELS=$5 NUM_WORKERS=$6 +AGENT_CONFIG=$7 if [ -z "$NUM_WORKERS" ]; then NUM_WORKERS=1 @@ -49,5 +50,9 @@ if [ -n "$EVAL_LIMIT" ]; then COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT" fi +if [ -n "$AGENT_CONFIG" ]; then + echo "AGENT_CONFIG: $AGENT_CONFIG" + COMMAND="$COMMAND --agent-config $AGENT_CONFIG" + # Run the command eval $COMMAND diff --git a/evaluation/benchmarks/the_agent_company/browsing.py b/evaluation/benchmarks/the_agent_company/browsing.py index 5ce97129777a..e8747c2dede9 100644 --- a/evaluation/benchmarks/the_agent_company/browsing.py +++ b/evaluation/benchmarks/the_agent_company/browsing.py @@ -267,7 +267,9 @@ def pre_login( obs: BrowserOutputObservation = runtime.run_action(browser_action) logger.debug(obs, extra={'msg_type': 'OBSERVATION'}) if save_screenshots: - image_data = base64.b64decode(obs.screenshot) + image_data = base64.b64decode( + obs.screenshot.replace('data:image/png;base64,', '') + ) with open(os.path.join(directory, f'{image_id}.png'), 'wb') as file: file.write(image_data) image_id += 1 diff --git a/evaluation/benchmarks/the_agent_company/run_infer.py b/evaluation/benchmarks/the_agent_company/run_infer.py index 5cd7c027e20f..84fb057ec791 100644 --- a/evaluation/benchmarks/the_agent_company/run_infer.py +++ b/evaluation/benchmarks/the_agent_company/run_infer.py @@ -18,9 +18,11 @@ AppConfig, LLMConfig, SandboxConfig, + get_agent_config_arg, get_llm_config_arg, get_parser, ) +from openhands.core.config.agent_config import AgentConfig from openhands.core.logger import openhands_logger as logger from openhands.core.main import create_runtime, run_controller from openhands.events.action import CmdRunAction, MessageAction @@ -34,6 +36,7 @@ def get_config( task_short_name: str, mount_path_on_host: str, llm_config: LLMConfig, + agent_config: AgentConfig | None, ) -> AppConfig: config = AppConfig( run_as_openhands=False, @@ -58,6 +61,14 @@ def get_config( workspace_mount_path_in_sandbox='/outputs', ) config.set_llm_config(llm_config) + if agent_config: + config.set_agent_config(agent_config) + else: + logger.info('Agent config not provided, using default settings') + agent_config = AgentConfig( + enable_prompt_extensions=False, + ) + config.set_agent_config(agent_config) return config @@ -148,11 +159,21 @@ def run_solver( os.makedirs(screenshots_dir, exist_ok=True) for image_id, obs in enumerate(state.history): if isinstance(obs, BrowserOutputObservation): - image_data = base64.b64decode(obs.screenshot) + image_data = base64.b64decode( + obs.screenshot.replace('data:image/png;base64,', '') + ) with open( os.path.join(screenshots_dir, f'{image_id}.png'), 'wb' ) as file: file.write(image_data) + if obs.set_of_marks: + som_image_data = base64.b64decode( + obs.set_of_marks.replace('data:image/png;base64,', '') + ) + with open( + os.path.join(screenshots_dir, f'{image_id}_som.png'), 'wb' + ) as file: + file.write(som_image_data) if save_final_state: os.makedirs(state_dir, exist_ok=True) @@ -215,6 +236,10 @@ def run_evaluator( ) args, _ = parser.parse_known_args() + agent_config: AgentConfig | None = None + if args.agent_config: + agent_config = get_agent_config_arg(args.agent_config) + agent_llm_config: LLMConfig | None = None if args.agent_llm_config: agent_llm_config = get_llm_config_arg(args.agent_llm_config) @@ -255,7 +280,7 @@ def run_evaluator( else: temp_dir = tempfile.mkdtemp() config: AppConfig = get_config( - args.task_image_name, task_short_name, temp_dir, agent_llm_config + args.task_image_name, task_short_name, temp_dir, agent_llm_config, agent_config ) runtime: Runtime = create_runtime(config) call_async_from_sync(runtime.connect) diff --git a/evaluation/benchmarks/the_agent_company/scripts/run_infer.sh b/evaluation/benchmarks/the_agent_company/scripts/run_infer.sh index b5bc7874c12e..e266e5990b1a 100755 --- a/evaluation/benchmarks/the_agent_company/scripts/run_infer.sh +++ b/evaluation/benchmarks/the_agent_company/scripts/run_infer.sh @@ -44,6 +44,10 @@ while [[ $# -gt 0 ]]; do ENV_LLM_CONFIG="$2" shift 2 ;; + --agent-config) + AGENT_CONFIG="$2" + shift 2 + ;; --outputs-path) OUTPUTS_PATH="$2" shift 2 @@ -125,8 +129,6 @@ temp_file="tasks_${START_PERCENTILE}_${END_PERCENTILE}.md" sed -n "${start_line},${end_line}p" tasks.md > "$temp_file" while IFS= read -r task_image; do - docker pull $task_image - # Remove prefix using ## to remove longest matching pattern from start task_name=${task_image##ghcr.io/theagentcompany/} @@ -140,13 +142,23 @@ while IFS= read -r task_image; do continue fi - export PYTHONPATH=evaluation/benchmarks/the_agent_company:\$PYTHONPATH && \ - poetry run python run_infer.py \ - --agent-llm-config "$AGENT_LLM_CONFIG" \ - --env-llm-config "$ENV_LLM_CONFIG" \ - --outputs-path "$OUTPUTS_PATH" \ - --server-hostname "$SERVER_HOSTNAME" \ - --task-image-name "$task_image" + docker pull $task_image + + # Build the Python command + COMMAND="poetry run python run_infer.py \ + --agent-llm-config \"$AGENT_LLM_CONFIG\" \ + --env-llm-config \"$ENV_LLM_CONFIG\" \ + --outputs-path \"$OUTPUTS_PATH\" \ + --server-hostname \"$SERVER_HOSTNAME\" \ + --task-image-name \"$task_image\"" + + # Add agent-config if it's defined + if [ -n "$AGENT_CONFIG" ]; then + COMMAND="$COMMAND --agent-config $AGENT_CONFIG" + fi + + export PYTHONPATH=evaluation/benchmarks/the_agent_company:$PYTHONPATH && \ + eval "$COMMAND" # Prune unused images and volumes docker image rm "$task_image" diff --git a/evaluation/swe_bench/scripts/docker/docker_env.sh b/evaluation/swe_bench/scripts/docker/docker_env.sh new file mode 100755 index 000000000000..1dc6ec037d9f --- /dev/null +++ b/evaluation/swe_bench/scripts/docker/docker_env.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Set Docker to run non-interactively +export DOCKER_BUILDKIT=1 +export DOCKER_SCAN_SUGGEST=false +export DEBIAN_FRONTEND=noninteractive + +# Function to run Docker commands with yes piped in +docker_noninteractive() { + yes | "$@" +} + +# Alias docker to use the non-interactive function +alias docker=docker_noninteractive \ No newline at end of file diff --git a/evaluation/swe_bench/scripts/docker/pull_all_eval_docker.sh b/evaluation/swe_bench/scripts/docker/pull_all_eval_docker.sh new file mode 100755 index 000000000000..aa827b589711 --- /dev/null +++ b/evaluation/swe_bench/scripts/docker/pull_all_eval_docker.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -e + +# Source the Docker environment settings +source "$(dirname "$0")/docker_env.sh" + +LEVEL=$1 +# three levels: +# - base, keyword "sweb.base" +# - env, keyword "sweb.env" +# - instance, keyword "sweb.eval" +SET=$2 + +if [ -z "$LEVEL" ]; then + echo "Usage: $0 " + echo "cache_level: base, env, or instance" + echo "set: lite, full" + exit 1 +fi + +if [ -z "$SET" ]; then + echo "Usage: $0 " + echo "cache_level: base, env, or instance" + echo "set: lite, full, default is lite" + SET="lite" +fi + +# Check if namespace is provided via argument $3, otherwise default to 'xingyaoww' +NAMESPACE=${3:-xingyaoww} + +echo "Using namespace: $NAMESPACE" + +if [ "$SET" == "lite" ]; then + IMAGE_FILE="$(dirname "$0")/all-swebench-lite-instance-images.txt" +else + IMAGE_FILE="$(dirname "$0")/all-swebench-full-instance-images.txt" +fi + +# Define a pattern based on the level +case $LEVEL in + base) + PATTERN="sweb.base" + ;; + env) + PATTERN="sweb.base\|sweb.env" + ;; + instance) + PATTERN="sweb.base\|sweb.env\|sweb.eval" + ;; + *) + echo "Invalid cache level: $LEVEL" + echo "Valid levels are: base, env, instance" + exit 1 + ;; +esac + +echo "Pulling docker images for [$LEVEL] level" + +echo "Pattern: $PATTERN" +echo "Image file: $IMAGE_FILE" + +# Read each line from the file, filter by pattern, and pull the docker image +grep "$PATTERN" "$IMAGE_FILE" | while IFS= read -r image; do + echo "Pulling $NAMESPACE/$image into $image" + docker pull $NAMESPACE/$image + # replace _s_ to __ in the image name + renamed_image=$(echo "$image" | sed 's/_s_/__/g') + docker tag $NAMESPACE/$image $renamed_image +done \ No newline at end of file diff --git a/evaluation/utils/shared.py b/evaluation/utils/shared.py index 0f8ac8fa8332..7035d56e41ef 100644 --- a/evaluation/utils/shared.py +++ b/evaluation/utils/shared.py @@ -17,6 +17,7 @@ from openhands.controller.state.state import State from openhands.core.config import LLMConfig +from openhands.core.config.agent_config import AgentConfig from openhands.core.config.condenser_config import ( CondenserConfig, NoOpCondenserConfig, @@ -43,6 +44,7 @@ class EvalMetadata(BaseModel): agent_class: str llm_config: LLMConfig + agent_config: AgentConfig | None = None max_iterations: int eval_output_dir: str start_time: str @@ -167,6 +169,7 @@ def make_metadata( eval_output_dir: str, data_split: str | None = None, details: dict[str, Any] | None = None, + agent_config: AgentConfig | None = None, condenser_config: CondenserConfig | None = None, ) -> EvalMetadata: model_name = llm_config.model.split('/')[-1] @@ -189,6 +192,7 @@ def make_metadata( metadata = EvalMetadata( agent_class=agent_class, llm_config=llm_config, + agent_config=agent_config, max_iterations=max_iterations, eval_output_dir=eval_output_path, start_time=time.strftime('%Y-%m-%d %H:%M:%S'), diff --git a/frontend/__tests__/components/chat/chat-interface.test.tsx b/frontend/__tests__/components/chat/chat-interface.test.tsx index 168715f21943..9411ec473dd8 100644 --- a/frontend/__tests__/components/chat/chat-interface.test.tsx +++ b/frontend/__tests__/components/chat/chat-interface.test.tsx @@ -1,4 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import type { Message } from "#/message"; import { act, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; 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 89780e07aef7..00ac10532202 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 @@ -18,7 +18,6 @@ describe("AccountSettingsContextMenu", () => { it("should always render the right options", () => { render( { expect( screen.getByTestId("account-settings-context-menu"), ).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 () => { - render( - , - ); - - const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS"); - await user.click(accountSettingsOption); - - expect(onClickAccountSettingsMock).toHaveBeenCalledOnce(); - }); - it("should call onLogout when the logout option is clicked", async () => { render( { test("onLogout should be disabled if the user is not logged in", async () => { render( { it("should call onClose when clicking outside of the element", async () => { render( , ); - const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$SETTINGS"); + const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT"); await user.click(accountSettingsButton); await user.click(document.body); diff --git a/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx b/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx index 01f7f5a1378a..4ebfbe3ba57e 100644 --- a/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx +++ b/frontend/__tests__/components/features/analytics/analytics-consent-form-modal.test.tsx @@ -1,6 +1,6 @@ import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal"; import OpenHands from "#/api/open-hands"; @@ -8,7 +8,7 @@ import { SettingsProvider } from "#/context/settings-context"; import { AuthProvider } from "#/context/auth-context"; describe("AnalyticsConsentFormModal", () => { - it("should call saveUserSettings with default settings on confirm reset settings", async () => { + it("should call saveUserSettings with consent", async () => { const user = userEvent.setup(); const onCloseMock = vi.fn(); const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); @@ -26,20 +26,9 @@ describe("AnalyticsConsentFormModal", () => { const confirmButton = screen.getByTestId("confirm-preferences"); await user.click(confirmButton); - expect(saveUserSettingsSpy).toHaveBeenCalledWith({ - user_consents_to_analytics: true, - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - github_token: undefined, - language: "en", - llm_api_key: undefined, - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - unset_github_token: undefined, - }); - expect(onCloseMock).toHaveBeenCalled(); + expect(saveUserSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ user_consents_to_analytics: true }), + ); + await waitFor(() => expect(onCloseMock).toHaveBeenCalled()); }); }); diff --git a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx index 62e2c05e047a..0039a1819b45 100644 --- a/frontend/__tests__/components/features/sidebar/sidebar.test.tsx +++ b/frontend/__tests__/components/features/sidebar/sidebar.test.tsx @@ -1,9 +1,6 @@ -import { screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "test-utils"; import { createRoutesStub } from "react-router"; -import { AxiosError } from "axios"; import { Sidebar } from "#/components/features/sidebar/sidebar"; import OpenHands from "#/api/open-hands"; @@ -21,161 +18,14 @@ const renderSidebar = () => renderWithProviders(); describe("Sidebar", () => { - describe("Settings", () => { - const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); - const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); - afterEach(() => { - vi.clearAllMocks(); - }); - - it("should fetch settings data on mount", () => { - renderSidebar(); - expect(getSettingsSpy).toHaveBeenCalledOnce(); - }); - - it("should send all settings data when saving AI configuration", async () => { - const user = userEvent.setup(); - renderSidebar(); - - const settingsButton = screen.getByTestId("settings-button"); - await user.click(settingsButton); - - const settingsModal = screen.getByTestId("ai-config-modal"); - const saveButton = within(settingsModal).getByTestId( - "save-settings-button", - ); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "en", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - }); - }); - - it("should not reset AI configuration when saving account settings", async () => { - const user = userEvent.setup(); - renderSidebar(); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - - const menu = screen.getByTestId("account-settings-context-menu"); - const accountSettingsButton = within(menu).getByTestId( - "account-settings-button", - ); - await user.click(accountSettingsButton); - - const accountSettingsModal = screen.getByTestId("account-settings-form"); - - const languageInput = - within(accountSettingsModal).getByLabelText(/language/i); - await user.click(languageInput); - - const norskOption = screen.getByText(/norsk/i); - await user.click(norskOption); - - const tokenInput = - within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_LABEL/i); - await user.type(tokenInput, "new-token"); - - const analyticsConsentInput = - within(accountSettingsModal).getByTestId("analytics-consent"); - await user.click(analyticsConsentInput); - - const saveButton = - within(accountSettingsModal).getByTestId("save-settings"); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - github_token: "new-token", - language: "no", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - user_consents_to_analytics: true, - }); - }); - - it("should not send the api key if its SET", async () => { - const user = userEvent.setup(); - renderSidebar(); - - const settingsButton = screen.getByTestId("settings-button"); - await user.click(settingsButton); - - const settingsModal = screen.getByTestId("ai-config-modal"); - - // Click the advanced options switch to show the API key input - const advancedOptionsSwitch = within(settingsModal).getByTestId( - "advanced-option-switch", - ); - await user.click(advancedOptionsSwitch); - - const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i); - await user.type(apiKeyInput, "**********"); - - const saveButton = within(settingsModal).getByTestId( - "save-settings-button", - ); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "en", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - }); - }); + afterEach(() => { + vi.clearAllMocks(); }); - describe("Settings Modal", () => { - it("should open the settings modal if the user clicks the settings button", async () => { - const user = userEvent.setup(); - renderSidebar(); - - expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument(); - - const settingsButton = screen.getByTestId("settings-button"); - await user.click(settingsButton); - - const settingsModal = screen.getByTestId("ai-config-modal"); - expect(settingsModal).toBeInTheDocument(); - }); - - it("should open the settings modal if GET /settings fails with a 404", async () => { - const error = new AxiosError( - "Request failed with status code 404", - "ERR_BAD_REQUEST", - undefined, - undefined, - { - status: 404, - statusText: "Not Found", - data: { message: "Settings not found" }, - headers: {}, - // @ts-expect-error - we only need the response object for this test - config: {}, - }, - ); - - vi.spyOn(OpenHands, "getSettings").mockRejectedValue(error); - - renderSidebar(); - - const settingsModal = await screen.findByTestId("ai-config-modal"); - expect(settingsModal).toBeInTheDocument(); - }); + it("should fetch settings data on mount", () => { + renderSidebar(); + expect(getSettingsSpy).toHaveBeenCalled(); }); }); diff --git a/frontend/__tests__/components/file-operations.test.tsx b/frontend/__tests__/components/file-operations.test.tsx new file mode 100644 index 000000000000..2d2018df90df --- /dev/null +++ b/frontend/__tests__/components/file-operations.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { Messages } from "#/components/features/chat/messages"; +import type { Message } from "#/message"; + +describe("File Operations Messages", () => { + it("should show success indicator for successful file read operation", () => { + const messages: Message[] = [ + { + type: "action", + translationID: "read_file_contents", + content: "Successfully read file contents", + success: true, + sender: "assistant", + timestamp: new Date().toISOString(), + }, + ]; + + render(); + + const statusIcon = screen.getByTestId("status-icon"); + expect(statusIcon).toBeInTheDocument(); + expect(statusIcon.closest("svg")).toHaveClass("fill-success"); + }); + + it("should show failure indicator for failed file read operation", () => { + const messages: Message[] = [ + { + type: "action", + translationID: "read_file_contents", + content: "Failed to read file contents", + success: false, + sender: "assistant", + timestamp: new Date().toISOString(), + }, + ]; + + render(); + + const statusIcon = screen.getByTestId("status-icon"); + expect(statusIcon).toBeInTheDocument(); + expect(statusIcon.closest("svg")).toHaveClass("fill-danger"); + }); + + it("should show success indicator for successful file edit operation", () => { + const messages: Message[] = [ + { + type: "action", + translationID: "edit_file_contents", + content: "Successfully edited file contents", + success: true, + sender: "assistant", + timestamp: new Date().toISOString(), + }, + ]; + + render(); + + const statusIcon = screen.getByTestId("status-icon"); + expect(statusIcon).toBeInTheDocument(); + expect(statusIcon.closest("svg")).toHaveClass("fill-success"); + }); + + it("should show failure indicator for failed file edit operation", () => { + const messages: Message[] = [ + { + type: "action", + translationID: "edit_file_contents", + content: "Failed to edit file contents", + success: false, + sender: "assistant", + timestamp: new Date().toISOString(), + }, + ]; + + render(); + + const statusIcon = screen.getByTestId("status-icon"); + expect(statusIcon).toBeInTheDocument(); + expect(statusIcon.closest("svg")).toHaveClass("fill-danger"); + }); +}); diff --git a/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx b/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx deleted file mode 100644 index 8e1b236722d1..000000000000 --- a/frontend/__tests__/components/modals/settings/account-settings-modal.test.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "test-utils"; -import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal"; -import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; -import OpenHands from "#/api/open-hands"; -import * as ConsentHandlers from "#/utils/handle-capture-consent"; - -describe("AccountSettingsModal", () => { - const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); - const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it.skip("should set the appropriate user analytics consent default", async () => { - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - user_consents_to_analytics: true, - }); - renderWithProviders( {}} />); - - const analyticsConsentInput = screen.getByTestId("analytics-consent"); - await waitFor(() => expect(analyticsConsentInput).toBeChecked()); - }); - - it("should save the users consent to analytics when saving account settings", async () => { - const user = userEvent.setup(); - renderWithProviders( {}} />); - - const analyticsConsentInput = screen.getByTestId("analytics-consent"); - await user.click(analyticsConsentInput); - - const saveButton = screen.getByTestId("save-settings"); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "en", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - user_consents_to_analytics: true, - }); - }); - - it("should call handleCaptureConsent with the analytics consent value if the save is successful", async () => { - const user = userEvent.setup(); - const handleCaptureConsentSpy = vi.spyOn( - ConsentHandlers, - "handleCaptureConsent", - ); - renderWithProviders( {}} />); - - const analyticsConsentInput = screen.getByTestId("analytics-consent"); - await user.click(analyticsConsentInput); - - const saveButton = screen.getByTestId("save-settings"); - await user.click(saveButton); - - expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true); - - await user.click(analyticsConsentInput); - await user.click(saveButton); - - expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false); - }); - - it("should send all settings data when saving account settings", async () => { - const user = userEvent.setup(); - renderWithProviders( {}} />); - - const languageInput = screen.getByLabelText(/language/i); - await user.click(languageInput); - - const norskOption = screen.getByText(/norsk/i); - await user.click(norskOption); - - const tokenInput = screen.getByTestId("github-token-input"); - await user.type(tokenInput, "new-token"); - - const saveButton = screen.getByTestId("save-settings"); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "no", - github_token: "new-token", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - user_consents_to_analytics: false, - }); - }); - - it("should render a checkmark and not the input if the github token is set", async () => { - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, - }); - renderWithProviders( {}} />); - - await waitFor(() => { - const checkmark = screen.queryByTestId("github-token-set-checkmark"); - const input = screen.queryByTestId("github-token-input"); - - expect(checkmark).toBeInTheDocument(); - expect(input).not.toBeInTheDocument(); - }); - }); - - it("should send an unset github token property when pressing disconnect", async () => { - const user = userEvent.setup(); - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, - }); - renderWithProviders( {}} />); - - const disconnectButton = await screen.findByTestId("disconnect-github"); - await user.click(disconnectButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "en", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - unset_github_token: true, - }); - }); - - it("should not unset the github token when changing the language", async () => { - const user = userEvent.setup(); - getSettingsSpy.mockResolvedValue({ - ...MOCK_DEFAULT_USER_SETTINGS, - github_token_is_set: true, - }); - renderWithProviders( {}} />); - - const languageInput = screen.getByLabelText(/language/i); - await user.click(languageInput); - - const norskOption = screen.getByText(/norsk/i); - await user.click(norskOption); - - const saveButton = screen.getByTestId("save-settings"); - await user.click(saveButton); - - expect(saveSettingsSpy).toHaveBeenCalledWith({ - agent: "CodeActAgent", - confirmation_mode: false, - enable_default_condenser: false, - language: "no", - llm_base_url: "", - llm_model: "anthropic/claude-3-5-sonnet-20241022", - remote_runtime_resource_factor: 1, - security_analyzer: "", - user_consents_to_analytics: false, - }); - }); -}); diff --git a/frontend/__tests__/components/modals/settings/brand-button.test.tsx b/frontend/__tests__/components/modals/settings/brand-button.test.tsx new file mode 100644 index 000000000000..784cecc62514 --- /dev/null +++ b/frontend/__tests__/components/modals/settings/brand-button.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { BrandButton } from "#/components/features/settings/brand-button"; + +describe("BrandButton", () => { + const onClickMock = vi.fn(); + + it("should set a test id", () => { + render( + + Test Button + , + ); + + expect(screen.getByTestId("brand-button")).toBeInTheDocument(); + }); + + it("should call onClick when clicked", async () => { + const user = userEvent.setup(); + render( + + Test Button + , + ); + + await user.click(screen.getByText("Test Button")); + }); + + it("should be disabled if isDisabled is true", () => { + render( + + Test Button + , + ); + + expect(screen.getByText("Test Button")).toBeDisabled(); + }); +}); diff --git a/frontend/__tests__/components/modals/settings/model-selector.test.tsx b/frontend/__tests__/components/modals/settings/model-selector.test.tsx index 757f5dcd45ce..cacc6fad1053 100644 --- a/frontend/__tests__/components/modals/settings/model-selector.test.tsx +++ b/frontend/__tests__/components/modals/settings/model-selector.test.tsx @@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; -import { I18nKey } from "#/i18n/declaration"; // Mock react-i18next vi.mock("react-i18next", () => ({ diff --git a/frontend/__tests__/components/settings/settings-input.test.tsx b/frontend/__tests__/components/settings/settings-input.test.tsx new file mode 100644 index 000000000000..6009a2409e83 --- /dev/null +++ b/frontend/__tests__/components/settings/settings-input.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { SettingsInput } from "#/components/features/settings/settings-input"; + +describe("SettingsInput", () => { + it("should render an optional tag if showOptionalTag is true", async () => { + const { rerender } = render( + , + ); + + expect(screen.queryByText(/optional/i)).not.toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByText(/optional/i)).toBeInTheDocument(); + }); + + it("should disable the input if isDisabled is true", async () => { + const { rerender } = render( + , + ); + + expect(screen.getByTestId("test-input")).toBeEnabled(); + + rerender( + , + ); + + expect(screen.getByTestId("test-input")).toBeDisabled(); + }); + + it("should set a placeholder on the input", async () => { + render( + , + ); + + expect(screen.getByTestId("test-input")).toHaveAttribute( + "placeholder", + "Test Placeholder", + ); + }); + + it("should set a default value on the input", async () => { + render( + , + ); + + expect(screen.getByTestId("test-input")).toHaveValue("Test Value"); + }); + + it("should render start content", async () => { + const startContent =
Start Content
; + + render( + , + ); + + expect(screen.getByText("Start Content")).toBeInTheDocument(); + }); +}); diff --git a/frontend/__tests__/components/settings/settings-switch.test.tsx b/frontend/__tests__/components/settings/settings-switch.test.tsx new file mode 100644 index 000000000000..054bbc932823 --- /dev/null +++ b/frontend/__tests__/components/settings/settings-switch.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { SettingsSwitch } from "#/components/features/settings/settings-switch"; + +describe("SettingsSwitch", () => { + it("should call the onChange handler when the input is clicked", async () => { + const user = userEvent.setup(); + const onToggleMock = vi.fn(); + render( + + Test Switch + , + ); + + const switchInput = screen.getByTestId("test-switch"); + + await user.click(switchInput); + expect(onToggleMock).toHaveBeenCalledWith(true); + + await user.click(switchInput); + expect(onToggleMock).toHaveBeenCalledWith(false); + }); + + it("should render a beta tag if isBeta is true", () => { + const { rerender } = render( + + Test Switch + , + ); + + expect(screen.queryByText(/beta/i)).not.toBeInTheDocument(); + + rerender( + + Test Switch + , + ); + + expect(screen.getByText(/beta/i)).toBeInTheDocument(); + }); + + it("should be able to set a default toggle state", async () => { + const user = userEvent.setup(); + const onToggleMock = vi.fn(); + render( + + Test Switch + , + ); + + expect(screen.getByTestId("test-switch")).toBeChecked(); + + const switchInput = screen.getByTestId("test-switch"); + await user.click(switchInput); + expect(onToggleMock).toHaveBeenCalledWith(false); + + expect(screen.getByTestId("test-switch")).not.toBeChecked(); + }); +}); diff --git a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx index 06d1628e1f74..d1d623f137f5 100644 --- a/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx +++ b/frontend/__tests__/components/shared/modals/settings/settings-form.test.tsx @@ -1,36 +1,22 @@ -import { screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi, afterEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "test-utils"; import { createRoutesStub } from "react-router"; -import userEvent from "@testing-library/user-event"; -import { DEFAULT_SETTINGS } from "#/services/settings"; -import { SettingsForm } from "#/components/shared/modals/settings/settings-form"; +import { screen } from "@testing-library/react"; import OpenHands from "#/api/open-hands"; +import { SettingsForm } from "#/components/shared/modals/settings/settings-form"; +import { DEFAULT_SETTINGS } from "#/services/settings"; describe("SettingsForm", () => { - const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); - const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); - const onCloseMock = vi.fn(); + const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); - afterEach(() => { - vi.clearAllMocks(); - }); - - getConfigSpy.mockResolvedValue({ - APP_MODE: "saas", - GITHUB_CLIENT_ID: "123", - POSTHOG_CLIENT_KEY: "123", - }); - - const RouterStub = createRoutesStub([ + const RouteStub = createRoutesStub([ { Component: () => ( ), @@ -38,39 +24,17 @@ describe("SettingsForm", () => { }, ]); - it("should not show runtime size selector by default", () => { - renderWithProviders(); - expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument(); - }); - - it("should show runtime size selector when advanced options are enabled", async () => { - const user = userEvent.setup(); - renderWithProviders(); - - const toggleAdvancedMode = screen.getByTestId("advanced-option-switch"); - await user.click(toggleAdvancedMode); - - await screen.findByTestId("runtime-size"); - }); - - it("should not submit the form if required fields are empty", async () => { + it("should save the user settings and close the modal when the form is submitted", async () => { const user = userEvent.setup(); - renderWithProviders(); - - expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument(); - - const toggleAdvancedMode = screen.getByTestId("advanced-option-switch"); - await user.click(toggleAdvancedMode); - - const customModelInput = screen.getByTestId("custom-model-input"); - expect(customModelInput).toBeInTheDocument(); - - await user.clear(customModelInput); + renderWithProviders(); - const saveButton = screen.getByTestId("save-settings-button"); + const saveButton = screen.getByRole("button", { name: /save/i }); await user.click(saveButton); - expect(saveSettingsSpy).not.toHaveBeenCalled(); - expect(onCloseMock).not.toHaveBeenCalled(); + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + llm_model: DEFAULT_SETTINGS.LLM_MODEL, + }), + ); }); }); diff --git a/frontend/__tests__/components/user-actions.test.tsx b/frontend/__tests__/components/user-actions.test.tsx index 143af7d7113f..3ce7e308d59c 100644 --- a/frontend/__tests__/components/user-actions.test.tsx +++ b/frontend/__tests__/components/user-actions.test.tsx @@ -14,24 +14,14 @@ describe("UserActions", () => { }); it("should render", () => { - render( - , - ); + render(); expect(screen.getByTestId("user-actions")).toBeInTheDocument(); expect(screen.getByTestId("user-avatar")).toBeInTheDocument(); }); it("should toggle the user menu when the user avatar is clicked", async () => { - render( - , - ); + render(); const userAvatar = screen.getByTestId("user-avatar"); await user.click(userAvatar); @@ -47,30 +37,9 @@ describe("UserActions", () => { ).not.toBeInTheDocument(); }); - it("should call onClickAccountSettings and close the menu when the account settings option is clicked", async () => { - render( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - await user.click(userAvatar); - - const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS"); - await user.click(accountSettingsOption); - - expect(onClickAccountSettingsMock).toHaveBeenCalledOnce(); - expect( - screen.queryByTestId("account-settings-context-menu"), - ).not.toBeInTheDocument(); - }); - it("should call onLogout and close the menu when the logout option is clicked", async () => { render( , @@ -89,12 +58,7 @@ describe("UserActions", () => { }); test("onLogout should not be called when the user is not logged in", async () => { - render( - , - ); + render(); const userAvatar = screen.getByTestId("user-avatar"); await user.click(userAvatar); @@ -104,21 +68,4 @@ describe("UserActions", () => { expect(onLogoutMock).not.toHaveBeenCalled(); }); - - // FIXME: Spinner now provided through useQuery - it.skip("should display the loading spinner", () => { - render( - , - ); - - const userAvatar = screen.getByTestId("user-avatar"); - user.click(userAvatar); - - expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); - expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument(); - }); }); diff --git a/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx new file mode 100644 index 000000000000..2cf5dce1d47f --- /dev/null +++ b/frontend/__tests__/hooks/mutation/use-save-settings.test.tsx @@ -0,0 +1,36 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import OpenHands from "#/api/open-hands"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; + +describe("useSaveSettings", () => { + it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => { + const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); + const { result } = renderHook(() => useSaveSettings(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + result.current.mutate({ LLM_API_KEY: "" }); + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + llm_api_key: "", + }), + ); + }); + + result.current.mutate({ LLM_API_KEY: null }); + await waitFor(() => { + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + llm_api_key: undefined, + }), + ); + }); + }); +}); diff --git a/frontend/__tests__/i18n/translations.test.tsx b/frontend/__tests__/i18n/translations.test.tsx index 3833b4d306d1..01a0bebffe2a 100644 --- a/frontend/__tests__/i18n/translations.test.tsx +++ b/frontend/__tests__/i18n/translations.test.tsx @@ -1,20 +1,21 @@ -import { screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import i18n from '../../src/i18n'; -import { AccountSettingsContextMenu } from '../../src/components/features/context-menu/account-settings-context-menu'; -import { renderWithProviders } from '../../test-utils'; +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import i18n from "../../src/i18n"; +import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu"; +import { renderWithProviders } from "../../test-utils"; -describe('Translations', () => { - it('should render translated text', () => { - i18n.changeLanguage('en'); +describe("Translations", () => { + it("should render translated text", () => { + i18n.changeLanguage("en"); renderWithProviders( {}} onLogout={() => {}} onClose={() => {}} - isLoggedIn={true} - /> + isLoggedIn + />, ); - expect(screen.getByTestId('account-settings-context-menu')).toBeInTheDocument(); + expect( + screen.getByTestId("account-settings-context-menu"), + ).toBeInTheDocument(); }); }); diff --git a/frontend/__tests__/routes/home.test.tsx b/frontend/__tests__/routes/home.test.tsx new file mode 100644 index 000000000000..ec7a24761f60 --- /dev/null +++ b/frontend/__tests__/routes/home.test.tsx @@ -0,0 +1,114 @@ +import { createRoutesStub } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "test-utils"; +import userEvent from "@testing-library/user-event"; +import { screen } from "@testing-library/react"; +import { AxiosError } from "axios"; +import MainApp from "#/routes/_oh/route"; +import SettingsScreen from "#/routes/settings"; +import Home from "#/routes/_oh._index/route"; +import OpenHands from "#/api/open-hands"; + +const createAxiosNotFoundErrorObject = () => + new AxiosError( + "Request failed with status code 404", + "ERR_BAD_REQUEST", + undefined, + undefined, + { + status: 404, + statusText: "Not Found", + data: { message: "Settings not found" }, + headers: {}, + // @ts-expect-error - we only need the response object for this test + config: {}, + }, + ); + +describe("Home Screen", () => { + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); + + const RouterStub = createRoutesStub([ + { + // layout route + Component: MainApp, + path: "/", + children: [ + { + // home route + Component: Home, + path: "/", + }, + ], + }, + { + Component: SettingsScreen, + path: "/settings", + }, + ]); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should render the home screen", () => { + renderWithProviders(); + }); + + it("should navigate to the settings screen when the settings button is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const settingsButton = await screen.findByTestId("settings-button"); + await user.click(settingsButton); + + const settingsScreen = await screen.findByTestId("settings-screen"); + expect(settingsScreen).toBeInTheDocument(); + }); + + it("should navigate to the settings when pressing 'Connect to GitHub' if the user isn't authenticated", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const connectToGitHubButton = + await screen.findByTestId("connect-to-github"); + await user.click(connectToGitHubButton); + + const settingsScreen = await screen.findByTestId("settings-screen"); + expect(settingsScreen).toBeInTheDocument(); + }); + + describe("Settings 404", () => { + it("should open the settings modal if GET /settings fails with a 404", async () => { + const error = createAxiosNotFoundErrorObject(); + getSettingsSpy.mockRejectedValue(error); + + renderWithProviders(); + + const settingsModal = await screen.findByTestId("ai-config-modal"); + expect(settingsModal).toBeInTheDocument(); + }); + + it("should navigate to the settings screen when clicking the advanced settings button", async () => { + const error = createAxiosNotFoundErrorObject(); + getSettingsSpy.mockRejectedValue(error); + + const user = userEvent.setup(); + renderWithProviders(); + + const settingsModal = await screen.findByTestId("ai-config-modal"); + expect(settingsModal).toBeInTheDocument(); + + const advancedSettingsButton = await screen.findByTestId( + "advanced-settings-link", + ); + await user.click(advancedSettingsButton); + + const settingsModalAfter = screen.queryByTestId("ai-config-modal"); + expect(settingsModalAfter).not.toBeInTheDocument(); + + const settingsScreen = await screen.findByTestId("settings-screen"); + expect(settingsScreen).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/routes/settings.test.tsx b/frontend/__tests__/routes/settings.test.tsx new file mode 100644 index 000000000000..2052d6eb9cbe --- /dev/null +++ b/frontend/__tests__/routes/settings.test.tsx @@ -0,0 +1,873 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import { createRoutesStub } from "react-router"; +import { afterEach, describe, expect, it, test, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import userEvent, { UserEvent } from "@testing-library/user-event"; +import OpenHands from "#/api/open-hands"; +import { AuthProvider } from "#/context/auth-context"; +import SettingsScreen from "#/routes/settings"; +import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set"; +import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; +import { PostApiSettings } from "#/types/settings"; +import * as ConsentHandlers from "#/utils/handle-capture-consent"; + +const toggleAdvancedSettings = async (user: UserEvent) => { + const advancedSwitch = await screen.findByTestId("advanced-settings-switch"); + await user.click(advancedSwitch); +}; + +describe("Settings Screen", () => { + const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); + const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); + const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); + + const { handleLogoutMock } = vi.hoisted(() => ({ + handleLogoutMock: vi.fn(), + })); + vi.mock("#/hooks/use-app-logout", () => ({ + useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }), + })); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const RouterStub = createRoutesStub([ + { + Component: SettingsScreen, + path: "/settings", + }, + ]); + + const renderSettingsScreen = () => { + const queryClient = new QueryClient(); + return render(, { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + }; + + it("should render", async () => { + renderSettingsScreen(); + + await waitFor(() => { + screen.getByText("LLM Settings"); + screen.getByText("GitHub Settings"); + screen.getByText("Additional Settings"); + screen.getByText("Reset to defaults"); + screen.getByText("Save Changes"); + }); + }); + + describe("Account Settings", () => { + it("should render the account settings", async () => { + renderSettingsScreen(); + + await waitFor(() => { + screen.getByTestId("github-token-input"); + screen.getByTestId("github-token-help-anchor"); + screen.getByTestId("language-input"); + screen.getByTestId("enable-analytics-switch"); + }); + }); + + it("should render an indicator if the GitHub token is not set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: false, + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("github-token-input"); + const inputParent = input.parentElement; + + if (inputParent) { + const badge = within(inputParent).getByTestId("unset-indicator"); + expect(badge).toBeInTheDocument(); + } else { + throw new Error("GitHub token input parent not found"); + } + }); + }); + + it("should render an indicator if the GitHub token is set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: true, + }); + + renderSettingsScreen(); + + const input = await screen.findByTestId("github-token-input"); + const inputParent = input.parentElement; + + if (inputParent) { + const badge = await within(inputParent).findByTestId("set-indicator"); + expect(badge).toBeInTheDocument(); + } else { + throw new Error("GitHub token input parent not found"); + } + }); + + it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: false, + }); + + renderSettingsScreen(); + + const button = await screen.findByText("Disconnect from GitHub"); + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + }); + + it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: true, + }); + + renderSettingsScreen(); + const button = await screen.findByText("Disconnect from GitHub"); + expect(button).toBeInTheDocument(); + expect(button).toBeEnabled(); + + // input should still be rendered + const input = await screen.findByTestId("github-token-input"); + expect(input).toBeInTheDocument(); + }); + + it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => { + const user = userEvent.setup(); + + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: true, + }); + + renderSettingsScreen(); + + const button = await screen.findByText("Disconnect from GitHub"); + await user.click(button); + + expect(handleLogoutMock).toHaveBeenCalled(); + }); + + it("should not render the 'Configure GitHub Repositories' button if OSS mode", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + const button = screen.queryByText("Configure GitHub Repositories"); + expect(button).not.toBeInTheDocument(); + }); + + it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + APP_SLUG: "test-app", + }); + + renderSettingsScreen(); + await screen.findByText("Configure GitHub Repositories"); + }); + + it("should not render the GitHub token input if SaaS mode", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.queryByTestId("github-token-input"); + const helpAnchor = screen.queryByTestId("github-token-help-anchor"); + + expect(input).not.toBeInTheDocument(); + expect(helpAnchor).not.toBeInTheDocument(); + }); + }); + + it.skip("should not reset LLM Provider and Model if GitHub token is invalid", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + github_token_is_set: false, + llm_model: "anthropic/claude-3-5-sonnet-20241022", + }); + saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token")); + + renderSettingsScreen(); + + let llmProviderInput = await screen.findByTestId("llm-provider-input"); + let llmModelInput = await screen.findByTestId("llm-model-input"); + + expect(llmProviderInput).toHaveValue("Anthropic"); + expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022"); + + const input = await screen.findByTestId("github-token-input"); + await user.type(input, "invalid-token"); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + llmProviderInput = await screen.findByTestId("llm-provider-input"); + llmModelInput = await screen.findByTestId("llm-model-input"); + + expect(llmProviderInput).toHaveValue("Anthropic"); + expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022"); + }); + + test("enabling advanced, enabling confirmation mode, and then disabling + enabling advanced should not render the security analyzer input", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const confirmationModeSwitch = await screen.findByTestId( + "enable-confirmation-mode-switch", + ); + await user.click(confirmationModeSwitch); + + let securityAnalyzerInput = screen.queryByTestId( + "security-analyzer-input", + ); + expect(securityAnalyzerInput).toBeInTheDocument(); + + await toggleAdvancedSettings(user); + + securityAnalyzerInput = screen.queryByTestId("security-analyzer-input"); + expect(securityAnalyzerInput).not.toBeInTheDocument(); + + await toggleAdvancedSettings(user); + + securityAnalyzerInput = screen.queryByTestId("security-analyzer-input"); + expect(securityAnalyzerInput).not.toBeInTheDocument(); + }); + }); + + describe("LLM Settings", () => { + it("should render the basic LLM settings by default", async () => { + renderSettingsScreen(); + + await waitFor(() => { + screen.getByTestId("advanced-settings-switch"); + screen.getByTestId("llm-provider-input"); + screen.getByTestId("llm-model-input"); + screen.getByTestId("llm-api-key-input"); + screen.getByTestId("llm-api-key-help-anchor"); + }); + }); + + it("should render the advanced LLM settings if the advanced switch is toggled", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + // Should not render the advanced settings by default + expect( + screen.queryByTestId("llm-custom-model-input"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("security-analyzer-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("enable-confirmation-mode-switch"), + ).not.toBeInTheDocument(); + + const advancedSwitch = await screen.findByTestId( + "advanced-settings-switch", + ); + await user.click(advancedSwitch); + + // Should render the advanced settings + expect( + screen.queryByTestId("llm-provider-input"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("llm-model-input")).not.toBeInTheDocument(); + + screen.getByTestId("llm-custom-model-input"); + screen.getByTestId("base-url-input"); + screen.getByTestId("agent-input"); + + // "Invariant" security analyzer + screen.getByTestId("enable-confirmation-mode-switch"); + + // Not rendered until the switch is toggled + // screen.getByTestId("security-analyzer-input"); + }); + + it("should render an indicator if the LLM API key is not set", async () => { + getSettingsSpy.mockResolvedValueOnce({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_api_key: null, + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("llm-api-key-input"); + const inputParent = input.parentElement; + + if (inputParent) { + const badge = within(inputParent).getByTestId("unset-indicator"); + expect(badge).toBeInTheDocument(); + } else { + throw new Error("LLM API Key input parent not found"); + } + }); + }); + + it("should render an indicator if the LLM API key is set", async () => { + getSettingsSpy.mockResolvedValueOnce({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_api_key: "**********", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("llm-api-key-input"); + const inputParent = input.parentElement; + + if (inputParent) { + const badge = within(inputParent).getByTestId("set-indicator"); + expect(badge).toBeInTheDocument(); + } else { + throw new Error("LLM API Key input parent not found"); + } + }); + }); + + it("should set asterik placeholder if the LLM API key is set", async () => { + getSettingsSpy.mockResolvedValueOnce({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_api_key: "**********", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const input = screen.getByTestId("llm-api-key-input"); + expect(input).toHaveProperty("placeholder", "**********"); + }); + }); + + describe("Basic Model Selector", () => { + it("should set the provider and model", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_model: "anthropic/claude-3-5-sonnet-20241022", + }); + + renderSettingsScreen(); + + await waitFor(() => { + const providerInput = screen.getByTestId("llm-provider-input"); + const modelInput = screen.getByTestId("llm-model-input"); + + expect(providerInput).toHaveValue("Anthropic"); + expect(modelInput).toHaveValue("claude-3-5-sonnet-20241022"); + }); + }); + + it.todo("should change the model values if the provider is changed"); + + it.todo("should clear the model values if the provider is cleared"); + }); + + describe("Advanced LLM Settings", () => { + it("should not render the runtime settings input if OSS mode", async () => { + const user = userEvent.setup(); + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + const input = screen.queryByTestId("runtime-settings-input"); + expect(input).not.toBeInTheDocument(); + }); + + it("should render the runtime settings input if SaaS mode", async () => { + const user = userEvent.setup(); + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + screen.getByTestId("runtime-settings-input"); + }); + + it("should set the default runtime setting set", async () => { + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + remote_runtime_resource_factor: 1, + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(userEvent.setup()); + + const input = await screen.findByTestId("runtime-settings-input"); + expect(input).toHaveValue("1x (2 core, 8G)"); + }); + + it("should save the runtime settings when the 'Save Changes' button is clicked", async () => { + const user = userEvent.setup(); + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + GITHUB_CLIENT_ID: "123", + POSTHOG_CLIENT_KEY: "456", + }); + + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + }); + + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const input = await screen.findByTestId("runtime-settings-input"); + await user.click(input); + + const option = await screen.findByText("2x (4 core, 16G)"); + await user.click(option); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + remote_runtime_resource_factor: 2, + }), + ); + }); + + test("saving with no changes but having advanced enabled should hide the advanced items", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + await waitFor(() => { + expect( + screen.queryByTestId("llm-custom-model-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("base-url-input"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("security-analyzer-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("enable-confirmation-mode-switch"), + ).not.toBeInTheDocument(); + }); + }); + + test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const resetButton = screen.getByText("Reset to defaults"); + await user.click(resetButton); + + // show modal + const modal = await screen.findByTestId("reset-modal"); + expect(modal).toBeInTheDocument(); + + // confirm reset + const confirmButton = within(modal).getByText("Reset"); + await user.click(confirmButton); + + await waitFor(() => { + expect( + screen.queryByTestId("llm-custom-model-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("base-url-input"), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument(); + expect( + screen.queryByTestId("security-analyzer-input"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("enable-confirmation-mode-switch"), + ).not.toBeInTheDocument(); + }); + }); + + it("should save if only confirmation mode is enabled", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + await toggleAdvancedSettings(user); + + const confirmationModeSwitch = await screen.findByTestId( + "enable-confirmation-mode-switch", + ); + await user.click(confirmationModeSwitch); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + confirmation_mode: true, + }), + ); + }); + }); + + it("should toggle advanced if user had set a custom model", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + llm_model: "some/custom-model", + }); + renderSettingsScreen(); + + await waitFor(() => { + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + expect(advancedSwitch).toBeChecked(); + + const llmCustomInput = screen.getByTestId("llm-custom-model-input"); + expect(llmCustomInput).toBeInTheDocument(); + expect(llmCustomInput).toHaveValue("some/custom-model"); + }); + }); + + it("should have advanced settings enabled if the user previously had them enabled", async () => { + const hasAdvancedSettingsSetSpy = vi.spyOn( + AdvancedSettingsUtlls, + "hasAdvancedSettingsSet", + ); + hasAdvancedSettingsSetSpy.mockReturnValue(true); + + renderSettingsScreen(); + + await waitFor(() => { + const advancedSwitch = screen.getByTestId("advanced-settings-switch"); + expect(advancedSwitch).toBeChecked(); + + const llmCustomInput = screen.getByTestId("llm-custom-model-input"); + expect(llmCustomInput).toBeInTheDocument(); + }); + }); + + it("should have confirmation mode enabled if the user previously had it enabled", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + confirmation_mode: true, + }); + + renderSettingsScreen(); + + await waitFor(() => { + const confirmationModeSwitch = screen.getByTestId( + "enable-confirmation-mode-switch", + ); + expect(confirmationModeSwitch).toBeChecked(); + }); + }); + + // FIXME: security analyzer is not found for some reason... + it.skip("should have the values set if the user previously had them set", async () => { + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + language: "no", + github_token_is_set: true, + user_consents_to_analytics: true, + llm_base_url: "https://test.com", + llm_model: "anthropic/claude-3-5-sonnet-20241022", + agent: "CoActAgent", + security_analyzer: "mock-invariant", + }); + + renderSettingsScreen(); + + await waitFor(() => { + expect(screen.getByTestId("language-input")).toHaveValue("Norsk"); + expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument(); + expect(screen.getByTestId("enable-analytics-switch")).toBeChecked(); + expect(screen.getByTestId("advanced-settings-switch")).toBeChecked(); + expect(screen.getByTestId("base-url-input")).toHaveValue( + "https://test.com", + ); + expect(screen.getByTestId("llm-custom-model-input")).toHaveValue( + "anthropic/claude-3-5-sonnet-20241022", + ); + expect(screen.getByTestId("agent-input")).toHaveValue("CoActAgent"); + expect( + screen.getByTestId("enable-confirmation-mode-switch"), + ).toBeChecked(); + expect(screen.getByTestId("security-analyzer-input")).toHaveValue( + "mock-invariant", + ); + }); + }); + + it("should save the settings when the 'Save Changes' button is clicked", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + }); + + renderSettingsScreen(); + + const languageInput = await screen.findByTestId("language-input"); + await user.click(languageInput); + + const norskOption = await screen.findByText("Norsk"); + await user.click(norskOption); + + expect(languageInput).toHaveValue("Norsk"); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + llm_api_key: undefined, + github_token: undefined, + language: "no", + }), + ); + }); + + it("should properly save basic LLM model settings", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + }); + + renderSettingsScreen(); + + // disable advanced mode + const advancedSwitch = await screen.findByTestId( + "advanced-settings-switch", + ); + await user.click(advancedSwitch); + + const providerInput = await screen.findByTestId("llm-provider-input"); + await user.click(providerInput); + + const openaiOption = await screen.findByText("OpenAI"); + await user.click(openaiOption); + + const modelInput = await screen.findByTestId("llm-model-input"); + await user.click(modelInput); + + const gpt4Option = await screen.findByText("gpt-4o"); + await user.click(gpt4Option); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + github_token: undefined, + llm_api_key: undefined, + llm_model: "openai/gpt-4o", + }), + ); + }); + + it("should reset the settings when the 'Reset to defaults' button is clicked", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); + + renderSettingsScreen(); + + const languageInput = await screen.findByTestId("language-input"); + await user.click(languageInput); + + const norskOption = await screen.findByText("Norsk"); + await user.click(norskOption); + + expect(languageInput).toHaveValue("Norsk"); + + const resetButton = screen.getByText("Reset to defaults"); + await user.click(resetButton); + + expect(saveSettingsSpy).not.toHaveBeenCalled(); + + // show modal + const modal = await screen.findByTestId("reset-modal"); + expect(modal).toBeInTheDocument(); + + // confirm reset + const confirmButton = within(modal).getByText("Reset"); + await user.click(confirmButton); + + const mockCopy: Partial = { + ...MOCK_DEFAULT_USER_SETTINGS, + }; + delete mockCopy.github_token_is_set; + delete mockCopy.unset_github_token; + delete mockCopy.user_consents_to_analytics; + + expect(saveSettingsSpy).toHaveBeenCalledWith({ + ...mockCopy, + github_token: undefined, // not set + llm_api_key: "", // reset as well + }); + expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument(); + }); + + it("should cancel the reset when the 'Cancel' button is clicked", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS); + + renderSettingsScreen(); + + const resetButton = await screen.findByText("Reset to defaults"); + await user.click(resetButton); + + const modal = await screen.findByTestId("reset-modal"); + expect(modal).toBeInTheDocument(); + + const cancelButton = within(modal).getByText("Cancel"); + await user.click(cancelButton); + + expect(saveSettingsSpy).not.toHaveBeenCalled(); + expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument(); + }); + + it("should call handleCaptureConsent with true if the save is successful", async () => { + const user = userEvent.setup(); + const handleCaptureConsentSpy = vi.spyOn( + ConsentHandlers, + "handleCaptureConsent", + ); + renderSettingsScreen(); + + const analyticsConsentInput = await screen.findByTestId( + "enable-analytics-switch", + ); + + expect(analyticsConsentInput).not.toBeChecked(); + await user.click(analyticsConsentInput); + expect(analyticsConsentInput).toBeChecked(); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true); + }); + + it("should call handleCaptureConsent with false if the save is successful", async () => { + const user = userEvent.setup(); + const handleCaptureConsentSpy = vi.spyOn( + ConsentHandlers, + "handleCaptureConsent", + ); + renderSettingsScreen(); + + const saveButton = await screen.findByText("Save Changes"); + await user.click(saveButton); + + expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false); + }); + + it("should not reset analytics consent when resetting to defaults", async () => { + const user = userEvent.setup(); + getSettingsSpy.mockResolvedValue({ + ...MOCK_DEFAULT_USER_SETTINGS, + user_consents_to_analytics: true, + }); + + renderSettingsScreen(); + + const analyticsConsentInput = await screen.findByTestId( + "enable-analytics-switch", + ); + expect(analyticsConsentInput).toBeChecked(); + + const resetButton = await screen.findByText("Reset to defaults"); + await user.click(resetButton); + + const modal = await screen.findByTestId("reset-modal"); + const confirmButton = within(modal).getByText("Reset"); + await user.click(confirmButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ user_consents_to_analytics: undefined }), + ); + }); + + it("should render the security analyzer input if the confirmation mode is enabled", async () => { + const user = userEvent.setup(); + renderSettingsScreen(); + + let securityAnalyzerInput = screen.queryByTestId( + "security-analyzer-input", + ); + expect(securityAnalyzerInput).not.toBeInTheDocument(); + + const confirmationModeSwitch = await screen.findByTestId( + "enable-confirmation-mode-switch", + ); + await user.click(confirmationModeSwitch); + + securityAnalyzerInput = await screen.findByTestId( + "security-analyzer-input", + ); + expect(securityAnalyzerInput).toBeInTheDocument(); + }); + + // FIXME: localStorage isn't being set + it.skip("should save with ENABLE_DEFAULT_CONDENSER with true if user set the feature flag in local storage", async () => { + localStorage.setItem("ENABLE_DEFAULT_CONDENSER", "true"); + + const user = userEvent.setup(); + renderSettingsScreen(); + + const saveButton = screen.getByText("Save Changes"); + await user.click(saveButton); + + expect(saveSettingsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + enable_default_condenser: true, + }), + ); + }); + }); +}); diff --git a/frontend/__tests__/utils/has-advanced-settings-set.test.ts b/frontend/__tests__/utils/has-advanced-settings-set.test.ts new file mode 100644 index 000000000000..73568ccd98e2 --- /dev/null +++ b/frontend/__tests__/utils/has-advanced-settings-set.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, test } from "vitest"; +import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set"; +import { DEFAULT_SETTINGS } from "#/services/settings"; + +describe("hasAdvancedSettingsSet", () => { + it("should return false by default", () => { + expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false); + }); + + describe("should be true if", () => { + test("LLM_BASE_URL is set", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + LLM_BASE_URL: "test", + }), + ).toBe(true); + }); + + test("AGENT is not default value", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + AGENT: "test", + }), + ).toBe(true); + }); + + test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + REMOTE_RUNTIME_RESOURCE_FACTOR: 999, + }), + ).toBe(true); + }); + + test("CONFIRMATION_MODE is true", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + CONFIRMATION_MODE: true, + }), + ).toBe(true); + }); + + test("SECURITY_ANALYZER is set", () => { + expect( + hasAdvancedSettingsSet({ + ...DEFAULT_SETTINGS, + SECURITY_ANALYZER: "test", + }), + ).toBe(true); + }); + }); +}); diff --git a/frontend/__tests__/utils/is-custom-model.test.ts b/frontend/__tests__/utils/is-custom-model.test.ts new file mode 100644 index 000000000000..1da5667920ae --- /dev/null +++ b/frontend/__tests__/utils/is-custom-model.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isCustomModel } from "#/utils/is-custom-model"; + +describe("isCustomModel", () => { + const models = ["anthropic/claude-3.5", "openai/gpt-3.5-turbo", "gpt-4o"]; + + it("should return false by default", () => { + expect(isCustomModel(models, "")).toBe(false); + }); + + it("should be true if it is a custom model", () => { + expect(isCustomModel(models, "some/model")).toBe(true); + }); + + it("should be false if it is not a custom model", () => { + expect(isCustomModel(models, "anthropic/claude-3.5")).toBe(false); + expect(isCustomModel(models, "openai/gpt-3.5-turbo")).toBe(false); + expect(isCustomModel(models, "openai/gpt-4o")).toBe(false); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f310be528248..0115a8c6934b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "openhands-frontend", - "version": "0.23.0", + "version": "0.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openhands-frontend", - "version": "0.23.0", + "version": "0.24.0", "dependencies": { "@heroui/react": "2.6.14", "@monaco-editor/react": "^4.7.0-rc.0", @@ -21,14 +21,14 @@ "axios": "^1.7.9", "clsx": "^2.1.1", "eslint-config-airbnb-typescript": "^18.0.0", - "framer-motion": "^12.3.0", + "framer-motion": "^12.4.2", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.2", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.22", "jose": "^5.9.4", "monaco-editor": "^0.52.2", - "posthog-js": "^1.215.3", + "posthog-js": "^1.217.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-highlight": "^0.15.0", @@ -40,7 +40,7 @@ "react-router": "^7.1.5", "react-syntax-highlighter": "^15.6.1", "react-textarea-autosize": "^8.5.7", - "remark-gfm": "^4.0.0", + "remark-gfm": "^4.0.1", "sirv-cli": "^3.0.0", "socket.io-client": "^4.8.1", "tailwind-merge": "^3.0.1", @@ -53,12 +53,12 @@ "@playwright/test": "^1.50.1", "@react-router/dev": "^7.1.5", "@tailwindcss/typography": "^0.5.16", - "@tanstack/eslint-plugin-query": "^5.66.0", + "@tanstack/eslint-plugin-query": "^5.66.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.1", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.13.1", + "@types/node": "^22.13.2", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@types/react-highlight": "^0.12.8", @@ -82,8 +82,8 @@ "jsdom": "^26.0.0", "lint-staged": "^15.4.3", "msw": "^2.6.6", - "postcss": "^8.5.1", - "prettier": "^3.4.2", + "postcss": "^8.5.2", + "prettier": "^3.5.1", "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "vite-plugin-svgr": "^4.2.0", @@ -95,9 +95,9 @@ } }, "node_modules/@adobe/css-tools": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.1.tgz", - "integrity": "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", + "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==", "dev": true, "license": "MIT" }, @@ -162,30 +162,31 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", - "integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.7.tgz", - "integrity": "sha512-SRijHmF0PSPgLIBYlWnG0hyeJLwXE2CgpsXaMOrtt2yp9/86ALw6oUlj9KYuZ0JN07T4eBMVIW4li/9S1j2BGA==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.8.tgz", + "integrity": "sha512-l+lkXCHS6tQEc5oUpK28xBOZ6+HwaH7YwoYQbLFiYb4nS2/l1tKnZEtEWkD0GuiYdvArf9qBS0XlQGXzPMsNqQ==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.5", + "@babel/generator": "^7.26.8", "@babel/helper-compilation-targets": "^7.26.5", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.7", - "@babel/parser": "^7.26.7", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.26.7", - "@babel/types": "^7.26.7", + "@babel/parser": "^7.26.8", + "@babel/template": "^7.26.8", + "@babel/traverse": "^7.26.8", + "@babel/types": "^7.26.8", + "@types/gensync": "^1.0.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -210,13 +211,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", - "integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.8.tgz", + "integrity": "sha512-ef383X5++iZHWAXX0SXQR6ZyQhw/0KtTkrTz61WXRhFM6dhpHulO/RJz79L8S6ugZHJkOOkUrUdxgdF2YiPFnA==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.5", - "@babel/types": "^7.26.5", + "@babel/parser": "^7.26.8", + "@babel/types": "^7.26.8", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -434,12 +435,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.8.tgz", + "integrity": "sha512-TZIQ25pkSoaKEYYaHbbxkfL36GNsQ6iFiBbeuzAkLnXayKR1yP1zFe+NxuZWWsUyvt8icPU9CCq0sgWGXR1GEw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.7" + "@babel/types": "^7.26.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -544,9 +545,9 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.7.tgz", - "integrity": "sha512-5cJurntg+AT+cgelGP9Bt788DKiAw9gIMSMU2NJrLAilnj0m8WZWUNZPSLOmadYsujHutpgElO+50foX+ib/Wg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.26.8.tgz", + "integrity": "sha512-bME5J9AC8ChwA7aEPJ6zym3w7aObZULHhbNLU0bKUhKsAkylkzUdq+0kdymh9rzi8nlNFl2bmldFBCKNJBUpuw==", "dev": true, "license": "MIT", "dependencies": { @@ -596,30 +597,30 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.8.tgz", + "integrity": "sha512-iNKaX3ZebKIsCvJ+0jd6embf+Aulaa3vNBqZ41kM7iTWjx5qzWKXGHiJUW3+nTpQ18SG11hdF8OAzKrpXkb96Q==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.26.8", + "@babel/types": "^7.26.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", - "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.8.tgz", + "integrity": "sha512-nic9tRkjYH0oB2dzr/JoGIm+4Q6SuYeLEiIiZDwBscRMYFJ+tMAz98fuel9ZnbXViA2I0HVSSRRK8DW5fjXStA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.5", - "@babel/parser": "^7.26.7", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7", + "@babel/generator": "^7.26.8", + "@babel/parser": "^7.26.8", + "@babel/template": "^7.26.8", + "@babel/types": "^7.26.8", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -628,9 +629,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", - "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", + "version": "7.26.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.8.tgz", + "integrity": "sha512-eUuWapzEGWFEpHFxgEaBG8e3n6S8L3MSu0oda755rOfabWPnh0Our1AozNFVUxGFIhbKgd1ksprsoDGMinTOTA==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -1349,13 +1350,13 @@ } }, "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.2.tgz", - "integrity": "sha512-6sE5nyvDloULiyOMbOTJEEgWL32w+VHkZQs8S02Lnn8Y/O5aQhjOEXwWzvR7SsBE/exxlSpY2EsWZgqHbtLatg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.3.tgz", + "integrity": "sha512-pJT1OkhplSmvvr6i3CWTPvC/FGC06MbN5TNBfRO6Ox62AEz90eMq+dVvtX9Bl3jxCEkS0tATzDarRZuOLw7oFg==", "license": "MIT", "dependencies": { "@formatjs/fast-memoize": "2.2.6", - "@formatjs/intl-localematcher": "0.5.10", + "@formatjs/intl-localematcher": "0.6.0", "decimal.js": "10", "tslib": "2" } @@ -1370,30 +1371,30 @@ } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.0.tgz", - "integrity": "sha512-Hp81uTjjdTk3FLh/dggU5NK7EIsVWc5/ZDWrIldmf2rBuPejuZ13CZ/wpVE2SToyi4EiroPTQ1XJcJuZFIxTtw==", + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.1.tgz", + "integrity": "sha512-o0AhSNaOfKoic0Sn1GkFCK4MxdRsw7mPJ5/rBpIqdvcC7MIuyUSW8WChUEvrK78HhNpYOgqCQbINxCTumJLzZA==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.2", - "@formatjs/icu-skeleton-parser": "1.8.12", + "@formatjs/ecma402-abstract": "2.3.3", + "@formatjs/icu-skeleton-parser": "1.8.13", "tslib": "2" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.12", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.12.tgz", - "integrity": "sha512-QRAY2jC1BomFQHYDMcZtClqHR55EEnB96V7Xbk/UiBodsuFc5kujybzt87+qj1KqmJozFhk6n4KiT1HKwAkcfg==", + "version": "1.8.13", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.13.tgz", + "integrity": "sha512-N/LIdTvVc1TpJmMt2jVg0Fr1F7Q1qJPdZSCs19unMskCmVQ/sa0H9L8PWt13vq+gLdLg1+pPsvBLydL1Apahjg==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.2", + "@formatjs/ecma402-abstract": "2.3.3", "tslib": "2" } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", - "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.0.tgz", + "integrity": "sha512-4rB4g+3hESy1bHSBG3tDFaMY2CH67iT7yne1e+0CLTsGLDcmoEWWpJjjpWVaYgYfYuohIRuo0E+N536gd2ZHZA==", "license": "MIT", "dependencies": { "tslib": "2" @@ -3219,31 +3220,36 @@ "license": "BSD-3-Clause" }, "node_modules/@inquirer/confirm": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.4.tgz", - "integrity": "sha512-EsiT7K4beM5fN5Mz6j866EFA9+v9d5o9VUra3hrg8zY4GHmCS8b616FErbdo5eyKoVotBQkHzMIeeKYsKDStDw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.5.tgz", + "integrity": "sha512-ZB2Cz8KeMINUvoeDi7IrvghaVkYT2RB0Zb31EaLWOE87u276w4wnApv0SH2qWaJ3r0VSUa3BIuz7qAV2ZvsZlg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.5", - "@inquirer/type": "^3.0.3" + "@inquirer/core": "^10.1.6", + "@inquirer/type": "^3.0.4" }, "engines": { "node": ">=18" }, "peerDependencies": { "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/core": { - "version": "10.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.5.tgz", - "integrity": "sha512-/vyCWhET0ktav/mUeBqJRYTwmjFPIKPRYb3COAw7qORULgipGSUO2vL32lQKki3UxDKJ8BvuEbokaoyCA6YlWw==", + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.6.tgz", + "integrity": "sha512-Bwh/Zk6URrHwZnSSzAZAKH7YgGYi0xICIBDFOqBQoXNNAzBHw/bgXgLmChfp+GyR3PnChcTbiCTZGC6YJNJkMA==", "dev": true, "license": "MIT", "dependencies": { "@inquirer/figures": "^1.0.10", - "@inquirer/type": "^3.0.3", + "@inquirer/type": "^3.0.4", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", @@ -3253,6 +3259,14 @@ }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@inquirer/core/node_modules/ansi-escapes": { @@ -3342,9 +3356,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.3.tgz", - "integrity": "sha512-I4VIHFxUuY1bshGbXZTxCmhwaaEst9s/lll3ekok+o1Z26/ZUKdx8y1b7lsoG6rtsBDwEGfiBJ2SfirjoISLpg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.4.tgz", + "integrity": "sha512-2MNFrDY8jkFYc9Il9DgLsHhMzuHnOYM1+CUYVWbzu9oT0hC7V7EcYvdCKeoll/Fcci04A+ERZ9wcc7cQ8lTkIA==", "dev": true, "license": "MIT", "engines": { @@ -3352,6 +3366,11 @@ }, "peerDependencies": { "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/@internationalized/date": { @@ -3500,24 +3519,21 @@ "license": "MIT" }, "node_modules/@monaco-editor/loader": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.4.0.tgz", - "integrity": "sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", + "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", "license": "MIT", "dependencies": { "state-local": "^1.0.6" - }, - "peerDependencies": { - "monaco-editor": ">= 0.21.0 < 1" } }, "node_modules/@monaco-editor/react": { - "version": "4.7.0-rc.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0-rc.0.tgz", - "integrity": "sha512-YfjXkDK0bcwS0zo8PXptvQdCQfOPPtzGsAzmIv7PnoUGFdIohsR+NVDyjbajMddF+3cWUm/3q9NzP/DUke9a+w==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", "license": "MIT", "dependencies": { - "@monaco-editor/loader": "^1.4.0" + "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", @@ -3526,9 +3542,9 @@ } }, "node_modules/@mswjs/interceptors": { - "version": "0.37.5", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.5.tgz", - "integrity": "sha512-AAwRb5vXFcY4L+FvZ7LZusDuZ0vEe0Zm8ohn1FM6/X7A3bj4mqmkAcGRWuvC2JwSygNwHAAmMnAI73vPHeqsHA==", + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", "dev": true, "license": "MIT", "dependencies": { @@ -4706,6 +4722,28 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/@react-router/express": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.1.5.tgz", + "integrity": "sha512-k9aGrvPwCP+8CeHPxRaIqYKJi3xVzdN4QXFdZ++PPcPNy5/g8pM7GBAxWyUYH26+aDO8AqjzgbGgph2H0MN7kQ==", + "license": "MIT", + "dependencies": { + "@react-router/node": "7.1.5" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "express": "^4.17.1", + "react-router": "7.1.5", + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@react-router/node": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.1.5.tgz", @@ -4754,28 +4792,6 @@ "react-router": "7.1.5" } }, - "node_modules/@react-router/serve/node_modules/@react-router/express": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/@react-router/express/-/express-7.1.5.tgz", - "integrity": "sha512-k9aGrvPwCP+8CeHPxRaIqYKJi3xVzdN4QXFdZ++PPcPNy5/g8pM7GBAxWyUYH26+aDO8AqjzgbGgph2H0MN7kQ==", - "license": "MIT", - "dependencies": { - "@react-router/node": "7.1.5" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "express": "^4.17.1", - "react-router": "7.1.5", - "typescript": "^5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@react-stately/calendar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@react-stately/calendar/-/calendar-3.6.0.tgz", @@ -5597,9 +5613,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.32.1.tgz", - "integrity": "sha512-/pqA4DmqyCm8u5YIDzIdlLcEmuvxb0v8fZdFhVMszSpDTgbQKdw3/mB3eMUHIbubtJ6F9j+LtmyCnHTEqIHyzA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.6.tgz", + "integrity": "sha512-+GcCXtOQoWuC7hhX1P00LqjjIiS/iOouHXhMdiDSnq/1DGTox4SpUvO52Xm+div6+106r+TcvOeo/cxvyEyTgg==", "cpu": [ "arm" ], @@ -5610,9 +5626,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.32.1.tgz", - "integrity": "sha512-If3PDskT77q7zgqVqYuj7WG3WC08G1kwXGVFi9Jr8nY6eHucREHkfpX79c0ACAjLj3QIWKPJR7w4i+f5EdLH5Q==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.6.tgz", + "integrity": "sha512-E8+2qCIjciYUnCa1AiVF1BkRgqIGW9KzJeesQqVfyRITGQN+dFuoivO0hnro1DjT74wXLRZ7QF8MIbz+luGaJA==", "cpu": [ "arm64" ], @@ -5623,9 +5639,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.32.1.tgz", - "integrity": "sha512-zCpKHioQ9KgZToFp5Wvz6zaWbMzYQ2LJHQ+QixDKq52KKrF65ueu6Af4hLlLWHjX1Wf/0G5kSJM9PySW9IrvHA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.6.tgz", + "integrity": "sha512-z9Ib+OzqN3DZEjX7PDQMHEhtF+t6Mi2z/ueChQPLS/qUMKY7Ybn5A2ggFoKRNRh1q1T03YTQfBTQCJZiepESAg==", "cpu": [ "arm64" ], @@ -5636,9 +5652,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.32.1.tgz", - "integrity": "sha512-sFvF+t2+TyUo/ZQqUcifrJIgznx58oFZbdHS9TvHq3xhPVL9nOp+yZ6LKrO9GWTP+6DbFtoyLDbjTpR62Mbr3Q==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.6.tgz", + "integrity": "sha512-PShKVY4u0FDAR7jskyFIYVyHEPCPnIQY8s5OcXkdU8mz3Y7eXDJPdyM/ZWjkYdR2m0izD9HHWA8sGcXn+Qrsyg==", "cpu": [ "x64" ], @@ -5649,9 +5665,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.32.1.tgz", - "integrity": "sha512-NbOa+7InvMWRcY9RG+B6kKIMD/FsnQPH0MWUvDlQB1iXnF/UcKSudCXZtv4lW+C276g3w5AxPbfry5rSYvyeYA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.6.tgz", + "integrity": "sha512-YSwyOqlDAdKqs0iKuqvRHLN4SrD2TiswfoLfvYXseKbL47ht1grQpq46MSiQAx6rQEN8o8URtpXARCpqabqxGQ==", "cpu": [ "arm64" ], @@ -5662,9 +5678,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.32.1.tgz", - "integrity": "sha512-JRBRmwvHPXR881j2xjry8HZ86wIPK2CcDw0EXchE1UgU0ubWp9nvlT7cZYKc6bkypBt745b4bglf3+xJ7hXWWw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.6.tgz", + "integrity": "sha512-HEP4CgPAY1RxXwwL5sPFv6BBM3tVeLnshF03HMhJYCNc6kvSqBgTMmsEjb72RkZBAWIqiPUyF1JpEBv5XT9wKQ==", "cpu": [ "x64" ], @@ -5675,9 +5691,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.32.1.tgz", - "integrity": "sha512-PKvszb+9o/vVdUzCCjL0sKHukEQV39tD3fepXxYrHE3sTKrRdCydI7uldRLbjLmDA3TFDmh418XH19NOsDRH8g==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.6.tgz", + "integrity": "sha512-88fSzjC5xeH9S2Vg3rPgXJULkHcLYMkh8faix8DX4h4TIAL65ekwuQMA/g2CXq8W+NJC43V6fUpYZNjaX3+IIg==", "cpu": [ "arm" ], @@ -5688,9 +5704,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.32.1.tgz", - "integrity": "sha512-9WHEMV6Y89eL606ReYowXuGF1Yb2vwfKWKdD1A5h+OYnPZSJvxbEjxTRKPgi7tkP2DSnW0YLab1ooy+i/FQp/Q==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.6.tgz", + "integrity": "sha512-wM4ztnutBqYFyvNeR7Av+reWI/enK9tDOTKNF+6Kk2Q96k9bwhDDOlnCUNRPvromlVXo04riSliMBs/Z7RteEg==", "cpu": [ "arm" ], @@ -5701,9 +5717,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.32.1.tgz", - "integrity": "sha512-tZWc9iEt5fGJ1CL2LRPw8OttkCBDs+D8D3oEM8mH8S1ICZCtFJhD7DZ3XMGM8kpqHvhGUTvNUYVDnmkj4BDXnw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.6.tgz", + "integrity": "sha512-9RyprECbRa9zEjXLtvvshhw4CMrRa3K+0wcp3KME0zmBe1ILmvcVHnypZ/aIDXpRyfhSYSuN4EPdCCj5Du8FIA==", "cpu": [ "arm64" ], @@ -5714,9 +5730,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.32.1.tgz", - "integrity": "sha512-FTYc2YoTWUsBz5GTTgGkRYYJ5NGJIi/rCY4oK/I8aKowx1ToXeoVVbIE4LGAjsauvlhjfl0MYacxClLld1VrOw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.6.tgz", + "integrity": "sha512-qTmklhCTyaJSB05S+iSovfo++EwnIEZxHkzv5dep4qoszUMX5Ca4WM4zAVUMbfdviLgCSQOu5oU8YoGk1s6M9Q==", "cpu": [ "arm64" ], @@ -5727,9 +5743,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.32.1.tgz", - "integrity": "sha512-F51qLdOtpS6P1zJVRzYM0v6MrBNypyPEN1GfMiz0gPu9jN8ScGaEFIZQwteSsGKg799oR5EaP7+B2jHgL+d+Kw==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.6.tgz", + "integrity": "sha512-4Qmkaps9yqmpjY5pvpkfOerYgKNUGzQpFxV6rnS7c/JfYbDSU0y6WpbbredB5cCpLFGJEqYX40WUmxMkwhWCjw==", "cpu": [ "loong64" ], @@ -5740,9 +5756,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.32.1.tgz", - "integrity": "sha512-wO0WkfSppfX4YFm5KhdCCpnpGbtgQNj/tgvYzrVYFKDpven8w2N6Gg5nB6w+wAMO3AIfSTWeTjfVe+uZ23zAlg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.6.tgz", + "integrity": "sha512-Zsrtux3PuaxuBTX/zHdLaFmcofWGzaWW1scwLU3ZbW/X+hSsFbz9wDIp6XvnT7pzYRl9MezWqEqKy7ssmDEnuQ==", "cpu": [ "ppc64" ], @@ -5753,9 +5769,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.32.1.tgz", - "integrity": "sha512-iWswS9cIXfJO1MFYtI/4jjlrGb/V58oMu4dYJIKnR5UIwbkzR0PJ09O0PDZT0oJ3LYWXBSWahNf/Mjo6i1E5/g==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.6.tgz", + "integrity": "sha512-aK+Zp+CRM55iPrlyKiU3/zyhgzWBxLVrw2mwiQSYJRobCURb781+XstzvA8Gkjg/hbdQFuDw44aUOxVQFycrAg==", "cpu": [ "riscv64" ], @@ -5766,9 +5782,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.32.1.tgz", - "integrity": "sha512-RKt8NI9tebzmEthMnfVgG3i/XeECkMPS+ibVZjZ6mNekpbbUmkNWuIN2yHsb/mBPyZke4nlI4YqIdFPgKuoyQQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.6.tgz", + "integrity": "sha512-WoKLVrY9ogmaYPXwTH326+ErlCIgMmsoRSx6bO+l68YgJnlOXhygDYSZe/qbUJCSiCiZAQ+tKm88NcWuUXqOzw==", "cpu": [ "s390x" ], @@ -5779,9 +5795,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.32.1.tgz", - "integrity": "sha512-WQFLZ9c42ECqEjwg/GHHsouij3pzLXkFdz0UxHa/0OM12LzvX7DzedlY0SIEly2v18YZLRhCRoHZDxbBSWoGYg==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.6.tgz", + "integrity": "sha512-Sht4aFvmA4ToHd2vFzwMFaQCiYm2lDFho5rPcvPBT5pCdC+GwHG6CMch4GQfmWTQ1SwRKS0dhDYb54khSrjDWw==", "cpu": [ "x64" ], @@ -5792,9 +5808,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.32.1.tgz", - "integrity": "sha512-BLoiyHDOWoS3uccNSADMza6V6vCNiphi94tQlVIL5de+r6r/CCQuNnerf+1g2mnk2b6edp5dk0nhdZ7aEjOBsA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.6.tgz", + "integrity": "sha512-zmmpOQh8vXc2QITsnCiODCDGXFC8LMi64+/oPpPx5qz3pqv0s6x46ps4xoycfUiVZps5PFn1gksZzo4RGTKT+A==", "cpu": [ "x64" ], @@ -5805,9 +5821,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.32.1.tgz", - "integrity": "sha512-w2l3UnlgYTNNU+Z6wOR8YdaioqfEnwPjIsJ66KxKAf0p+AuL2FHeTX6qvM+p/Ue3XPBVNyVSfCrfZiQh7vZHLQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.6.tgz", + "integrity": "sha512-3/q1qUsO/tLqGBaD4uXsB6coVGB3usxw3qyeVb59aArCgedSF66MPdgRStUd7vbZOsko/CgVaY5fo2vkvPLWiA==", "cpu": [ "arm64" ], @@ -5818,9 +5834,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.32.1.tgz", - "integrity": "sha512-Am9H+TGLomPGkBnaPWie4F3x+yQ2rr4Bk2jpwy+iV+Gel9jLAu/KqT8k3X4jxFPW6Zf8OMnehyutsd+eHoq1WQ==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.6.tgz", + "integrity": "sha512-oLHxuyywc6efdKVTxvc0135zPrRdtYVjtVD5GUm55I3ODxhU/PwkQFD97z16Xzxa1Fz0AEe4W/2hzRtd+IfpOA==", "cpu": [ "ia32" ], @@ -5831,9 +5847,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.32.1.tgz", - "integrity": "sha512-ar80GhdZb4DgmW3myIS9nRFYcpJRSME8iqWgzH2i44u+IdrzmiXVxeFnExQ5v4JYUSpg94bWjevMG8JHf1Da5Q==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.6.tgz", + "integrity": "sha512-0PVwmgzZ8+TZ9oGBmdZoQVXflbvuwzN/HRclujpl4N/q3i+y0lqLw8n1bXA8ru3sApDjlmONaNAuYr38y1Kr9w==", "cpu": [ "x64" ], @@ -6107,9 +6123,9 @@ } }, "node_modules/@tanstack/eslint-plugin-query": { - "version": "5.66.0", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.66.0.tgz", - "integrity": "sha512-CzZhBxicLDuuSJbkZ4nPcuBqWnhLu72Zt9p/7qLQ93BepVnZJV6ZDlBLBuN5eg7YRACwECPLsntnwo1zuhgseQ==", + "version": "5.66.1", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.66.1.tgz", + "integrity": "sha512-pYMVTGgJ7yPk9Rm6UWEmbY6TX0EmMmxJqYkthgeDCwEznToy2m+W928nUODFirtZBZlhBsqHy33LO0kyTlgf0w==", "dev": true, "license": "MIT", "dependencies": { @@ -6358,6 +6374,12 @@ "@types/estree": "*" } }, + "node_modules/@types/gensync": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.4.tgz", + "integrity": "sha512-C3YYeRQWp2fmq9OryX+FoDy8nXS6scQ7dPptD8LnFDAUNcKWJjXQKDNJD3HVm+kOUsXhTOkpi69vI4EuAr95bA==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -6405,9 +6427,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.13.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", - "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", + "version": "22.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.2.tgz", + "integrity": "sha512-Z+r8y3XL9ZpI2EY52YYygAFmo2/oWfNSj4BCpAXE2McAexDk8VcnBMGC9Djn9gTKt4d2T/hhXqmPzo4hfIXtTg==", "devOptional": true, "license": "MIT", "dependencies": { @@ -6688,16 +6710,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.22.0.tgz", - "integrity": "sha512-T8oc1MbF8L+Bk2msAvCUzjxVB2Z2f+vXYfcucE2wOmYs7ZUwco5Ep0fYZw8quNwOiw9K8GYVL+Kgc2pETNTLOg==", + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.24.0.tgz", + "integrity": "sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.22.0", - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/typescript-estree": "8.22.0" + "@typescript-eslint/scope-manager": "8.24.0", + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/typescript-estree": "8.24.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6712,14 +6734,14 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.22.0.tgz", - "integrity": "sha512-/lwVV0UYgkj7wPSw0o8URy6YI64QmcOdwHuGuxWIYznO6d45ER0wXUbksr9pYdViAofpUCNJx/tAzNukgvaaiQ==", + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.24.0.tgz", + "integrity": "sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/visitor-keys": "8.22.0" + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6730,9 +6752,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.22.0.tgz", - "integrity": "sha512-0S4M4baNzp612zwpD4YOieP3VowOARgK2EkN/GBn95hpyF8E2fbMT55sRHWBq+Huaqk3b3XK+rxxlM8sPgGM6A==", + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.24.0.tgz", + "integrity": "sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==", "dev": true, "license": "MIT", "engines": { @@ -6744,20 +6766,20 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.22.0.tgz", - "integrity": "sha512-SJX99NAS2ugGOzpyhMza/tX+zDwjvwAtQFLsBo3GQxiGcvaKlqGBkmZ+Y1IdiSi9h4Q0Lr5ey+Cp9CGWNY/F/w==", + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.24.0.tgz", + "integrity": "sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.22.0", - "@typescript-eslint/visitor-keys": "8.22.0", + "@typescript-eslint/types": "8.24.0", + "@typescript-eslint/visitor-keys": "8.24.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^2.0.0" + "ts-api-utils": "^2.0.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6771,13 +6793,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.22.0.tgz", - "integrity": "sha512-AWpYAXnUgvLNabGTy3uBylkgZoosva/miNd1I8Bz3SjotmQPbVqhO4Cczo8AsZ44XVErEBPr/CRSgaj8sG7g0w==", + "version": "8.24.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.24.0.tgz", + "integrity": "sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.22.0", + "@typescript-eslint/types": "8.24.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -6802,9 +6824,9 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/ts-api-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.0.tgz", - "integrity": "sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz", + "integrity": "sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==", "dev": true, "license": "MIT", "engines": { @@ -6971,9 +6993,9 @@ } }, "node_modules/@vitest/runner/node_modules/pathe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", - "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -6993,9 +7015,9 @@ } }, "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", - "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -7488,9 +7510,9 @@ } }, "node_modules/babel-dead-code-elimination": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.8.tgz", - "integrity": "sha512-og6HQERk0Cmm+nTT4Od2wbPtgABXFMPaHACjbKLulZIFMkYyXZLkUGuAxdgpMJBrxyt/XFpSz++lNzjbcMnPkQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.9.tgz", + "integrity": "sha512-JLIhax/xullfInZjtu13UJjaLHDeTzt3vOeomaSUdO/nAMEL/pWC/laKrSvWylXMnVWyL5bpmG9njqBZlUQOdg==", "dev": true, "license": "MIT", "dependencies": { @@ -7693,9 +7715,9 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", - "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7754,9 +7776,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001696", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001696.tgz", - "integrity": "sha512-pDCPkvzfa39ehJtJ+OwGT/2yvT2SbjfHhiIW2LWOAcMQ7BzwxT/XuyUp4OTOd0XFWA6BKw0JalnBHgSi5DGJBQ==", + "version": "1.0.30001699", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001699.tgz", + "integrity": "sha512-b+uH5BakXZ9Do9iK+CkDmctUSEqZl+SP056vc5usa0PL+ev5OHw003rZXcnjNDv3L8P5j6rwT6C0BPKSikW08w==", "funding": [ { "type": "opencollective", @@ -8153,9 +8175,9 @@ } }, "node_modules/compression": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.5.tgz", - "integrity": "sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", "license": "MIT", "dependencies": { "bytes": "3.1.2", @@ -8731,9 +8753,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.88", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.88.tgz", - "integrity": "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw==", + "version": "1.5.98", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.98.tgz", + "integrity": "sha512-bI/LbtRBxU2GzK7KK5xxFd2y9Lf9XguHooPYbcXWy6wUoT8NMnffsvRhPmSeUHLSDKAEtKuTaEtK4Ms15zkIEA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -9019,13 +9041,16 @@ } }, "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-to-primitive": { @@ -9949,9 +9974,9 @@ "license": "MIT" }, "node_modules/fastq": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", - "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", + "integrity": "sha512-7SFSRCNjBQIZH/xZR3iy5iQYR8aGBE0h3VG6/cwlbrpdciNYBMotQav8c1XI3HjHH+NikUpP53nPdlZSdWmFzA==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -10103,9 +10128,9 @@ } }, "node_modules/for-each": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.4.tgz", - "integrity": "sha512-kKaIINnFpzW6ffJNDjjyjrk21BkDx38c0xa/klsT8VzLCaMEefv4ZTacrcVR4DmgTeBra++jMDAfS/tS799YDw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -10180,9 +10205,9 @@ } }, "node_modules/framer-motion": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.3.0.tgz", - "integrity": "sha512-pIL/fAMlj8J1Px+owKYfxtq1bpz9EOFt/GanUIwsWme2dS6w7WGviAaadhJxfhdpKa/DxR8pWZxDrgC6ujR26w==", + "version": "12.4.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.2.tgz", + "integrity": "sha512-pW307cQKjDqEuO1flEoIFf6TkuJRfKr+c7qsHAJhDo4368N/5U8/7WU8J+xhd9+gjmOgJfgp+46evxRRFM39dA==", "license": "MIT", "dependencies": { "motion-dom": "^12.0.0", @@ -10995,9 +11020,9 @@ } }, "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11081,14 +11106,14 @@ } }, "node_modules/intl-messageformat": { - "version": "10.7.14", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.14.tgz", - "integrity": "sha512-mMGnE4E1otdEutV5vLUdCxRJygHB5ozUBxsPB5qhitewssrS/qGruq9bmvIRkkGsNeK5ZWLfYRld18UHGTIifQ==", + "version": "10.7.15", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.15.tgz", + "integrity": "sha512-LRyExsEsefQSBjU2p47oAheoKz+EOJxSLDdjOaEjdriajfHsMXOmV/EhMvYSg9bAgCUHasuAC+mcUBe/95PfIg==", "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.2", + "@formatjs/ecma402-abstract": "2.3.3", "@formatjs/fast-memoize": "2.2.6", - "@formatjs/icu-messageformat-parser": "2.11.0", + "@formatjs/icu-messageformat-parser": "2.11.1", "tslib": "2" } }, @@ -11198,13 +11223,13 @@ } }, "node_modules/is-boolean-object": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz", - "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", + "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" }, "engines": { @@ -11584,13 +11609,13 @@ } }, "node_modules/is-weakref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.0.tgz", - "integrity": "sha512-SXM8Nwyys6nT5WP6pltOwKytLV7FqQ4UiibxVmW+EIosHcmCqkkjViTb5SNssDlkCiEYRP1/pdWUKVvZBmsR2Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -12476,9 +12501,9 @@ } }, "node_modules/mdast-util-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", - "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", "dependencies": { "mdast-util-from-markdown": "^2.0.0", @@ -12512,9 +12537,9 @@ } }, "node_modules/mdast-util-gfm-footnote": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", - "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -13835,9 +13860,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -14350,9 +14375,9 @@ } }, "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { @@ -14360,9 +14385,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", + "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", "funding": [ { "type": "opencollective", @@ -14517,9 +14542,9 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.215.3", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.215.3.tgz", - "integrity": "sha512-vTk8/gyjbKP7EbDxWzo/GBCK7Ok7M6RTqEWOzRgIxCPf/KA5faFi5z1T4cRR1oPgcDqLeB1ZGa04Za/cPEHxgA==", + "version": "1.217.4", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.217.4.tgz", + "integrity": "sha512-ZIOb75F1pdMZl6e7C4mgH2accKArLA2RG3zMEjeils+3J/cylwgcr2Iw0QtzSLqQVvR7AFRRbXMZXUWsiB2zyA==", "license": "MIT", "dependencies": { "core-js": "^3.38.1", @@ -14555,9 +14580,9 @@ } }, "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", + "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "dev": true, "license": "MIT", "bin": { @@ -15270,9 +15295,9 @@ } }, "node_modules/remark-gfm": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz", - "integrity": "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", @@ -15512,9 +15537,9 @@ } }, "node_modules/rollup": { - "version": "4.32.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.32.1.tgz", - "integrity": "sha512-z+aeEsOeEa3mEbS1Tjl6sAZ8NE3+AalQz1RJGj81M+fizusbdDMoEJwdJNHfaB40Scr4qNu+welOfes7maKonA==", + "version": "4.34.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.6.tgz", + "integrity": "sha512-wc2cBWqJgkU3Iz5oztRkQbfVkbxoz5EhnCGOrnJvnLnQ7O0WhQUYyv18qQI79O8L7DdHrrlJNeCHd4VGpnaXKQ==", "license": "MIT", "dependencies": { "@types/estree": "1.0.6" @@ -15527,25 +15552,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.32.1", - "@rollup/rollup-android-arm64": "4.32.1", - "@rollup/rollup-darwin-arm64": "4.32.1", - "@rollup/rollup-darwin-x64": "4.32.1", - "@rollup/rollup-freebsd-arm64": "4.32.1", - "@rollup/rollup-freebsd-x64": "4.32.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.32.1", - "@rollup/rollup-linux-arm-musleabihf": "4.32.1", - "@rollup/rollup-linux-arm64-gnu": "4.32.1", - "@rollup/rollup-linux-arm64-musl": "4.32.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.32.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.32.1", - "@rollup/rollup-linux-riscv64-gnu": "4.32.1", - "@rollup/rollup-linux-s390x-gnu": "4.32.1", - "@rollup/rollup-linux-x64-gnu": "4.32.1", - "@rollup/rollup-linux-x64-musl": "4.32.1", - "@rollup/rollup-win32-arm64-msvc": "4.32.1", - "@rollup/rollup-win32-ia32-msvc": "4.32.1", - "@rollup/rollup-win32-x64-msvc": "4.32.1", + "@rollup/rollup-android-arm-eabi": "4.34.6", + "@rollup/rollup-android-arm64": "4.34.6", + "@rollup/rollup-darwin-arm64": "4.34.6", + "@rollup/rollup-darwin-x64": "4.34.6", + "@rollup/rollup-freebsd-arm64": "4.34.6", + "@rollup/rollup-freebsd-x64": "4.34.6", + "@rollup/rollup-linux-arm-gnueabihf": "4.34.6", + "@rollup/rollup-linux-arm-musleabihf": "4.34.6", + "@rollup/rollup-linux-arm64-gnu": "4.34.6", + "@rollup/rollup-linux-arm64-musl": "4.34.6", + "@rollup/rollup-linux-loongarch64-gnu": "4.34.6", + "@rollup/rollup-linux-powerpc64le-gnu": "4.34.6", + "@rollup/rollup-linux-riscv64-gnu": "4.34.6", + "@rollup/rollup-linux-s390x-gnu": "4.34.6", + "@rollup/rollup-linux-x64-gnu": "4.34.6", + "@rollup/rollup-linux-x64-musl": "4.34.6", + "@rollup/rollup-win32-arm64-msvc": "4.34.6", + "@rollup/rollup-win32-ia32-msvc": "4.34.6", + "@rollup/rollup-win32-x64-msvc": "4.34.6", "fsevents": "~2.3.2" } }, @@ -15724,9 +15749,9 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", "dev": true, "license": "ISC", "bin": { @@ -16893,22 +16918,22 @@ } }, "node_modules/tldts": { - "version": "6.1.75", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.75.tgz", - "integrity": "sha512-+lFzEXhpl7JXgWYaXcB6DqTYXbUArvrWAE/5ioq/X3CdWLbDjpPP4XTrQBmEJ91y3xbe4Fkw7Lxv4P3GWeJaNg==", + "version": "6.1.77", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.77.tgz", + "integrity": "sha512-lBpoWgy+kYmuXWQ83+R7LlJCnsd9YW8DGpZSHhrMl4b8Ly/1vzOie3OdtmUJDkKxcgRGOehDu5btKkty+JEe+g==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^6.1.75" + "tldts-core": "^6.1.77" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "6.1.75", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.75.tgz", - "integrity": "sha512-AOvV5YYIAFFBfransBzSTyztkc3IMfz5Eq3YluaRiEu55nn43Fzaufx70UqEKYr8BoLCach4q8g/bg6e5+/aFw==", + "version": "6.1.77", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.77.tgz", + "integrity": "sha512-bCaqm24FPk8OgBkM0u/SrEWJgHnhBWYqeBo6yUmcZJDCHt/IfyWBb+14CXdGi4RInMv4v7eUAin15W0DoA+Ytg==", "dev": true, "license": "MIT" }, @@ -16943,9 +16968,9 @@ } }, "node_modules/tough-cookie": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.0.tgz", - "integrity": "sha512-rvZUv+7MoBYTiDmFPBrhL7Ujx9Sk+q9wwm22x8c8T5IJaR+Wsyc7TNxbVxo84kZoRJZZMazowFLqpankBEQrGg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.1.tgz", + "integrity": "sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -17008,9 +17033,9 @@ "license": "Apache-2.0" }, "node_modules/tsconfck": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.4.tgz", - "integrity": "sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.5.tgz", + "integrity": "sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==", "dev": true, "license": "MIT", "bin": { @@ -17080,9 +17105,9 @@ } }, "node_modules/type-fest": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.33.0.tgz", - "integrity": "sha512-s6zVrxuyKbbAsSAD5ZPTB77q4YIdRctkTbJ2/Dqlinwz+8ooH2gd+YA7VA6Pa93KML9GockVvoxjZ2vHP+mu8g==", + "version": "4.34.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.34.1.tgz", + "integrity": "sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { @@ -17745,9 +17770,9 @@ } }, "node_modules/vitest/node_modules/pathe": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", - "integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, @@ -17849,9 +17874,9 @@ } }, "node_modules/whatwg-url": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.0.tgz", - "integrity": "sha512-jlf/foYIKywAt3x/XWKZ/3rz8OSJPiWktjmk891alJUEjiVxKX9LEO92qH3hv4aJ0mN3MWPvGMCy8jQi95xK4w==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", + "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 2b6469da302c..2781b75eabbc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,14 +1,14 @@ { "name": "openhands-frontend", - "version": "0.23.0", + "version": "0.24.0", "private": true, "type": "module", "engines": { "node": ">=20.0.0" }, "dependencies": { - "@monaco-editor/react": "^4.7.0-rc.0", "@heroui/react": "2.6.14", + "@monaco-editor/react": "^4.7.0-rc.0", "@react-router/node": "^7.1.5", "@react-router/serve": "^7.1.5", "@react-types/shared": "^3.27.0", @@ -20,14 +20,14 @@ "axios": "^1.7.9", "clsx": "^2.1.1", "eslint-config-airbnb-typescript": "^18.0.0", - "framer-motion": "^12.3.0", + "framer-motion": "^12.4.2", "i18next": "^24.2.2", "i18next-browser-languagedetector": "^8.0.2", "i18next-http-backend": "^3.0.2", "isbot": "^5.1.22", "jose": "^5.9.4", "monaco-editor": "^0.52.2", - "posthog-js": "^1.215.3", + "posthog-js": "^1.217.2", "react": "^19.0.0", "react-dom": "^19.0.0", "react-highlight": "^0.15.0", @@ -39,7 +39,7 @@ "react-router": "^7.1.5", "react-syntax-highlighter": "^15.6.1", "react-textarea-autosize": "^8.5.7", - "remark-gfm": "^4.0.0", + "remark-gfm": "^4.0.1", "sirv-cli": "^3.0.0", "socket.io-client": "^4.8.1", "tailwind-merge": "^3.0.1", @@ -50,7 +50,7 @@ "scripts": { "dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev", "dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev", - "build": "npm run make-i18n && tsc && react-router build", + "build": "npm run make-i18n && npm run typecheck && react-router build", "start": "npx sirv-cli build/ --single", "test": "vitest run", "test:e2e": "playwright test", @@ -80,12 +80,12 @@ "@playwright/test": "^1.50.1", "@react-router/dev": "^7.1.5", "@tailwindcss/typography": "^0.5.16", - "@tanstack/eslint-plugin-query": "^5.66.0", + "@tanstack/eslint-plugin-query": "^5.66.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.1", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.1", - "@types/node": "^22.13.1", + "@types/node": "^22.13.2", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@types/react-highlight": "^0.12.8", @@ -109,8 +109,8 @@ "jsdom": "^26.0.0", "lint-staged": "^15.4.3", "msw": "^2.6.6", - "postcss": "^8.5.1", - "prettier": "^3.4.2", + "postcss": "^8.5.2", + "prettier": "^3.5.1", "tailwindcss": "^3.4.17", "typescript": "^5.7.3", "vite-plugin-svgr": "^4.2.0", diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 92da6c4d2956..e77d7a5527c2 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -13,7 +13,7 @@ import { GetTrajectoryResponse, } from "./open-hands.types"; import { openHands } from "./open-hands-axios"; -import { ApiSettings } from "#/types/settings"; +import { ApiSettings, PostApiSettings } from "#/types/settings"; class OpenHands { /** @@ -229,6 +229,7 @@ class OpenHands { ): Promise { const body = { selected_repository: selectedRepository, + selected_branch: undefined, initial_user_msg: initialUserMsg, image_urls: imageUrls, }; @@ -266,7 +267,9 @@ class OpenHands { * Save the settings to the server. Only valid settings are saved. * @param settings - the settings to save */ - static async saveSettings(settings: Partial): Promise { + static async saveSettings( + settings: Partial, + ): Promise { const data = await openHands.post("/api/settings", settings); return data.status === 200; } diff --git a/frontend/src/components/features/analytics/analytics-consent-form-modal.tsx b/frontend/src/components/features/analytics/analytics-consent-form-modal.tsx index 21c0c04a0438..ea22445db07f 100644 --- a/frontend/src/components/features/analytics/analytics-consent-form-modal.tsx +++ b/frontend/src/components/features/analytics/analytics-consent-form-modal.tsx @@ -27,11 +27,10 @@ export function AnalyticsConsentFormModal({ { onSuccess: () => { handleCaptureConsent(analytics); + onClose(); }, }, ); - - onClose(); }; return ( diff --git a/frontend/src/components/features/chat/expandable-message.tsx b/frontend/src/components/features/chat/expandable-message.tsx index f04c9c428354..036ad9ce664b 100644 --- a/frontend/src/components/features/chat/expandable-message.tsx +++ b/frontend/src/components/features/chat/expandable-message.tsx @@ -46,55 +46,57 @@ export function ExpandableMessage({ )} >
- {headline && ( -
- + + {headline && ( + <> + {headline} + + + )} + + {type === "action" && success !== undefined && ( + + {success ? ( + + ) : ( + )} - > - {headline} - - {type === "action" && success !== undefined && ( - - {success ? ( - - ) : ( - - )} - - )} -
- )} - {showDetails && ( + )} +
+ {(!headline || showDetails) && ( void; onLogout: () => void; onClose: () => void; isLoggedIn: boolean; } export function AccountSettingsContextMenu({ - onClickAccountSettings, onLogout, onClose, isLoggedIn, @@ -27,13 +24,6 @@ export function AccountSettingsContextMenu({ ref={ref} className="absolute left-full -top-1 z-10" > - - {t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)} - - {t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)} diff --git a/frontend/src/components/features/github/github-repo-selector.tsx b/frontend/src/components/features/github/github-repo-selector.tsx index 722134a42ddb..750474ded3c6 100644 --- a/frontend/src/components/features/github/github-repo-selector.tsx +++ b/frontend/src/components/features/github/github-repo-selector.tsx @@ -31,7 +31,7 @@ export function GitHubRepositorySelector({ const allRepositories: GitHubRepository[] = [ ...publicRepositories.filter( - (repo) => !publicRepositories.find((r) => r.id === repo.id), + (repo) => !userRepositories.find((r) => r.id === repo.id), ), ...userRepositories, ]; 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 45eb5278068d..2450dd7b59e6 100644 --- a/frontend/src/components/features/github/github-repositories-suggestion-box.tsx +++ b/frontend/src/components/features/github/github-repositories-suggestion-box.tsx @@ -1,5 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router"; import { I18nKey } from "#/i18n/declaration"; import { SuggestionBox } from "#/components/features/suggestions/suggestion-box"; import GitHubLogo from "#/assets/branding/github-logo.svg?react"; @@ -10,7 +11,6 @@ import { useSearchRepositories } from "#/hooks/query/use-search-repositories"; import { useUserRepositories } from "#/hooks/query/use-user-repositories"; import { sanitizeQuery } from "#/utils/sanitize-query"; import { useDebounce } from "#/hooks/use-debounce"; -import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal"; interface GitHubRepositoriesSuggestionBoxProps { handleSubmit: () => void; @@ -24,8 +24,7 @@ export function GitHubRepositoriesSuggestionBox({ user, }: GitHubRepositoriesSuggestionBoxProps) { const { t } = useTranslation(); - const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] = - React.useState(false); + const navigate = useNavigate(); const [searchQuery, setSearchQuery] = React.useState(""); const debouncedSearchQuery = useDebounce(searchQuery, 300); @@ -45,39 +44,33 @@ export function GitHubRepositoriesSuggestionBox({ if (gitHubAuthUrl) { window.location.href = gitHubAuthUrl; } else { - setConnectToGitHubModalOpen(true); + navigate("/settings"); } }; const isLoggedIn = !!user; return ( - <> - - ) : ( - } - className="bg-[#791B80] w-full" - onClick={handleConnectToGitHub} - /> - ) - } - /> - {connectToGitHubModalOpen && ( - setConnectToGitHubModalOpen(false)} - /> - )} - + + ) : ( + } + className="bg-[#791B80] w-full" + onClick={handleConnectToGitHub} + /> + ) + } + /> ); } diff --git a/frontend/src/components/features/settings/brand-button.tsx b/frontend/src/components/features/settings/brand-button.tsx new file mode 100644 index 000000000000..b4d2dc24aa5f --- /dev/null +++ b/frontend/src/components/features/settings/brand-button.tsx @@ -0,0 +1,39 @@ +import { cn } from "#/utils/utils"; + +interface BrandButtonProps { + testId?: string; + variant: "primary" | "secondary"; + type: React.ButtonHTMLAttributes["type"]; + isDisabled?: boolean; + className?: string; + onClick?: () => void; +} + +export function BrandButton({ + testId, + children, + variant, + type, + isDisabled, + className, + onClick, +}: React.PropsWithChildren) { + return ( + + ); +} diff --git a/frontend/src/components/features/settings/help-link.tsx b/frontend/src/components/features/settings/help-link.tsx new file mode 100644 index 000000000000..984f279de230 --- /dev/null +++ b/frontend/src/components/features/settings/help-link.tsx @@ -0,0 +1,22 @@ +interface HelpLinkProps { + testId: string; + text: string; + linkText: string; + href: string; +} + +export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) { + return ( +

+ {text}{" "} + + {linkText} + +

+ ); +} diff --git a/frontend/src/components/features/settings/key-status-icon.tsx b/frontend/src/components/features/settings/key-status-icon.tsx new file mode 100644 index 000000000000..ad4bd3cf8343 --- /dev/null +++ b/frontend/src/components/features/settings/key-status-icon.tsx @@ -0,0 +1,16 @@ +import SuccessIcon from "#/icons/success.svg?react"; +import { cn } from "#/utils/utils"; + +interface KeyStatusIconProps { + isSet: boolean; +} + +export function KeyStatusIcon({ isSet }: KeyStatusIconProps) { + return ( + + + + ); +} diff --git a/frontend/src/components/features/settings/optional-tag.tsx b/frontend/src/components/features/settings/optional-tag.tsx new file mode 100644 index 000000000000..3df207fc1b94 --- /dev/null +++ b/frontend/src/components/features/settings/optional-tag.tsx @@ -0,0 +1,3 @@ +export function OptionalTag() { + return (Optional); +} diff --git a/frontend/src/components/features/settings/settings-dropdown-input.tsx b/frontend/src/components/features/settings/settings-dropdown-input.tsx new file mode 100644 index 000000000000..69385bf08f48 --- /dev/null +++ b/frontend/src/components/features/settings/settings-dropdown-input.tsx @@ -0,0 +1,56 @@ +import { Autocomplete, AutocompleteItem } from "@heroui/react"; +import { OptionalTag } from "./optional-tag"; + +interface SettingsDropdownInputProps { + testId: string; + label: string; + name: string; + items: { key: React.Key; label: string }[]; + showOptionalTag?: boolean; + isDisabled?: boolean; + defaultSelectedKey?: string; + isClearable?: boolean; +} + +export function SettingsDropdownInput({ + testId, + label, + name, + items, + showOptionalTag, + isDisabled, + defaultSelectedKey, + isClearable, +}: SettingsDropdownInputProps) { + return ( + + ); +} diff --git a/frontend/src/components/features/settings/settings-input.tsx b/frontend/src/components/features/settings/settings-input.tsx new file mode 100644 index 000000000000..5362af09aa28 --- /dev/null +++ b/frontend/src/components/features/settings/settings-input.tsx @@ -0,0 +1,50 @@ +import { cn } from "#/utils/utils"; +import { OptionalTag } from "./optional-tag"; + +interface SettingsInputProps { + testId?: string; + name?: string; + label: string; + type: React.HTMLInputTypeAttribute; + defaultValue?: string; + placeholder?: string; + showOptionalTag?: boolean; + isDisabled?: boolean; + startContent?: React.ReactNode; + className?: string; +} + +export function SettingsInput({ + testId, + name, + label, + type, + defaultValue, + placeholder, + showOptionalTag, + isDisabled, + startContent, + className, +}: SettingsInputProps) { + return ( + + ); +} diff --git a/frontend/src/components/features/settings/settings-switch.tsx b/frontend/src/components/features/settings/settings-switch.tsx new file mode 100644 index 000000000000..d1bfaff94935 --- /dev/null +++ b/frontend/src/components/features/settings/settings-switch.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { StyledSwitchComponent } from "./styled-switch-component"; + +interface SettingsSwitchProps { + testId?: string; + name?: string; + onToggle?: (value: boolean) => void; + defaultIsToggled?: boolean; + isBeta?: boolean; +} + +export function SettingsSwitch({ + children, + testId, + name, + onToggle, + defaultIsToggled, + isBeta, +}: React.PropsWithChildren) { + const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false); + + const handleToggle = (value: boolean) => { + setIsToggled(value); + onToggle?.(value); + }; + + return ( + + ); +} diff --git a/frontend/src/components/features/settings/styled-switch-component.tsx b/frontend/src/components/features/settings/styled-switch-component.tsx new file mode 100644 index 000000000000..36d9ffda6bfb --- /dev/null +++ b/frontend/src/components/features/settings/styled-switch-component.tsx @@ -0,0 +1,26 @@ +import { cn } from "#/utils/utils"; + +interface StyledSwitchComponentProps { + isToggled: boolean; +} + +export function StyledSwitchComponent({ + isToggled, +}: StyledSwitchComponentProps) { + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx index 777878a1821e..645543ac6fd2 100644 --- a/frontend/src/components/features/sidebar/sidebar.tsx +++ b/frontend/src/components/features/sidebar/sidebar.tsx @@ -3,6 +3,7 @@ import { FaListUl } from "react-icons/fa"; import { useDispatch } from "react-redux"; import posthog from "posthog-js"; import toast from "react-hot-toast"; +import { NavLink } from "react-router"; import { useGitHubUser } from "#/hooks/query/use-github-user"; import { UserActions } from "./user-actions"; import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button"; @@ -10,7 +11,6 @@ import { DocsButton } from "#/components/shared/buttons/docs-button"; import { ExitProjectButton } from "#/components/shared/buttons/exit-project-button"; import { SettingsButton } from "#/components/shared/buttons/settings-button"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; -import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal"; import { SettingsModal } from "#/components/shared/modals/settings/settings-modal"; import { useCurrentSettings } from "#/context/settings-context"; import { useSettings } from "#/hooks/query/use-settings"; @@ -30,28 +30,18 @@ export function Sidebar() { const user = useGitHubUser(); const { data: config } = useConfig(); const { - data: settings, error: settingsError, isError: settingsIsError, isFetching: isFetchingSettings, } = useSettings(); const { mutateAsync: logout } = useLogout(); - const { saveUserSettings } = useCurrentSettings(); + const { settings, saveUserSettings } = useCurrentSettings(); - const [accountSettingsModalOpen, setAccountSettingsModalOpen] = - React.useState(false); const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(false); - React.useEffect(() => { - // If the github token is invalid, open the account settings modal again - if (user.isError) { - setAccountSettingsModalOpen(true); - } - }, [user.isError]); - React.useEffect(() => { // We don't show toast errors for settings in the global error handler // because we have a special case for 404 errors @@ -63,6 +53,8 @@ export function Sidebar() { toast.error( "Something went wrong while fetching settings. Please reload the page.", ); + } else if (settingsError?.status === 404) { + setSettingsModalIsOpen(true); } }, [settingsError?.status, settingsError, isFetchingSettings]); @@ -71,10 +63,6 @@ export function Sidebar() { endSession(); }; - const handleAccountSettingsModalClose = () => { - setAccountSettingsModalOpen(false); - }; - const handleLogout = async () => { if (config?.APP_MODE === "saas") await logout(); else await saveUserSettings({ unset_github_token: true }); @@ -84,33 +72,44 @@ export function Sidebar() { return ( <> - {accountSettingsModalOpen && ( - - )} - {(settingsError?.status === 404 || settingsModalIsOpen) && ( + {settingsModalIsOpen && ( setSettingsModalIsOpen(false)} diff --git a/frontend/src/components/features/sidebar/user-actions.tsx b/frontend/src/components/features/sidebar/user-actions.tsx index 359c8fbbb314..0ac8dc95f532 100644 --- a/frontend/src/components/features/sidebar/user-actions.tsx +++ b/frontend/src/components/features/sidebar/user-actions.tsx @@ -3,16 +3,11 @@ import { UserAvatar } from "./user-avatar"; import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu"; interface UserActionsProps { - onClickAccountSettings: () => void; onLogout: () => void; user?: { avatar_url: string }; } -export function UserActions({ - onClickAccountSettings, - onLogout, - user, -}: UserActionsProps) { +export function UserActions({ onLogout, user }: UserActionsProps) { const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] = React.useState(false); @@ -24,11 +19,6 @@ export function UserActions({ setAccountContextMenuIsVisible(false); }; - const handleClickAccountSettings = () => { - onClickAccountSettings(); - closeAccountMenu(); - }; - const handleLogout = () => { onLogout(); closeAccountMenu(); @@ -41,7 +31,6 @@ export function UserActions({ {accountContextMenuIsVisible && ( diff --git a/frontend/src/components/features/sidebar/user-avatar.tsx b/frontend/src/components/features/sidebar/user-avatar.tsx index 3857f8f52d4b..3e5d0fda57fb 100644 --- a/frontend/src/components/features/sidebar/user-avatar.tsx +++ b/frontend/src/components/features/sidebar/user-avatar.tsx @@ -1,7 +1,7 @@ import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; -import DefaultUserAvatar from "#/icons/default-user.svg?react"; +import ProfileIcon from "#/icons/profile.svg?react"; import { cn } from "#/utils/utils"; import { Avatar } from "./avatar"; import { TooltipButton } from "#/components/shared/buttons/tooltip-button"; @@ -21,16 +21,17 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) { ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)} onClick={onClick} className={cn( - "w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200", + "w-8 h-8 rounded-full flex items-center justify-center", isLoading && "bg-transparent", )} > {!isLoading && avatarUrl && } {!isLoading && !avatarUrl && ( - )} {isLoading && } diff --git a/frontend/src/components/shared/buttons/all-hands-logo-button.tsx b/frontend/src/components/shared/buttons/all-hands-logo-button.tsx index f8af9ac47651..e068f152af13 100644 --- a/frontend/src/components/shared/buttons/all-hands-logo-button.tsx +++ b/frontend/src/components/shared/buttons/all-hands-logo-button.tsx @@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) { ariaLabel="All Hands Logo" onClick={onClick} > - + ); } diff --git a/frontend/src/components/shared/buttons/docs-button.tsx b/frontend/src/components/shared/buttons/docs-button.tsx index d2cafe2422cb..4c2b248e0b8e 100644 --- a/frontend/src/components/shared/buttons/docs-button.tsx +++ b/frontend/src/components/shared/buttons/docs-button.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import DocsIcon from "#/icons/docs.svg?react"; +import DocsIcon from "#/icons/academy.svg?react"; import { I18nKey } from "#/i18n/declaration"; import { TooltipButton } from "./tooltip-button"; @@ -11,7 +11,7 @@ export function DocsButton() { ariaLabel={t(I18nKey.SIDEBAR$DOCS)} href="https://docs.all-hands.dev" > - + ); } diff --git a/frontend/src/components/shared/buttons/exit-project-button.tsx b/frontend/src/components/shared/buttons/exit-project-button.tsx index 8e40bf5efac3..eaef96680ae7 100644 --- a/frontend/src/components/shared/buttons/exit-project-button.tsx +++ b/frontend/src/components/shared/buttons/exit-project-button.tsx @@ -1,7 +1,6 @@ import { useTranslation } from "react-i18next"; -import { useLocation } from "react-router"; import { I18nKey } from "#/i18n/declaration"; -import NewProjectIcon from "#/icons/new-project.svg?react"; +import PlusIcon from "#/icons/plus.svg?react"; import { TooltipButton } from "./tooltip-button"; interface ExitProjectButtonProps { @@ -10,12 +9,7 @@ interface ExitProjectButtonProps { export function ExitProjectButton({ onClick }: ExitProjectButtonProps) { const { t } = useTranslation(); - const location = useLocation(); const startNewProject = t(I18nKey.PROJECT$START_NEW); - - // Only show the button in the conversations page - if (!location.pathname.startsWith("/conversations")) return null; - return ( - + ); } diff --git a/frontend/src/components/shared/buttons/settings-button.tsx b/frontend/src/components/shared/buttons/settings-button.tsx index 2b792e5ed4c4..80bd5eee8a24 100644 --- a/frontend/src/components/shared/buttons/settings-button.tsx +++ b/frontend/src/components/shared/buttons/settings-button.tsx @@ -1,14 +1,15 @@ -import { FaCog } from "react-icons/fa"; import { useTranslation } from "react-i18next"; +import SettingsIcon from "#/icons/settings.svg?react"; import { TooltipButton } from "./tooltip-button"; import { I18nKey } from "#/i18n/declaration"; interface SettingsButtonProps { - onClick: () => void; + onClick?: () => void; } export function SettingsButton({ onClick }: SettingsButtonProps) { const { t } = useTranslation(); + return ( - + ); } diff --git a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx b/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx deleted file mode 100644 index cbce86d6ee0c..000000000000 --- a/frontend/src/components/shared/modals/account-settings/account-settings-form.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import posthog from "posthog-js"; -import { - BaseModalDescription, - BaseModalTitle, -} from "../confirmation-modals/base-modal"; -import { ModalBody } from "../modal-body"; -import { AvailableLanguages } from "#/i18n"; -import { I18nKey } from "#/i18n/declaration"; -import { handleCaptureConsent } from "#/utils/handle-capture-consent"; -import { ModalButton } from "../../buttons/modal-button"; -import { FormFieldset } from "../../form-fieldset"; -import { useConfig } from "#/hooks/query/use-config"; -import { useCurrentSettings } from "#/context/settings-context"; -import { GitHubTokenInput } from "./github-token-input"; -import { PostSettings } from "#/types/settings"; -import { useGitHubUser } from "#/hooks/query/use-github-user"; - -interface AccountSettingsFormProps { - onClose: () => void; -} - -export function AccountSettingsForm({ onClose }: AccountSettingsFormProps) { - const { isError: isGitHubError } = useGitHubUser(); - const { data: config } = useConfig(); - const { saveUserSettings, settings } = useCurrentSettings(); - const { t } = useTranslation(); - - const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET; - const analyticsConsentValue = !!settings?.USER_CONSENTS_TO_ANALYTICS; - const selectedLanguage = settings?.LANGUAGE || "en"; - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - const formData = new FormData(event.currentTarget); - - const ghToken = formData.get("ghToken")?.toString(); - const language = formData.get("language")?.toString(); - const analytics = formData.get("analytics")?.toString() === "on"; - - const newSettings: Partial = {}; - newSettings.user_consents_to_analytics = analytics; - - if (ghToken) newSettings.github_token = ghToken; - - // The form returns the language label, so we need to find the corresponding - // language key to save it in the settings - if (language) { - const languageKey = AvailableLanguages.find( - ({ label }) => label === language, - )?.value; - - if (languageKey) newSettings.LANGUAGE = languageKey; - } - - await saveUserSettings(newSettings, { - onSuccess: () => { - handleCaptureConsent(analytics); - }, - }); - - onClose(); - }; - - const onDisconnect = async () => { - await saveUserSettings({ unset_github_token: true }); - posthog.reset(); - onClose(); - }; - - return ( - -
-
- - - {config?.APP_MODE === "saas" && config?.APP_SLUG && ( - - {t(I18nKey.GITHUB$CONFIGURE_REPOS)} - - )} - ({ - key, - value: label, - }))} - /> - - {config?.APP_MODE !== "saas" && ( - <> - - {!githubTokenIsSet && ( - - {t(I18nKey.GITHUB$GET_TOKEN)}{" "} - - {t(I18nKey.COMMON$HERE)} - - - )} - - )} - {isGitHubError && ( -

- {t(I18nKey.GITHUB$TOKEN_INVALID)} -

- )} - {githubTokenIsSet && !isGitHubError && ( - - )} -
- - - -
- - -
-
-
- ); -} diff --git a/frontend/src/components/shared/modals/account-settings/account-settings-modal.tsx b/frontend/src/components/shared/modals/account-settings/account-settings-modal.tsx deleted file mode 100644 index 49a0d54f3ef8..000000000000 --- a/frontend/src/components/shared/modals/account-settings/account-settings-modal.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { ModalBackdrop } from "../modal-backdrop"; -import { AccountSettingsForm } from "./account-settings-form"; - -interface AccountSettingsModalProps { - onClose: () => void; -} - -export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) { - return ( - - - - ); -} diff --git a/frontend/src/components/shared/modals/account-settings/github-token-input.tsx b/frontend/src/components/shared/modals/account-settings/github-token-input.tsx deleted file mode 100644 index f5f4de4b226e..000000000000 --- a/frontend/src/components/shared/modals/account-settings/github-token-input.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { FaCheckCircle } from "react-icons/fa"; -import { I18nKey } from "#/i18n/declaration"; - -interface GitHubTokenInputProps { - githubTokenIsSet: boolean; -} - -export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) { - const { t } = useTranslation(); - - return ( - - ); -} diff --git a/frontend/src/components/shared/modals/settings/model-selector.tsx b/frontend/src/components/shared/modals/settings/model-selector.tsx index c169d67870b7..811113844132 100644 --- a/frontend/src/components/shared/modals/settings/model-selector.tsx +++ b/frontend/src/components/shared/modals/settings/model-selector.tsx @@ -65,107 +65,109 @@ export function ModelSelector({ const { t } = useTranslation(); return ( -
-
-
- - { - if (e?.toString()) handleChangeProvider(e.toString()); - }} - onInputChange={(value) => !value && clear()} - defaultSelectedKey={selectedProvider ?? undefined} - selectedKey={selectedProvider} - inputProps={{ - classNames: { - inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]", - }, - }} - > - - {Object.keys(models) - .filter((provider) => VERIFIED_PROVIDERS.includes(provider)) - .map((provider) => ( - - {mapProvider(provider)} - - ))} - - - {Object.keys(models) - .filter((provider) => !VERIFIED_PROVIDERS.includes(provider)) - .map((provider) => ( - - {mapProvider(provider)} - - ))} - - -
+
+
+ + { + if (e?.toString()) handleChangeProvider(e.toString()); + }} + onInputChange={(value) => !value && clear()} + defaultSelectedKey={selectedProvider ?? undefined} + selectedKey={selectedProvider} + classNames={{ + popoverContent: "bg-[#454545] rounded-xl border border-[#717888]", + }} + inputProps={{ + classNames: { + inputWrapper: + "bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic", + }, + }} + > + + {Object.keys(models) + .filter((provider) => VERIFIED_PROVIDERS.includes(provider)) + .map((provider) => ( + + {mapProvider(provider)} + + ))} + + + {Object.keys(models) + .filter((provider) => !VERIFIED_PROVIDERS.includes(provider)) + .map((provider) => ( + + {mapProvider(provider)} + + ))} + + +
-
- - { - if (e?.toString()) handleChangeModel(e.toString()); - }} - isDisabled={isDisabled || !selectedProvider} - selectedKey={selectedModel} - defaultSelectedKey={selectedModel ?? undefined} - inputProps={{ - classNames: { - inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]", - }, - }} - > - - {models[selectedProvider || ""]?.models - .filter((model) => VERIFIED_MODELS.includes(model)) - .map((model) => ( - - {model} - - ))} - - - {models[selectedProvider || ""]?.models - .filter((model) => !VERIFIED_MODELS.includes(model)) - .map((model) => ( - - {model} - - ))} - - -
-
+
+ + { + if (e?.toString()) handleChangeModel(e.toString()); + }} + isDisabled={isDisabled || !selectedProvider} + selectedKey={selectedModel} + defaultSelectedKey={selectedModel ?? undefined} + classNames={{ + popoverContent: "bg-[#454545] rounded-xl border border-[#717888]", + }} + inputProps={{ + classNames: { + inputWrapper: + "bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic", + }, + }} + > + + {models[selectedProvider || ""]?.models + .filter((model) => VERIFIED_MODELS.includes(model)) + .map((model) => ( + + {model} + + ))} + + + {models[selectedProvider || ""]?.models + .filter((model) => !VERIFIED_MODELS.includes(model)) + .map((model) => ( + + {model} + + ))} + + +
); } diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx index 6ed859f47dbc..0c7ef45079ee 100644 --- a/frontend/src/components/shared/modals/settings/settings-form.tsx +++ b/frontend/src/components/shared/modals/settings/settings-form.tsx @@ -4,86 +4,34 @@ import React from "react"; import posthog from "posthog-js"; import { I18nKey } from "#/i18n/declaration"; import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers"; -import { getDefaultSettings } from "#/services/settings"; -import { extractModelAndProvider } from "#/utils/extract-model-and-provider"; import { DangerModal } from "../confirmation-modals/danger-modal"; import { extractSettings } from "#/utils/settings-utils"; import { useEndSession } from "#/hooks/use-end-session"; -import { ModalButton } from "../../buttons/modal-button"; -import { AdvancedOptionSwitch } from "../../inputs/advanced-option-switch"; -import { AgentInput } from "../../inputs/agent-input"; -import { APIKeyInput } from "../../inputs/api-key-input"; -import { BaseUrlInput } from "../../inputs/base-url-input"; -import { ConfirmationModeSwitch } from "../../inputs/confirmation-mode-switch"; -import { CustomModelInput } from "../../inputs/custom-model-input"; -import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input"; import { ModalBackdrop } from "../modal-backdrop"; import { ModelSelector } from "./model-selector"; - -import { RuntimeSizeSelector } from "./runtime-size-selector"; -import { useConfig } from "#/hooks/query/use-config"; import { useCurrentSettings } from "#/context/settings-context"; import { MEMORY_CONDENSER } from "#/utils/feature-flags"; import { Settings } from "#/types/settings"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { HelpLink } from "#/components/features/settings/help-link"; interface SettingsFormProps { - disabled?: boolean; settings: Settings; models: string[]; - agents: string[]; - securityAnalyzers: string[]; onClose: () => void; } -export function SettingsForm({ - disabled, - settings, - models, - agents, - securityAnalyzers, - onClose, -}: SettingsFormProps) { +export function SettingsForm({ settings, models, onClose }: SettingsFormProps) { const { saveUserSettings } = useCurrentSettings(); const endSession = useEndSession(); - const { data: config } = useConfig(); const location = useLocation(); const { t } = useTranslation(); const formRef = React.useRef(null); - const advancedAlreadyInUse = React.useMemo(() => { - if (models.length > 0) { - const organizedModels = organizeModelsAndProviders(models); - const { provider, model } = extractModelAndProvider( - settings.LLM_MODEL || "", - ); - const isKnownModel = - provider in organizedModels && - organizedModels[provider].models.includes(model); - - const isUsingSecurityAnalyzer = !!settings.SECURITY_ANALYZER; - const isUsingConfirmationMode = !!settings.CONFIRMATION_MODE; - const isUsingBaseUrl = !!settings.LLM_BASE_URL; - const isUsingCustomModel = !!settings.LLM_MODEL && !isKnownModel; - const isUsingDefaultCondenser = !!settings.ENABLE_DEFAULT_CONDENSER; - - return ( - isUsingSecurityAnalyzer || - isUsingConfirmationMode || - isUsingBaseUrl || - isUsingCustomModel || - isUsingDefaultCondenser - ); - } - - return false; - }, [settings, models]); - - const [showAdvancedOptions, setShowAdvancedOptions] = - React.useState(advancedAlreadyInUse); - const [confirmResetDefaultsModalOpen, setConfirmResetDefaultsModalOpen] = - React.useState(false); const [confirmEndSessionModalOpen, setConfirmEndSessionModalOpen] = React.useState(false); @@ -111,13 +59,6 @@ export function SettingsForm({ }); }; - const handleConfirmResetSettings = async () => { - await saveUserSettings(getDefaultSettings()); - onClose(); - resetOngoingSession(); - posthog.capture("settings_reset"); - }; - const handleConfirmEndSession = () => { const formData = new FormData(formRef.current ?? undefined); handleFormSubmission(formData); @@ -134,7 +75,7 @@ export function SettingsForm({ } }; - const isSaasMode = config?.APP_MODE === "saas"; + const isLLMKeySet = settings.LLM_API_KEY !== "**********"; return (
@@ -144,115 +85,41 @@ export function SettingsForm({ className="flex flex-col gap-6" onSubmit={handleSubmit} > -
- + - {showAdvancedOptions && ( - <> - - - - - )} - - {!showAdvancedOptions && ( - - )} - - } /> - {showAdvancedOptions && ( - <> - - - {isSaasMode && ( - - )} - - - - - - )} +
-
- - -
- { - setConfirmResetDefaultsModalOpen(true); - }} - /> + + {t(I18nKey.BUTTON$SAVE)} +
- {confirmResetDefaultsModalOpen && ( - - setConfirmResetDefaultsModalOpen(false), - }, - }} - /> - - )} {confirmEndSessionModalOpen && ( void; } @@ -19,18 +21,24 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) {
{aiConfigOptions.error && (

{aiConfigOptions.error.message}

)} - + {t(I18nKey.AI_SETTINGS$TITLE)}

- {t(I18nKey.SETTINGS$DESCRIPTION)} + {t(I18nKey.SETTINGS$DESCRIPTION)} For other options,{" "} + + see advanced settings +

-

{t(I18nKey.SETTINGS$WARNING)}

{aiConfigOptions.isLoading && (
@@ -38,10 +46,8 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) { )} {aiConfigOptions.data && ( )} diff --git a/frontend/src/context/auth-context.tsx b/frontend/src/context/auth-context.tsx index e7aed7b0d4d5..3e42759b9e4b 100644 --- a/frontend/src/context/auth-context.tsx +++ b/frontend/src/context/auth-context.tsx @@ -5,10 +5,16 @@ interface AuthContextType { setGitHubTokenIsSet: (value: boolean) => void; } +interface AuthContextProps extends React.PropsWithChildren { + initialGithubTokenIsSet?: boolean; +} + const AuthContext = React.createContext(undefined); -function AuthProvider({ children }: React.PropsWithChildren) { - const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState(false); +function AuthProvider({ children, initialGithubTokenIsSet }: AuthContextProps) { + const [githubTokenIsSet, setGitHubTokenIsSet] = React.useState( + !!initialGithubTokenIsSet, + ); const value = React.useMemo( () => ({ diff --git a/frontend/src/context/settings-context.tsx b/frontend/src/context/settings-context.tsx index c3abbe3184ee..ac05ea92bd86 100644 --- a/frontend/src/context/settings-context.tsx +++ b/frontend/src/context/settings-context.tsx @@ -1,8 +1,10 @@ import React from "react"; import { MutateOptions } from "@tanstack/react-query"; +import toast from "react-hot-toast"; import { useSettings } from "#/hooks/query/use-settings"; import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; import { PostSettings, Settings } from "#/types/settings"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; type SaveUserSettingsConfig = { onSuccess: MutateOptions>["onSuccess"]; @@ -41,7 +43,13 @@ export function SettingsProvider({ children }: SettingsProviderProps) { delete updatedSettings.LLM_API_KEY; } - await saveSettings(updatedSettings, { onSuccess: config?.onSuccess }); + await saveSettings(updatedSettings, { + onSuccess: config?.onSuccess, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + toast.error(errorMessage); + }, + }); }; const value = React.useMemo( diff --git a/frontend/src/hooks/mutation/use-save-settings.ts b/frontend/src/hooks/mutation/use-save-settings.ts index 77a34fbe108d..0a12f6f1eb4e 100644 --- a/frontend/src/hooks/mutation/use-save-settings.ts +++ b/frontend/src/hooks/mutation/use-save-settings.ts @@ -2,8 +2,11 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { DEFAULT_SETTINGS } from "#/services/settings"; import OpenHands from "#/api/open-hands"; import { PostSettings, PostApiSettings } from "#/types/settings"; +import { MEMORY_CONDENSER } from "#/utils/feature-flags"; const saveSettingsMutationFn = async (settings: Partial) => { + const resetLlmApiKey = settings.LLM_API_KEY === ""; + const apiSettings: Partial = { llm_model: settings.LLM_MODEL, llm_base_url: settings.LLM_BASE_URL, @@ -11,11 +14,14 @@ const saveSettingsMutationFn = async (settings: Partial) => { language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE, confirmation_mode: settings.CONFIRMATION_MODE, security_analyzer: settings.SECURITY_ANALYZER, - llm_api_key: settings.LLM_API_KEY?.trim() || undefined, + llm_api_key: resetLlmApiKey + ? "" + : settings.LLM_API_KEY?.trim() || undefined, remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR, github_token: settings.github_token, unset_github_token: settings.unset_github_token, - enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER, + enable_default_condenser: + MEMORY_CONDENSER || settings.ENABLE_DEFAULT_CONDENSER, user_consents_to_analytics: settings.user_consents_to_analytics, }; @@ -30,5 +36,8 @@ export const useSaveSettings = () => { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["settings"] }); }, + meta: { + disableToast: true, + }, }); }; diff --git a/frontend/src/hooks/query/use-settings.ts b/frontend/src/hooks/query/use-settings.ts index 107a91c6926b..d5286b8dd729 100644 --- a/frontend/src/hooks/query/use-settings.ts +++ b/frontend/src/hooks/query/use-settings.ts @@ -1,10 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import React from "react"; import posthog from "posthog-js"; -import { DEFAULT_SETTINGS } from "#/services/settings"; import OpenHands from "#/api/open-hands"; import { useAuth } from "#/context/auth-context"; import { useConfig } from "#/hooks/query/use-config"; +import { DEFAULT_SETTINGS } from "#/services/settings"; const getSettingsQueryFn = async () => { const apiSettings = await OpenHands.getSettings(); @@ -29,12 +29,13 @@ export const useSettings = () => { const { data: config } = useConfig(); const query = useQuery({ - queryKey: ["settings"], + queryKey: ["settings", githubTokenIsSet], queryFn: getSettingsQueryFn, - initialData: DEFAULT_SETTINGS, - staleTime: 0, - retry: false, enabled: config?.APP_MODE !== "saas" || githubTokenIsSet, + // Only retry if the error is not a 404 because we + // would want to show the modal immediately if the + // settings are not found + retry: (_, error) => error.status !== 404, meta: { disableToast: true, }, @@ -50,8 +51,11 @@ export const useSettings = () => { setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET); }, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]); - // Return default settings if in SAAS mode and not authenticated - if (config?.APP_MODE === "saas" && !githubTokenIsSet) { + // We want to return the defaults if the settings aren't found so the user can still see the + // options to make their initial save. We don't set the defaults in `initialData` above because + // that would prepopulate the data to the cache and mess with expectations. Read more: + // https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#using-initialdata-to-prepopulate-a-query + if (query.error?.status === 404) { return { ...query, data: DEFAULT_SETTINGS, diff --git a/frontend/src/hooks/use-app-logout.ts b/frontend/src/hooks/use-app-logout.ts new file mode 100644 index 000000000000..403e443eb06a --- /dev/null +++ b/frontend/src/hooks/use-app-logout.ts @@ -0,0 +1,16 @@ +import { useCurrentSettings } from "#/context/settings-context"; +import { useLogout } from "./mutation/use-logout"; +import { useConfig } from "./query/use-config"; + +export const useAppLogout = () => { + const { data: config } = useConfig(); + const { mutateAsync: logout } = useLogout(); + const { saveUserSettings } = useCurrentSettings(); + + const handleLogout = async () => { + if (config?.APP_MODE === "saas") await logout(); + else await saveUserSettings({ unset_github_token: true }); + }; + + return { handleLogout }; +}; diff --git a/frontend/src/icons/academy.svg b/frontend/src/icons/academy.svg new file mode 100644 index 000000000000..86320b3c6d16 --- /dev/null +++ b/frontend/src/icons/academy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/icons/plus.svg b/frontend/src/icons/plus.svg new file mode 100644 index 000000000000..a3c0dffd5841 --- /dev/null +++ b/frontend/src/icons/plus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/icons/profile.svg b/frontend/src/icons/profile.svg new file mode 100644 index 000000000000..a3ed9941d685 --- /dev/null +++ b/frontend/src/icons/profile.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/icons/settings.svg b/frontend/src/icons/settings.svg new file mode 100644 index 000000000000..ef366cac9e2e --- /dev/null +++ b/frontend/src/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/icons/success.svg b/frontend/src/icons/success.svg new file mode 100644 index 000000000000..ff27cfcfcf61 --- /dev/null +++ b/frontend/src/icons/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/icons/warning.svg b/frontend/src/icons/warning.svg new file mode 100644 index 000000000000..dc404bc1c7ac --- /dev/null +++ b/frontend/src/icons/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/message.d.ts b/frontend/src/message.d.ts index 65bd7e0cb193..9af06a738444 100644 --- a/frontend/src/message.d.ts +++ b/frontend/src/message.d.ts @@ -1,4 +1,4 @@ -type Message = { +export type Message = { sender: "user" | "assistant"; content: string; timestamp: string; diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 1a4aa1981088..7ccaeaf689ce 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -179,8 +179,10 @@ export const handlers = [ return HttpResponse.json(config); }), http.get("/api/settings", async () => { + await delay(); const settings: ApiSettings = { ...MOCK_USER_PREFERENCES.settings, + language: "no", }; // @ts-expect-error - mock types if (settings.github_token) settings.github_token_is_set = true; @@ -290,4 +292,6 @@ export const handlers = [ return HttpResponse.json(null, { status: 404 }); }), + + http.post("/api/logout", () => HttpResponse.json(null, { status: 200 })), ]; diff --git a/frontend/src/query-client-config.ts b/frontend/src/query-client-config.ts index fa9342ce4540..9b95523c0124 100644 --- a/frontend/src/query-client-config.ts +++ b/frontend/src/query-client-config.ts @@ -1,4 +1,8 @@ -import { QueryClientConfig, QueryCache } from "@tanstack/react-query"; +import { + QueryClientConfig, + QueryCache, + MutationCache, +} from "@tanstack/react-query"; import toast from "react-hot-toast"; import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message"; @@ -20,16 +24,18 @@ export const queryClientConfig: QueryClientConfig = { } }, }), + mutationCache: new MutationCache({ + onError: (error, _, __, mutation) => { + if (!mutation?.meta?.disableToast) { + const message = retrieveAxiosErrorMessage(error); + toast.error(message); + } + }, + }), defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 15, // 15 minutes }, - mutations: { - onError: (error) => { - const message = retrieveAxiosErrorMessage(error); - toast.error(message); - }, - }, }, }; diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 53305537a0b8..b840b25402a7 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -8,6 +8,7 @@ import { export default [ layout("routes/_oh/route.tsx", [ index("routes/_oh._index/route.tsx"), + route("settings", "routes/settings.tsx"), route("conversations/:conversationId", "routes/_oh.app/route.tsx", [ index("routes/_oh.app._index/route.tsx"), route("browser", "routes/_oh.app.browser.tsx"), @@ -15,6 +16,4 @@ export default [ route("served", "routes/app.tsx"), ]), ]), - - route("oauth/github/callback", "routes/oauth.github.callback.tsx"), ] satisfies RouteConfig; diff --git a/frontend/src/routes/oauth.github.callback.tsx b/frontend/src/routes/oauth.github.callback.tsx deleted file mode 100644 index f10fcf23e6e2..000000000000 --- a/frontend/src/routes/oauth.github.callback.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useNavigate, useSearchParams } from "react-router"; -import { useQuery } from "@tanstack/react-query"; -import React from "react"; -import OpenHands from "#/api/open-hands"; - -function OAuthGitHubCallback() { - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const code = searchParams.get("code"); - - const { isSuccess, error } = useQuery({ - queryKey: ["access_token", code], - queryFn: () => OpenHands.getGitHubAccessToken(code!), - enabled: !!code, - }); - - React.useEffect(() => { - if (isSuccess) { - navigate("/"); - } - }, [isSuccess]); - - if (error) { - return ( -
-

Error

-

{error.message}

-
- ); - } - - return ( -
-

Redirecting...

-
- ); -} - -export default OAuthGitHubCallback; diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx new file mode 100644 index 000000000000..15078c9e13a2 --- /dev/null +++ b/frontend/src/routes/settings.tsx @@ -0,0 +1,452 @@ +import React from "react"; +import toast from "react-hot-toast"; +import { Link } from "react-router"; +import { BrandButton } from "#/components/features/settings/brand-button"; +import { SettingsInput } from "#/components/features/settings/settings-input"; +import { SettingsSwitch } from "#/components/features/settings/settings-switch"; +import { HelpLink } from "#/components/features/settings/help-link"; +import { AvailableLanguages } from "#/i18n"; +import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set"; +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { useSettings } from "#/hooks/query/use-settings"; +import { useConfig } from "#/hooks/query/use-config"; +import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; +import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options"; +import { ModelSelector } from "#/components/shared/modals/settings/model-selector"; +import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers"; +import { useAppLogout } from "#/hooks/use-app-logout"; +import { handleCaptureConsent } from "#/utils/handle-capture-consent"; +import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop"; +import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input"; +import { KeyStatusIcon } from "#/components/features/settings/key-status-icon"; +import SettingsIcon from "#/icons/settings.svg?react"; +import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; +import { LoadingSpinner } from "#/components/shared/loading-spinner"; +import { isCustomModel } from "#/utils/is-custom-model"; + +const REMOTE_RUNTIME_OPTIONS = [ + { key: 1, label: "1x (2 core, 8G)" }, + { key: 2, label: "2x (4 core, 16G)" }, +]; + +const displayErrorToast = (error: string) => { + toast.error(error, { + position: "top-right", + style: { + background: "#454545", + border: "1px solid #717888", + color: "#fff", + borderRadius: "4px", + }, + }); +}; + +const displaySuccessToast = (message: string) => { + toast.success(message, { + position: "top-right", + style: { + background: "#454545", + border: "1px solid #717888", + color: "#fff", + borderRadius: "4px", + }, + }); +}; + +function SettingsScreen() { + const { + data: settings, + isFetching: isFetchingSettings, + isFetched, + isSuccess: isSuccessfulSettings, + } = useSettings(); + const { data: config } = useConfig(); + const { + data: resources, + isFetching: isFetchingResources, + isSuccess: isSuccessfulResources, + } = useAIConfigOptions(); + const { mutate: saveSettings } = useSaveSettings(); + const { handleLogout } = useAppLogout(); + + const isFetching = isFetchingSettings || isFetchingResources; + const isSuccess = isSuccessfulSettings && isSuccessfulResources; + + const determineWhetherToToggleAdvancedSettings = () => { + if (isSuccess) { + return ( + isCustomModel(resources.models, settings.LLM_MODEL) || + hasAdvancedSettingsSet(settings) + ); + } + + return false; + }; + + const isSaas = config?.APP_MODE === "saas"; + const hasAppSlug = !!config?.APP_SLUG; + const isGitHubTokenSet = settings?.GITHUB_TOKEN_IS_SET; + const isLLMKeySet = settings?.LLM_API_KEY === "**********"; + const isAnalyticsEnabled = settings?.USER_CONSENTS_TO_ANALYTICS; + const isAdvancedSettingsSet = determineWhetherToToggleAdvancedSettings(); + + const modelsAndProviders = organizeModelsAndProviders( + resources?.models || [], + ); + + const [llmConfigMode, setLlmConfigMode] = React.useState< + "basic" | "advanced" + >(isAdvancedSettingsSet ? "advanced" : "basic"); + const [confirmationModeIsEnabled, setConfirmationModeIsEnabled] = + React.useState(!!settings?.SECURITY_ANALYZER); + const [resetSettingsModalIsOpen, setResetSettingsModalIsOpen] = + React.useState(false); + + const formAction = async (formData: FormData) => { + const languageLabel = formData.get("language-input")?.toString(); + const languageValue = AvailableLanguages.find( + ({ label }) => label === languageLabel, + )?.value; + + const llmProvider = formData.get("llm-provider-input")?.toString(); + const llmModel = formData.get("llm-model-input")?.toString(); + const fullLlmModel = `${llmProvider}/${llmModel}`.toLowerCase(); + const customLlmModel = formData.get("llm-custom-model-input")?.toString(); + + const rawRemoteRuntimeResourceFactor = formData + .get("runtime-settings-input") + ?.toString(); + const remoteRuntimeResourceFactor = REMOTE_RUNTIME_OPTIONS.find( + ({ label }) => label === rawRemoteRuntimeResourceFactor, + )?.key; + + const userConsentsToAnalytics = + formData.get("enable-analytics-switch")?.toString() === "on"; + + saveSettings( + { + github_token: + formData.get("github-token-input")?.toString() || undefined, + LANGUAGE: languageValue, + user_consents_to_analytics: userConsentsToAnalytics, + LLM_MODEL: customLlmModel || fullLlmModel, + LLM_BASE_URL: formData.get("base-url-input")?.toString() || "", + LLM_API_KEY: formData.get("llm-api-key-input")?.toString() || undefined, + AGENT: formData.get("agent-input")?.toString(), + SECURITY_ANALYZER: + formData.get("security-analyzer-input")?.toString() || "", + REMOTE_RUNTIME_RESOURCE_FACTOR: + remoteRuntimeResourceFactor || + DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR, + CONFIRMATION_MODE: confirmationModeIsEnabled, + }, + { + onSuccess: () => { + handleCaptureConsent(userConsentsToAnalytics); + displaySuccessToast("Settings saved"); + setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic"); + }, + onError: (error) => { + const errorMessage = retrieveAxiosErrorMessage(error); + displayErrorToast(errorMessage); + }, + }, + ); + }; + + const handleReset = () => { + saveSettings( + { + ...DEFAULT_SETTINGS, + LLM_API_KEY: "", // reset LLM API key + }, + { + onSuccess: () => { + displaySuccessToast("Settings reset"); + setResetSettingsModalIsOpen(false); + setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic"); + }, + }, + ); + }; + + React.useEffect(() => { + // If settings is still loading by the time the state is set, it will always + // default to basic settings. This is a workaround to ensure the correct + // settings are displayed. + setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic"); + }, [isAdvancedSettingsSet]); + + if (isFetched && !settings) { + return
Failed to fetch settings. Please try reloading.
; + } + + const onToggleAdvancedMode = (isToggled: boolean) => { + setLlmConfigMode(isToggled ? "advanced" : "basic"); + if (!isToggled) { + // reset advanced state + setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER); + } + }; + + return ( +
+
+
+ +

Settings

+
+ + {isFetching && ( +
+ +
+ )} + {!isFetching && settings && ( +
+
+
+

+ LLM Settings +

+ + Advanced + +
+ + {llmConfigMode === "basic" && ( + + )} + + {llmConfigMode === "advanced" && ( + + )} + {llmConfigMode === "advanced" && ( + + )} + + } + placeholder={isLLMKeySet ? "**********" : ""} + /> + + + + {llmConfigMode === "advanced" && ( + ({ + key: agent, + label: agent, + })) || [] + } + defaultSelectedKey={settings.AGENT} + isClearable={false} + /> + )} + + {isSaas && llmConfigMode === "advanced" && ( + + )} + + {llmConfigMode === "advanced" && ( + + Enable confirmation mode + + )} + {llmConfigMode === "advanced" && confirmationModeIsEnabled && ( +
+ ({ + key: analyzer, + label: analyzer, + })) || [] + } + defaultSelectedKey={settings.SECURITY_ANALYZER} + isClearable + showOptionalTag + /> +
+ )} +
+ +
+

+ GitHub Settings +

+ {isSaas && hasAppSlug && ( + + + Configure GitHub Repositories + + + )} + {!isSaas && ( + <> + } + /> + + + + )} + + + Disconnect from GitHub + +
+ +
+

+ Additional Settings +

+ + ({ + key: language.value, + label: language.label, + }))} + defaultSelectedKey={settings.LANGUAGE} + isClearable={false} + /> + + + Enable analytics + +
+
+ )} + +
+ setResetSettingsModalIsOpen(true)} + > + Reset to defaults + + + Save Changes + +
+
+ + {resetSettingsModalIsOpen && ( + +
+

Are you sure you want to reset all settings?

+
+ { + handleReset(); + }} + > + Reset + + + { + setResetSettingsModalIsOpen(false); + }} + > + Cancel + +
+
+
+ )} +
+ ); +} + +export default SettingsScreen; diff --git a/frontend/src/state/chat-slice.ts b/frontend/src/state/chat-slice.ts index 5bfffb62d4b3..7975be6cc6ce 100644 --- a/frontend/src/state/chat-slice.ts +++ b/frontend/src/state/chat-slice.ts @@ -1,4 +1,5 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import type { Message } from "#/message"; import { ActionSecurityRisk } from "#/state/security-analyzer-slice"; import { @@ -154,6 +155,11 @@ export const chatSlice = createSlice({ causeMessage.success = !ipythonObs.content .toLowerCase() .includes("error:"); + } else if (observationID === "read" || observationID === "edit") { + // For read/edit operations, we consider it successful if there's content and no error + causeMessage.success = + observation.payload.content.length > 0 && + !observation.payload.content.toLowerCase().includes("error:"); } if (observationID === "run" || observationID === "run_ipython") { diff --git a/frontend/src/types/core/actions.ts b/frontend/src/types/core/actions.ts index eb8aba6ada63..21c76d5d22a3 100644 --- a/frontend/src/types/core/actions.ts +++ b/frontend/src/types/core/actions.ts @@ -83,7 +83,9 @@ export interface FileReadAction extends OpenHandsActionEvent<"read"> { args: { path: string; thought: string; - translated_ipython_code: string | null; + security_risk: ActionSecurityRisk | null; + impl_source?: string; + view_range?: number[] | null; }; } @@ -100,7 +102,18 @@ export interface FileEditAction extends OpenHandsActionEvent<"edit"> { source: "agent"; args: { path: string; - translated_ipython_code: string; + command?: string; + file_text?: string | null; + view_range?: number[] | null; + old_str?: string | null; + new_str?: string | null; + insert_line?: number | null; + content?: string; + start?: number; + end?: number; + thought: string; + security_risk: ActionSecurityRisk | null; + impl_source?: string; }; } diff --git a/frontend/src/types/react-query.d.ts b/frontend/src/types/react-query.d.ts index 830a95a340b3..870623edb269 100644 --- a/frontend/src/types/react-query.d.ts +++ b/frontend/src/types/react-query.d.ts @@ -1,8 +1,15 @@ import "@tanstack/react-query"; import type { AxiosError } from "axios"; +interface MyMeta extends Record { + disableToast?: boolean; +} + declare module "@tanstack/react-query" { interface Register { defaultError: AxiosError; + + queryMeta: MyMeta; + mutationMeta: MyMeta; } } diff --git a/frontend/src/types/settings.ts b/frontend/src/types/settings.ts index 51da54b9ac6e..4723690e52f3 100644 --- a/frontend/src/types/settings.ts +++ b/frontend/src/types/settings.ts @@ -6,7 +6,7 @@ export type Settings = { LLM_API_KEY: string | null; CONFIRMATION_MODE: boolean; SECURITY_ANALYZER: string; - REMOTE_RUNTIME_RESOURCE_FACTOR: number; + REMOTE_RUNTIME_RESOURCE_FACTOR: number | null; GITHUB_TOKEN_IS_SET: boolean; ENABLE_DEFAULT_CONDENSER: boolean; USER_CONSENTS_TO_ANALYTICS: boolean | null; @@ -20,7 +20,7 @@ export type ApiSettings = { llm_api_key: string | null; confirmation_mode: boolean; security_analyzer: string; - remote_runtime_resource_factor: number; + remote_runtime_resource_factor: number | null; github_token_is_set: boolean; enable_default_condenser: boolean; user_consents_to_analytics: boolean | null; diff --git a/frontend/src/utils/has-advanced-settings-set.ts b/frontend/src/utils/has-advanced-settings-set.ts new file mode 100644 index 000000000000..374047c36a6f --- /dev/null +++ b/frontend/src/utils/has-advanced-settings-set.ts @@ -0,0 +1,10 @@ +import { DEFAULT_SETTINGS } from "#/services/settings"; +import { Settings } from "#/types/settings"; + +export const hasAdvancedSettingsSet = (settings: Settings): boolean => + !!settings.LLM_BASE_URL || + settings.AGENT !== DEFAULT_SETTINGS.AGENT || + settings.REMOTE_RUNTIME_RESOURCE_FACTOR !== + DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR || + settings.CONFIRMATION_MODE || + !!settings.SECURITY_ANALYZER; diff --git a/frontend/src/utils/is-custom-model.ts b/frontend/src/utils/is-custom-model.ts new file mode 100644 index 000000000000..b4d5418f3814 --- /dev/null +++ b/frontend/src/utils/is-custom-model.ts @@ -0,0 +1,22 @@ +import { extractModelAndProvider } from "./extract-model-and-provider"; +import { organizeModelsAndProviders } from "./organize-models-and-providers"; + +/** + * Check if a model is a custom model. A custom model is a model that is not part of the default models. + * @param models Full list of models + * @param model Model to check + * @returns Whether the model is a custom model + */ +export const isCustomModel = (models: string[], model: string): boolean => { + if (!model) return false; + + const organizedModels = organizeModelsAndProviders(models); + const { provider: extractedProvider, model: extractedModel } = + extractModelAndProvider(model); + + const isKnownModel = + extractedProvider in organizedModels && + organizedModels[extractedProvider].models.includes(extractedModel); + + return !isKnownModel; +}; diff --git a/frontend/src/utils/settings-utils.ts b/frontend/src/utils/settings-utils.ts index f32835d2d804..b8533b3a4991 100644 --- a/frontend/src/utils/settings-utils.ts +++ b/frontend/src/utils/settings-utils.ts @@ -1,11 +1,11 @@ import { Settings } from "#/types/settings"; const extractBasicFormData = (formData: FormData) => { - const provider = formData.get("llm-provider")?.toString(); - const model = formData.get("llm-model")?.toString(); + const provider = formData.get("llm-provider-input")?.toString(); + const model = formData.get("llm-model-input")?.toString(); const LLM_MODEL = `${provider}/${model}`.toLowerCase(); - const LLM_API_KEY = formData.get("api-key")?.toString(); + const LLM_API_KEY = formData.get("llm-api-key-input")?.toString(); const AGENT = formData.get("agent")?.toString(); const LANGUAGE = formData.get("language")?.toString(); diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx index 42bd9ec6e0d9..d39ced887a39 100644 --- a/frontend/test-utils.tsx +++ b/frontend/test-utils.tsx @@ -66,7 +66,7 @@ export function renderWithProviders( function Wrapper({ children }: PropsWithChildren) { return ( - + { // rewriteWsOrigin: true, }, }, + watch: { + ignored: ['**/node_modules/**', '**/.git/**'], + }, }, ssr: { noExternal: ["react-syntax-highlighter"], diff --git a/openhands/README.md b/openhands/README.md index 4c6a67f09787..f43f9ae3795b 100644 --- a/openhands/README.md +++ b/openhands/README.md @@ -6,6 +6,7 @@ This diagram provides an overview of the roles of each component and how they co ![OpenHands System Architecture Diagram (July 4, 2024)](../docs/static/img/system_architecture_overview.png) ## Classes + The key classes in OpenHands are: * LLM: brokers all interactions with large language models. Works with any underlying completion model, thanks to LiteLLM. @@ -23,7 +24,9 @@ The key classes in OpenHands are: * ConversationManager: keeps a list of active sessions, and ensures requests are routed to the correct Session ## Control Flow + Here's the basic loop (in pseudocode) that drives agents. + ```python while True: prompt = agent.generate_prompt(state) diff --git a/openhands/agenthub/codeact_agent/codeact_agent.py b/openhands/agenthub/codeact_agent/codeact_agent.py index 62ad243e915b..5a1f6d54a84d 100644 --- a/openhands/agenthub/codeact_agent/codeact_agent.py +++ b/openhands/agenthub/codeact_agent/codeact_agent.py @@ -303,7 +303,6 @@ def get_observation_message( and len(obs.set_of_marks) > 0 and self.config.enable_som_visual_browsing and self.llm.vision_is_active() - and self.llm.is_visual_browser_tool_supported() ): text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. You may need to scroll to view the remaining portion of the web-page.)\n' message = Message( diff --git a/openhands/agenthub/codeact_agent/function_calling.py b/openhands/agenthub/codeact_agent/function_calling.py index 196457db0f6e..be39a1624ab5 100644 --- a/openhands/agenthub/codeact_agent/function_calling.py +++ b/openhands/agenthub/codeact_agent/function_calling.py @@ -16,7 +16,6 @@ FunctionCallNotExistsError, FunctionCallValidationError, ) -from openhands.core.logger import openhands_logger as logger from openhands.events.action import ( Action, AgentDelegateAction, @@ -541,26 +540,27 @@ def response_to_actions(response: ModelResponse) -> list[Action]: raise FunctionCallValidationError( f'Missing required argument "path" in tool call {tool_call.function.name}' ) + path = arguments['path'] + command = arguments['command'] + other_kwargs = { + k: v for k, v in arguments.items() if k not in ['command', 'path'] + } - # We implement this in agent_skills, which can be used via Jupyter - # convert tool_call.function.arguments to kwargs that can be passed to file_editor - code = f'print(file_editor(**{arguments}))' - logger.debug( - f'TOOL CALL: str_replace_editor -> file_editor with code: {code}' - ) - - if arguments['command'] == 'view': + if command == 'view': action = FileReadAction( - path=arguments['path'], - translated_ipython_code=code, + path=path, impl_source=FileReadSource.OH_ACI, + view_range=other_kwargs.get('view_range', None), ) else: + if 'view_range' in other_kwargs: + # Remove view_range from other_kwargs since it is not needed for FileEditAction + other_kwargs.pop('view_range') action = FileEditAction( - path=arguments['path'], - content='', # dummy value -- we don't need it - translated_ipython_code=code, + path=path, + command=command, impl_source=FileEditSource.OH_ACI, + **other_kwargs, ) elif tool_call.function.name == 'browser': if 'code' not in arguments: diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index 7189371f0bc3..e5a0b24f9694 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -77,6 +77,7 @@ class AgentController: NullObservation, ChangeAgentStateAction, AgentStateChangedObservation, + AgentCondensationObservation, ) def __init__( diff --git a/openhands/core/cli.py b/openhands/core/cli.py index f1f687fed52a..1e31537155ac 100644 --- a/openhands/core/cli.py +++ b/openhands/core/cli.py @@ -99,7 +99,7 @@ def read_input(config: AppConfig) -> str: async def main(loop: asyncio.AbstractEventLoop): - """Runs the agent in CLI mode""" + """Runs the agent in CLI mode.""" args = parse_arguments() diff --git a/openhands/core/config/__init__.py b/openhands/core/config/__init__.py index 2e0f87e32143..d653f3e70ac4 100644 --- a/openhands/core/config/__init__.py +++ b/openhands/core/config/__init__.py @@ -10,6 +10,7 @@ from openhands.core.config.security_config import SecurityConfig from openhands.core.config.utils import ( finalize_config, + get_agent_config_arg, get_llm_config_arg, get_parser, load_app_config, @@ -31,6 +32,7 @@ 'load_from_env', 'load_from_toml', 'finalize_config', + 'get_agent_config_arg', 'get_llm_config_arg', 'get_field_info', 'get_parser', diff --git a/openhands/core/config/utils.py b/openhands/core/config/utils.py index 4fb7a5278bc5..f057eb6ad2fe 100644 --- a/openhands/core/config/utils.py +++ b/openhands/core/config/utils.py @@ -3,6 +3,7 @@ import pathlib import platform import sys +from ast import literal_eval from types import UnionType from typing import Any, MutableMapping, get_args, get_origin from uuid import uuid4 @@ -28,9 +29,14 @@ load_dotenv() -def load_from_env(cfg: AppConfig, env_or_toml_dict: dict | MutableMapping[str, str]): - """Reads the env-style vars and sets config attributes based on env vars or a config.toml dict. - Compatibility with vars like LLM_BASE_URL, AGENT_MEMORY_ENABLED, SANDBOX_TIMEOUT and others. +def load_from_env( + cfg: AppConfig, env_or_toml_dict: dict | MutableMapping[str, str] +) -> None: + """Sets config attributes from environment variables or TOML dictionary. + + Reads environment-style variables and updates the config attributes accordingly. + Supports configuration of LLM settings (e.g., LLM_BASE_URL), agent settings + (e.g., AGENT_MEMORY_ENABLED), sandbox settings (e.g., SANDBOX_TIMEOUT), and more. Args: cfg: The AppConfig object to set attributes on. @@ -43,7 +49,7 @@ def get_optional_type(union_type: UnionType) -> Any: return next((t for t in types if t is not type(None)), None) # helper function to set attributes based on env vars - def set_attr_from_env(sub_config: BaseModel, prefix=''): + def set_attr_from_env(sub_config: BaseModel, prefix='') -> None: """Set attributes of a config model based on environment variables.""" for field_name, field_info in sub_config.model_fields.items(): field_value = getattr(sub_config, field_name) @@ -72,6 +78,9 @@ def set_attr_from_env(sub_config: BaseModel, prefix=''): # Attempt to cast the env var to type hinted in the dataclass if field_type is bool: cast_value = str(value).lower() in ['true', '1'] + # parse dicts like SANDBOX_RUNTIME_STARTUP_ENV_VARS + elif get_origin(field_type) is dict: + cast_value = literal_eval(value) else: cast_value = field_type(value) setattr(sub_config, field_name, cast_value) @@ -91,7 +100,7 @@ def set_attr_from_env(sub_config: BaseModel, prefix=''): set_attr_from_env(default_agent_config, 'AGENT_') -def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'): +def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml') -> None: """Load the config from the toml file. Supports both styles of config vars. Args: @@ -99,8 +108,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'): toml_file: The path to the toml file. Defaults to 'config.toml'. See Also: - - `config.template.toml` for the full list of config options. - - `SandboxConfig` for the sandbox-specific config options. + - config.template.toml for the full list of config options. """ # try to read the config.toml file into the config object try: @@ -294,7 +302,59 @@ def finalize_config(cfg: AppConfig): ) -# Utility function for command line --group argument +def get_agent_config_arg( + agent_config_arg: str, toml_file: str = 'config.toml' +) -> AgentConfig | None: + """Get a group of agent settings from the config file. + + A group in config.toml can look like this: + + ``` + [agent.default] + enable_prompt_extensions = false + ``` + + The user-defined group name, like "default", is the argument to this function. The function will load the AgentConfig object + with the settings of this group, from the config file, and set it as the AgentConfig object for the app. + + Note that the group must be under "agent" group, or in other words, the group name must start with "agent.". + + Args: + agent_config_arg: The group of agent settings to get from the config.toml file. + toml_file: Path to the configuration file to read from. Defaults to 'config.toml'. + + Returns: + AgentConfig: The AgentConfig object with the settings from the config file. + """ + # keep only the name, just in case + agent_config_arg = agent_config_arg.strip('[]') + + # truncate the prefix, just in case + if agent_config_arg.startswith('agent.'): + agent_config_arg = agent_config_arg[6:] + + logger.openhands_logger.debug(f'Loading agent config from {agent_config_arg}') + + # load the toml file + try: + with open(toml_file, 'r', encoding='utf-8') as toml_contents: + toml_config = toml.load(toml_contents) + except FileNotFoundError as e: + logger.openhands_logger.error(f'Config file not found: {e}') + return None + except toml.TomlDecodeError as e: + logger.openhands_logger.error( + f'Cannot parse agent group from {agent_config_arg}. Exception: {e}' + ) + return None + + # update the agent config with the specified section + if 'agent' in toml_config and agent_config_arg in toml_config['agent']: + return AgentConfig(**toml_config['agent'][agent_config_arg]) + logger.openhands_logger.debug(f'Loading from toml failed for {agent_config_arg}') + return None + + def get_llm_config_arg( llm_config_arg: str, toml_file: str = 'config.toml' ) -> LLMConfig | None: @@ -439,6 +499,12 @@ def get_parser() -> argparse.ArgumentParser: type=str, help='Replace default LLM ([llm] section in config.toml) config with the specified LLM config, e.g. "llama3" for [llm.llama3] section in config.toml', ) + parser.add_argument( + '--agent-config', + default=None, + type=str, + help='Replace default Agent ([agent] section in config.toml) config with the specified Agent config, e.g. "CodeAct" for [agent.CodeAct] section in config.toml', + ) parser.add_argument( '-n', '--name', diff --git a/openhands/core/logger.py b/openhands/core/logger.py index 7b7fd89a97a5..b384fedac1d8 100644 --- a/openhands/core/logger.py +++ b/openhands/core/logger.py @@ -86,7 +86,8 @@ class NoColorFormatter(logging.Formatter): def format(self, record: logging.LogRecord) -> str: # Create a deep copy of the record to avoid modifying the original - new_record: logging.LogRecord = copy.deepcopy(record) + new_record = _fix_record(record) + # Strip ANSI color codes from the message new_record.msg = strip_ansi(new_record.msg) @@ -130,7 +131,18 @@ def format(self, record): return f'{msg}' else: return record.msg - return super().format(record) + + new_record = _fix_record(record) + return super().format(new_record) + + +def _fix_record(record: logging.LogRecord): + new_record = copy.copy(record) + # The formatter expects non boolean values, and will raise an exception if there is a boolean - so we fix these + if new_record.exc_info is True and not new_record.exc_text: # type: ignore + new_record.exc_info = sys.exc_info() # type: ignore + new_record.stack_info = None # type: ignore + return new_record file_formatter = NoColorFormatter( @@ -144,11 +156,13 @@ class RollingLogger: max_lines: int char_limit: int log_lines: list[str] + all_lines: str def __init__(self, max_lines=10, char_limit=80): self.max_lines = max_lines self.char_limit = char_limit self.log_lines = [''] * self.max_lines + self.all_lines = '' def is_enabled(self): return DEBUG and sys.stdout.isatty() @@ -163,6 +177,7 @@ def add_line(self, line): self.log_lines.pop(0) self.log_lines.append(line[: self.char_limit]) self.print_lines() + self.all_lines += line + '\n' def write_immediately(self, line): self._write(line) diff --git a/openhands/core/main.py b/openhands/core/main.py index 474757d9c70c..2652931cce7a 100644 --- a/openhands/core/main.py +++ b/openhands/core/main.py @@ -78,6 +78,7 @@ async def run_controller( headless_mode: bool = True, ) -> State | None: """Main coroutine to run the agent controller with task input flexibility. + It's only used when you launch openhands backend directly via cmdline. Args: @@ -91,6 +92,26 @@ async def run_controller( fake_user_response_fn: An optional function that receives the current state (could be None) and returns a fake user response. headless_mode: Whether the agent is run in headless mode. + + Returns: + The final state of the agent, or None if an error occurred. + + Raises: + AssertionError: If initial_user_action is not an Action instance. + Exception: Various exceptions may be raised during execution and will be logged. + + Notes: + - State persistence: If config.file_store is set, the agent's state will be + saved between sessions. + - Trajectories: If config.trajectories_path is set, execution history will be + saved as JSON for analysis. + - Budget control: Execution is limited by config.max_iterations and + config.max_budget_per_task. + + Example: + >>> config = load_app_config() + >>> action = MessageAction(content="Write a hello world program") + >>> state = await run_controller(config=config, initial_user_action=action) """ sid = sid or generate_sid(config) diff --git a/openhands/core/message.py b/openhands/core/message.py index ea4f0106abea..b508142242fd 100644 --- a/openhands/core/message.py +++ b/openhands/core/message.py @@ -122,8 +122,8 @@ def _list_serializer(self) -> dict: def _add_tool_call_keys(self, message_dict: dict) -> dict: """Add tool call keys if we have a tool call or response. - NOTE: this is necessary for both native and non-native tool calling.""" - + NOTE: this is necessary for both native and non-native tool calling + """ # an assistant message calling a tool if self.tool_calls is not None: message_dict['tool_calls'] = [ diff --git a/openhands/events/action/files.py b/openhands/events/action/files.py index de2db691f146..ac626c02f50c 100644 --- a/openhands/events/action/files.py +++ b/openhands/events/action/files.py @@ -21,7 +21,7 @@ class FileReadAction(Action): runnable: ClassVar[bool] = True security_risk: ActionSecurityRisk | None = None impl_source: FileReadSource = FileReadSource.DEFAULT - translated_ipython_code: str = '' # translated openhands-aci IPython code + view_range: list[int] | None = None # ONLY used in OH_ACI mode @property def message(self) -> str: @@ -60,29 +60,79 @@ def __repr__(self) -> str: @dataclass class FileEditAction(Action): - """Edits a file by provided a draft at a given path. - - Can be set to edit specific lines using start and end (1-index, inclusive) if the file is too long. - Default lines 1:-1 (whole file). - - If start is set to -1, the FileEditAction will simply append the content to the file. + """Edits a file using various commands including view, create, str_replace, insert, and undo_edit. + + This class supports two main modes of operation: + 1. LLM-based editing (impl_source = FileEditSource.LLM_BASED_EDIT) + 2. ACI-based editing (impl_source = FileEditSource.OH_ACI) + + Attributes: + path (str): The path to the file being edited. Works for both LLM-based and OH_ACI editing. + OH_ACI only arguments: + command (str): The editing command to be performed (view, create, str_replace, insert, undo_edit, write). + file_text (str): The content of the file to be created (used with 'create' command in OH_ACI mode). + old_str (str): The string to be replaced (used with 'str_replace' command in OH_ACI mode). + new_str (str): The string to replace old_str (used with 'str_replace' and 'insert' commands in OH_ACI mode). + insert_line (int): The line number after which to insert new_str (used with 'insert' command in OH_ACI mode). + LLM-based editing arguments: + content (str): The content to be written or edited in the file (used in LLM-based editing and 'write' command). + start (int): The starting line for editing (1-indexed, inclusive). Default is 1. + end (int): The ending line for editing (1-indexed, inclusive). Default is -1 (end of file). + thought (str): The reasoning behind the edit action. + action (str): The type of action being performed (always ActionType.EDIT). + runnable (bool): Indicates if the action can be executed (always True). + security_risk (ActionSecurityRisk | None): Indicates any security risks associated with the action. + impl_source (FileEditSource): The source of the implementation (LLM_BASED_EDIT or OH_ACI). + + Usage: + - For LLM-based editing: Use path, content, start, and end attributes. + - For ACI-based editing: Use path, command, and the appropriate attributes for the specific command. + + Note: + - If start is set to -1 in LLM-based editing, the content will be appended to the file. + - The 'write' command behaves similarly to LLM-based editing, using content, start, and end attributes. """ path: str - content: str + + # OH_ACI arguments + command: str = '' + file_text: str | None = None + old_str: str | None = None + new_str: str | None = None + insert_line: int | None = None + + # LLM-based editing arguments + content: str = '' start: int = 1 end: int = -1 + + # Shared arguments thought: str = '' action: str = ActionType.EDIT runnable: ClassVar[bool] = True security_risk: ActionSecurityRisk | None = None - impl_source: FileEditSource = FileEditSource.LLM_BASED_EDIT - translated_ipython_code: str = '' + impl_source: FileEditSource = FileEditSource.OH_ACI def __repr__(self) -> str: ret = '**FileEditAction**\n' - ret += f'Thought: {self.thought}\n' - ret += f'Range: [L{self.start}:L{self.end}]\n' ret += f'Path: [{self.path}]\n' - ret += f'Content:\n```\n{self.content}\n```\n' + ret += f'Thought: {self.thought}\n' + + if self.impl_source == FileEditSource.LLM_BASED_EDIT: + ret += f'Range: [L{self.start}:L{self.end}]\n' + ret += f'Content:\n```\n{self.content}\n```\n' + else: # OH_ACI mode + ret += f'Command: {self.command}\n' + if self.command == 'create': + ret += f'Created File with Text:\n```\n{self.file_text}\n```\n' + elif self.command == 'str_replace': + ret += f'Old String: ```\n{self.old_str}\n```\n' + ret += f'New String: ```\n{self.new_str}\n```\n' + elif self.command == 'insert': + ret += f'Insert Line: {self.insert_line}\n' + ret += f'New String: ```\n{self.new_str}\n```\n' + elif self.command == 'undo_edit': + ret += 'Undo Edit\n' + # We ignore "view" command because it will be mapped to a FileReadAction return ret diff --git a/openhands/events/observation/files.py b/openhands/events/observation/files.py index cc921052312c..0b988852c2c5 100644 --- a/openhands/events/observation/files.py +++ b/openhands/events/observation/files.py @@ -50,15 +50,18 @@ class FileEditObservation(Observation): The observation includes both the old and new content of the file, and can generate a diff visualization showing the changes. The diff is computed lazily and cached to improve performance. + + The .content property can either be: + - Git diff in LLM-based editing mode + - the rendered message sent to the LLM in OH_ACI mode (e.g., "The file /path/to/file.txt is created with the provided content.") """ - path: str - prev_exist: bool - old_content: str - new_content: str + path: str = '' + prev_exist: bool = False + old_content: str | None = None + new_content: str | None = None observation: str = ObservationType.EDIT impl_source: FileEditSource = FileEditSource.LLM_BASED_EDIT - formatted_output_and_error: str = '' _diff_cache: str | None = None # Cache for the diff visualization @property @@ -75,6 +78,8 @@ def get_edit_groups(self, n_context_lines: int = 2) -> list[dict[str, list[str]] Returns: A list of edit groups, where each group contains before/after edits. """ + if self.old_content is None or self.new_content is None: + return [] old_lines = self.old_content.split('\n') new_lines = self.new_content.split('\n') # Borrowed from difflib.unified_diff to directly parse into structured format @@ -173,7 +178,7 @@ def visualize_diff( def __str__(self) -> str: """Get a string representation of the file edit observation.""" if self.impl_source == FileEditSource.OH_ACI: - return self.formatted_output_and_error + return self.content if not self.prev_exist: assert ( diff --git a/openhands/events/serialization/action.py b/openhands/events/serialization/action.py index be9990750fc6..b6b09ebad318 100644 --- a/openhands/events/serialization/action.py +++ b/openhands/events/serialization/action.py @@ -1,3 +1,5 @@ +import re + from openhands.core.exceptions import LLMMalformedActionError from openhands.events.action.action import Action from openhands.events.action.agent import ( @@ -38,6 +40,38 @@ ACTION_TYPE_TO_CLASS = {action_class.action: action_class for action_class in actions} # type: ignore[attr-defined] +def handle_action_deprecated_args(args: dict) -> dict: + # keep_prompt has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881 + if 'keep_prompt' in args: + args.pop('keep_prompt') + + # Handle translated_ipython_code deprecation + if 'translated_ipython_code' in args: + code = args.pop('translated_ipython_code') + + # Check if it's a file_editor call + file_editor_pattern = r'print\(file_editor\(\*\*(.*?)\)\)' + if code is not None and (match := re.match(file_editor_pattern, code)): + try: + # Extract and evaluate the dictionary string + import ast + + file_args = ast.literal_eval(match.group(1)) + + # Update args with the extracted file editor arguments + args.update(file_args) + except (ValueError, SyntaxError): + # If parsing fails, just remove the translated_ipython_code + pass + + if args.get('command') == 'view': + args.pop( + 'command' + ) # "view" will be translated to FileReadAction which doesn't have a command argument + + return args + + def action_from_dict(action: dict) -> Action: if not isinstance(action, dict): raise LLMMalformedActionError('action must be a dictionary') @@ -67,9 +101,8 @@ def action_from_dict(action: dict) -> Action: if 'images_urls' in args: args['image_urls'] = args.pop('images_urls') - # keep_prompt has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881 - if 'keep_prompt' in args: - args.pop('keep_prompt') + # handle deprecated args + args = handle_action_deprecated_args(args) try: decoded_action = action_class(**args) diff --git a/openhands/events/serialization/observation.py b/openhands/events/serialization/observation.py index f1ee333c019a..89164fff1e80 100644 --- a/openhands/events/serialization/observation.py +++ b/openhands/events/serialization/observation.py @@ -64,6 +64,23 @@ def _update_cmd_output_metadata( return metadata +def handle_observation_deprecated_extras(extras: dict) -> dict: + # These are deprecated in https://github.com/All-Hands-AI/OpenHands/pull/4881 + if 'exit_code' in extras: + extras['metadata'] = _update_cmd_output_metadata( + extras.get('metadata', None), exit_code=extras.pop('exit_code') + ) + if 'command_id' in extras: + extras['metadata'] = _update_cmd_output_metadata( + extras.get('metadata', None), pid=extras.pop('command_id') + ) + + # formatted_output_and_error has been deprecated in https://github.com/All-Hands-AI/OpenHands/pull/6671 + if 'formatted_output_and_error' in extras: + extras.pop('formatted_output_and_error') + return extras + + def observation_from_dict(observation: dict) -> Observation: observation = observation.copy() if 'observation' not in observation: @@ -78,15 +95,8 @@ def observation_from_dict(observation: dict) -> Observation: content = observation.pop('content', '') extras = copy.deepcopy(observation.pop('extras', {})) - # Handle legacy attributes for CmdOutputObservation - if 'exit_code' in extras: - extras['metadata'] = _update_cmd_output_metadata( - extras.get('metadata', None), exit_code=extras.pop('exit_code') - ) - if 'command_id' in extras: - extras['metadata'] = _update_cmd_output_metadata( - extras.get('metadata', None), pid=extras.pop('command_id') - ) + extras = handle_observation_deprecated_extras(extras) + # convert metadata to CmdOutputMetadata if it is a dict if observation_class is CmdOutputObservation: if 'metadata' in extras and isinstance(extras['metadata'], dict): diff --git a/openhands/server/services/github_service.py b/openhands/integrations/github/github_service.py similarity index 72% rename from openhands/server/services/github_service.py rename to openhands/integrations/github/github_service.py index 9ade12f8a852..4d8b73125537 100644 --- a/openhands/server/services/github_service.py +++ b/openhands/integrations/github/github_service.py @@ -1,41 +1,47 @@ +import os from typing import Any import httpx -from fastapi import Request +from pydantic import SecretStr -from openhands.server.auth import get_github_token -from openhands.server.data_models.gh_types import GitHubRepository, GitHubUser -from openhands.server.shared import SettingsStoreImpl, config, server_config -from openhands.server.types import AppMode, GhAuthenticationError, GHUnknownException +from openhands.integrations.github.github_types import ( + GhAuthenticationError, + GHUnknownException, + GitHubRepository, + GitHubUser, +) +from openhands.utils.import_utils import get_impl class GitHubService: BASE_URL = 'https://api.github.com' - token: str = '' + token: SecretStr = SecretStr('') + refresh = False - def __init__(self, user_id: str | None): + def __init__(self, user_id: str | None = None, token: SecretStr | None = None): self.user_id = user_id - async def _get_github_headers(self): + if token: + self.token = token + + async def _get_github_headers(self) -> dict: """ Retrieve the GH Token from settings store to construct the headers """ - settings_store = await SettingsStoreImpl.get_instance(config, self.user_id) - settings = await settings_store.load() - if settings and settings.github_token: - self.token = settings.github_token.get_secret_value() + if self.user_id and not self.token: + self.token = await self.get_latest_token() return { - 'Authorization': f'Bearer {self.token}', + 'Authorization': f'Bearer {self.token.get_secret_value()}', 'Accept': 'application/vnd.github.v3+json', } - def _has_token_expired(self, status_code: int): + def _has_token_expired(self, status_code: int) -> bool: return status_code == 401 - async def _get_latest_token(self): - pass + async def get_latest_token(self) -> SecretStr: + return self.token async def _fetch_data( self, url: str, params: dict | None = None @@ -44,10 +50,8 @@ async def _fetch_data( async with httpx.AsyncClient() as client: github_headers = await self._get_github_headers() response = await client.get(url, headers=github_headers, params=params) - if server_config.app_mode == AppMode.SAAS and self._has_token_expired( - response.status_code - ): - await self._get_latest_token() + if self.refresh and self._has_token_expired(response.status_code): + await self.get_latest_token() github_headers = await self._get_github_headers() response = await client.get( url, headers=github_headers, params=params @@ -60,8 +64,10 @@ async def _fetch_data( return response.json(), headers - except httpx.HTTPStatusError: - raise GhAuthenticationError('Invalid Github token') + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + raise GhAuthenticationError('Invalid Github token') + raise GHUnknownException('Unknown error') except httpx.HTTPError: raise GHUnknownException('Unknown error') @@ -79,10 +85,6 @@ async def get_user(self) -> GitHubUser: email=response.get('email'), ) - async def validate_user(self, token) -> GitHubUser: - self.token = token - return await self.get_user() - async def get_repositories( self, page: int, per_page: int, sort: str, installation_id: int | None ) -> list[GitHubRepository]: @@ -134,6 +136,9 @@ async def search_repositories( return repos - @classmethod - def get_gh_token(cls, request: Request) -> str | None: - return get_github_token(request) + +github_service_cls = os.environ.get( + 'OPENHANDS_GITHUB_SERVICE_CLS', + 'openhands.integrations.github.github_service.GitHubService', +) +GithubServiceImpl = get_impl(GitHubService, github_service_cls) diff --git a/openhands/server/data_models/gh_types.py b/openhands/integrations/github/github_types.py similarity index 58% rename from openhands/server/data_models/gh_types.py rename to openhands/integrations/github/github_types.py index e6b67392bcca..d1958c9bbd19 100644 --- a/openhands/server/data_models/gh_types.py +++ b/openhands/integrations/github/github_types.py @@ -15,3 +15,15 @@ class GitHubRepository(BaseModel): full_name: str stargazers_count: int | None = None link_header: str | None = None + + +class GhAuthenticationError(ValueError): + """Raised when there is an issue with GitHub authentication.""" + + pass + + +class GHUnknownException(ValueError): + """Raised when there is an issue with GitHub communcation.""" + + pass diff --git a/openhands/llm/llm.py b/openhands/llm/llm.py index ff3c62772b47..b5fe67943467 100644 --- a/openhands/llm/llm.py +++ b/openhands/llm/llm.py @@ -75,16 +75,6 @@ 'o3-mini', ] -# visual browsing tool supported models -# This flag is needed since gpt-4o and gpt-4o-mini do not allow passing image_urls with role='tool' -VISUAL_BROWSING_TOOL_SUPPORTED_MODELS = [ - 'claude-3-5-sonnet', - 'claude-3-5-sonnet-20240620', - 'claude-3-5-sonnet-20241022', - 'o1-2024-12-17', -] - - REASONING_EFFORT_SUPPORTED_MODELS = [ 'o1-2024-12-17', 'o1', @@ -495,15 +485,6 @@ def is_function_calling_active(self) -> bool: """ return self._function_calling_active - def is_visual_browser_tool_supported(self) -> bool: - return ( - self.config.model in VISUAL_BROWSING_TOOL_SUPPORTED_MODELS - or self.config.model.split('/')[-1] in VISUAL_BROWSING_TOOL_SUPPORTED_MODELS - or any( - m in self.config.model for m in VISUAL_BROWSING_TOOL_SUPPORTED_MODELS - ) - ) - def _post_completion(self, response: ModelResponse) -> float: """Post-process the completion response. @@ -624,7 +605,9 @@ def _is_local(self) -> bool: return False def _completion_cost(self, response) -> float: - """Calculate the cost of a completion response based on the model. Local models are treated as free. + """Calculate completion cost and update metrics with running total. + + Calculate the cost of a completion response based on the model. Local models are treated as free. Add the current cost into total cost in metrics. Args: diff --git a/openhands/resolver/README.md b/openhands/resolver/README.md index eab4af667e3f..01455e72aa30 100644 --- a/openhands/resolver/README.md +++ b/openhands/resolver/README.md @@ -1,4 +1,4 @@ -# OpenHands Github Issue Resolver 🙌 +# OpenHands Github & Gitlab Issue Resolver 🙌 Need help resolving a GitHub issue but don't have the time to do it yourself? Let an AI agent help you out! @@ -74,14 +74,24 @@ If you prefer to run the resolver programmatically instead of using GitHub Actio pip install openhands-ai ``` -2. Create a GitHub access token: - - Visit [GitHub's token settings](https://github.com/settings/personal-access-tokens/new) - - Create a fine-grained token with these scopes: - - "Content" - - "Pull requests" - - "Issues" - - "Workflows" - - If you don't have push access to the target repo, you can fork it first +2. Create a GitHub or GitLab access token: + - Create a GitHub acces token + - Visit [GitHub's token settings](https://github.com/settings/personal-access-tokens/new) + - Create a fine-grained token with these scopes: + - "Content" + - "Pull requests" + - "Issues" + - "Workflows" + - If you don't have push access to the target repo, you can fork it first + + - Create a GitLab acces token + - Visit [GitLab's token settings](https://gitlab.com/-/user_settings/personal_access_tokens) + - Create a fine-grained token with these scopes: + - 'api' + - 'read_api' + - 'read_user' + - 'read_repository' + - 'write_repository' 3. Set up environment variables: @@ -90,7 +100,12 @@ pip install openhands-ai # GitHub credentials export GITHUB_TOKEN="your-github-token" -export GITHUB_USERNAME="your-github-username" # Optional, defaults to token owner +export GIT_USERNAME="your-github-username" # Optional, defaults to token owner + +# GitLab credentials if you're using GitLab repo + +export GITLAB_TOKEN="your-gitlab-token" +export GIT_USERNAME="your-gitlab-username" # Optional, defaults to token owner # LLM configuration @@ -169,13 +184,13 @@ There are three ways you can upload: 3. `ready` - create a non-draft PR that's ready for review ```bash -python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --github-username YOUR_GITHUB_USERNAME --pr-type draft +python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft ``` If you want to upload to a fork, you can do so by specifying the `fork-owner`: ```bash -python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --github-username YOUR_GITHUB_USERNAME --pr-type draft --fork-owner YOUR_GITHUB_USERNAME +python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft --fork-owner YOUR_GITHUB_OR_GITLAB_USERNAME ``` ## Providing Custom Instructions @@ -184,5 +199,5 @@ You can customize how the AI agent approaches issue resolution by adding a `.ope ## Troubleshooting -If you have any issues, please open an issue on this github repo, we're happy to help! +If you have any issues, please open an issue on this github or gitlab repo, we're happy to help! Alternatively, you can [email us](mailto:contact@all-hands.dev) or join the OpenHands Slack workspace (see [the README](/README.md) for an invite link). diff --git a/openhands/resolver/examples/openhands-resolver.yml b/openhands/resolver/examples/openhands-resolver.yml index 8ebb451ca05b..c76e197d36d7 100644 --- a/openhands/resolver/examples/openhands-resolver.yml +++ b/openhands/resolver/examples/openhands-resolver.yml @@ -25,6 +25,7 @@ jobs: max_iterations: ${{ fromJson(vars.OPENHANDS_MAX_ITER || 50) }} base_container_image: ${{ vars.OPENHANDS_BASE_CONTAINER_IMAGE || '' }} LLM_MODEL: ${{ vars.LLM_MODEL || 'anthropic/claude-3-5-sonnet-20241022' }} + target_branch: ${{ vars.TARGET_BRANCH || 'main' }} secrets: PAT_TOKEN: ${{ secrets.PAT_TOKEN }} PAT_USERNAME: ${{ secrets.PAT_USERNAME }} diff --git a/openhands/resolver/github_issue.py b/openhands/resolver/github_issue.py deleted file mode 100644 index d7d7974d3fdf..000000000000 --- a/openhands/resolver/github_issue.py +++ /dev/null @@ -1,21 +0,0 @@ -from pydantic import BaseModel - - -class ReviewThread(BaseModel): - comment: str - files: list[str] - - -class GithubIssue(BaseModel): - owner: str - repo: str - number: int - title: str - body: str - thread_comments: list[str] | None = None # Added field for issue thread comments - closing_issues: list[str] | None = None - review_comments: list[str] | None = None - review_threads: list[ReviewThread] | None = None - thread_ids: list[str] | None = None - head_branch: str | None = None - base_branch: str | None = None diff --git a/openhands/resolver/interfaces/github.py b/openhands/resolver/interfaces/github.py new file mode 100644 index 000000000000..46cceb68a4f7 --- /dev/null +++ b/openhands/resolver/interfaces/github.py @@ -0,0 +1,591 @@ +from typing import Any + +import requests + +from openhands.core.logger import openhands_logger as logger +from openhands.resolver.interfaces.issue import ( + Issue, + IssueHandlerInterface, + ReviewThread, +) +from openhands.resolver.utils import extract_issue_references + + +class GithubIssueHandler(IssueHandlerInterface): + def __init__(self, owner: str, repo: str, token: str, username: str | None = None): + self.owner = owner + self.repo = repo + self.token = token + self.username = username + self.base_url = self.get_base_url() + self.download_url = self.get_download_url() + self.clone_url = self.get_clone_url() + self.headers = self.get_headers() + + def set_owner(self, owner: str): + self.owner = owner + + def get_headers(self): + return { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json', + } + + def get_base_url(self): + return f'https://api.github.com/repos/{self.owner}/{self.repo}' + + def get_authorize_url(self): + return f'https://{self.username}:{self.token}@github.com/' + + def get_branch_url(self, branch_name: str): + return self.get_base_url() + f'/branches/{branch_name}' + + def get_download_url(self): + return f'{self.base_url}/issues' + + def get_clone_url(self): + username_and_token = ( + f'{self.username}:{self.token}' + if self.username + else f'x-auth-token:{self.token}' + ) + return f'https://{username_and_token}@github.com/{self.owner}/{self.repo}.git' + + def get_graphql_url(self): + return 'https://api.github.com/graphql' + + def get_compare_url(self, branch_name: str): + return f'https://github.com/{self.owner}/{self.repo}/compare/{branch_name}?expand=1' + + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + """Download issues from Github. + + Args: + issue_numbers: The numbers of the issues to download + comment_id: The ID of a single comment, if provided, otherwise all comments + + Returns: + List of Github issues. + """ + + if not issue_numbers: + raise ValueError('Unspecified issue number') + + all_issues = self.download_issues() + logger.info(f'Limiting resolving to issues {issue_numbers}.') + all_issues = [ + issue + for issue in all_issues + if issue['number'] in issue_numbers and 'pull_request' not in issue + ] + + if len(issue_numbers) == 1 and not all_issues: + raise ValueError(f'Issue {issue_numbers[0]} not found') + + converted_issues = [] + for issue in all_issues: + # Check for required fields (number and title) + if any([issue.get(key) is None for key in ['number', 'title']]): + logger.warning( + f'Skipping issue {issue} as it is missing number or title.' + ) + continue + + # Handle empty body by using empty string + if issue.get('body') is None: + issue['body'] = '' + + # Get issue thread comments + thread_comments = self.get_issue_comments( + issue['number'], comment_id=comment_id + ) + # Convert empty lists to None for optional fields + issue_details = Issue( + owner=self.owner, + repo=self.repo, + number=issue['number'], + title=issue['title'], + body=issue['body'], + thread_comments=thread_comments, + review_comments=None, # Initialize review comments as None for regular issues + ) + + converted_issues.append(issue_details) + + return converted_issues + + def download_issues(self) -> list[Any]: + params: dict[str, int | str] = {'state': 'open', 'per_page': 100, 'page': 1} + all_issues = [] + + while True: + response = requests.get( + self.download_url, headers=self.headers, params=params + ) + response.raise_for_status() + issues = response.json() + + if not issues: + break + + if not isinstance(issues, list) or any( + [not isinstance(issue, dict) for issue in issues] + ): + raise ValueError( + 'Expected list of dictionaries from Service Github API.' + ) + + all_issues.extend(issues) + assert isinstance(params['page'], int) + params['page'] += 1 + + return all_issues + + def get_issue_comments( + self, issue_number: int, comment_id: int | None = None + ) -> list[str] | None: + """Download comments for a specific issue from Github.""" + url = f'{self.download_url}/{issue_number}/comments' + params = {'per_page': 100, 'page': 1} + all_comments = [] + + while True: + response = requests.get(url, headers=self.headers, params=params) + response.raise_for_status() + comments = response.json() + + if not comments: + break + + if comment_id: + matching_comment = next( + ( + comment['body'] + for comment in comments + if comment['id'] == comment_id + ), + None, + ) + if matching_comment: + return [matching_comment] + else: + all_comments.extend([comment['body'] for comment in comments]) + + params['page'] += 1 + + return all_comments if all_comments else None + + def branch_exists(self, branch_name: str) -> bool: + print(f'Checking if branch {branch_name} exists...') + response = requests.get( + f'{self.base_url}/branches/{branch_name}', headers=self.headers + ) + exists = response.status_code == 200 + print(f'Branch {branch_name} exists: {exists}') + return exists + + def get_branch_name(self, base_branch_name: str): + branch_name = base_branch_name + attempt = 1 + while self.branch_exists(branch_name): + attempt += 1 + branch_name = f'{base_branch_name}-try{attempt}' + return branch_name + + def reply_to_comment(self, pr_number: int, comment_id: str, reply: str): + # Opting for graphql as REST API doesn't allow reply to replies in comment threads + query = """ + mutation($body: String!, $pullRequestReviewThreadId: ID!) { + addPullRequestReviewThreadReply(input: { body: $body, pullRequestReviewThreadId: $pullRequestReviewThreadId }) { + comment { + id + body + createdAt + } + } + } + """ + + comment_reply = f'Openhands fix success summary\n\n\n{reply}' + variables = {'body': comment_reply, 'pullRequestReviewThreadId': comment_id} + url = self.get_graphql_url() + headers = { + 'Authorization': f'Bearer {self.token}', + 'Content-Type': 'application/json', + } + + response = requests.post( + url, json={'query': query, 'variables': variables}, headers=headers + ) + response.raise_for_status() + + def get_pull_url(self, pr_number: int): + return f'https://github.com/{self.owner}/{self.repo}/pull/{pr_number}' + + def get_default_branch_name(self) -> str: + response = requests.get(f'{self.base_url}', headers=self.headers) + response.raise_for_status() + return response.json()['default_branch'] + + def create_pull_request(self, data=dict) -> dict: + response = requests.post( + f'{self.base_url}/pulls', headers=self.headers, json=data + ) + if response.status_code == 403: + raise RuntimeError( + 'Failed to create pull request due to missing permissions. ' + 'Make sure that the provided token has push permissions for the repository.' + ) + response.raise_for_status() + pr_data = response.json() + return pr_data + + def request_reviewers(self, reviewer: str, pr_number: int): + review_data = {'reviewers': [reviewer]} + review_response = requests.post( + f'{self.base_url}/pulls/{pr_number}/requested_reviewers', + headers=self.headers, + json=review_data, + ) + if review_response.status_code != 201: + print( + f'Warning: Failed to request review from {reviewer}: {review_response.text}' + ) + + def send_comment_msg(self, issue_number: int, msg: str): + """Send a comment message to a GitHub issue or pull request. + + Args: + issue_number: The issue or pull request number + msg: The message content to post as a comment + """ + # Post a comment on the PR + comment_url = f'{self.base_url}/issues/{issue_number}/comments' + comment_data = {'body': msg} + comment_response = requests.post( + comment_url, headers=self.headers, json=comment_data + ) + if comment_response.status_code != 201: + print( + f'Failed to post comment: {comment_response.status_code} {comment_response.text}' + ) + else: + print(f'Comment added to the PR: {msg}') + + def get_context_from_external_issues_references( + self, + closing_issues: list[str], + closing_issue_numbers: list[int], + issue_body: str, + review_comments: list[str] | None, + review_threads: list[ReviewThread], + thread_comments: list[str] | None, + ): + pass + + +class GithubPRHandler(GithubIssueHandler): + def __init__(self, owner: str, repo: str, token: str, username: str | None = None): + super().__init__(owner, repo, token, username) + self.download_url = ( + f'https://api.github.com/repos/{self.owner}/{self.repo}/pulls' + ) + + def download_pr_metadata( + self, pull_number: int, comment_id: int | None = None + ) -> tuple[list[str], list[int], list[str], list[ReviewThread], list[str]]: + """Run a GraphQL query against the GitHub API for information. + + Retrieves information about: + 1. unresolved review comments + 2. referenced issues the pull request would close + + Args: + pull_number: The number of the pull request to query. + comment_id: Optional ID of a specific comment to focus on. + query: The GraphQL query as a string. + variables: A dictionary of variables for the query. + token: Your GitHub personal access token. + + Returns: + The JSON response from the GitHub API. + """ + # Using graphql as REST API doesn't indicate resolved status for review comments + # TODO: grabbing the first 10 issues, 100 review threads, and 100 coments; add pagination to retrieve all + query = """ + query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + closingIssuesReferences(first: 10) { + edges { + node { + body + number + } + } + } + url + reviews(first: 100) { + nodes { + body + state + fullDatabaseId + } + } + reviewThreads(first: 100) { + edges{ + node{ + id + isResolved + comments(first: 100) { + totalCount + nodes { + body + path + fullDatabaseId + } + } + } + } + } + } + } + } + """ + + variables = {'owner': self.owner, 'repo': self.repo, 'pr': pull_number} + + url = 'https://api.github.com/graphql' + headers = { + 'Authorization': f'Bearer {self.token}', + 'Content-Type': 'application/json', + } + + response = requests.post( + url, json={'query': query, 'variables': variables}, headers=headers + ) + response.raise_for_status() + response_json = response.json() + + # Parse the response to get closing issue references and unresolved review comments + pr_data = ( + response_json.get('data', {}).get('repository', {}).get('pullRequest', {}) + ) + + # Get closing issues + closing_issues = pr_data.get('closingIssuesReferences', {}).get('edges', []) + closing_issues_bodies = [issue['node']['body'] for issue in closing_issues] + closing_issue_numbers = [ + issue['node']['number'] for issue in closing_issues + ] # Extract issue numbers + + # Get review comments + reviews = pr_data.get('reviews', {}).get('nodes', []) + if comment_id is not None: + reviews = [ + review + for review in reviews + if int(review['fullDatabaseId']) == comment_id + ] + review_bodies = [review['body'] for review in reviews] + + # Get unresolved review threads + review_threads = [] + thread_ids = [] # Store thread IDs; agent replies to the thread + raw_review_threads = pr_data.get('reviewThreads', {}).get('edges', []) + for thread in raw_review_threads: + node = thread.get('node', {}) + if not node.get( + 'isResolved', True + ): # Check if the review thread is unresolved + id = node.get('id') + thread_contains_comment_id = False + my_review_threads = node.get('comments', {}).get('nodes', []) + message = '' + files = [] + for i, review_thread in enumerate(my_review_threads): + if ( + comment_id is not None + and int(review_thread['fullDatabaseId']) == comment_id + ): + thread_contains_comment_id = True + + if ( + i == len(my_review_threads) - 1 + ): # Check if it's the last thread in the thread + if len(my_review_threads) > 1: + message += '---\n' # Add "---" before the last message if there's more than one thread + message += 'latest feedback:\n' + review_thread['body'] + '\n' + else: + message += ( + review_thread['body'] + '\n' + ) # Add each thread in a new line + + file = review_thread.get('path') + if file and file not in files: + files.append(file) + + if comment_id is None or thread_contains_comment_id: + unresolved_thread = ReviewThread(comment=message, files=files) + review_threads.append(unresolved_thread) + thread_ids.append(id) + + return ( + closing_issues_bodies, + closing_issue_numbers, + review_bodies, + review_threads, + thread_ids, + ) + + # Override processing of downloaded issues + def get_pr_comments( + self, pr_number: int, comment_id: int | None = None + ) -> list[str] | None: + """Download comments for a specific pull request from Github.""" + url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments' + headers = { + 'Authorization': f'token {self.token}', + 'Accept': 'application/vnd.github.v3+json', + } + params = {'per_page': 100, 'page': 1} + all_comments = [] + + while True: + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + comments = response.json() + + if not comments: + break + + if comment_id is not None: + matching_comment = next( + ( + comment['body'] + for comment in comments + if comment['id'] == comment_id + ), + None, + ) + if matching_comment: + return [matching_comment] + else: + all_comments.extend([comment['body'] for comment in comments]) + + params['page'] += 1 + + return all_comments if all_comments else None + + def get_context_from_external_issues_references( + self, + closing_issues: list[str], + closing_issue_numbers: list[int], + issue_body: str, + review_comments: list[str] | None, + review_threads: list[ReviewThread], + thread_comments: list[str] | None, + ): + new_issue_references = [] + + if issue_body: + new_issue_references.extend(extract_issue_references(issue_body)) + + if review_comments: + for comment in review_comments: + new_issue_references.extend(extract_issue_references(comment)) + + if review_threads: + for review_thread in review_threads: + new_issue_references.extend( + extract_issue_references(review_thread.comment) + ) + + if thread_comments: + for thread_comment in thread_comments: + new_issue_references.extend(extract_issue_references(thread_comment)) + + non_duplicate_references = set(new_issue_references) + unique_issue_references = non_duplicate_references.difference( + closing_issue_numbers + ) + + for issue_number in unique_issue_references: + try: + url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}' + headers = { + 'Authorization': f'Bearer {self.token}', + 'Accept': 'application/vnd.github.v3+json', + } + response = requests.get(url, headers=headers) + response.raise_for_status() + issue_data = response.json() + issue_body = issue_data.get('body', '') + if issue_body: + closing_issues.append(issue_body) + except requests.exceptions.RequestException as e: + logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}') + + return closing_issues + + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + if not issue_numbers: + raise ValueError('Unspecified issue numbers') + + all_issues = self.download_issues() + logger.info(f'Limiting resolving to issues {issue_numbers}.') + all_issues = [issue for issue in all_issues if issue['number'] in issue_numbers] + + converted_issues = [] + for issue in all_issues: + # For PRs, body can be None + if any([issue.get(key) is None for key in ['number', 'title']]): + logger.warning(f'Skipping #{issue} as it is missing number or title.') + continue + + # Handle None body for PRs + body = issue.get('body') if issue.get('body') is not None else '' + ( + closing_issues, + closing_issues_numbers, + review_comments, + review_threads, + thread_ids, + ) = self.download_pr_metadata(issue['number'], comment_id=comment_id) + head_branch = issue['head']['ref'] + + # Get PR thread comments + thread_comments = self.get_pr_comments( + issue['number'], comment_id=comment_id + ) + + closing_issues = self.get_context_from_external_issues_references( + closing_issues, + closing_issues_numbers, + body, + review_comments, + review_threads, + thread_comments, + ) + + issue_details = Issue( + owner=self.owner, + repo=self.repo, + number=issue['number'], + title=issue['title'], + body=body, + closing_issues=closing_issues, + review_comments=review_comments, + review_threads=review_threads, + thread_ids=thread_ids, + head_branch=head_branch, + thread_comments=thread_comments, + ) + + converted_issues.append(issue_details) + + return converted_issues diff --git a/openhands/resolver/interfaces/gitlab.py b/openhands/resolver/interfaces/gitlab.py new file mode 100644 index 000000000000..52661d93032d --- /dev/null +++ b/openhands/resolver/interfaces/gitlab.py @@ -0,0 +1,579 @@ +from typing import Any +from urllib.parse import quote + +import requests + +from openhands.core.logger import openhands_logger as logger +from openhands.resolver.interfaces.issue import ( + Issue, + IssueHandlerInterface, + ReviewThread, +) +from openhands.resolver.utils import extract_issue_references + + +class GitlabIssueHandler(IssueHandlerInterface): + def __init__(self, owner: str, repo: str, token: str, username: str | None = None): + self.owner = owner + self.repo = repo + self.token = token + self.username = username + self.base_url = self.get_base_url() + self.download_url = self.get_download_url() + self.clone_url = self.get_clone_url() + self.headers = self.get_headers() + + def set_owner(self, owner: str): + self.owner = owner + + def get_headers(self): + return { + 'Authorization': f'Bearer {self.token}', + 'Accept': 'application/json', + } + + def get_base_url(self): + project_path = quote(f'{self.owner}/{self.repo}', safe="") + return f'https://gitlab.com/api/v4/projects/{project_path}' + + def get_authorize_url(self): + return f'https://{self.username}:{self.token}@gitlab.com/' + + def get_branch_url(self, branch_name: str): + return self.get_base_url() + f'/repository/branches/{branch_name}' + + def get_download_url(self): + return f'{self.base_url}/issues' + + def get_clone_url(self): + username_and_token = self.token + if self.username: + username_and_token = f'{self.username}:{self.token}' + return f'https://{username_and_token}@gitlab.com/{self.owner}/{self.repo}.git' + + def get_graphql_url(self): + return 'https://gitlab.com/api/graphql' + + def get_compare_url(self, branch_name: str): + return f'https://gitlab.com/{self.owner}/{self.repo}/-/compare/{self.get_default_branch_name()}...{branch_name}' + + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + """Download issues from Gitlab. + + Args: + issue_numbers: The numbers of the issues to download + comment_id: The ID of a single comment, if provided, otherwise all comments + + Returns: + List of Gitlab issues. + """ + + if not issue_numbers: + raise ValueError('Unspecified issue number') + + all_issues = self.download_issues() + logger.info(f'Limiting resolving to issues {issue_numbers}.') + all_issues = [ + issue + for issue in all_issues + # if issue['iid'] in issue_numbers and issue['merge_requests_count'] == 0 + if issue['iid'] in issue_numbers # TODO for testing + ] + + if len(issue_numbers) == 1 and not all_issues: + raise ValueError(f'Issue {issue_numbers[0]} not found') + + converted_issues = [] + for issue in all_issues: + if any([issue.get(key) is None for key in ['iid', 'title']]): + logger.warning(f'Skipping issue {issue} as it is missing iid or title.') + continue + + # Handle empty body by using empty string + if issue.get('description') is None: + issue['description'] = '' + + # Get issue thread comments + thread_comments = self.get_issue_comments( + issue['iid'], comment_id=comment_id + ) + # Convert empty lists to None for optional fields + issue_details = Issue( + owner=self.owner, + repo=self.repo, + number=issue['iid'], + title=issue['title'], + body=issue['description'], + thread_comments=thread_comments, + review_comments=None, # Initialize review comments as None for regular issues + ) + + converted_issues.append(issue_details) + + return converted_issues + + def download_issues(self) -> list[Any]: + params: dict[str, int | str] = { + 'state': 'opened', + 'scope': 'all', + 'per_page': 100, + 'page': 1, + } + all_issues = [] + + while True: + response = requests.get( + self.download_url, headers=self.headers, params=params + ) + response.raise_for_status() + issues = response.json() + + if not issues: + break + + if not isinstance(issues, list) or any( + [not isinstance(issue, dict) for issue in issues] + ): + raise ValueError( + 'Expected list of dictionaries from Service Gitlab API.' + ) + + all_issues.extend(issues) + assert isinstance(params['page'], int) + params['page'] += 1 + + return all_issues + + def get_issue_comments( + self, issue_number: int, comment_id: int | None = None + ) -> list[str] | None: + """Download comments for a specific issue from Gitlab.""" + url = f'{self.download_url}/{issue_number}/notes' + params = {'per_page': 100, 'page': 1} + all_comments = [] + + while True: + response = requests.get(url, headers=self.headers, params=params) + response.raise_for_status() + comments = response.json() + + if not comments: + break + + if comment_id: + matching_comment = next( + ( + comment['body'] + for comment in comments + if comment['id'] == comment_id + ), + None, + ) + if matching_comment: + return [matching_comment] + else: + all_comments.extend([comment['body'] for comment in comments]) + + params['page'] += 1 + + return all_comments if all_comments else None + + def branch_exists(self, branch_name: str) -> bool: + print(f'Checking if branch {branch_name} exists...') + response = requests.get( + f'{self.base_url}/repository/branches/{branch_name}', headers=self.headers + ) + exists = response.status_code == 200 + print(f'Branch {branch_name} exists: {exists}') + return exists + + def get_branch_name(self, base_branch_name: str): + branch_name = base_branch_name + attempt = 1 + while self.branch_exists(branch_name): + attempt += 1 + branch_name = f'{base_branch_name}-try{attempt}' + return branch_name + + def reply_to_comment(self, pr_number: int, comment_id: str, reply: str): + response = requests.get( + f'{self.base_url}/merge_requests/{pr_number}/discussions/{comment_id.split('/')[-1]}', + headers=self.headers, + ) + response.raise_for_status() + discussions = response.json() + if len(discussions.get('notes', [])) > 0: + data = { + 'body': f'Openhands fix success summary\n\n\n{reply}', + 'note_id': discussions.get('notes', [])[-1]['id'], + } + response = requests.post( + f'{self.base_url}/merge_requests/{pr_number}/discussions/{comment_id.split('/')[-1]}/notes', + headers=self.headers, + json=data, + ) + response.raise_for_status() + + def get_pull_url(self, pr_number: int): + return ( + f'https://gitlab.com/{self.owner}/{self.repo}/-/merge_requests/{pr_number}' + ) + + def get_default_branch_name(self) -> str: + response = requests.get(f'{self.base_url}', headers=self.headers) + response.raise_for_status() + return response.json()['default_branch'] + + def create_pull_request(self, data=dict) -> dict: + response = requests.post( + f'{self.base_url}/merge_requests', headers=self.headers, json=data + ) + if response.status_code == 403: + raise RuntimeError( + 'Failed to create pull request due to missing permissions. ' + 'Make sure that the provided token has push permissions for the repository.' + ) + response.raise_for_status() + pr_data = response.json() + if 'web_url' in pr_data: + pr_data['html_url'] = pr_data['web_url'] + + if 'iid' in pr_data: + pr_data['number'] = pr_data['iid'] + + return pr_data + + def request_reviewers(self, reviewer: str, pr_number: int): + response = requests.get( + f'https://gitlab.com/api/v4/users?username={reviewer}', + headers=self.headers, + ) + response.raise_for_status() + user_data = response.json() + if len(user_data) > 0: + review_data = {'reviewer_ids': [user_data[0]['id']]} + review_response = requests.put( + f'{self.base_url}/merge_requests/{pr_number}', + headers=self.headers, + json=review_data, + ) + if review_response.status_code != 200: + print( + f'Warning: Failed to request review from {reviewer}: {review_response.text}' + ) + + def send_comment_msg(self, issue_number: int, msg: str): + """Send a comment message to a GitHub issue or pull request. + + Args: + issue_number: The issue or pull request number + msg: The message content to post as a comment + """ + # Post a comment on the PR + comment_url = f'{self.base_url}/issues/{issue_number}/notes' + comment_data = {'body': msg} + comment_response = requests.post( + comment_url, headers=self.headers, json=comment_data + ) + if comment_response.status_code != 201: + print( + f'Failed to post comment: {comment_response.status_code} {comment_response.text}' + ) + else: + print(f'Comment added to the PR: {msg}') + + def get_context_from_external_issues_references( + self, + closing_issues: list[str], + closing_issue_numbers: list[int], + issue_body: str, + review_comments: list[str] | None, + review_threads: list[ReviewThread], + thread_comments: list[str] | None, + ): + pass + + +class GitlabPRHandler(GitlabIssueHandler): + def __init__(self, owner: str, repo: str, token: str, username: str | None = None): + super().__init__(owner, repo, token, username) + self.download_url = f'{self.base_url}/merge_requests' + + def download_pr_metadata( + self, pull_number: int, comment_id: int | None = None + ) -> tuple[list[str], list[int], list[str] | None, list[ReviewThread], list[str]]: + """Run a GraphQL query against the Gitlab API for information. + + Retrieves information about: + 1. unresolved review comments + 2. referenced issues the pull request would close + + Args: + pull_number: The number of the pull request to query. + comment_id: Optional ID of a specific comment to focus on. + query: The GraphQL query as a string. + variables: A dictionary of variables for the query. + token: Your Gitlab personal access token. + + Returns: + The JSON response from the Gitlab API. + """ + # Using graphql as REST API doesn't indicate resolved status for review comments + # TODO: grabbing the first 10 issues, 100 review threads, and 100 coments; add pagination to retrieve all + response = requests.get( + f'{self.base_url}/merge_requests/{pull_number}/related_issues', + headers=self.headers, + ) + response.raise_for_status() + closing_issues = response.json() + closing_issues_bodies = [issue['description'] for issue in closing_issues] + closing_issue_numbers = [ + issue['iid'] for issue in closing_issues + ] # Extract issue numbers + + query = """ + query($projectPath: ID!, $pr: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $pr) { + webUrl + discussions(first: 100) { + edges { + node { + id + resolved + resolvable + notes(first: 100) { + nodes { + body + id + position { + filePath + } + } + } + } + } + } + } + } + } + """ + + project_path = f'{self.owner}/{self.repo}' + variables = {'projectPath': project_path, 'pr': str(pull_number)} + + response = requests.post( + self.get_graphql_url(), + json={'query': query, 'variables': variables}, + headers=self.headers, + ) + response.raise_for_status() + response_json = response.json() + + # Parse the response to get closing issue references and unresolved review comments + pr_data = ( + response_json.get('data', {}).get('project', {}).get('mergeRequest', {}) + ) + + # Get review comments + review_bodies = None + + # Get unresolved review threads + review_threads = [] + thread_ids = [] # Store thread IDs; agent replies to the thread + raw_review_threads = pr_data.get('discussions', {}).get('edges', []) + + for thread in raw_review_threads: + node = thread.get('node', {}) + if not node.get('resolved', True) and node.get( + 'resolvable', True + ): # Check if the review thread is unresolved + id = node.get('id') + thread_contains_comment_id = False + my_review_threads = node.get('notes', {}).get('nodes', []) + message = '' + files = [] + for i, review_thread in enumerate(my_review_threads): + if ( + comment_id is not None + and int(review_thread['id'].split('/')[-1]) == comment_id + ): + thread_contains_comment_id = True + + if ( + i == len(my_review_threads) - 1 + ): # Check if it's the last thread in the thread + if len(my_review_threads) > 1: + message += '---\n' # Add "---" before the last message if there's more than one thread + message += 'latest feedback:\n' + review_thread['body'] + '\n' + else: + message += ( + review_thread['body'] + '\n' + ) # Add each thread in a new line + + file = review_thread.get('position', {}) + file = file.get('filePath') if file is not None else None + if file and file not in files: + files.append(file) + + if comment_id is None or thread_contains_comment_id: + unresolved_thread = ReviewThread(comment=message, files=files) + review_threads.append(unresolved_thread) + thread_ids.append(id) + + return ( + closing_issues_bodies, + closing_issue_numbers, + review_bodies, + review_threads, + thread_ids, + ) + + # Override processing of downloaded issues + def get_pr_comments( + self, pr_number: int, comment_id: int | None = None + ) -> list[str] | None: + """Download comments for a specific pull request from Gitlab.""" + url = f'{self.base_url}/merge_requests/{pr_number}/notes' + params = {'per_page': 100, 'page': 1} + all_comments = [] + + while True: + response = requests.get(url, headers=self.headers, params=params) + response.raise_for_status() + comments = response.json() + comments = [ + comment + for comment in comments + if comment.get('resolvable', True) and not comment.get('system', True) + ] + + if not comments: + break + + if comment_id is not None: + matching_comment = next( + ( + comment['body'] + for comment in comments + if comment['id'] == comment_id + ), + None, + ) + if matching_comment: + return [matching_comment] + else: + all_comments.extend([comment['body'] for comment in comments]) + + params['page'] += 1 + + return all_comments if all_comments else None + + def get_context_from_external_issues_references( + self, + closing_issues: list[str], + closing_issue_numbers: list[int], + issue_body: str, + review_comments: list[str] | None, + review_threads: list[ReviewThread], + thread_comments: list[str] | None, + ): + new_issue_references = [] + + if issue_body: + new_issue_references.extend(extract_issue_references(issue_body)) + + if review_comments: + for comment in review_comments: + new_issue_references.extend(extract_issue_references(comment)) + + if review_threads: + for review_thread in review_threads: + new_issue_references.extend( + extract_issue_references(review_thread.comment) + ) + + if thread_comments: + for thread_comment in thread_comments: + new_issue_references.extend(extract_issue_references(thread_comment)) + + non_duplicate_references = set(new_issue_references) + unique_issue_references = non_duplicate_references.difference( + closing_issue_numbers + ) + + for issue_number in unique_issue_references: + try: + url = f'{self.base_url}/issues/{issue_number}' + response = requests.get(url, headers=self.headers) + response.raise_for_status() + issue_data = response.json() + issue_body = issue_data.get('description', '') + if issue_body: + closing_issues.append(issue_body) + except requests.exceptions.RequestException as e: + logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}') + + return closing_issues + + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + if not issue_numbers: + raise ValueError('Unspecified issue numbers') + + all_issues = self.download_issues() + logger.info(f'Limiting resolving to issues {issue_numbers}.') + all_issues = [issue for issue in all_issues if issue['iid'] in issue_numbers] + + converted_issues = [] + for issue in all_issues: + # For PRs, body can be None + if any([issue.get(key) is None for key in ['iid', 'title']]): + logger.warning(f'Skipping #{issue} as it is missing iid or title.') + continue + + # Handle None body for PRs + body = ( + issue.get('description') if issue.get('description') is not None else '' + ) + ( + closing_issues, + closing_issues_numbers, + review_comments, + review_threads, + thread_ids, + ) = self.download_pr_metadata(issue['iid'], comment_id=comment_id) + head_branch = issue['source_branch'] + + # Get PR thread comments + thread_comments = self.get_pr_comments(issue['iid'], comment_id=comment_id) + + closing_issues = self.get_context_from_external_issues_references( + closing_issues, + closing_issues_numbers, + body, + review_comments, + review_threads, + thread_comments, + ) + + issue_details = Issue( + owner=self.owner, + repo=self.repo, + number=issue['iid'], + title=issue['title'], + body=body, + closing_issues=closing_issues, + review_comments=review_comments, + review_threads=review_threads, + thread_ids=thread_ids, + head_branch=head_branch, + thread_comments=thread_comments, + ) + + converted_issues.append(issue_details) + + return converted_issues diff --git a/openhands/resolver/interfaces/issue.py b/openhands/resolver/interfaces/issue.py new file mode 100644 index 000000000000..263fd8160377 --- /dev/null +++ b/openhands/resolver/interfaces/issue.py @@ -0,0 +1,123 @@ +from abc import ABC, abstractmethod +from typing import Any + +from pydantic import BaseModel + + +class ReviewThread(BaseModel): + comment: str + files: list[str] + + +class Issue(BaseModel): + owner: str + repo: str + number: int + title: str + body: str + thread_comments: list[str] | None = None # Added field for issue thread comments + closing_issues: list[str] | None = None + review_comments: list[str] | None = None + review_threads: list[ReviewThread] | None = None + thread_ids: list[str] | None = None + head_branch: str | None = None + base_branch: str | None = None + + +class IssueHandlerInterface(ABC): + @abstractmethod + def set_owner(self, owner: str): + pass + + @abstractmethod + def download_issues(self) -> list[Any]: + pass + + @abstractmethod + def get_issue_comments( + self, issue_number: int, comment_id: int | None = None + ) -> list[str] | None: + pass + + @abstractmethod + def get_base_url(self): + pass + + @abstractmethod + def get_branch_url(self, branch_name): + pass + + @abstractmethod + def get_download_url(self): + pass + + @abstractmethod + def get_clone_url(self): + pass + + @abstractmethod + def get_pull_url(self, pr_number: int): + pass + + @abstractmethod + def get_graphql_url(self): + pass + + @abstractmethod + def get_headers(self): + pass + + @abstractmethod + def get_compare_url(self, branch_name): + pass + + @abstractmethod + def get_branch_name(self, base_branch_name: str): + pass + + @abstractmethod + def get_default_branch_name(self): + pass + + @abstractmethod + def branch_exists(self, branch_name: str) -> bool: + pass + + @abstractmethod + def reply_to_comment(self, pr_number: int, comment_id: str, reply: str): + pass + + @abstractmethod + def send_comment_msg(self, issue_number: int, msg: str): + pass + + @abstractmethod + def get_authorize_url(self): + pass + + @abstractmethod + def create_pull_request(self, data=dict) -> dict: + pass + + @abstractmethod + def request_reviewers(self, reviewer: str, pr_number: int): + pass + + @abstractmethod + def get_context_from_external_issues_references( + self, + closing_issues: list[str], + closing_issue_numbers: list[int], + issue_body: str, + review_comments: list[str] | None, + review_threads: list[ReviewThread], + thread_comments: list[str] | None, + ): + pass + + @abstractmethod + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + """Download issues from Gitlab.""" + pass diff --git a/openhands/resolver/interfaces/issue_definitions.py b/openhands/resolver/interfaces/issue_definitions.py new file mode 100644 index 000000000000..6912ab5c1e78 --- /dev/null +++ b/openhands/resolver/interfaces/issue_definitions.py @@ -0,0 +1,400 @@ +import json +import os +import re +from typing import Any, ClassVar + +import jinja2 + +from openhands.core.config import LLMConfig +from openhands.events.event import Event +from openhands.llm.llm import LLM +from openhands.resolver.interfaces.issue import ( + Issue, + IssueHandlerInterface, + ReviewThread, +) +from openhands.resolver.utils import extract_image_urls + + +class ServiceContext: + issue_type: ClassVar[str] + default_git_patch: ClassVar[str] = 'No changes made yet' + + def __init__(self, strategy: IssueHandlerInterface, llm_config: LLMConfig | None): + self._strategy = strategy + if llm_config is not None: + self.llm = LLM(llm_config) + + def set_strategy(self, strategy): + self._strategy = strategy + + +# Strategy context interface +class ServiceContextPR(ServiceContext): + issue_type: ClassVar[str] = 'pr' + + def __init__(self, strategy: IssueHandlerInterface, llm_config: LLMConfig): + super().__init__(strategy, llm_config) + + def get_clone_url(self): + return self._strategy.get_clone_url() + + def download_issues(self) -> list[Any]: + return self._strategy.download_issues() + + def guess_success( + self, + issue: Issue, + history: list[Event], + git_patch: str | None = None, + ) -> tuple[bool, None | list[bool], str]: + """Guess if the issue is fixed based on the history, issue description and git patch. + + Args: + issue: The issue to check + history: The agent's history + git_patch: Optional git patch showing the changes made + """ + last_message = history[-1].message + + issues_context = json.dumps(issue.closing_issues, indent=4) + success_list = [] + explanation_list = [] + + # Handle PRs with file-specific review comments + if issue.review_threads: + for review_thread in issue.review_threads: + if issues_context and last_message: + success, explanation = self._check_review_thread( + review_thread, issues_context, last_message, git_patch + ) + else: + success, explanation = False, 'Missing context or message' + success_list.append(success) + explanation_list.append(explanation) + # Handle PRs with only thread comments (no file-specific review comments) + elif issue.thread_comments: + if issue.thread_comments and issues_context and last_message: + success, explanation = self._check_thread_comments( + issue.thread_comments, issues_context, last_message, git_patch + ) + else: + success, explanation = ( + False, + 'Missing thread comments, context or message', + ) + success_list.append(success) + explanation_list.append(explanation) + elif issue.review_comments: + # Handle PRs with only review comments (no file-specific review comments or thread comments) + if issue.review_comments and issues_context and last_message: + success, explanation = self._check_review_comments( + issue.review_comments, issues_context, last_message, git_patch + ) + else: + success, explanation = ( + False, + 'Missing review comments, context or message', + ) + success_list.append(success) + explanation_list.append(explanation) + else: + # No review comments, thread comments, or file-level review comments found + return False, None, 'No feedback was found to process' + + # Return overall success (all must be true) and explanations + if not success_list: + return False, None, 'No feedback was processed' + return all(success_list), success_list, json.dumps(explanation_list) + + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + return self._strategy.get_converted_issues(issue_numbers, comment_id) + + def get_instruction( + self, + issue: Issue, + prompt_template: str, + repo_instruction: str | None = None, + ) -> tuple[str, list[str]]: + """Generate instruction for the agent.""" + template = jinja2.Template(prompt_template) + images = [] + + issues_str = None + if issue.closing_issues: + issues_str = json.dumps(issue.closing_issues, indent=4) + images.extend(extract_image_urls(issues_str)) + + # Handle PRs with review comments + review_comments_str = None + if issue.review_comments: + review_comments_str = json.dumps(issue.review_comments, indent=4) + images.extend(extract_image_urls(review_comments_str)) + + # Handle PRs with file-specific review comments + review_thread_str = None + review_thread_file_str = None + if issue.review_threads: + review_threads = [ + review_thread.comment for review_thread in issue.review_threads + ] + review_thread_files = [] + for review_thread in issue.review_threads: + review_thread_files.extend(review_thread.files) + review_thread_str = json.dumps(review_threads, indent=4) + review_thread_file_str = json.dumps(review_thread_files, indent=4) + images.extend(extract_image_urls(review_thread_str)) + + # Format thread comments if they exist + thread_context = '' + if issue.thread_comments: + thread_context = '\n---\n'.join(issue.thread_comments) + images.extend(extract_image_urls(thread_context)) + + instruction = template.render( + issues=issues_str, + review_comments=review_comments_str, + review_threads=review_thread_str, + files=review_thread_file_str, + thread_context=thread_context, + repo_instruction=repo_instruction, + ) + return instruction, images + + def _check_feedback_with_llm(self, prompt: str) -> tuple[bool, str]: + """Helper function to check feedback with LLM and parse response.""" + response = self.llm.completion(messages=[{'role': 'user', 'content': prompt}]) + + answer = response.choices[0].message.content.strip() + pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)' + match = re.search(pattern, answer) + if match: + return match.group(1).lower() == 'true', match.group(2).strip() + return False, f'Failed to decode answer from LLM response: {answer}' + + def _check_review_thread( + self, + review_thread: ReviewThread, + issues_context: str, + last_message: str, + git_patch: str | None = None, + ) -> tuple[bool, str]: + """Check if a review thread's feedback has been addressed.""" + files_context = json.dumps(review_thread.files, indent=4) + + with open( + os.path.join( + os.path.dirname(__file__), + '../prompts/guess_success/pr-feedback-check.jinja', + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + + prompt = template.render( + issue_context=issues_context, + feedback=review_thread.comment, + files_context=files_context, + last_message=last_message, + git_patch=git_patch or self.default_git_patch, + ) + + return self._check_feedback_with_llm(prompt) + + def _check_thread_comments( + self, + thread_comments: list[str], + issues_context: str, + last_message: str, + git_patch: str | None = None, + ) -> tuple[bool, str]: + """Check if thread comments feedback has been addressed.""" + thread_context = '\n---\n'.join(thread_comments) + + with open( + os.path.join( + os.path.dirname(__file__), + '../prompts/guess_success/pr-thread-check.jinja', + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + + prompt = template.render( + issue_context=issues_context, + thread_context=thread_context, + last_message=last_message, + git_patch=git_patch or self.default_git_patch, + ) + + return self._check_feedback_with_llm(prompt) + + def _check_review_comments( + self, + review_comments: list[str], + issues_context: str, + last_message: str, + git_patch: str | None = None, + ) -> tuple[bool, str]: + """Check if review comments feedback has been addressed.""" + review_context = '\n---\n'.join(review_comments) + + with open( + os.path.join( + os.path.dirname(__file__), + '../prompts/guess_success/pr-review-check.jinja', + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + + prompt = template.render( + issue_context=issues_context, + review_context=review_context, + last_message=last_message, + git_patch=git_patch or self.default_git_patch, + ) + + return self._check_feedback_with_llm(prompt) + + +class ServiceContextIssue(ServiceContext): + issue_type: ClassVar[str] = 'issue' + + def __init__(self, strategy: IssueHandlerInterface, llm_config: LLMConfig | None): + super().__init__(strategy, llm_config) + + def get_base_url(self): + return self._strategy.get_base_url() + + def get_branch_url(self, branch_name): + return self._strategy.get_branch_url(branch_name) + + def get_download_url(self): + return self._strategy.get_download_url() + + def get_clone_url(self): + return self._strategy.get_clone_url() + + def get_graphql_url(self): + return self._strategy.get_graphql_url() + + def get_headers(self): + return self._strategy.get_headers() + + def get_authorize_url(self): + return self._strategy.get_authorize_url() + + def get_pull_url(self, pr_number: int): + return self._strategy.get_pull_url(pr_number) + + def get_compare_url(self, branch_name: str): + return self._strategy.get_compare_url(branch_name) + + def download_issues(self) -> list[Any]: + return self._strategy.download_issues() + + def get_branch_name( + self, + base_branch_name: str, + ): + return self._strategy.get_branch_name(base_branch_name) + + def branch_exists(self, branch_name: str): + return self._strategy.branch_exists(branch_name) + + def get_default_branch_name(self) -> str: + return self._strategy.get_default_branch_name() + + def create_pull_request(self, data=dict): + return self._strategy.create_pull_request(data) + + def request_reviewers(self, reviewer: str, pr_number: int): + return self._strategy.request_reviewers(reviewer, pr_number) + + def reply_to_comment(self, pr_number, comment_id, reply): + return self._strategy.reply_to_comment(pr_number, comment_id, reply) + + def send_comment_msg(self, issue_number: int, msg: str): + return self._strategy.send_comment_msg(issue_number, msg) + + def get_issue_comments( + self, issue_number: int, comment_id: int | None = None + ) -> list[str] | None: + return self._strategy.get_issue_comments(issue_number, comment_id) + + def get_instruction( + self, + issue: Issue, + prompt_template: str, + repo_instruction: str | None = None, + ) -> tuple[str, list[str]]: + """Generate instruction for the agent.""" + # Format thread comments if they exist + thread_context = '' + if issue.thread_comments: + thread_context = '\n\nIssue Thread Comments:\n' + '\n---\n'.join( + issue.thread_comments + ) + + images = [] + images.extend(extract_image_urls(issue.body)) + images.extend(extract_image_urls(thread_context)) + + template = jinja2.Template(prompt_template) + return ( + template.render( + body=issue.title + '\n\n' + issue.body + thread_context, + repo_instruction=repo_instruction, + ), + images, + ) + + def guess_success( + self, issue: Issue, history: list[Event], git_patch: str | None = None + ) -> tuple[bool, None | list[bool], str]: + """Guess if the issue is fixed based on the history and the issue description. + + Args: + issue: The issue to check + history: The agent's history + git_patch: Optional git patch showing the changes made + """ + last_message = history[-1].message + # Include thread comments in the prompt if they exist + issue_context = issue.body + if issue.thread_comments: + issue_context += '\n\nIssue Thread Comments:\n' + '\n---\n'.join( + issue.thread_comments + ) + + with open( + os.path.join( + os.path.dirname(__file__), + '../prompts/guess_success/issue-success-check.jinja', + ), + 'r', + ) as f: + template = jinja2.Template(f.read()) + prompt = template.render( + issue_context=issue_context, + last_message=last_message, + git_patch=git_patch or self.default_git_patch, + ) + + response = self.llm.completion(messages=[{'role': 'user', 'content': prompt}]) + + answer = response.choices[0].message.content.strip() + pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)' + match = re.search(pattern, answer) + if match: + return match.group(1).lower() == 'true', None, match.group(2) + + return False, None, f'Failed to decode answer from LLM response: {answer}' + + def get_converted_issues( + self, issue_numbers: list[int] | None = None, comment_id: int | None = None + ) -> list[Issue]: + return self._strategy.get_converted_issues(issue_numbers, comment_id) diff --git a/openhands/resolver/issue_definitions.py b/openhands/resolver/issue_definitions.py deleted file mode 100644 index b9d7e83a3071..000000000000 --- a/openhands/resolver/issue_definitions.py +++ /dev/null @@ -1,806 +0,0 @@ -import json -import os -import re -from abc import ABC, abstractmethod -from typing import Any, ClassVar - -import jinja2 -import requests - -from openhands.core.config import LLMConfig -from openhands.core.logger import openhands_logger as logger -from openhands.events.event import Event -from openhands.llm.llm import LLM -from openhands.resolver.github_issue import GithubIssue, ReviewThread - - -class IssueHandlerInterface(ABC): - issue_type: ClassVar[str] - llm: LLM - - @abstractmethod - def get_converted_issues( - self, issue_numbers: list[int] | None = None, comment_id: int | None = None - ) -> list[GithubIssue]: - """Download issues from GitHub.""" - pass - - @abstractmethod - def get_instruction( - self, - issue: GithubIssue, - prompt_template: str, - repo_instruction: str | None = None, - ) -> tuple[str, list[str]]: - """Generate instruction and image urls for the agent.""" - pass - - @abstractmethod - def guess_success( - self, issue: GithubIssue, history: list[Event], git_patch: str | None = None - ) -> tuple[bool, list[bool] | None, str]: - """Guess if the issue has been resolved based on the agent's output and git patch.""" - pass - - -class IssueHandler(IssueHandlerInterface): - issue_type: ClassVar[str] = 'issue' - default_git_patch: ClassVar[str] = 'No changes made yet' - - def __init__(self, owner: str, repo: str, token: str, llm_config: LLMConfig): - self.download_url = 'https://api.github.com/repos/{}/{}/issues' - self.owner = owner - self.repo = repo - self.token = token - self.llm = LLM(llm_config) - - def _download_issues_from_github(self) -> list[Any]: - url = self.download_url.format(self.owner, self.repo) - headers = { - 'Authorization': f'token {self.token}', - 'Accept': 'application/vnd.github.v3+json', - } - params: dict[str, int | str] = {'state': 'open', 'per_page': 100, 'page': 1} - all_issues = [] - - # Get issues, page by page - while True: - response = requests.get(url, headers=headers, params=params) - response.raise_for_status() - issues = response.json() - - # No more issues, break the loop - if not issues: - break - - # Sanity check - the response is a list of dictionaries - if not isinstance(issues, list) or any( - [not isinstance(issue, dict) for issue in issues] - ): - raise ValueError('Expected list of dictionaries from Github API.') - - # Add the issues to the final list - all_issues.extend(issues) - assert isinstance(params['page'], int) - params['page'] += 1 - - return all_issues - - def _extract_image_urls(self, issue_body: str) -> list[str]: - # Regular expression to match Markdown image syntax ![alt text](image_url) - image_pattern = r'!\[.*?\]\((https?://[^\s)]+)\)' - return re.findall(image_pattern, issue_body) - - def _extract_issue_references(self, body: str) -> list[int]: - # First, remove code blocks as they may contain false positives - body = re.sub(r'```.*?```', '', body, flags=re.DOTALL) - - # Remove inline code - body = re.sub(r'`[^`]*`', '', body) - - # Remove URLs that contain hash symbols - body = re.sub(r'https?://[^\s)]*#\d+[^\s)]*', '', body) - - # Now extract issue numbers, making sure they're not part of other text - # The pattern matches #number that: - # 1. Is at the start of text or after whitespace/punctuation - # 2. Is followed by whitespace, punctuation, or end of text - # 3. Is not part of a URL - pattern = r'(?:^|[\s\[({]|[^\w#])#(\d+)(?=[\s,.\])}]|$)' - return [int(match) for match in re.findall(pattern, body)] - - def _get_issue_comments( - self, issue_number: int, comment_id: int | None = None - ) -> list[str] | None: - """Retrieve comments for a specific issue from Github. - - Args: - issue_number: The ID of the issue to get comments for - comment_id: The ID of a single comment, if provided, otherwise all comments - """ - url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}/comments' - headers = { - 'Authorization': f'token {self.token}', - 'Accept': 'application/vnd.github.v3+json', - } - params = {'per_page': 100, 'page': 1} - all_comments = [] - - # Get comments, page by page - while True: - response = requests.get(url, headers=headers, params=params) - response.raise_for_status() - comments = response.json() - - if not comments: - break - - # If a single comment ID is provided, return only that comment - if comment_id: - matching_comment = next( - ( - comment['body'] - for comment in comments - if comment['id'] == comment_id - ), - None, - ) - if matching_comment: - return [matching_comment] - else: - # Otherwise, return all comments - all_comments.extend([comment['body'] for comment in comments]) - - params['page'] += 1 - - return all_comments if all_comments else None - - def get_converted_issues( - self, issue_numbers: list[int] | None = None, comment_id: int | None = None - ) -> list[GithubIssue]: - """Download issues from Github. - - Args: - issue_numbers: The numbers of the issues to download - comment_id: The ID of a single comment, if provided, otherwise all comments - - Returns: - List of Github issues. - """ - - if not issue_numbers: - raise ValueError('Unspecified issue number') - - all_issues = self._download_issues_from_github() - logger.info(f'Limiting resolving to issues {issue_numbers}.') - all_issues = [ - issue - for issue in all_issues - if issue['number'] in issue_numbers and 'pull_request' not in issue - ] - - if len(issue_numbers) == 1 and not all_issues: - raise ValueError(f'Issue {issue_numbers[0]} not found') - - converted_issues = [] - for issue in all_issues: - # Check for required fields (number and title) - if any([issue.get(key) is None for key in ['number', 'title']]): - logger.warning( - f'Skipping issue {issue} as it is missing number or title.' - ) - continue - - # Handle empty body by using empty string - if issue.get('body') is None: - issue['body'] = '' - - # Get issue thread comments - thread_comments = self._get_issue_comments( - issue['number'], comment_id=comment_id - ) - # Convert empty lists to None for optional fields - issue_details = GithubIssue( - owner=self.owner, - repo=self.repo, - number=issue['number'], - title=issue['title'], - body=issue['body'], - thread_comments=thread_comments, - review_comments=None, # Initialize review comments as None for regular issues - ) - - converted_issues.append(issue_details) - - return converted_issues - - def get_instruction( - self, - issue: GithubIssue, - prompt_template: str, - repo_instruction: str | None = None, - ) -> tuple[str, list[str]]: - """Generate instruction for the agent. - - Args: - issue: The issue to generate instruction for - prompt_template: The prompt template to use - repo_instruction: The repository instruction if it exists - """ - - # Format thread comments if they exist - thread_context = '' - if issue.thread_comments: - thread_context = '\n\nIssue Thread Comments:\n' + '\n---\n'.join( - issue.thread_comments - ) - - # Extract image URLs from the issue body and thread comments - images = [] - images.extend(self._extract_image_urls(issue.body)) - images.extend(self._extract_image_urls(thread_context)) - - template = jinja2.Template(prompt_template) - return ( - template.render( - body=issue.title + '\n\n' + issue.body + thread_context, - repo_instruction=repo_instruction, - ), - images, - ) - - def guess_success( - self, issue: GithubIssue, history: list[Event], git_patch: str | None = None - ) -> tuple[bool, None | list[bool], str]: - """Guess if the issue is fixed based on the history and the issue description. - - Args: - issue: The issue to check - history: The agent's history - git_patch: Optional git patch showing the changes made - """ - last_message = history[-1].message - - # Include thread comments in the prompt if they exist - issue_context = issue.body - if issue.thread_comments: - issue_context += '\n\nIssue Thread Comments:\n' + '\n---\n'.join( - issue.thread_comments - ) - - # Prepare the prompt - with open( - os.path.join( - os.path.dirname(__file__), - 'prompts/guess_success/issue-success-check.jinja', - ), - 'r', - ) as f: - template = jinja2.Template(f.read()) - prompt = template.render( - issue_context=issue_context, - last_message=last_message, - git_patch=git_patch or self.default_git_patch, - ) - - # Get the LLM response and check for 'success' and 'explanation' in the answer - response = self.llm.completion(messages=[{'role': 'user', 'content': prompt}]) - - answer = response.choices[0].message.content.strip() - pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)' - match = re.search(pattern, answer) - if match: - return match.group(1).lower() == 'true', None, match.group(2) - - return False, None, f'Failed to decode answer from LLM response: {answer}' - - -class PRHandler(IssueHandler): - issue_type: ClassVar[str] = 'pr' - - def __init__(self, owner: str, repo: str, token: str, llm_config: LLMConfig): - super().__init__(owner, repo, token, llm_config) - self.download_url = 'https://api.github.com/repos/{}/{}/pulls' - - def __download_pr_metadata( - self, pull_number: int, comment_id: int | None = None - ) -> tuple[list[str], list[int], list[str], list[ReviewThread], list[str]]: - """Run a GraphQL query against the GitHub API for information. - - Retrieves information about: - 1. unresolved review comments - 2. referenced issues the pull request would close - - Args: - pull_number: The number of the pull request to query. - comment_id: Optional ID of a specific comment to focus on. - query: The GraphQL query as a string. - variables: A dictionary of variables for the query. - token: Your GitHub personal access token. - - Returns: - The JSON response from the GitHub API. - """ - # Using graphql as REST API doesn't indicate resolved status for review comments - # TODO: grabbing the first 10 issues, 100 review threads, and 100 coments; add pagination to retrieve all - query = """ - query($owner: String!, $repo: String!, $pr: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - closingIssuesReferences(first: 10) { - edges { - node { - body - number - } - } - } - url - reviews(first: 100) { - nodes { - body - state - fullDatabaseId - } - } - reviewThreads(first: 100) { - edges{ - node{ - id - isResolved - comments(first: 100) { - totalCount - nodes { - body - path - fullDatabaseId - } - } - } - } - } - } - } - } - """ - - variables = {'owner': self.owner, 'repo': self.repo, 'pr': pull_number} - - # Run the query - url = 'https://api.github.com/graphql' - headers = { - 'Authorization': f'Bearer {self.token}', - 'Content-Type': 'application/json', - } - - response = requests.post( - url, json={'query': query, 'variables': variables}, headers=headers - ) - response.raise_for_status() - response_json = response.json() - - # Parse the response to get closing issue references and unresolved review comments - pr_data = ( - response_json.get('data', {}).get('repository', {}).get('pullRequest', {}) - ) - - # Get closing issues - closing_issues = pr_data.get('closingIssuesReferences', {}).get('edges', []) - closing_issues_bodies = [issue['node']['body'] for issue in closing_issues] - closing_issue_numbers = [ - issue['node']['number'] for issue in closing_issues - ] # Extract issue numbers - - # Get review comments - reviews = pr_data.get('reviews', {}).get('nodes', []) - if comment_id is not None: - reviews = [ - review - for review in reviews - if int(review['fullDatabaseId']) == comment_id - ] - review_bodies = [review['body'] for review in reviews] - - # Get unresolved review threads - review_threads = [] - thread_ids = [] # Store thread IDs; agent replies to the thread - raw_review_threads = pr_data.get('reviewThreads', {}).get('edges', []) - for thread in raw_review_threads: - node = thread.get('node', {}) - if not node.get( - 'isResolved', True - ): # Check if the review thread is unresolved - id = node.get('id') - thread_contains_comment_id = False - my_review_threads = node.get('comments', {}).get('nodes', []) - message = '' - files = [] - for i, review_thread in enumerate(my_review_threads): - if ( - comment_id is not None - and int(review_thread['fullDatabaseId']) == comment_id - ): - thread_contains_comment_id = True - - if ( - i == len(my_review_threads) - 1 - ): # Check if it's the last thread in the thread - if len(my_review_threads) > 1: - message += '---\n' # Add "---" before the last message if there's more than one thread - message += 'latest feedback:\n' + review_thread['body'] + '\n' - else: - message += ( - review_thread['body'] + '\n' - ) # Add each thread in a new line - - # Source files on which the comments were made - file = review_thread.get('path') - if file and file not in files: - files.append(file) - - # If the comment ID is not provided or the thread contains the comment ID, add the thread to the list - if comment_id is None or thread_contains_comment_id: - unresolved_thread = ReviewThread(comment=message, files=files) - review_threads.append(unresolved_thread) - thread_ids.append(id) - - return ( - closing_issues_bodies, - closing_issue_numbers, - review_bodies, - review_threads, - thread_ids, - ) - - # Override processing of downloaded issues - def _get_pr_comments( - self, pr_number: int, comment_id: int | None = None - ) -> list[str] | None: - """Download comments for a specific pull request from Github.""" - url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{pr_number}/comments' - headers = { - 'Authorization': f'token {self.token}', - 'Accept': 'application/vnd.github.v3+json', - } - params = {'per_page': 100, 'page': 1} - all_comments = [] - - while True: - response = requests.get(url, headers=headers, params=params) - response.raise_for_status() - comments = response.json() - - if not comments: - break - - if comment_id is not None: - matching_comment = next( - ( - comment['body'] - for comment in comments - if comment['id'] == comment_id - ), - None, - ) - if matching_comment: - return [matching_comment] - else: - all_comments.extend([comment['body'] for comment in comments]) - - params['page'] += 1 - - return all_comments if all_comments else None - - def __get_context_from_external_issues_references( - self, - closing_issues: list[str], - closing_issue_numbers: list[int], - issue_body: str, - review_comments: list[str], - review_threads: list[ReviewThread], - thread_comments: list[str] | None, - ): - new_issue_references = [] - - if issue_body: - new_issue_references.extend(self._extract_issue_references(issue_body)) - - if review_comments: - for comment in review_comments: - new_issue_references.extend(self._extract_issue_references(comment)) - - if review_threads: - for review_thread in review_threads: - new_issue_references.extend( - self._extract_issue_references(review_thread.comment) - ) - - if thread_comments: - for thread_comment in thread_comments: - new_issue_references.extend( - self._extract_issue_references(thread_comment) - ) - - non_duplicate_references = set(new_issue_references) - unique_issue_references = non_duplicate_references.difference( - closing_issue_numbers - ) - - for issue_number in unique_issue_references: - try: - url = f'https://api.github.com/repos/{self.owner}/{self.repo}/issues/{issue_number}' - headers = { - 'Authorization': f'Bearer {self.token}', - 'Accept': 'application/vnd.github.v3+json', - } - response = requests.get(url, headers=headers) - response.raise_for_status() - issue_data = response.json() - issue_body = issue_data.get('body', '') - if issue_body: - closing_issues.append(issue_body) - except requests.exceptions.RequestException as e: - logger.warning(f'Failed to fetch issue {issue_number}: {str(e)}') - - return closing_issues - - def get_converted_issues( - self, issue_numbers: list[int] | None = None, comment_id: int | None = None - ) -> list[GithubIssue]: - if not issue_numbers: - raise ValueError('Unspecified issue numbers') - - all_issues = self._download_issues_from_github() - logger.info(f'Limiting resolving to issues {issue_numbers}.') - all_issues = [issue for issue in all_issues if issue['number'] in issue_numbers] - - converted_issues = [] - for issue in all_issues: - # For PRs, body can be None - if any([issue.get(key) is None for key in ['number', 'title']]): - logger.warning(f'Skipping #{issue} as it is missing number or title.') - continue - - # Handle None body for PRs - body = issue.get('body') if issue.get('body') is not None else '' - ( - closing_issues, - closing_issues_numbers, - review_comments, - review_threads, - thread_ids, - ) = self.__download_pr_metadata(issue['number'], comment_id=comment_id) - head_branch = issue['head']['ref'] - - # Get PR thread comments - thread_comments = self._get_pr_comments( - issue['number'], comment_id=comment_id - ) - - closing_issues = self.__get_context_from_external_issues_references( - closing_issues, - closing_issues_numbers, - body, - review_comments, - review_threads, - thread_comments, - ) - - issue_details = GithubIssue( - owner=self.owner, - repo=self.repo, - number=issue['number'], - title=issue['title'], - body=body, - closing_issues=closing_issues, - review_comments=review_comments, - review_threads=review_threads, - thread_ids=thread_ids, - head_branch=head_branch, - thread_comments=thread_comments, - ) - - converted_issues.append(issue_details) - - return converted_issues - - def get_instruction( - self, - issue: GithubIssue, - prompt_template: str, - repo_instruction: str | None = None, - ) -> tuple[str, list[str]]: - """Generate instruction for the agent.""" - template = jinja2.Template(prompt_template) - images = [] - - issues_str = None - if issue.closing_issues: - issues_str = json.dumps(issue.closing_issues, indent=4) - images.extend(self._extract_image_urls(issues_str)) - - # Handle PRs with review comments - review_comments_str = None - if issue.review_comments: - review_comments_str = json.dumps(issue.review_comments, indent=4) - images.extend(self._extract_image_urls(review_comments_str)) - - # Handle PRs with file-specific review comments - review_thread_str = None - review_thread_file_str = None - if issue.review_threads: - review_threads = [ - review_thread.comment for review_thread in issue.review_threads - ] - review_thread_files = [] - for review_thread in issue.review_threads: - review_thread_files.extend(review_thread.files) - review_thread_str = json.dumps(review_threads, indent=4) - review_thread_file_str = json.dumps(review_thread_files, indent=4) - images.extend(self._extract_image_urls(review_thread_str)) - - # Format thread comments if they exist - thread_context = '' - if issue.thread_comments: - thread_context = '\n---\n'.join(issue.thread_comments) - images.extend(self._extract_image_urls(thread_context)) - - instruction = template.render( - issues=issues_str, - review_comments=review_comments_str, - review_threads=review_thread_str, - files=review_thread_file_str, - thread_context=thread_context, - repo_instruction=repo_instruction, - ) - return instruction, images - - def _check_feedback_with_llm(self, prompt: str) -> tuple[bool, str]: - """Helper function to check feedback with LLM and parse response.""" - response = self.llm.completion(messages=[{'role': 'user', 'content': prompt}]) - - answer = response.choices[0].message.content.strip() - pattern = r'--- success\n*(true|false)\n*--- explanation*\n((?:.|\n)*)' - match = re.search(pattern, answer) - if match: - return match.group(1).lower() == 'true', match.group(2).strip() - return False, f'Failed to decode answer from LLM response: {answer}' - - def _check_review_thread( - self, - review_thread: ReviewThread, - issues_context: str, - last_message: str, - git_patch: str | None = None, - ) -> tuple[bool, str]: - """Check if a review thread's feedback has been addressed.""" - files_context = json.dumps(review_thread.files, indent=4) - - with open( - os.path.join( - os.path.dirname(__file__), - 'prompts/guess_success/pr-feedback-check.jinja', - ), - 'r', - ) as f: - template = jinja2.Template(f.read()) - - prompt = template.render( - issue_context=issues_context, - feedback=review_thread.comment, - files_context=files_context, - last_message=last_message, - git_patch=git_patch or self.default_git_patch, - ) - - return self._check_feedback_with_llm(prompt) - - def _check_thread_comments( - self, - thread_comments: list[str], - issues_context: str, - last_message: str, - git_patch: str | None = None, - ) -> tuple[bool, str]: - """Check if thread comments feedback has been addressed.""" - thread_context = '\n---\n'.join(thread_comments) - - with open( - os.path.join( - os.path.dirname(__file__), 'prompts/guess_success/pr-thread-check.jinja' - ), - 'r', - ) as f: - template = jinja2.Template(f.read()) - - prompt = template.render( - issue_context=issues_context, - thread_context=thread_context, - last_message=last_message, - git_patch=git_patch or self.default_git_patch, - ) - - return self._check_feedback_with_llm(prompt) - - def _check_review_comments( - self, - review_comments: list[str], - issues_context: str, - last_message: str, - git_patch: str | None = None, - ) -> tuple[bool, str]: - """Check if review comments feedback has been addressed.""" - review_context = '\n---\n'.join(review_comments) - - with open( - os.path.join( - os.path.dirname(__file__), 'prompts/guess_success/pr-review-check.jinja' - ), - 'r', - ) as f: - template = jinja2.Template(f.read()) - - prompt = template.render( - issue_context=issues_context, - review_context=review_context, - last_message=last_message, - git_patch=git_patch or self.default_git_patch, - ) - - return self._check_feedback_with_llm(prompt) - - def guess_success( - self, issue: GithubIssue, history: list[Event], git_patch: str | None = None - ) -> tuple[bool, None | list[bool], str]: - """Guess if the issue is fixed based on the history, issue description and git patch.""" - last_message = history[-1].message - - issues_context = json.dumps(issue.closing_issues, indent=4) - success_list = [] - explanation_list = [] - - # Handle PRs with file-specific review comments - if issue.review_threads: - for review_thread in issue.review_threads: - if issues_context and last_message: - success, explanation = self._check_review_thread( - review_thread, issues_context, last_message, git_patch - ) - else: - success, explanation = False, 'Missing context or message' - success_list.append(success) - explanation_list.append(explanation) - # Handle PRs with only thread comments (no file-specific review comments) - elif issue.thread_comments: - if issue.thread_comments and issues_context and last_message: - success, explanation = self._check_thread_comments( - issue.thread_comments, issues_context, last_message, git_patch - ) - else: - success, explanation = ( - False, - 'Missing thread comments, context or message', - ) - success_list.append(success) - explanation_list.append(explanation) - elif issue.review_comments: - # Handle PRs with only review comments (no file-specific review comments or thread comments) - if issue.review_comments and issues_context and last_message: - success, explanation = self._check_review_comments( - issue.review_comments, issues_context, last_message, git_patch - ) - else: - success, explanation = ( - False, - 'Missing review comments, context or message', - ) - success_list.append(success) - explanation_list.append(explanation) - else: - # No review comments, thread comments, or file-level review comments found - return False, None, 'No feedback was found to process' - - # Return overall success (all must be true) and explanations - if not success_list: - return False, None, 'No feedback was processed' - return all(success_list), success_list, json.dumps(explanation_list) diff --git a/openhands/resolver/patching/apply.py b/openhands/resolver/patching/apply.py index 24f2266f56cf..aedc521a1cd6 100644 --- a/openhands/resolver/patching/apply.py +++ b/openhands/resolver/patching/apply.py @@ -94,12 +94,17 @@ def apply_diff(diff, text, reverse=False, use_patch=False): hunk=hunk, ) if lines[old - 1] != line: - raise HunkApplyException( - 'context line {n}, "{line}" does not match "{sl}"'.format( - n=old, line=line, sl=lines[old - 1] - ), - hunk=hunk, - ) + # Try to normalize whitespace by replacing multiple spaces with a single space + # This helps with patches that have different indentation levels + normalized_line = ' '.join(line.split()) + normalized_source = ' '.join(lines[old - 1].split()) + if normalized_line != normalized_source: + raise HunkApplyException( + 'context line {n}, "{line}" does not match "{sl}"'.format( + n=old, line=line, sl=lines[old - 1] + ), + hunk=hunk, + ) # for calculating the old line r = 0 diff --git a/openhands/resolver/resolve_all_issues.py b/openhands/resolver/resolve_all_issues.py index 6192fc02f8e7..6aa32396545d 100644 --- a/openhands/resolver/resolve_all_issues.py +++ b/openhands/resolver/resolve_all_issues.py @@ -13,12 +13,16 @@ import openhands from openhands.core.config import LLMConfig from openhands.core.logger import openhands_logger as logger -from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.interfaces.issue import Issue from openhands.resolver.resolve_issue import ( issue_handler_factory, process_issue, ) from openhands.resolver.resolver_output import ResolverOutput +from openhands.resolver.utils import ( + Platform, + identify_token, +) def cleanup(): @@ -51,6 +55,7 @@ async def resolve_issues( repo: str, token: str, username: str, + platform: Platform, max_iterations: int, limit_issues: int | None, num_workers: int, @@ -62,13 +67,13 @@ async def resolve_issues( repo_instruction: str | None, issue_numbers: list[int] | None, ) -> None: - """Resolve multiple github issues. + """Resolve multiple github or gitlab issues. Args: - owner: Github owner of the repo. - repo: Github repository to resolve issues in form of `owner/repo`. - token: Github token to access the repository. - username: Github username to access the repository. + owner: Github or Gitlab owner of the repo. + repo: Github or Gitlab repository to resolve issues in form of `owner/repo`. + token: Github or Gitlab token to access the repository. + username: Github or Gitlab username to access the repository. max_iterations: Maximum number of iterations to run. limit_issues: Limit the number of issues to resolve. num_workers: Number of workers to use for parallel processing. @@ -80,10 +85,12 @@ async def resolve_issues( repo_instruction: Repository instruction to use. issue_numbers: List of issue numbers to resolve. """ - issue_handler = issue_handler_factory(issue_type, owner, repo, token, llm_config) + issue_handler = issue_handler_factory( + issue_type, owner, repo, token, llm_config, platform + ) # Load dataset - issues: list[GithubIssue] = issue_handler.get_converted_issues( + issues: list[Issue] = issue_handler.get_converted_issues( issue_numbers=issue_numbers ) @@ -107,7 +114,7 @@ async def resolve_issues( [ 'git', 'clone', - f'https://{username}:{token}@github.com/{owner}/{repo}', + issue_handler.get_clone_url(), f'{output_dir}/repo', ] ).decode('utf-8') @@ -188,6 +195,7 @@ async def resolve_issues( task = update_progress( process_issue( issue, + platform, base_commit, max_iterations, llm_config, @@ -221,24 +229,26 @@ async def run_with_semaphore(task): def main(): - parser = argparse.ArgumentParser(description='Resolve multiple issues from Github.') + parser = argparse.ArgumentParser( + description='Resolve multiple issues from Github or Gitlab.' + ) parser.add_argument( '--repo', type=str, required=True, - help='Github repository to resolve issues in form of `owner/repo`.', + help='Github or Gitlab repository to resolve issues in form of `owner/repo`.', ) parser.add_argument( '--token', type=str, default=None, - help='Github token to access the repository.', + help='Github or Gitlab token to access the repository.', ) parser.add_argument( '--username', type=str, default=None, - help='Github username to access the repository.', + help='Github or Gitlab username to access the repository.', ) parser.add_argument( '--runtime-container-image', @@ -323,15 +333,20 @@ def main(): ) owner, repo = my_args.repo.split('/') - token = my_args.token if my_args.token else os.getenv('GITHUB_TOKEN') - username = my_args.username if my_args.username else os.getenv('GITHUB_USERNAME') + token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN') + username = my_args.username if my_args.username else os.getenv('GIT_USERNAME') if not username: - raise ValueError('Github username is required.') + raise ValueError('Username is required.') if not token: - raise ValueError('Github token is required.') + raise ValueError('Token is required.') + + platform = identify_token(token) + if platform == Platform.INVALID: + raise ValueError('Token is invalid.') api_key = my_args.llm_api_key or os.environ['LLM_API_KEY'] + llm_config = LLMConfig( model=my_args.llm_model or os.environ['LLM_MODEL'], api_key=str(api_key) if api_key else None, @@ -369,6 +384,7 @@ def main(): repo=repo, token=token, username=username, + platform=platform, runtime_container_image=runtime_container_image, max_iterations=my_args.max_iterations, limit_issues=my_args.limit_issues, diff --git a/openhands/resolver/resolve_issue.py b/openhands/resolver/resolve_issue.py index 45c9e33af7c0..80cddb9ed581 100644 --- a/openhands/resolver/resolve_issue.py +++ b/openhands/resolver/resolve_issue.py @@ -24,15 +24,19 @@ Observation, ) from openhands.events.stream import EventStreamSubscriber -from openhands.resolver.github_issue import GithubIssue -from openhands.resolver.issue_definitions import ( - IssueHandler, - IssueHandlerInterface, - PRHandler, +from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler +from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler +from openhands.resolver.interfaces.issue import Issue +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, ) from openhands.resolver.resolver_output import ResolverOutput from openhands.resolver.utils import ( + Platform, codeact_user_response, + get_unique_uid, + identify_token, reset_logger_for_multiprocessing, ) from openhands.runtime.base import Runtime @@ -43,6 +47,7 @@ def initialize_runtime( runtime: Runtime, + platform: Platform, ): """Initialize the runtime for the agent. @@ -61,6 +66,12 @@ def initialize_runtime( if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: raise RuntimeError(f'Failed to change directory to /workspace.\n{obs}') + if platform == Platform.GITLAB and os.getenv('GITLAB_CI') == 'true': + action = CmdRunAction(command='sudo chown -R 1001:0 /workspace/*') + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + action = CmdRunAction(command='git config --global core.pager ""') logger.info(action, extra={'msg_type': 'ACTION'}) obs = runtime.run_action(action) @@ -72,6 +83,7 @@ def initialize_runtime( async def complete_runtime( runtime: Runtime, base_commit: str, + platform: Platform, ) -> dict[str, Any]: """Complete the runtime for the agent. @@ -107,7 +119,11 @@ async def complete_runtime( if not isinstance(obs, CmdOutputObservation) or obs.exit_code != 0: raise RuntimeError(f'Failed to set git config. Observation: {obs}') - action = CmdRunAction(command='git add -A') + if platform == Platform.GITLAB and os.getenv('GITLAB_CI') == 'true': + action = CmdRunAction(command='sudo git add -A') + else: + action = CmdRunAction(command='git add -A') + logger.info(action, extra={'msg_type': 'ACTION'}) obs = runtime.run_action(action) logger.info(obs, extra={'msg_type': 'OBSERVATION'}) @@ -143,14 +159,15 @@ async def complete_runtime( async def process_issue( - issue: GithubIssue, + issue: Issue, + platform: Platform, base_commit: str, max_iterations: int, llm_config: LLMConfig, output_dir: str, runtime_container_image: str | None, prompt_template: str, - issue_handler: IssueHandlerInterface, + issue_handler: ServiceContextIssue | ServiceContextPR, repo_instruction: str | None = None, reset_logger: bool = False, ) -> ResolverOutput: @@ -172,6 +189,16 @@ async def process_issue( shutil.rmtree(workspace_base) shutil.copytree(os.path.join(output_dir, 'repo'), workspace_base) + # This code looks unnecessary because these are default values in the config class + # they're set by default if nothing else overrides them + # FIXME we should remove them here + kwargs = {} + if os.getenv('GITLAB_CI') == 'True': + kwargs['local_runtime_url'] = os.getenv('LOCAL_RUNTIME_URL', 'http://localhost') + user_id = os.getuid() if hasattr(os, 'getuid') else 1000 + if user_id == 0: + kwargs['user_id'] = get_unique_uid() + config = AppConfig( default_agent='CodeActAgent', runtime='docker', @@ -183,6 +210,7 @@ async def process_issue( use_host_network=False, # large enough timeout, since some testcases take very long to run timeout=300, + **kwargs, ), # do not mount workspace workspace_base=workspace_base, @@ -199,7 +227,7 @@ def on_event(evt): runtime.event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, str(uuid4())) - initialize_runtime(runtime) + initialize_runtime(runtime, platform) instruction, images_urls = issue_handler.get_instruction( issue, prompt_template, repo_instruction @@ -222,7 +250,7 @@ def on_event(evt): last_error: str | None = error_msg # Get git patch - return_val = await complete_runtime(runtime, base_commit) + return_val = await complete_runtime(runtime, base_commit, platform) git_patch = return_val['git_patch'] logger.info( f'Got git diff for instance {issue.number}:\n--------\n{git_patch}\n--------' @@ -283,12 +311,32 @@ def on_event(evt): def issue_handler_factory( - issue_type: str, owner: str, repo: str, token: str, llm_config: LLMConfig -) -> IssueHandlerInterface: + issue_type: str, + owner: str, + repo: str, + token: str, + llm_config: LLMConfig, + platform: Platform, + username: str | None = None, +) -> ServiceContextIssue | ServiceContextPR: if issue_type == 'issue': - return IssueHandler(owner, repo, token, llm_config) + if platform == Platform.GITHUB: + return ServiceContextIssue( + GithubIssueHandler(owner, repo, token, username), llm_config + ) + else: # platform == Platform.GITLAB + return ServiceContextIssue( + GitlabIssueHandler(owner, repo, token, username), llm_config + ) elif issue_type == 'pr': - return PRHandler(owner, repo, token, llm_config) + if platform == Platform.GITHUB: + return ServiceContextPR( + GithubPRHandler(owner, repo, token, username), llm_config + ) + else: # platform == Platform.GITLAB + return ServiceContextPR( + GitlabPRHandler(owner, repo, token, username), llm_config + ) else: raise ValueError(f'Invalid issue type: {issue_type}') @@ -298,6 +346,7 @@ async def resolve_issue( repo: str, token: str, username: str, + platform: Platform, max_iterations: int, output_dir: str, llm_config: LLMConfig, @@ -309,13 +358,14 @@ async def resolve_issue( comment_id: int | None, reset_logger: bool = False, ) -> None: - """Resolve a single github issue. + """Resolve a single issue. Args: - owner: Github owner of the repo. - repo: Github repository to resolve issues in form of `owner/repo`. - token: Github token to access the repository. - username: Github username to access the repository. + owner: owner of the repo. + repo: repository to resolve issues in form of `owner/repo`. + token: token to access the repository. + username: username to access the repository. + platform: platform of the repository. max_iterations: Maximum number of iterations to run. output_dir: Output directory to write the results. llm_config: Configuration for the language model. @@ -328,10 +378,12 @@ async def resolve_issue( reset_logger: Whether to reset the logger for multiprocessing. """ - issue_handler = issue_handler_factory(issue_type, owner, repo, token, llm_config) + issue_handler = issue_handler_factory( + issue_type, owner, repo, token, llm_config, platform, username + ) # Load dataset - issues: list[GithubIssue] = issue_handler.get_converted_issues( + issues: list[Issue] = issue_handler.get_converted_issues( issue_numbers=[issue_number], comment_id=comment_id ) @@ -377,7 +429,7 @@ async def resolve_issue( [ 'git', 'clone', - f'https://{username}:{token}@github.com/{owner}/{repo}', + issue_handler.get_clone_url(), f'{output_dir}/repo', ] ).decode('utf-8') @@ -453,6 +505,7 @@ async def resolve_issue( output = await process_issue( issue, + platform, base_commit, max_iterations, llm_config, @@ -480,24 +533,24 @@ def int_or_none(value): else: return int(value) - parser = argparse.ArgumentParser(description='Resolve a single issue from Github.') + parser = argparse.ArgumentParser(description='Resolve a single issue.') parser.add_argument( '--repo', type=str, required=True, - help='Github repository to resolve issues in form of `owner/repo`.', + help='repository to resolve issues in form of `owner/repo`.', ) parser.add_argument( '--token', type=str, default=None, - help='Github token to access the repository.', + help='token to access the repository.', ) parser.add_argument( '--username', type=str, default=None, - help='Github username to access the repository.', + help='username to access the repository.', ) parser.add_argument( '--runtime-container-image', @@ -581,14 +634,22 @@ def int_or_none(value): f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik' ) - owner, repo = my_args.repo.split('/') - token = my_args.token if my_args.token else os.getenv('GITHUB_TOKEN') - username = my_args.username if my_args.username else os.getenv('GITHUB_USERNAME') + parts = my_args.repo.rsplit('/', 1) + if len(parts) < 2: + raise ValueError('Invalid repo name') + owner, repo = parts + + token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN') + username = my_args.username if my_args.username else os.getenv('GIT_USERNAME') if not username: - raise ValueError('Github username is required.') + raise ValueError('Username is required.') if not token: - raise ValueError('Github token is required.') + raise ValueError('Token is required.') + + platform = identify_token(token) + if platform == Platform.INVALID: + raise ValueError('Token is invalid.') api_key = my_args.llm_api_key or os.environ['LLM_API_KEY'] llm_config = LLMConfig( @@ -624,6 +685,7 @@ def int_or_none(value): repo=repo, token=token, username=username, + platform=platform, runtime_container_image=runtime_container_image, max_iterations=my_args.max_iterations, output_dir=my_args.output_dir, diff --git a/openhands/resolver/resolver_output.py b/openhands/resolver/resolver_output.py index 7ae89e164250..9394783ff07c 100644 --- a/openhands/resolver/resolver_output.py +++ b/openhands/resolver/resolver_output.py @@ -2,12 +2,12 @@ from litellm import BaseModel -from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.interfaces.issue import Issue class ResolverOutput(BaseModel): # NOTE: User-specified - issue: GithubIssue + issue: Issue issue_type: str instruction: str base_commit: str diff --git a/openhands/resolver/send_pull_request.py b/openhands/resolver/send_pull_request.py index 6b37502aaa4a..7cbe37cfcca0 100644 --- a/openhands/resolver/send_pull_request.py +++ b/openhands/resolver/send_pull_request.py @@ -5,18 +5,24 @@ import subprocess import jinja2 -import requests from openhands.core.config import LLMConfig from openhands.core.logger import openhands_logger as logger from openhands.llm.llm import LLM -from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.interfaces.github import GithubIssueHandler +from openhands.resolver.interfaces.gitlab import GitlabIssueHandler +from openhands.resolver.interfaces.issue import Issue +from openhands.resolver.interfaces.issue_definitions import ServiceContextIssue from openhands.resolver.io_utils import ( load_all_resolver_outputs, load_single_resolver_output, ) from openhands.resolver.patching import apply_diff, parse_patch from openhands.resolver.resolver_output import ResolverOutput +from openhands.resolver.utils import ( + Platform, + identify_token, +) def apply_patch(repo_dir: str, patch: str) -> None: @@ -153,7 +159,7 @@ def initialize_repo( return dest_dir -def make_commit(repo_dir: str, issue: GithubIssue, issue_type: str) -> None: +def make_commit(repo_dir: str, issue: Issue, issue_type: str) -> None: """Make a commit with the changes to the repository. Args: @@ -214,25 +220,11 @@ def make_commit(repo_dir: str, issue: GithubIssue, issue_type: str) -> None: raise RuntimeError(f'Failed to commit changes: {result}') -def branch_exists(base_url: str, branch_name: str, headers: dict) -> bool: - """Check if a branch exists in the GitHub repository. - - Args: - base_url: The base URL of the GitHub repository API - branch_name: The name of the branch to check - headers: The HTTP headers to use for authentication - """ - print(f'Checking if branch {branch_name} exists...') - response = requests.get(f'{base_url}/branches/{branch_name}', headers=headers) - exists = response.status_code == 200 - print(f'Branch {branch_name} exists: {exists}') - return exists - - def send_pull_request( - github_issue: GithubIssue, - github_token: str, - github_username: str | None, + issue: Issue, + token: str, + username: str | None, + platform: Platform, patch_dir: str, pr_type: str, fork_owner: str | None = None, @@ -241,53 +233,49 @@ def send_pull_request( reviewer: str | None = None, pr_title: str | None = None, ) -> str: - """Send a pull request to a GitHub repository. + """Send a pull request to a GitHub or Gitlab repository. Args: - github_issue: The issue to send the pull request for - github_token: The GitHub token to use for authentication - github_username: The GitHub username, if provided + issue: The issue to send the pull request for + token: The GitHub or Gitlab token to use for authentication + username: The GitHub or Gitlab username, if provided + platform: The platform of the repository. patch_dir: The directory containing the patches to apply pr_type: The type: branch (no PR created), draft or ready (regular PR created) fork_owner: The owner of the fork to push changes to (if different from the original repo owner) additional_message: The additional messages to post as a comment on the PR in json list format target_branch: The target branch to create the pull request against (defaults to repository default branch) - reviewer: The GitHub username of the reviewer to assign + reviewer: The GitHub or Gitlab username of the reviewer to assign pr_title: Custom title for the pull request (optional) """ if pr_type not in ['branch', 'draft', 'ready']: raise ValueError(f'Invalid pr_type: {pr_type}') - # Set up headers and base URL for GitHub API - headers = { - 'Authorization': f'token {github_token}', - 'Accept': 'application/vnd.github.v3+json', - } - base_url = f'https://api.github.com/repos/{github_issue.owner}/{github_issue.repo}' + handler = None + if platform == Platform.GITHUB: + handler = ServiceContextIssue( + GithubIssueHandler(issue.owner, issue.repo, token, username), None + ) + else: # platform == Platform.GITLAB + handler = ServiceContextIssue( + GitlabIssueHandler(issue.owner, issue.repo, token, username), None + ) # Create a new branch with a unique name - base_branch_name = f'openhands-fix-issue-{github_issue.number}' - branch_name = base_branch_name - attempt = 1 - - # Find a unique branch name - print('Checking if branch exists...') - while branch_exists(base_url, branch_name, headers): - attempt += 1 - branch_name = f'{base_branch_name}-try{attempt}' + base_branch_name = f'openhands-fix-issue-{issue.number}' + branch_name = handler.get_branch_name( + base_branch_name=base_branch_name, + ) # Get the default branch or use specified target branch print('Getting base branch...') if target_branch: base_branch = target_branch - # Verify the target branch exists - response = requests.get(f'{base_url}/branches/{target_branch}', headers=headers) - if response.status_code != 200: + exists = handler.branch_exists(branch_name=target_branch) + if not exists: raise ValueError(f'Target branch {target_branch} does not exist') else: - response = requests.get(f'{base_url}', headers=headers) - response.raise_for_status() - base_branch = response.json()['default_branch'] + base_branch = handler.get_default_branch_name() print(f'Base branch: {base_branch}') # Create and checkout the new branch @@ -304,16 +292,12 @@ def send_pull_request( ) # Determine the repository to push to (original or fork) - push_owner = fork_owner if fork_owner else github_issue.owner - push_repo = github_issue.repo + push_owner = fork_owner if fork_owner else issue.owner + + handler._strategy.set_owner(push_owner) print('Pushing changes...') - username_and_token = ( - f'{github_username}:{github_token}' - if github_username - else f'x-auth-token:{github_token}' - ) - push_url = f'https://{username_and_token}@github.com/{push_owner}/{push_repo}.git' + push_url = handler.get_clone_url() result = subprocess.run( ['git', '-C', patch_dir, 'push', push_url, branch_name], capture_output=True, @@ -325,11 +309,9 @@ def send_pull_request( # Prepare the PR data: title and body final_pr_title = ( - pr_title - if pr_title - else f'Fix issue #{github_issue.number}: {github_issue.title}' + pr_title if pr_title else f'Fix issue #{issue.number}: {issue.title}' ) - pr_body = f'This pull request fixes #{github_issue.number}.' + pr_body = f'This pull request fixes #{issue.number}.' if additional_message: pr_body += f'\n\n{additional_message}' pr_body += '\n\nAutomatic fix generated by [OpenHands](https://github.com/All-Hands-AI/OpenHands/) 🙌' @@ -337,41 +319,25 @@ def send_pull_request( # If we are not sending a PR, we can finish early and return the # URL for the user to open a PR manually if pr_type == 'branch': - url = f'https://github.com/{push_owner}/{github_issue.repo}/compare/{branch_name}?expand=1' + url = handler.get_compare_url(branch_name) else: # Prepare the PR for the GitHub API data = { - 'title': final_pr_title, # No need to escape title for GitHub API - 'body': pr_body, - 'head': branch_name, - 'base': base_branch, + 'title': final_pr_title, + ('body' if platform == Platform.GITHUB else 'description'): pr_body, + ('head' if platform == Platform.GITHUB else 'source_branch'): branch_name, + ('base' if platform == Platform.GITHUB else 'target_branch'): base_branch, 'draft': pr_type == 'draft', } - # Send the PR and get its URL to tell the user - response = requests.post(f'{base_url}/pulls', headers=headers, json=data) - if response.status_code == 403: - raise RuntimeError( - 'Failed to create pull request due to missing permissions. ' - 'Make sure that the provided token has push permissions for the repository.' - ) - response.raise_for_status() - pr_data = response.json() + pr_data = handler.create_pull_request(data) + url = pr_data['html_url'] + print(pr_data) # Request review if a reviewer was specified if reviewer and pr_type != 'branch': - review_data = {'reviewers': [reviewer]} - review_response = requests.post( - f'{base_url}/pulls/{pr_data["number"]}/requested_reviewers', - headers=headers, - json=review_data, - ) - if review_response.status_code != 201: - print( - f'Warning: Failed to request review from {reviewer}: {review_response.text}' - ) - - url = pr_data['html_url'] + number = pr_data['number'] + handler.request_reviewers(reviewer, number) print( f'{pr_type} created: {url}\n\n--- Title: {final_pr_title}\n\n--- Body:\n{pr_body}' @@ -380,74 +346,11 @@ def send_pull_request( return url -def reply_to_comment(github_token: str, comment_id: str, reply: str): - """Reply to a comment on a GitHub issue or pull request. - - Args: - github_token: The GitHub token to use for authentication - comment_id: The ID of the comment to reply to - reply: The reply message to post - """ - # Opting for graphql as REST API doesn't allow reply to replies in comment threads - query = """ - mutation($body: String!, $pullRequestReviewThreadId: ID!) { - addPullRequestReviewThreadReply(input: { body: $body, pullRequestReviewThreadId: $pullRequestReviewThreadId }) { - comment { - id - body - createdAt - } - } - } - """ - - # Prepare the reply to the comment - comment_reply = f'Openhands fix success summary\n\n\n{reply}' - variables = {'body': comment_reply, 'pullRequestReviewThreadId': comment_id} - url = 'https://api.github.com/graphql' - headers = { - 'Authorization': f'Bearer {github_token}', - 'Content-Type': 'application/json', - } - - # Send the reply to the comment - response = requests.post( - url, json={'query': query, 'variables': variables}, headers=headers - ) - response.raise_for_status() - - -def send_comment_msg(base_url: str, issue_number: int, github_token: str, msg: str): - """Send a comment message to a GitHub issue or pull request. - - Args: - base_url: The base URL of the GitHub repository API - issue_number: The issue or pull request number - github_token: The GitHub token to use for authentication - msg: The message content to post as a comment - """ - # Set up headers for GitHub API - headers = { - 'Authorization': f'token {github_token}', - 'Accept': 'application/vnd.github.v3+json', - } - - # Post a comment on the PR - comment_url = f'{base_url}/issues/{issue_number}/comments' - comment_data = {'body': msg} - comment_response = requests.post(comment_url, headers=headers, json=comment_data) - if comment_response.status_code != 201: - print( - f'Failed to post comment: {comment_response.status_code} {comment_response.text}' - ) - else: - print(f'Comment added to the PR: {msg}') - - def update_existing_pull_request( - github_issue: GithubIssue, - github_token: str, - github_username: str | None, + issue: Issue, + token: str, + username: str | None, + platform: Platform, patch_dir: str, llm_config: LLMConfig, comment_message: str | None = None, @@ -456,23 +359,34 @@ def update_existing_pull_request( """Update an existing pull request with the new patches. Args: - github_issue: The issue to update. - github_token: The GitHub token to use for authentication. - github_username: The GitHub username to use for authentication. + issue: The issue to update. + token: The token to use for authentication. + username: The username to use for authentication. + platform: The platform of the repository. patch_dir: The directory containing the patches to apply. llm_config: The LLM configuration to use for summarizing changes. comment_message: The main message to post as a comment on the PR. additional_message: The additional messages to post as a comment on the PR in json list format. """ - # Set up base URL for GitHub API - base_url = f'https://api.github.com/repos/{github_issue.owner}/{github_issue.repo}' - branch_name = github_issue.head_branch + # Set up headers and base URL for GitHub or GitLab API + + handler = None + if platform == Platform.GITHUB: + handler = ServiceContextIssue( + GithubIssueHandler(issue.owner, issue.repo, token, username), llm_config + ) + else: # platform == Platform.GITLAB + handler = ServiceContextIssue( + GitlabIssueHandler(issue.owner, issue.repo, token, username), llm_config + ) + + branch_name = issue.head_branch # Prepare the push command push_command = ( f'git -C {patch_dir} push ' - f'https://{github_username}:{github_token}@github.com/' - f'{github_issue.owner}/{github_issue.repo}.git {branch_name}' + f'{handler.get_authorize_url()}' + f'{issue.owner}/{issue.repo}.git {branch_name}' ) # Push the changes to the existing branch @@ -481,7 +395,7 @@ def update_existing_pull_request( print(f'Error pushing changes: {result.stderr}') raise RuntimeError('Failed to push changes to the remote repository') - pr_url = f'https://github.com/{github_issue.owner}/{github_issue.repo}/pull/{github_issue.number}' + pr_url = handler.get_pull_url(issue.number) print(f'Updated pull request {pr_url} with new patches.') # Generate a summary of all comment success indicators for PR message @@ -517,18 +431,18 @@ def update_existing_pull_request( # Post a comment on the PR if comment_message: - send_comment_msg(base_url, github_issue.number, github_token, comment_message) + handler.send_comment_msg(issue.number, comment_message) # Reply to each unresolved comment thread - if additional_message and github_issue.thread_ids: + if additional_message and issue.thread_ids: try: explanations = json.loads(additional_message) for count, reply_comment in enumerate(explanations): - comment_id = github_issue.thread_ids[count] - reply_to_comment(github_token, comment_id, reply_comment) + comment_id = issue.thread_ids[count] + handler.reply_to_comment(issue.number, comment_id, reply_comment) except (json.JSONDecodeError, TypeError): msg = f'Error occured when replying to threads; success explanations {additional_message}' - send_comment_msg(base_url, github_issue.number, github_token, msg) + handler.send_comment_msg(issue.number, msg) return pr_url @@ -536,8 +450,9 @@ def update_existing_pull_request( def process_single_issue( output_dir: str, resolver_output: ResolverOutput, - github_token: str, - github_username: str, + token: str, + username: str, + platform: Platform, pr_type: str, llm_config: LLMConfig, fork_owner: str | None, @@ -577,18 +492,20 @@ def process_single_issue( if issue_type == 'pr': update_existing_pull_request( - github_issue=resolver_output.issue, - github_token=github_token, - github_username=github_username, + issue=resolver_output.issue, + token=token, + username=username, + platform=platform, patch_dir=patched_repo_dir, additional_message=resolver_output.result_explanation, llm_config=llm_config, ) else: send_pull_request( - github_issue=resolver_output.issue, - github_token=github_token, - github_username=github_username, + issue=resolver_output.issue, + token=token, + username=username, + platform=platform, patch_dir=patched_repo_dir, pr_type=pr_type, fork_owner=fork_owner, @@ -601,8 +518,9 @@ def process_single_issue( def process_all_successful_issues( output_dir: str, - github_token: str, - github_username: str, + token: str, + username: str, + platform: Platform, pr_type: str, llm_config: LLMConfig, fork_owner: str | None, @@ -614,8 +532,9 @@ def process_all_successful_issues( process_single_issue( output_dir, resolver_output, - github_token, - github_username, + token, + username, + platform, pr_type, llm_config, fork_owner, @@ -625,18 +544,20 @@ def process_all_successful_issues( def main(): - parser = argparse.ArgumentParser(description='Send a pull request to Github.') + parser = argparse.ArgumentParser( + description='Send a pull request to Github or Gitlab.' + ) parser.add_argument( - '--github-token', + '--token', type=str, default=None, - help='Github token to access the repository.', + help='token to access the repository.', ) parser.add_argument( - '--github-username', + '--username', type=str, default=None, - help='Github username to access the repository.', + help='username to access the repository.', ) parser.add_argument( '--output-dir', @@ -695,7 +616,7 @@ def main(): parser.add_argument( '--reviewer', type=str, - help='GitHub username of the person to request review from', + help='GitHub or GitLab username of the person to request review from', default=None, ) parser.add_argument( @@ -706,18 +627,16 @@ def main(): ) my_args = parser.parse_args() - github_token = ( - my_args.github_token if my_args.github_token else os.getenv('GITHUB_TOKEN') - ) - if not github_token: + token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN') + if not token: raise ValueError( - 'Github token is not set, set via --github-token or GITHUB_TOKEN environment variable.' + 'token is not set, set via --token or GITHUB_TOKEN or GITLAB_TOKEN environment variable.' ) - github_username = ( - my_args.github_username - if my_args.github_username - else os.getenv('GITHUB_USERNAME') - ) + username = my_args.username if my_args.username else os.getenv('GIT_USERNAME') + + platform = identify_token(token) + if platform == Platform.INVALID: + raise ValueError('Token is invalid.') api_key = my_args.llm_api_key or os.environ['LLM_API_KEY'] llm_config = LLMConfig( @@ -730,12 +649,13 @@ def main(): raise ValueError(f'Output directory {my_args.output_dir} does not exist.') if my_args.issue_number == 'all_successful': - if not github_username: - raise ValueError('Github username is required.') + if not username: + raise ValueError('username is required.') process_all_successful_issues( my_args.output_dir, - github_token, - github_username, + token, + username, + platform, my_args.pr_type, llm_config, my_args.fork_owner, @@ -746,13 +666,14 @@ def main(): issue_number = int(my_args.issue_number) output_path = os.path.join(my_args.output_dir, 'output.jsonl') resolver_output = load_single_resolver_output(output_path, issue_number) - if not github_username: - raise ValueError('Github username is required.') + if not username: + raise ValueError('username is required.') process_single_issue( my_args.output_dir, resolver_output, - github_token, - github_username, + token, + username, + platform, my_args.pr_type, llm_config, my_args.fork_owner, diff --git a/openhands/resolver/utils.py b/openhands/resolver/utils.py index 583026455945..b0e25861ccb7 100644 --- a/openhands/resolver/utils.py +++ b/openhands/resolver/utils.py @@ -2,9 +2,12 @@ import logging import multiprocessing as mp import os +import re +from enum import Enum from typing import Callable import pandas as pd +import requests from openhands.controller.state.state import State from openhands.core.logger import get_console_handler @@ -13,6 +16,47 @@ from openhands.events.action.message import MessageAction +class Platform(Enum): + INVALID = 0 + GITHUB = 1 + GITLAB = 2 + + +def identify_token(token: str) -> Platform: + """ + Identifies whether a token belongs to GitHub or GitLab. + + Parameters: + token (str): The personal access token to check. + + Returns: + Platform: "GitHub" if the token is valid for GitHub, + "GitLab" if the token is valid for GitLab, + "Invalid" if the token is not recognized by either. + """ + github_url = 'https://api.github.com/user' + github_headers = {'Authorization': f'token {token}'} + + try: + github_response = requests.get(github_url, headers=github_headers, timeout=5) + if github_response.status_code == 200: + return Platform.GITHUB + except requests.RequestException as e: + print(f'Error connecting to GitHub API: {e}') + + gitlab_url = 'https://gitlab.com/api/v4/user' + gitlab_headers = {'Authorization': f'Bearer {token}'} + + try: + gitlab_response = requests.get(gitlab_url, headers=gitlab_headers, timeout=5) + if gitlab_response.status_code == 200: + return Platform.GITLAB + except requests.RequestException as e: + print(f'Error connecting to GitLab API: {e}') + + return Platform.INVALID + + def codeact_user_response( state: State, encapsulate_solution: bool = False, @@ -137,3 +181,45 @@ def reset_logger_for_multiprocessing( logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') ) logger.addHandler(file_handler) + + +def extract_image_urls(issue_body: str) -> list[str]: + # Regular expression to match Markdown image syntax ![alt text](image_url) + image_pattern = r'!\[.*?\]\((https?://[^\s)]+)\)' + return re.findall(image_pattern, issue_body) + + +def extract_issue_references(body: str) -> list[int]: + # First, remove code blocks as they may contain false positives + body = re.sub(r'```.*?```', '', body, flags=re.DOTALL) + + # Remove inline code + body = re.sub(r'`[^`]*`', '', body) + + # Remove URLs that contain hash symbols + body = re.sub(r'https?://[^\s)]*#\d+[^\s)]*', '', body) + + # Now extract issue numbers, making sure they're not part of other text + # The pattern matches #number that: + # 1. Is at the start of text or after whitespace/punctuation + # 2. Is followed by whitespace, punctuation, or end of text + # 3. Is not part of a URL + pattern = r'(?:^|[\s\[({]|[^\w#])#(\d+)(?=[\s,.\])}]|$)' + return [int(match) for match in re.findall(pattern, body)] + + +def get_unique_uid(start_uid=1000): + existing_uids = set() + with open('/etc/passwd', 'r') as passwd_file: + for line in passwd_file: + parts = line.split(':') + if len(parts) > 2: + try: + existing_uids.add(int(parts[2])) + except ValueError: + continue + + while start_uid in existing_uids: + start_uid += 1 + + return start_uid diff --git a/openhands/runtime/action_execution_server.py b/openhands/runtime/action_execution_server.py index 2148ab2267d1..536a6a6ed82d 100644 --- a/openhands/runtime/action_execution_server.py +++ b/openhands/runtime/action_execution_server.py @@ -9,10 +9,8 @@ import asyncio import base64 import io -import json import mimetypes import os -import re import shutil import tempfile import time @@ -25,7 +23,9 @@ from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, StreamingResponse from fastapi.security import APIKeyHeader -from openhands_aci.utils.diff import get_diff +from openhands_aci.editor.editor import OHEditor +from openhands_aci.editor.exceptions import ToolError +from openhands_aci.editor.results import ToolResult from pydantic import BaseModel from starlette.exceptions import HTTPException as StarletteHTTPException from uvicorn import run @@ -36,6 +36,7 @@ BrowseInteractiveAction, BrowseURLAction, CmdRunAction, + FileEditAction, FileReadAction, FileWriteAction, IPythonRunCellAction, @@ -77,6 +78,58 @@ def verify_api_key(api_key: str = Depends(api_key_header)): return api_key +def _execute_file_editor( + editor: OHEditor, + command: str, + path: str, + file_text: str | None = None, + view_range: list[int] | None = None, + old_str: str | None = None, + new_str: str | None = None, + insert_line: int | None = None, + enable_linting: bool = False, +) -> str: + """Execute file editor command and handle exceptions. + + Args: + editor: The OHEditor instance + command: Editor command to execute + path: File path + file_text: Optional file text content + view_range: Optional view range tuple (start, end) + old_str: Optional string to replace + new_str: Optional replacement string + insert_line: Optional line number for insertion + enable_linting: Whether to enable linting + + Returns: + str: Result string from the editor operation + """ + result: ToolResult | None = None + try: + result = editor( + command=command, + path=path, + file_text=file_text, + view_range=view_range, + old_str=old_str, + new_str=new_str, + insert_line=insert_line, + enable_linting=enable_linting, + ) + except ToolError as e: + result = ToolResult(error=e.message) + + if result.error: + return f'ERROR:\n{result.error}' + + if not result.output: + logger.warning(f'No output from file_editor for {path}') + return '' + + return result.output + + class ActionExecutor: """ActionExecutor is running inside docker sandbox. It is responsible for executing actions received from OpenHands backend and producing observations. @@ -103,11 +156,21 @@ def __init__( self.bash_session: BashSession | None = None self.lock = asyncio.Lock() self.plugins: dict[str, Plugin] = {} + self.file_editor = OHEditor() self.browser = BrowserEnv(browsergym_eval_env) self.start_time = time.time() self.last_execution_time = self.start_time self._initialized = False + self.max_memory_gb: int | None = None + if _override_max_memory_gb := os.environ.get('RUNTIME_MAX_MEMORY_GB', None): + self.max_memory_gb = int(_override_max_memory_gb) + logger.info( + f'Setting max memory to {self.max_memory_gb}GB (according to the RUNTIME_MAX_MEMORY_GB environment variable)' + ) + else: + logger.info('No max memory limit set, using all available system memory') + @property def initial_cwd(self): return self._initial_cwd @@ -120,8 +183,10 @@ async def ainit(self): no_change_timeout_seconds=int( os.environ.get('NO_CHANGE_TIMEOUT_SECONDS', 30) ), + max_memory_mb=self.max_memory_gb * 1024 if self.max_memory_gb else None, ) self.bash_session.initialize() + await wait_all( (self._init_plugin(plugin) for plugin in self.plugins_to_load), timeout=30, @@ -218,65 +283,6 @@ async def run_ipython(self, action: IPythonRunCellAction) -> Observation: obs: IPythonRunCellObservation = await _jupyter_plugin.run(action) obs.content = obs.content.rstrip() - matches = re.findall( - r'(.*?)', - obs.content, - re.DOTALL, - ) - if matches: - results: list[str] = [] - if len(matches) == 1: - # Use specific actions/observations types - match = matches[0] - try: - result_dict = json.loads(match) - if result_dict.get('path'): # Successful output - if ( - result_dict['new_content'] is not None - ): # File edit commands - diff = get_diff( - old_contents=result_dict['old_content'] - or '', # old_content is None when file is created - new_contents=result_dict['new_content'], - filepath=result_dict['path'], - ) - return FileEditObservation( - content=diff, - path=result_dict['path'], - old_content=result_dict['old_content'], - new_content=result_dict['new_content'], - prev_exist=result_dict['prev_exist'], - impl_source=FileEditSource.OH_ACI, - formatted_output_and_error=result_dict[ - 'formatted_output_and_error' - ], - ) - else: # File view commands - return FileReadObservation( - content=result_dict['formatted_output_and_error'], - path=result_dict['path'], - impl_source=FileReadSource.OH_ACI, - ) - else: # Error output - results.append(result_dict['formatted_output_and_error']) - except json.JSONDecodeError: - # Handle JSON decoding errors if necessary - results.append( - f"Invalid JSON in 'openhands-aci' output: {match}" - ) - else: - for match in matches: - try: - result_dict = json.loads(match) - results.append(result_dict['formatted_output_and_error']) - except json.JSONDecodeError: - # Handle JSON decoding errors if necessary - results.append( - f"Invalid JSON in 'openhands-aci' output: {match}" - ) - - # Combine the results (e.g., join them) or handle them as required - obs.content = '\n'.join(str(result) for result in results) if action.include_extra: obs.content += ( @@ -298,11 +304,17 @@ def _resolve_path(self, path: str, working_dir: str) -> str: async def read(self, action: FileReadAction) -> Observation: assert self.bash_session is not None if action.impl_source == FileReadSource.OH_ACI: - return await self.run_ipython( - IPythonRunCellAction( - code=action.translated_ipython_code, - include_extra=False, - ) + result_str = _execute_file_editor( + self.file_editor, + command='view', + path=action.path, + view_range=action.view_range, + ) + + return FileReadObservation( + content=result_str, + path=action.path, + impl_source=FileReadSource.OH_ACI, ) # NOTE: the client code is running inside the sandbox, @@ -359,56 +371,75 @@ async def write(self, action: FileWriteAction) -> Observation: filepath = self._resolve_path(action.path, working_dir) insert = action.content.split('\n') + if not os.path.exists(os.path.dirname(filepath)): + os.makedirs(os.path.dirname(filepath)) + + file_exists = os.path.exists(filepath) + if file_exists: + file_stat = os.stat(filepath) + else: + file_stat = None + + mode = 'w' if not file_exists else 'r+' try: - if not os.path.exists(os.path.dirname(filepath)): - os.makedirs(os.path.dirname(filepath)) + with open(filepath, mode, encoding='utf-8') as file: + if mode != 'w': + all_lines = file.readlines() + new_file = insert_lines(insert, all_lines, action.start, action.end) + else: + new_file = [i + '\n' for i in insert] + + file.seek(0) + file.writelines(new_file) + file.truncate() - file_exists = os.path.exists(filepath) + except FileNotFoundError: + return ErrorObservation(f'File not found: {filepath}') + except IsADirectoryError: + return ErrorObservation( + f'Path is a directory: {filepath}. You can only write to files' + ) + except UnicodeDecodeError: + return ErrorObservation(f'File could not be decoded as utf-8: {filepath}') + + # Attempt to handle file permissions + try: if file_exists: - file_stat = os.stat(filepath) + assert file_stat is not None + # restore the original file permissions if the file already exists + os.chmod(filepath, file_stat.st_mode) + os.chown(filepath, file_stat.st_uid, file_stat.st_gid) else: - file_stat = None - - mode = 'w' if not file_exists else 'r+' - try: - with open(filepath, mode, encoding='utf-8') as file: - if mode != 'w': - all_lines = file.readlines() - new_file = insert_lines( - insert, all_lines, action.start, action.end - ) - else: - new_file = [i + '\n' for i in insert] - - file.seek(0) - file.writelines(new_file) - file.truncate() - - # Handle file permissions - if file_exists: - assert file_stat is not None - # restore the original file permissions if the file already exists - os.chmod(filepath, file_stat.st_mode) - os.chown(filepath, file_stat.st_uid, file_stat.st_gid) - else: - # set the new file permissions if the file is new - os.chmod(filepath, 0o664) - os.chown(filepath, self.user_id, self.user_id) - - except FileNotFoundError: - return ErrorObservation(f'File not found: {filepath}') - except IsADirectoryError: - return ErrorObservation( - f'Path is a directory: {filepath}. You can only write to files' - ) - except UnicodeDecodeError: - return ErrorObservation( - f'File could not be decoded as utf-8: {filepath}' - ) - except PermissionError: - return ErrorObservation(f'Malformed paths not permitted: {filepath}') + # set the new file permissions if the file is new + os.chmod(filepath, 0o664) + os.chown(filepath, self.user_id, self.user_id) + except PermissionError as e: + return ErrorObservation( + f'File {filepath} written, but failed to change ownership and permissions: {e}' + ) return FileWriteObservation(content='', path=filepath) + async def edit(self, action: FileEditAction) -> Observation: + assert action.impl_source == FileEditSource.OH_ACI + result_str = _execute_file_editor( + self.file_editor, + command=action.command, + path=action.path, + file_text=action.file_text, + old_str=action.old_str, + new_str=action.new_str, + insert_line=action.insert_line, + enable_linting=False, + ) + + return FileEditObservation( + content=result_str, + path=action.path, + old_content=action.old_str, + new_content=action.new_str, + impl_source=FileEditSource.OH_ACI, + ) + async def browse(self, action: BrowseURLAction) -> Observation: return await browse(action, self.browser) diff --git a/openhands/runtime/base.py b/openhands/runtime/base.py index 6b10ac07c9bf..4f1e37f471dc 100644 --- a/openhands/runtime/base.py +++ b/openhands/runtime/base.py @@ -12,6 +12,7 @@ from typing import Callable from zipfile import ZipFile +from pydantic import SecretStr from requests.exceptions import ConnectionError from openhands.core.config import AppConfig, SandboxConfig @@ -38,6 +39,7 @@ UserRejectObservation, ) from openhands.events.serialization.action import ACTION_TYPE_TO_CLASS +from openhands.integrations.github.github_service import GithubServiceImpl from openhands.microagent import ( BaseMicroAgent, load_microagents_from_dir, @@ -93,6 +95,7 @@ def __init__( status_callback: Callable | None = None, attach_to_existing: bool = False, headless_mode: bool = False, + github_user_id: str | None = None, ): self.sid = sid self.event_stream = event_stream @@ -125,6 +128,8 @@ def __init__( self, enable_llm_editor=config.get_agent_config().codeact_enable_llm_editor ) + self.github_user_id = github_user_id + def setup_initial_env(self) -> None: if self.attach_to_existing: return @@ -212,6 +217,16 @@ async def _handle_action(self, event: Action) -> None: event.set_hard_timeout(self.config.sandbox.timeout, blocking=False) assert event.timeout is not None try: + if isinstance(event, CmdRunAction): + if self.github_user_id and '$GITHUB_TOKEN' in event.command: + gh_client = GithubServiceImpl(user_id=self.github_user_id) + token = await gh_client.get_latest_token() + if token: + export_cmd = CmdRunAction( + f"export GITHUB_TOKEN='{token.get_secret_value()}'" + ) + await call_sync_from_async(self.run, export_cmd) + observation: Observation = await call_sync_from_async( self.run_action, event ) @@ -234,20 +249,37 @@ async def _handle_action(self, event: Action) -> None: source = event.source if event.source else EventSource.AGENT self.event_stream.add_event(observation, source) # type: ignore[arg-type] - def clone_repo(self, github_token: str, selected_repository: str) -> str: + def clone_repo( + self, + github_token: SecretStr, + selected_repository: str, + selected_branch: str | None, + ) -> str: if not github_token or not selected_repository: raise ValueError( 'github_token and selected_repository must be provided to clone a repository' ) - url = f'https://{github_token}@github.com/{selected_repository}.git' + url = f'https://{github_token.get_secret_value()}@github.com/{selected_repository}.git' dir_name = selected_repository.split('/')[1] - # add random branch name to avoid conflicts + + # Generate a random branch name to avoid conflicts random_str = ''.join( random.choices(string.ascii_lowercase + string.digits, k=8) ) - branch_name = f'openhands-workspace-{random_str}' + openhands_workspace_branch = f'openhands-workspace-{random_str}' + + # Clone repository command + clone_command = f'git clone {url} {dir_name}' + + # Checkout to appropriate branch + checkout_command = ( + f'git checkout {selected_branch}' + if selected_branch + else f'git checkout -b {openhands_workspace_branch}' + ) + action = CmdRunAction( - command=f'git clone {url} {dir_name} ; cd {dir_name} ; git checkout -b {branch_name}', + command=f'{clone_command} ; cd {dir_name} ; {checkout_command}', ) self.log('info', f'Cloning repo: {selected_repository}') self.run_action(action) diff --git a/openhands/runtime/builder/docker.py b/openhands/runtime/builder/docker.py index de99bd440f3e..dbbea66daed0 100644 --- a/openhands/runtime/builder/docker.py +++ b/openhands/runtime/builder/docker.py @@ -168,8 +168,10 @@ def build( ) except subprocess.CalledProcessError as e: - logger.error(f'Image build failed:\n{e}') + logger.error(f'Image build failed:\n{e}') # TODO: {e} is empty logger.error(f'Command output:\n{e.output}') + if self.rolling_logger.is_enabled(): + logger.error("Docker build output:\n" + self.rolling_logger.all_lines) # Show the error raise except subprocess.TimeoutExpired: diff --git a/openhands/runtime/impl/action_execution/action_execution_client.py b/openhands/runtime/impl/action_execution/action_execution_client.py index 38afc544870c..258dcf3a85f7 100644 --- a/openhands/runtime/impl/action_execution/action_execution_client.py +++ b/openhands/runtime/impl/action_execution/action_execution_client.py @@ -24,6 +24,7 @@ IPythonRunCellAction, ) from openhands.events.action.action import Action +from openhands.events.action.files import FileEditSource from openhands.events.observation import ( ErrorObservation, NullObservation, @@ -55,6 +56,7 @@ def __init__( status_callback: Any | None = None, attach_to_existing: bool = False, headless_mode: bool = True, + github_user_id: str | None = None, ): self.session = HttpSession() self.action_semaphore = threading.Semaphore(1) # Ensure one action at a time @@ -70,6 +72,7 @@ def __init__( status_callback, attach_to_existing, headless_mode, + github_user_id, ) @abstractmethod @@ -140,11 +143,13 @@ def copy_from(self, path: str) -> Path: stream=True, timeout=30, ) as response: - temp_file = tempfile.NamedTemporaryFile(delete=False) - for chunk in response.iter_content(chunk_size=8192): - if chunk: # filter out keep-alive new chunks - temp_file.write(chunk) - return Path(temp_file.name) + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + total_length = 0 + for chunk in response.iter_content(chunk_size=8192): + if chunk: # filter out keep-alive new chunks + total_length += len(chunk) + temp_file.write(chunk) + return Path(temp_file.name) except requests.Timeout: raise TimeoutError('Copy operation timed out') @@ -213,8 +218,11 @@ def get_vscode_token(self) -> str: return '' def send_action_for_execution(self, action: Action) -> Observation: - if isinstance(action, FileEditAction): - return self.edit(action) + if ( + isinstance(action, FileEditAction) + and action.impl_source == FileEditSource.LLM_BASED_EDIT + ): + return self.llm_based_edit(action) # set timeout to default if not set if action.timeout is None: @@ -277,6 +285,9 @@ def read(self, action: FileReadAction) -> Observation: def write(self, action: FileWriteAction) -> Observation: return self.send_action_for_execution(action) + def edit(self, action: FileEditAction) -> Observation: + return self.send_action_for_execution(action) + def browse(self, action: BrowseURLAction) -> Observation: return self.send_action_for_execution(action) diff --git a/openhands/runtime/impl/docker/docker_runtime.py b/openhands/runtime/impl/docker/docker_runtime.py index 4312f3b6e6e4..b2c5e980226e 100644 --- a/openhands/runtime/impl/docker/docker_runtime.py +++ b/openhands/runtime/impl/docker/docker_runtime.py @@ -39,6 +39,7 @@ class DockerRuntime(ActionExecutionClient): """This runtime will subscribe the event stream. + When receive an event, it will send the event to runtime-client which run inside the docker environment. Args: @@ -405,11 +406,11 @@ def pause(self): """Pause the runtime by stopping the container. This is different from container.stop() as it ensures environment variables are properly preserved.""" if not self.container: - raise RuntimeError("Container not initialized") - + raise RuntimeError('Container not initialized') + # First, ensure all environment variables are properly persisted in .bashrc # This is already handled by add_env_vars in base.py - + # Stop the container self.container.stop() self.log('debug', f'Container {self.container_name} paused') @@ -418,12 +419,12 @@ def resume(self): """Resume the runtime by starting the container. This is different from container.start() as it ensures environment variables are properly restored.""" if not self.container: - raise RuntimeError("Container not initialized") - + raise RuntimeError('Container not initialized') + # Start the container self.container.start() self.log('debug', f'Container {self.container_name} resumed') - + # Wait for the container to be ready self._wait_until_alive() diff --git a/openhands/runtime/impl/remote/remote_runtime.py b/openhands/runtime/impl/remote/remote_runtime.py index f663c1f5af0d..0340f7d0a088 100644 --- a/openhands/runtime/impl/remote/remote_runtime.py +++ b/openhands/runtime/impl/remote/remote_runtime.py @@ -45,6 +45,7 @@ def __init__( status_callback: Optional[Callable] = None, attach_to_existing: bool = False, headless_mode: bool = True, + github_user_id: str | None = None, ): super().__init__( config, @@ -55,6 +56,7 @@ def __init__( status_callback, attach_to_existing, headless_mode, + github_user_id, ) if self.config.sandbox.api_key is None: raise ValueError( @@ -90,8 +92,9 @@ def _get_action_execution_server_host(self): async def connect(self): try: await call_sync_from_async(self._start_or_attach_to_runtime) - except AgentRuntimeNotReadyError: - self.log('error', 'Runtime failed to start, timed out before ready') + except Exception: + self.close() + self.log('error', 'Runtime failed to start') raise await call_sync_from_async(self.setup_initial_env) self._runtime_initialized = True @@ -210,13 +213,15 @@ def _start_runtime(self): plugins=self.plugins, app_config=self.config, ) + environment = {} + if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true': + environment['DEBUG'] = 'true' + environment.update(self.config.sandbox.runtime_startup_env_vars) start_request = { 'image': self.container_image, 'command': command, 'working_dir': '/openhands/code/', - 'environment': {'DEBUG': 'true'} - if self.config.debug or os.environ.get('DEBUG', 'false').lower() == 'true' - else {}, + 'environment': environment, 'session_id': self.sid, 'resource_factor': self.config.sandbox.remote_runtime_resource_factor, } @@ -291,7 +296,8 @@ def _wait_until_alive(self): stop=tenacity.stop_after_delay( self.config.sandbox.remote_runtime_init_timeout ) - | stop_if_should_exit() | self._stop_if_closed, + | stop_if_should_exit() + | self._stop_if_closed, reraise=True, retry=tenacity.retry_if_exception_type(AgentRuntimeNotReadyError), wait=tenacity.wait_fixed(2), @@ -302,7 +308,7 @@ def _wait_until_alive_impl(self): self.log('debug', f'Waiting for runtime to be alive at url: {self.runtime_url}') with self._send_runtime_api_request( 'GET', - f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}', + f'{self.config.sandbox.remote_runtime_api_url}/runtime/{self.runtime_id}', ) as runtime_info_response: runtime_data = runtime_info_response.json() assert 'runtime_id' in runtime_data @@ -394,10 +400,14 @@ def _send_action_server_request(self, method, url, **kwargs): retry_decorator = tenacity.retry( retry=tenacity.retry_if_exception_type(ConnectionError), - stop=tenacity.stop_after_attempt(3) | stop_if_should_exit() | self._stop_if_closed, + stop=tenacity.stop_after_attempt(3) + | stop_if_should_exit() + | self._stop_if_closed, wait=tenacity.wait_exponential(multiplier=1, min=4, max=60), ) - return retry_decorator(self._send_action_server_request_impl)(method, url, **kwargs) + return retry_decorator(self._send_action_server_request_impl)( + method, url, **kwargs + ) def _send_action_server_request_impl(self, method, url, **kwargs): try: @@ -430,6 +440,6 @@ def _send_action_server_request_impl(self, method, url, **kwargs): ) from e else: raise e - + def _stop_if_closed(self, retry_state: tenacity.RetryCallState) -> bool: return self._runtime_closed diff --git a/openhands/runtime/plugins/agent_skills/file_ops/file_ops.py b/openhands/runtime/plugins/agent_skills/file_ops/file_ops.py index b2e1b4c8aa4c..47451c2985c1 100644 --- a/openhands/runtime/plugins/agent_skills/file_ops/file_ops.py +++ b/openhands/runtime/plugins/agent_skills/file_ops/file_ops.py @@ -1,6 +1,8 @@ -"""file_ops.py +"""File operations module for OpenHands agent. -This module provides various file manipulation skills for the OpenHands agent. +This module provides a collection of file manipulation skills that enable the OpenHands +agent to perform various file operations such as opening, searching, and navigating +through files and directories. Functions: - open_file(path: str, line_number: int | None = 1, context_lines: int = 100): Opens a file and optionally moves to a specific line. @@ -10,6 +12,9 @@ - search_dir(search_term: str, dir_path: str = './'): Searches for a term in all files in the specified directory. - search_file(search_term: str, file_path: str | None = None): Searches for a term in the specified file or the currently open file. - find_file(file_name: str, dir_path: str = './'): Finds all files with the given name in the specified directory. + +Note: + All functions return string representations of their results. """ import os @@ -81,11 +86,18 @@ def _clamp(value, min_value, max_value): def _lint_file(file_path: str) -> tuple[str | None, int | None]: - """Lint the file at the given path and return a tuple with a boolean indicating if there are errors, + """Perform linting on a file and identify the first error location. + + Lint the file at the given path and return a tuple with a boolean indicating if there are errors, and the line number of the first error, if any. + Args: + file_path: str: The path to the file to lint. + Returns: - tuple[str | None, int | None]: (lint_error, first_error_line_number) + A tuple containing: + - The lint error message if found, None otherwise + - The line number of the first error, None if no errors """ linter = DefaultLinter() lint_error: list[LintResult] = linter.lint(file_path) @@ -165,14 +177,18 @@ def _cur_file_header(current_file, total_lines) -> str: def open_file( path: str, line_number: int | None = 1, context_lines: int | None = WINDOW ) -> None: - """Opens the file at the given path in the editor. IF the file is to be edited, first use `scroll_down` repeatedly to read the full file! - If line_number is provided, the window will be moved to include that line. - It only shows the first 100 lines by default! `context_lines` is the max number of lines to be displayed, up to 100. Use `scroll_up` and `scroll_down` to view more content up or down. + """Opens a file in the editor and optionally positions at a specific line. + + The function displays a limited window of content, centered around the specified line + number if provided. To view the complete file content, the agent should use scroll_down and scroll_up + commands iteratively. Args: - path: str: The path to the file to open, preferred absolute path. - line_number: int | None = 1: The line number to move to. Defaults to 1. - context_lines: int | None = 100: Only shows this number of lines in the context window (usually from line 1), with line_number as the center (if possible). Defaults to 100. + path: The path to the file to open. Absolute path is recommended. + line_number: The target line number to center the view on (if possible). + Defaults to 1. + context_lines: Maximum number of lines to display in the view window. + Limited to 100 lines. Defaults to 100. """ global CURRENT_FILE, CURRENT_LINE, WINDOW @@ -316,8 +332,8 @@ def search_file(search_term: str, file_path: str | None = None) -> None: """Searches for search_term in file. If file is not provided, searches in the current open file. Args: - search_term: str: The term to search for. - file_path: str | None: The path to the file to search. + search_term: The term to search for. + file_path: The path to the file to search. """ global CURRENT_FILE if file_path is None: diff --git a/openhands/runtime/utils/bash.py b/openhands/runtime/utils/bash.py index 5fda883d4d01..419573d7546d 100644 --- a/openhands/runtime/utils/bash.py +++ b/openhands/runtime/utils/bash.py @@ -175,25 +175,32 @@ def __init__( work_dir: str, username: str | None = None, no_change_timeout_seconds: int = 30, + max_memory_mb: int | None = None, ): self.NO_CHANGE_TIMEOUT_SECONDS = no_change_timeout_seconds self.work_dir = work_dir self.username = username self._initialized = False + self.max_memory_mb = max_memory_mb def initialize(self): self.server = libtmux.Server() - window_command = '/bin/bash' + _shell_command = '/bin/bash' if self.username in ['root', 'openhands']: # This starts a non-login (new) shell for the given user - window_command = f'su {self.username} -' + _shell_command = f'su {self.username} -' # otherwise, we are running as the CURRENT USER (e.g., when running LocalRuntime) + if self.max_memory_mb is not None: + window_command = ( + f'prlimit --as={self.max_memory_mb * 1024 * 1024} {_shell_command}' + ) + else: + window_command = _shell_command + logger.debug(f'Initializing bash session with command: {window_command}') session_name = f'openhands-{self.username}-{uuid.uuid4()}' self.session = self.server.new_session( session_name=session_name, - window_name='bash', - window_command=window_command, start_directory=self.work_dir, kill_session=True, x=1000, @@ -207,6 +214,7 @@ def initialize(self): # We need to create a new pane because the initial pane's history limit is (default) 2000 _initial_window = self.session.attached_window self.window = self.session.new_window( + window_name='bash', window_shell=window_command, start_directory=self.work_dir, ) diff --git a/openhands/runtime/utils/edit.py b/openhands/runtime/utils/edit.py index 3dce0544f0a7..a66b2039674d 100644 --- a/openhands/runtime/utils/edit.py +++ b/openhands/runtime/utils/edit.py @@ -13,7 +13,6 @@ FileWriteAction, IPythonRunCellAction, ) -from openhands.events.event import FileEditSource from openhands.events.observation import ( ErrorObservation, FileEditObservation, @@ -205,16 +204,7 @@ def _get_lint_error( return ErrorObservation(error_message) return None - def edit(self, action: FileEditAction) -> Observation: - if action.impl_source == FileEditSource.OH_ACI: - # Translate to ipython command to file_editor - return self.run_ipython( - IPythonRunCellAction( - code=action.translated_ipython_code, - include_extra=False, - ) - ) - + def llm_based_edit(self, action: FileEditAction) -> Observation: obs = self.read(FileReadAction(path=action.path)) if ( isinstance(obs, ErrorObservation) diff --git a/openhands/runtime/utils/files.py b/openhands/runtime/utils/files.py index b9664cafc45f..1d54c90b3609 100644 --- a/openhands/runtime/utils/files.py +++ b/openhands/runtime/utils/files.py @@ -140,6 +140,6 @@ async def write_file( ) except UnicodeDecodeError: return ErrorObservation(f'File could not be decoded as utf-8: {path}') - except PermissionError: - return ErrorObservation(f'Malformed paths not permitted: {path}') + except PermissionError as e: + return ErrorObservation(f'Permission error on {path}: {e}') return FileWriteObservation(content='', path=path) diff --git a/openhands/runtime/utils/runtime_build.py b/openhands/runtime/utils/runtime_build.py index bbb83ac7f9df..862ce04d7a58 100644 --- a/openhands/runtime/utils/runtime_build.py +++ b/openhands/runtime/utils/runtime_build.py @@ -69,7 +69,6 @@ def get_runtime_image_repo_and_tag(base_image: str) -> tuple[str, str]: Returns: - tuple[str, str]: The Docker repo and tag of the Docker image """ - if get_runtime_image_repo() in base_image: logger.debug( f'The provided image [{base_image}] is already a valid runtime image.\n' @@ -115,6 +114,7 @@ def build_runtime_image( extra_build_args: List[str] | None = None, ) -> str: """Prepares the final docker build folder. + If dry_run is False, it will also build the OpenHands runtime Docker image using the docker build folder. Parameters: @@ -349,7 +349,7 @@ def _build_sandbox_image( platform: str | None = None, extra_build_args: List[str] | None = None, ): - """Build and tag the sandbox image. The image will be tagged with all tags that do not yet exist""" + """Build and tag the sandbox image. The image will be tagged with all tags that do not yet exist.""" names = [ f'{runtime_image_repo}:{source_tag}', f'{runtime_image_repo}:{lock_tag}', diff --git a/openhands/server/auth.py b/openhands/server/auth.py index d54577a66524..fa28dafbf45e 100644 --- a/openhands/server/auth.py +++ b/openhands/server/auth.py @@ -1,7 +1,8 @@ from fastapi import Request +from pydantic import SecretStr -def get_github_token(request: Request) -> str | None: +def get_github_token(request: Request) -> SecretStr | None: return getattr(request.state, 'github_token', None) diff --git a/openhands/server/config/server_config.py b/openhands/server/config/server_config.py index 456567ef5783..ae86d8d43ade 100644 --- a/openhands/server/config/server_config.py +++ b/openhands/server/config/server_config.py @@ -18,8 +18,6 @@ class ServerConfig(ServerConfigInterface): ) conversation_manager_class: str = 'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager' - github_service_class: str = 'openhands.server.services.github_service.GitHubService' - def verify_config(self): if self.config_cls: raise ValueError('Unexpected config path provided') diff --git a/openhands/server/conversation_manager/standalone_conversation_manager.py b/openhands/server/conversation_manager/standalone_conversation_manager.py index a1748038d600..2f49de63dc9e 100644 --- a/openhands/server/conversation_manager/standalone_conversation_manager.py +++ b/openhands/server/conversation_manager/standalone_conversation_manager.py @@ -149,8 +149,8 @@ async def _cleanup_stale(self): self._close_session(sid) for sid in self._local_agent_loops_by_sid ) return - except Exception as e: - logger.error(f'error_cleaning_stale') + except Exception: + logger.error('error_cleaning_stale') await asyncio.sleep(_CLEANUP_INTERVAL) async def get_running_agent_loops( diff --git a/openhands/server/middleware.py b/openhands/server/middleware.py index cf72579197ab..734d52004bc5 100644 --- a/openhands/server/middleware.py +++ b/openhands/server/middleware.py @@ -196,7 +196,7 @@ async def __call__(self, request: Request, call_next: Callable): # TODO: To avoid checks like this we should re-add the abilty to have completely different middleware in SAAS as in OSS if getattr(request.state, 'github_token', None) is None: if settings and settings.github_token: - request.state.github_token = settings.github_token.get_secret_value() + request.state.github_token = settings.github_token else: request.state.github_token = None diff --git a/openhands/server/routes/github.py b/openhands/server/routes/github.py index 889b7a30da90..51013beff751 100644 --- a/openhands/server/routes/github.py +++ b/openhands/server/routes/github.py @@ -1,17 +1,18 @@ from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse +from pydantic import SecretStr -from openhands.server.auth import get_user_id -from openhands.server.data_models.gh_types import GitHubRepository, GitHubUser -from openhands.server.services.github_service import GitHubService -from openhands.server.shared import server_config -from openhands.server.types import GhAuthenticationError, GHUnknownException -from openhands.utils.import_utils import get_impl +from openhands.integrations.github.github_service import GithubServiceImpl +from openhands.integrations.github.github_types import ( + GhAuthenticationError, + GHUnknownException, + GitHubRepository, + GitHubUser, +) +from openhands.server.auth import get_github_token, get_user_id app = APIRouter(prefix='/api/github') -GithubServiceImpl = get_impl(GitHubService, server_config.github_service_class) - @app.get('/repositories') async def get_github_repositories( @@ -20,8 +21,9 @@ async def get_github_repositories( sort: str = 'pushed', installation_id: int | None = None, github_user_id: str | None = Depends(get_user_id), + github_user_token: SecretStr | None = Depends(get_github_token), ): - client = GithubServiceImpl(github_user_id) + client = GithubServiceImpl(user_id=github_user_id, token=github_user_token) try: repos: list[GitHubRepository] = await client.get_repositories( page, per_page, sort, installation_id @@ -44,8 +46,9 @@ async def get_github_repositories( @app.get('/user') async def get_github_user( github_user_id: str | None = Depends(get_user_id), + github_user_token: SecretStr | None = Depends(get_github_token), ): - client = GithubServiceImpl(github_user_id) + client = GithubServiceImpl(user_id=github_user_id, token=github_user_token) try: user: GitHubUser = await client.get_user() return user @@ -66,8 +69,9 @@ async def get_github_user( @app.get('/installations') async def get_github_installation_ids( github_user_id: str | None = Depends(get_user_id), + github_user_token: SecretStr | None = Depends(get_github_token), ): - client = GithubServiceImpl(github_user_id) + client = GithubServiceImpl(user_id=github_user_id, token=github_user_token) try: installations_ids: list[int] = await client.get_installation_ids() return installations_ids @@ -92,8 +96,9 @@ async def search_github_repositories( sort: str = 'stars', order: str = 'desc', github_user_id: str | None = Depends(get_user_id), + github_user_token: SecretStr | None = Depends(get_github_token), ): - client = GithubServiceImpl(github_user_id) + client = GithubServiceImpl(user_id=github_user_id, token=github_user_token) try: repos: list[GitHubRepository] = await client.search_repositories( query, per_page, sort, order diff --git a/openhands/server/routes/manage_conversations.py b/openhands/server/routes/manage_conversations.py index 2a453e30f1d3..29db83007656 100644 --- a/openhands/server/routes/manage_conversations.py +++ b/openhands/server/routes/manage_conversations.py @@ -4,14 +4,14 @@ from fastapi import APIRouter, Body, Request from fastapi.responses import JSONResponse -from pydantic import BaseModel +from pydantic import BaseModel, SecretStr from openhands.core.logger import openhands_logger as logger from openhands.events.action.message import MessageAction from openhands.events.stream import EventStreamSubscriber +from openhands.integrations.github.github_service import GithubServiceImpl from openhands.runtime import get_runtime_cls -from openhands.server.auth import get_user_id -from openhands.server.routes.github import GithubServiceImpl +from openhands.server.auth import get_github_token, get_user_id from openhands.server.session.conversation_init_data import ConversationInitData from openhands.server.shared import ( ConversationStoreImpl, @@ -38,14 +38,16 @@ class InitSessionRequest(BaseModel): selected_repository: str | None = None + selected_branch: str | None = None initial_user_msg: str | None = None image_urls: list[str] | None = None async def _create_new_conversation( user_id: str | None, - token: str | None, + token: SecretStr | None, selected_repository: str | None, + selected_branch: str | None, initial_user_msg: str | None, image_urls: list[str] | None, ): @@ -72,8 +74,9 @@ async def _create_new_conversation( logger.warn('Settings not present, not starting conversation') raise MissingSettingsError('Settings not found') - session_init_args['github_token'] = token or '' + session_init_args['github_token'] = token or SecretStr('') session_init_args['selected_repository'] = selected_repository + session_init_args['selected_branch'] = selected_branch conversation_init_data = ConversationInitData(**session_init_args) logger.info('Loading conversation store') conversation_store = await ConversationStoreImpl.get_instance(config, user_id) @@ -131,8 +134,11 @@ async def new_conversation(request: Request, data: InitSessionRequest): """ logger.info('Initializing new conversation') user_id = get_user_id(request) - github_token = GithubServiceImpl.get_gh_token(request) + gh_client = GithubServiceImpl(user_id=user_id, token=get_github_token(request)) + github_token = await gh_client.get_latest_token() + selected_repository = data.selected_repository + selected_branch = data.selected_branch initial_user_msg = data.initial_user_msg image_urls = data.image_urls or [] @@ -142,6 +148,7 @@ async def new_conversation(request: Request, data: InitSessionRequest): user_id, github_token, selected_repository, + selected_branch, initial_user_msg, image_urls, ) diff --git a/openhands/server/routes/public.py b/openhands/server/routes/public.py index a5c861a62e59..59e5c4e4efe6 100644 --- a/openhands/server/routes/public.py +++ b/openhands/server/routes/public.py @@ -23,8 +23,7 @@ @app.get('/models') async def get_litellm_models() -> list[str]: - """ - Get all models supported by LiteLLM. + """Get all models supported by LiteLLM. This function combines models from litellm and Bedrock, removing any error-prone Bedrock models. diff --git a/openhands/server/routes/settings.py b/openhands/server/routes/settings.py index d14c113a6549..66ed76a23e33 100644 --- a/openhands/server/routes/settings.py +++ b/openhands/server/routes/settings.py @@ -1,9 +1,10 @@ from fastapi import APIRouter, Request, status from fastapi.responses import JSONResponse +from pydantic import SecretStr from openhands.core.logger import openhands_logger as logger -from openhands.server.auth import get_user_id -from openhands.server.services.github_service import GitHubService +from openhands.integrations.github.github_service import GithubServiceImpl +from openhands.server.auth import get_github_token, get_user_id from openhands.server.settings import GETSettingsModel, POSTSettingsModel, Settings from openhands.server.shared import SettingsStoreImpl, config @@ -22,7 +23,7 @@ async def load_settings(request: Request) -> GETSettingsModel | None: content={'error': 'Settings not found'}, ) - token_is_set = bool(user_id) or bool(request.state.github_token) + token_is_set = bool(user_id) or bool(get_github_token(request)) settings_with_token_data = GETSettingsModel( **settings.model_dump(), github_token_is_set=token_is_set, @@ -50,8 +51,10 @@ async def store_settings( try: # We check if the token is valid by getting the user # If the token is invalid, this will raise an exception - github = GitHubService(None) - await github.validate_user(settings.github_token) + github = GithubServiceImpl( + user_id=None, token=SecretStr(settings.github_token) + ) + await github.get_user() except Exception as e: logger.warning(f'Invalid GitHub token: {e}') diff --git a/openhands/server/session/agent_session.py b/openhands/server/session/agent_session.py index 59afdf141c10..79b98733850a 100644 --- a/openhands/server/session/agent_session.py +++ b/openhands/server/session/agent_session.py @@ -2,6 +2,8 @@ import time from typing import Callable, Optional +from pydantic import SecretStr + from openhands.controller import AgentController from openhands.controller.agent import Agent from openhands.controller.state.state import State @@ -15,6 +17,7 @@ from openhands.microagent import BaseMicroAgent from openhands.runtime import get_runtime_cls from openhands.runtime.base import Runtime +from openhands.runtime.impl.remote.remote_runtime import RemoteRuntime from openhands.security import SecurityAnalyzer, options from openhands.storage.files import FileStore from openhands.utils.async_utils import call_sync_from_async @@ -47,6 +50,7 @@ def __init__( sid: str, file_store: FileStore, status_callback: Optional[Callable] = None, + github_user_id: str | None = None, ): """Initializes a new instance of the Session class @@ -59,6 +63,7 @@ def __init__( self.event_stream = EventStream(sid, file_store) self.file_store = file_store self._status_callback = status_callback + self.github_user_id = github_user_id async def start( self, @@ -69,8 +74,9 @@ async def start( max_budget_per_task: float | None = None, agent_to_llm_config: dict[str, LLMConfig] | None = None, agent_configs: dict[str, AgentConfig] | None = None, - github_token: str | None = None, + github_token: SecretStr | None = None, selected_repository: str | None = None, + selected_branch: str | None = None, initial_message: MessageAction | None = None, ): """Starts the Agent session @@ -93,41 +99,43 @@ async def start( return self._starting = True self._started_at = time.time() - self._create_security_analyzer(config.security.security_analyzer) - await self._create_runtime( - runtime_name=runtime_name, - config=config, - agent=agent, - github_token=github_token, - selected_repository=selected_repository, - ) - - self.controller = self._create_controller( - agent, - config.security.confirmation_mode, - max_iterations, - max_budget_per_task=max_budget_per_task, - agent_to_llm_config=agent_to_llm_config, - agent_configs=agent_configs, - ) - if github_token: - self.event_stream.set_secrets( - { - 'github_token': github_token, - } - ) - if initial_message: - self.event_stream.add_event(initial_message, EventSource.USER) - self.event_stream.add_event( - ChangeAgentStateAction(AgentState.RUNNING), EventSource.ENVIRONMENT - ) - else: - self.event_stream.add_event( - ChangeAgentStateAction(AgentState.AWAITING_USER_INPUT), - EventSource.ENVIRONMENT, + try: + self._create_security_analyzer(config.security.security_analyzer) + await self._create_runtime( + runtime_name=runtime_name, + config=config, + agent=agent, + github_token=github_token, + selected_repository=selected_repository, + selected_branch=selected_branch, ) - self._starting = False + self.controller = self._create_controller( + agent, + config.security.confirmation_mode, + max_iterations, + max_budget_per_task=max_budget_per_task, + agent_to_llm_config=agent_to_llm_config, + agent_configs=agent_configs, + ) + if github_token: + self.event_stream.set_secrets( + { + 'github_token': github_token.get_secret_value(), + } + ) + if initial_message: + self.event_stream.add_event(initial_message, EventSource.USER) + self.event_stream.add_event( + ChangeAgentStateAction(AgentState.RUNNING), EventSource.ENVIRONMENT + ) + else: + self.event_stream.add_event( + ChangeAgentStateAction(AgentState.AWAITING_USER_INPUT), + EventSource.ENVIRONMENT, + ) + finally: + self._starting = False async def close(self): """Closes the Agent session""" @@ -177,8 +185,9 @@ async def _create_runtime( runtime_name: str, config: AppConfig, agent: Agent, - github_token: str | None = None, + github_token: SecretStr | None = None, selected_repository: str | None = None, + selected_branch: str | None = None, ): """Creates a runtime instance @@ -195,11 +204,16 @@ async def _create_runtime( runtime_cls = get_runtime_cls(runtime_name) env_vars = ( { - 'GITHUB_TOKEN': github_token, + 'GITHUB_TOKEN': github_token.get_secret_value(), } if github_token else None ) + + kwargs = {} + if runtime_cls == RemoteRuntime: + kwargs['github_user_id'] = self.github_user_id + self.runtime = runtime_cls( config=config, event_stream=self.event_stream, @@ -208,6 +222,7 @@ async def _create_runtime( status_callback=self._status_callback, headless_mode=False, env_vars=env_vars, + **kwargs, ) # FIXME: this sleep is a terrible hack. @@ -228,7 +243,10 @@ async def _create_runtime( repo_directory = None if selected_repository: repo_directory = await call_sync_from_async( - self.runtime.clone_repo, github_token, selected_repository + self.runtime.clone_repo, + github_token, + selected_repository, + selected_branch, ) if agent.prompt_manager: diff --git a/openhands/server/session/conversation_init_data.py b/openhands/server/session/conversation_init_data.py index 82979e91fd96..4cb6acd50f22 100644 --- a/openhands/server/session/conversation_init_data.py +++ b/openhands/server/session/conversation_init_data.py @@ -1,4 +1,4 @@ -from pydantic import Field +from pydantic import Field, SecretStr from openhands.server.settings import Settings @@ -8,5 +8,6 @@ class ConversationInitData(Settings): Session initialization data for the web environment - a deep copy of the global config is made and then overridden with this data. """ - github_token: str | None = Field(default=None) + github_token: SecretStr | None = Field(default=None) selected_repository: str | None = Field(default=None) + selected_branch: str | None = Field(default=None) diff --git a/openhands/server/session/session.py b/openhands/server/session/session.py index 848b51efc45d..d7807fc94740 100644 --- a/openhands/server/session/session.py +++ b/openhands/server/session/session.py @@ -55,7 +55,10 @@ def __init__( self.last_active_ts = int(time.time()) self.file_store = file_store self.agent_session = AgentSession( - sid, file_store, status_callback=self.queue_status_message + sid, + file_store, + status_callback=self.queue_status_message, + github_user_id=user_id, ) self.agent_session.event_stream.subscribe( EventStreamSubscriber.SERVER, self.on_event, self.sid @@ -120,9 +123,11 @@ async def initialize_agent( github_token = None selected_repository = None + selected_branch = None if isinstance(settings, ConversationInitData): github_token = settings.github_token selected_repository = settings.selected_repository + selected_branch = settings.selected_branch try: await self.agent_session.start( @@ -135,6 +140,7 @@ async def initialize_agent( agent_configs=self.config.get_agent_configs(), github_token=github_token, selected_repository=selected_repository, + selected_branch=selected_branch, initial_message=initial_message, ) except Exception as e: diff --git a/openhands/server/types.py b/openhands/server/types.py index da115de8cda5..4c8c1dc96a1c 100644 --- a/openhands/server/types.py +++ b/openhands/server/types.py @@ -42,15 +42,3 @@ class LLMAuthenticationError(ValueError): """Raised when there is an issue with LLM authentication.""" pass - - -class GhAuthenticationError(ValueError): - """Raised when there is an issue with LLM authentication.""" - - pass - - -class GHUnknownException(ValueError): - """Raised when there is an issue with LLM authentication.""" - - pass diff --git a/openhands/storage/settings/file_settings_store.py b/openhands/storage/settings/file_settings_store.py index eaf35554d7ae..d3cc08677078 100644 --- a/openhands/storage/settings/file_settings_store.py +++ b/openhands/storage/settings/file_settings_store.py @@ -23,7 +23,7 @@ async def load(self) -> Settings | None: settings = Settings(**kwargs) return settings except FileNotFoundError: - return Settings.from_config() + return None async def store(self, settings: Settings): json_str = settings.model_dump_json(context={'expose_secrets': True}) diff --git a/poetry.lock b/poetry.lock index ef37bd55ce1b..5100a72f3949 100644 --- a/poetry.lock +++ b/poetry.lock @@ -547,6 +547,21 @@ files = [ {file = "bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71"}, ] +[[package]] +name = "binaryornot" +version = "0.4.4" +description = "Ultra-lightweight pure Python package to check if a file is binary or text." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4"}, + {file = "binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061"}, +] + +[package.dependencies] +chardet = ">=3.0.2" + [[package]] name = "bleach" version = "6.2.0" @@ -580,18 +595,18 @@ files = [ [[package]] name = "boto3" -version = "1.36.12" +version = "1.36.20" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "boto3-1.36.12-py3-none-any.whl", hash = "sha256:32cdf0967287f3ec25a9dc09df0d29cb86b8900c3e0546a63d672775d8127abf"}, - {file = "boto3-1.36.12.tar.gz", hash = "sha256:287d84f49bba3255a17b374578127d42b6251e72f55914a62e0ad9ca78c0954b"}, + {file = "boto3-1.36.20-py3-none-any.whl", hash = "sha256:e132e31232ee107f1c187f566d96863a907433e5bdd8d8928effddd30a96242f"}, + {file = "boto3-1.36.20.tar.gz", hash = "sha256:4a27ffc0543c2a429600542047f00c6a1e95270139d36d8cc636e9cc9a78b835"}, ] [package.dependencies] -botocore = ">=1.36.12,<1.37.0" +botocore = ">=1.36.20,<1.37.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.11.0,<0.12.0" @@ -600,14 +615,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.36.12" +version = "1.36.20" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "botocore-1.36.12-py3-none-any.whl", hash = "sha256:5ae1ed362c8ed908a6ced8cdd12b21e2196c100bc79f9e95c9c1fc7f9ea74f5a"}, - {file = "botocore-1.36.12.tar.gz", hash = "sha256:86ed88beb4f244c96529435c868d3940073c2774116f0023fb7691f6e7053bd9"}, + {file = "botocore-1.36.20-py3-none-any.whl", hash = "sha256:0110bf2208e4569659d0ccfca94baa4999501334397987b02712a94493cbf48b"}, + {file = "botocore-1.36.20.tar.gz", hash = "sha256:3815a05518ff03a8dbc8d5a3c29b95889409a25ac87a282067f6e26fefb7c40a"}, ] [package.dependencies] @@ -911,7 +926,7 @@ version = "5.2.0" description = "Universal encoding detector for Python 3" optional = false python-versions = ">=3.7" -groups = ["evaluation", "test"] +groups = ["main", "evaluation", "test"] files = [ {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, @@ -1370,6 +1385,7 @@ files = [ {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, @@ -1380,6 +1396,7 @@ files = [ {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, @@ -1687,14 +1704,14 @@ files = [ [[package]] name = "e2b" -version = "1.0.6" +version = "1.1.0" description = "E2B SDK that give agents cloud environments" optional = false -python-versions = "<4.0,>=3.8" +python-versions = "<4.0,>=3.9" groups = ["main"] files = [ - {file = "e2b-1.0.6-py3-none-any.whl", hash = "sha256:4ae6e00d46e6b0b9ab05388c408f9155488ee9f022c5a6fd47939f492ccf3b58"}, - {file = "e2b-1.0.6.tar.gz", hash = "sha256:e35d47f5581565060a5c18e4cb839cf61de310d275fa0a6589d8fc8bf65957a7"}, + {file = "e2b-1.1.0-py3-none-any.whl", hash = "sha256:5d99c675e155cf124f457d77f91c4cb32b286d241ca6cd37ac8d6c0711fc272e"}, + {file = "e2b-1.1.0.tar.gz", hash = "sha256:bd054fbaa9baed48919500ba853bdb72c750b04e0bac8365bde75cdfbdf80d18"}, ] [package.dependencies] @@ -2379,14 +2396,14 @@ grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] [[package]] name = "google-api-python-client" -version = "2.160.0" +version = "2.161.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "google_api_python_client-2.160.0-py2.py3-none-any.whl", hash = "sha256:63d61fb3e4cf3fb31a70a87f45567c22f6dfe87bbfa27252317e3e2c42900db4"}, - {file = "google_api_python_client-2.160.0.tar.gz", hash = "sha256:a8ccafaecfa42d15d5b5c3134ced8de08380019717fc9fb1ed510ca58eca3b7e"}, + {file = "google_api_python_client-2.161.0-py2.py3-none-any.whl", hash = "sha256:9476a5a4f200bae368140453df40f9cda36be53fa7d0e9a9aac4cdb859a26448"}, + {file = "google_api_python_client-2.161.0.tar.gz", hash = "sha256:324c0cce73e9ea0a0d2afd5937e01b7c2d6a4d7e2579cdb6c384f9699d6c9f37"}, ] [package.dependencies] @@ -2458,14 +2475,14 @@ tool = ["click (>=6.0.0)"] [[package]] name = "google-cloud-aiplatform" -version = "1.79.0" +version = "1.80.0" description = "Vertex AI API client library" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "google_cloud_aiplatform-1.79.0-py2.py3-none-any.whl", hash = "sha256:e52d518c386ce2b4ce57f1b73b46c57531d9a6ccd70c21a37b349f428bfc1c3f"}, - {file = "google_cloud_aiplatform-1.79.0.tar.gz", hash = "sha256:362bfd16716dcfb6c131736f25246790002b29c99a246fcf4c08a7c71bd2301f"}, + {file = "google_cloud_aiplatform-1.80.0-py2.py3-none-any.whl", hash = "sha256:45d2a170f22431dae977551eccb740400bdb899807d0c8d4c16c53b2c1dbc6a5"}, + {file = "google_cloud_aiplatform-1.80.0.tar.gz", hash = "sha256:bcaa4570a6fb56d3d29cb6b8f92588d4d1a1931de5f90cf07761853dab4c76fd"}, ] [package.dependencies] @@ -3567,14 +3584,14 @@ files = [ [[package]] name = "json-repair" -version = "0.35.0" +version = "0.36.1" description = "A package to repair broken json strings" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "json_repair-0.35.0-py3-none-any.whl", hash = "sha256:1d429407158474d28a996e745b8f8f7dc78957cb2cfbc92120b9f580b5230a9e"}, - {file = "json_repair-0.35.0.tar.gz", hash = "sha256:e70f834865a4ae5fe64352c23c1c16d3b70c5dd62dc544a169d8b0932bdbdcaa"}, + {file = "json_repair-0.36.1-py3-none-any.whl", hash = "sha256:ed7ca0c4cf813cc9a75843297507dd8bb21394fef58dc9fde81f542aaaa43457"}, + {file = "json_repair-0.36.1.tar.gz", hash = "sha256:f01688157d610b0b1f22d86bf54d45c7fc0729965c66726c1ec1ad64f98d6572"}, ] [[package]] @@ -4106,20 +4123,20 @@ types-tqdm = "*" [[package]] name = "litellm" -version = "1.60.2" +version = "1.61.3" description = "Library to easily interface with LLM API providers" optional = false python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" groups = ["main"] files = [ - {file = "litellm-1.60.2-py3-none-any.whl", hash = "sha256:1cb08cda04bf8c5ef3e690171a779979e4b16a5e3a24cd8dc1f198e7f198d5c4"}, - {file = "litellm-1.60.2.tar.gz", hash = "sha256:a8170584fcfd6f5175201d869e61ccd8a40ffe3264fc5e53c5b805ddf8a6e05a"}, + {file = "litellm-1.61.3-py3-none-any.whl", hash = "sha256:c01145759260eeb7f624aba82e333804181da4c03fa5b980361490cdaf5b1e65"}, + {file = "litellm-1.61.3.tar.gz", hash = "sha256:626ca5731cf640097e30b0b665ec282de2d2bd8b95cf6a41255819576201de03"}, ] [package.dependencies] aiohttp = "*" click = "*" -httpx = ">=0.23.0,<0.28.0" +httpx = ">=0.23.0" importlib-metadata = ">=6.8.0" jinja2 = ">=3.1.2,<4.0.0" jsonschema = ">=4.22.0,<5.0.0" @@ -4152,20 +4169,20 @@ pydantic = ">=1.10" [[package]] name = "llama-index" -version = "0.12.15" +version = "0.12.17" description = "Interface between LLMs and your data" optional = false python-versions = "<4.0,>=3.9" groups = ["llama-index"] files = [ - {file = "llama_index-0.12.15-py3-none-any.whl", hash = "sha256:badb0da25dc0a8e2d7c34734378c3f5d88a96b6eb7ffb2c6935bbe7ca33f6f22"}, - {file = "llama_index-0.12.15.tar.gz", hash = "sha256:2d77c3264d624776e8dace51397c296ae438f3f0be5abf83f07100930a4d5329"}, + {file = "llama_index-0.12.17-py3-none-any.whl", hash = "sha256:d8938e5e6e5ff78b6865f7890a01d1a40818a5df798555ee6eb7f2c5ab65aeb0"}, + {file = "llama_index-0.12.17.tar.gz", hash = "sha256:761a2dad3eb74bd5242ecf8fd28337c0c8745fc8d39d2f9f9b18bf733ad679f4"}, ] [package.dependencies] llama-index-agent-openai = ">=0.4.0,<0.5.0" llama-index-cli = ">=0.4.0,<0.5.0" -llama-index-core = ">=0.12.15,<0.13.0" +llama-index-core = ">=0.12.17,<0.13.0" llama-index-embeddings-openai = ">=0.3.0,<0.4.0" llama-index-indices-managed-llama-cloud = ">=0.4.0" llama-index-llms-openai = ">=0.3.0,<0.4.0" @@ -4212,14 +4229,14 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0" [[package]] name = "llama-index-core" -version = "0.12.15" +version = "0.12.17" description = "Interface between LLMs and your data" optional = false python-versions = "<4.0,>=3.9" groups = ["llama-index"] files = [ - {file = "llama_index_core-0.12.15-py3-none-any.whl", hash = "sha256:0c65ca72c4fb43a77a2463f2114d6eb570681d729139879ed659179798ecab7f"}, - {file = "llama_index_core-0.12.15.tar.gz", hash = "sha256:f9aaeef792db24d490b1e3484d2bbd1b3c43e7a24a40fd6dbd1b298efb1f9429"}, + {file = "llama_index_core-0.12.17-py3-none-any.whl", hash = "sha256:867ec650a1f9eba9f6d65005045a68bc13bae8d65763e32029d9610360c03979"}, + {file = "llama_index_core-0.12.17.tar.gz", hash = "sha256:2e8fb457983978af19db1ceba71d440f6891279525c5e7eb2ec73a6b727be113"}, ] [package.dependencies] @@ -5014,13 +5031,14 @@ type = ["mypy (==1.11.2)"] [[package]] name = "modal" -version = "0.73.12" +version = "0.73.49" description = "Python client library for Modal" optional = false python-versions = ">=3.9" groups = ["main", "evaluation"] files = [ - {file = "modal-0.73.12-py3-none-any.whl", hash = "sha256:9eecbb36d53fa29c59a2f9d463a835d0c3eb0889c8b34447a4b26d85290140a6"}, + {file = "modal-0.73.49-py3-none-any.whl", hash = "sha256:81e885c8b7246d447d1adb914f80b8ac41a82e7004bb4044624d490701912e1e"}, + {file = "modal-0.73.49.tar.gz", hash = "sha256:4a9cd8eb47ad8226b7f508a60c6c1f3f238c39e9a4207844ff98fbbd9f64f296"}, ] [package.dependencies] @@ -5036,7 +5054,7 @@ toml = "*" typer = ">=0.9" types-certifi = "*" types-toml = "*" -typing-extensions = ">=4.6,<5.0" +typing_extensions = ">=4.6,<5.0" watchfiles = "*" [[package]] @@ -5234,50 +5252,44 @@ dill = ">=0.3.8" [[package]] name = "mypy" -version = "1.14.1" +version = "1.15.0" description = "Optional static typing for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, - {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, - {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, - {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, - {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, - {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, - {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, - {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, - {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, - {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, - {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, - {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, - {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, - {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, - {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, - {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, - {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, - {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, - {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, - {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, - {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, - {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, - {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, - {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, - {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, - {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, - {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, - {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, - {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, - {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, - {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, - {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, - {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, - {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, - {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, - {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, - {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, - {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, + {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, + {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, + {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, + {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, + {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, + {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, + {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, + {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, + {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, + {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, + {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, + {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, + {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, + {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, + {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, + {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, + {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, + {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, + {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, + {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, + {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, + {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, ] [package.dependencies] @@ -5526,67 +5538,67 @@ test = ["pytest", "pytest-console-scripts", "pytest-jupyter", "pytest-tornasync" [[package]] name = "numpy" -version = "2.2.2" +version = "2.2.3" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.10" groups = ["main", "evaluation", "llama-index", "test"] files = [ - {file = "numpy-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7079129b64cb78bdc8d611d1fd7e8002c0a2565da6a47c4df8062349fee90e3e"}, - {file = "numpy-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec6c689c61df613b783aeb21f945c4cbe6c51c28cb70aae8430577ab39f163e"}, - {file = "numpy-2.2.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:40c7ff5da22cd391944a28c6a9c638a5eef77fcf71d6e3a79e1d9d9e82752715"}, - {file = "numpy-2.2.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:995f9e8181723852ca458e22de5d9b7d3ba4da3f11cc1cb113f093b271d7965a"}, - {file = "numpy-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b78ea78450fd96a498f50ee096f69c75379af5138f7881a51355ab0e11286c97"}, - {file = "numpy-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fbe72d347fbc59f94124125e73fc4976a06927ebc503ec5afbfb35f193cd957"}, - {file = "numpy-2.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8e6da5cffbbe571f93588f562ed130ea63ee206d12851b60819512dd3e1ba50d"}, - {file = "numpy-2.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09d6a2032faf25e8d0cadde7fd6145118ac55d2740132c1d845f98721b5ebcfd"}, - {file = "numpy-2.2.2-cp310-cp310-win32.whl", hash = "sha256:159ff6ee4c4a36a23fe01b7c3d07bd8c14cc433d9720f977fcd52c13c0098160"}, - {file = "numpy-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:64bd6e1762cd7f0986a740fee4dff927b9ec2c5e4d9a28d056eb17d332158014"}, - {file = "numpy-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:642199e98af1bd2b6aeb8ecf726972d238c9877b0f6e8221ee5ab945ec8a2189"}, - {file = "numpy-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d9fc9d812c81e6168b6d405bf00b8d6739a7f72ef22a9214c4241e0dc70b323"}, - {file = "numpy-2.2.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:c7d1fd447e33ee20c1f33f2c8e6634211124a9aabde3c617687d8b739aa69eac"}, - {file = "numpy-2.2.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:451e854cfae0febe723077bd0cf0a4302a5d84ff25f0bfece8f29206c7bed02e"}, - {file = "numpy-2.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd249bc894af67cbd8bad2c22e7cbcd46cf87ddfca1f1289d1e7e54868cc785c"}, - {file = "numpy-2.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02935e2c3c0c6cbe9c7955a8efa8908dd4221d7755644c59d1bba28b94fd334f"}, - {file = "numpy-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a972cec723e0563aa0823ee2ab1df0cb196ed0778f173b381c871a03719d4826"}, - {file = "numpy-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6d6a0910c3b4368d89dde073e630882cdb266755565155bc33520283b2d9df8"}, - {file = "numpy-2.2.2-cp311-cp311-win32.whl", hash = "sha256:860fd59990c37c3ef913c3ae390b3929d005243acca1a86facb0773e2d8d9e50"}, - {file = "numpy-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:da1eeb460ecce8d5b8608826595c777728cdf28ce7b5a5a8c8ac8d949beadcf2"}, - {file = "numpy-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ac9bea18d6d58a995fac1b2cb4488e17eceeac413af014b1dd26170b766d8467"}, - {file = "numpy-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae9f0c2d889b7b2d88a3791f6c09e2ef827c2446f1c4a3e3e76328ee4afd9a"}, - {file = "numpy-2.2.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3074634ea4d6df66be04f6728ee1d173cfded75d002c75fac79503a880bf3825"}, - {file = "numpy-2.2.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:8ec0636d3f7d68520afc6ac2dc4b8341ddb725039de042faf0e311599f54eb37"}, - {file = "numpy-2.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ffbb1acd69fdf8e89dd60ef6182ca90a743620957afb7066385a7bbe88dc748"}, - {file = "numpy-2.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0349b025e15ea9d05c3d63f9657707a4e1d471128a3b1d876c095f328f8ff7f0"}, - {file = "numpy-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:463247edcee4a5537841d5350bc87fe8e92d7dd0e8c71c995d2c6eecb8208278"}, - {file = "numpy-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9dd47ff0cb2a656ad69c38da850df3454da88ee9a6fde0ba79acceee0e79daba"}, - {file = "numpy-2.2.2-cp312-cp312-win32.whl", hash = "sha256:4525b88c11906d5ab1b0ec1f290996c0020dd318af8b49acaa46f198b1ffc283"}, - {file = "numpy-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:5acea83b801e98541619af398cc0109ff48016955cc0818f478ee9ef1c5c3dcb"}, - {file = "numpy-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b208cfd4f5fe34e1535c08983a1a6803fdbc7a1e86cf13dd0c61de0b51a0aadc"}, - {file = "numpy-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d0bbe7dd86dca64854f4b6ce2ea5c60b51e36dfd597300057cf473d3615f2369"}, - {file = "numpy-2.2.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:22ea3bb552ade325530e72a0c557cdf2dea8914d3a5e1fecf58fa5dbcc6f43cd"}, - {file = "numpy-2.2.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:128c41c085cab8a85dc29e66ed88c05613dccf6bc28b3866cd16050a2f5448be"}, - {file = "numpy-2.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:250c16b277e3b809ac20d1f590716597481061b514223c7badb7a0f9993c7f84"}, - {file = "numpy-2.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0c8854b09bc4de7b041148d8550d3bd712b5c21ff6a8ed308085f190235d7ff"}, - {file = "numpy-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6fb9c32a91ec32a689ec6410def76443e3c750e7cfc3fb2206b985ffb2b85f0"}, - {file = "numpy-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:57b4012e04cc12b78590a334907e01b3a85efb2107df2b8733ff1ed05fce71de"}, - {file = "numpy-2.2.2-cp313-cp313-win32.whl", hash = "sha256:4dbd80e453bd34bd003b16bd802fac70ad76bd463f81f0c518d1245b1c55e3d9"}, - {file = "numpy-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:5a8c863ceacae696aff37d1fd636121f1a512117652e5dfb86031c8d84836369"}, - {file = "numpy-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b3482cb7b3325faa5f6bc179649406058253d91ceda359c104dac0ad320e1391"}, - {file = "numpy-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9491100aba630910489c1d0158034e1c9a6546f0b1340f716d522dc103788e39"}, - {file = "numpy-2.2.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:41184c416143defa34cc8eb9d070b0a5ba4f13a0fa96a709e20584638254b317"}, - {file = "numpy-2.2.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7dca87ca328f5ea7dafc907c5ec100d187911f94825f8700caac0b3f4c384b49"}, - {file = "numpy-2.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bc61b307655d1a7f9f4b043628b9f2b721e80839914ede634e3d485913e1fb2"}, - {file = "numpy-2.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fad446ad0bc886855ddf5909cbf8cb5d0faa637aaa6277fb4b19ade134ab3c7"}, - {file = "numpy-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:149d1113ac15005652e8d0d3f6fd599360e1a708a4f98e43c9c77834a28238cb"}, - {file = "numpy-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:106397dbbb1896f99e044efc90360d098b3335060375c26aa89c0d8a97c5f648"}, - {file = "numpy-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:0eec19f8af947a61e968d5429f0bd92fec46d92b0008d0a6685b40d6adf8a4f4"}, - {file = "numpy-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:97b974d3ba0fb4612b77ed35d7627490e8e3dff56ab41454d9e8b23448940576"}, - {file = "numpy-2.2.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b0531f0b0e07643eb089df4c509d30d72c9ef40defa53e41363eca8a8cc61495"}, - {file = "numpy-2.2.2-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:e9e82dcb3f2ebbc8cb5ce1102d5f1c5ed236bf8a11730fb45ba82e2841ec21df"}, - {file = "numpy-2.2.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0d4142eb40ca6f94539e4db929410f2a46052a0fe7a2c1c59f6179c39938d2a"}, - {file = "numpy-2.2.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:356ca982c188acbfa6af0d694284d8cf20e95b1c3d0aefa8929376fea9146f60"}, - {file = "numpy-2.2.2.tar.gz", hash = "sha256:ed6906f61834d687738d25988ae117683705636936cc605be0bb208b23df4d8f"}, + {file = "numpy-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cbc6472e01952d3d1b2772b720428f8b90e2deea8344e854df22b0618e9cce71"}, + {file = "numpy-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdfe0c22692a30cd830c0755746473ae66c4a8f2e7bd508b35fb3b6a0813d787"}, + {file = "numpy-2.2.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:e37242f5324ffd9f7ba5acf96d774f9276aa62a966c0bad8dae692deebec7716"}, + {file = "numpy-2.2.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:95172a21038c9b423e68be78fd0be6e1b97674cde269b76fe269a5dfa6fadf0b"}, + {file = "numpy-2.2.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5b47c440210c5d1d67e1cf434124e0b5c395eee1f5806fdd89b553ed1acd0a3"}, + {file = "numpy-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0391ea3622f5c51a2e29708877d56e3d276827ac5447d7f45e9bc4ade8923c52"}, + {file = "numpy-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f6b3dfc7661f8842babd8ea07e9897fe3d9b69a1d7e5fbb743e4160f9387833b"}, + {file = "numpy-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1ad78ce7f18ce4e7df1b2ea4019b5817a2f6a8a16e34ff2775f646adce0a5027"}, + {file = "numpy-2.2.3-cp310-cp310-win32.whl", hash = "sha256:5ebeb7ef54a7be11044c33a17b2624abe4307a75893c001a4800857956b41094"}, + {file = "numpy-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:596140185c7fa113563c67c2e894eabe0daea18cf8e33851738c19f70ce86aeb"}, + {file = "numpy-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:16372619ee728ed67a2a606a614f56d3eabc5b86f8b615c79d01957062826ca8"}, + {file = "numpy-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5521a06a3148686d9269c53b09f7d399a5725c47bbb5b35747e1cb76326b714b"}, + {file = "numpy-2.2.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:7c8dde0ca2f77828815fd1aedfdf52e59071a5bae30dac3b4da2a335c672149a"}, + {file = "numpy-2.2.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:77974aba6c1bc26e3c205c2214f0d5b4305bdc719268b93e768ddb17e3fdd636"}, + {file = "numpy-2.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d42f9c36d06440e34226e8bd65ff065ca0963aeecada587b937011efa02cdc9d"}, + {file = "numpy-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2712c5179f40af9ddc8f6727f2bd910ea0eb50206daea75f58ddd9fa3f715bb"}, + {file = "numpy-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c8b0451d2ec95010d1db8ca733afc41f659f425b7f608af569711097fd6014e2"}, + {file = "numpy-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9b4a8148c57ecac25a16b0e11798cbe88edf5237b0df99973687dd866f05e1b"}, + {file = "numpy-2.2.3-cp311-cp311-win32.whl", hash = "sha256:1f45315b2dc58d8a3e7754fe4e38b6fce132dab284a92851e41b2b344f6441c5"}, + {file = "numpy-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f48ba6f6c13e5e49f3d3efb1b51c8193215c42ac82610a04624906a9270be6f"}, + {file = "numpy-2.2.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12c045f43b1d2915eca6b880a7f4a256f59d62df4f044788c8ba67709412128d"}, + {file = "numpy-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87eed225fd415bbae787f93a457af7f5990b92a334e346f72070bf569b9c9c95"}, + {file = "numpy-2.2.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:712a64103d97c404e87d4d7c47fb0c7ff9acccc625ca2002848e0d53288b90ea"}, + {file = "numpy-2.2.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a5ae282abe60a2db0fd407072aff4599c279bcd6e9a2475500fc35b00a57c532"}, + {file = "numpy-2.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5266de33d4c3420973cf9ae3b98b54a2a6d53a559310e3236c4b2b06b9c07d4e"}, + {file = "numpy-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b787adbf04b0db1967798dba8da1af07e387908ed1553a0d6e74c084d1ceafe"}, + {file = "numpy-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:34c1b7e83f94f3b564b35f480f5652a47007dd91f7c839f404d03279cc8dd021"}, + {file = "numpy-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4d8335b5f1b6e2bce120d55fb17064b0262ff29b459e8493d1785c18ae2553b8"}, + {file = "numpy-2.2.3-cp312-cp312-win32.whl", hash = "sha256:4d9828d25fb246bedd31e04c9e75714a4087211ac348cb39c8c5f99dbb6683fe"}, + {file = "numpy-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:83807d445817326b4bcdaaaf8e8e9f1753da04341eceec705c001ff342002e5d"}, + {file = "numpy-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bfdb06b395385ea9b91bf55c1adf1b297c9fdb531552845ff1d3ea6e40d5aba"}, + {file = "numpy-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23c9f4edbf4c065fddb10a4f6e8b6a244342d95966a48820c614891e5059bb50"}, + {file = "numpy-2.2.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:a0c03b6be48aaf92525cccf393265e02773be8fd9551a2f9adbe7db1fa2b60f1"}, + {file = "numpy-2.2.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:2376e317111daa0a6739e50f7ee2a6353f768489102308b0d98fcf4a04f7f3b5"}, + {file = "numpy-2.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fb62fe3d206d72fe1cfe31c4a1106ad2b136fcc1606093aeab314f02930fdf2"}, + {file = "numpy-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52659ad2534427dffcc36aac76bebdd02b67e3b7a619ac67543bc9bfe6b7cdb1"}, + {file = "numpy-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b416af7d0ed3271cad0f0a0d0bee0911ed7eba23e66f8424d9f3dfcdcae1304"}, + {file = "numpy-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1402da8e0f435991983d0a9708b779f95a8c98c6b18a171b9f1be09005e64d9d"}, + {file = "numpy-2.2.3-cp313-cp313-win32.whl", hash = "sha256:136553f123ee2951bfcfbc264acd34a2fc2f29d7cdf610ce7daf672b6fbaa693"}, + {file = "numpy-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5b732c8beef1d7bc2d9e476dbba20aaff6167bf205ad9aa8d30913859e82884b"}, + {file = "numpy-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:435e7a933b9fda8126130b046975a968cc2d833b505475e588339e09f7672890"}, + {file = "numpy-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7678556eeb0152cbd1522b684dcd215250885993dd00adb93679ec3c0e6e091c"}, + {file = "numpy-2.2.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2e8da03bd561504d9b20e7a12340870dfc206c64ea59b4cfee9fceb95070ee94"}, + {file = "numpy-2.2.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:c9aa4496fd0e17e3843399f533d62857cef5900facf93e735ef65aa4bbc90ef0"}, + {file = "numpy-2.2.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4ca91d61a4bf61b0f2228f24bbfa6a9facd5f8af03759fe2a655c50ae2c6610"}, + {file = "numpy-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:deaa09cd492e24fd9b15296844c0ad1b3c976da7907e1c1ed3a0ad21dded6f76"}, + {file = "numpy-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:246535e2f7496b7ac85deffe932896a3577be7af8fb7eebe7146444680297e9a"}, + {file = "numpy-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:daf43a3d1ea699402c5a850e5313680ac355b4adc9770cd5cfc2940e7861f1bf"}, + {file = "numpy-2.2.3-cp313-cp313t-win32.whl", hash = "sha256:cf802eef1f0134afb81fef94020351be4fe1d6681aadf9c5e862af6602af64ef"}, + {file = "numpy-2.2.3-cp313-cp313t-win_amd64.whl", hash = "sha256:aee2512827ceb6d7f517c8b85aa5d3923afe8fc7a57d028cffcd522f1c6fd082"}, + {file = "numpy-2.2.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3c2ec8a0f51d60f1e9c0c5ab116b7fc104b165ada3f6c58abf881cb2eb16044d"}, + {file = "numpy-2.2.3-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:ed2cf9ed4e8ebc3b754d398cba12f24359f018b416c380f577bbae112ca52fc9"}, + {file = "numpy-2.2.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39261798d208c3095ae4f7bc8eaeb3481ea8c6e03dc48028057d3cbdbdb8937e"}, + {file = "numpy-2.2.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:783145835458e60fa97afac25d511d00a1eca94d4a8f3ace9fe2043003c678e4"}, + {file = "numpy-2.2.3.tar.gz", hash = "sha256:dbdc15f0c81611925f382dfa97b3bd0bc2c1ce19d4fe50482cb0ddc12ba30020"}, ] [[package]] @@ -5842,14 +5854,14 @@ sympy = "*" [[package]] name = "openai" -version = "1.61.0" +version = "1.63.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" groups = ["main", "evaluation", "llama-index", "test"] files = [ - {file = "openai-1.61.0-py3-none-any.whl", hash = "sha256:e8c512c0743accbdbe77f3429a1490d862f8352045de8dc81969301eb4a4f666"}, - {file = "openai-1.61.0.tar.gz", hash = "sha256:216f325a24ed8578e929b0f1b3fb2052165f3b04b0461818adaa51aa29c71f8a"}, + {file = "openai-1.63.0-py3-none-any.whl", hash = "sha256:a664dfc78f0a05ca46c3e21f344f840cf6bf7174f13cfa9de214ed28bfca1dda"}, + {file = "openai-1.63.0.tar.gz", hash = "sha256:597d7a1b35b113e5a09fcb953bdb1eef44f404a39985f3d7573b3ab09221fd66"}, ] [package.dependencies] @@ -5868,17 +5880,18 @@ realtime = ["websockets (>=13,<15)"] [[package]] name = "openhands-aci" -version = "0.2.0" +version = "0.2.2" description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands." optional = false python-versions = "<4.0,>=3.12" groups = ["main"] files = [ - {file = "openhands_aci-0.2.0-py3-none-any.whl", hash = "sha256:5ca0df7ab6dab1034e70d3982b401db9888dd6deb8149d30e47193bf8588ed65"}, - {file = "openhands_aci-0.2.0.tar.gz", hash = "sha256:6c54defd07a7b2e861ff5c8f683777c2c2503a0f417eeb570382be682e7038d6"}, + {file = "openhands_aci-0.2.2-py3-none-any.whl", hash = "sha256:fdcea74d5760b7f936e532dec2923f06d6ba67b13312e2d91d230e751aa255f1"}, + {file = "openhands_aci-0.2.2.tar.gz", hash = "sha256:947d6c42d4d439200d0bda4748ee8bf5f0c517e8ee554d1c819b82f1d38536c6"}, ] [package.dependencies] +binaryornot = ">=0.4.4,<0.5.0" diskcache = ">=5.6.3,<6.0.0" flake8 = "*" gitpython = "*" @@ -7987,14 +8000,14 @@ files = [ [[package]] name = "reportlab" -version = "4.3.0" +version = "4.3.1" description = "The Reportlab Toolkit" optional = false python-versions = "<4,>=3.7" groups = ["test"] files = [ - {file = "reportlab-4.3.0-py3-none-any.whl", hash = "sha256:81e7bb207132c430cdb9d9f41cfdd1e0fbd1b0eb26a0f7def55d39c1680ad345"}, - {file = "reportlab-4.3.0.tar.gz", hash = "sha256:a90754589bea1c921a745aa981677d2d144f50c690800cda29aafae67c1a8d93"}, + {file = "reportlab-4.3.1-py3-none-any.whl", hash = "sha256:0f37dd16652db3ef84363cf744632a28c38bd480d5bf94683466852d7bb678dd"}, + {file = "reportlab-4.3.1.tar.gz", hash = "sha256:230f78b21667194d8490ac9d12958d5c14686352db7fbe03b95140fafdf5aa97"}, ] [package.dependencies] @@ -8002,9 +8015,9 @@ chardet = "*" pillow = ">=9.0.0" [package.extras] -accel = ["rl-accel (>=0.9.0,<1.1)"] +accel = ["rl_accel (>=0.9.0,<1.1)"] pycairo = ["freetype-py (>=2.3.0,<2.4)", "rlPyCairo (>=0.2.0,<1)"] -renderpm = ["rl-renderPM (>=4.0.3,<4.1)"] +renderpm = ["rl_renderPM (>=4.0.3,<4.1)"] [[package]] name = "requests" @@ -8240,42 +8253,42 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.9.4" +version = "0.9.6" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev", "evaluation"] files = [ - {file = "ruff-0.9.4-py3-none-linux_armv6l.whl", hash = "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706"}, - {file = "ruff-0.9.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf"}, - {file = "ruff-0.9.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b"}, - {file = "ruff-0.9.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137"}, - {file = "ruff-0.9.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e"}, - {file = "ruff-0.9.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec"}, - {file = "ruff-0.9.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b"}, - {file = "ruff-0.9.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a"}, - {file = "ruff-0.9.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214"}, - {file = "ruff-0.9.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231"}, - {file = "ruff-0.9.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b"}, - {file = "ruff-0.9.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6"}, - {file = "ruff-0.9.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c"}, - {file = "ruff-0.9.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0"}, - {file = "ruff-0.9.4-py3-none-win32.whl", hash = "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402"}, - {file = "ruff-0.9.4-py3-none-win_amd64.whl", hash = "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e"}, - {file = "ruff-0.9.4-py3-none-win_arm64.whl", hash = "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41"}, - {file = "ruff-0.9.4.tar.gz", hash = "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7"}, + {file = "ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba"}, + {file = "ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504"}, + {file = "ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5"}, + {file = "ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08"}, + {file = "ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656"}, + {file = "ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d"}, + {file = "ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa"}, + {file = "ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a"}, + {file = "ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9"}, ] [[package]] name = "runloop-api-client" -version = "0.19.0" +version = "0.23.0" description = "The official Python library for the runloop API" optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "runloop_api_client-0.19.0-py3-none-any.whl", hash = "sha256:e7ed5ca3067f19dbf41e38f112bdb58317f23725d0a379e7ab8e5d8d137e7a54"}, - {file = "runloop_api_client-0.19.0.tar.gz", hash = "sha256:62b3cc3137902c5380cbe74a77fe4631ded38700911ef3f08ba1a423835554e4"}, + {file = "runloop_api_client-0.23.0-py3-none-any.whl", hash = "sha256:ee42c46385a986648a6c7bdf49833ec9010a1ffdf1a58c4957940f150606e3ac"}, + {file = "runloop_api_client-0.23.0.tar.gz", hash = "sha256:93b2915d78c3258eba0924a2f1db246b586fa92bb318148ffd5d45fcb60adb3e"}, ] [package.dependencies] @@ -8924,14 +8937,14 @@ full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7 [[package]] name = "streamlit" -version = "1.41.1" +version = "1.42.0" description = "A faster way to build and share data apps" optional = false python-versions = "!=3.9.7,>=3.9" groups = ["evaluation"] files = [ - {file = "streamlit-1.41.1-py2.py3-none-any.whl", hash = "sha256:0def00822480071d642e6df36cd63c089f991da3a69fd9eb4ab8f65ce27de4e0"}, - {file = "streamlit-1.41.1.tar.gz", hash = "sha256:6626d32b098ba1458b71eebdd634c62af2dd876380e59c4b6a1e828a39d62d69"}, + {file = "streamlit-1.42.0-py2.py3-none-any.whl", hash = "sha256:edf333fd3525b7c64b19e1156b483a1a93cbdb09a3a06f26478388d68f971090"}, + {file = "streamlit-1.42.0.tar.gz", hash = "sha256:8c48494ccfad33e7d0bc5873151800b203cb71203bfd42bc7418940710ca4970"}, ] [package.dependencies] @@ -8952,11 +8965,11 @@ rich = ">=10.14.0,<14" tenacity = ">=8.1.0,<10" toml = ">=0.10.1,<2" tornado = ">=6.0.3,<7" -typing-extensions = ">=4.3.0,<5" +typing-extensions = ">=4.4.0,<5" watchdog = {version = ">=2.1.5,<7", markers = "platform_system != \"Darwin\""} [package.extras] -snowflake = ["snowflake-connector-python (>=2.8.0)", "snowflake-snowpark-python[modin] (>=1.17.0)"] +snowflake = ["snowflake-connector-python (>=3.3.0)", "snowflake-snowpark-python[modin] (>=1.17.0)"] [[package]] name = "strenum" @@ -10642,4 +10655,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "cb3fab7a5e6d48140970edc84b14918bbbd637cdfdefd0a88462e4db42fb1d6f" +content-hash = "431b15e98a730d03d7b3b8ea9ea15d812cf50802b35c18c741a69518c1a00464" diff --git a/pyproject.toml b/pyproject.toml index b9a1df2b04fb..c4d209769f8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "openhands-ai" -version = "0.23.0" +version = "0.24.0" description = "OpenHands: Code Less, Make More" authors = ["OpenHands"] license = "MIT" @@ -32,7 +32,7 @@ numpy = "*" json-repair = "*" browsergym-core = "0.10.2" # integrate browsergym-core as the browsing interface html2text = "*" -e2b = ">=1.0.5,<1.1.0" +e2b = ">=1.0.5,<1.2.0" pexpect = "*" jinja2 = "^3.1.3" python-multipart = "*" @@ -63,11 +63,11 @@ protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+ opentelemetry-api = "1.25.0" opentelemetry-exporter-otlp-proto-grpc = "1.25.0" modal = ">=0.66.26,<0.74.0" -runloop-api-client = "0.19.0" +runloop-api-client = "0.23.0" libtmux = ">=0.37,<0.40" pygithub = "^2.5.0" joblib = "*" -openhands-aci = "^0.2.0" +openhands-aci = "^0.2.2" python-socketio = "^5.11.4" redis = "^5.2.0" sse-starlette = "^2.1.3" @@ -86,8 +86,8 @@ voyageai = "*" llama-index-embeddings-voyageai = "*" [tool.poetry.group.dev.dependencies] -ruff = "0.9.4" -mypy = "1.14.1" +ruff = "0.9.6" +mypy = "1.15.0" pre-commit = "4.1.0" build = "*" diff --git a/tests/runtime/test_aci_edit.py b/tests/runtime/test_aci_edit.py new file mode 100644 index 000000000000..e6de0410e8f8 --- /dev/null +++ b/tests/runtime/test_aci_edit.py @@ -0,0 +1,692 @@ +"""Editor-related tests for the DockerRuntime.""" + +import os + +from conftest import _close_test_runtime, _load_runtime + +from openhands.core.logger import openhands_logger as logger +from openhands.events.action import FileEditAction, FileWriteAction + + +def test_view_file(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Create test file + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='This is a test file.\nThis file is for testing purposes.', + path=test_file, + ) + obs = runtime.run_action(action) + + # Test view command + action = FileEditAction( + command='view', + path=test_file, + ) + obs = runtime.run_action(action) + + assert f"Here's the result of running `cat -n` on {test_file}:" in obs.content + assert '1\tThis is a test file.' in obs.content + assert '2\tThis file is for testing purposes.' in obs.content + + finally: + _close_test_runtime(runtime) + + +def test_view_directory(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Create test file + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='This is a test file.\nThis file is for testing purposes.', + path=test_file, + ) + obs = runtime.run_action(action) + + # Test view command + action = FileEditAction( + command='view', + path=config.workspace_mount_path_in_sandbox, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert ( + obs.content + == f"""Here's the files and directories up to 2 levels deep in {config.workspace_mount_path_in_sandbox}, excluding hidden items: +{config.workspace_mount_path_in_sandbox}/ +{config.workspace_mount_path_in_sandbox}/test.txt""" + ) + + finally: + _close_test_runtime(runtime) + + +def test_create_file(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + new_file = os.path.join(config.workspace_mount_path_in_sandbox, 'new_file.txt') + action = FileEditAction( + command='create', + path=new_file, + file_text='New file content', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'File created successfully' in obs.content + + # Verify file content + action = FileEditAction( + command='view', + path=new_file, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'New file content' in obs.content + + finally: + _close_test_runtime(runtime) + + +def test_create_file_with_empty_content(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + new_file = os.path.join(config.workspace_mount_path_in_sandbox, 'new_file.txt') + action = FileEditAction( + command='create', + path=new_file, + file_text='', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'File created successfully' in obs.content + + # Verify file content + action = FileEditAction( + command='view', + path=new_file, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert '1\t' in obs.content + + finally: + _close_test_runtime(runtime) + + +def test_create_with_none_file_text(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + new_file = os.path.join( + config.workspace_mount_path_in_sandbox, 'none_content.txt' + ) + action = FileEditAction( + command='create', + path=new_file, + file_text=None, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert ( + obs.content + == 'ERROR:\nParameter `file_text` is required for command: create.' + ) + finally: + _close_test_runtime(runtime) + + +def test_str_replace(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Create test file + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='This is a test file.\nThis file is for testing purposes.', + path=test_file, + ) + runtime.run_action(action) + + # Test str_replace command + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='test file', + new_str='sample file', + ) + obs = runtime.run_action(action) + assert f'The file {test_file} has been edited' in obs.content + + # Verify file content + action = FileEditAction( + command='view', + path=test_file, + ) + obs = runtime.run_action(action) + assert 'This is a sample file.' in obs.content + + finally: + _close_test_runtime(runtime) + + +def test_str_replace_multi_line(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='This is a test file.\nThis file is for testing purposes.', + path=test_file, + ) + runtime.run_action(action) + + # Test str_replace command + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='This is a test file.\nThis file is for testing purposes.', + new_str='This is a sample file.\nThis file is for testing purposes.', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert f'The file {test_file} has been edited.' in obs.content + assert 'This is a sample file.' in obs.content + assert 'This file is for testing purposes.' in obs.content + + finally: + _close_test_runtime(runtime) + + +def test_str_replace_multi_line_with_tabs(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='def test():\n\tprint("Hello, World!")', + path=test_file, + ) + runtime.run_action(action) + + # Test str_replace command + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='def test():\n\tprint("Hello, World!")', + new_str='def test():\n\tprint("Hello, Universe!")', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert ( + obs.content + == f"""The file {test_file} has been edited. Here's the result of running `cat -n` on a snippet of {test_file}: + 1\tdef test(): + 2\t{'\t'.expandtabs()}print("Hello, Universe!") + 3\t +Review the changes and make sure they are as expected. Edit the file again if necessary.""" + ) + + finally: + _close_test_runtime(runtime) + + +def test_str_replace_error_multiple_occurrences( + temp_dir, runtime_cls, run_as_openhands +): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='This is a test file.\nThis file is for testing purposes.', + path=test_file, + ) + runtime.run_action(action) + + action = FileEditAction( + command='str_replace', path=test_file, old_str='test', new_str='sample' + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Multiple occurrences of old_str `test`' in obs.content + assert '[1, 2]' in obs.content # Should show both line numbers + finally: + _close_test_runtime(runtime) + + +def test_str_replace_error_multiple_multiline_occurrences( + temp_dir, runtime_cls, run_as_openhands +): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + # Create a file with two identical multi-line blocks + multi_block = """def example(): + print("Hello") + return True""" + content = f"{multi_block}\n\nprint('separator')\n\n{multi_block}" + action = FileWriteAction( + content=content, + path=test_file, + ) + runtime.run_action(action) + + # Test str_replace command + action = FileEditAction( + command='str_replace', + path=test_file, + old_str=multi_block, + new_str='def new():\n print("World")', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Multiple occurrences of old_str' in obs.content + assert '[1, 7]' in obs.content # Should show correct starting line numbers + + finally: + _close_test_runtime(runtime) + + +def test_str_replace_nonexistent_string(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='Non-existent Line', + new_str='New Line', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'No replacement was performed' in obs.content + assert ( + f'old_str `Non-existent Line` did not appear verbatim in {test_file}' + in obs.content + ) + finally: + _close_test_runtime(runtime) + + +def test_str_replace_with_empty_new_str(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine to remove\nLine 3', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='Line to remove\n', + new_str='', + ) + obs = runtime.run_action(action) + assert 'Line to remove' not in obs.content + assert 'Line 1' in obs.content + assert 'Line 3' in obs.content + + finally: + _close_test_runtime(runtime) + + +def test_str_replace_with_empty_old_str(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2\nLine 3', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='', + new_str='New string', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert ( + 'No replacement was performed. Multiple occurrences of old_str `` in lines [1, 2, 3, 4]. Please ensure it is unique.' + in obs.content + ) + finally: + _close_test_runtime(runtime) + + +def test_str_replace_with_none_old_str(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2\nLine 3', + path=test_file, + ) + runtime.run_action(action) + + action = FileEditAction( + command='str_replace', + path=test_file, + old_str=None, + new_str='new content', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'old_str' in obs.content + finally: + _close_test_runtime(runtime) + + +def test_insert(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Create test file + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + + # Test insert command + action = FileEditAction( + command='insert', + path=test_file, + insert_line=1, + new_str='Inserted line', + ) + obs = runtime.run_action(action) + assert f'The file {test_file} has been edited' in obs.content + + # Verify file content + action = FileEditAction( + command='view', + path=test_file, + ) + obs = runtime.run_action(action) + assert 'Line 1' in obs.content + assert 'Inserted line' in obs.content + assert 'Line 2' in obs.content + + finally: + _close_test_runtime(runtime) + + +def test_insert_invalid_line(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='insert', + path=test_file, + insert_line=10, + new_str='Invalid Insert', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Invalid `insert_line` parameter' in obs.content + assert 'It should be within the range of lines of the file' in obs.content + finally: + _close_test_runtime(runtime) + + +def test_insert_with_empty_string(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='insert', + path=test_file, + insert_line=1, + new_str='', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert '1\tLine 1' in obs.content + assert '2\t\n' in obs.content + assert '3\tLine 2' in obs.content + finally: + _close_test_runtime(runtime) + + +def test_insert_with_none_new_str(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + + action = FileEditAction( + command='insert', + path=test_file, + insert_line=1, + new_str=None, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'ERROR' in obs.content + assert 'Parameter `new_str` is required for command: insert' in obs.content + finally: + _close_test_runtime(runtime) + + +def test_undo_edit(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Create test file + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='This is a test file.', + path=test_file, + ) + runtime.run_action(action) + + # Make an edit + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='test', + new_str='sample', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'This is a sample file.' in obs.content + + # Undo the edit + action = FileEditAction( + command='undo_edit', + path=test_file, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Last edit to' in obs.content + assert 'This is a test file.' in obs.content + + # Verify file content + action = FileEditAction( + command='view', + path=test_file, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'This is a test file.' in obs.content + + finally: + _close_test_runtime(runtime) + + +def test_validate_path_invalid(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + invalid_file = os.path.join( + config.workspace_mount_path_in_sandbox, 'nonexistent.txt' + ) + action = FileEditAction( + command='view', + path=invalid_file, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Invalid `path` parameter' in obs.content + assert f'The path {invalid_file} does not exist' in obs.content + finally: + _close_test_runtime(runtime) + + +def test_create_existing_file_error(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='create', + path=test_file, + file_text='New content', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'File already exists' in obs.content + finally: + _close_test_runtime(runtime) + + +def test_str_replace_missing_old_str(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='', + new_str='sample', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert ( + 'No replacement was performed. Multiple occurrences of old_str ``' + in obs.content + ) + finally: + _close_test_runtime(runtime) + + +def test_str_replace_new_str_and_old_str_same(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='str_replace', + path=test_file, + old_str='test file', + new_str='test file', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert ( + 'No replacement was performed. `new_str` and `old_str` must be different.' + in obs.content + ) + finally: + _close_test_runtime(runtime) + + +def test_insert_missing_line_param(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt') + action = FileWriteAction( + content='Line 1\nLine 2', + path=test_file, + ) + runtime.run_action(action) + action = FileEditAction( + command='insert', + path=test_file, + new_str='Missing insert line', + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'Parameter `insert_line` is required for command: insert' in obs.content + finally: + _close_test_runtime(runtime) + + +def test_undo_edit_no_history_error(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + empty_file = os.path.join(config.workspace_mount_path_in_sandbox, 'empty.txt') + action = FileWriteAction( + content='', + path=empty_file, + ) + runtime.run_action(action) + + action = FileEditAction( + command='undo_edit', + path=empty_file, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'No edit history found for' in obs.content + finally: + _close_test_runtime(runtime) + + +def test_view_large_file_with_truncation(temp_dir, runtime_cls, run_as_openhands): + runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands) + try: + # Create a large file to trigger truncation + large_file = os.path.join( + config.workspace_mount_path_in_sandbox, 'large_test.txt' + ) + large_content = 'Line 1\n' * 16000 # 16000 lines should trigger truncation + action = FileWriteAction( + content=large_content, + path=large_file, + ) + runtime.run_action(action) + + action = FileEditAction( + command='view', + path=large_file, + ) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert ( + 'Due to the max output limit, only part of this file has been shown to you.' + in obs.content + ) + finally: + _close_test_runtime(runtime) diff --git a/tests/runtime/test_ipython.py b/tests/runtime/test_ipython.py index ea0db4ac88b5..c9fe1bf3e729 100644 --- a/tests/runtime/test_ipython.py +++ b/tests/runtime/test_ipython.py @@ -10,12 +10,10 @@ from openhands.core.logger import openhands_logger as logger from openhands.events.action import ( CmdRunAction, - FileEditAction, FileReadAction, FileWriteAction, IPythonRunCellAction, ) -from openhands.events.event import FileEditSource from openhands.events.observation import ( CmdOutputObservation, ErrorObservation, @@ -310,65 +308,3 @@ def test_ipython_file_editor_permissions_as_openhands(temp_dir, runtime_cls): assert obs.exit_code == 0 _close_test_runtime(runtime) - - -def test_file_read_and_edit_via_oh_aci(runtime_cls, run_as_openhands): - runtime, config = _load_runtime(None, runtime_cls, run_as_openhands) - sandbox_dir = '/workspace' - - actions = [ - { - 'command': 'create', - 'test_code': f"print(file_editor(command='create', path='{sandbox_dir}/test.txt', file_text='Line 1\\nLine 2\\nLine 3'))", - 'action_cls': FileEditAction, - 'assertions': ['File created successfully'], - }, - { - 'command': 'view', - 'test_code': f"print(file_editor(command='view', path='{sandbox_dir}/test.txt'))", - 'action_cls': FileReadAction, - 'assertions': ['Line 1', 'Line 2', 'Line 3'], - }, - { - 'command': 'str_replace', - 'test_code': f"print(file_editor(command='str_replace', path='{sandbox_dir}/test.txt', old_str='Line 2', new_str='New Line 2'))", - 'action_cls': FileEditAction, - 'assertions': ['New Line 2'], - }, - { - 'command': 'undo_edit', - 'test_code': f"print(file_editor(command='undo_edit', path='{sandbox_dir}/test.txt'))", - 'action_cls': FileEditAction, - 'assertions': ['Last edit to', 'undone successfully'], - }, - { - 'command': 'insert', - 'test_code': f"print(file_editor(command='insert', path='{sandbox_dir}/test.txt', insert_line=2, new_str='Line 4'))", - 'action_cls': FileEditAction, - 'assertions': ['Line 4'], - }, - ] - - for action_info in actions: - action_cls = action_info['action_cls'] - - kwargs = { - 'path': f'{sandbox_dir}/test.txt', - 'translated_ipython_code': action_info['test_code'], - 'impl_source': FileEditSource.OH_ACI, - } - if action_info['action_cls'] == FileEditAction: - kwargs['content'] = '' # dummy value required for FileEditAction - - action = action_cls(**kwargs) - - logger.info(action, extra={'msg_type': 'ACTION'}) - obs = runtime.run_action(action) - logger.info(obs, extra={'msg_type': 'OBSERVATION'}) - for assertion in action_info['assertions']: - if action_cls == FileReadAction: - assert assertion in obs.content - else: - assert assertion in str(obs) - - _close_test_runtime(runtime) diff --git a/tests/runtime/test_edit.py b/tests/runtime/test_llm_based_edit.py similarity index 100% rename from tests/runtime/test_edit.py rename to tests/runtime/test_llm_based_edit.py diff --git a/tests/runtime/test_runtime_resource.py b/tests/runtime/test_runtime_resource.py new file mode 100644 index 000000000000..2873939f132d --- /dev/null +++ b/tests/runtime/test_runtime_resource.py @@ -0,0 +1,113 @@ +"""Stress tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox.""" + +from conftest import _close_test_runtime, _load_runtime + +from openhands.core.logger import openhands_logger as logger +from openhands.events.action import CmdRunAction + + +def test_stress_docker_runtime(temp_dir, runtime_cls, repeat=1): + runtime, config = _load_runtime( + temp_dir, + runtime_cls, + docker_runtime_kwargs={ + 'cpu_period': 100000, # 100ms + 'cpu_quota': 100000, # Can use 100ms out of each 100ms period (1 CPU) + 'mem_limit': '4G', # 4 GB of memory + }, + ) + + action = CmdRunAction( + command='sudo apt-get update && sudo apt-get install -y stress-ng' + ) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + for _ in range(repeat): + # run stress-ng stress tests for 1 minute + action = CmdRunAction(command='stress-ng --all 1 -t 30s') + action.set_hard_timeout(120) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + + _close_test_runtime(runtime) + + +def test_stress_docker_runtime_hit_memory_limits(temp_dir, runtime_cls): + """Test runtime behavior under resource constraints.""" + runtime, config = _load_runtime( + temp_dir, + runtime_cls, + docker_runtime_kwargs={ + 'cpu_period': 100000, # 100ms + 'cpu_quota': 100000, # Can use 100ms out of each 100ms period (1 CPU) + 'mem_limit': '4G', # 4 GB of memory + 'memswap_limit': '0', # No swap + 'mem_swappiness': 0, # Disable swapping + 'oom_kill_disable': False, # Enable OOM killer + }, + runtime_startup_env_vars={ + 'RUNTIME_MAX_MEMORY_GB': '3', + }, + ) + + action = CmdRunAction( + command='sudo apt-get update && sudo apt-get install -y stress-ng' + ) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + action = CmdRunAction( + command='stress-ng --vm 1 --vm-bytes 6G --timeout 30s --metrics' + ) + action.set_hard_timeout(120) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert 'aborted early, out of system resources' in obs.content + assert obs.exit_code == 3 # OOM killed! + + _close_test_runtime(runtime) + + +def test_stress_docker_runtime_within_memory_limits(temp_dir, runtime_cls): + """Test runtime behavior under resource constraints.""" + runtime, config = _load_runtime( + temp_dir, + runtime_cls, + docker_runtime_kwargs={ + 'cpu_period': 100000, # 100ms + 'cpu_quota': 100000, # Can use 100ms out of each 100ms period (1 CPU) + 'mem_limit': '4G', # 4 GB of memory + 'memswap_limit': '0', # No swap + 'mem_swappiness': 0, # Disable swapping + 'oom_kill_disable': False, # Enable OOM killer + }, + runtime_startup_env_vars={ + 'RUNTIME_MAX_MEMORY_GB': '7', + }, + ) + + action = CmdRunAction( + command='sudo apt-get update && sudo apt-get install -y stress-ng' + ) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + action = CmdRunAction( + command='stress-ng --vm 1 --vm-bytes 6G --timeout 30s --metrics' + ) + action.set_hard_timeout(120) + logger.info(action, extra={'msg_type': 'ACTION'}) + obs = runtime.run_action(action) + logger.info(obs, extra={'msg_type': 'OBSERVATION'}) + assert obs.exit_code == 0 + + _close_test_runtime(runtime) diff --git a/tests/runtime/test_stress_docker_runtime.py b/tests/runtime/test_stress_docker_runtime.py deleted file mode 100644 index b679a0836253..000000000000 --- a/tests/runtime/test_stress_docker_runtime.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Stress tests for the DockerRuntime, which connects to the ActionExecutor running in the sandbox.""" - -from conftest import _close_test_runtime, _load_runtime - -from openhands.core.logger import openhands_logger as logger -from openhands.events.action import CmdRunAction - - -def test_stress_docker_runtime(temp_dir, runtime_cls, repeat=1): - runtime, config = _load_runtime( - temp_dir, - runtime_cls, - docker_runtime_kwargs={ - 'cpu_period': 100000, # 100ms - 'cpu_quota': 100000, # Can use 100ms out of each 100ms period (1 CPU) - 'mem_limit': '4G', # 4 GB of memory - }, - ) - - action = CmdRunAction( - command='sudo apt-get update && sudo apt-get install -y stress-ng' - ) - logger.info(action, extra={'msg_type': 'ACTION'}) - obs = runtime.run_action(action) - logger.info(obs, extra={'msg_type': 'OBSERVATION'}) - assert obs.exit_code == 0 - - for _ in range(repeat): - # run stress-ng stress tests for 1 minute - action = CmdRunAction(command='stress-ng --all 1 -t 1m') - action.set_hard_timeout(120) - logger.info(action, extra={'msg_type': 'ACTION'}) - obs = runtime.run_action(action) - logger.info(obs, extra={'msg_type': 'OBSERVATION'}) - - _close_test_runtime(runtime) diff --git a/tests/unit/resolver/test_guess_success.py b/tests/unit/resolver/github/test_guess_success.py similarity index 88% rename from tests/unit/resolver/test_guess_success.py rename to tests/unit/resolver/github/test_guess_success.py index 5f0feef8d110..bef1e1f49bcf 100644 --- a/tests/unit/resolver/test_guess_success.py +++ b/tests/unit/resolver/github/test_guess_success.py @@ -4,13 +4,17 @@ from openhands.core.config import LLMConfig from openhands.events.action.message import MessageAction from openhands.llm import LLM -from openhands.resolver.github_issue import GithubIssue -from openhands.resolver.issue_definitions import IssueHandler, PRHandler +from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler +from openhands.resolver.interfaces.issue import Issue +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) def test_guess_success_multiline_explanation(): # Mock data - issue = GithubIssue( + issue = Issue( owner='test', repo='test', number=1, @@ -44,7 +48,9 @@ def test_guess_success_multiline_explanation(): # Use patch to mock the LLM completion call with patch.object(LLM, 'completion', return_value=mock_response) as mock_completion: # Create a handler instance - handler = IssueHandler('test', 'test', 'test', llm_config) + handler = ServiceContextIssue( + GithubIssueHandler('test', 'test', 'test'), llm_config + ) # Call guess_success success, _, explanation = handler.guess_success(issue, history) @@ -64,10 +70,10 @@ def test_guess_success_multiline_explanation(): def test_pr_handler_guess_success_with_thread_comments(): # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR(GithubPRHandler('test', 'test', 'test'), llm_config) # Create a mock issue with thread comments but no review comments - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, @@ -114,10 +120,12 @@ def test_pr_handler_guess_success_with_thread_comments(): def test_pr_handler_guess_success_only_review_comments(): # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create a mock issue with only review comments - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, @@ -165,10 +173,10 @@ def test_pr_handler_guess_success_only_review_comments(): def test_pr_handler_guess_success_no_comments(): # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR(GithubPRHandler('test', 'test', 'test'), llm_config) # Create a mock issue with no comments - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, diff --git a/tests/unit/resolver/test_issue_handler.py b/tests/unit/resolver/github/test_issue_handler.py similarity index 94% rename from tests/unit/resolver/test_issue_handler.py rename to tests/unit/resolver/github/test_issue_handler.py index 56f012fd77c3..4d21e5de696a 100644 --- a/tests/unit/resolver/test_issue_handler.py +++ b/tests/unit/resolver/github/test_issue_handler.py @@ -1,8 +1,12 @@ from unittest.mock import MagicMock, patch from openhands.core.config import LLMConfig -from openhands.resolver.github_issue import ReviewThread -from openhands.resolver.issue_definitions import IssueHandler, PRHandler +from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler +from openhands.resolver.interfaces.issue import ReviewThread +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) def test_get_converted_issues_initializes_review_comments(): @@ -27,7 +31,9 @@ def test_get_converted_issues_initializes_review_comments(): # Create an instance of IssueHandler llm_config = LLMConfig(model='test', api_key='test') - handler = IssueHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextIssue( + GithubIssueHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Get converted issues issues = handler.get_converted_issues(issue_numbers=[1]) @@ -57,7 +63,6 @@ def test_get_converted_issues_handles_empty_body(): # Mock the response for comments mock_comments_response = MagicMock() mock_comments_response.json.return_value = [] - # Set up the mock to return different responses mock_get.side_effect = [ mock_issues_response, @@ -67,7 +72,9 @@ def test_get_converted_issues_handles_empty_body(): # Create an instance of IssueHandler llm_config = LLMConfig(model='test', api_key='test') - handler = IssueHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextIssue( + GithubIssueHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Get converted issues issues = handler.get_converted_issues(issue_numbers=[1]) @@ -148,7 +155,9 @@ def test_pr_handler_get_converted_issues_with_comments(): # Create an instance of PRHandler llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Get converted issues prs = handler.get_converted_issues(issue_numbers=[1]) @@ -185,10 +194,12 @@ def test_get_issue_comments_with_specific_comment_id(): # Create an instance of IssueHandler llm_config = LLMConfig(model='test', api_key='test') - handler = IssueHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextIssue( + GithubIssueHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Get comments with a specific comment_id - specific_comment = handler._get_issue_comments(issue_number=1, comment_id=123) + specific_comment = handler.get_issue_comments(issue_number=1, comment_id=123) # Verify only the specific comment is returned assert specific_comment == ['First comment'] @@ -273,7 +284,9 @@ def test_pr_handler_get_converted_issues_with_specific_thread_comment(): # Create an instance of PRHandler llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Get converted issues prs = handler.get_converted_issues( @@ -376,7 +389,9 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment(): # Create an instance of PRHandler llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Get converted issues prs = handler.get_converted_issues( @@ -499,7 +514,9 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs(): # Create an instance of PRHandler llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Get converted issues prs = handler.get_converted_issues( @@ -599,7 +616,9 @@ def test_pr_handler_get_converted_issues_with_duplicate_issue_refs(): # Create an instance of PRHandler llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Get converted issues prs = handler.get_converted_issues(issue_numbers=[1]) diff --git a/tests/unit/resolver/test_issue_handler_error_handling.py b/tests/unit/resolver/github/test_issue_handler_error_handling.py similarity index 86% rename from tests/unit/resolver/test_issue_handler_error_handling.py rename to tests/unit/resolver/github/test_issue_handler_error_handling.py index 93a98437168e..51e2fbb50728 100644 --- a/tests/unit/resolver/test_issue_handler_error_handling.py +++ b/tests/unit/resolver/github/test_issue_handler_error_handling.py @@ -7,8 +7,12 @@ from openhands.core.config import LLMConfig from openhands.events.action.message import MessageAction from openhands.llm.llm import LLM -from openhands.resolver.github_issue import GithubIssue -from openhands.resolver.issue_definitions import IssueHandler, PRHandler +from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler +from openhands.resolver.interfaces.issue import Issue +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) @pytest.fixture(autouse=True) @@ -33,7 +37,9 @@ def default_config(): def test_handle_nonexistent_issue_reference(): llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Mock the requests.get to simulate a 404 error mock_response = MagicMock() @@ -43,7 +49,7 @@ def test_handle_nonexistent_issue_reference(): with patch('requests.get', return_value=mock_response): # Call the method with a non-existent issue reference - result = handler._PRHandler__get_context_from_external_issues_references( + result = handler._strategy.get_context_from_external_issues_references( closing_issues=[], closing_issue_numbers=[], issue_body='This references #999999', # Non-existent issue @@ -58,7 +64,9 @@ def test_handle_nonexistent_issue_reference(): def test_handle_rate_limit_error(): llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Mock the requests.get to simulate a rate limit error mock_response = MagicMock() @@ -68,7 +76,7 @@ def test_handle_rate_limit_error(): with patch('requests.get', return_value=mock_response): # Call the method with an issue reference - result = handler._PRHandler__get_context_from_external_issues_references( + result = handler._strategy.get_context_from_external_issues_references( closing_issues=[], closing_issue_numbers=[], issue_body='This references #123', @@ -83,14 +91,16 @@ def test_handle_rate_limit_error(): def test_handle_network_error(): llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Mock the requests.get to simulate a network error with patch( 'requests.get', side_effect=requests.exceptions.ConnectionError('Network Error') ): # Call the method with an issue reference - result = handler._PRHandler__get_context_from_external_issues_references( + result = handler._strategy.get_context_from_external_issues_references( closing_issues=[], closing_issue_numbers=[], issue_body='This references #123', @@ -105,7 +115,9 @@ def test_handle_network_error(): def test_successful_issue_reference(): llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Mock a successful response mock_response = MagicMock() @@ -114,7 +126,7 @@ def test_successful_issue_reference(): with patch('requests.get', return_value=mock_response): # Call the method with an issue reference - result = handler._PRHandler__get_context_from_external_issues_references( + result = handler._strategy.get_context_from_external_issues_references( closing_issues=[], closing_issue_numbers=[], issue_body='This references #123', @@ -201,11 +213,13 @@ def test_guess_success_rate_limit_wait_time(mock_litellm_completion, default_con ] llm = LLM(config=default_config) - handler = IssueHandler('test-owner', 'test-repo', 'test-token', default_config) + handler = ServiceContextIssue( + GithubIssueHandler('test-owner', 'test-repo', 'test-token'), default_config + ) handler.llm = llm # Mock issue and history - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, @@ -241,11 +255,13 @@ def test_guess_success_exhausts_retries(mock_completion, default_config): # Initialize LLM and handler llm = LLM(config=default_config) - handler = PRHandler('test-owner', 'test-repo', 'test-token', default_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), default_config + ) handler.llm = llm # Mock issue and history - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, diff --git a/tests/unit/resolver/test_pr_handler_guess_success.py b/tests/unit/resolver/github/test_pr_handler_guess_success.py similarity index 92% rename from tests/unit/resolver/test_pr_handler_guess_success.py rename to tests/unit/resolver/github/test_pr_handler_guess_success.py index c8e6bbe62c09..e94b0bdeb9f0 100644 --- a/tests/unit/resolver/test_pr_handler_guess_success.py +++ b/tests/unit/resolver/github/test_pr_handler_guess_success.py @@ -6,14 +6,18 @@ from openhands.core.config import LLMConfig from openhands.events.action.message import MessageAction from openhands.llm.llm import LLM -from openhands.resolver.github_issue import GithubIssue, ReviewThread -from openhands.resolver.issue_definitions import PRHandler +from openhands.resolver.interfaces.github import GithubPRHandler +from openhands.resolver.interfaces.issue import Issue, ReviewThread +from openhands.resolver.interfaces.issue_definitions import ServiceContextPR @pytest.fixture def pr_handler(): llm_config = LLMConfig(model='test-model') - return PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + return handler @pytest.fixture @@ -37,10 +41,12 @@ def test_guess_success_review_threads_litellm_call(): """Test that the completion() call for review threads contains the expected content.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create a mock issue with review threads - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, @@ -142,10 +148,12 @@ def test_guess_success_thread_comments_litellm_call(): """Test that the completion() call for thread comments contains the expected content.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create a mock issue with thread comments - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, @@ -215,7 +223,9 @@ def test_check_feedback_with_llm(): """Test the _check_feedback_with_llm helper function.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Test cases for different LLM responses test_cases = [ @@ -255,7 +265,9 @@ def test_check_review_thread_with_git_patch(): """Test that git patch from complete_runtime is included in the prompt.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create test data review_thread = ReviewThread( @@ -312,7 +324,9 @@ def test_check_review_thread(): """Test the _check_review_thread helper function.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create test data review_thread = ReviewThread( @@ -367,7 +381,9 @@ def test_check_thread_comments_with_git_patch(): """Test that git patch from complete_runtime is included in the prompt.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create test data thread_comments = [ @@ -422,7 +438,9 @@ def test_check_thread_comments(): """Test the _check_thread_comments helper function.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create test data thread_comments = [ @@ -475,7 +493,9 @@ def test_check_review_comments_with_git_patch(): """Test that git patch from complete_runtime is included in the prompt.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create test data review_comments = [ @@ -530,7 +550,9 @@ def test_check_review_comments(): """Test the _check_review_comments helper function.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create test data review_comments = [ @@ -583,10 +605,12 @@ def test_guess_success_review_comments_litellm_call(): """Test that the completion() call for review comments contains the expected content.""" # Create a PR handler instance llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('test-owner', 'test-repo', 'test-token', llm_config) + handler = ServiceContextPR( + GithubPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) # Create a mock issue with review comments - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, @@ -627,7 +651,6 @@ def test_guess_success_review_comments_litellm_call(): ) ] - # Test the guess_success method with patch.object(LLM, 'completion') as mock_completion: mock_completion.return_value = mock_response success, success_list, explanation = handler.guess_success(issue, history) diff --git a/tests/unit/resolver/test_pr_title_escaping.py b/tests/unit/resolver/github/test_pr_title_escaping.py similarity index 93% rename from tests/unit/resolver/test_pr_title_escaping.py rename to tests/unit/resolver/github/test_pr_title_escaping.py index 9cc5d90bc4b0..336d1522781c 100644 --- a/tests/unit/resolver/test_pr_title_escaping.py +++ b/tests/unit/resolver/github/test_pr_title_escaping.py @@ -2,8 +2,9 @@ import subprocess import tempfile -from openhands.resolver.github_issue import GithubIssue +from openhands.resolver.interfaces.issue import Issue from openhands.resolver.send_pull_request import make_commit +from openhands.resolver.utils import Platform def test_commit_message_with_quotes(): @@ -19,7 +20,7 @@ def test_commit_message_with_quotes(): subprocess.run(['git', '-C', temp_dir, 'add', 'test.txt'], check=True) # Create a test issue with problematic title - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=123, @@ -89,7 +90,7 @@ def raise_for_status(self): monkeypatch.setattr('requests.post', mock_post) monkeypatch.setattr('requests.get', lambda *args, **kwargs: MockGetResponse()) monkeypatch.setattr( - 'openhands.resolver.send_pull_request.branch_exists', + 'openhands.resolver.interfaces.github.GithubIssueHandler.branch_exists', lambda *args, **kwargs: False, ) @@ -135,7 +136,7 @@ def mock_run(*args, **kwargs): # Create a test issue with problematic title print('Creating test issue...') - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=123, @@ -156,9 +157,10 @@ def mock_run(*args, **kwargs): from openhands.resolver.send_pull_request import send_pull_request send_pull_request( - github_issue=issue, - github_token='dummy-token', - github_username='test-user', + issue=issue, + token='dummy-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=temp_dir, pr_type='ready', ) diff --git a/tests/unit/resolver/test_resolve_issues.py b/tests/unit/resolver/github/test_resolve_issues.py similarity index 93% rename from tests/unit/resolver/test_resolve_issues.py rename to tests/unit/resolver/github/test_resolve_issues.py index fcc12f1d0698..d46ddf732fb4 100644 --- a/tests/unit/resolver/test_resolve_issues.py +++ b/tests/unit/resolver/github/test_resolve_issues.py @@ -12,14 +12,19 @@ NullObservation, ) from openhands.llm.llm import LLM -from openhands.resolver.github_issue import GithubIssue, ReviewThread -from openhands.resolver.issue_definitions import IssueHandler, PRHandler +from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler +from openhands.resolver.interfaces.issue import Issue, ReviewThread +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) from openhands.resolver.resolve_issue import ( complete_runtime, initialize_runtime, process_issue, ) from openhands.resolver.resolver_output import ResolverOutput +from openhands.resolver.utils import Platform @pytest.fixture @@ -76,7 +81,7 @@ def test_initialize_runtime(): ), ] - initialize_runtime(mock_runtime) + initialize_runtime(mock_runtime, Platform.GITHUB) assert mock_runtime.run_action.call_count == 2 mock_runtime.run_action.assert_any_call(CmdRunAction(command='cd /workspace')) @@ -103,6 +108,7 @@ async def test_resolve_issue_no_issues_found(): repo='test-repo', token='test-token', username='test-user', + platform=Platform.GITHUB, max_iterations=5, output_dir='/tmp', llm_config=LLMConfig(model='test', api_key='test'), @@ -122,7 +128,9 @@ async def test_resolve_issue_no_issues_found(): def test_download_issues_from_github(): llm_config = LLMConfig(model='test', api_key='test') - handler = IssueHandler('owner', 'repo', 'token', llm_config) + handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), llm_config + ) mock_issues_response = MagicMock() mock_issues_response.json.side_effect = [ @@ -154,7 +162,7 @@ def get_mock_response(url, *args, **kwargs): assert len(issues) == 2 assert handler.issue_type == 'issue' - assert all(isinstance(issue, GithubIssue) for issue in issues) + assert all(isinstance(issue, Issue) for issue in issues) assert [issue.number for issue in issues] == [1, 3] assert [issue.title for issue in issues] == ['Issue 1', 'Issue 2'] assert [issue.review_comments for issue in issues] == [None, None] @@ -164,7 +172,7 @@ def get_mock_response(url, *args, **kwargs): def test_download_pr_from_github(): llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('owner', 'repo', 'token', llm_config) + handler = ServiceContextPR(GithubPRHandler('owner', 'repo', 'token'), llm_config) mock_pr_response = MagicMock() mock_pr_response.json.side_effect = [ [ @@ -268,7 +276,7 @@ def get_mock_response(url, *args, **kwargs): assert len(issues) == 3 assert handler.issue_type == 'pr' - assert all(isinstance(issue, GithubIssue) for issue in issues) + assert all(isinstance(issue, Issue) for issue in issues) assert [issue.number for issue in issues] == [1, 2, 3] assert [issue.title for issue in issues] == ['PR 1', 'My PR', 'PR 3'] assert [issue.head_branch for issue in issues] == ['b1', 'b2', 'b3'] @@ -307,7 +315,7 @@ async def test_complete_runtime(): create_cmd_output(exit_code=0, content='git diff content', command='git apply'), ] - result = await complete_runtime(mock_runtime, 'base_commit_hash') + result = await complete_runtime(mock_runtime, 'base_commit_hash', Platform.GITHUB) assert result == {'git_patch': 'git diff content'} assert mock_runtime.run_action.call_count == 5 @@ -323,7 +331,7 @@ async def test_process_issue(mock_output_dir, mock_prompt_template): handler_instance = MagicMock() # Set up test data - issue = GithubIssue( + issue = Issue( owner='test_owner', repo='test_repo', number=1, @@ -434,6 +442,7 @@ async def test_process_issue(mock_output_dir, mock_prompt_template): # Call the function result = await process_issue( issue, + Platform.GITHUB, base_commit, max_iterations, llm_config, @@ -470,7 +479,7 @@ async def test_process_issue(mock_output_dir, mock_prompt_template): def test_get_instruction(mock_prompt_template, mock_followup_prompt_template): - issue = GithubIssue( + issue = Issue( owner='test_owner', repo='test_repo', number=123, @@ -478,7 +487,9 @@ def test_get_instruction(mock_prompt_template, mock_followup_prompt_template): body='This is a test issue refer to image ![First Image](https://sampleimage.com/image1.png)', ) mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') - issue_handler = IssueHandler('owner', 'repo', 'token', mock_llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) instruction, images_urls = issue_handler.get_instruction( issue, mock_prompt_template, None ) @@ -488,7 +499,7 @@ def test_get_instruction(mock_prompt_template, mock_followup_prompt_template): assert issue_handler.issue_type == 'issue' assert instruction == expected_instruction - issue = GithubIssue( + issue = Issue( owner='test_owner', repo='test_repo', number=123, @@ -506,7 +517,9 @@ def test_get_instruction(mock_prompt_template, mock_followup_prompt_template): ], ) - pr_handler = PRHandler('owner', 'repo', 'token', mock_llm_config) + pr_handler = ServiceContextPR( + GithubPRHandler('owner', 'repo', 'token'), mock_llm_config + ) instruction, images_urls = pr_handler.get_instruction( issue, mock_followup_prompt_template, None ) @@ -518,7 +531,7 @@ def test_get_instruction(mock_prompt_template, mock_followup_prompt_template): def test_file_instruction(): - issue = GithubIssue( + issue = Issue( owner='test_owner', repo='test_repo', number=123, @@ -530,7 +543,9 @@ def test_file_instruction(): prompt = f.read() # Test without thread comments mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') - issue_handler = IssueHandler('owner', 'repo', 'token', mock_llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) instruction, images_urls = issue_handler.get_instruction(issue, prompt, None) expected_instruction = """Please fix the following issue for the repository in /workspace. An environment has been set up for you to start working. You may assume all necessary tools are installed. @@ -550,7 +565,7 @@ def test_file_instruction(): def test_file_instruction_with_repo_instruction(): - issue = GithubIssue( + issue = Issue( owner='test_owner', repo='test_repo', number=123, @@ -568,7 +583,9 @@ def test_file_instruction_with_repo_instruction(): repo_instruction = f.read() mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') - issue_handler = IssueHandler('owner', 'repo', 'token', mock_llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) instruction, image_urls = issue_handler.get_instruction( issue, prompt, repo_instruction ) @@ -597,7 +614,7 @@ def test_file_instruction_with_repo_instruction(): def test_guess_success(): - mock_issue = GithubIssue( + mock_issue = Issue( owner='test_owner', repo='test_repo', number=1, @@ -615,7 +632,9 @@ def test_guess_success(): ) ) ] - issue_handler = IssueHandler('owner', 'repo', 'token', mock_llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) with patch.object( LLM, 'completion', MagicMock(return_value=mock_completion_response) @@ -630,7 +649,7 @@ def test_guess_success(): def test_guess_success_with_thread_comments(): - mock_issue = GithubIssue( + mock_issue = Issue( owner='test_owner', repo='test_repo', number=1, @@ -653,7 +672,9 @@ def test_guess_success_with_thread_comments(): ) ) ] - issue_handler = IssueHandler('owner', 'repo', 'token', mock_llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) with patch.object( LLM, 'completion', MagicMock(return_value=mock_completion_response) @@ -669,7 +690,7 @@ def test_guess_success_with_thread_comments(): def test_instruction_with_thread_comments(): # Create an issue with thread comments - issue = GithubIssue( + issue = Issue( owner='test_owner', repo='test_repo', number=123, @@ -687,7 +708,9 @@ def test_instruction_with_thread_comments(): prompt = f.read() llm_config = LLMConfig(model='test', api_key='test') - issue_handler = IssueHandler('owner', 'repo', 'token', llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), llm_config + ) instruction, images_urls = issue_handler.get_instruction(issue, prompt, None) # Verify that thread comments are included in the instruction @@ -699,7 +722,7 @@ def test_instruction_with_thread_comments(): def test_guess_success_failure(): - mock_issue = GithubIssue( + mock_issue = Issue( owner='test_owner', repo='test_repo', number=1, @@ -722,7 +745,9 @@ def test_guess_success_failure(): ) ) ] - issue_handler = IssueHandler('owner', 'repo', 'token', mock_llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) with patch.object( LLM, 'completion', MagicMock(return_value=mock_completion_response) @@ -737,7 +762,7 @@ def test_guess_success_failure(): def test_guess_success_negative_case(): - mock_issue = GithubIssue( + mock_issue = Issue( owner='test_owner', repo='test_repo', number=1, @@ -755,7 +780,9 @@ def test_guess_success_negative_case(): ) ) ] - issue_handler = IssueHandler('owner', 'repo', 'token', mock_llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) with patch.object( LLM, 'completion', MagicMock(return_value=mock_completion_response) @@ -770,7 +797,7 @@ def test_guess_success_negative_case(): def test_guess_success_invalid_output(): - mock_issue = GithubIssue( + mock_issue = Issue( owner='test_owner', repo='test_repo', number=1, @@ -784,7 +811,9 @@ def test_guess_success_invalid_output(): mock_completion_response.choices = [ MagicMock(message=MagicMock(content='This is not a valid output')) ] - issue_handler = IssueHandler('owner', 'repo', 'token', mock_llm_config) + issue_handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) with patch.object( LLM, 'completion', MagicMock(return_value=mock_completion_response) @@ -803,7 +832,7 @@ def test_guess_success_invalid_output(): def test_download_pr_with_review_comments(): llm_config = LLMConfig(model='test', api_key='test') - handler = PRHandler('owner', 'repo', 'token', llm_config) + handler = ServiceContextPR(GithubPRHandler('owner', 'repo', 'token'), llm_config) mock_pr_response = MagicMock() mock_pr_response.json.side_effect = [ [ @@ -854,7 +883,7 @@ def get_mock_response(url, *args, **kwargs): assert len(issues) == 1 assert handler.issue_type == 'pr' - assert isinstance(issues[0], GithubIssue) + assert isinstance(issues[0], Issue) assert issues[0].number == 1 assert issues[0].title == 'PR 1' assert issues[0].head_branch == 'b1' @@ -870,7 +899,9 @@ def get_mock_response(url, *args, **kwargs): def test_download_issue_with_specific_comment(): llm_config = LLMConfig(model='test', api_key='test') - handler = IssueHandler('owner', 'repo', 'token', llm_config) + handler = ServiceContextIssue( + GithubIssueHandler('owner', 'repo', 'token'), llm_config + ) # Define the specific comment_id to filter specific_comment_id = 101 diff --git a/tests/unit/resolver/test_send_pull_request.py b/tests/unit/resolver/github/test_send_pull_request.py similarity index 89% rename from tests/unit/resolver/test_send_pull_request.py rename to tests/unit/resolver/github/test_send_pull_request.py index c03738cf9abf..62d5c5d8f4f2 100644 --- a/tests/unit/resolver/test_send_pull_request.py +++ b/tests/unit/resolver/github/test_send_pull_request.py @@ -5,8 +5,9 @@ import pytest from openhands.core.config import LLMConfig -from openhands.resolver.github_issue import ReviewThread -from openhands.resolver.resolver_output import GithubIssue, ResolverOutput +from openhands.resolver.interfaces.github import GithubIssueHandler +from openhands.resolver.interfaces.issue import ReviewThread +from openhands.resolver.resolver_output import Issue, ResolverOutput from openhands.resolver.send_pull_request import ( apply_patch, initialize_repo, @@ -14,10 +15,10 @@ make_commit, process_all_successful_issues, process_single_issue, - reply_to_comment, send_pull_request, update_existing_pull_request, ) +from openhands.resolver.utils import Platform @pytest.fixture @@ -36,8 +37,8 @@ def mock_output_dir(): @pytest.fixture -def mock_github_issue(): - return GithubIssue( +def mock_issue(): + return Issue( number=42, title='Test Issue', owner='test-owner', @@ -241,7 +242,7 @@ def test_initialize_repo(mock_output_dir): assert f.read() == 'hello world' -@patch('openhands.resolver.send_pull_request.reply_to_comment') +@patch('openhands.resolver.interfaces.github.GithubIssueHandler.reply_to_comment') @patch('requests.post') @patch('subprocess.run') @patch('openhands.resolver.send_pull_request.LLM') @@ -252,7 +253,7 @@ def test_update_existing_pull_request( mock_reply_to_comment, ): # Arrange: Set up test data - github_issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=1, @@ -261,8 +262,8 @@ def test_update_existing_pull_request( thread_ids=['comment1', 'comment2'], head_branch='test-branch', ) - github_token = 'test-token' - github_username = 'test-user' + token = 'test-token' + username = 'test-user' patch_dir = '/path/to/patch' additional_message = '["Fixed bug in function A", "Updated documentation for B"]' @@ -285,9 +286,10 @@ def test_update_existing_pull_request( # Act: Call the function without comment_message to test auto-generation result = update_existing_pull_request( - github_issue, - github_token, - github_username, + issue, + token, + username, + Platform.GITHUB, patch_dir, llm_config, comment_message=None, @@ -297,20 +299,20 @@ def test_update_existing_pull_request( # Assert: Check if the git push command was executed push_command = ( f'git -C {patch_dir} push ' - f'https://{github_username}:{github_token}@github.com/' - f'{github_issue.owner}/{github_issue.repo}.git {github_issue.head_branch}' + f'https://{username}:{token}@github.com/' + f'{issue.owner}/{issue.repo}.git {issue.head_branch}' ) mock_subprocess_run.assert_called_once_with( push_command, shell=True, capture_output=True, text=True ) # Assert: Check if the auto-generated comment was posted to the PR - comment_url = f'https://api.github.com/repos/{github_issue.owner}/{github_issue.repo}/issues/{github_issue.number}/comments' + comment_url = f'https://api.github.com/repos/{issue.owner}/{issue.repo}/issues/{issue.number}/comments' expected_comment = 'This is an issue resolution.' mock_requests_post.assert_called_once_with( comment_url, headers={ - 'Authorization': f'token {github_token}', + 'Authorization': f'token {token}', 'Accept': 'application/vnd.github.v3+json', }, json={'body': expected_comment}, @@ -319,15 +321,14 @@ def test_update_existing_pull_request( # Assert: Check if the reply_to_comment function was called for each thread ID mock_reply_to_comment.assert_has_calls( [ - call(github_token, 'comment1', 'Fixed bug in function A'), - call(github_token, 'comment2', 'Updated documentation for B'), + call(issue.number, 'comment1', 'Fixed bug in function A'), + call(issue.number, 'comment2', 'Updated documentation for B'), ] ) # Assert: Check the returned PR URL assert ( - result - == f'https://github.com/{github_issue.owner}/{github_issue.repo}/pull/{github_issue.number}' + result == f'https://github.com/{issue.owner}/{issue.repo}/pull/{issue.number}' ) @@ -351,7 +352,8 @@ def test_send_pull_request( mock_get, mock_post, mock_run, - mock_github_issue, + mock_issue, + mock_llm_config, mock_output_dir, pr_type, target_branch, @@ -383,9 +385,10 @@ def test_send_pull_request( # Call the function result = send_pull_request( - github_issue=mock_github_issue, - github_token='test-token', - github_username='test-user', + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=repo_path, pr_type=pr_type, target_branch=target_branch, @@ -441,7 +444,7 @@ def test_send_pull_request( @patch('requests.post') @patch('requests.get') def test_send_pull_request_with_reviewer( - mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir + mock_get, mock_post, mock_run, mock_issue, mock_output_dir, mock_llm_config ): repo_path = os.path.join(mock_output_dir, 'repo') reviewer = 'test-reviewer' @@ -472,9 +475,10 @@ def test_send_pull_request_with_reviewer( # Call the function with reviewer result = send_pull_request( - github_issue=mock_github_issue, - github_token='test-token', - github_username='test-user', + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=repo_path, pr_type='ready', reviewer=reviewer, @@ -504,7 +508,7 @@ def test_send_pull_request_with_reviewer( @patch('requests.post') @patch('requests.get') def test_send_pull_request_target_branch_with_fork( - mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir + mock_get, mock_post, mock_run, mock_issue, mock_output_dir ): """Test that target_branch works correctly when using a fork.""" repo_path = os.path.join(mock_output_dir, 'repo') @@ -528,10 +532,11 @@ def test_send_pull_request_target_branch_with_fork( ] # Call the function with fork_owner and target_branch - result = send_pull_request( - github_issue=mock_github_issue, - github_token='test-token', - github_username='test-user', + send_pull_request( + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=repo_path, pr_type='ready', fork_owner=fork_owner, @@ -540,27 +545,34 @@ def test_send_pull_request_target_branch_with_fork( # Assert API calls assert mock_get.call_count == 2 - + # Verify target branch was checked in original repo, not fork target_branch_check = mock_get.call_args_list[1] - assert target_branch_check[0][0] == f'https://api.github.com/repos/test-owner/test-repo/branches/{target_branch}' + assert ( + target_branch_check[0][0] + == f'https://api.github.com/repos/test-owner/test-repo/branches/{target_branch}' + ) # Check PR creation mock_post.assert_called_once() post_data = mock_post.call_args[1]['json'] assert post_data['base'] == target_branch # PR should target the specified branch - assert post_data['head'] == 'openhands-fix-issue-42' # Branch name should be standard + assert ( + post_data['head'] == 'openhands-fix-issue-42' + ) # Branch name should be standard # Check that push was to fork push_call = mock_run.call_args_list[1] - assert f'https://test-user:test-token@github.com/{fork_owner}/test-repo.git' in str(push_call) + assert f'https://test-user:test-token@github.com/{fork_owner}/test-repo.git' in str( + push_call + ) @patch('subprocess.run') @patch('requests.post') @patch('requests.get') def test_send_pull_request_target_branch_with_additional_message( - mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir + mock_get, mock_post, mock_run, mock_issue, mock_output_dir ): """Test that target_branch works correctly with additional PR message.""" repo_path = os.path.join(mock_output_dir, 'repo') @@ -584,10 +596,11 @@ def test_send_pull_request_target_branch_with_additional_message( ] # Call the function with target_branch and additional_message - result = send_pull_request( - github_issue=mock_github_issue, - github_token='test-token', - github_username='test-user', + send_pull_request( + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=repo_path, pr_type='ready', target_branch=target_branch, @@ -607,7 +620,7 @@ def test_send_pull_request_target_branch_with_additional_message( @patch('requests.get') def test_send_pull_request_invalid_target_branch( - mock_get, mock_github_issue, mock_output_dir + mock_get, mock_issue, mock_output_dir, mock_llm_config ): """Test that an error is raised when specifying a non-existent target branch""" repo_path = os.path.join(mock_output_dir, 'repo') @@ -623,9 +636,10 @@ def test_send_pull_request_invalid_target_branch( ValueError, match='Target branch nonexistent-branch does not exist' ): send_pull_request( - github_issue=mock_github_issue, - github_token='test-token', - github_username='test-user', + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=repo_path, pr_type='ready', target_branch='nonexistent-branch', @@ -639,7 +653,7 @@ def test_send_pull_request_invalid_target_branch( @patch('requests.post') @patch('requests.get') def test_send_pull_request_git_push_failure( - mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir + mock_get, mock_post, mock_run, mock_issue, mock_output_dir, mock_llm_config ): repo_path = os.path.join(mock_output_dir, 'repo') @@ -657,9 +671,10 @@ def test_send_pull_request_git_push_failure( RuntimeError, match='Failed to push changes to the remote repository' ): send_pull_request( - github_issue=mock_github_issue, - github_token='test-token', - github_username='test-user', + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=repo_path, pr_type='ready', ) @@ -697,7 +712,7 @@ def test_send_pull_request_git_push_failure( @patch('requests.post') @patch('requests.get') def test_send_pull_request_permission_error( - mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir + mock_get, mock_post, mock_run, mock_issue, mock_output_dir, mock_llm_config ): repo_path = os.path.join(mock_output_dir, 'repo') @@ -716,9 +731,10 @@ def test_send_pull_request_permission_error( RuntimeError, match='Failed to create pull request due to missing permissions.' ): send_pull_request( - github_issue=mock_github_issue, - github_token='test-token', - github_username='test-user', + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=repo_path, pr_type='ready', ) @@ -729,12 +745,17 @@ def test_send_pull_request_permission_error( @patch('requests.post') -def test_reply_to_comment(mock_post): +def test_reply_to_comment(mock_post, mock_issue): # Arrange: set up the test data - github_token = 'test_token' + token = 'test_token' comment_id = 'test_comment_id' reply = 'This is a test reply.' + # Create an instance of GithubIssueHandler + handler = GithubIssueHandler( + owner='test-owner', repo='test-repo', token=token, username='test-user' + ) + # Mock the response from the GraphQL API mock_response = MagicMock() mock_response.status_code = 200 @@ -753,7 +774,7 @@ def test_reply_to_comment(mock_post): mock_post.return_value = mock_response # Act: call the function - reply_to_comment(github_token, comment_id, reply) + handler.reply_to_comment(mock_issue.number, comment_id, reply) # Assert: check that the POST request was made with the correct parameters query = """ @@ -778,7 +799,7 @@ def test_reply_to_comment(mock_post): 'https://api.github.com/graphql', json={'query': query, 'variables': expected_variables}, headers={ - 'Authorization': f'Bearer {github_token}', + 'Authorization': f'Bearer {token}', 'Content-Type': 'application/json', }, ) @@ -800,12 +821,12 @@ def test_process_single_pr_update( mock_llm_config, ): # Initialize test data - github_token = 'test_token' - github_username = 'test_user' + token = 'test_token' + username = 'test_user' pr_type = 'draft' resolver_output = ResolverOutput( - issue=GithubIssue( + issue=Issue( owner='test-owner', repo='test-repo', number=1, @@ -838,8 +859,9 @@ def test_process_single_pr_update( process_single_issue( mock_output_dir, resolver_output, - github_token, - github_username, + token, + username, + Platform.GITHUB, pr_type, mock_llm_config, None, @@ -855,9 +877,10 @@ def test_process_single_pr_update( f'{mock_output_dir}/patches/pr_1', resolver_output.issue, 'pr' ) mock_update_existing_pull_request.assert_called_once_with( - github_issue=resolver_output.issue, - github_token=github_token, - github_username=github_username, + issue=resolver_output.issue, + token=token, + username=username, + platform=Platform.GITHUB, patch_dir=f'{mock_output_dir}/patches/pr_1', additional_message='[Test success 1]', llm_config=mock_llm_config, @@ -877,12 +900,13 @@ def test_process_single_issue( mock_llm_config, ): # Initialize test data - github_token = 'test_token' - github_username = 'test_user' + token = 'test_token' + username = 'test_user' pr_type = 'draft' + platform = Platform.GITHUB resolver_output = ResolverOutput( - issue=GithubIssue( + issue=Issue( owner='test-owner', repo='test-repo', number=1, @@ -911,8 +935,9 @@ def test_process_single_issue( process_single_issue( mock_output_dir, resolver_output, - github_token, - github_username, + token, + username, + platform, pr_type, mock_llm_config, None, @@ -929,9 +954,10 @@ def test_process_single_issue( f'{mock_output_dir}/patches/issue_1', resolver_output.issue, 'issue' ) mock_send_pull_request.assert_called_once_with( - github_issue=resolver_output.issue, - github_token=github_token, - github_username=github_username, + issue=resolver_output.issue, + token=token, + username=username, + platform=platform, patch_dir=f'{mock_output_dir}/patches/issue_1', pr_type=pr_type, fork_owner=None, @@ -955,12 +981,12 @@ def test_process_single_issue_unsuccessful( mock_llm_config, ): # Initialize test data - github_token = 'test_token' - github_username = 'test_user' + token = 'test_token' + username = 'test_user' pr_type = 'draft' resolver_output = ResolverOutput( - issue=GithubIssue( + issue=Issue( owner='test-owner', repo='test-repo', number=1, @@ -983,8 +1009,9 @@ def test_process_single_issue_unsuccessful( process_single_issue( mock_output_dir, resolver_output, - github_token, - github_username, + token, + username, + Platform.GITHUB, pr_type, mock_llm_config, None, @@ -1006,7 +1033,7 @@ def test_process_all_successful_issues( ): # Create ResolverOutput objects with properly initialized GithubIssue instances resolver_output_1 = ResolverOutput( - issue=GithubIssue( + issue=Issue( owner='test-owner', repo='test-repo', number=1, @@ -1026,7 +1053,7 @@ def test_process_all_successful_issues( ) resolver_output_2 = ResolverOutput( - issue=GithubIssue( + issue=Issue( owner='test-owner', repo='test-repo', number=2, @@ -1046,7 +1073,7 @@ def test_process_all_successful_issues( ) resolver_output_3 = ResolverOutput( - issue=GithubIssue( + issue=Issue( owner='test-owner', repo='test-repo', number=3, @@ -1074,8 +1101,9 @@ def test_process_all_successful_issues( # Call the function process_all_successful_issues( 'output_dir', - 'github_token', - 'github_username', + 'token', + 'username', + Platform.GITHUB, 'draft', mock_llm_config, # llm_config None, # fork_owner @@ -1090,8 +1118,9 @@ def test_process_all_successful_issues( call( 'output_dir', resolver_output_1, - 'github_token', - 'github_username', + 'token', + 'username', + Platform.GITHUB, 'draft', mock_llm_config, None, @@ -1101,8 +1130,9 @@ def test_process_all_successful_issues( call( 'output_dir', resolver_output_3, - 'github_token', - 'github_username', + 'token', + 'username', + Platform.GITHUB, 'draft', mock_llm_config, None, @@ -1118,7 +1148,7 @@ def test_process_all_successful_issues( @patch('requests.get') @patch('subprocess.run') def test_send_pull_request_branch_naming( - mock_run, mock_get, mock_github_issue, mock_output_dir + mock_run, mock_get, mock_issue, mock_output_dir, mock_llm_config ): repo_path = os.path.join(mock_output_dir, 'repo') @@ -1138,9 +1168,10 @@ def test_send_pull_request_branch_naming( # Call the function result = send_pull_request( - github_issue=mock_github_issue, - github_token='test-token', - github_username='test-user', + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITHUB, patch_dir=repo_path, pr_type='branch', ) @@ -1181,11 +1212,13 @@ def test_send_pull_request_branch_naming( @patch('openhands.resolver.send_pull_request.process_all_successful_issues') @patch('openhands.resolver.send_pull_request.process_single_issue') @patch('openhands.resolver.send_pull_request.load_single_resolver_output') +@patch('openhands.resolver.send_pull_request.identify_token') @patch('os.path.exists') @patch('os.getenv') def test_main( mock_getenv, mock_path_exists, + mock_identify_token, mock_load_single_resolver_output, mock_process_single_issue, mock_process_all_successful_issues, @@ -1195,8 +1228,8 @@ def test_main( # Setup mock parser mock_args = MagicMock() - mock_args.github_token = None - mock_args.github_username = 'mock_username' + mock_args.token = None + mock_args.username = 'mock_username' mock_args.output_dir = '/mock/output' mock_args.pr_type = 'draft' mock_args.issue_number = '42' @@ -1222,9 +1255,13 @@ def test_main( mock_resolver_output = MagicMock() mock_load_single_resolver_output.return_value = mock_resolver_output + mock_identify_token.return_value = Platform.GITHUB + # Run main function main() + mock_identify_token.assert_called_with('mock_token') + llm_config = LLMConfig( model=mock_args.llm_model, base_url=mock_args.llm_base_url, @@ -1237,6 +1274,7 @@ def test_main( mock_resolver_output, 'mock_token', 'mock_username', + Platform.GITHUB, 'draft', llm_config, None, @@ -1259,6 +1297,7 @@ def test_main( '/mock/output', 'mock_token', 'mock_username', + Platform.GITHUB, 'draft', llm_config, None, @@ -1269,12 +1308,17 @@ def test_main( with pytest.raises(ValueError): main() + # Test for invalid token + mock_identify_token.return_value = Platform.INVALID + with pytest.raises(ValueError, match='Token is invalid.'): + main() + @patch('subprocess.run') def test_make_commit_escapes_issue_title(mock_subprocess_run): # Setup repo_dir = '/path/to/repo' - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=42, @@ -1314,7 +1358,7 @@ def test_make_commit_escapes_issue_title(mock_subprocess_run): def test_make_commit_no_changes(mock_subprocess_run): # Setup repo_dir = '/path/to/repo' - issue = GithubIssue( + issue = Issue( owner='test-owner', repo='test-repo', number=42, diff --git a/tests/unit/resolver/gitlab/test_gitlab_guess_success.py b/tests/unit/resolver/gitlab/test_gitlab_guess_success.py new file mode 100644 index 000000000000..9c4991c0913d --- /dev/null +++ b/tests/unit/resolver/gitlab/test_gitlab_guess_success.py @@ -0,0 +1,202 @@ +import json +from unittest.mock import MagicMock, patch + +from openhands.core.config import LLMConfig +from openhands.events.action.message import MessageAction +from openhands.llm import LLM +from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler +from openhands.resolver.interfaces.issue import Issue +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) + + +def test_guess_success_multiline_explanation(): + # Mock data + issue = Issue( + owner='test', + repo='test', + number=1, + title='Test Issue', + body='Test body', + thread_comments=None, + review_comments=None, + ) + history = [MessageAction(content='Test message')] + llm_config = LLMConfig(model='test', api_key='test') + + # Create a mock response with multi-line explanation + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +The PR successfully addressed the issue by: +- Fixed bug A +- Added test B +- Updated documentation C + +Automatic fix generated by OpenHands 🙌""" + ) + ) + ] + + # Use patch to mock the LLM completion call + with patch.object(LLM, 'completion', return_value=mock_response) as mock_completion: + # Create a handler instance + handler = ServiceContextIssue( + GitlabIssueHandler('test', 'test', 'test'), llm_config + ) + + # Call guess_success + success, _, explanation = handler.guess_success(issue, history) + + # Verify the results + assert success is True + assert 'The PR successfully addressed the issue by:' in explanation + assert 'Fixed bug A' in explanation + assert 'Added test B' in explanation + assert 'Updated documentation C' in explanation + assert 'Automatic fix generated by OpenHands' in explanation + + # Verify that LLM completion was called exactly once + mock_completion.assert_called_once() + + +def test_pr_handler_guess_success_with_thread_comments(): + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR(GitlabPRHandler('test', 'test', 'test'), llm_config) + + # Create a mock issue with thread comments but no review comments + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test PR', + body='Test Body', + thread_comments=['First comment', 'Second comment'], + closing_issues=['Issue description'], + review_comments=None, + thread_ids=None, + head_branch='test-branch', + ) + + # Create mock history + history = [MessageAction(content='Fixed the issue by implementing X and Y')] + + # Create mock LLM config + llm_config = LLMConfig(model='test-model', api_key='test-key') + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +The changes successfully address the feedback.""" + ) + ) + ] + + # Test the guess_success method + with patch.object(LLM, 'completion', return_value=mock_response): + success, success_list, explanation = handler.guess_success(issue, history) + + # Verify the results + assert success is True + assert success_list == [True] + assert 'successfully address' in explanation + assert len(json.loads(explanation)) == 1 + + +def test_pr_handler_guess_success_only_review_comments(): + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create a mock issue with only review comments + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test PR', + body='Test Body', + thread_comments=None, + closing_issues=['Issue description'], + review_comments=['Please fix the formatting', 'Add more tests'], + thread_ids=None, + head_branch='test-branch', + ) + + # Create mock history + history = [MessageAction(content='Fixed the formatting and added more tests')] + + # Create mock LLM config + llm_config = LLMConfig(model='test-model', api_key='test-key') + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +The changes successfully address the review comments.""" + ) + ) + ] + + # Test the guess_success method + with patch.object(LLM, 'completion', return_value=mock_response): + success, success_list, explanation = handler.guess_success(issue, history) + + # Verify the results + assert success is True + assert success_list == [True] + assert ( + '["The changes successfully address the review comments."]' in explanation + ) + + +def test_pr_handler_guess_success_no_comments(): + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR(GitlabPRHandler('test', 'test', 'test'), llm_config) + + # Create a mock issue with no comments + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test PR', + body='Test Body', + thread_comments=None, + closing_issues=['Issue description'], + review_comments=None, + thread_ids=None, + head_branch='test-branch', + ) + + # Create mock history + history = [MessageAction(content='Fixed the issue')] + + # Create mock LLM config + llm_config = LLMConfig(model='test-model', api_key='test-key') + + # Test that it returns appropriate message when no comments are present + success, success_list, explanation = handler.guess_success(issue, history) + assert success is False + assert success_list is None + assert explanation == 'No feedback was found to process' diff --git a/tests/unit/resolver/gitlab/test_gitlab_issue_handler.py b/tests/unit/resolver/gitlab/test_gitlab_issue_handler.py new file mode 100644 index 000000000000..6b5a5c6de609 --- /dev/null +++ b/tests/unit/resolver/gitlab/test_gitlab_issue_handler.py @@ -0,0 +1,683 @@ +from unittest.mock import MagicMock, patch + +from openhands.core.config import LLMConfig +from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler +from openhands.resolver.interfaces.issue import ReviewThread +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) + + +def test_get_converted_issues_initializes_review_comments(): + # Mock the necessary dependencies + with patch('requests.get') as mock_get: + # Mock the response for issues + mock_issues_response = MagicMock() + mock_issues_response.json.return_value = [ + {'iid': 1, 'title': 'Test Issue', 'description': 'Test Body'} + ] + # Mock the response for comments + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [] + + # Set up the mock to return different responses for different calls + # First call is for issues, second call is for comments + mock_get.side_effect = [ + mock_issues_response, + mock_comments_response, + mock_comments_response, + ] # Need two comment responses because we make two API calls + + # Create an instance of IssueHandler + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextIssue( + GitlabIssueHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Get converted issues + issues = handler.get_converted_issues(issue_numbers=[1]) + + # Verify that we got exactly one issue + assert len(issues) == 1 + + # Verify that review_comments is initialized as None + assert issues[0].review_comments is None + + # Verify other fields are set correctly + assert issues[0].number == 1 + assert issues[0].title == 'Test Issue' + assert issues[0].body == 'Test Body' + assert issues[0].owner == 'test-owner' + assert issues[0].repo == 'test-repo' + + +def test_get_converted_issues_handles_empty_body(): + # Mock the necessary dependencies + with patch('requests.get') as mock_get: + # Mock the response for issues + mock_issues_response = MagicMock() + mock_issues_response.json.return_value = [ + {'iid': 1, 'title': 'Test Issue', 'description': None} + ] + # Mock the response for comments + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [] + # Set up the mock to return different responses + mock_get.side_effect = [ + mock_issues_response, + mock_comments_response, + mock_comments_response, + ] + + # Create an instance of IssueHandler + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextIssue( + GitlabIssueHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Get converted issues + issues = handler.get_converted_issues(issue_numbers=[1]) + + # Verify that we got exactly one issue + assert len(issues) == 1 + + # Verify that body is empty string when None + assert issues[0].body == '' + + # Verify other fields are set correctly + assert issues[0].number == 1 + assert issues[0].title == 'Test Issue' + assert issues[0].owner == 'test-owner' + assert issues[0].repo == 'test-repo' + + # Verify that review_comments is initialized as None + assert issues[0].review_comments is None + + +def test_pr_handler_get_converted_issues_with_comments(): + # Mock the necessary dependencies + with patch('requests.get') as mock_get: + # Mock the response for PRs + mock_prs_response = MagicMock() + mock_prs_response.json.return_value = [ + { + 'iid': 1, + 'title': 'Test PR', + 'description': 'Test Body fixes #1', + 'source_branch': 'test-branch', + } + ] + + # Mock the response for PR comments + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [ + {'body': 'First comment', 'resolvable': True, 'system': False}, + {'body': 'Second comment', 'resolvable': True, 'system': False}, + ] + + # Mock the response for PR metadata (GraphQL) + mock_graphql_response = MagicMock() + mock_graphql_response.json.return_value = { + 'data': { + 'project': { + 'mergeRequest': { + 'discussions': {'edges': []}, + } + } + } + } + + # Set up the mock to return different responses + # We need to return empty responses for subsequent pages + mock_empty_response = MagicMock() + mock_empty_response.json.return_value = [] + + # Mock the response for fetching the external issue referenced in PR body + mock_external_issue_response = MagicMock() + mock_external_issue_response.json.return_value = { + 'description': 'This is additional context from an externally referenced issue.' + } + + mock_get.side_effect = [ + mock_prs_response, # First call for PRs + mock_empty_response, # Second call for PRs (empty page) + mock_empty_response, # Third call for related issues + mock_comments_response, # Fourth call for PR comments + mock_empty_response, # Fifth call for PR comments (empty page) + mock_external_issue_response, # Mock response for the external issue reference #1 + ] + + # Mock the post request for GraphQL + with patch('requests.post') as mock_post: + mock_post.return_value = mock_graphql_response + + # Create an instance of PRHandler + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Get converted issues + prs = handler.get_converted_issues(issue_numbers=[1]) + + # Verify that we got exactly one PR + assert len(prs) == 1 + + # Verify that thread_comments are set correctly + assert prs[0].thread_comments == ['First comment', 'Second comment'] + + # Verify other fields are set correctly + assert prs[0].number == 1 + assert prs[0].title == 'Test PR' + assert prs[0].body == 'Test Body fixes #1' + assert prs[0].owner == 'test-owner' + assert prs[0].repo == 'test-repo' + assert prs[0].head_branch == 'test-branch' + assert prs[0].closing_issues == [ + 'This is additional context from an externally referenced issue.' + ] + + +def test_get_issue_comments_with_specific_comment_id(): + # Mock the necessary dependencies + with patch('requests.get') as mock_get: + # Mock the response for comments + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [ + {'id': 123, 'body': 'First comment', 'resolvable': True, 'system': False}, + {'id': 456, 'body': 'Second comment', 'resolvable': True, 'system': False}, + ] + + mock_get.return_value = mock_comments_response + + # Create an instance of IssueHandler + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextIssue( + GitlabIssueHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Get comments with a specific comment_id + specific_comment = handler.get_issue_comments(issue_number=1, comment_id=123) + + # Verify only the specific comment is returned + assert specific_comment == ['First comment'] + + +def test_pr_handler_get_converted_issues_with_specific_thread_comment(): + # Define the specific comment_id to filter + specific_comment_id = 123 + + # Mock GraphQL response for review threads + with patch('requests.get') as mock_get: + # Mock the response for PRs + mock_prs_response = MagicMock() + mock_prs_response.json.return_value = [ + { + 'iid': 1, + 'title': 'Test PR', + 'description': 'Test Body', + 'source_branch': 'test-branch', + } + ] + + # Mock the response for PR comments + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [ + {'body': 'First comment', 'id': 123, 'resolvable': True, 'system': False}, + {'body': 'Second comment', 'id': 124, 'resolvable': True, 'system': False}, + ] + + # Mock the response for PR metadata (GraphQL) + mock_graphql_response = MagicMock() + mock_graphql_response.json.return_value = { + 'data': { + 'project': { + 'mergeRequest': { + 'discussions': { + 'edges': [ + { + 'node': { + 'id': 'review-thread-1', + 'resolved': False, + 'resolvable': True, + 'notes': { + 'nodes': [ + { + 'id': 'GID/121', + 'body': 'Specific review comment', + 'position': { + 'filePath': 'file1.txt', + }, + }, + { + 'id': 'GID/456', + 'body': 'Another review comment', + 'position': { + 'filePath': 'file2.txt', + }, + }, + ] + }, + } + } + ] + }, + } + } + } + } + + # Set up the mock to return different responses + # We need to return empty responses for subsequent pages + mock_empty_response = MagicMock() + mock_empty_response.json.return_value = [] + + mock_get.side_effect = [ + mock_prs_response, # First call for PRs + mock_empty_response, # Second call for PRs (empty page) + mock_empty_response, # Third call for related issues + mock_comments_response, # Fourth call for PR comments + mock_empty_response, # Fifth call for PR comments (empty page) + ] + + # Mock the post request for GraphQL + with patch('requests.post') as mock_post: + mock_post.return_value = mock_graphql_response + + # Create an instance of PRHandler + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Get converted issues + prs = handler.get_converted_issues( + issue_numbers=[1], comment_id=specific_comment_id + ) + + # Verify that we got exactly one PR + assert len(prs) == 1 + + # Verify that thread_comments are set correctly + assert prs[0].thread_comments == ['First comment'] + assert prs[0].review_comments is None + assert prs[0].review_threads == [] + + # Verify other fields are set correctly + assert prs[0].number == 1 + assert prs[0].title == 'Test PR' + assert prs[0].body == 'Test Body' + assert prs[0].owner == 'test-owner' + assert prs[0].repo == 'test-repo' + assert prs[0].head_branch == 'test-branch' + + +def test_pr_handler_get_converted_issues_with_specific_review_thread_comment(): + # Define the specific comment_id to filter + specific_comment_id = 123 + + # Mock GraphQL response for review threads + with patch('requests.get') as mock_get: + # Mock the response for PRs + mock_prs_response = MagicMock() + mock_prs_response.json.return_value = [ + { + 'iid': 1, + 'title': 'Test PR', + 'description': 'Test Body', + 'source_branch': 'test-branch', + } + ] + + # Mock the response for PR comments + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [ + { + 'description': 'First comment', + 'id': 120, + 'resolvable': True, + 'system': False, + }, + { + 'description': 'Second comment', + 'id': 124, + 'resolvable': True, + 'system': False, + }, + ] + + # Mock the response for PR metadata (GraphQL) + mock_graphql_response = MagicMock() + mock_graphql_response.json.return_value = { + 'data': { + 'project': { + 'mergeRequest': { + 'discussions': { + 'edges': [ + { + 'node': { + 'id': 'review-thread-1', + 'resolved': False, + 'resolvable': True, + 'notes': { + 'nodes': [ + { + 'id': f'GID/{specific_comment_id}', + 'body': 'Specific review comment', + 'position': { + 'filePath': 'file1.txt', + }, + }, + { + 'id': 'GID/456', + 'body': 'Another review comment', + 'position': { + 'filePath': 'file1.txt', + }, + }, + ] + }, + } + } + ] + }, + } + } + } + } + + # Set up the mock to return different responses + # We need to return empty responses for subsequent pages + mock_empty_response = MagicMock() + mock_empty_response.json.return_value = [] + + mock_get.side_effect = [ + mock_prs_response, # First call for PRs + mock_empty_response, # Second call for PRs (empty page) + mock_empty_response, # Third call for related issues + mock_comments_response, # Fourth call for PR comments + mock_empty_response, # Fifth call for PR comments (empty page) + ] + + # Mock the post request for GraphQL + with patch('requests.post') as mock_post: + mock_post.return_value = mock_graphql_response + + # Create an instance of PRHandler + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Get converted issues + prs = handler.get_converted_issues( + issue_numbers=[1], comment_id=specific_comment_id + ) + + # Verify that we got exactly one PR + assert len(prs) == 1 + + # Verify that thread_comments are set correctly + assert prs[0].thread_comments is None + assert prs[0].review_comments is None + assert len(prs[0].review_threads) == 1 + assert isinstance(prs[0].review_threads[0], ReviewThread) + assert ( + prs[0].review_threads[0].comment + == 'Specific review comment\n---\nlatest feedback:\nAnother review comment\n' + ) + assert prs[0].review_threads[0].files == ['file1.txt'] + + # Verify other fields are set correctly + assert prs[0].number == 1 + assert prs[0].title == 'Test PR' + assert prs[0].body == 'Test Body' + assert prs[0].owner == 'test-owner' + assert prs[0].repo == 'test-repo' + assert prs[0].head_branch == 'test-branch' + + +def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs(): + # Define the specific comment_id to filter + specific_comment_id = 123 + + # Mock GraphQL response for review threads + with patch('requests.get') as mock_get: + # Mock the response for PRs + mock_prs_response = MagicMock() + mock_prs_response.json.return_value = [ + { + 'iid': 1, + 'title': 'Test PR fixes #3', + 'description': 'Test Body', + 'source_branch': 'test-branch', + } + ] + + # Mock the response for PR comments + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [ + { + 'description': 'First comment', + 'id': 120, + 'resolvable': True, + 'system': False, + }, + { + 'description': 'Second comment', + 'id': 124, + 'resolvable': True, + 'system': False, + }, + ] + + # Mock the response for PR metadata (GraphQL) + mock_graphql_response = MagicMock() + mock_graphql_response.json.return_value = { + 'data': { + 'project': { + 'mergeRequest': { + 'discussions': { + 'edges': [ + { + 'node': { + 'id': 'review-thread-1', + 'resolved': False, + 'resolvable': True, + 'notes': { + 'nodes': [ + { + 'id': f'GID/{specific_comment_id}', + 'body': 'Specific review comment that references #6', + 'position': { + 'filePath': 'file1.txt', + }, + }, + { + 'id': 'GID/456', + 'body': 'Another review comment referencing #7', + 'position': { + 'filePath': 'file2.txt', + }, + }, + ] + }, + } + } + ] + }, + } + } + } + } + + # Set up the mock to return different responses + # We need to return empty responses for subsequent pages + mock_empty_response = MagicMock() + mock_empty_response.json.return_value = [] + + # Mock the response for fetching the external issue referenced in PR body + mock_external_issue_response_in_body = MagicMock() + mock_external_issue_response_in_body.json.return_value = { + 'description': 'External context #1.' + } + + # Mock the response for fetching the external issue referenced in review thread + mock_external_issue_response_review_thread = MagicMock() + mock_external_issue_response_review_thread.json.return_value = { + 'description': 'External context #2.' + } + + mock_get.side_effect = [ + mock_prs_response, # First call for PRs + mock_empty_response, # Second call for PRs (empty page) + mock_empty_response, # Third call for related issues + mock_comments_response, # Fourth call for PR comments + mock_empty_response, # Fifth call for PR comments (empty page) + mock_external_issue_response_in_body, + mock_external_issue_response_review_thread, + ] + + # Mock the post request for GraphQL + with patch('requests.post') as mock_post: + mock_post.return_value = mock_graphql_response + + # Create an instance of PRHandler + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Get converted issues + prs = handler.get_converted_issues( + issue_numbers=[1], comment_id=specific_comment_id + ) + + # Verify that we got exactly one PR + assert len(prs) == 1 + + # Verify that thread_comments are set correctly + assert prs[0].thread_comments is None + assert prs[0].review_comments is None + assert len(prs[0].review_threads) == 1 + assert isinstance(prs[0].review_threads[0], ReviewThread) + assert ( + prs[0].review_threads[0].comment + == 'Specific review comment that references #6\n---\nlatest feedback:\nAnother review comment referencing #7\n' + ) + assert prs[0].closing_issues == [ + 'External context #1.', + 'External context #2.', + ] # Only includes references inside comment ID and body PR + + # Verify other fields are set correctly + assert prs[0].number == 1 + assert prs[0].title == 'Test PR fixes #3' + assert prs[0].body == 'Test Body' + assert prs[0].owner == 'test-owner' + assert prs[0].repo == 'test-repo' + assert prs[0].head_branch == 'test-branch' + + +def test_pr_handler_get_converted_issues_with_duplicate_issue_refs(): + # Mock the necessary dependencies + with patch('requests.get') as mock_get: + # Mock the response for PRs + mock_prs_response = MagicMock() + mock_prs_response.json.return_value = [ + { + 'iid': 1, + 'title': 'Test PR', + 'description': 'Test Body fixes #1', + 'source_branch': 'test-branch', + } + ] + + # Mock the response for PR comments + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [ + { + 'body': 'First comment addressing #1', + 'resolvable': True, + 'system': False, + }, + { + 'body': 'Second comment addressing #2', + 'resolvable': True, + 'system': False, + }, + ] + + # Mock the response for PR metadata (GraphQL) + mock_graphql_response = MagicMock() + mock_graphql_response.json.return_value = { + 'data': { + 'project': { + 'mergeRequest': { + 'discussions': {'edges': []}, + } + } + } + } + + # Set up the mock to return different responses + # We need to return empty responses for subsequent pages + mock_empty_response = MagicMock() + mock_empty_response.json.return_value = [] + + # Mock the response for fetching the external issue referenced in PR body + mock_external_issue_response_in_body = MagicMock() + mock_external_issue_response_in_body.json.return_value = { + 'description': 'External context #1.' + } + + # Mock the response for fetching the external issue referenced in review thread + mock_external_issue_response_in_comment = MagicMock() + mock_external_issue_response_in_comment.json.return_value = { + 'description': 'External context #2.' + } + + mock_get.side_effect = [ + mock_prs_response, # First call for PRs + mock_empty_response, # Second call for PRs (empty page) + mock_empty_response, # Third call for related issues + mock_comments_response, # Fourth call for PR comments + mock_empty_response, # Fifth call for PR comments (empty page) + mock_external_issue_response_in_body, # Mock response for the external issue reference #1 + mock_external_issue_response_in_comment, + ] + + # Mock the post request for GraphQL + with patch('requests.post') as mock_post: + mock_post.return_value = mock_graphql_response + + # Create an instance of PRHandler + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Get converted issues + prs = handler.get_converted_issues(issue_numbers=[1]) + + # Verify that we got exactly one PR + assert len(prs) == 1 + + # Verify that thread_comments are set correctly + assert prs[0].thread_comments == [ + 'First comment addressing #1', + 'Second comment addressing #2', + ] + + # Verify other fields are set correctly + assert prs[0].number == 1 + assert prs[0].title == 'Test PR' + assert prs[0].body == 'Test Body fixes #1' + assert prs[0].owner == 'test-owner' + assert prs[0].repo == 'test-repo' + assert prs[0].head_branch == 'test-branch' + assert prs[0].closing_issues == [ + 'External context #1.', + 'External context #2.', + ] diff --git a/tests/unit/resolver/gitlab/test_gitlab_issue_handler_error_handling.py b/tests/unit/resolver/gitlab/test_gitlab_issue_handler_error_handling.py new file mode 100644 index 000000000000..66978ebc8984 --- /dev/null +++ b/tests/unit/resolver/gitlab/test_gitlab_issue_handler_error_handling.py @@ -0,0 +1,283 @@ +from unittest.mock import MagicMock, patch + +import pytest +import requests +from litellm.exceptions import RateLimitError + +from openhands.core.config import LLMConfig +from openhands.events.action.message import MessageAction +from openhands.llm.llm import LLM +from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler +from openhands.resolver.interfaces.issue import Issue +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) + + +@pytest.fixture(autouse=True) +def mock_logger(monkeypatch): + # suppress logging of completion data to file + mock_logger = MagicMock() + monkeypatch.setattr('openhands.llm.debug_mixin.llm_prompt_logger', mock_logger) + monkeypatch.setattr('openhands.llm.debug_mixin.llm_response_logger', mock_logger) + return mock_logger + + +@pytest.fixture +def default_config(): + return LLMConfig( + model='gpt-4o', + api_key='test_key', + num_retries=2, + retry_min_wait=1, + retry_max_wait=2, + ) + + +def test_handle_nonexistent_issue_reference(): + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Mock the requests.get to simulate a 404 error + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + '404 Client Error: Not Found' + ) + + with patch('requests.get', return_value=mock_response): + # Call the method with a non-existent issue reference + result = handler._strategy.get_context_from_external_issues_references( + closing_issues=[], + closing_issue_numbers=[], + issue_body='This references #999999', # Non-existent issue + review_comments=[], + review_threads=[], + thread_comments=None, + ) + + # The method should return an empty list since the referenced issue couldn't be fetched + assert result == [] + + +def test_handle_rate_limit_error(): + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Mock the requests.get to simulate a rate limit error + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + '403 Client Error: Rate Limit Exceeded' + ) + + with patch('requests.get', return_value=mock_response): + # Call the method with an issue reference + result = handler._strategy.get_context_from_external_issues_references( + closing_issues=[], + closing_issue_numbers=[], + issue_body='This references #123', + review_comments=[], + review_threads=[], + thread_comments=None, + ) + + # The method should return an empty list since the request was rate limited + assert result == [] + + +def test_handle_network_error(): + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Mock the requests.get to simulate a network error + with patch( + 'requests.get', side_effect=requests.exceptions.ConnectionError('Network Error') + ): + # Call the method with an issue reference + result = handler._strategy.get_context_from_external_issues_references( + closing_issues=[], + closing_issue_numbers=[], + issue_body='This references #123', + review_comments=[], + review_threads=[], + thread_comments=None, + ) + + # The method should return an empty list since the network request failed + assert result == [] + + +def test_successful_issue_reference(): + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Mock a successful response + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.json.return_value = { + 'description': 'This is the referenced issue body' + } + + with patch('requests.get', return_value=mock_response): + # Call the method with an issue reference + result = handler._strategy.get_context_from_external_issues_references( + closing_issues=[], + closing_issue_numbers=[], + issue_body='This references #123', + review_comments=[], + review_threads=[], + thread_comments=None, + ) + + # The method should return a list with the referenced issue body + assert result == ['This is the referenced issue body'] + + +class MockLLMResponse: + """Mock LLM Response class to mimic the actual LLM response structure.""" + + class Choice: + class Message: + def __init__(self, content): + self.content = content + + def __init__(self, content): + self.message = self.Message(content) + + def __init__(self, content): + self.choices = [self.Choice(content)] + + +class DotDict(dict): + """ + A dictionary that supports dot notation access. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for key, value in self.items(): + if isinstance(value, dict): + self[key] = DotDict(value) + elif isinstance(value, list): + self[key] = [ + DotDict(item) if isinstance(item, dict) else item for item in value + ] + + def __getattr__(self, key): + if key in self: + return self[key] + else: + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{key}'" + ) + + def __setattr__(self, key, value): + self[key] = value + + def __delattr__(self, key): + if key in self: + del self[key] + else: + raise AttributeError( + f"'{self.__class__.__name__}' object has no attribute '{key}'" + ) + + +@patch('openhands.llm.llm.litellm_completion') +def test_guess_success_rate_limit_wait_time(mock_litellm_completion, default_config): + """Test that the retry mechanism in guess_success respects wait time between retries.""" + + with patch('time.sleep') as mock_sleep: + # Simulate a rate limit error followed by a successful response + mock_litellm_completion.side_effect = [ + RateLimitError( + 'Rate limit exceeded', llm_provider='test_provider', model='test_model' + ), + DotDict( + { + 'choices': [ + { + 'message': { + 'content': '--- success\ntrue\n--- explanation\nRetry successful' + } + } + ] + } + ), + ] + + llm = LLM(config=default_config) + handler = ServiceContextIssue( + GitlabIssueHandler('test-owner', 'test-repo', 'test-token'), default_config + ) + handler.llm = llm + + # Mock issue and history + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test Issue', + body='This is a test issue.', + thread_comments=['Please improve error handling'], + ) + history = [MessageAction(content='Fixed error handling.')] + + # Call guess_success + success, _, explanation = handler.guess_success(issue, history) + + # Assertions + assert success is True + assert explanation == 'Retry successful' + assert mock_litellm_completion.call_count == 2 # Two attempts made + mock_sleep.assert_called_once() # Sleep called once between retries + + # Validate wait time + wait_time = mock_sleep.call_args[0][0] + assert ( + default_config.retry_min_wait <= wait_time <= default_config.retry_max_wait + ), f'Expected wait time between {default_config.retry_min_wait} and {default_config.retry_max_wait} seconds, but got {wait_time}' + + +@patch('openhands.llm.llm.litellm_completion') +def test_guess_success_exhausts_retries(mock_completion, default_config): + """Test the retry mechanism in guess_success exhausts retries and raises an error.""" + # Simulate persistent rate limit errors by always raising RateLimitError + mock_completion.side_effect = RateLimitError( + 'Rate limit exceeded', llm_provider='test_provider', model='test_model' + ) + + # Initialize LLM and handler + llm = LLM(config=default_config) + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), default_config + ) + handler.llm = llm + + # Mock issue and history + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test Issue', + body='This is a test issue.', + thread_comments=['Please improve error handling'], + ) + history = [MessageAction(content='Fixed error handling.')] + + # Call guess_success and expect it to raise an error after retries + with pytest.raises(RateLimitError): + handler.guess_success(issue, history) + + # Assertions + assert ( + mock_completion.call_count == default_config.num_retries + ) # Initial call + retries diff --git a/tests/unit/resolver/gitlab/test_gitlab_pr_handler_guess_success.py b/tests/unit/resolver/gitlab/test_gitlab_pr_handler_guess_success.py new file mode 100644 index 000000000000..a5596d7d76df --- /dev/null +++ b/tests/unit/resolver/gitlab/test_gitlab_pr_handler_guess_success.py @@ -0,0 +1,672 @@ +import json +from unittest.mock import MagicMock, patch + +import pytest + +from openhands.core.config import LLMConfig +from openhands.events.action.message import MessageAction +from openhands.llm.llm import LLM +from openhands.resolver.interfaces.gitlab import GitlabPRHandler +from openhands.resolver.interfaces.issue import Issue, ReviewThread +from openhands.resolver.interfaces.issue_definitions import ServiceContextPR + + +@pytest.fixture +def pr_handler(): + llm_config = LLMConfig(model='test-model') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + return handler + + +@pytest.fixture +def mock_llm_success_response(): + return MagicMock( + choices=[ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +The changes look good""" + ) + ) + ] + ) + + +def test_guess_success_review_threads_litellm_call(): + """Test that the completion() call for review threads contains the expected content.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create a mock issue with review threads + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test PR', + body='Test Body', + thread_comments=None, + closing_issues=['Issue 1 description', 'Issue 2 description'], + review_comments=None, + review_threads=[ + ReviewThread( + comment='Please fix the formatting\n---\nlatest feedback:\nAdd docstrings', + files=['/src/file1.py', '/src/file2.py'], + ), + ReviewThread( + comment='Add more tests\n---\nlatest feedback:\nAdd test cases', + files=['/tests/test_file.py'], + ), + ], + thread_ids=['1', '2'], + head_branch='test-branch', + ) + + # Create mock history with a detailed response + history = [ + MessageAction( + content="""I have made the following changes: +1. Fixed formatting in file1.py and file2.py +2. Added docstrings to all functions +3. Added test cases in test_file.py""" + ) + ] + + # Create mock LLM config + llm_config = LLMConfig(model='test-model', api_key='test-key') + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +The changes successfully address the feedback.""" + ) + ) + ] + + # Test the guess_success method + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, success_list, explanation = handler.guess_success(issue, history) + + # Verify the completion() calls + assert mock_completion.call_count == 2 # One call per review thread + + # Check first call + first_call = mock_completion.call_args_list[0] + first_prompt = first_call[1]['messages'][0]['content'] + assert ( + 'Issue descriptions:\n' + + json.dumps(['Issue 1 description', 'Issue 2 description'], indent=4) + in first_prompt + ) + assert ( + 'Feedback:\nPlease fix the formatting\n---\nlatest feedback:\nAdd docstrings' + in first_prompt + ) + assert ( + 'Files locations:\n' + + json.dumps(['/src/file1.py', '/src/file2.py'], indent=4) + in first_prompt + ) + assert 'Last message from AI agent:\n' + history[0].content in first_prompt + + # Check second call + second_call = mock_completion.call_args_list[1] + second_prompt = second_call[1]['messages'][0]['content'] + assert ( + 'Issue descriptions:\n' + + json.dumps(['Issue 1 description', 'Issue 2 description'], indent=4) + in second_prompt + ) + assert ( + 'Feedback:\nAdd more tests\n---\nlatest feedback:\nAdd test cases' + in second_prompt + ) + assert ( + 'Files locations:\n' + json.dumps(['/tests/test_file.py'], indent=4) + in second_prompt + ) + assert 'Last message from AI agent:\n' + history[0].content in second_prompt + + assert len(json.loads(explanation)) == 2 + + +def test_guess_success_thread_comments_litellm_call(): + """Test that the completion() call for thread comments contains the expected content.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create a mock issue with thread comments + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test PR', + body='Test Body', + thread_comments=[ + 'Please improve error handling', + 'Add input validation', + 'latest feedback:\nHandle edge cases', + ], + closing_issues=['Issue 1 description', 'Issue 2 description'], + review_comments=None, + thread_ids=None, + head_branch='test-branch', + ) + + # Create mock history with a detailed response + history = [ + MessageAction( + content="""I have made the following changes: +1. Added try/catch blocks for error handling +2. Added input validation checks +3. Added handling for edge cases""" + ) + ] + + # Create mock LLM config + llm_config = LLMConfig(model='test-model', api_key='test-key') + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +The changes successfully address the feedback.""" + ) + ) + ] + + # Test the guess_success method + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, success_list, explanation = handler.guess_success(issue, history) + + # Verify the completion() call + mock_completion.assert_called_once() + call_args = mock_completion.call_args + prompt = call_args[1]['messages'][0]['content'] + + # Check prompt content + assert ( + 'Issue descriptions:\n' + + json.dumps(['Issue 1 description', 'Issue 2 description'], indent=4) + in prompt + ) + assert 'PR Thread Comments:\n' + '\n---\n'.join(issue.thread_comments) in prompt + assert 'Last message from AI agent:\n' + history[0].content in prompt + + assert len(json.loads(explanation)) == 1 + + +def test_check_feedback_with_llm(): + """Test the _check_feedback_with_llm helper function.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Test cases for different LLM responses + test_cases = [ + { + 'response': '--- success\ntrue\n--- explanation\nChanges look good', + 'expected': (True, 'Changes look good'), + }, + { + 'response': '--- success\nfalse\n--- explanation\nNot all issues fixed', + 'expected': (False, 'Not all issues fixed'), + }, + { + 'response': 'Invalid response format', + 'expected': ( + False, + 'Failed to decode answer from LLM response: Invalid response format', + ), + }, + { + 'response': '--- success\ntrue\n--- explanation\nMultiline\nexplanation\nhere', + 'expected': (True, 'Multiline\nexplanation\nhere'), + }, + ] + + for case in test_cases: + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [MagicMock(message=MagicMock(content=case['response']))] + + # Test the function + with patch.object(LLM, 'completion', return_value=mock_response): + success, explanation = handler._check_feedback_with_llm('test prompt') + assert (success, explanation) == case['expected'] + + +def test_check_review_thread_with_git_patch(): + """Test that git patch from complete_runtime is included in the prompt.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create test data + review_thread = ReviewThread( + comment='Please fix the formatting\n---\nlatest feedback:\nAdd docstrings', + files=['/src/file1.py', '/src/file2.py'], + ) + issues_context = json.dumps( + ['Issue 1 description', 'Issue 2 description'], indent=4 + ) + last_message = 'I have fixed the formatting and added docstrings' + git_patch = 'diff --git a/src/file1.py b/src/file1.py\n+"""Added docstring."""\n' + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +Changes look good""" + ) + ) + ] + + # Test the function + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, explanation = handler._check_review_thread( + review_thread, issues_context, last_message, git_patch + ) + + # Verify the completion() call + mock_completion.assert_called_once() + call_args = mock_completion.call_args + prompt = call_args[1]['messages'][0]['content'] + + # Check prompt content + assert 'Issue descriptions:\n' + issues_context in prompt + assert 'Feedback:\n' + review_thread.comment in prompt + assert ( + 'Files locations:\n' + json.dumps(review_thread.files, indent=4) in prompt + ) + assert 'Last message from AI agent:\n' + last_message in prompt + assert 'Changes made (git patch):\n' + git_patch in prompt + + # Check result + assert success is True + assert explanation == 'Changes look good' + + +def test_check_review_thread(): + """Test the _check_review_thread helper function.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create test data + review_thread = ReviewThread( + comment='Please fix the formatting\n---\nlatest feedback:\nAdd docstrings', + files=['/src/file1.py', '/src/file2.py'], + ) + issues_context = json.dumps( + ['Issue 1 description', 'Issue 2 description'], indent=4 + ) + last_message = 'I have fixed the formatting and added docstrings' + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +Changes look good""" + ) + ) + ] + + # Test the function + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, explanation = handler._check_review_thread( + review_thread, issues_context, last_message + ) + + # Verify the completion() call + mock_completion.assert_called_once() + call_args = mock_completion.call_args + prompt = call_args[1]['messages'][0]['content'] + + # Check prompt content + assert 'Issue descriptions:\n' + issues_context in prompt + assert 'Feedback:\n' + review_thread.comment in prompt + assert ( + 'Files locations:\n' + json.dumps(review_thread.files, indent=4) in prompt + ) + assert 'Last message from AI agent:\n' + last_message in prompt + + # Check result + assert success is True + assert explanation == 'Changes look good' + + +def test_check_thread_comments_with_git_patch(): + """Test that git patch from complete_runtime is included in the prompt.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create test data + thread_comments = [ + 'Please improve error handling', + 'Add input validation', + 'latest feedback:\nHandle edge cases', + ] + issues_context = json.dumps( + ['Issue 1 description', 'Issue 2 description'], indent=4 + ) + last_message = 'I have added error handling and input validation' + git_patch = 'diff --git a/src/file1.py b/src/file1.py\n+try:\n+ validate_input()\n+except ValueError:\n+ handle_error()\n' + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +Changes look good""" + ) + ) + ] + + # Test the function + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, explanation = handler._check_thread_comments( + thread_comments, issues_context, last_message, git_patch + ) + + # Verify the completion() call + mock_completion.assert_called_once() + call_args = mock_completion.call_args + prompt = call_args[1]['messages'][0]['content'] + + # Check prompt content + assert 'Issue descriptions:\n' + issues_context in prompt + assert 'PR Thread Comments:\n' + '\n---\n'.join(thread_comments) in prompt + assert 'Last message from AI agent:\n' + last_message in prompt + assert 'Changes made (git patch):\n' + git_patch in prompt + + # Check result + assert success is True + assert explanation == 'Changes look good' + + +def test_check_thread_comments(): + """Test the _check_thread_comments helper function.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create test data + thread_comments = [ + 'Please improve error handling', + 'Add input validation', + 'latest feedback:\nHandle edge cases', + ] + issues_context = json.dumps( + ['Issue 1 description', 'Issue 2 description'], indent=4 + ) + last_message = 'I have added error handling and input validation' + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +Changes look good""" + ) + ) + ] + + # Test the function + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, explanation = handler._check_thread_comments( + thread_comments, issues_context, last_message + ) + + # Verify the completion() call + mock_completion.assert_called_once() + call_args = mock_completion.call_args + prompt = call_args[1]['messages'][0]['content'] + + # Check prompt content + assert 'Issue descriptions:\n' + issues_context in prompt + assert 'PR Thread Comments:\n' + '\n---\n'.join(thread_comments) in prompt + assert 'Last message from AI agent:\n' + last_message in prompt + + # Check result + assert success is True + assert explanation == 'Changes look good' + + +def test_check_review_comments_with_git_patch(): + """Test that git patch from complete_runtime is included in the prompt.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create test data + review_comments = [ + 'Please fix the code style', + 'Add more test cases', + 'latest feedback:\nImprove documentation', + ] + issues_context = json.dumps( + ['Issue 1 description', 'Issue 2 description'], indent=4 + ) + last_message = 'I have fixed the code style and added tests' + git_patch = 'diff --git a/src/file1.py b/src/file1.py\n+"""This module does X."""\n+def func():\n+ """Do Y."""\n' + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +Changes look good""" + ) + ) + ] + + # Test the function + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, explanation = handler._check_review_comments( + review_comments, issues_context, last_message, git_patch + ) + + # Verify the completion() call + mock_completion.assert_called_once() + call_args = mock_completion.call_args + prompt = call_args[1]['messages'][0]['content'] + + # Check prompt content + assert 'Issue descriptions:\n' + issues_context in prompt + assert 'PR Review Comments:\n' + '\n---\n'.join(review_comments) in prompt + assert 'Last message from AI agent:\n' + last_message in prompt + assert 'Changes made (git patch):\n' + git_patch in prompt + + # Check result + assert success is True + assert explanation == 'Changes look good' + + +def test_check_review_comments(): + """Test the _check_review_comments helper function.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create test data + review_comments = [ + 'Please improve code readability', + 'Add comments to complex functions', + 'Follow PEP 8 style guide', + ] + issues_context = json.dumps( + ['Issue 1 description', 'Issue 2 description'], indent=4 + ) + last_message = 'I have improved code readability and added comments' + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +Changes look good""" + ) + ) + ] + + # Test the function + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, explanation = handler._check_review_comments( + review_comments, issues_context, last_message + ) + + # Verify the completion() call + mock_completion.assert_called_once() + call_args = mock_completion.call_args + prompt = call_args[1]['messages'][0]['content'] + + # Check prompt content + assert 'Issue descriptions:\n' + issues_context in prompt + assert 'PR Review Comments:\n' + '\n---\n'.join(review_comments) in prompt + assert 'Last message from AI agent:\n' + last_message in prompt + + # Check result + assert success is True + assert explanation == 'Changes look good' + + +def test_guess_success_review_comments_litellm_call(): + """Test that the completion() call for review comments contains the expected content.""" + # Create a PR handler instance + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR( + GitlabPRHandler('test-owner', 'test-repo', 'test-token'), llm_config + ) + + # Create a mock issue with review comments + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test PR', + body='Test Body', + thread_comments=None, + closing_issues=['Issue 1 description', 'Issue 2 description'], + review_comments=[ + 'Please improve code readability', + 'Add comments to complex functions', + 'Follow PEP 8 style guide', + ], + thread_ids=None, + head_branch='test-branch', + ) + + # Create mock history with a detailed response + history = [ + MessageAction( + content="""I have made the following changes: +1. Improved code readability by breaking down complex functions +2. Added detailed comments to all complex functions +3. Fixed code style to follow PEP 8""" + ) + ] + + # Mock the LLM response + mock_response = MagicMock() + mock_response.choices = [ + MagicMock( + message=MagicMock( + content="""--- success +true + +--- explanation +The changes successfully address the feedback.""" + ) + ) + ] + + with patch.object(LLM, 'completion') as mock_completion: + mock_completion.return_value = mock_response + success, success_list, explanation = handler.guess_success(issue, history) + + # Verify the completion() call + mock_completion.assert_called_once() + call_args = mock_completion.call_args + prompt = call_args[1]['messages'][0]['content'] + + # Check prompt content + assert ( + 'Issue descriptions:\n' + + json.dumps(['Issue 1 description', 'Issue 2 description'], indent=4) + in prompt + ) + assert 'PR Review Comments:\n' + '\n---\n'.join(issue.review_comments) in prompt + assert 'Last message from AI agent:\n' + history[0].content in prompt + + assert len(json.loads(explanation)) == 1 diff --git a/tests/unit/resolver/gitlab/test_gitlab_pr_title_escaping.py b/tests/unit/resolver/gitlab/test_gitlab_pr_title_escaping.py new file mode 100644 index 000000000000..54709d2f3620 --- /dev/null +++ b/tests/unit/resolver/gitlab/test_gitlab_pr_title_escaping.py @@ -0,0 +1,167 @@ +import os +import subprocess +import tempfile + +from openhands.core.logger import openhands_logger as logger +from openhands.resolver.interfaces.issue import Issue +from openhands.resolver.send_pull_request import make_commit +from openhands.resolver.utils import Platform + + +def test_commit_message_with_quotes(): + # Create a temporary directory and initialize git repo + with tempfile.TemporaryDirectory() as temp_dir: + subprocess.run(['git', 'init', temp_dir], check=True) + + # Create a test file and add it to git + test_file = os.path.join(temp_dir, 'test.txt') + with open(test_file, 'w') as f: + f.write('test content') + + subprocess.run(['git', '-C', temp_dir, 'add', 'test.txt'], check=True) + + # Create a test issue with problematic title + issue = Issue( + owner='test-owner', + repo='test-repo', + number=123, + title="Issue with 'quotes' and \"double quotes\" and ", + body='Test body', + labels=[], + assignees=[], + state='open', + created_at='2024-01-01T00:00:00Z', + updated_at='2024-01-01T00:00:00Z', + closed_at=None, + head_branch=None, + thread_ids=None, + ) + + # Make the commit + make_commit(temp_dir, issue, 'issue') + + # Get the commit message + result = subprocess.run( + ['git', '-C', temp_dir, 'log', '-1', '--pretty=%B'], + capture_output=True, + text=True, + check=True, + ) + commit_msg = result.stdout.strip() + + # The commit message should contain the quotes without excessive escaping + expected = "Fix issue #123: Issue with 'quotes' and \"double quotes\" and " + assert commit_msg == expected, f'Expected: {expected}\nGot: {commit_msg}' + + +def test_pr_title_with_quotes(monkeypatch): + # Mock requests.post to avoid actual API calls + class MockResponse: + def __init__(self, status_code=201): + self.status_code = status_code + self.text = '' + + def json(self): + return {'html_url': 'https://github.com/test/test/pull/1'} + + def raise_for_status(self): + pass + + def mock_post(*args, **kwargs): + # Verify that the PR title is not over-escaped + data = kwargs.get('json', {}) + title = data.get('title', '') + expected = "Fix issue #123: Issue with 'quotes' and \"double quotes\" and " + assert ( + title == expected + ), f'PR title was incorrectly escaped.\nExpected: {expected}\nGot: {title}' + return MockResponse() + + class MockGetResponse: + def __init__(self, status_code=200): + self.status_code = status_code + self.text = '' + + def json(self): + return {'default_branch': 'main'} + + def raise_for_status(self): + pass + + monkeypatch.setattr('requests.post', mock_post) + monkeypatch.setattr('requests.get', lambda *args, **kwargs: MockGetResponse()) + monkeypatch.setattr( + 'openhands.resolver.interfaces.github.GithubIssueHandler.branch_exists', + lambda *args, **kwargs: False, + ) + + # Mock subprocess.run to avoid actual git commands + original_run = subprocess.run + + def mock_run(*args, **kwargs): + logger.info(f"Running command: {args[0] if args else kwargs.get('args', [])}") + if isinstance(args[0], list) and args[0][0] == 'git': + if 'push' in args[0]: + return subprocess.CompletedProcess( + args[0], returncode=0, stdout='', stderr='' + ) + return original_run(*args, **kwargs) + return original_run(*args, **kwargs) + + monkeypatch.setattr('subprocess.run', mock_run) + + # Create a temporary directory and initialize git repo + with tempfile.TemporaryDirectory() as temp_dir: + logger.info('Initializing git repo...') + subprocess.run(['git', 'init', temp_dir], check=True) + + # Add these lines to configure git + subprocess.run( + ['git', '-C', temp_dir, 'config', 'user.name', 'Test User'], check=True + ) + subprocess.run( + ['git', '-C', temp_dir, 'config', 'user.email', 'test@example.com'], + check=True, + ) + + # Create a test file and add it to git + test_file = os.path.join(temp_dir, 'test.txt') + with open(test_file, 'w') as f: + f.write('test content') + + logger.info('Adding and committing test file...') + subprocess.run(['git', '-C', temp_dir, 'add', 'test.txt'], check=True) + subprocess.run( + ['git', '-C', temp_dir, 'commit', '-m', 'Initial commit'], check=True + ) + + # Create a test issue with problematic title + logger.info('Creating test issue...') + issue = Issue( + owner='test-owner', + repo='test-repo', + number=123, + title="Issue with 'quotes' and \"double quotes\" and ", + body='Test body', + labels=[], + assignees=[], + state='open', + created_at='2024-01-01T00:00:00Z', + updated_at='2024-01-01T00:00:00Z', + closed_at=None, + head_branch=None, + thread_ids=None, + ) + + # Try to send a PR - this will fail if the title is incorrectly escaped + logger.info('Sending PR...') + from openhands.resolver.send_pull_request import send_pull_request + + send_pull_request( + issue=issue, + token='dummy-token', + username='test-user', + platform=Platform.GITHUB, + patch_dir=temp_dir, + pr_type='ready', + ) diff --git a/tests/unit/resolver/gitlab/test_gitlab_resolve_issues.py b/tests/unit/resolver/gitlab/test_gitlab_resolve_issues.py new file mode 100644 index 000000000000..a2dbd336cbe8 --- /dev/null +++ b/tests/unit/resolver/gitlab/test_gitlab_resolve_issues.py @@ -0,0 +1,923 @@ +import os +import tempfile +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from openhands.core.config import LLMConfig +from openhands.events.action import CmdRunAction +from openhands.events.observation import ( + CmdOutputMetadata, + CmdOutputObservation, + NullObservation, +) +from openhands.llm.llm import LLM +from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler +from openhands.resolver.interfaces.issue import Issue, ReviewThread +from openhands.resolver.interfaces.issue_definitions import ( + ServiceContextIssue, + ServiceContextPR, +) +from openhands.resolver.resolve_issue import ( + complete_runtime, + initialize_runtime, + process_issue, +) +from openhands.resolver.resolver_output import ResolverOutput +from openhands.resolver.utils import Platform + + +@pytest.fixture +def mock_output_dir(): + with tempfile.TemporaryDirectory() as temp_dir: + repo_path = os.path.join(temp_dir, 'repo') + # Initialize a Gitlab repo in "repo" and add a commit with "README.md" + os.makedirs(repo_path) + os.system(f'git init {repo_path}') + readme_path = os.path.join(repo_path, 'README.md') + with open(readme_path, 'w') as f: + f.write('hello world') + os.system(f'git -C {repo_path} add README.md') + os.system(f"git -C {repo_path} commit -m 'Initial commit'") + yield temp_dir + + +@pytest.fixture +def mock_subprocess(): + with patch('subprocess.check_output') as mock_check_output: + yield mock_check_output + + +@pytest.fixture +def mock_os(): + with patch('os.system') as mock_system, patch('os.path.join') as mock_join: + yield mock_system, mock_join + + +@pytest.fixture +def mock_prompt_template(): + return 'Issue: {{ body }}\n\nPlease fix this issue.' + + +@pytest.fixture +def mock_followup_prompt_template(): + return 'Issue context: {{ issues }}\n\nReview comments: {{ review_comments }}\n\nReview threads: {{ review_threads }}\n\nFiles: {{ files }}\n\nThread comments: {{ thread_context }}\n\nPlease fix this issue.' + + +def create_cmd_output(exit_code: int, content: str, command: str): + return CmdOutputObservation( + content=content, + command=command, + metadata=CmdOutputMetadata(exit_code=exit_code), + ) + + +def test_initialize_runtime(): + mock_runtime = MagicMock() + + if os.getenv('GITLAB_CI') == 'true': + mock_runtime.run_action.side_effect = [ + create_cmd_output(exit_code=0, content='', command='cd /workspace'), + create_cmd_output( + exit_code=0, content='', command='sudo chown -R 1001:0 /workspace/*' + ), + create_cmd_output( + exit_code=0, content='', command='git config --global core.pager ""' + ), + ] + else: + mock_runtime.run_action.side_effect = [ + create_cmd_output(exit_code=0, content='', command='cd /workspace'), + create_cmd_output( + exit_code=0, content='', command='git config --global core.pager ""' + ), + ] + + initialize_runtime(mock_runtime, Platform.GITLAB) + + if os.getenv('GITLAB_CI') == 'true': + assert mock_runtime.run_action.call_count == 3 + else: + assert mock_runtime.run_action.call_count == 2 + + mock_runtime.run_action.assert_any_call(CmdRunAction(command='cd /workspace')) + if os.getenv('GITLAB_CI') == 'true': + mock_runtime.run_action.assert_any_call( + CmdRunAction(command='sudo chown -R 1001:0 /workspace/*') + ) + mock_runtime.run_action.assert_any_call( + CmdRunAction(command='git config --global core.pager ""') + ) + + +@pytest.mark.asyncio +async def test_resolve_issue_no_issues_found(): + from openhands.resolver.resolve_issue import resolve_issue + + # Mock dependencies + mock_handler = MagicMock() + mock_handler.get_converted_issues.return_value = [] # Return empty list + + with patch( + 'openhands.resolver.resolve_issue.issue_handler_factory', + return_value=mock_handler, + ): + with pytest.raises(ValueError) as exc_info: + await resolve_issue( + owner='test-owner', + repo='test-repo', + token='test-token', + username='test-user', + platform=Platform.GITLAB, + max_iterations=5, + output_dir='/tmp', + llm_config=LLMConfig(model='test', api_key='test'), + runtime_container_image='test-image', + prompt_template='test-template', + issue_type='pr', + repo_instruction=None, + issue_number=5432, + comment_id=None, + ) + + assert 'No issues found for issue number 5432' in str(exc_info.value) + assert 'test-owner/test-repo' in str(exc_info.value) + assert 'exists in the repository' in str(exc_info.value) + assert 'correct permissions' in str(exc_info.value) + + +def test_download_issues_from_gitlab(): + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), llm_config + ) + + mock_issues_response = MagicMock() + mock_issues_response.json.side_effect = [ + [ + {'iid': 1, 'title': 'Issue 1', 'description': 'This is an issue'}, + { + 'iid': 2, + 'title': 'PR 1', + 'description': 'This is a pull request', + 'pull_request': {}, + }, + {'iid': 3, 'title': 'Issue 2', 'description': 'This is another issue'}, + ], + None, + ] + mock_issues_response.raise_for_status = MagicMock() + + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [] + mock_comments_response.raise_for_status = MagicMock() + + def get_mock_response(url, *args, **kwargs): + if '/notes' in url: + return mock_comments_response + return mock_issues_response + + with patch('requests.get', side_effect=get_mock_response): + issues = handler.get_converted_issues(issue_numbers=[1, 3]) + + assert len(issues) == 2 + assert handler.issue_type == 'issue' + assert all(isinstance(issue, Issue) for issue in issues) + assert [issue.number for issue in issues] == [1, 3] + assert [issue.title for issue in issues] == ['Issue 1', 'Issue 2'] + assert [issue.review_comments for issue in issues] == [None, None] + assert [issue.closing_issues for issue in issues] == [None, None] + assert [issue.thread_ids for issue in issues] == [None, None] + + +def test_download_pr_from_gitlab(): + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextPR(GitlabPRHandler('owner', 'repo', 'token'), llm_config) + mock_pr_response = MagicMock() + mock_pr_response.json.side_effect = [ + [ + { + 'iid': 1, + 'title': 'PR 1', + 'description': 'This is a pull request', + 'source_branch': 'b1', + }, + { + 'iid': 2, + 'title': 'My PR', + 'description': 'This is another pull request', + 'source_branch': 'b2', + }, + { + 'iid': 3, + 'title': 'PR 3', + 'description': 'Final PR', + 'source_branch': 'b3', + }, + ], + None, + ] + mock_pr_response.raise_for_status = MagicMock() + + # Mock for related issues response + mock_related_issuse_response = MagicMock() + mock_related_issuse_response.json.return_value = [ + {'description': 'Issue 1 body', 'iid': 1}, + {'description': 'Issue 2 body', 'iid': 2}, + ] + mock_related_issuse_response.raise_for_status = MagicMock() + + # Mock for PR comments response + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [] # No PR comments + mock_comments_response.raise_for_status = MagicMock() + + # Mock for GraphQL request (for download_pr_metadata) + mock_graphql_response = MagicMock() + mock_graphql_response.json.side_effect = lambda: { + 'data': { + 'project': { + 'mergeRequest': { + 'discussions': { + 'edges': [ + { + 'node': { + 'id': '1', + 'resolved': False, + 'resolvable': True, + 'notes': { + 'nodes': [ + { + 'body': 'Unresolved comment 1', + 'position': { + 'filePath': '/frontend/header.tsx', + }, + }, + { + 'body': 'Follow up thread', + }, + ] + }, + } + }, + { + 'node': { + 'id': '2', + 'resolved': True, + 'resolvable': True, + 'notes': { + 'nodes': [ + { + 'body': 'Resolved comment 1', + 'position': { + 'filePath': '/some/file.py', + }, + }, + ] + }, + } + }, + { + 'node': { + 'id': '3', + 'resolved': False, + 'resolvable': True, + 'notes': { + 'nodes': [ + { + 'body': 'Unresolved comment 3', + 'position': { + 'filePath': '/another/file.py', + }, + }, + ] + }, + } + }, + ] + }, + } + } + } + } + + mock_graphql_response.raise_for_status = MagicMock() + + def get_mock_response(url, *args, **kwargs): + if '/notes' in url: + return mock_comments_response + if '/related_issues' in url: + return mock_related_issuse_response + return mock_pr_response + + with patch('requests.get', side_effect=get_mock_response): + with patch('requests.post', return_value=mock_graphql_response): + issues = handler.get_converted_issues(issue_numbers=[1, 2, 3]) + + assert len(issues) == 3 + assert handler.issue_type == 'pr' + assert all(isinstance(issue, Issue) for issue in issues) + assert [issue.number for issue in issues] == [1, 2, 3] + assert [issue.title for issue in issues] == ['PR 1', 'My PR', 'PR 3'] + assert [issue.head_branch for issue in issues] == ['b1', 'b2', 'b3'] + + assert len(issues[0].review_threads) == 2 # Only unresolved threads + assert ( + issues[0].review_threads[0].comment + == 'Unresolved comment 1\n---\nlatest feedback:\nFollow up thread\n' + ) + assert issues[0].review_threads[0].files == ['/frontend/header.tsx'] + assert ( + issues[0].review_threads[1].comment + == 'latest feedback:\nUnresolved comment 3\n' + ) + assert issues[0].review_threads[1].files == ['/another/file.py'] + assert issues[0].closing_issues == ['Issue 1 body', 'Issue 2 body'] + assert issues[0].thread_ids == ['1', '3'] + + +@pytest.mark.asyncio +async def test_complete_runtime(): + mock_runtime = MagicMock() + mock_runtime.run_action.side_effect = [ + create_cmd_output(exit_code=0, content='', command='cd /workspace'), + create_cmd_output( + exit_code=0, content='', command='git config --global core.pager ""' + ), + create_cmd_output( + exit_code=0, + content='', + command='git config --global --add safe.directory /workspace', + ), + create_cmd_output( + exit_code=0, content='', command='git diff base_commit_hash fix' + ), + create_cmd_output(exit_code=0, content='git diff content', command='git apply'), + ] + + result = await complete_runtime(mock_runtime, 'base_commit_hash', Platform.GITLAB) + + assert result == {'git_patch': 'git diff content'} + assert mock_runtime.run_action.call_count == 5 + + +@pytest.mark.asyncio +async def test_process_issue(mock_output_dir, mock_prompt_template): + # Mock dependencies + mock_create_runtime = MagicMock() + mock_initialize_runtime = AsyncMock() + mock_run_controller = AsyncMock() + mock_complete_runtime = AsyncMock() + handler_instance = MagicMock() + + # Set up test data + issue = Issue( + owner='test_owner', + repo='test_repo', + number=1, + title='Test Issue', + body='This is a test issue', + ) + base_commit = 'abcdef1234567890' + repo_instruction = 'Resolve this repo' + max_iterations = 5 + llm_config = LLMConfig(model='test_model', api_key='test_api_key') + runtime_container_image = 'test_image:latest' + + # Test cases for different scenarios + test_cases = [ + { + 'name': 'successful_run', + 'run_controller_return': MagicMock( + history=[NullObservation(content='')], + metrics=MagicMock( + get=MagicMock(return_value={'test_result': 'passed'}) + ), + last_error=None, + ), + 'run_controller_raises': None, + 'expected_success': True, + 'expected_error': None, + 'expected_explanation': 'Issue resolved successfully', + }, + { + 'name': 'value_error', + 'run_controller_return': None, + 'run_controller_raises': ValueError('Test value error'), + 'expected_success': False, + 'expected_error': 'Agent failed to run or crashed', + 'expected_explanation': 'Agent failed to run', + }, + { + 'name': 'runtime_error', + 'run_controller_return': None, + 'run_controller_raises': RuntimeError('Test runtime error'), + 'expected_success': False, + 'expected_error': 'Agent failed to run or crashed', + 'expected_explanation': 'Agent failed to run', + }, + { + 'name': 'json_decode_error', + 'run_controller_return': MagicMock( + history=[NullObservation(content='')], + metrics=MagicMock( + get=MagicMock(return_value={'test_result': 'passed'}) + ), + last_error=None, + ), + 'run_controller_raises': None, + 'expected_success': True, + 'expected_error': None, + 'expected_explanation': 'Non-JSON explanation', + 'is_pr': True, + 'comment_success': [ + True, + False, + ], # To trigger the PR success logging code path + }, + ] + + for test_case in test_cases: + # Reset mocks + mock_create_runtime.reset_mock() + mock_initialize_runtime.reset_mock() + mock_run_controller.reset_mock() + mock_complete_runtime.reset_mock() + handler_instance.reset_mock() + + # Mock return values + mock_create_runtime.return_value = MagicMock(connect=AsyncMock()) + if test_case['run_controller_raises']: + mock_run_controller.side_effect = test_case['run_controller_raises'] + else: + mock_run_controller.return_value = test_case['run_controller_return'] + mock_run_controller.side_effect = None + + mock_complete_runtime.return_value = {'git_patch': 'test patch'} + handler_instance.guess_success.return_value = ( + test_case['expected_success'], + test_case.get('comment_success', None), + test_case['expected_explanation'], + ) + handler_instance.get_instruction.return_value = ('Test instruction', []) + handler_instance.issue_type = 'pr' if test_case.get('is_pr', False) else 'issue' + + with ( + patch( + 'openhands.resolver.resolve_issue.create_runtime', mock_create_runtime + ), + patch( + 'openhands.resolver.resolve_issue.initialize_runtime', + mock_initialize_runtime, + ), + patch( + 'openhands.resolver.resolve_issue.run_controller', mock_run_controller + ), + patch( + 'openhands.resolver.resolve_issue.complete_runtime', + mock_complete_runtime, + ), + patch('openhands.resolver.resolve_issue.logger'), + ): + # Call the function + result = await process_issue( + issue, + Platform.GITLAB, + base_commit, + max_iterations, + llm_config, + mock_output_dir, + runtime_container_image, + mock_prompt_template, + handler_instance, + repo_instruction, + reset_logger=False, + ) + + # Assert the result + expected_issue_type = 'pr' if test_case.get('is_pr', False) else 'issue' + assert handler_instance.issue_type == expected_issue_type + assert isinstance(result, ResolverOutput) + assert result.issue == issue + assert result.base_commit == base_commit + assert result.git_patch == 'test patch' + assert result.success == test_case['expected_success'] + assert result.result_explanation == test_case['expected_explanation'] + assert result.error == test_case['expected_error'] + + # Assert that the mocked functions were called + mock_create_runtime.assert_called_once() + mock_initialize_runtime.assert_called_once() + mock_run_controller.assert_called_once() + mock_complete_runtime.assert_called_once() + + # Assert that guess_success was called only for successful runs + if test_case['expected_success']: + handler_instance.guess_success.assert_called_once() + else: + handler_instance.guess_success.assert_not_called() + + +def test_get_instruction(mock_prompt_template, mock_followup_prompt_template): + issue = Issue( + owner='test_owner', + repo='test_repo', + number=123, + title='Test Issue', + body='This is a test issue refer to image ![First Image](https://sampleimage.com/image1.png)', + ) + mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) + instruction, images_urls = issue_handler.get_instruction( + issue, mock_prompt_template, None + ) + expected_instruction = 'Issue: Test Issue\n\nThis is a test issue refer to image ![First Image](https://sampleimage.com/image1.png)\n\nPlease fix this issue.' + + assert images_urls == ['https://sampleimage.com/image1.png'] + assert issue_handler.issue_type == 'issue' + assert instruction == expected_instruction + + issue = Issue( + owner='test_owner', + repo='test_repo', + number=123, + title='Test Issue', + body='This is a test issue', + closing_issues=['Issue 1 fix the type'], + review_threads=[ + ReviewThread( + comment="There is still a typo 'pthon' instead of 'python'", files=[] + ) + ], + thread_comments=[ + "I've left review comments, please address them", + 'This is a valid concern.', + ], + ) + + pr_handler = ServiceContextPR( + GitlabPRHandler('owner', 'repo', 'token'), mock_llm_config + ) + instruction, images_urls = pr_handler.get_instruction( + issue, mock_followup_prompt_template, None + ) + expected_instruction = "Issue context: [\n \"Issue 1 fix the type\"\n]\n\nReview comments: None\n\nReview threads: [\n \"There is still a typo 'pthon' instead of 'python'\"\n]\n\nFiles: []\n\nThread comments: I've left review comments, please address them\n---\nThis is a valid concern.\n\nPlease fix this issue." + + assert images_urls == [] + assert pr_handler.issue_type == 'pr' + assert instruction == expected_instruction + + +def test_file_instruction(): + issue = Issue( + owner='test_owner', + repo='test_repo', + number=123, + title='Test Issue', + body='This is a test issue ![image](https://sampleimage.com/sample.png)', + ) + # load prompt from openhands/resolver/prompts/resolve/basic.jinja + with open('openhands/resolver/prompts/resolve/basic.jinja', 'r') as f: + prompt = f.read() + # Test without thread comments + mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) + instruction, images_urls = issue_handler.get_instruction(issue, prompt, None) + expected_instruction = """Please fix the following issue for the repository in /workspace. +An environment has been set up for you to start working. You may assume all necessary tools are installed. + +# Problem Statement +Test Issue + +This is a test issue ![image](https://sampleimage.com/sample.png) + +IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP. +You SHOULD INCLUDE PROPER INDENTATION in your edit commands. + +When you think you have fixed the issue through code changes, please finish the interaction.""" + + assert instruction == expected_instruction + assert images_urls == ['https://sampleimage.com/sample.png'] + + +def test_file_instruction_with_repo_instruction(): + issue = Issue( + owner='test_owner', + repo='test_repo', + number=123, + title='Test Issue', + body='This is a test issue', + ) + # load prompt from openhands/resolver/prompts/resolve/basic.jinja + with open('openhands/resolver/prompts/resolve/basic.jinja', 'r') as f: + prompt = f.read() + # load repo instruction from openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands-resolver.txt + with open( + 'openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands-resolver.txt', + 'r', + ) as f: + repo_instruction = f.read() + + mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) + instruction, image_urls = issue_handler.get_instruction( + issue, prompt, repo_instruction + ) + expected_instruction = """Please fix the following issue for the repository in /workspace. +An environment has been set up for you to start working. You may assume all necessary tools are installed. + +# Problem Statement +Test Issue + +This is a test issue + +IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP. +You SHOULD INCLUDE PROPER INDENTATION in your edit commands. + +Some basic information about this repository: +This is a Python repo for openhands-resolver, a library that attempts to resolve github issues with the AI agent OpenHands. + +- Setup: `poetry install --with test --with dev` +- Testing: `poetry run pytest tests/test_*.py` + + +When you think you have fixed the issue through code changes, please finish the interaction.""" + assert instruction == expected_instruction + assert issue_handler.issue_type == 'issue' + assert image_urls == [] + + +def test_guess_success(): + mock_issue = Issue( + owner='test_owner', + repo='test_repo', + number=1, + title='Test Issue', + body='This is a test issue', + ) + mock_history = [create_cmd_output(exit_code=0, content='', command='cd /workspace')] + mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') + + mock_completion_response = MagicMock() + mock_completion_response.choices = [ + MagicMock( + message=MagicMock( + content='--- success\ntrue\n--- explanation\nIssue resolved successfully' + ) + ) + ] + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) + + with patch.object( + LLM, 'completion', MagicMock(return_value=mock_completion_response) + ): + success, comment_success, explanation = issue_handler.guess_success( + mock_issue, mock_history + ) + assert issue_handler.issue_type == 'issue' + assert comment_success is None + assert success + assert explanation == 'Issue resolved successfully' + + +def test_guess_success_with_thread_comments(): + mock_issue = Issue( + owner='test_owner', + repo='test_repo', + number=1, + title='Test Issue', + body='This is a test issue', + thread_comments=[ + 'First comment', + 'Second comment', + 'latest feedback:\nPlease add tests', + ], + ) + mock_history = [MagicMock(message='I have added tests for this case')] + mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') + + mock_completion_response = MagicMock() + mock_completion_response.choices = [ + MagicMock( + message=MagicMock( + content='--- success\ntrue\n--- explanation\nTests have been added to verify thread comments handling' + ) + ) + ] + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) + + with patch.object( + LLM, 'completion', MagicMock(return_value=mock_completion_response) + ): + success, comment_success, explanation = issue_handler.guess_success( + mock_issue, mock_history + ) + assert issue_handler.issue_type == 'issue' + assert comment_success is None + assert success + assert 'Tests have been added' in explanation + + +def test_instruction_with_thread_comments(): + # Create an issue with thread comments + issue = Issue( + owner='test_owner', + repo='test_repo', + number=123, + title='Test Issue', + body='This is a test issue', + thread_comments=[ + 'First comment', + 'Second comment', + 'latest feedback:\nPlease add tests', + ], + ) + + # Load the basic prompt template + with open('openhands/resolver/prompts/resolve/basic.jinja', 'r') as f: + prompt = f.read() + + llm_config = LLMConfig(model='test', api_key='test') + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), llm_config + ) + instruction, images_urls = issue_handler.get_instruction(issue, prompt, None) + + # Verify that thread comments are included in the instruction + assert 'First comment' in instruction + assert 'Second comment' in instruction + assert 'Please add tests' in instruction + assert 'Issue Thread Comments:' in instruction + assert images_urls == [] + + +def test_guess_success_failure(): + mock_issue = Issue( + owner='test_owner', + repo='test_repo', + number=1, + title='Test Issue', + body='This is a test issue', + thread_comments=[ + 'First comment', + 'Second comment', + 'latest feedback:\nPlease add tests', + ], + ) + mock_history = [MagicMock(message='I have added tests for this case')] + mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') + + mock_completion_response = MagicMock() + mock_completion_response.choices = [ + MagicMock( + message=MagicMock( + content='--- success\ntrue\n--- explanation\nTests have been added to verify thread comments handling' + ) + ) + ] + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) + + with patch.object( + LLM, 'completion', MagicMock(return_value=mock_completion_response) + ): + success, comment_success, explanation = issue_handler.guess_success( + mock_issue, mock_history + ) + assert issue_handler.issue_type == 'issue' + assert comment_success is None + assert success + assert 'Tests have been added' in explanation + + +def test_guess_success_negative_case(): + mock_issue = Issue( + owner='test_owner', + repo='test_repo', + number=1, + title='Test Issue', + body='This is a test issue', + ) + mock_history = [create_cmd_output(exit_code=0, content='', command='cd /workspace')] + mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') + + mock_completion_response = MagicMock() + mock_completion_response.choices = [ + MagicMock( + message=MagicMock( + content='--- success\nfalse\n--- explanation\nIssue not resolved' + ) + ) + ] + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) + + with patch.object( + LLM, 'completion', MagicMock(return_value=mock_completion_response) + ): + success, comment_success, explanation = issue_handler.guess_success( + mock_issue, mock_history + ) + assert issue_handler.issue_type == 'issue' + assert comment_success is None + assert not success + assert explanation == 'Issue not resolved' + + +def test_guess_success_invalid_output(): + mock_issue = Issue( + owner='test_owner', + repo='test_repo', + number=1, + title='Test Issue', + body='This is a test issue', + ) + mock_history = [create_cmd_output(exit_code=0, content='', command='cd /workspace')] + mock_llm_config = LLMConfig(model='test_model', api_key='test_api_key') + + mock_completion_response = MagicMock() + mock_completion_response.choices = [ + MagicMock(message=MagicMock(content='This is not a valid output')) + ] + issue_handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), mock_llm_config + ) + + with patch.object( + LLM, 'completion', MagicMock(return_value=mock_completion_response) + ): + success, comment_success, explanation = issue_handler.guess_success( + mock_issue, mock_history + ) + assert issue_handler.issue_type == 'issue' + assert comment_success is None + assert not success + assert ( + explanation + == 'Failed to decode answer from LLM response: This is not a valid output' + ) + + +def test_download_issue_with_specific_comment(): + llm_config = LLMConfig(model='test', api_key='test') + handler = ServiceContextIssue( + GitlabIssueHandler('owner', 'repo', 'token'), llm_config + ) + + # Define the specific comment_id to filter + specific_comment_id = 101 + + # Mock issue and comment responses + mock_issue_response = MagicMock() + mock_issue_response.json.side_effect = [ + [ + {'iid': 1, 'title': 'Issue 1', 'description': 'This is an issue'}, + ], + None, + ] + mock_issue_response.raise_for_status = MagicMock() + + mock_comments_response = MagicMock() + mock_comments_response.json.return_value = [ + { + 'id': specific_comment_id, + 'body': 'Specific comment body', + }, + { + 'id': 102, + 'body': 'Another comment body', + }, + ] + mock_comments_response.raise_for_status = MagicMock() + + def get_mock_response(url, *args, **kwargs): + if '/notes' in url: + return mock_comments_response + + return mock_issue_response + + with patch('requests.get', side_effect=get_mock_response): + issues = handler.get_converted_issues( + issue_numbers=[1], comment_id=specific_comment_id + ) + + assert len(issues) == 1 + assert issues[0].number == 1 + assert issues[0].title == 'Issue 1' + assert issues[0].thread_comments == ['Specific comment body'] + + +if __name__ == '__main__': + pytest.main() diff --git a/tests/unit/resolver/gitlab/test_gitlab_send_pull_request.py b/tests/unit/resolver/gitlab/test_gitlab_send_pull_request.py new file mode 100644 index 000000000000..4b198e471c86 --- /dev/null +++ b/tests/unit/resolver/gitlab/test_gitlab_send_pull_request.py @@ -0,0 +1,1335 @@ +import os +import tempfile +from unittest.mock import MagicMock, call, patch +from urllib.parse import quote + +import pytest + +from openhands.core.config import LLMConfig +from openhands.resolver.interfaces.gitlab import GitlabIssueHandler +from openhands.resolver.interfaces.issue import ReviewThread +from openhands.resolver.resolver_output import Issue, ResolverOutput +from openhands.resolver.send_pull_request import ( + apply_patch, + initialize_repo, + load_single_resolver_output, + make_commit, + process_all_successful_issues, + process_single_issue, + send_pull_request, + update_existing_pull_request, +) +from openhands.resolver.utils import Platform + + +@pytest.fixture +def mock_output_dir(): + with tempfile.TemporaryDirectory() as temp_dir: + repo_path = os.path.join(temp_dir, 'repo') + # Initialize a Gitlab repo in "repo" and add a commit with "README.md" + os.makedirs(repo_path) + os.system(f'git init {repo_path}') + readme_path = os.path.join(repo_path, 'README.md') + with open(readme_path, 'w') as f: + f.write('hello world') + os.system(f'git -C {repo_path} add README.md') + os.system(f"git -C {repo_path} commit -m 'Initial commit'") + yield temp_dir + + +@pytest.fixture +def mock_issue(): + return Issue( + number=42, + title='Test Issue', + owner='test-owner', + repo='test-repo', + body='Test body', + ) + + +@pytest.fixture +def mock_llm_config(): + return LLMConfig() + + +def test_load_single_resolver_output(): + mock_output_jsonl = 'tests/unit/resolver/mock_output/output.jsonl' + + # Test loading an existing issue + resolver_output = load_single_resolver_output(mock_output_jsonl, 5) + assert isinstance(resolver_output, ResolverOutput) + assert resolver_output.issue.number == 5 + assert resolver_output.issue.title == 'Add MIT license' + assert resolver_output.issue.owner == 'neubig' + assert resolver_output.issue.repo == 'pr-viewer' + + # Test loading a non-existent issue + with pytest.raises(ValueError): + load_single_resolver_output(mock_output_jsonl, 999) + + +def test_apply_patch(mock_output_dir): + # Create a sample file in the mock repo + sample_file = os.path.join(mock_output_dir, 'sample.txt') + with open(sample_file, 'w') as f: + f.write('Original content') + + # Create a sample patch + patch_content = """ +diff --git a/sample.txt b/sample.txt +index 9daeafb..b02def2 100644 +--- a/sample.txt ++++ b/sample.txt +@@ -1 +1,2 @@ +-Original content ++Updated content ++New line +""" + + # Apply the patch + apply_patch(mock_output_dir, patch_content) + + # Check if the file was updated correctly + with open(sample_file, 'r') as f: + updated_content = f.read() + + assert updated_content.strip() == 'Updated content\nNew line'.strip() + + +def test_apply_patch_preserves_line_endings(mock_output_dir): + # Create sample files with different line endings + unix_file = os.path.join(mock_output_dir, 'unix_style.txt') + dos_file = os.path.join(mock_output_dir, 'dos_style.txt') + + with open(unix_file, 'w', newline='\n') as f: + f.write('Line 1\nLine 2\nLine 3') + + with open(dos_file, 'w', newline='\r\n') as f: + f.write('Line 1\r\nLine 2\r\nLine 3') + + # Create patches for both files + unix_patch = """ +diff --git a/unix_style.txt b/unix_style.txt +index 9daeafb..b02def2 100644 +--- a/unix_style.txt ++++ b/unix_style.txt +@@ -1,3 +1,3 @@ + Line 1 +-Line 2 ++Updated Line 2 + Line 3 +""" + + dos_patch = """ +diff --git a/dos_style.txt b/dos_style.txt +index 9daeafb..b02def2 100644 +--- a/dos_style.txt ++++ b/dos_style.txt +@@ -1,3 +1,3 @@ + Line 1 +-Line 2 ++Updated Line 2 + Line 3 +""" + + # Apply patches + apply_patch(mock_output_dir, unix_patch) + apply_patch(mock_output_dir, dos_patch) + + # Check if line endings are preserved + with open(unix_file, 'rb') as f: + unix_content = f.read() + with open(dos_file, 'rb') as f: + dos_content = f.read() + + assert ( + b'\r\n' not in unix_content + ), 'Unix-style line endings were changed to DOS-style' + assert b'\r\n' in dos_content, 'DOS-style line endings were changed to Unix-style' + + # Check if content was updated correctly + assert unix_content.decode('utf-8').split('\n')[1] == 'Updated Line 2' + assert dos_content.decode('utf-8').split('\r\n')[1] == 'Updated Line 2' + + +def test_apply_patch_create_new_file(mock_output_dir): + # Create a patch that adds a new file + patch_content = """ +diff --git a/new_file.txt b/new_file.txt +new file mode 100644 +index 0000000..3b18e51 +--- /dev/null ++++ b/new_file.txt +@@ -0,0 +1 @@ ++hello world +""" + + # Apply the patch + apply_patch(mock_output_dir, patch_content) + + # Check if the new file was created + new_file_path = os.path.join(mock_output_dir, 'new_file.txt') + assert os.path.exists(new_file_path), 'New file was not created' + + # Check if the file content is correct + with open(new_file_path, 'r') as f: + content = f.read().strip() + assert content == 'hello world', 'File content is incorrect' + + +def test_apply_patch_rename_file(mock_output_dir): + # Create a sample file in the mock repo + old_file = os.path.join(mock_output_dir, 'old_name.txt') + with open(old_file, 'w') as f: + f.write('This file will be renamed') + + # Create a patch that renames the file + patch_content = """diff --git a/old_name.txt b/new_name.txt +similarity index 100% +rename from old_name.txt +rename to new_name.txt""" + + # Apply the patch + apply_patch(mock_output_dir, patch_content) + + # Check if the file was renamed + new_file = os.path.join(mock_output_dir, 'new_name.txt') + assert not os.path.exists(old_file), 'Old file still exists' + assert os.path.exists(new_file), 'New file was not created' + + # Check if the content is preserved + with open(new_file, 'r') as f: + content = f.read() + assert content == 'This file will be renamed' + + +def test_apply_patch_delete_file(mock_output_dir): + # Create a sample file in the mock repo + sample_file = os.path.join(mock_output_dir, 'to_be_deleted.txt') + with open(sample_file, 'w') as f: + f.write('This file will be deleted') + + # Create a patch that deletes the file + patch_content = """ +diff --git a/to_be_deleted.txt b/to_be_deleted.txt +deleted file mode 100644 +index 9daeafb..0000000 +--- a/to_be_deleted.txt ++++ /dev/null +@@ -1 +0,0 @@ +-This file will be deleted +""" + + # Apply the patch + apply_patch(mock_output_dir, patch_content) + + # Check if the file was deleted + assert not os.path.exists(sample_file), 'File was not deleted' + + +def test_initialize_repo(mock_output_dir): + issue_type = 'issue' + # Copy the repo to patches + ISSUE_NUMBER = 3 + initialize_repo(mock_output_dir, ISSUE_NUMBER, issue_type) + patches_dir = os.path.join(mock_output_dir, 'patches', f'issue_{ISSUE_NUMBER}') + + # Check if files were copied correctly + assert os.path.exists(os.path.join(patches_dir, 'README.md')) + + # Check file contents + with open(os.path.join(patches_dir, 'README.md'), 'r') as f: + assert f.read() == 'hello world' + + +@patch('openhands.resolver.interfaces.gitlab.GitlabIssueHandler.reply_to_comment') +@patch('requests.post') +@patch('subprocess.run') +@patch('openhands.resolver.send_pull_request.LLM') +def test_update_existing_pull_request( + mock_llm_class, + mock_subprocess_run, + mock_requests_post, + mock_reply_to_comment, +): + # Arrange: Set up test data + issue = Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Test PR', + body='This is a test PR', + thread_ids=['comment1', 'comment2'], + head_branch='test-branch', + ) + token = 'test-token' + username = 'test-user' + patch_dir = '/path/to/patch' + additional_message = '["Fixed bug in function A", "Updated documentation for B"]' + + # Mock the subprocess.run call for git push + mock_subprocess_run.return_value = MagicMock(returncode=0) + + # Mock the requests.post call for adding a PR comment + mock_requests_post.return_value.status_code = 201 + + # Mock LLM instance and completion call + mock_llm_instance = MagicMock() + mock_completion_response = MagicMock() + mock_completion_response.choices = [ + MagicMock(message=MagicMock(content='This is an issue resolution.')) + ] + mock_llm_instance.completion.return_value = mock_completion_response + mock_llm_class.return_value = mock_llm_instance + + llm_config = LLMConfig() + + # Act: Call the function without comment_message to test auto-generation + result = update_existing_pull_request( + issue, + token, + username, + Platform.GITLAB, + patch_dir, + llm_config, + comment_message=None, + additional_message=additional_message, + ) + + # Assert: Check if the git push command was executed + push_command = ( + f'git -C {patch_dir} push ' + f'https://{username}:{token}@gitlab.com/' + f'{issue.owner}/{issue.repo}.git {issue.head_branch}' + ) + mock_subprocess_run.assert_called_once_with( + push_command, shell=True, capture_output=True, text=True + ) + + # Assert: Check if the auto-generated comment was posted to the PR + comment_url = f'https://gitlab.com/api/v4/projects/{quote(f'{issue.owner}/{issue.repo}', safe="")}/issues/{issue.number}/notes' + expected_comment = 'This is an issue resolution.' + mock_requests_post.assert_called_once_with( + comment_url, + headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/json', + }, + json={'body': expected_comment}, + ) + + # Assert: Check if the reply_to_comment function was called for each thread ID + mock_reply_to_comment.assert_has_calls( + [ + call(issue.number, 'comment1', 'Fixed bug in function A'), + call(issue.number, 'comment2', 'Updated documentation for B'), + ] + ) + + # Assert: Check the returned PR URL + assert ( + result + == f'https://gitlab.com/{issue.owner}/{issue.repo}/-/merge_requests/{issue.number}' + ) + + +@pytest.mark.parametrize( + 'pr_type,target_branch,pr_title', + [ + ('branch', None, None), + ('draft', None, None), + ('ready', None, None), + ('branch', 'feature', None), + ('draft', 'develop', None), + ('ready', 'staging', None), + ('ready', None, 'Custom PR Title'), + ('draft', 'develop', 'Another Custom Title'), + ], +) +@patch('subprocess.run') +@patch('requests.post') +@patch('requests.get') +def test_send_pull_request( + mock_get, + mock_post, + mock_run, + mock_issue, + mock_llm_config, + mock_output_dir, + pr_type, + target_branch, + pr_title, +): + repo_path = os.path.join(mock_output_dir, 'repo') + + # Mock API responses based on whether target_branch is specified + if target_branch: + mock_get.side_effect = [ + MagicMock(status_code=404), # Branch doesn't exist + MagicMock(status_code=200), # Target branch exists + MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch + ] + else: + mock_get.side_effect = [ + MagicMock(status_code=404), # Branch doesn't exist + MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch + MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch + ] + + mock_post.return_value.json.return_value = { + 'web_url': 'https://gitlab.com/test-owner/test-repo/-/merge_requests/1', + } + + # Mock subprocess.run calls + mock_run.side_effect = [ + MagicMock(returncode=0), # git checkout -b + MagicMock(returncode=0), # git push + ] + + # Call the function + result = send_pull_request( + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITLAB, + patch_dir=repo_path, + pr_type=pr_type, + target_branch=target_branch, + pr_title=pr_title, + ) + + # Assert API calls + expected_get_calls = 2 + if pr_type == 'branch': + expected_get_calls = 3 + + assert mock_get.call_count == expected_get_calls + + # Check branch creation and push + assert mock_run.call_count == 2 + checkout_call, push_call = mock_run.call_args_list + + assert checkout_call == call( + ['git', '-C', repo_path, 'checkout', '-b', 'openhands-fix-issue-42'], + capture_output=True, + text=True, + ) + assert push_call == call( + [ + 'git', + '-C', + repo_path, + 'push', + 'https://test-user:test-token@gitlab.com/test-owner/test-repo.git', + 'openhands-fix-issue-42', + ], + capture_output=True, + text=True, + ) + + # Check PR creation based on pr_type + if pr_type == 'branch': + assert ( + result + == 'https://gitlab.com/test-owner/test-repo/-/compare/main...openhands-fix-issue-42' + ) + mock_post.assert_not_called() + else: + assert result == 'https://gitlab.com/test-owner/test-repo/-/merge_requests/1' + mock_post.assert_called_once() + post_data = mock_post.call_args[1]['json'] + expected_title = pr_title if pr_title else 'Fix issue #42: Test Issue' + assert post_data['title'] == expected_title + assert post_data['description'].startswith('This pull request fixes #42.') + assert post_data['source_branch'] == 'openhands-fix-issue-42' + assert post_data['target_branch'] == ( + target_branch if target_branch else 'main' + ) + assert post_data['draft'] == (pr_type == 'draft') + + +@patch('subprocess.run') +@patch('requests.post') +@patch('requests.put') +@patch('requests.get') +def test_send_pull_request_with_reviewer( + mock_get, + mock_put, + mock_post, + mock_run, + mock_issue, + mock_output_dir, + mock_llm_config, +): + repo_path = os.path.join(mock_output_dir, 'repo') + reviewer = 'test-reviewer' + + # Mock API responses + mock_get.side_effect = [ + MagicMock(status_code=404), # Branch doesn't exist + MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch + MagicMock(json=lambda: [{'id': 123}]), # Get user data + ] + + # Mock PR creation response + mock_post.side_effect = [ + MagicMock( + status_code=200, + json=lambda: { + 'web_url': 'https://gitlab.com/test-owner/test-repo/-/merge_requests/1', + 'iid': 1, + }, + ), # PR creation + ] + + # Mock request reviwers response + mock_put.side_effect = [ + MagicMock(status_code=200), # Reviewer request + ] + + # Mock subprocess.run calls + mock_run.side_effect = [ + MagicMock(returncode=0), # git checkout -b + MagicMock(returncode=0), # git push + ] + + # Call the function with reviewer + result = send_pull_request( + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITLAB, + patch_dir=repo_path, + pr_type='ready', + reviewer=reviewer, + ) + + # Assert API calls + assert mock_get.call_count == 3 + assert mock_post.call_count == 1 + assert mock_put.call_count == 1 + + # Check PR creation + pr_create_call = mock_post.call_args_list[0] + assert pr_create_call[1]['json']['title'] == 'Fix issue #42: Test Issue' + + # Check reviewer request + reviewer_request_call = mock_put.call_args_list[0] + assert ( + reviewer_request_call[0][0] + == 'https://gitlab.com/api/v4/projects/test-owner%2Ftest-repo/merge_requests/1' + ) + assert reviewer_request_call[1]['json'] == {'reviewer_ids': [123]} + + # Check the result URL + assert result == 'https://gitlab.com/test-owner/test-repo/-/merge_requests/1' + + +@patch('requests.get') +def test_send_pull_request_invalid_target_branch( + mock_get, mock_issue, mock_output_dir, mock_llm_config +): + """Test that an error is raised when specifying a non-existent target branch""" + repo_path = os.path.join(mock_output_dir, 'repo') + + # Mock API response for non-existent branch + mock_get.side_effect = [ + MagicMock(status_code=404), # Branch doesn't exist + MagicMock(status_code=404), # Target branch doesn't exist + ] + + # Test that ValueError is raised when target branch doesn't exist + with pytest.raises( + ValueError, match='Target branch nonexistent-branch does not exist' + ): + send_pull_request( + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITLAB, + patch_dir=repo_path, + pr_type='ready', + target_branch='nonexistent-branch', + ) + + # Verify API calls + assert mock_get.call_count == 2 + + +@patch('subprocess.run') +@patch('requests.post') +@patch('requests.get') +def test_send_pull_request_git_push_failure( + mock_get, mock_post, mock_run, mock_issue, mock_output_dir, mock_llm_config +): + repo_path = os.path.join(mock_output_dir, 'repo') + + # Mock API responses + mock_get.return_value = MagicMock(json=lambda: {'default_branch': 'main'}) + + # Mock the subprocess.run calls + mock_run.side_effect = [ + MagicMock(returncode=0), # git checkout -b + MagicMock(returncode=1, stderr='Error: failed to push some refs'), # git push + ] + + # Test that RuntimeError is raised when git push fails + with pytest.raises( + RuntimeError, match='Failed to push changes to the remote repository' + ): + send_pull_request( + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITLAB, + patch_dir=repo_path, + pr_type='ready', + ) + + # Assert that subprocess.run was called twice + assert mock_run.call_count == 2 + + # Check the git checkout -b command + checkout_call = mock_run.call_args_list[0] + assert checkout_call[0][0] == [ + 'git', + '-C', + repo_path, + 'checkout', + '-b', + 'openhands-fix-issue-42', + ] + + # Check the git push command + push_call = mock_run.call_args_list[1] + assert push_call[0][0] == [ + 'git', + '-C', + repo_path, + 'push', + 'https://test-user:test-token@gitlab.com/test-owner/test-repo.git', + 'openhands-fix-issue-42', + ] + + # Assert that no pull request was created + mock_post.assert_not_called() + + +@patch('subprocess.run') +@patch('requests.post') +@patch('requests.get') +def test_send_pull_request_permission_error( + mock_get, mock_post, mock_run, mock_issue, mock_output_dir, mock_llm_config +): + repo_path = os.path.join(mock_output_dir, 'repo') + + # Mock API responses + mock_get.return_value = MagicMock(json=lambda: {'default_branch': 'main'}) + mock_post.return_value.status_code = 403 + + # Mock subprocess.run calls + mock_run.side_effect = [ + MagicMock(returncode=0), # git checkout -b + MagicMock(returncode=0), # git push + ] + + # Test that RuntimeError is raised when PR creation fails due to permissions + with pytest.raises( + RuntimeError, match='Failed to create pull request due to missing permissions.' + ): + send_pull_request( + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITLAB, + patch_dir=repo_path, + pr_type='ready', + ) + + # Assert that the branch was created and pushed + assert mock_run.call_count == 2 + mock_post.assert_called_once() + + +@patch('requests.post') +@patch('requests.get') +def test_reply_to_comment(mock_get, mock_post, mock_issue): + # Arrange: set up the test data + token = 'test_token' + comment_id = 'GID/test_comment_id' + reply = 'This is a test reply.' + + # Create an instance of GitlabIssueHandler + handler = GitlabIssueHandler( + owner='test-owner', repo='test-repo', token=token, username='test-user' + ) + + mock_get.return_value = MagicMock( + json=lambda: { + 'notes': [ + { + 'id': 123, + } + ] + } + ) + + # Mock the response from the GraphQL API + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'id': 123, + 'body': 'Openhands fix success summary\n\n\nThis is a test reply.', + 'createdAt': '2024-10-01T12:34:56Z', + } + + mock_post.return_value = mock_response + + # Act: call the function + handler.reply_to_comment(mock_issue.number, comment_id, reply) + + # Assert: check that the POST request was made with the correct parameters + data = { + 'body': 'Openhands fix success summary\n\n\nThis is a test reply.', + 'note_id': 123, + } + + # Check that the correct request was made to the API + mock_post.assert_called_once_with( + f'https://gitlab.com/api/v4/projects/{quote(f'{mock_issue.owner}/{mock_issue.repo}', safe="")}/merge_requests/{mock_issue.number}/discussions/{comment_id.split('/')[-1]}/notes', + headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/json', + }, + json=data, + ) + + # Check that the response status was checked (via response.raise_for_status) + mock_response.raise_for_status.assert_called_once() + + +@patch('openhands.resolver.send_pull_request.initialize_repo') +@patch('openhands.resolver.send_pull_request.apply_patch') +@patch('openhands.resolver.send_pull_request.update_existing_pull_request') +@patch('openhands.resolver.send_pull_request.make_commit') +def test_process_single_pr_update( + mock_make_commit, + mock_update_existing_pull_request, + mock_apply_patch, + mock_initialize_repo, + mock_output_dir, + mock_llm_config, +): + # Initialize test data + token = 'test_token' + username = 'test_user' + pr_type = 'draft' + + resolver_output = ResolverOutput( + issue=Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Issue 1', + body='Body 1', + closing_issues=[], + review_threads=[ + ReviewThread(comment='review comment for feedback', files=[]) + ], + thread_ids=['1'], + head_branch='branch 1', + ), + issue_type='pr', + instruction='Test instruction 1', + base_commit='def456', + git_patch='Test patch 1', + history=[], + metrics={}, + success=True, + comment_success=None, + result_explanation='[Test success 1]', + error=None, + ) + + mock_update_existing_pull_request.return_value = ( + 'https://gitlab.com/test-owner/test-repo/-/merge_requests/1' + ) + mock_initialize_repo.return_value = f'{mock_output_dir}/patches/pr_1' + + process_single_issue( + mock_output_dir, + resolver_output, + token, + username, + Platform.GITLAB, + pr_type, + mock_llm_config, + None, + False, + None, + ) + + mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'pr', 'branch 1') + mock_apply_patch.assert_called_once_with( + f'{mock_output_dir}/patches/pr_1', resolver_output.git_patch + ) + mock_make_commit.assert_called_once_with( + f'{mock_output_dir}/patches/pr_1', resolver_output.issue, 'pr' + ) + mock_update_existing_pull_request.assert_called_once_with( + issue=resolver_output.issue, + token=token, + username=username, + platform=Platform.GITLAB, + patch_dir=f'{mock_output_dir}/patches/pr_1', + additional_message='[Test success 1]', + llm_config=mock_llm_config, + ) + + +@patch('openhands.resolver.send_pull_request.initialize_repo') +@patch('openhands.resolver.send_pull_request.apply_patch') +@patch('openhands.resolver.send_pull_request.send_pull_request') +@patch('openhands.resolver.send_pull_request.make_commit') +def test_process_single_issue( + mock_make_commit, + mock_send_pull_request, + mock_apply_patch, + mock_initialize_repo, + mock_output_dir, + mock_llm_config, +): + # Initialize test data + token = 'test_token' + username = 'test_user' + pr_type = 'draft' + platform = Platform.GITLAB + + resolver_output = ResolverOutput( + issue=Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Issue 1', + body='Body 1', + ), + issue_type='issue', + instruction='Test instruction 1', + base_commit='def456', + git_patch='Test patch 1', + history=[], + metrics={}, + success=True, + comment_success=None, + result_explanation='Test success 1', + error=None, + ) + + # Mock return value + mock_send_pull_request.return_value = ( + 'https://gitlab.com/test-owner/test-repo/-/merge_requests/1' + ) + mock_initialize_repo.return_value = f'{mock_output_dir}/patches/issue_1' + + # Call the function + process_single_issue( + mock_output_dir, + resolver_output, + token, + username, + platform, + pr_type, + mock_llm_config, + None, + False, + None, + ) + + # Assert that the mocked functions were called with correct arguments + mock_initialize_repo.assert_called_once_with(mock_output_dir, 1, 'issue', 'def456') + mock_apply_patch.assert_called_once_with( + f'{mock_output_dir}/patches/issue_1', resolver_output.git_patch + ) + mock_make_commit.assert_called_once_with( + f'{mock_output_dir}/patches/issue_1', resolver_output.issue, 'issue' + ) + mock_send_pull_request.assert_called_once_with( + issue=resolver_output.issue, + token=token, + username=username, + platform=platform, + patch_dir=f'{mock_output_dir}/patches/issue_1', + pr_type=pr_type, + fork_owner=None, + additional_message=resolver_output.result_explanation, + target_branch=None, + reviewer=None, + pr_title=None, + ) + + +@patch('openhands.resolver.send_pull_request.initialize_repo') +@patch('openhands.resolver.send_pull_request.apply_patch') +@patch('openhands.resolver.send_pull_request.send_pull_request') +@patch('openhands.resolver.send_pull_request.make_commit') +def test_process_single_issue_unsuccessful( + mock_make_commit, + mock_send_pull_request, + mock_apply_patch, + mock_initialize_repo, + mock_output_dir, + mock_llm_config, +): + # Initialize test data + token = 'test_token' + username = 'test_user' + pr_type = 'draft' + + resolver_output = ResolverOutput( + issue=Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Issue 1', + body='Body 1', + ), + issue_type='issue', + instruction='Test instruction 1', + base_commit='def456', + git_patch='Test patch 1', + history=[], + metrics={}, + success=False, + comment_success=None, + result_explanation='', + error='Test error', + ) + + # Call the function + process_single_issue( + mock_output_dir, + resolver_output, + token, + username, + Platform.GITLAB, + pr_type, + mock_llm_config, + None, + False, + None, + ) + + # Assert that none of the mocked functions were called + mock_initialize_repo.assert_not_called() + mock_apply_patch.assert_not_called() + mock_make_commit.assert_not_called() + mock_send_pull_request.assert_not_called() + + +@patch('openhands.resolver.send_pull_request.load_all_resolver_outputs') +@patch('openhands.resolver.send_pull_request.process_single_issue') +def test_process_all_successful_issues( + mock_process_single_issue, mock_load_all_resolver_outputs, mock_llm_config +): + # Create ResolverOutput objects with properly initialized GitlabIssue instances + resolver_output_1 = ResolverOutput( + issue=Issue( + owner='test-owner', + repo='test-repo', + number=1, + title='Issue 1', + body='Body 1', + ), + issue_type='issue', + instruction='Test instruction 1', + base_commit='def456', + git_patch='Test patch 1', + history=[], + metrics={}, + success=True, + comment_success=None, + result_explanation='Test success 1', + error=None, + ) + + resolver_output_2 = ResolverOutput( + issue=Issue( + owner='test-owner', + repo='test-repo', + number=2, + title='Issue 2', + body='Body 2', + ), + issue_type='issue', + instruction='Test instruction 2', + base_commit='ghi789', + git_patch='Test patch 2', + history=[], + metrics={}, + success=False, + comment_success=None, + result_explanation='', + error='Test error 2', + ) + + resolver_output_3 = ResolverOutput( + issue=Issue( + owner='test-owner', + repo='test-repo', + number=3, + title='Issue 3', + body='Body 3', + ), + issue_type='issue', + instruction='Test instruction 3', + base_commit='jkl012', + git_patch='Test patch 3', + history=[], + metrics={}, + success=True, + comment_success=None, + result_explanation='Test success 3', + error=None, + ) + + mock_load_all_resolver_outputs.return_value = [ + resolver_output_1, + resolver_output_2, + resolver_output_3, + ] + + # Call the function + process_all_successful_issues( + 'output_dir', + 'token', + 'username', + Platform.GITLAB, + 'draft', + mock_llm_config, # llm_config + None, # fork_owner + ) + + # Assert that process_single_issue was called for successful issues only + assert mock_process_single_issue.call_count == 2 + + # Check that the function was called with the correct arguments for successful issues + mock_process_single_issue.assert_has_calls( + [ + call( + 'output_dir', + resolver_output_1, + 'token', + 'username', + Platform.GITLAB, + 'draft', + mock_llm_config, + None, + False, + None, + ), + call( + 'output_dir', + resolver_output_3, + 'token', + 'username', + Platform.GITLAB, + 'draft', + mock_llm_config, + None, + False, + None, + ), + ] + ) + + # Add more assertions as needed to verify the behavior of the function + + +@patch('requests.get') +@patch('subprocess.run') +def test_send_pull_request_branch_naming( + mock_run, mock_get, mock_issue, mock_output_dir, mock_llm_config +): + repo_path = os.path.join(mock_output_dir, 'repo') + + # Mock API responses + mock_get.side_effect = [ + MagicMock(status_code=200), # First branch exists + MagicMock(status_code=200), # Second branch exists + MagicMock(status_code=404), # Third branch doesn't exist + MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch + MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch + ] + + # Mock subprocess.run calls + mock_run.side_effect = [ + MagicMock(returncode=0), # git checkout -b + MagicMock(returncode=0), # git push + ] + + # Call the function + result = send_pull_request( + issue=mock_issue, + token='test-token', + username='test-user', + platform=Platform.GITLAB, + patch_dir=repo_path, + pr_type='branch', + ) + + # Assert API calls + assert mock_get.call_count == 5 + + # Check branch creation and push + assert mock_run.call_count == 2 + checkout_call, push_call = mock_run.call_args_list + + assert checkout_call == call( + ['git', '-C', repo_path, 'checkout', '-b', 'openhands-fix-issue-42-try3'], + capture_output=True, + text=True, + ) + assert push_call == call( + [ + 'git', + '-C', + repo_path, + 'push', + 'https://test-user:test-token@gitlab.com/test-owner/test-repo.git', + 'openhands-fix-issue-42-try3', + ], + capture_output=True, + text=True, + ) + + # Check the result + assert ( + result + == 'https://gitlab.com/test-owner/test-repo/-/compare/main...openhands-fix-issue-42-try3' + ) + + +@patch('openhands.resolver.send_pull_request.argparse.ArgumentParser') +@patch('openhands.resolver.send_pull_request.process_all_successful_issues') +@patch('openhands.resolver.send_pull_request.process_single_issue') +@patch('openhands.resolver.send_pull_request.load_single_resolver_output') +@patch('openhands.resolver.send_pull_request.identify_token') +@patch('os.path.exists') +@patch('os.getenv') +def test_main( + mock_getenv, + mock_path_exists, + mock_identify_token, + mock_load_single_resolver_output, + mock_process_single_issue, + mock_process_all_successful_issues, + mock_parser, +): + from openhands.resolver.send_pull_request import main + + # Setup mock parser + mock_args = MagicMock() + mock_args.token = None + mock_args.username = 'mock_username' + mock_args.output_dir = '/mock/output' + mock_args.pr_type = 'draft' + mock_args.issue_number = '42' + mock_args.fork_owner = None + mock_args.send_on_failure = False + mock_args.llm_model = 'mock_model' + mock_args.llm_base_url = 'mock_url' + mock_args.llm_api_key = 'mock_key' + mock_args.target_branch = None + mock_args.reviewer = None + mock_args.pr_title = None + mock_parser.return_value.parse_args.return_value = mock_args + + # Setup environment variables + mock_getenv.side_effect = ( + lambda key, default=None: 'mock_token' if key == 'GITLAB_TOKEN' else default + ) + + # Setup path exists + mock_path_exists.return_value = True + + # Setup mock resolver output + mock_resolver_output = MagicMock() + mock_load_single_resolver_output.return_value = mock_resolver_output + + mock_identify_token.return_value = Platform.GITLAB + + # Run main function + main() + + mock_identify_token.assert_called_with('mock_token') + + llm_config = LLMConfig( + model=mock_args.llm_model, + base_url=mock_args.llm_base_url, + api_key=mock_args.llm_api_key, + ) + + # Use any_call instead of assert_called_with for more flexible matching + assert mock_process_single_issue.call_args == call( + '/mock/output', + mock_resolver_output, + 'mock_token', + 'mock_username', + Platform.GITLAB, + 'draft', + llm_config, + None, + False, + mock_args.target_branch, + mock_args.reviewer, + mock_args.pr_title, + ) + + # Other assertions + mock_parser.assert_called_once() + mock_getenv.assert_any_call('GITLAB_TOKEN') + mock_path_exists.assert_called_with('/mock/output') + mock_load_single_resolver_output.assert_called_with('/mock/output/output.jsonl', 42) + + # Test for 'all_successful' issue number + mock_args.issue_number = 'all_successful' + main() + mock_process_all_successful_issues.assert_called_with( + '/mock/output', + 'mock_token', + 'mock_username', + Platform.GITLAB, + 'draft', + llm_config, + None, + ) + + # Test for invalid issue number + mock_args.issue_number = 'invalid' + with pytest.raises(ValueError): + main() + + # Test for invalid token + mock_identify_token.return_value = Platform.INVALID + with pytest.raises(ValueError, match='Token is invalid.'): + main() + + +@patch('subprocess.run') +def test_make_commit_escapes_issue_title(mock_subprocess_run): + # Setup + repo_dir = '/path/to/repo' + issue = Issue( + owner='test-owner', + repo='test-repo', + number=42, + title='Issue with "quotes" and $pecial characters', + body='Test body', + ) + + # Mock subprocess.run to return success for all calls + mock_subprocess_run.return_value = MagicMock( + returncode=0, stdout='sample output', stderr='' + ) + + # Call the function + issue_type = 'issue' + make_commit(repo_dir, issue, issue_type) + + # Assert that subprocess.run was called with the correct arguments + calls = mock_subprocess_run.call_args_list + assert len(calls) == 4 # git config check, git add, git commit + + # Check the git commit call + git_commit_call = calls[3][0][0] + expected_commit_message = ( + 'Fix issue #42: Issue with "quotes" and $pecial characters' + ) + assert [ + 'git', + '-C', + '/path/to/repo', + 'commit', + '-m', + expected_commit_message, + ] == git_commit_call + + +@patch('subprocess.run') +def test_make_commit_no_changes(mock_subprocess_run): + # Setup + repo_dir = '/path/to/repo' + issue = Issue( + owner='test-owner', + repo='test-repo', + number=42, + title='Issue with no changes', + body='Test body', + ) + + # Mock subprocess.run to simulate no changes in the repo + mock_subprocess_run.side_effect = [ + MagicMock(returncode=0), + MagicMock(returncode=0), + MagicMock(returncode=1, stdout=''), # git status --porcelain (no changes) + ] + + with pytest.raises( + RuntimeError, match='ERROR: Openhands failed to make code changes.' + ): + make_commit(repo_dir, issue, 'issue') + + # Check that subprocess.run was called for checking git status and add, but not commit + assert mock_subprocess_run.call_count == 3 + git_status_call = mock_subprocess_run.call_args_list[2][0][0] + assert f'git -C {repo_dir} status --porcelain' in git_status_call + + +def test_apply_patch_rename_directory(mock_output_dir): + # Create a sample directory structure + old_dir = os.path.join(mock_output_dir, 'prompts', 'resolve') + os.makedirs(old_dir) + + # Create test files + test_files = [ + 'issue-success-check.jinja', + 'pr-feedback-check.jinja', + 'pr-thread-check.jinja', + ] + for filename in test_files: + file_path = os.path.join(old_dir, filename) + with open(file_path, 'w') as f: + f.write(f'Content of {filename}') + + # Create a patch that renames the directory + patch_content = """diff --git a/prompts/resolve/issue-success-check.jinja b/prompts/guess_success/issue-success-check.jinja +similarity index 100% +rename from prompts/resolve/issue-success-check.jinja +rename to prompts/guess_success/issue-success-check.jinja +diff --git a/prompts/resolve/pr-feedback-check.jinja b/prompts/guess_success/pr-feedback-check.jinja +similarity index 100% +rename from prompts/resolve/pr-feedback-check.jinja +rename to prompts/guess_success/pr-feedback-check.jinja +diff --git a/prompts/resolve/pr-thread-check.jinja b/prompts/guess_success/pr-thread-check.jinja +similarity index 100% +rename from prompts/resolve/pr-thread-check.jinja +rename to prompts/guess_success/pr-thread-check.jinja""" + + # Apply the patch + apply_patch(mock_output_dir, patch_content) + + # Check if files were moved correctly + new_dir = os.path.join(mock_output_dir, 'prompts', 'guess_success') + assert not os.path.exists(old_dir), 'Old directory still exists' + assert os.path.exists(new_dir), 'New directory was not created' + + # Check if all files were moved and content preserved + for filename in test_files: + old_path = os.path.join(old_dir, filename) + new_path = os.path.join(new_dir, filename) + assert not os.path.exists(old_path), f'Old file {filename} still exists' + assert os.path.exists(new_path), f'New file {filename} was not created' + with open(new_path, 'r') as f: + content = f.read() + assert content == f'Content of {filename}', f'Content mismatch for {filename}' diff --git a/tests/unit/resolver/test_issue_references.py b/tests/unit/resolver/test_issue_references.py index 409f276d5abc..0a117492bf01 100644 --- a/tests/unit/resolver/test_issue_references.py +++ b/tests/unit/resolver/test_issue_references.py @@ -1,19 +1,15 @@ -from openhands.core.config.llm_config import LLMConfig -from openhands.resolver.issue_definitions import IssueHandler +from openhands.resolver.utils import extract_issue_references def test_extract_issue_references(): - llm_config = LLMConfig(model='test', api_key='test') - handler = IssueHandler('test-owner', 'test-repo', 'test-token', llm_config) - # Test basic issue reference - assert handler._extract_issue_references('Fixes #123') == [123] + assert extract_issue_references('Fixes #123') == [123] # Test multiple issue references - assert handler._extract_issue_references('Fixes #123, #456') == [123, 456] + assert extract_issue_references('Fixes #123, #456') == [123, 456] # Test issue references in code blocks should be ignored - assert handler._extract_issue_references(""" + assert extract_issue_references(""" Here's a code block: ```python # This is a comment with #123 @@ -24,21 +20,37 @@ def func(): """) == [789] # Test issue references in inline code should be ignored - assert handler._extract_issue_references( + assert extract_issue_references( + 'This `#123` should be ignored but #456 should be extracted' + ) == [456] + assert extract_issue_references( 'This `#123` should be ignored but #456 should be extracted' ) == [456] # Test issue references in URLs should be ignored - assert handler._extract_issue_references( + assert extract_issue_references( + 'Check http://example.com/#123 but #456 should be extracted' + ) == [456] + assert extract_issue_references( 'Check http://example.com/#123 but #456 should be extracted' ) == [456] # Test issue references in markdown links should be extracted - assert handler._extract_issue_references( - '[Link to #123](http://example.com) and #456' - ) == [123, 456] + assert extract_issue_references('[Link to #123](http://example.com) and #456') == [ + 123, + 456, + ] + assert extract_issue_references('[Link to #123](http://example.com) and #456') == [ + 123, + 456, + ] # Test issue references with text around them - assert handler._extract_issue_references( - 'Issue #123 is fixed and #456 is pending' - ) == [123, 456] + assert extract_issue_references('Issue #123 is fixed and #456 is pending') == [ + 123, + 456, + ] + assert extract_issue_references('Issue #123 is fixed and #456 is pending') == [ + 123, + 456, + ] diff --git a/tests/unit/test_action_serialization.py b/tests/unit/test_action_serialization.py index 32b29e44b231..84eb03148454 100644 --- a/tests/unit/test_action_serialization.py +++ b/tests/unit/test_action_serialization.py @@ -5,11 +5,13 @@ BrowseInteractiveAction, BrowseURLAction, CmdRunAction, + FileEditAction, FileReadAction, FileWriteAction, MessageAction, ) from openhands.events.action.action import ActionConfirmationStatus +from openhands.events.action.files import FileEditSource, FileReadSource from openhands.events.serialization import ( event_from_dict, event_to_dict, @@ -135,7 +137,7 @@ def test_file_read_action_serialization_deserialization(): 'end': -1, 'thought': 'None', 'impl_source': 'default', - 'translated_ipython_code': '', + 'view_range': None, }, } serialization_deserialization(original_action_dict, FileReadAction) @@ -155,7 +157,47 @@ def test_file_write_action_serialization_deserialization(): serialization_deserialization(original_action_dict, FileWriteAction) -def test_legacy_serialization(): +def test_file_edit_action_aci_serialization_deserialization(): + original_action_dict = { + 'action': 'edit', + 'args': { + 'path': '/path/to/file.txt', + 'command': 'str_replace', + 'file_text': None, + 'old_str': 'old text', + 'new_str': 'new text', + 'insert_line': None, + 'content': '', + 'start': 1, + 'end': -1, + 'thought': 'Replacing text', + 'impl_source': 'oh_aci', + }, + } + serialization_deserialization(original_action_dict, FileEditAction) + + +def test_file_edit_action_llm_serialization_deserialization(): + original_action_dict = { + 'action': 'edit', + 'args': { + 'path': '/path/to/file.txt', + 'command': None, + 'file_text': None, + 'old_str': None, + 'new_str': None, + 'insert_line': None, + 'content': 'Updated content', + 'start': 1, + 'end': 10, + 'thought': 'Updating file content', + 'impl_source': 'llm_based_edit', + }, + } + serialization_deserialization(original_action_dict, FileEditAction) + + +def test_cmd_run_action_legacy_serialization(): original_action_dict = { 'action': 'run', 'args': { @@ -183,3 +225,167 @@ def test_legacy_serialization(): assert event_dict['args']['command'] == 'echo "Hello world"' assert event_dict['args']['thought'] == '' assert event_dict['args']['is_input'] is False + + +def test_file_llm_based_edit_action_legacy_serialization(): + original_action_dict = { + 'action': 'edit', + 'args': { + 'path': '/path/to/file.txt', + 'content': 'dummy content', + 'start': 1, + 'end': -1, + 'thought': 'Replacing text', + 'impl_source': 'oh_aci', + 'translated_ipython_code': None, + }, + } + event = event_from_dict(original_action_dict) + assert isinstance(event, Action) + assert isinstance(event, FileEditAction) + + # Common arguments + assert event.path == '/path/to/file.txt' + assert event.thought == 'Replacing text' + assert event.impl_source == FileEditSource.OH_ACI + assert not hasattr(event, 'translated_ipython_code') + + # OH_ACI arguments + assert event.command == '' + assert event.file_text is None + assert event.old_str is None + assert event.new_str is None + assert event.insert_line is None + + # LLM-based editing arguments + assert event.content == 'dummy content' + assert event.start == 1 + assert event.end == -1 + + event_dict = event_to_dict(event) + assert 'translated_ipython_code' not in event_dict['args'] + + # Common arguments + assert event_dict['args']['path'] == '/path/to/file.txt' + assert event_dict['args']['impl_source'] == 'oh_aci' + assert event_dict['args']['thought'] == 'Replacing text' + + # OH_ACI arguments + assert event_dict['args']['command'] == '' + assert event_dict['args']['file_text'] is None + assert event_dict['args']['old_str'] is None + assert event_dict['args']['new_str'] is None + assert event_dict['args']['insert_line'] is None + + # LLM-based editing arguments + assert event_dict['args']['content'] == 'dummy content' + assert event_dict['args']['start'] == 1 + assert event_dict['args']['end'] == -1 + + +def test_file_ohaci_edit_action_legacy_serialization(): + original_action_dict = { + 'action': 'edit', + 'args': { + 'path': '/workspace/game_2048.py', + 'content': '', + 'start': 1, + 'end': -1, + 'thought': "I'll help you create a simple 2048 game in Python. I'll use the str_replace_editor to create the file.", + 'impl_source': 'oh_aci', + 'translated_ipython_code': "print(file_editor(**{'command': 'create', 'path': '/workspace/game_2048.py', 'file_text': 'New file content'}))", + }, + } + event = event_from_dict(original_action_dict) + assert isinstance(event, Action) + assert isinstance(event, FileEditAction) + + # Common arguments + assert event.path == '/workspace/game_2048.py' + assert ( + event.thought + == "I'll help you create a simple 2048 game in Python. I'll use the str_replace_editor to create the file." + ) + assert event.impl_source == FileEditSource.OH_ACI + assert not hasattr(event, 'translated_ipython_code') + + # OH_ACI arguments + assert event.command == 'create' + assert event.file_text == 'New file content' + assert event.old_str is None + assert event.new_str is None + assert event.insert_line is None + + # LLM-based editing arguments + assert event.content == '' + assert event.start == 1 + assert event.end == -1 + + event_dict = event_to_dict(event) + assert 'translated_ipython_code' not in event_dict['args'] + + # Common arguments + assert event_dict['args']['path'] == '/workspace/game_2048.py' + assert event_dict['args']['impl_source'] == 'oh_aci' + assert ( + event_dict['args']['thought'] + == "I'll help you create a simple 2048 game in Python. I'll use the str_replace_editor to create the file." + ) + + # OH_ACI arguments + assert event_dict['args']['command'] == 'create' + assert event_dict['args']['file_text'] == 'New file content' + assert event_dict['args']['old_str'] is None + assert event_dict['args']['new_str'] is None + assert event_dict['args']['insert_line'] is None + + # LLM-based editing arguments + assert event_dict['args']['content'] == '' + assert event_dict['args']['start'] == 1 + assert event_dict['args']['end'] == -1 + + +def test_file_read_action_legacy_serialization(): + original_action_dict = { + 'action': 'read', + 'args': { + 'path': '/workspace/test.txt', + 'start': 0, + 'end': -1, + 'thought': 'Reading the file contents', + 'impl_source': 'oh_aci', + 'translated_ipython_code': "print(file_editor(**{'command': 'view', 'path': '/workspace/test.txt'}))", + }, + } + + event = event_from_dict(original_action_dict) + assert isinstance(event, Action) + assert isinstance(event, FileReadAction) + + # Common arguments + assert event.path == '/workspace/test.txt' + assert event.thought == 'Reading the file contents' + assert event.impl_source == FileReadSource.OH_ACI + assert not hasattr(event, 'translated_ipython_code') + assert not hasattr( + event, 'command' + ) # FileReadAction should not have command attribute + + # Read-specific arguments + assert event.start == 0 + assert event.end == -1 + + event_dict = event_to_dict(event) + assert 'translated_ipython_code' not in event_dict['args'] + assert ( + 'command' not in event_dict['args'] + ) # command should not be in serialized args + + # Common arguments in serialized form + assert event_dict['args']['path'] == '/workspace/test.txt' + assert event_dict['args']['impl_source'] == 'oh_aci' + assert event_dict['args']['thought'] == 'Reading the file contents' + + # Read-specific arguments in serialized form + assert event_dict['args']['start'] == 0 + assert event_dict['args']['end'] == -1 diff --git a/tests/unit/test_arg_parser.py b/tests/unit/test_arg_parser.py index 51c736f19c05..b71cd5e2c18b 100644 --- a/tests/unit/test_arg_parser.py +++ b/tests/unit/test_arg_parser.py @@ -128,6 +128,7 @@ def test_help_message(capsys): '--eval-note EVAL_NOTE', '--eval-ids EVAL_IDS', '-l LLM_CONFIG, --llm-config LLM_CONFIG', + '--agent-config AGENT_CONFIG', '-n NAME, --name NAME', '--config-file CONFIG_FILE', '--no-auto-continue', @@ -137,4 +138,4 @@ def test_help_message(capsys): assert element in help_output, f"Expected '{element}' to be in the help message" option_count = help_output.count(' -') - assert option_count == 17, f'Expected 17 options, found {option_count}' + assert option_count == 18, f'Expected 18 options, found {option_count}' diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 5edfd64cda90..10f09447ba6c 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -9,7 +9,9 @@ AppConfig, LLMConfig, finalize_config, + get_agent_config_arg, get_llm_config_arg, + load_app_config, load_from_env, load_from_toml, ) @@ -781,3 +783,58 @@ def test_get_agent_configs(default_config, temp_toml_file): assert codeact_config.memory_enabled is True browsing_config = default_config.get_agent_configs().get('BrowsingAgent') assert browsing_config.memory_max_threads == 10 + + +def test_get_agent_config_arg(temp_toml_file): + temp_toml = """ +[core] +max_iterations = 100 +max_budget_per_task = 4.0 + +[agent.CodeActAgent] +memory_enabled = true +enable_prompt_extensions = false + +[agent.BrowsingAgent] +memory_enabled = false +enable_prompt_extensions = true +memory_max_threads = 10 +""" + + with open(temp_toml_file, 'w') as f: + f.write(temp_toml) + + agent_config = get_agent_config_arg('CodeActAgent', temp_toml_file) + assert agent_config.memory_enabled + assert not agent_config.enable_prompt_extensions + + agent_config2 = get_agent_config_arg('BrowsingAgent', temp_toml_file) + assert not agent_config2.memory_enabled + assert agent_config2.enable_prompt_extensions + assert agent_config2.memory_max_threads == 10 + + +def test_agent_config_custom_group_name(temp_toml_file): + temp_toml = """ +[core] +max_iterations = 99 + +[agent.group1] +memory_enabled = true + +[agent.group2] +memory_enabled = false +""" + with open(temp_toml_file, 'w') as f: + f.write(temp_toml) + + # just a sanity check that load app config wouldn't fail + app_config = load_app_config(config_file=temp_toml_file) + assert app_config.max_iterations == 99 + + # run_infer in evaluation can use `get_agent_config_arg` to load custom + # agent configs with any group name (not just agent name) + agent_config1 = get_agent_config_arg('group1', temp_toml_file) + assert agent_config1.memory_enabled + agent_config2 = get_agent_config_arg('group2', temp_toml_file) + assert not agent_config2.memory_enabled diff --git a/tests/unit/test_github_service.py b/tests/unit/test_github_service.py new file mode 100644 index 000000000000..222be16767c4 --- /dev/null +++ b/tests/unit/test_github_service.py @@ -0,0 +1,81 @@ +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest +from pydantic import SecretStr + +from openhands.integrations.github.github_service import GitHubService +from openhands.integrations.github.github_types import GhAuthenticationError + + +@pytest.mark.asyncio +async def test_github_service_token_handling(): + # Test initialization with SecretStr token + token = SecretStr('test-token') + service = GitHubService(user_id=None, token=token) + assert service.token == token + assert service.token.get_secret_value() == 'test-token' + + # Test headers contain the token correctly + headers = await service._get_github_headers() + assert headers['Authorization'] == 'Bearer test-token' + assert headers['Accept'] == 'application/vnd.github.v3+json' + + # Test initialization without token + service = GitHubService(user_id='test-user') + assert service.token == SecretStr('') + + +@pytest.mark.asyncio +async def test_github_service_token_refresh(): + # Test that token refresh is only attempted when refresh=True + token = SecretStr('test-token') + service = GitHubService(user_id=None, token=token) + assert not service.refresh + + # Test token expiry detection + assert service._has_token_expired(401) + assert not service._has_token_expired(200) + assert not service._has_token_expired(404) + + # Test get_latest_token returns a copy of the current token + latest_token = await service.get_latest_token() + assert isinstance(latest_token, SecretStr) + assert latest_token.get_secret_value() == 'test-token' # Compare with known value + + +@pytest.mark.asyncio +async def test_github_service_fetch_data(): + # Mock httpx.AsyncClient for testing API calls + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json.return_value = {'login': 'test-user'} + mock_response.raise_for_status = Mock() + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__.return_value = None + + with patch('httpx.AsyncClient', return_value=mock_client): + service = GitHubService(user_id=None, token=SecretStr('test-token')) + _ = await service._fetch_data('https://api.github.com/user') + + # Verify the request was made with correct headers + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + headers = call_args[1]['headers'] + assert headers['Authorization'] == 'Bearer test-token' + + # Test error handling with 401 status code + mock_response.status_code = 401 + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + message='401 Unauthorized', request=Mock(), response=mock_response + ) + + # Reset the mock to test error handling + mock_client.get.reset_mock() + mock_client.get.return_value = mock_response + + with pytest.raises(GhAuthenticationError): + _ = await service._fetch_data('https://api.github.com/user') diff --git a/tests/unit/test_micro_agents.py b/tests/unit/test_micro_agents.py index c7461bbda226..7f78df16b183 100644 --- a/tests/unit/test_micro_agents.py +++ b/tests/unit/test_micro_agents.py @@ -53,7 +53,7 @@ def test_all_agents_are_loaded(): def test_coder_agent_with_summary(event_stream: EventStream, agent_configs: dict): - """Coder agent should render code summary as part of prompt""" + """Coder agent should render code summary as part of prompt.""" mock_llm = MagicMock() content = json.dumps({'action': 'finish', 'args': {}}) mock_llm.completion.return_value = {'choices': [{'message': {'content': content}}]} diff --git a/tests/unit/test_observation_serialization.py b/tests/unit/test_observation_serialization.py index 626ea2c14774..64888bbb7e2d 100644 --- a/tests/unit/test_observation_serialization.py +++ b/tests/unit/test_observation_serialization.py @@ -1,6 +1,8 @@ +from openhands.events.action.files import FileEditSource from openhands.events.observation import ( CmdOutputMetadata, CmdOutputObservation, + FileEditObservation, Observation, ) from openhands.events.serialization import ( @@ -146,3 +148,88 @@ def test_legacy_serialization(): assert event_dict['extras']['metadata']['pid'] == 3 assert event_dict['extras']['command'] == 'ls -l' assert event_dict['extras']['hidden'] is False + + +def test_file_edit_observation_serialization(): + original_observation_dict = { + 'observation': 'edit', + 'extras': { + '_diff_cache': None, + 'impl_source': FileEditSource.LLM_BASED_EDIT, + 'new_content': None, + 'old_content': None, + 'path': '', + 'prev_exist': False, + }, + 'message': 'I edited the file .', + 'content': '[Existing file /path/to/file.txt is edited with 1 changes.]', + } + serialization_deserialization(original_observation_dict, FileEditObservation) + + +def test_file_edit_observation_new_file_serialization(): + original_observation_dict = { + 'observation': 'edit', + 'content': '[New file /path/to/newfile.txt is created with the provided content.]', + 'extras': { + '_diff_cache': None, + 'impl_source': FileEditSource.LLM_BASED_EDIT, + 'new_content': None, + 'old_content': None, + 'path': '', + 'prev_exist': False, + }, + 'message': 'I edited the file .', + } + + serialization_deserialization(original_observation_dict, FileEditObservation) + + +def test_file_edit_observation_oh_aci_serialization(): + original_observation_dict = { + 'observation': 'edit', + 'content': 'The file /path/to/file.txt is edited with the provided content.', + 'extras': { + '_diff_cache': None, + 'impl_source': FileEditSource.LLM_BASED_EDIT, + 'new_content': None, + 'old_content': None, + 'path': '', + 'prev_exist': False, + }, + 'message': 'I edited the file .', + } + serialization_deserialization(original_observation_dict, FileEditObservation) + + +def test_file_edit_observation_legacy_serialization(): + original_observation_dict = { + 'observation': 'edit', + 'content': 'content', + 'extras': { + 'path': '/workspace/game_2048.py', + 'prev_exist': False, + 'old_content': None, + 'new_content': 'new content', + 'impl_source': 'oh_aci', + 'formatted_output_and_error': 'File created successfully at: /workspace/game_2048.py', + }, + } + + event = event_from_dict(original_observation_dict) + assert isinstance(event, Observation) + assert isinstance(event, FileEditObservation) + assert event.impl_source == FileEditSource.OH_ACI + assert event.path == '/workspace/game_2048.py' + assert event.prev_exist is False + assert event.old_content is None + assert event.new_content == 'new content' + assert not hasattr(event, 'formatted_output_and_error') + + event_dict = event_to_dict(event) + assert event_dict['extras']['impl_source'] == 'oh_aci' + assert event_dict['extras']['path'] == '/workspace/game_2048.py' + assert event_dict['extras']['prev_exist'] is False + assert event_dict['extras']['old_content'] is None + assert event_dict['extras']['new_content'] == 'new content' + assert 'formatted_output_and_error' not in event_dict['extras'] diff --git a/tests/unit/test_patch_whitespace.py b/tests/unit/test_patch_whitespace.py new file mode 100644 index 000000000000..197584f4e723 --- /dev/null +++ b/tests/unit/test_patch_whitespace.py @@ -0,0 +1,79 @@ +from openhands.resolver.patching.apply import apply_diff +from openhands.resolver.patching.patch import parse_patch + + +def test_patch_whitespace_mismatch(): + """Test that the patch application succeeds even when whitespace doesn't match.""" + # Original content has a line with spaces + original_content = """class Example: + def method(self): + pass + + def another(self): + pass""" + + # Patch expects an empty line (no spaces) + patch_text = """diff --git a/example.py b/example.py +index 1234567..89abcdef 100644 +--- a/example.py ++++ b/example.py +@@ -2,6 +2,10 @@ class Example: + def method(self): + pass + ++ new_field: str = "value" ++ + def another(self): + pass""" + + patch = next(parse_patch(patch_text)) + # The patch should still work because we normalize whitespace + new_content = apply_diff(patch, original_content) + assert new_content == [ + 'class Example:', + ' def method(self):', + ' pass', + '', + ' new_field: str = "value"', + '', + ' def another(self):', + ' pass', + ] + + +def test_patch_whitespace_match(): + """Test that the patch application succeeds when whitespace matches.""" + # Original content has an empty line (no spaces) + original_content = """class Example: + def method(self): + pass + + def another(self): + pass""" + + # Patch expects an empty line (no spaces) + patch_text = """diff --git a/example.py b/example.py +index 1234567..89abcdef 100644 +--- a/example.py ++++ b/example.py +@@ -2,6 +2,10 @@ class Example: + def method(self): + pass + ++ new_field: str = "value" ++ + def another(self): + pass""" + + patch = next(parse_patch(patch_text)) + new_content = apply_diff(patch, original_content) + assert new_content == [ + 'class Example:', + ' def method(self):', + ' pass', + '', + ' new_field: str = "value"', + '', + ' def another(self):', + ' pass', + ]