mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7ffb4e9e2 | |||
| 5a5a7de677 | |||
| 8f7c53687f |
@@ -24,7 +24,7 @@ on:
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
type: string
|
||||
default: "anthropic/claude-3-7-sonnet-20250219"
|
||||
default: "anthropic/claude-3-5-sonnet-20241022"
|
||||
LLM_API_VERSION:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
+1
-1
@@ -118,7 +118,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.34-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.35-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -52,17 +52,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -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.34-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.35-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.35-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:
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -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.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -44,7 +44,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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -42,7 +42,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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
|
||||
|
||||
```bash
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
@@ -82,5 +82,5 @@ docker network create openhands-network
|
||||
# 分離されたネットワークで OpenHands を実行
|
||||
docker run # ... \
|
||||
--network openhands-network \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "escreva um script bash que imprima oi"
|
||||
```
|
||||
|
||||
|
||||
@@ -58,17 +58,17 @@
|
||||
A maneira mais fácil de executar o OpenHands é no Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
Você encontrará o OpenHands em execução em http://localhost:3000!
|
||||
|
||||
@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](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)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# Using GitLab CI Runners
|
||||
@@ -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.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.35-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.35-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.34
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.35
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AppSettingsScreen from "#/routes/app-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
const renderAppSettingsScreen = () =>
|
||||
render(<AppSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render the screen", () => {
|
||||
renderAppSettingsScreen();
|
||||
screen.getByTestId("app-settings-screen");
|
||||
});
|
||||
|
||||
it("should render the correct default values", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "no",
|
||||
user_consents_to_analytics: true,
|
||||
enable_sound_notifications: true,
|
||||
});
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const language = screen.getByTestId("language-input");
|
||||
const analytics = screen.getByTestId("enable-analytics-switch");
|
||||
const sound = screen.getByTestId("enable-sound-notifications-switch");
|
||||
|
||||
expect(language).toHaveValue("Norsk");
|
||||
expect(analytics).toBeChecked();
|
||||
expect(sound).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the language options", async () => {
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const language = await screen.findByTestId("language-input");
|
||||
await userEvent.click(language);
|
||||
|
||||
AvailableLanguages.forEach((lang) => {
|
||||
const option = screen.getByText(lang.label);
|
||||
expect(option).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should submit the form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const language = await screen.findByTestId("language-input");
|
||||
const analytics = await screen.findByTestId("enable-analytics-switch");
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
|
||||
expect(language).toHaveValue("English");
|
||||
expect(analytics).not.toBeChecked();
|
||||
expect(sound).not.toBeChecked();
|
||||
|
||||
// change language
|
||||
await userEvent.click(language);
|
||||
const norsk = screen.getByText("Norsk");
|
||||
await userEvent.click(norsk);
|
||||
expect(language).toHaveValue("Norsk");
|
||||
|
||||
// toggle options
|
||||
await userEvent.click(analytics);
|
||||
expect(analytics).toBeChecked();
|
||||
await userEvent.click(sound);
|
||||
expect(sound).toBeChecked();
|
||||
|
||||
// submit the form
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
language: "no",
|
||||
user_consents_to_analytics: true,
|
||||
enable_sound_notifications: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should only enable the submit button when there are changes", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
// Language check
|
||||
const language = await screen.findByTestId("language-input");
|
||||
await userEvent.click(language);
|
||||
const norsk = screen.getByText("Norsk");
|
||||
await userEvent.click(norsk);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(language);
|
||||
const english = screen.getByText("English");
|
||||
await userEvent.click(english);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
// Analytics check
|
||||
const analytics = await screen.findByTestId("enable-analytics-switch");
|
||||
await userEvent.click(analytics);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(analytics);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
// Sound check
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(sound);
|
||||
expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const handleCaptureConsentsSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const analytics = await screen.findByTestId("enable-analytics-switch");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.click(analytics);
|
||||
await userEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
|
||||
const handleCaptureConsentsSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const analytics = await screen.findByTestId("enable-analytics-switch");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.click(analytics);
|
||||
await userEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
|
||||
);
|
||||
});
|
||||
|
||||
// flaky test
|
||||
it.skip("should disable the button when submitting changes", async () => {
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(submit).toHaveTextContent("Saving...");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await waitFor(() => expect(submit).toHaveTextContent("Save"));
|
||||
});
|
||||
|
||||
it("should disable the button after submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(submit).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status toasts", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const sound = await screen.findByTestId(
|
||||
"enable-sound-notifications-switch",
|
||||
);
|
||||
await userEvent.click(sound);
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,461 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
const VALID_OSS_CONFIG: GetConfigResponse = {
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
},
|
||||
};
|
||||
|
||||
const VALID_SAAS_CONFIG: GetConfigResponse = {
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
},
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const GitSettingsRouterStub = createRoutesStub([
|
||||
{
|
||||
Component: GitSettingsScreen,
|
||||
path: "/settings/github",
|
||||
},
|
||||
]);
|
||||
|
||||
const renderGitSettingsScreen = () => {
|
||||
const { rerender, ...rest } = render(
|
||||
<GitSettingsRouterStub initialEntries={["/settings/github"]} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderGitSettingsScreen = () =>
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/github"]} />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
rerender: rerenderGitSettingsScreen,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Since we don't recreate the query client on every test, we need to
|
||||
// reset the query client before each test to avoid state leaks
|
||||
// between tests.
|
||||
queryClient.invalidateQueries();
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
it("should render", async () => {
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
});
|
||||
|
||||
it("should render the inputs if OSS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
|
||||
await screen.findByTestId("github-token-input");
|
||||
await screen.findByTestId("github-token-help-anchor");
|
||||
|
||||
await screen.findByTestId("gitlab-token-input");
|
||||
await screen.findByTestId("gitlab-token-help-anchor");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("github-token-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("github-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("gitlab-token-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("gitlab-token-help-anchor"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should set '<hidden>' placeholder and indicator if the GitHub token is set", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: false,
|
||||
gitlab: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const githubInput = screen.getByTestId("github-token-input");
|
||||
expect(githubInput).toHaveProperty("placeholder", "");
|
||||
expect(
|
||||
screen.queryByTestId("gh-set-token-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const gitlabInput = screen.getByTestId("gitlab-token-input");
|
||||
expect(gitlabInput).toHaveProperty("placeholder", "");
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: true,
|
||||
gitlab: true,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
const githubInput = screen.getByTestId("github-token-input");
|
||||
expect(githubInput).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(
|
||||
screen.queryByTestId("gh-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const gitlabInput = screen.getByTestId("gitlab-token-input");
|
||||
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: false,
|
||||
gitlab: true,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
const githubInput = screen.getByTestId("github-token-input");
|
||||
expect(githubInput).toHaveProperty("placeholder", "");
|
||||
expect(
|
||||
screen.queryByTestId("gh-set-token-indicator"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const gitlabInput = screen.getByTestId("gitlab-token-input");
|
||||
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(
|
||||
screen.queryByTestId("gl-set-token-indicator"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
|
||||
let button = screen.queryByTestId("configure-github-repositories-button");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("disconnect-tokens-button")).toBeInTheDocument();
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
// wait until queries are resolved
|
||||
expect(queryClient.isFetching()).toBe(0);
|
||||
button = screen.queryByTestId("configure-github-repositories-button");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
APP_SLUG: "test-slug",
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
button = screen.getByTestId("configure-github-repositories-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("disconnect-tokens-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
it("should save the GitHub token", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider_tokens: {
|
||||
github: "test-token",
|
||||
gitlab: "",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider_tokens: {
|
||||
github: "",
|
||||
gitlab: "test-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable the button if there is no input", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(githubInput);
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(gitlabInput);
|
||||
expect(submit).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable a disconnect tokens button if there is at least one token set", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: true,
|
||||
gitlab: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
let disconnectButton = await screen.findByTestId(
|
||||
"disconnect-tokens-button",
|
||||
);
|
||||
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: false,
|
||||
gitlab: false,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
|
||||
await waitFor(() => expect(disconnectButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it("should call logout when pressing the disconnect tokens button", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const logoutSpy = vi.spyOn(OpenHands, "logout");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: true,
|
||||
gitlab: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const disconnectButton = await screen.findByTestId(
|
||||
"disconnect-tokens-button",
|
||||
);
|
||||
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
|
||||
await userEvent.click(disconnectButton);
|
||||
|
||||
expect(logoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// flaky test
|
||||
it.skip("should disable the button when submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
|
||||
expect(submit).toHaveTextContent("Saving...");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
await waitFor(() => expect(submit).toHaveTextContent("Save"));
|
||||
});
|
||||
|
||||
it("should disable the button after submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(submit).toBeDisabled();
|
||||
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
expect(gitlabInput).toHaveValue("test-token");
|
||||
expect(submit).not.toBeDisabled();
|
||||
|
||||
// submit the form
|
||||
await userEvent.click(submit);
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => expect(submit).toBeDisabled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status toasts", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const githubInput = await screen.findByTestId("github-token-input");
|
||||
await userEvent.type(githubInput, "test-token");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderGitSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const gitlabInput = await screen.findByTestId("gitlab-token-input");
|
||||
await userEvent.type(gitlabInput, "test-token");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -91,13 +91,6 @@ describe("HomeScreen", () => {
|
||||
screen.getByTestId("task-suggestions");
|
||||
});
|
||||
|
||||
it("should have responsive layout for mobile and desktop screens", async () => {
|
||||
renderHomeScreen();
|
||||
|
||||
const mainContainer = screen.getByTestId("home-screen").querySelector("main");
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
|
||||
});
|
||||
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
|
||||
@@ -1,674 +0,0 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
MOCK_DEFAULT_USER_SETTINGS,
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
const renderLlmSettingsScreen = () =>
|
||||
render(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetTestHandlersMockSettings();
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
describe("Basic form", () => {
|
||||
it("should render the basic form by default", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const basicFom = screen.getByTestId("llm-settings-form-basic");
|
||||
within(basicFom).getByTestId("llm-provider-input");
|
||||
within(basicFom).getByTestId("llm-model-input");
|
||||
within(basicFom).getByTestId("llm-api-key-input");
|
||||
within(basicFom).getByTestId("llm-api-key-help-anchor");
|
||||
});
|
||||
|
||||
it("should render the default values if non exist", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("Anthropic");
|
||||
expect(model).toHaveValue("claude-3-5-sonnet-20241022");
|
||||
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the existing settings values", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_api_key_set: true,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
expect(model).toHaveValue("gpt-4o");
|
||||
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should render the advanced form if the switch is toggled", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const basicForm = screen.getByTestId("llm-settings-form-basic");
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-form-advanced"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(basicForm).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-form-advanced"),
|
||||
).toBeInTheDocument();
|
||||
expect(basicForm).not.toBeInTheDocument();
|
||||
|
||||
const advancedForm = screen.getByTestId("llm-settings-form-advanced");
|
||||
within(advancedForm).getByTestId("llm-custom-model-input");
|
||||
within(advancedForm).getByTestId("base-url-input");
|
||||
within(advancedForm).getByTestId("llm-api-key-input");
|
||||
within(advancedForm).getByTestId("llm-api-key-help-anchor");
|
||||
within(advancedForm).getByTestId("agent-input");
|
||||
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
|
||||
within(advancedForm).getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
expect(
|
||||
screen.queryByTestId("llm-settings-form-advanced"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("llm-settings-form-basic")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the default advanced settings", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
expect(advancedSwitch).not.toBeChecked();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
expect(model).toHaveValue("anthropic/claude-3-5-sonnet-20241022");
|
||||
expect(baseUrl).toHaveValue("");
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
expect(agent).toHaveValue("CodeActAgent");
|
||||
expect(confirmation).not.toBeChecked();
|
||||
expect(condensor).toBeChecked();
|
||||
|
||||
// check that security analyzer is present
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(confirmation);
|
||||
screen.getByTestId("security-analyzer-input");
|
||||
});
|
||||
|
||||
it("should render the advanced form if existings settings are advanced", async () => {
|
||||
const hasAdvancedSettingsSetSpy = vi.spyOn(
|
||||
AdvancedSettingsUtlls,
|
||||
"hasAdvancedSettingsSet",
|
||||
);
|
||||
hasAdvancedSettingsSetSpy.mockReturnValue(true);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
expect(advancedSwitch).toBeChecked();
|
||||
screen.getByTestId("llm-settings-form-advanced");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render existing advanced settings correctly", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_base_url: "https://api.openai.com/v1/chat/completions",
|
||||
llm_api_key_set: true,
|
||||
agent: "CoActAgent",
|
||||
confirmation_mode: true,
|
||||
enable_default_condenser: false,
|
||||
security_analyzer: "mock-invariant",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(model).toHaveValue("openai/gpt-4o");
|
||||
expect(baseUrl).toHaveValue(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
);
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
|
||||
expect(agent).toHaveValue("CoActAgent");
|
||||
expect(confirmation).toBeChecked();
|
||||
expect(condensor).not.toBeChecked();
|
||||
expect(securityAnalyzer).toHaveValue("mock-invariant");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.todo("should render an indicator if the llm api key is set");
|
||||
});
|
||||
|
||||
describe("Form submission", () => {
|
||||
it("should submit the basic form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
// select provider
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("OpenAI");
|
||||
await userEvent.click(providerOption);
|
||||
expect(provider).toHaveValue("OpenAI");
|
||||
|
||||
// enter api key
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
|
||||
// select model
|
||||
await userEvent.click(model);
|
||||
const modelOption = screen.getByText("gpt-4o");
|
||||
await userEvent.click(modelOption);
|
||||
expect(model).toHaveValue("gpt-4o");
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_api_key: "test-api-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should submit the advanced form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
// enter custom model
|
||||
await userEvent.clear(model);
|
||||
await userEvent.type(model, "openai/gpt-4o");
|
||||
expect(model).toHaveValue("openai/gpt-4o");
|
||||
|
||||
// enter base url
|
||||
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
|
||||
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
|
||||
|
||||
// enter api key
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
|
||||
// toggle confirmation mode
|
||||
await userEvent.click(confirmation);
|
||||
expect(confirmation).toBeChecked();
|
||||
|
||||
// toggle memory condensor
|
||||
await userEvent.click(condensor);
|
||||
expect(condensor).not.toBeChecked();
|
||||
|
||||
// select agent
|
||||
await userEvent.click(agent);
|
||||
const agentOption = screen.getByText("CoActAgent");
|
||||
await userEvent.click(agentOption);
|
||||
expect(agent).toHaveValue("CoActAgent");
|
||||
|
||||
// select security analyzer
|
||||
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const securityAnalyzerOption = screen.getByText("mock-invariant");
|
||||
await userEvent.click(securityAnalyzerOption);
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_base_url: "https://api.openai.com/v1/chat/completions",
|
||||
agent: "CoActAgent",
|
||||
confirmation_mode: true,
|
||||
enable_default_condenser: false,
|
||||
security_analyzer: "mock-invariant",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should disable the button if there are no changes in the basic form", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_api_key_set: true,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
screen.getByTestId("llm-settings-form-basic");
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
// select model
|
||||
await userEvent.click(model);
|
||||
const modelOption = screen.getByText("gpt-4o-mini");
|
||||
await userEvent.click(modelOption);
|
||||
expect(model).toHaveValue("gpt-4o-mini");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset model
|
||||
await userEvent.click(model);
|
||||
const modelOption2 = screen.getByText("gpt-4o");
|
||||
await userEvent.click(modelOption2);
|
||||
expect(model).toHaveValue("gpt-4o");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// set api key
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
expect(apiKey).toHaveValue("test-api-key");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset api key
|
||||
await userEvent.clear(apiKey);
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable the button if there are no changes in the advanced form", async () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
llm_base_url: "https://api.openai.com/v1/chat/completions",
|
||||
llm_api_key_set: true,
|
||||
confirmation_mode: true,
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
screen.getByTestId("llm-settings-form-advanced");
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
const baseUrl = screen.getByTestId("base-url-input");
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
const agent = screen.getByTestId("agent-input");
|
||||
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
// enter custom model
|
||||
await userEvent.type(model, "-mini");
|
||||
expect(model).toHaveValue("openai/gpt-4o-mini");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset model
|
||||
await userEvent.clear(model);
|
||||
expect(model).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
await userEvent.type(model, "openai/gpt-4o");
|
||||
expect(model).toHaveValue("openai/gpt-4o");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// enter base url
|
||||
await userEvent.type(baseUrl, "/extra");
|
||||
expect(baseUrl).toHaveValue(
|
||||
"https://api.openai.com/v1/chat/completions/extra",
|
||||
);
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(baseUrl);
|
||||
expect(baseUrl).toHaveValue("");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
|
||||
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// set api key
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
expect(apiKey).toHaveValue("test-api-key");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset api key
|
||||
await userEvent.clear(apiKey);
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// set agent
|
||||
await userEvent.clear(agent);
|
||||
await userEvent.type(agent, "test-agent");
|
||||
expect(agent).toHaveValue("test-agent");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
// reset agent
|
||||
await userEvent.clear(agent);
|
||||
expect(agent).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
await userEvent.type(agent, "CodeActAgent");
|
||||
expect(agent).toHaveValue("CodeActAgent");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// toggle confirmation mode
|
||||
await userEvent.click(confirmation);
|
||||
expect(confirmation).not.toBeChecked();
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
await userEvent.click(confirmation);
|
||||
expect(confirmation).toBeChecked();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// toggle memory condensor
|
||||
await userEvent.click(condensor);
|
||||
expect(condensor).not.toBeChecked();
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
await userEvent.click(condensor);
|
||||
expect(condensor).toBeChecked();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// select security analyzer
|
||||
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const securityAnalyzerOption = screen.getByText("mock-invariant");
|
||||
await userEvent.click(securityAnalyzerOption);
|
||||
expect(securityAnalyzer).toHaveValue("mock-invariant");
|
||||
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.clear(securityAnalyzer);
|
||||
expect(securityAnalyzer).toHaveValue("");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should reset button state when switching between forms", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// dirty the basic form
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// dirty the advanced form
|
||||
const model = screen.getByTestId("llm-custom-model-input");
|
||||
await userEvent.type(model, "openai/gpt-4o");
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
|
||||
await userEvent.click(advancedSwitch);
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
// flaky test
|
||||
it.skip("should disable the button when submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKey, "test-api-key");
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: "test-api-key",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(submitButton).toHaveTextContent("Saving...");
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).toHaveTextContent("Save");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Status toasts", () => {
|
||||
describe("Basic form", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Toggle setting to change
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
// Toggle setting to change
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
// Toggle setting to change
|
||||
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
|
||||
await userEvent.type(apiKeyInput, "test-api-key");
|
||||
|
||||
const submit = await screen.findByTestId("submit-button");
|
||||
await userEvent.click(submit);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalled();
|
||||
expect(displayErrorToastSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SaaS mode", () => {
|
||||
it("should not render the runtime settings input in oss mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the runtime settings input in saas mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should always render the runtime settings input as disabled", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).toBeInTheDocument();
|
||||
expect(runtimeSettingsInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
@@ -7,30 +7,6 @@ import OpenHands from "#/api/open-hands";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"SETTINGS$NAV_GIT": "Git",
|
||||
"SETTINGS$NAV_APPLICATION": "Application",
|
||||
"SETTINGS$NAV_CREDITS": "Credits",
|
||||
"SETTINGS$NAV_API_KEYS": "API Keys",
|
||||
"SETTINGS$NAV_LLM": "LLM",
|
||||
"SETTINGS$TITLE": "Settings"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Settings Billing", () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
@@ -43,22 +19,18 @@ describe("Settings Billing", () => {
|
||||
Component: () => <PaymentForm />,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="git-settings-screen" />,
|
||||
path: "/settings/git",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = () =>
|
||||
renderWithProviders(<RoutesStub initialEntries={["/settings/billing"]} />);
|
||||
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should not render the credits tab if OSS mode", async () => {
|
||||
it("should not render the navbar if OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
@@ -71,12 +43,15 @@ describe("Settings Billing", () => {
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
const credits = within(navbar).queryByText("Credits");
|
||||
expect(credits).not.toBeInTheDocument();
|
||||
// Wait for the settings screen to be rendered
|
||||
await screen.findByTestId("settings-screen");
|
||||
|
||||
// Then check that the navbar is not present
|
||||
const navbar = screen.queryByTestId("settings-navbar");
|
||||
expect(navbar).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
|
||||
it("should render the navbar if SaaS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
@@ -89,8 +64,11 @@ describe("Settings Billing", () => {
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
within(navbar).getByText("Credits");
|
||||
await waitFor(() => {
|
||||
const navbar = screen.getByTestId("settings-navbar");
|
||||
within(navbar).getByText("Account");
|
||||
within(navbar).getByText("Credits");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the credits item", async () => {
|
||||
@@ -112,6 +90,6 @@ describe("Settings Billing", () => {
|
||||
await user.click(credits);
|
||||
|
||||
const billingSection = await screen.findByTestId("billing-settings");
|
||||
expect(billingSection).toBeInTheDocument();
|
||||
within(billingSection).getByText("PAYMENT$MANAGE_CREDITS");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,6 @@ describe("hasAdvancedSettingsSet", () => {
|
||||
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if an empty object", () => {
|
||||
expect(hasAdvancedSettingsSet({})).toBe(false);
|
||||
});
|
||||
|
||||
describe("should be true if", () => {
|
||||
test("LLM_BASE_URL is set", () => {
|
||||
expect(
|
||||
@@ -30,6 +26,15 @@ describe("hasAdvancedSettingsSet", () => {
|
||||
).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({
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.1",
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.7.6",
|
||||
"@microlink/react-json-view": "^1.26.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.34.0",
|
||||
"version": "0.35.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -107,10 +107,6 @@ function isRawTranslationKey(str) {
|
||||
const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"openid email profile", // OAuth scope string - not user-facing
|
||||
"OPEN_ISSUE", // Task type identifier, not a UI string
|
||||
"Merge Request", // Git provider specific terminology
|
||||
"GitLab API", // Git provider specific terminology
|
||||
"Pull Request", // Git provider specific terminology
|
||||
"GitHub API", // Git provider specific terminology
|
||||
];
|
||||
|
||||
function isExcludedTechnicalString(str) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DiffEditor, Monaco } from "@monaco-editor/react";
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import React from "react";
|
||||
import { editor as editor_t } from "monaco-editor";
|
||||
import { LuFileDiff, LuFileMinus, LuFilePlus } from "react-icons/lu";
|
||||
@@ -88,29 +88,6 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const beforeMount = (monaco: Monaco) => {
|
||||
monaco.editor.defineTheme("custom-diff-theme", {
|
||||
base: "vs-dark",
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: "comment", foreground: "6a9955" },
|
||||
{ token: "keyword", foreground: "569cd6" },
|
||||
{ token: "string", foreground: "ce9178" },
|
||||
{ token: "number", foreground: "b5cea8" },
|
||||
],
|
||||
colors: {
|
||||
"diffEditor.insertedTextBackground": "#014b01AA", // Stronger green background
|
||||
"diffEditor.removedTextBackground": "#750000AA", // Stronger red background
|
||||
"diffEditor.insertedLineBackground": "#003f00AA", // Dark green for added lines
|
||||
"diffEditor.removedLineBackground": "#5a0000AA", // Dark red for removed lines
|
||||
"diffEditor.border": "#444444", // Border between diff editors
|
||||
|
||||
"editorUnnecessaryCode.border": "#00000000", // No border for unnecessary code
|
||||
"editorUnnecessaryCode.opacity": "#00000077", // Slightly faded
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditorDidMount = (editor: editor_t.IStandaloneDiffEditor) => {
|
||||
diffEditorRef.current = editor;
|
||||
updateEditorHeight();
|
||||
@@ -168,9 +145,8 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
|
||||
language={getLanguageFromPath(filePath)}
|
||||
original={isAdded ? "" : diff.original}
|
||||
modified={isDeleted ? "" : diff.modified}
|
||||
theme="custom-diff-theme"
|
||||
theme="vs-dark"
|
||||
onMount={handleEditorDidMount}
|
||||
beforeMount={beforeMount}
|
||||
options={{
|
||||
renderValidationDecorations: "off",
|
||||
readOnly: true,
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { InputSkeleton } from "../input-skeleton";
|
||||
import { SwitchSkeleton } from "../switch-skeleton";
|
||||
|
||||
export function AppSettingsInputsSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-testid="app-settings-skeleton"
|
||||
className="px-11 py-9 flex flex-col gap-6"
|
||||
>
|
||||
<InputSkeleton />
|
||||
<SwitchSkeleton />
|
||||
<SwitchSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsDropdownInput } from "../settings-dropdown-input";
|
||||
|
||||
interface LanguageInputProps {
|
||||
name: string;
|
||||
onChange: (value: string) => void;
|
||||
defaultKey: string;
|
||||
}
|
||||
|
||||
export function LanguageInput({
|
||||
defaultKey,
|
||||
onChange,
|
||||
name,
|
||||
}: LanguageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsDropdownInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onInputChange={onChange}
|
||||
label={t(I18nKey.SETTINGS$LANGUAGE)}
|
||||
items={AvailableLanguages.map((l) => ({
|
||||
key: l.value,
|
||||
label: l.label,
|
||||
}))}
|
||||
defaultSelectedKey={defaultKey}
|
||||
isClearable={false}
|
||||
wrapperClassName="w-[680px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandButtonProps {
|
||||
testId?: string;
|
||||
name?: string;
|
||||
variant: "primary" | "secondary" | "danger";
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
isDisabled?: boolean;
|
||||
@@ -13,7 +12,6 @@ interface BrandButtonProps {
|
||||
|
||||
export function BrandButton({
|
||||
testId,
|
||||
name,
|
||||
children,
|
||||
variant,
|
||||
type,
|
||||
@@ -24,7 +22,6 @@ export function BrandButton({
|
||||
}: React.PropsWithChildren<BrandButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
name={name}
|
||||
data-testid={testId}
|
||||
disabled={isDisabled}
|
||||
// The type is alreadt passed as a prop to the button component
|
||||
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
|
||||
interface ConfigureGitHubRepositoriesAnchorProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function ConfigureGitHubRepositoriesAnchor({
|
||||
slug,
|
||||
}: ConfigureGitHubRepositoriesAnchorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<a
|
||||
data-testid="configure-github-repositories-button"
|
||||
href={`https://github.com/apps/${slug}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="px-11 py-9"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { InputSkeleton } from "../input-skeleton";
|
||||
import { SubtextSkeleton } from "../subtext-skeleton";
|
||||
|
||||
export function GitSettingInputsSkeleton() {
|
||||
return (
|
||||
<div className="px-11 py-9 flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputSkeleton />
|
||||
<SubtextSkeleton />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<InputSkeleton />
|
||||
<SubtextSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function GitHubTokenHelpAnchor() {
|
||||
return (
|
||||
<p data-testid="github-token-help-anchor" className="text-xs">
|
||||
<Trans
|
||||
i18nKey={I18nKey.GITHUB$TOKEN_HELP_TEXT}
|
||||
components={[
|
||||
<a
|
||||
key="github-token-help-anchor-link"
|
||||
aria-label="GitHub token help link"
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
<a
|
||||
key="github-token-help-anchor-link-2"
|
||||
aria-label="GitHub token see more link"
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
|
||||
import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface GitHubTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
isGitHubTokenSet: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function GitHubTokenInput({
|
||||
onChange,
|
||||
isGitHubTokenSet,
|
||||
name,
|
||||
}: GitHubTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
|
||||
startContent={
|
||||
isGitHubTokenSet && (
|
||||
<KeyStatusIcon
|
||||
testId="gh-set-token-indicator"
|
||||
isSet={isGitHubTokenSet}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<GitHubTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function GitLabTokenHelpAnchor() {
|
||||
return (
|
||||
<p data-testid="gitlab-token-help-anchor" className="text-xs">
|
||||
<Trans
|
||||
i18nKey={I18nKey.GITLAB$TOKEN_HELP_TEXT}
|
||||
components={[
|
||||
<a
|
||||
key="gitlab-token-help-anchor-link"
|
||||
aria-label="Gitlab token help link"
|
||||
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
<a
|
||||
key="gitlab-token-help-anchor-link-2"
|
||||
aria-label="GitLab token see more link"
|
||||
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
|
||||
import { KeyStatusIcon } from "../key-status-icon";
|
||||
|
||||
interface GitLabTokenInputProps {
|
||||
onChange: (value: string) => void;
|
||||
isGitLabTokenSet: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function GitLabTokenInput({
|
||||
onChange,
|
||||
isGitLabTokenSet,
|
||||
name,
|
||||
}: GitLabTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SettingsInput
|
||||
testId={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isGitLabTokenSet ? "<hidden>" : ""}
|
||||
startContent={
|
||||
isGitLabTokenSet && (
|
||||
<KeyStatusIcon
|
||||
testId="gl-set-token-indicator"
|
||||
isSet={isGitLabTokenSet}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<GitLabTokenHelpAnchor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export function InputSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="w-[70px] h-[20px] skeleton" />
|
||||
<div className="w-[680px] h-[40px] skeleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,12 @@ import SuccessIcon from "#/icons/success.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface KeyStatusIconProps {
|
||||
testId?: string;
|
||||
isSet: boolean;
|
||||
}
|
||||
|
||||
export function KeyStatusIcon({ testId, isSet }: KeyStatusIconProps) {
|
||||
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
|
||||
return (
|
||||
<span data-testid={testId || (isSet ? "set-indicator" : "unset-indicator")}>
|
||||
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
|
||||
<SuccessIcon className={cn(isSet ? "text-success" : "text-danger")} />
|
||||
</span>
|
||||
);
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { InputSkeleton } from "../input-skeleton";
|
||||
import { SubtextSkeleton } from "../subtext-skeleton";
|
||||
import { SwitchSkeleton } from "../switch-skeleton";
|
||||
|
||||
export function LlmSettingsInputsSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-testid="app-settings-skeleton"
|
||||
className="px-11 py-9 flex flex-col gap-6"
|
||||
>
|
||||
<SwitchSkeleton />
|
||||
<InputSkeleton />
|
||||
<InputSkeleton />
|
||||
<InputSkeleton />
|
||||
<SubtextSkeleton />
|
||||
<SwitchSkeleton />
|
||||
<SwitchSkeleton />
|
||||
<InputSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
|
||||
interface ResetSettingsModalProps {
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
|
||||
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
|
||||
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
type="submit"
|
||||
name="reset-settings"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
testId="cancel-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ interface SettingsSwitchProps {
|
||||
name?: string;
|
||||
onToggle?: (value: boolean) => void;
|
||||
defaultIsToggled?: boolean;
|
||||
isToggled?: boolean;
|
||||
isBeta?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,7 +15,6 @@ export function SettingsSwitch({
|
||||
name,
|
||||
onToggle,
|
||||
defaultIsToggled,
|
||||
isToggled: controlledIsToggled,
|
||||
isBeta,
|
||||
}: React.PropsWithChildren<SettingsSwitchProps>) {
|
||||
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
|
||||
@@ -27,18 +25,17 @@ export function SettingsSwitch({
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 w-fit cursor-pointer">
|
||||
<label className="flex items-center gap-2 w-fit">
|
||||
<input
|
||||
hidden
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
onChange={(e) => handleToggle(e.target.checked)}
|
||||
checked={controlledIsToggled ?? isToggled}
|
||||
defaultChecked={defaultIsToggled}
|
||||
/>
|
||||
|
||||
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
|
||||
<StyledSwitchComponent isToggled={isToggled} />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{children}</span>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function SubtextSkeleton() {
|
||||
return <div className="w-[250px] h-[20px] skeleton" />;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export function SwitchSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-[48px] h-[24px] skeleton-round" />
|
||||
<div className="w-[100px] h-[20px] skeleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,14 +14,12 @@ interface ModelSelectorProps {
|
||||
isDisabled?: boolean;
|
||||
models: Record<string, { separator: string; models: string[] }>;
|
||||
currentModel?: string;
|
||||
onChange?: (model: string | null) => void;
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
isDisabled,
|
||||
models,
|
||||
currentModel,
|
||||
onChange,
|
||||
}: ModelSelectorProps) {
|
||||
const [, setLitellmId] = React.useState<string | null>(null);
|
||||
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
|
||||
@@ -57,7 +55,6 @@ export function ModelSelector({
|
||||
}
|
||||
setLitellmId(fullModel);
|
||||
setSelectedModel(model);
|
||||
onChange?.(fullModel);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
|
||||
@@ -4,9 +4,8 @@ import posthog from "posthog-js";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
const getSettingsQueryFn = async () => {
|
||||
const apiSettings = await OpenHands.getSettings();
|
||||
|
||||
return {
|
||||
|
||||
@@ -26,17 +26,6 @@ export enum I18nKey {
|
||||
ANALYTICS$DESCRIPTION = "ANALYTICS$DESCRIPTION",
|
||||
ANALYTICS$SEND_ANONYMOUS_DATA = "ANALYTICS$SEND_ANONYMOUS_DATA",
|
||||
ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES",
|
||||
SETTINGS$SAVING = "SETTINGS$SAVING",
|
||||
SETTINGS$SAVE_CHANGES = "SETTINGS$SAVE_CHANGES",
|
||||
SETTINGS$NAV_GIT = "SETTINGS$NAV_GIT",
|
||||
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
|
||||
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
|
||||
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
|
||||
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
|
||||
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
|
||||
GIT$GITLAB_API = "GIT$GITLAB_API",
|
||||
GIT$PULL_REQUEST = "GIT$PULL_REQUEST",
|
||||
GIT$GITHUB_API = "GIT$GITHUB_API",
|
||||
BUTTON$COPY = "BUTTON$COPY",
|
||||
BUTTON$COPIED = "BUTTON$COPIED",
|
||||
APP$TITLE = "APP$TITLE",
|
||||
@@ -106,9 +95,6 @@ export enum I18nKey {
|
||||
GITHUB$TOKEN_LABEL = "GITHUB$TOKEN_LABEL",
|
||||
GITHUB$TOKEN_OPTIONAL = "GITHUB$TOKEN_OPTIONAL",
|
||||
GITHUB$GET_TOKEN = "GITHUB$GET_TOKEN",
|
||||
GITHUB$TOKEN_HELP_TEXT = "GITHUB$TOKEN_HELP_TEXT",
|
||||
GITHUB$TOKEN_LINK_TEXT = "GITHUB$TOKEN_LINK_TEXT",
|
||||
GITHUB$INSTRUCTIONS_LINK_TEXT = "GITHUB$INSTRUCTIONS_LINK_TEXT",
|
||||
COMMON$HERE = "COMMON$HERE",
|
||||
ANALYTICS$ENABLE = "ANALYTICS$ENABLE",
|
||||
GITHUB$TOKEN_INVALID = "GITHUB$TOKEN_INVALID",
|
||||
@@ -451,9 +437,6 @@ export enum I18nKey {
|
||||
MODEL_SELECTOR$OTHERS = "MODEL_SELECTOR$OTHERS",
|
||||
GITLAB$TOKEN_LABEL = "GITLAB$TOKEN_LABEL",
|
||||
GITLAB$GET_TOKEN = "GITLAB$GET_TOKEN",
|
||||
GITLAB$TOKEN_HELP_TEXT = "GITLAB$TOKEN_HELP_TEXT",
|
||||
GITLAB$TOKEN_LINK_TEXT = "GITLAB$TOKEN_LINK_TEXT",
|
||||
GITLAB$INSTRUCTIONS_LINK_TEXT = "GITLAB$INSTRUCTIONS_LINK_TEXT",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
|
||||
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
|
||||
|
||||
@@ -389,171 +389,6 @@
|
||||
"tr": "Tercihleri Onayla",
|
||||
"de": "Einstellungen bestätigen"
|
||||
},
|
||||
"SETTINGS$SAVING": {
|
||||
"en": "Saving...",
|
||||
"ja": "保存中...",
|
||||
"zh-CN": "保存中...",
|
||||
"zh-TW": "儲存中...",
|
||||
"ko-KR": "저장 중...",
|
||||
"no": "Lagrer...",
|
||||
"it": "Salvataggio in corso...",
|
||||
"pt": "Salvando...",
|
||||
"es": "Guardando...",
|
||||
"ar": "جار الحفظ...",
|
||||
"fr": "Enregistrement en cours...",
|
||||
"tr": "Kayıt yapılıyor...",
|
||||
"de": "Speichern..."
|
||||
},
|
||||
"SETTINGS$SAVE_CHANGES": {
|
||||
"en": "Save Changes",
|
||||
"ja": "変更を保存",
|
||||
"zh-CN": "保存更改",
|
||||
"zh-TW": "儲存變更",
|
||||
"ko-KR": "변경 사항 저장",
|
||||
"no": "Lagre endringer",
|
||||
"it": "Salva modifiche",
|
||||
"pt": "Salvar alterações",
|
||||
"es": "Guardar cambios",
|
||||
"ar": "حفظ التغييرات",
|
||||
"fr": "Enregistrer les modifications",
|
||||
"tr": "Değişiklikleri Kaydet",
|
||||
"de": "Änderungen speichern"
|
||||
},
|
||||
"SETTINGS$NAV_GIT": {
|
||||
"en": "Git",
|
||||
"ja": "Git",
|
||||
"zh-CN": "Git",
|
||||
"zh-TW": "Git",
|
||||
"ko-KR": "Git",
|
||||
"no": "Git",
|
||||
"it": "Git",
|
||||
"pt": "Git",
|
||||
"es": "Git",
|
||||
"ar": "Git",
|
||||
"fr": "Git",
|
||||
"tr": "Git",
|
||||
"de": "Git"
|
||||
},
|
||||
"SETTINGS$NAV_APPLICATION": {
|
||||
"en": "Application",
|
||||
"ja": "アプリケーション",
|
||||
"zh-CN": "应用程序",
|
||||
"zh-TW": "應用程式",
|
||||
"ko-KR": "애플리케이션",
|
||||
"no": "Applikasjon",
|
||||
"it": "Applicazione",
|
||||
"pt": "Aplicação",
|
||||
"es": "Aplicación",
|
||||
"ar": "التطبيق",
|
||||
"fr": "Application",
|
||||
"tr": "Uygulama",
|
||||
"de": "Anwendung"
|
||||
},
|
||||
"SETTINGS$NAV_CREDITS": {
|
||||
"en": "Credits",
|
||||
"ja": "クレジット",
|
||||
"zh-CN": "积分",
|
||||
"zh-TW": "點數",
|
||||
"ko-KR": "크레딧",
|
||||
"no": "Kreditter",
|
||||
"it": "Crediti",
|
||||
"pt": "Créditos",
|
||||
"es": "Créditos",
|
||||
"ar": "الرصيد",
|
||||
"fr": "Crédits",
|
||||
"tr": "Krediler",
|
||||
"de": "Guthaben"
|
||||
},
|
||||
"SETTINGS$NAV_API_KEYS": {
|
||||
"en": "API Keys",
|
||||
"ja": "APIキー",
|
||||
"zh-CN": "API密钥",
|
||||
"zh-TW": "API金鑰",
|
||||
"ko-KR": "API 키",
|
||||
"no": "API-nøkler",
|
||||
"it": "Chiavi API",
|
||||
"pt": "Chaves de API",
|
||||
"es": "Claves API",
|
||||
"ar": "مفاتيح API",
|
||||
"fr": "Clés API",
|
||||
"tr": "API Anahtarları",
|
||||
"de": "API-Schlüssel"
|
||||
},
|
||||
"SETTINGS$NAV_LLM": {
|
||||
"en": "LLM",
|
||||
"ja": "LLM",
|
||||
"zh-CN": "LLM",
|
||||
"zh-TW": "LLM",
|
||||
"ko-KR": "LLM",
|
||||
"no": "LLM",
|
||||
"it": "LLM",
|
||||
"pt": "LLM",
|
||||
"es": "LLM",
|
||||
"ar": "LLM",
|
||||
"fr": "LLM",
|
||||
"tr": "LLM",
|
||||
"de": "LLM"
|
||||
},
|
||||
"GIT$MERGE_REQUEST": {
|
||||
"en": "Merge Request",
|
||||
"ja": "マージリクエスト",
|
||||
"zh-CN": "合并请求",
|
||||
"zh-TW": "合併請求",
|
||||
"ko-KR": "머지 요청",
|
||||
"no": "Fletteforespørsel",
|
||||
"it": "Richiesta di fusione",
|
||||
"pt": "Solicitação de mesclagem",
|
||||
"es": "Solicitud de fusión",
|
||||
"ar": "طلب الدمج",
|
||||
"fr": "Demande de fusion",
|
||||
"tr": "Birleştirme İsteği",
|
||||
"de": "Merge-Anfrage"
|
||||
},
|
||||
"GIT$GITLAB_API": {
|
||||
"en": "GitLab API",
|
||||
"ja": "GitLab API",
|
||||
"zh-CN": "GitLab API",
|
||||
"zh-TW": "GitLab API",
|
||||
"ko-KR": "GitLab API",
|
||||
"no": "GitLab API",
|
||||
"it": "API GitLab",
|
||||
"pt": "API do GitLab",
|
||||
"es": "API de GitLab",
|
||||
"ar": "واجهة برمجة تطبيقات GitLab",
|
||||
"fr": "API GitLab",
|
||||
"tr": "GitLab API",
|
||||
"de": "GitLab API"
|
||||
},
|
||||
"GIT$PULL_REQUEST": {
|
||||
"en": "Pull Request",
|
||||
"ja": "プルリクエスト",
|
||||
"zh-CN": "拉取请求",
|
||||
"zh-TW": "拉取請求",
|
||||
"ko-KR": "풀 리퀘스트",
|
||||
"no": "Trekkforespørsel",
|
||||
"it": "Richiesta di pull",
|
||||
"pt": "Solicitação de pull",
|
||||
"es": "Solicitud de extracción",
|
||||
"ar": "طلب السحب",
|
||||
"fr": "Demande de tirage",
|
||||
"tr": "Çekme İsteği",
|
||||
"de": "Pull Request"
|
||||
},
|
||||
"GIT$GITHUB_API": {
|
||||
"en": "GitHub API",
|
||||
"ja": "GitHub API",
|
||||
"zh-CN": "GitHub API",
|
||||
"zh-TW": "GitHub API",
|
||||
"ko-KR": "GitHub API",
|
||||
"no": "GitHub API",
|
||||
"it": "API GitHub",
|
||||
"pt": "API do GitHub",
|
||||
"es": "API de GitHub",
|
||||
"ar": "واجهة برمجة تطبيقات GitHub",
|
||||
"fr": "API GitHub",
|
||||
"tr": "GitHub API",
|
||||
"de": "GitHub API"
|
||||
},
|
||||
"BUTTON$COPY": {
|
||||
"en": "Copy to clipboard",
|
||||
"ja": "クリップボードにコピー",
|
||||
@@ -1599,51 +1434,6 @@
|
||||
"tr": "Jetonunuzu alın",
|
||||
"de": "Token abrufen"
|
||||
},
|
||||
"GITHUB$TOKEN_HELP_TEXT": {
|
||||
"en": "Get your <0>GitHub token</0> or <1>click here for instructions</1>",
|
||||
"ja": "<0>GitHubトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
|
||||
"zh-CN": "获取您的<0>GitHub令牌</0>或<1>点击此处获取说明</1>",
|
||||
"zh-TW": "取得您的<0>GitHub權杖</0>或<1>點擊此處獲取說明</1>",
|
||||
"ko-KR": "<0>GitHub 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
|
||||
"no": "Få din <0>GitHub-token</0> eller <1>klikk her for instruksjoner</1>",
|
||||
"it": "Ottieni il tuo <0>token GitHub</0> o <1>clicca qui per istruzioni</1>",
|
||||
"pt": "Obtenha seu <0>token GitHub</0> ou <1>clique aqui para instruções</1>",
|
||||
"es": "Obtenga su <0>token de GitHub</0> o <1>haga clic aquí para obtener instrucciones</1>",
|
||||
"ar": "احصل على <0>رمز GitHub</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
|
||||
"fr": "Obtenez votre <0>jeton GitHub</0> ou <1>cliquez ici pour les instructions</1>",
|
||||
"tr": "<0>GitHub jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
|
||||
"de": "Holen Sie sich Ihren <0>GitHub-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
|
||||
},
|
||||
"GITHUB$TOKEN_LINK_TEXT": {
|
||||
"en": "GitHub token",
|
||||
"ja": "GitHubトークン",
|
||||
"zh-CN": "GitHub令牌",
|
||||
"zh-TW": "GitHub權杖",
|
||||
"ko-KR": "GitHub 토큰",
|
||||
"no": "GitHub-token",
|
||||
"it": "token GitHub",
|
||||
"pt": "token GitHub",
|
||||
"es": "token de GitHub",
|
||||
"ar": "رمز GitHub",
|
||||
"fr": "jeton GitHub",
|
||||
"tr": "GitHub jetonu",
|
||||
"de": "GitHub-Token"
|
||||
},
|
||||
"GITHUB$INSTRUCTIONS_LINK_TEXT": {
|
||||
"en": "click here for instructions",
|
||||
"ja": "手順についてはここをクリック",
|
||||
"zh-CN": "点击此处获取说明",
|
||||
"zh-TW": "點擊此處獲取說明",
|
||||
"ko-KR": "지침을 보려면 여기를 클릭",
|
||||
"no": "klikk her for instruksjoner",
|
||||
"it": "clicca qui per istruzioni",
|
||||
"pt": "clique aqui para instruções",
|
||||
"es": "haga clic aquí para obtener instrucciones",
|
||||
"ar": "انقر هنا للحصول على تعليمات",
|
||||
"fr": "cliquez ici pour les instructions",
|
||||
"tr": "talimatlar için buraya tıklayın",
|
||||
"de": "klicken Sie hier für Anweisungen"
|
||||
},
|
||||
"COMMON$HERE": {
|
||||
"en": "here",
|
||||
"ja": "こちら",
|
||||
@@ -6484,51 +6274,6 @@
|
||||
"tr": "Üzerinde bir jeton oluştur",
|
||||
"de": "Token generieren auf"
|
||||
},
|
||||
"GITLAB$TOKEN_HELP_TEXT": {
|
||||
"en": "Get your <0>GitLab token</0> or <1>click here for instructions</1>",
|
||||
"ja": "<0>GitLabトークン</0>を取得するか、<1>手順についてはここをクリック</1>",
|
||||
"zh-CN": "获取您的<0>GitLab令牌</0>或<1>点击此处获取说明</1>",
|
||||
"zh-TW": "取得您的<0>GitLab權杖</0>或<1>點擊此處獲取說明</1>",
|
||||
"ko-KR": "<0>GitLab 토큰</0>을 받거나 <1>지침을 보려면 여기를 클릭</1>",
|
||||
"no": "Få din <0>GitLab-token</0> eller <1>klikk her for instruksjoner</1>",
|
||||
"it": "Ottieni il tuo <0>token GitLab</0> o <1>clicca qui per istruzioni</1>",
|
||||
"pt": "Obtenha seu <0>token GitLab</0> ou <1>clique aqui para instruções</1>",
|
||||
"es": "Obtenga su <0>token de GitLab</0> o <1>haga clic aquí para obtener instrucciones</1>",
|
||||
"ar": "احصل على <0>رمز GitLab</0> الخاص بك أو <1>انقر هنا للحصول على تعليمات</1>",
|
||||
"fr": "Obtenez votre <0>jeton GitLab</0> ou <1>cliquez ici pour les instructions</1>",
|
||||
"tr": "<0>GitLab jetonu</0> alın veya <1>talimatlar için buraya tıklayın</1>",
|
||||
"de": "Holen Sie sich Ihren <0>GitLab-Token</0> oder <1>klicken Sie hier für Anweisungen</1>"
|
||||
},
|
||||
"GITLAB$TOKEN_LINK_TEXT": {
|
||||
"en": "GitLab token",
|
||||
"ja": "GitLabトークン",
|
||||
"zh-CN": "GitLab令牌",
|
||||
"zh-TW": "GitLab權杖",
|
||||
"ko-KR": "GitLab 토큰",
|
||||
"no": "GitLab-token",
|
||||
"it": "token GitLab",
|
||||
"pt": "token GitLab",
|
||||
"es": "token de GitLab",
|
||||
"ar": "رمز GitLab",
|
||||
"fr": "jeton GitLab",
|
||||
"tr": "GitLab jetonu",
|
||||
"de": "GitLab-Token"
|
||||
},
|
||||
"GITLAB$INSTRUCTIONS_LINK_TEXT": {
|
||||
"en": "click here for instructions",
|
||||
"ja": "手順についてはここをクリック",
|
||||
"zh-CN": "点击此处获取说明",
|
||||
"zh-TW": "點擊此處獲取說明",
|
||||
"ko-KR": "지침을 보려면 여기를 클릭",
|
||||
"no": "klikk her for instruksjoner",
|
||||
"it": "clicca qui per istruzioni",
|
||||
"pt": "clique aqui para instruções",
|
||||
"es": "haga clic aquí para obtener instrucciones",
|
||||
"ar": "انقر هنا للحصول على تعليمات",
|
||||
"fr": "cliquez ici pour les instructions",
|
||||
"tr": "talimatlar için buraya tıklayın",
|
||||
"de": "klicken Sie hier für Anweisungen"
|
||||
},
|
||||
"GITLAB$OR_SEE": {
|
||||
"en": "or see the",
|
||||
"ja": "または参照",
|
||||
|
||||
@@ -35,15 +35,6 @@ const MOCK_USER_PREFERENCES: {
|
||||
settings: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the user settings to the default settings
|
||||
*
|
||||
* Useful for resetting the settings in tests
|
||||
*/
|
||||
export const resetTestHandlersMockSettings = () => {
|
||||
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
|
||||
};
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
@@ -89,7 +80,6 @@ const openHandsHandlers = [
|
||||
HttpResponse.json([
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"anthropic/claude-3.5",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
]),
|
||||
@@ -183,7 +173,6 @@ export const handlers = [
|
||||
return HttpResponse.json(settings);
|
||||
}),
|
||||
http.post("/api/settings", async ({ request }) => {
|
||||
await delay();
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
|
||||
@@ -9,9 +9,7 @@ export default [
|
||||
layout("routes/root-layout.tsx", [
|
||||
index("routes/home.tsx"),
|
||||
route("settings", "routes/settings.tsx", [
|
||||
index("routes/llm-settings.tsx"),
|
||||
route("git", "routes/git-settings.tsx"),
|
||||
route("app", "routes/app-settings.tsx"),
|
||||
index("routes/account-settings.tsx"),
|
||||
route("billing", "routes/billing.tsx"),
|
||||
route("api-keys", "routes/api-keys.tsx"),
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { HelpLink } from "#/components/features/settings/help-link";
|
||||
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useAppLogout } from "#/hooks/use-app-logout";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
|
||||
import { isCustomModel } from "#/utils/is-custom-model";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { ProviderOptions } from "#/types/settings";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
// Define REMOTE_RUNTIME_OPTIONS for testing
|
||||
const REMOTE_RUNTIME_OPTIONS = [
|
||||
{ key: "1", label: "Standard" },
|
||||
{ key: "2", label: "Enhanced" },
|
||||
{ key: "4", label: "Premium" },
|
||||
];
|
||||
|
||||
function AccountSettings() {
|
||||
const { t } = useTranslation();
|
||||
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 { providerTokensSet, providersAreSet } = useAuth();
|
||||
|
||||
const isFetching = isFetchingSettings || isFetchingResources;
|
||||
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const shouldHandleSpecialSaasCase =
|
||||
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && isSaas;
|
||||
|
||||
const determineWhetherToToggleAdvancedSettings = () => {
|
||||
if (shouldHandleSpecialSaasCase) return true;
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
isCustomModel(resources.models, settings.LLM_MODEL) ||
|
||||
hasAdvancedSettingsSet({
|
||||
...settings,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasAppSlug = !!config?.APP_SLUG;
|
||||
const isGitHubTokenSet =
|
||||
providerTokensSet.includes(ProviderOptions.github) || false;
|
||||
const isGitLabTokenSet =
|
||||
providerTokensSet.includes(ProviderOptions.gitlab) || false;
|
||||
const isLLMKeySet = settings?.LLM_API_KEY_SET;
|
||||
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 formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const onSubmit = 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";
|
||||
const enableMemoryCondenser =
|
||||
formData.get("enable-memory-condenser-switch")?.toString() === "on";
|
||||
const enableSoundNotifications =
|
||||
formData.get("enable-sound-notifications-switch")?.toString() === "on";
|
||||
const llmBaseUrl = formData.get("base-url-input")?.toString().trim() || "";
|
||||
const inputApiKey = formData.get("llm-api-key-input")?.toString() || "";
|
||||
const llmApiKey =
|
||||
inputApiKey === "" && isLLMKeySet
|
||||
? undefined // don't update if it's already set and input is empty
|
||||
: inputApiKey; // otherwise use the input value
|
||||
|
||||
const githubToken = formData.get("github-token-input")?.toString();
|
||||
const gitlabToken = formData.get("gitlab-token-input")?.toString();
|
||||
// we don't want the user to be able to modify these settings in SaaS
|
||||
const finalLlmModel = shouldHandleSpecialSaasCase
|
||||
? undefined
|
||||
: customLlmModel || fullLlmModel;
|
||||
const finalLlmBaseUrl = shouldHandleSpecialSaasCase
|
||||
? undefined
|
||||
: llmBaseUrl;
|
||||
const finalLlmApiKey = shouldHandleSpecialSaasCase ? undefined : llmApiKey;
|
||||
|
||||
const newSettings = {
|
||||
provider_tokens:
|
||||
githubToken || gitlabToken
|
||||
? {
|
||||
github: githubToken || "",
|
||||
gitlab: gitlabToken || "",
|
||||
}
|
||||
: undefined,
|
||||
LANGUAGE: languageValue,
|
||||
user_consents_to_analytics: userConsentsToAnalytics,
|
||||
ENABLE_DEFAULT_CONDENSER: enableMemoryCondenser,
|
||||
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
|
||||
LLM_MODEL: finalLlmModel,
|
||||
LLM_BASE_URL: finalLlmBaseUrl,
|
||||
llm_api_key: finalLlmApiKey,
|
||||
AGENT: formData.get("agent-input")?.toString(),
|
||||
SECURITY_ANALYZER:
|
||||
formData.get("security-analyzer-input")?.toString() || "",
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR:
|
||||
remoteRuntimeResourceFactor !== null
|
||||
? Number(remoteRuntimeResourceFactor)
|
||||
: DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
CONFIRMATION_MODE: confirmationModeIsEnabled,
|
||||
};
|
||||
|
||||
saveSettings(newSettings, {
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(userConsentsToAnalytics);
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
setLlmConfigMode(isAdvancedSettingsSet ? "advanced" : "basic");
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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 <div>Failed to fetch settings. Please try reloading.</div>;
|
||||
}
|
||||
|
||||
const onToggleAdvancedMode = (isToggled: boolean) => {
|
||||
setLlmConfigMode(isToggled ? "advanced" : "basic");
|
||||
if (!isToggled) {
|
||||
// reset advanced state
|
||||
setConfirmationModeIsEnabled(!!settings?.SECURITY_ANALYZER);
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching || !settings) {
|
||||
return (
|
||||
<div className="flex grow p-4">
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
data-testid="account-settings-form"
|
||||
ref={formRef}
|
||||
action={onSubmit}
|
||||
className="flex flex-col grow overflow-auto"
|
||||
>
|
||||
<div className="flex flex-col gap-12 px-11 py-9">
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<section
|
||||
data-testid="llm-settings-section"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-7">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
{t(I18nKey.SETTINGS$LLM_SETTINGS)}
|
||||
</h2>
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={isAdvancedSettingsSet}
|
||||
onToggle={onToggleAdvancedMode}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{llmConfigMode === "basic" && !shouldHandleSpecialSaasCase && (
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={settings.LLM_MODEL}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
|
||||
defaultValue={settings.LLM_MODEL}
|
||||
placeholder="anthropic/claude-3-5-sonnet-20241022"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
{llmConfigMode === "advanced" && !shouldHandleSpecialSaasCase && (
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label={t(I18nKey.SETTINGS$BASE_URL)}
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={isLLMKeySet ? "<hidden>" : ""}
|
||||
startContent={
|
||||
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!shouldHandleSpecialSaasCase && (
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label={t(I18nKey.SETTINGS$AGENT)}
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
label: agent,
|
||||
})) || []
|
||||
}
|
||||
wrapperClassName="w-[680px]"
|
||||
defaultSelectedKey={settings.AGENT}
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isSaas && llmConfigMode === "advanced" && (
|
||||
<SettingsDropdownInput
|
||||
testId="runtime-settings-input"
|
||||
name="runtime-settings-input"
|
||||
label={
|
||||
<>
|
||||
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
|
||||
<a href="mailto:contact@all-hands.dev">
|
||||
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
}
|
||||
items={REMOTE_RUNTIME_OPTIONS}
|
||||
defaultSelectedKey={settings.REMOTE_RUNTIME_RESOURCE_FACTOR?.toString()}
|
||||
isDisabled
|
||||
isClearable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-confirmation-mode-switch"
|
||||
onToggle={setConfirmationModeIsEnabled}
|
||||
defaultIsToggled={!!settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && (
|
||||
<SettingsSwitch
|
||||
testId="enable-memory-condenser-switch"
|
||||
name="enable-memory-condenser-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_DEFAULT_CONDENSER}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
{llmConfigMode === "advanced" && confirmationModeIsEnabled && (
|
||||
<div>
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-input"
|
||||
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
|
||||
items={
|
||||
resources?.securityAnalyzers.map((analyzer) => ({
|
||||
key: analyzer,
|
||||
label: analyzer,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.SECURITY_ANALYZER}
|
||||
isClearable
|
||||
showOptionalTag
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
{t(I18nKey.SETTINGS$GIT_SETTINGS)}
|
||||
</h2>
|
||||
{isSaas && hasAppSlug && (
|
||||
<Link
|
||||
to={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</BrandButton>
|
||||
</Link>
|
||||
)}
|
||||
{!isSaas && (
|
||||
<>
|
||||
<SettingsInput
|
||||
testId="github-token-input"
|
||||
name="github-token-input"
|
||||
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
isGitHubTokenSet && (
|
||||
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
|
||||
)
|
||||
}
|
||||
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
|
||||
/>
|
||||
<p data-testid="github-token-help-anchor" className="text-xs">
|
||||
{" "}
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<b>
|
||||
{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>{" "}
|
||||
</b>
|
||||
{t(I18nKey.COMMON$HERE)}{" "}
|
||||
<b>
|
||||
<a
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(I18nKey.COMMON$CLICK_FOR_INSTRUCTIONS)}
|
||||
</a>
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
|
||||
<SettingsInput
|
||||
testId="gitlab-token-input"
|
||||
name="gitlab-token-input"
|
||||
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
startContent={
|
||||
isGitLabTokenSet && (
|
||||
<KeyStatusIcon isSet={!!isGitLabTokenSet} />
|
||||
)
|
||||
}
|
||||
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
|
||||
/>
|
||||
|
||||
<p data-testid="gitlab-token-help-anchor" className="text-xs">
|
||||
{" "}
|
||||
{t(I18nKey.GITLAB$GET_TOKEN)}{" "}
|
||||
<b>
|
||||
{" "}
|
||||
<a
|
||||
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitLab
|
||||
</a>{" "}
|
||||
</b>
|
||||
{t(I18nKey.GITLAB$OR_SEE)}{" "}
|
||||
<b>
|
||||
<a
|
||||
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(I18nKey.COMMON$DOCUMENTATION)}
|
||||
</a>
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleLogout}
|
||||
isDisabled={!providersAreSet}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-6">
|
||||
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS)}
|
||||
</h2>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="language-input"
|
||||
name="language-input"
|
||||
label={t(I18nKey.SETTINGS$LANGUAGE)}
|
||||
items={AvailableLanguages.map((language) => ({
|
||||
key: language.value,
|
||||
label: language.label,
|
||||
}))}
|
||||
defaultSelectedKey={settings.LANGUAGE}
|
||||
wrapperClassName="w-[680px]"
|
||||
isClearable={false}
|
||||
/>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-analytics-switch"
|
||||
name="enable-analytics-switch"
|
||||
defaultIsToggled={!!isAnalyticsEnabled}
|
||||
>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-sound-notifications-switch"
|
||||
name="enable-sound-notifications-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
|
||||
</SettingsSwitch>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<footer className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
formRef.current?.requestSubmit();
|
||||
}}
|
||||
>
|
||||
{t(I18nKey.BUTTON$SAVE)}
|
||||
</BrandButton>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountSettings;
|
||||
@@ -3,7 +3,7 @@ import { ApiKeysManager } from "#/components/features/settings/api-keys-manager"
|
||||
|
||||
function ApiKeysScreen() {
|
||||
return (
|
||||
<div className="flex flex-col grow overflow-auto p-9">
|
||||
<div className="flex flex-col grow overflow-auto p-11">
|
||||
<ApiKeysManager />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
|
||||
|
||||
function AppSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mutate: saveSettings, isPending } = useSaveSettings();
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
|
||||
const [languageInputHasChanged, setLanguageInputHasChanged] =
|
||||
React.useState(false);
|
||||
const [analyticsSwitchHasChanged, setAnalyticsSwitchHasChanged] =
|
||||
React.useState(false);
|
||||
const [
|
||||
soundNotificationsSwitchHasChanged,
|
||||
setSoundNotificationsSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const languageLabel = formData.get("language-input")?.toString();
|
||||
const languageValue = AvailableLanguages.find(
|
||||
({ label }) => label === languageLabel,
|
||||
)?.value;
|
||||
const language = languageValue || DEFAULT_SETTINGS.LANGUAGE;
|
||||
|
||||
const enableAnalytics =
|
||||
formData.get("enable-analytics-switch")?.toString() === "on";
|
||||
const enableSoundNotifications =
|
||||
formData.get("enable-sound-notifications-switch")?.toString() === "on";
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
LANGUAGE: language,
|
||||
user_consents_to_analytics: enableAnalytics,
|
||||
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(enableAnalytics);
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
onSettled: () => {
|
||||
setLanguageInputHasChanged(false);
|
||||
setAnalyticsSwitchHasChanged(false);
|
||||
setSoundNotificationsSwitchHasChanged(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const checkIfLanguageInputHasChanged = (value: string) => {
|
||||
const selectedLanguage = AvailableLanguages.find(
|
||||
({ label: langValue }) => langValue === value,
|
||||
)?.label;
|
||||
const currentLanguage = AvailableLanguages.find(
|
||||
({ value: langValue }) => langValue === settings?.LANGUAGE,
|
||||
)?.label;
|
||||
|
||||
setLanguageInputHasChanged(selectedLanguage !== currentLanguage);
|
||||
};
|
||||
|
||||
const checkIfAnalyticsSwitchHasChanged = (checked: boolean) => {
|
||||
const currentAnalytics = !!settings?.USER_CONSENTS_TO_ANALYTICS;
|
||||
setAnalyticsSwitchHasChanged(checked !== currentAnalytics);
|
||||
};
|
||||
|
||||
const checkIfSoundNotificationsSwitchHasChanged = (checked: boolean) => {
|
||||
const currentSoundNotifications = !!settings?.ENABLE_SOUND_NOTIFICATIONS;
|
||||
setSoundNotificationsSwitchHasChanged(
|
||||
checked !== currentSoundNotifications,
|
||||
);
|
||||
};
|
||||
|
||||
const formIsClean =
|
||||
!languageInputHasChanged &&
|
||||
!analyticsSwitchHasChanged &&
|
||||
!soundNotificationsSwitchHasChanged;
|
||||
|
||||
const shouldBeLoading = !settings || isLoading || isPending;
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid="app-settings-screen"
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
{shouldBeLoading && <AppSettingsInputsSkeleton />}
|
||||
{!shouldBeLoading && (
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
<LanguageInput
|
||||
name="language-input"
|
||||
defaultKey={settings.LANGUAGE}
|
||||
onChange={checkIfLanguageInputHasChanged}
|
||||
/>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-analytics-switch"
|
||||
name="enable-analytics-switch"
|
||||
defaultIsToggled={!!settings.USER_CONSENTS_TO_ANALYTICS}
|
||||
onToggle={checkIfAnalyticsSwitchHasChanged}
|
||||
>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-sound-notifications-switch"
|
||||
name="enable-sound-notifications-switch"
|
||||
defaultIsToggled={!!settings.ENABLE_SOUND_NOTIFICATIONS}
|
||||
onToggle={checkIfSoundNotificationsSwitchHasChanged}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
|
||||
</SettingsSwitch>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isDisabled={isPending || formIsClean}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppSettingsScreen;
|
||||
@@ -1,134 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { useLogout } from "#/hooks/mutation/use-logout";
|
||||
import { GitHubTokenInput } from "#/components/features/settings/git-settings/github-token-input";
|
||||
import { GitLabTokenInput } from "#/components/features/settings/git-settings/gitlab-token-input";
|
||||
import { ConfigureGitHubRepositoriesAnchor } from "#/components/features/settings/git-settings/configure-github-repositories-anchor";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
|
||||
|
||||
function GitSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mutate: saveSettings, isPending } = useSaveSettings();
|
||||
const { mutate: disconnectGitTokens } = useLogout();
|
||||
|
||||
const { data: settings, isLoading } = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const [githubTokenInputHasValue, setGithubTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
const [gitlabTokenInputHasValue, setGitlabTokenInputHasValue] =
|
||||
React.useState(false);
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
const isGitHubTokenSet = !!settings?.PROVIDER_TOKENS_SET.github;
|
||||
const isGitLabTokenSet = !!settings?.PROVIDER_TOKENS_SET.gitlab;
|
||||
|
||||
const formAction = async (formData: FormData) => {
|
||||
const disconnectButtonClicked =
|
||||
formData.get("disconnect-tokens-button") !== null;
|
||||
|
||||
if (disconnectButtonClicked) {
|
||||
disconnectGitTokens();
|
||||
return;
|
||||
}
|
||||
|
||||
const githubToken = formData.get("github-token-input")?.toString() || "";
|
||||
const gitlabToken = formData.get("gitlab-token-input")?.toString() || "";
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
provider_tokens: {
|
||||
github: githubToken,
|
||||
gitlab: gitlabToken,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
onSettled: () => {
|
||||
setGithubTokenInputHasValue(false);
|
||||
setGitlabTokenInputHasValue(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const formIsClean = !githubTokenInputHasValue && !gitlabTokenInputHasValue;
|
||||
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid="git-settings-screen"
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
{isLoading && <GitSettingInputsSkeleton />}
|
||||
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
|
||||
)}
|
||||
|
||||
{!isSaas && !isLoading && (
|
||||
<div className="p-9 flex flex-col gap-12">
|
||||
<GitHubTokenInput
|
||||
name="github-token-input"
|
||||
isGitHubTokenSet={isGitHubTokenSet}
|
||||
onChange={(value) => {
|
||||
setGithubTokenInputHasValue(!!value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<GitLabTokenInput
|
||||
name="gitlab-token-input"
|
||||
isGitLabTokenSet={isGitLabTokenSet}
|
||||
onChange={(value) => {
|
||||
setGitlabTokenInputHasValue(!!value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!shouldRenderExternalConfigureButtons && (
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
testId="disconnect-tokens-button"
|
||||
name="disconnect-tokens-button"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
isDisabled={!isGitHubTokenSet && !isGitLabTokenSet}
|
||||
>
|
||||
Disconnect Tokens
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={isPending || formIsClean}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default GitSettingsScreen;
|
||||
@@ -22,7 +22,7 @@ function HomeScreen() {
|
||||
|
||||
<hr className="border-[#717888]" />
|
||||
|
||||
<main className="flex flex-col md:flex-row justify-between gap-4">
|
||||
<main className="flex justify-between gap-4">
|
||||
<RepoConnector
|
||||
onRepoSelection={(title) => setSelectedRepoTitle(title)}
|
||||
/>
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AxiosError } from "axios";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { HelpLink } from "#/components/features/settings/help-link";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { isCustomModel } from "#/utils/is-custom-model";
|
||||
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
|
||||
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
|
||||
|
||||
function LlmSettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { mutate: saveSettings, isPending } = useSaveSettings();
|
||||
|
||||
const { data: resources } = useAIConfigOptions();
|
||||
const { data: settings, isLoading, isFetching } = useSettings();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const [view, setView] = React.useState<"basic" | "advanced">("basic");
|
||||
const [securityAnalyzerInputIsVisible, setSecurityAnalyzerInputIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const [dirtyInputs, setDirtyInputs] = React.useState({
|
||||
model: false,
|
||||
apiKey: false,
|
||||
baseUrl: false,
|
||||
agent: false,
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
});
|
||||
|
||||
const modelsAndProviders = organizeModelsAndProviders(
|
||||
resources?.models || [],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const determineWhetherToToggleAdvancedSettings = () => {
|
||||
if (resources && settings) {
|
||||
return (
|
||||
isCustomModel(resources.models, settings.LLM_MODEL) ||
|
||||
hasAdvancedSettingsSet({
|
||||
...settings,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const userSettingsIsAdvanced = determineWhetherToToggleAdvancedSettings();
|
||||
if (settings) setSecurityAnalyzerInputIsVisible(settings.CONFIRMATION_MODE);
|
||||
|
||||
if (userSettingsIsAdvanced) setView("advanced");
|
||||
else setView("basic");
|
||||
}, [settings, resources]);
|
||||
|
||||
const handleSuccessfulMutation = () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
setDirtyInputs({
|
||||
model: false,
|
||||
apiKey: false,
|
||||
baseUrl: false,
|
||||
agent: false,
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleErrorMutation = (error: AxiosError) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
};
|
||||
|
||||
const basicFormAction = (formData: FormData) => {
|
||||
const provider = formData.get("llm-provider-input")?.toString();
|
||||
const model = formData.get("llm-model-input")?.toString();
|
||||
const apiKey = formData.get("llm-api-key-input")?.toString();
|
||||
|
||||
const fullLlmModel =
|
||||
provider && model && `${provider}/${model}`.toLowerCase();
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
LLM_MODEL: fullLlmModel,
|
||||
llm_api_key: apiKey || null,
|
||||
},
|
||||
{
|
||||
onSuccess: handleSuccessfulMutation,
|
||||
onError: handleErrorMutation,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const advancedFormAction = (formData: FormData) => {
|
||||
const model = formData.get("llm-custom-model-input")?.toString();
|
||||
const baseUrl = formData.get("base-url-input")?.toString();
|
||||
const apiKey = formData.get("llm-api-key-input")?.toString();
|
||||
const agent = formData.get("agent-input")?.toString();
|
||||
const confirmationMode =
|
||||
formData.get("enable-confirmation-mode-switch")?.toString() === "on";
|
||||
const enableDefaultCondenser =
|
||||
formData.get("enable-memory-condenser-switch")?.toString() === "on";
|
||||
const securityAnalyzer = formData
|
||||
.get("security-analyzer-input")
|
||||
?.toString();
|
||||
|
||||
saveSettings(
|
||||
{
|
||||
LLM_MODEL: model,
|
||||
LLM_BASE_URL: baseUrl,
|
||||
llm_api_key: apiKey,
|
||||
AGENT: agent,
|
||||
CONFIRMATION_MODE: confirmationMode,
|
||||
ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
|
||||
SECURITY_ANALYZER: confirmationMode ? securityAnalyzer : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: handleSuccessfulMutation,
|
||||
onError: handleErrorMutation,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
if (view === "basic") basicFormAction(formData);
|
||||
else advancedFormAction(formData);
|
||||
};
|
||||
|
||||
const handleToggleAdvancedSettings = (isToggled: boolean) => {
|
||||
setSecurityAnalyzerInputIsVisible(!!settings?.CONFIRMATION_MODE);
|
||||
setView(isToggled ? "advanced" : "basic");
|
||||
setDirtyInputs({
|
||||
model: false,
|
||||
apiKey: false,
|
||||
baseUrl: false,
|
||||
agent: false,
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelIsDirty = (model: string | null) => {
|
||||
// openai providers are special case; see ModelSelector
|
||||
// component for details
|
||||
const modelIsDirty = model !== settings?.LLM_MODEL.replace("openai/", "");
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
model: modelIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleApiKeyIsDirty = (apiKey: string) => {
|
||||
const apiKeyIsDirty = apiKey !== "";
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
apiKey: apiKeyIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCustomModelIsDirty = (model: string) => {
|
||||
const modelIsDirty = model !== settings?.LLM_MODEL && model !== "";
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
model: modelIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBaseUrlIsDirty = (baseUrl: string) => {
|
||||
const baseUrlIsDirty = baseUrl !== settings?.LLM_BASE_URL;
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
baseUrl: baseUrlIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAgentIsDirty = (agent: string) => {
|
||||
const agentIsDirty = agent !== settings?.AGENT && agent !== "";
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
agent: agentIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleConfirmationModeIsDirty = (isToggled: boolean) => {
|
||||
setSecurityAnalyzerInputIsVisible(isToggled);
|
||||
const confirmationModeIsDirty = isToggled !== settings?.CONFIRMATION_MODE;
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
confirmationMode: confirmationModeIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEnableDefaultCondenserIsDirty = (isToggled: boolean) => {
|
||||
const enableDefaultCondenserIsDirty =
|
||||
isToggled !== settings?.ENABLE_DEFAULT_CONDENSER;
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
enableDefaultCondenser: enableDefaultCondenserIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => {
|
||||
const securityAnalyzerIsDirty =
|
||||
securityAnalyzer !== settings?.SECURITY_ANALYZER;
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
securityAnalyzer: securityAnalyzerIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const formIsDirty = Object.values(dirtyInputs).some((isDirty) => isDirty);
|
||||
|
||||
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
|
||||
|
||||
return (
|
||||
<div data-testid="llm-settings-screen" className="h-full">
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-col h-full justify-between"
|
||||
>
|
||||
<div className="p-9 flex flex-col gap-6">
|
||||
<SettingsSwitch
|
||||
testId="advanced-settings-switch"
|
||||
defaultIsToggled={view === "advanced"}
|
||||
onToggle={handleToggleAdvancedSettings}
|
||||
isToggled={view === "advanced"}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ADVANCED)}
|
||||
</SettingsSwitch>
|
||||
|
||||
{view === "basic" && (
|
||||
<div
|
||||
data-testid="llm-settings-form-basic"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{!isLoading && !isFetching && (
|
||||
<ModelSelector
|
||||
models={modelsAndProviders}
|
||||
currentModel={
|
||||
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
|
||||
}
|
||||
onChange={handleModelIsDirty}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
startContent={
|
||||
settings.LLM_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === "advanced" && (
|
||||
<div
|
||||
data-testid="llm-settings-form-advanced"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsInput
|
||||
testId="llm-custom-model-input"
|
||||
name="llm-custom-model-input"
|
||||
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
|
||||
defaultValue={
|
||||
settings.LLM_MODEL || "anthropic/claude-3-5-sonnet-20241022"
|
||||
}
|
||||
placeholder="anthropic/claude-3-5-sonnet-20241022"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
onChange={handleCustomModelIsDirty}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="base-url-input"
|
||||
name="base-url-input"
|
||||
label={t(I18nKey.SETTINGS$BASE_URL)}
|
||||
defaultValue={settings.LLM_BASE_URL}
|
||||
placeholder="https://api.openai.com"
|
||||
type="text"
|
||||
className="w-[680px]"
|
||||
onChange={handleBaseUrlIsDirty}
|
||||
/>
|
||||
|
||||
<SettingsInput
|
||||
testId="llm-api-key-input"
|
||||
name="llm-api-key-input"
|
||||
label={t(I18nKey.SETTINGS_FORM$API_KEY)}
|
||||
type="password"
|
||||
className="w-[680px]"
|
||||
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
|
||||
onChange={handleApiKeyIsDirty}
|
||||
startContent={
|
||||
settings.LLM_API_KEY_SET && (
|
||||
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<HelpLink
|
||||
testId="llm-api-key-help-anchor"
|
||||
text={t(I18nKey.SETTINGS$DONT_KNOW_API_KEY)}
|
||||
linkText={t(I18nKey.SETTINGS$CLICK_FOR_INSTRUCTIONS)}
|
||||
href="https://docs.all-hands.dev/modules/usage/installation#getting-an-api-key"
|
||||
/>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="agent-input"
|
||||
name="agent-input"
|
||||
label={t(I18nKey.SETTINGS$AGENT)}
|
||||
items={
|
||||
resources?.agents.map((agent) => ({
|
||||
key: agent,
|
||||
label: agent,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.AGENT}
|
||||
isClearable={false}
|
||||
onInputChange={handleAgentIsDirty}
|
||||
wrapperClassName="w-[680px]"
|
||||
/>
|
||||
|
||||
{config?.APP_MODE === "saas" && (
|
||||
<SettingsDropdownInput
|
||||
testId="runtime-settings-input"
|
||||
name="runtime-settings-input"
|
||||
label={
|
||||
<>
|
||||
{t(I18nKey.SETTINGS$RUNTIME_SETTINGS)}
|
||||
<a href="mailto:contact@all-hands.dev">
|
||||
{t(I18nKey.SETTINGS$GET_IN_TOUCH)}
|
||||
</a>
|
||||
)
|
||||
</>
|
||||
}
|
||||
items={[]}
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-memory-condenser-switch"
|
||||
name="enable-memory-condenser-switch"
|
||||
defaultIsToggled={settings.ENABLE_DEFAULT_CONDENSER}
|
||||
onToggle={handleEnableDefaultCondenserIsDirty}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
|
||||
</SettingsSwitch>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-confirmation-mode-switch"
|
||||
name="enable-confirmation-mode-switch"
|
||||
onToggle={handleConfirmationModeIsDirty}
|
||||
defaultIsToggled={settings.CONFIRMATION_MODE}
|
||||
isBeta
|
||||
>
|
||||
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
|
||||
</SettingsSwitch>
|
||||
|
||||
{securityAnalyzerInputIsVisible && (
|
||||
<SettingsDropdownInput
|
||||
testId="security-analyzer-input"
|
||||
name="security-analyzer-input"
|
||||
label={t(I18nKey.SETTINGS$SECURITY_ANALYZER)}
|
||||
items={
|
||||
resources?.securityAnalyzers.map((analyzer) => ({
|
||||
key: analyzer,
|
||||
label: analyzer,
|
||||
})) || []
|
||||
}
|
||||
defaultSelectedKey={settings.SECURITY_ANALYZER}
|
||||
isClearable
|
||||
showOptionalTag
|
||||
onInputChange={handleSecurityAnalyzerIsDirty}
|
||||
wrapperClassName="w-[680px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end border-t border-t-tertiary">
|
||||
<BrandButton
|
||||
testId="submit-button"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={!formIsDirty || isPending}
|
||||
>
|
||||
{!isPending && t("SETTINGS$SAVE_CHANGES")}
|
||||
{isPending && t("SETTINGS$SAVING")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LlmSettingsScreen;
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
|
||||
import { NavLink, Outlet } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@@ -8,44 +7,9 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
|
||||
const saasNavItems = [
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
|
||||
];
|
||||
|
||||
const ossNavItems = [
|
||||
{ to: "/settings", text: t("SETTINGS$NAV_LLM") },
|
||||
{ to: "/settings/git", text: t("SETTINGS$NAV_GIT") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
];
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSaas) {
|
||||
if (pathname === "/settings") {
|
||||
navigate("/settings/git");
|
||||
}
|
||||
} else {
|
||||
const noEnteringPaths = [
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
];
|
||||
if (noEnteringPaths.includes(pathname)) {
|
||||
navigate("/settings");
|
||||
}
|
||||
}
|
||||
}, [isSaas, pathname]);
|
||||
|
||||
const navItems = isSaas ? saasNavItems : ossNavItems;
|
||||
|
||||
return (
|
||||
<main
|
||||
data-testid="settings-screen"
|
||||
@@ -56,26 +20,32 @@ function SettingsScreen() {
|
||||
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
|
||||
</header>
|
||||
|
||||
<nav
|
||||
data-testid="settings-navbar"
|
||||
className="flex items-end gap-12 px-9 border-b border-tertiary"
|
||||
>
|
||||
{navItems.map(({ to, text }) => (
|
||||
<NavLink
|
||||
end
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"border-b-2 border-transparent py-2.5",
|
||||
isActive && "border-primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
{isSaas && (
|
||||
<nav
|
||||
data-testid="settings-navbar"
|
||||
className="flex items-end gap-12 px-11 border-b border-tertiary"
|
||||
>
|
||||
{[
|
||||
{ to: "/settings", text: "Account" },
|
||||
{ to: "/settings/billing", text: "Credits" },
|
||||
{ to: "/settings/api-keys", text: "API Keys" },
|
||||
].map(({ to, text }) => (
|
||||
<NavLink
|
||||
end
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"border-b-2 border-transparent py-2.5",
|
||||
isActive && "border-primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ul className="text-[#F9FBFE] text-sm">{text}</ul>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col grow overflow-auto">
|
||||
<Outlet />
|
||||
|
||||
@@ -6,14 +6,10 @@
|
||||
@apply bg-tertiary border border-neutral-600 rounded;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply bg-gray-400 rounded-md animate-pulse;
|
||||
}
|
||||
|
||||
.skeleton-round {
|
||||
@apply bg-gray-400 rounded-full animate-pulse;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply bg-gray-400 rounded-md animate-pulse;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { Settings } from "#/types/settings";
|
||||
|
||||
export const hasAdvancedSettingsSet = (settings: Partial<Settings>): boolean =>
|
||||
Object.keys(settings).length > 0 &&
|
||||
(!!settings.LLM_BASE_URL ||
|
||||
settings.AGENT !== DEFAULT_SETTINGS.AGENT ||
|
||||
settings.CONFIRMATION_MODE ||
|
||||
!!settings.SECURITY_ANALYZER);
|
||||
!!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;
|
||||
|
||||
@@ -20,7 +20,10 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.message import Message
|
||||
from openhands.events.action import Action, AgentFinishAction, MessageAction
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
AgentFinishAction,
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.memory.condenser import Condenser
|
||||
@@ -170,8 +173,7 @@ class CodeActAgent(Agent):
|
||||
f'Processing {len(condensed_history)} events from a total of {len(state.history)} events'
|
||||
)
|
||||
|
||||
initial_user_message = self._get_initial_user_message(state.history)
|
||||
messages = self._get_messages(condensed_history, initial_user_message)
|
||||
messages = self._get_messages(condensed_history)
|
||||
params: dict = {
|
||||
'messages': self.llm.format_messages_for_llm(messages),
|
||||
}
|
||||
@@ -214,29 +216,7 @@ class CodeActAgent(Agent):
|
||||
self.pending_actions.append(action)
|
||||
return self.pending_actions.popleft()
|
||||
|
||||
def _get_initial_user_message(self, history: list[Event]) -> MessageAction:
|
||||
"""Finds the initial user message action from the full history."""
|
||||
initial_user_message: MessageAction | None = None
|
||||
for event in history:
|
||||
if isinstance(event, MessageAction) and event.source == 'user':
|
||||
initial_user_message = event
|
||||
break
|
||||
|
||||
if initial_user_message is None:
|
||||
# This should not happen in a valid conversation
|
||||
logger.error(
|
||||
f'CRITICAL: Could not find the initial user MessageAction in the full {len(history)} events history.'
|
||||
)
|
||||
# Depending on desired robustness, could raise error or create a dummy action
|
||||
# and log the error
|
||||
raise ValueError(
|
||||
'Initial user message not found in history. Please report this issue.'
|
||||
)
|
||||
return initial_user_message
|
||||
|
||||
def _get_messages(
|
||||
self, events: list[Event], initial_user_message: MessageAction
|
||||
) -> list[Message]:
|
||||
def _get_messages(self, events: list[Event]) -> list[Message]:
|
||||
"""Constructs the message history for the LLM conversation.
|
||||
|
||||
This method builds a structured conversation history by processing events from the state
|
||||
@@ -273,7 +253,6 @@ class CodeActAgent(Agent):
|
||||
# Use ConversationMemory to process events (including SystemMessageAction)
|
||||
messages = self.conversation_memory.process_events(
|
||||
condensed_history=events,
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=self.llm.config.max_message_chars,
|
||||
vision_is_active=self.llm.vision_is_active(),
|
||||
)
|
||||
|
||||
@@ -190,7 +190,7 @@ class AgentController:
|
||||
logger.debug(f'System message got from agent: {system_message}')
|
||||
if system_message:
|
||||
self.event_stream.add_event(system_message, EventSource.AGENT)
|
||||
logger.info(f'System message added to event stream: {system_message}')
|
||||
logger.debug(f'System message added to event stream: {system_message}')
|
||||
|
||||
async def close(self, set_stop_state: bool = True) -> None:
|
||||
"""Closes the agent controller, canceling any ongoing tasks and unsubscribing from the event stream.
|
||||
@@ -1020,7 +1020,7 @@ class AgentController:
|
||||
self.state.start_id = 0
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
'debug',
|
||||
f'AgentController {self.id} - created new state. start_id: {self.state.start_id}',
|
||||
)
|
||||
else:
|
||||
@@ -1030,7 +1030,7 @@ class AgentController:
|
||||
self.state.start_id = 0
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
'debug',
|
||||
f'AgentController {self.id} initializing history from event {self.state.start_id}',
|
||||
)
|
||||
|
||||
@@ -1143,169 +1143,70 @@ class AgentController:
|
||||
|
||||
def _handle_long_context_error(self) -> None:
|
||||
# When context window is exceeded, keep roughly half of agent interactions
|
||||
kept_events = self._apply_conversation_window()
|
||||
kept_event_ids = {e.id for e in kept_events}
|
||||
|
||||
self.log(
|
||||
'info',
|
||||
f'Context window exceeded. Keeping events with IDs: {kept_event_ids}',
|
||||
)
|
||||
|
||||
# The events to forget are those that are not in the kept set
|
||||
kept_event_ids = {
|
||||
e.id for e in self._apply_conversation_window(self.state.history)
|
||||
}
|
||||
forgotten_event_ids = {e.id for e in self.state.history} - kept_event_ids
|
||||
|
||||
if len(kept_event_ids) == 0:
|
||||
self.log(
|
||||
'warning',
|
||||
'No events kept after applying conversation window. This should not happen.',
|
||||
)
|
||||
|
||||
# verify that the first event id in kept_event_ids is the same as the start_id
|
||||
if len(kept_event_ids) > 0 and self.state.history[0].id not in kept_event_ids:
|
||||
self.log(
|
||||
'warning',
|
||||
f'First event after applying conversation window was not kept: {self.state.history[0].id} not in {kept_event_ids}',
|
||||
)
|
||||
# Save the ID of the first event in our truncated history for future reloading
|
||||
if self.state.history:
|
||||
self.state.start_id = self.state.history[0].id
|
||||
|
||||
# Add an error event to trigger another step by the agent
|
||||
self.event_stream.add_event(
|
||||
CondensationAction(
|
||||
forgotten_events_start_id=min(forgotten_event_ids)
|
||||
if forgotten_event_ids
|
||||
else 0,
|
||||
forgotten_events_end_id=max(forgotten_event_ids)
|
||||
if forgotten_event_ids
|
||||
else 0,
|
||||
forgotten_events_start_id=min(forgotten_event_ids),
|
||||
forgotten_events_end_id=max(forgotten_event_ids),
|
||||
),
|
||||
EventSource.AGENT,
|
||||
)
|
||||
|
||||
def _apply_conversation_window(self) -> list[Event]:
|
||||
def _apply_conversation_window(self, events: list[Event]) -> list[Event]:
|
||||
"""Cuts history roughly in half when context window is exceeded.
|
||||
|
||||
It preserves action-observation pairs and ensures that the system message,
|
||||
the first user message, and its associated recall observation are always included
|
||||
at the beginning of the context window.
|
||||
It preserves action-observation pairs and ensures that the first user message is always included.
|
||||
|
||||
The algorithm:
|
||||
1. Identify essential initial events: System Message, First User Message, Recall Observation.
|
||||
2. Determine the slice of recent events to potentially keep.
|
||||
3. Validate the start of the recent slice for dangling observations.
|
||||
4. Combine essential events and validated recent events, ensuring essentials come first.
|
||||
1. Cut history in half
|
||||
2. Check first event in new history:
|
||||
- If Observation: find and include its Action
|
||||
- If MessageAction: ensure its related Action-Observation pair isn't split
|
||||
3. Always include the first user message
|
||||
|
||||
Args:
|
||||
events: List of events to filter
|
||||
|
||||
Returns:
|
||||
Filtered list of events keeping newest half while preserving pairs and essential initial events.
|
||||
Filtered list of events keeping newest half while preserving pairs
|
||||
"""
|
||||
if not self.state.history:
|
||||
return []
|
||||
if not events:
|
||||
return events
|
||||
|
||||
history = self.state.history
|
||||
|
||||
# 1. Identify essential initial events
|
||||
system_message: SystemMessageAction | None = None
|
||||
first_user_msg: MessageAction | None = None
|
||||
recall_action: RecallAction | None = None
|
||||
recall_observation: Observation | None = None
|
||||
|
||||
# Find System Message (should be the first event, if it exists)
|
||||
system_message = next(
|
||||
(e for e in history if isinstance(e, SystemMessageAction)), None
|
||||
)
|
||||
assert (
|
||||
system_message is None
|
||||
or isinstance(system_message, SystemMessageAction)
|
||||
and system_message.id == history[0].id
|
||||
# Find first user message - we'll need to ensure it's included
|
||||
first_user_msg = next(
|
||||
(
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, MessageAction) and e.source == EventSource.USER
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
# Find First User Message, which MUST exist
|
||||
first_user_msg = self._first_user_message()
|
||||
if first_user_msg is None:
|
||||
raise RuntimeError('No first user message found in the event stream.')
|
||||
# cut in half
|
||||
mid_point = max(1, len(events) // 2)
|
||||
kept_events = events[mid_point:]
|
||||
if len(kept_events) > 0 and isinstance(kept_events[0], Observation):
|
||||
kept_events = kept_events[1:]
|
||||
|
||||
first_user_msg_index = -1
|
||||
for i, event in enumerate(history):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
first_user_msg = event
|
||||
first_user_msg_index = i
|
||||
break
|
||||
# Ensure first user message is included
|
||||
if first_user_msg and first_user_msg not in kept_events:
|
||||
kept_events = [first_user_msg] + kept_events
|
||||
|
||||
# Find Recall Action and Observation related to the First User Message
|
||||
if first_user_msg is not None and first_user_msg_index != -1:
|
||||
# Look for RecallAction after the first user message
|
||||
for i in range(first_user_msg_index + 1, len(history)):
|
||||
event = history[i]
|
||||
if (
|
||||
isinstance(event, RecallAction)
|
||||
and event.query == first_user_msg.content
|
||||
):
|
||||
# Found RecallAction, now look for its Observation
|
||||
recall_action = event
|
||||
for j in range(i + 1, len(history)):
|
||||
obs_event = history[j]
|
||||
# Check for Observation caused by this RecallAction
|
||||
if (
|
||||
isinstance(obs_event, Observation)
|
||||
and obs_event.cause == recall_action.id
|
||||
):
|
||||
recall_observation = obs_event
|
||||
break # Found the observation, stop inner loop
|
||||
break # Found the recall action (and maybe obs), stop outer loop
|
||||
|
||||
essential_events: list[Event] = []
|
||||
if system_message:
|
||||
essential_events.append(system_message)
|
||||
# start_id points to first user message
|
||||
if first_user_msg:
|
||||
essential_events.append(first_user_msg)
|
||||
# Also keep the RecallAction that triggered the essential RecallObservation
|
||||
if recall_action:
|
||||
essential_events.append(recall_action)
|
||||
if recall_observation:
|
||||
essential_events.append(recall_observation)
|
||||
self.state.start_id = first_user_msg.id
|
||||
|
||||
# 2. Determine the slice of recent events to potentially keep
|
||||
num_non_essential_events = len(history) - len(essential_events)
|
||||
# Keep roughly half of the non-essential events, minimum 1
|
||||
num_recent_to_keep = max(1, num_non_essential_events // 2)
|
||||
|
||||
# Calculate the starting index for the recent slice
|
||||
slice_start_index = len(history) - num_recent_to_keep
|
||||
slice_start_index = max(0, slice_start_index) # Ensure index is not negative
|
||||
recent_events_slice = history[slice_start_index:]
|
||||
|
||||
# 3. Validate the start of the recent slice for dangling observations
|
||||
# IMPORTANT: Most observations in history are tool call results, which cannot be without their action, or we get an LLM API error
|
||||
first_valid_event_index = 0
|
||||
for i, event in enumerate(recent_events_slice):
|
||||
if isinstance(event, Observation):
|
||||
first_valid_event_index += 1
|
||||
else:
|
||||
break
|
||||
# If all events in the slice are dangling observations, we need to keep at least one
|
||||
if first_valid_event_index == len(recent_events_slice):
|
||||
self.log(
|
||||
'warning',
|
||||
'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.',
|
||||
)
|
||||
|
||||
# Adjust the recent_events_slice if dangling observations were found at the start
|
||||
if first_valid_event_index < len(recent_events_slice):
|
||||
validated_recent_events = recent_events_slice[first_valid_event_index:]
|
||||
if first_valid_event_index > 0:
|
||||
self.log(
|
||||
'debug',
|
||||
f'Removed {first_valid_event_index} dangling observation(s) from the start of recent event slice.',
|
||||
)
|
||||
else:
|
||||
validated_recent_events = []
|
||||
|
||||
# 4. Combine essential events and validated recent events
|
||||
events_to_keep: list[Event] = essential_events + validated_recent_events
|
||||
self.log('debug', f'History truncated. Kept {len(events_to_keep)} events.')
|
||||
|
||||
return events_to_keep
|
||||
return kept_events
|
||||
|
||||
def _is_stuck(self) -> bool:
|
||||
"""Checks if the agent or its delegate is stuck in a loop.
|
||||
|
||||
+17
-39
@@ -14,14 +14,12 @@ from openhands.core.cli_commands import (
|
||||
)
|
||||
from openhands.core.cli_tui import (
|
||||
UsageMetrics,
|
||||
display_agent_running_message,
|
||||
display_banner,
|
||||
display_event,
|
||||
display_initial_user_prompt,
|
||||
display_initialization_animation,
|
||||
display_runtime_initialization_message,
|
||||
display_welcome_message,
|
||||
process_agent_pause,
|
||||
read_confirmation_input,
|
||||
read_prompt_input,
|
||||
)
|
||||
@@ -101,7 +99,6 @@ async def run_session(
|
||||
|
||||
sid = str(uuid4())
|
||||
is_loaded = asyncio.Event()
|
||||
is_paused = asyncio.Event()
|
||||
|
||||
# Show runtime initialization message
|
||||
display_runtime_initialization_message(config.runtime)
|
||||
@@ -127,12 +124,10 @@ async def run_session(
|
||||
|
||||
usage_metrics = UsageMetrics()
|
||||
|
||||
async def prompt_for_next_task(agent_state: str):
|
||||
async def prompt_for_next_task():
|
||||
nonlocal reload_microagents, new_session_requested
|
||||
while True:
|
||||
next_message = await read_prompt_input(
|
||||
agent_state, multiline=config.cli_multiline_input
|
||||
)
|
||||
next_message = await read_prompt_input(config.cli_multiline_input)
|
||||
|
||||
if not next_message.strip():
|
||||
continue
|
||||
@@ -155,23 +150,14 @@ async def run_session(
|
||||
return
|
||||
|
||||
async def on_event_async(event: Event) -> None:
|
||||
nonlocal reload_microagents, is_paused
|
||||
nonlocal reload_microagents
|
||||
display_event(event, config)
|
||||
update_usage_metrics(event, usage_metrics)
|
||||
|
||||
# Pause the agent if the pause event is set (if Ctrl-P is pressed)
|
||||
if is_paused.is_set():
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.PAUSED),
|
||||
EventSource.USER,
|
||||
)
|
||||
is_paused.clear()
|
||||
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state in [
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
AgentState.PAUSED,
|
||||
]:
|
||||
# Reload microagents after initialization of repo.md
|
||||
if reload_microagents:
|
||||
@@ -180,28 +166,20 @@ async def run_session(
|
||||
)
|
||||
memory.load_user_workspace_microagents(microagents)
|
||||
reload_microagents = False
|
||||
await prompt_for_next_task(event.agent_state)
|
||||
await prompt_for_next_task()
|
||||
|
||||
if event.agent_state == AgentState.AWAITING_USER_CONFIRMATION:
|
||||
# Only display the confirmation prompt if the agent is not paused
|
||||
if not is_paused.is_set():
|
||||
user_confirmed = await read_confirmation_input()
|
||||
if user_confirmed:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
|
||||
EventSource.USER,
|
||||
)
|
||||
else:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED),
|
||||
EventSource.USER,
|
||||
)
|
||||
|
||||
if event.agent_state == AgentState.RUNNING:
|
||||
# Enable pause/resume functionality only if the confirmation mode is disabled
|
||||
if not config.security.confirmation_mode:
|
||||
display_agent_running_message()
|
||||
loop.create_task(process_agent_pause(is_paused))
|
||||
user_confirmed = await read_confirmation_input()
|
||||
if user_confirmed:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
|
||||
EventSource.USER,
|
||||
)
|
||||
else:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED),
|
||||
EventSource.USER,
|
||||
)
|
||||
|
||||
def on_event(event: Event) -> None:
|
||||
loop.create_task(on_event_async(event))
|
||||
@@ -234,7 +212,7 @@ async def run_session(
|
||||
clear()
|
||||
|
||||
# Show OpenHands banner and session ID
|
||||
display_banner(session_id=sid)
|
||||
display_banner(session_id=sid, is_loaded=is_loaded)
|
||||
|
||||
# Show OpenHands welcome
|
||||
display_welcome_message()
|
||||
@@ -247,7 +225,7 @@ async def run_session(
|
||||
)
|
||||
else:
|
||||
# Otherwise prompt for the user's first message right away
|
||||
asyncio.create_task(prompt_for_next_task(''))
|
||||
asyncio.create_task(prompt_for_next_task())
|
||||
|
||||
await run_agent_until_done(
|
||||
controller, runtime, memory, [AgentState.STOPPED, AgentState.ERROR]
|
||||
|
||||
@@ -70,8 +70,6 @@ async def handle_commands(
|
||||
)
|
||||
elif command == '/settings':
|
||||
await handle_settings_command(config, settings_store)
|
||||
elif command == '/resume':
|
||||
close_repl, new_session_requested = await handle_resume_command(event_stream)
|
||||
else:
|
||||
close_repl = True
|
||||
action = MessageAction(content=command)
|
||||
@@ -185,28 +183,6 @@ async def handle_settings_command(
|
||||
await modify_llm_settings_advanced(config, settings_store)
|
||||
|
||||
|
||||
# FIXME: Currently there's an issue with the actual 'resume' behavior.
|
||||
# Setting the agent state to RUNNING will currently freeze the agent without continuing with the rest of the task.
|
||||
# This is a workaround to handle the resume command for the time being. Replace user message with the state change event once the issue is fixed.
|
||||
async def handle_resume_command(
|
||||
event_stream: EventStream,
|
||||
) -> tuple[bool, bool]:
|
||||
close_repl = True
|
||||
new_session_requested = False
|
||||
|
||||
event_stream.add_event(
|
||||
MessageAction(content='continue'),
|
||||
EventSource.USER,
|
||||
)
|
||||
|
||||
# event_stream.add_event(
|
||||
# ChangeAgentStateAction(AgentState.RUNNING),
|
||||
# EventSource.ENVIRONMENT,
|
||||
# )
|
||||
|
||||
return close_repl, new_session_requested
|
||||
|
||||
|
||||
async def init_repository(current_dir: str) -> bool:
|
||||
repo_file_path = Path(current_dir) / '.openhands' / 'microagents' / 'repo.md'
|
||||
init_repo = False
|
||||
|
||||
+75
-76
@@ -10,9 +10,7 @@ from prompt_toolkit import PromptSession, print_formatted_text
|
||||
from prompt_toolkit.application import Application
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
from prompt_toolkit.formatted_text import HTML, FormattedText, StyleAndTextTuples
|
||||
from prompt_toolkit.input import create_input
|
||||
from prompt_toolkit.key_binding import KeyBindings
|
||||
from prompt_toolkit.keys import Keys
|
||||
from prompt_toolkit.layout.containers import HSplit, Window
|
||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||
from prompt_toolkit.layout.layout import Layout
|
||||
@@ -24,7 +22,6 @@ from prompt_toolkit.widgets import Frame, TextArea
|
||||
|
||||
from openhands import __version__
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events import EventSource
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
@@ -35,7 +32,6 @@ from openhands.events.action import (
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
CmdOutputObservation,
|
||||
FileEditObservation,
|
||||
FileReadObservation,
|
||||
@@ -60,7 +56,6 @@ COMMANDS = {
|
||||
'/status': 'Display session details and usage metrics',
|
||||
'/new': 'Create a new session',
|
||||
'/settings': 'Display and modify current settings',
|
||||
'/resume': 'Resume the agent',
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +114,7 @@ def display_initialization_animation(text, is_loaded: asyncio.Event):
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def display_banner(session_id: str):
|
||||
def display_banner(session_id: str, is_loaded: asyncio.Event):
|
||||
print_formatted_text(
|
||||
HTML(r"""<gold>
|
||||
___ _ _ _
|
||||
@@ -134,8 +129,11 @@ def display_banner(session_id: str):
|
||||
|
||||
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
|
||||
|
||||
banner_text = (
|
||||
'Initialized session' if is_loaded.is_set() else 'Initializing session'
|
||||
)
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML(f'<grey>Initialized session {session_id}</grey>'))
|
||||
print_formatted_text(HTML(f'<grey>{banner_text} {session_id}</grey>'))
|
||||
print_formatted_text('')
|
||||
|
||||
|
||||
@@ -179,8 +177,6 @@ def display_event(event: Event, config: AppConfig) -> None:
|
||||
display_file_edit(event)
|
||||
if isinstance(event, FileReadObservation):
|
||||
display_file_read(event)
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
display_agent_paused_message(event.agent_state)
|
||||
|
||||
|
||||
def display_message(message: str):
|
||||
@@ -393,58 +389,77 @@ def display_status(usage_metrics: UsageMetrics, session_id: str):
|
||||
display_usage_metrics(usage_metrics)
|
||||
|
||||
|
||||
def display_agent_running_message():
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<gold>Agent running...</gold> <grey>(Ctrl-P to pause)</grey>')
|
||||
)
|
||||
|
||||
|
||||
def display_agent_paused_message(agent_state: str):
|
||||
if agent_state != AgentState.PAUSED:
|
||||
return
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML('<gold>Agent paused</gold> <grey>(type /resume to resume)</grey>')
|
||||
)
|
||||
|
||||
|
||||
# Common input functions
|
||||
class CommandCompleter(Completer):
|
||||
"""Custom completer for commands."""
|
||||
|
||||
def __init__(self, agent_state: str):
|
||||
super().__init__()
|
||||
self.agent_state = agent_state
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text_before_cursor.lstrip()
|
||||
text = document.text
|
||||
|
||||
# Only show completions if the user has typed '/'
|
||||
if text.startswith('/'):
|
||||
available_commands = dict(COMMANDS)
|
||||
if self.agent_state != AgentState.PAUSED:
|
||||
available_commands.pop('/resume', None)
|
||||
|
||||
for command, description in available_commands.items():
|
||||
if command.startswith(text):
|
||||
# If just '/' is typed, show all commands
|
||||
if text == '/':
|
||||
for command, description in COMMANDS.items():
|
||||
yield Completion(
|
||||
command,
|
||||
start_position=-len(text),
|
||||
display_meta=description,
|
||||
style='bg:ansidarkgray fg:ansiwhite',
|
||||
command[1:], # Remove the leading '/' as it's already typed
|
||||
start_position=0,
|
||||
display=f'{command} - {description}',
|
||||
)
|
||||
# Otherwise show matching commands
|
||||
else:
|
||||
for command, description in COMMANDS.items():
|
||||
if command.startswith(text):
|
||||
yield Completion(
|
||||
command[len(text) :], # Complete the remaining part
|
||||
start_position=0,
|
||||
display=f'{command} - {description}',
|
||||
)
|
||||
|
||||
|
||||
def create_prompt_session():
|
||||
return PromptSession(style=DEFAULT_STYLE)
|
||||
prompt_session = PromptSession(style=DEFAULT_STYLE)
|
||||
|
||||
# RPrompt animation related variables
|
||||
SPINNER_FRAMES = [
|
||||
'[ ■□□□ ]',
|
||||
'[ □■□□ ]',
|
||||
'[ □□■□ ]',
|
||||
'[ □□□■ ]',
|
||||
'[ □□■□ ]',
|
||||
'[ □■□□ ]',
|
||||
]
|
||||
ANIMATION_INTERVAL = 0.2 # seconds
|
||||
|
||||
current_frame_index = 0
|
||||
last_update_time = time.monotonic()
|
||||
|
||||
|
||||
async def read_prompt_input(agent_state: str, multiline=False):
|
||||
# RPrompt function for the user confirmation
|
||||
def get_rprompt() -> FormattedText:
|
||||
"""
|
||||
Returns the current animation frame for the rprompt.
|
||||
This function is called by prompt_toolkit during rendering.
|
||||
"""
|
||||
global current_frame_index, last_update_time
|
||||
|
||||
# Only update the frame if enough time has passed
|
||||
# This prevents excessive recalculation during rendering
|
||||
now = time.monotonic()
|
||||
if now - last_update_time > ANIMATION_INTERVAL:
|
||||
current_frame_index = (current_frame_index + 1) % len(SPINNER_FRAMES)
|
||||
last_update_time = now
|
||||
|
||||
# Return the frame wrapped in FormattedText
|
||||
return FormattedText(
|
||||
[
|
||||
('', ' '), # Add a space before the spinner
|
||||
(COLOR_GOLD, SPINNER_FRAMES[current_frame_index]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def read_prompt_input(multiline=False):
|
||||
try:
|
||||
prompt_session = create_prompt_session()
|
||||
prompt_session.completer = (
|
||||
CommandCompleter(agent_state) if not multiline else None
|
||||
)
|
||||
|
||||
if multiline:
|
||||
kb = KeyBindings()
|
||||
|
||||
@@ -455,54 +470,38 @@ async def read_prompt_input(agent_state: str, multiline=False):
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
message = await prompt_session.prompt_async(
|
||||
HTML(
|
||||
'<gold>Enter your message and press Ctrl-D to finish:</gold>\n'
|
||||
),
|
||||
'Enter your message and press Ctrl+D to finish:\n',
|
||||
multiline=True,
|
||||
key_bindings=kb,
|
||||
)
|
||||
else:
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
prompt_session.completer = CommandCompleter()
|
||||
message = await prompt_session.prompt_async(
|
||||
HTML('<gold>> </gold>'),
|
||||
'> ',
|
||||
)
|
||||
return message if message is not None else ''
|
||||
return message
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return '/exit'
|
||||
|
||||
|
||||
async def read_confirmation_input() -> bool:
|
||||
async def read_confirmation_input():
|
||||
try:
|
||||
prompt_session = create_prompt_session()
|
||||
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
confirmation: str = await prompt_session.prompt_async(
|
||||
HTML('<gold>Proceed with action? (y)es/(n)o > </gold>'),
|
||||
prompt_session.completer = None
|
||||
confirmation = await prompt_session.prompt_async(
|
||||
'Proceed with action? (y)es/(n)o > ',
|
||||
rprompt=get_rprompt,
|
||||
refresh_interval=ANIMATION_INTERVAL / 2,
|
||||
)
|
||||
|
||||
confirmation = '' if confirmation is None else confirmation.strip().lower()
|
||||
prompt_session.rprompt = None
|
||||
confirmation = confirmation.strip().lower()
|
||||
return confirmation in ['y', 'yes']
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return False
|
||||
|
||||
|
||||
async def process_agent_pause(done: asyncio.Event) -> None:
|
||||
input = create_input()
|
||||
|
||||
def keys_ready():
|
||||
for key_press in input.read_keys():
|
||||
if key_press.key == Keys.ControlP:
|
||||
print_formatted_text('')
|
||||
print_formatted_text(HTML('<gold>Pausing the agent...</gold>'))
|
||||
done.set()
|
||||
|
||||
with input.raw_mode():
|
||||
with input.attach(keys_ready):
|
||||
await done.wait()
|
||||
|
||||
|
||||
def cli_confirm(
|
||||
question: str = 'Are you sure?', choices: list[str] | None = None
|
||||
) -> int:
|
||||
|
||||
@@ -54,7 +54,6 @@ class ConversationMemory:
|
||||
def process_events(
|
||||
self,
|
||||
condensed_history: list[Event],
|
||||
initial_user_action: MessageAction,
|
||||
max_message_chars: int | None = None,
|
||||
vision_is_active: bool = False,
|
||||
) -> list[Message]:
|
||||
@@ -67,14 +66,12 @@ class ConversationMemory:
|
||||
max_message_chars: The maximum number of characters in the content of an event included
|
||||
in the prompt to the LLM. Larger observations are truncated.
|
||||
vision_is_active: Whether vision is active in the LLM. If True, image URLs will be included.
|
||||
initial_user_action: The initial user message action, if available. Used to ensure the conversation starts correctly.
|
||||
"""
|
||||
|
||||
events = condensed_history
|
||||
|
||||
# Ensure the event list starts with SystemMessageAction, then MessageAction(source='user')
|
||||
# Ensure the system message exists (handles legacy cases)
|
||||
self._ensure_system_message(events)
|
||||
self._ensure_initial_user_message(events, initial_user_action)
|
||||
|
||||
# log visual browsing status
|
||||
logger.debug(f'Visual browsing: {self.agent_config.enable_som_visual_browsing}')
|
||||
@@ -702,43 +699,6 @@ class ConversationMemory:
|
||||
system_message = SystemMessageAction(content=system_prompt)
|
||||
# Insert the system message directly at the beginning of the events list
|
||||
events.insert(0, system_message)
|
||||
logger.info(
|
||||
logger.debug(
|
||||
'[ConversationMemory] Added SystemMessageAction for backward compatibility'
|
||||
)
|
||||
|
||||
def _ensure_initial_user_message(
|
||||
self, events: list[Event], initial_user_action: MessageAction
|
||||
) -> None:
|
||||
"""Checks if the second event is a user MessageAction and inserts the provided one if needed."""
|
||||
if (
|
||||
not events
|
||||
): # Should have system message from previous step, but safety check
|
||||
logger.error('Cannot ensure initial user message: event list is empty.')
|
||||
# Or raise? Let's log for now, _ensure_system_message should handle this.
|
||||
return
|
||||
|
||||
# We expect events[0] to be SystemMessageAction after _ensure_system_message
|
||||
if len(events) == 1:
|
||||
# Only system message exists
|
||||
logger.info(
|
||||
'Initial user message action was missing. Inserting the initial user message.'
|
||||
)
|
||||
events.insert(1, initial_user_action)
|
||||
elif not isinstance(events[1], MessageAction) or events[1].source != 'user':
|
||||
# The second event exists but is not the correct initial user message action.
|
||||
# We will insert the correct one provided.
|
||||
logger.info(
|
||||
'Second event was not the initial user message action. Inserting correct one at index 1.'
|
||||
)
|
||||
|
||||
# Insert the user message event at index 1. This will be the second message as LLM APIs expect
|
||||
# but something was wrong with the history, so log all we can.
|
||||
events.insert(1, initial_user_action)
|
||||
|
||||
# Else: events[1] is already a user MessageAction.
|
||||
# Check if it matches the one provided (if any discrepancy, log warning but proceed).
|
||||
elif events[1] != initial_user_action:
|
||||
logger.debug(
|
||||
'The user MessageAction at index 1 does not match the provided initial_user_action. '
|
||||
'Proceeding with the one found in condensed history.'
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import overload
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
@@ -66,8 +65,6 @@ class View(BaseModel):
|
||||
break
|
||||
|
||||
if summary is not None and summary_offset is not None:
|
||||
logger.info(f'Inserting summary at offset {summary_offset}')
|
||||
|
||||
kept_events.insert(
|
||||
summary_offset, AgentCondensationObservation(content=summary)
|
||||
)
|
||||
|
||||
+116
-66
@@ -1,141 +1,191 @@
|
||||
# OpenHands GitHub & GitLab Issue Resolver 🙌
|
||||
# OpenHands Github & Gitlab Issue Resolver 🙌
|
||||
|
||||
Need help resolving GitHub or GitLab issues? Let an AI agent help you out!
|
||||
Need help resolving a GitHub issue but don't have the time to do it yourself? Let an AI agent help you out!
|
||||
|
||||
This tool uses [OpenHands](https://github.com/all-hands-ai/openhands) AI agents to automatically resolve issues in your repositories. It's designed to handle one issue at a time with high quality.
|
||||
This tool allows you to use open-source AI agents based on [OpenHands](https://github.com/all-hands-ai/openhands)
|
||||
to attempt to resolve GitHub issues automatically. While it can handle multiple issues, it's primarily designed
|
||||
to help you resolve one issue at a time with high quality.
|
||||
|
||||
## 1. Setting Up for GitHub (Action Workflow)
|
||||
Getting started is simple - just follow the instructions below.
|
||||
|
||||
### Prerequisites
|
||||
## Using the GitHub Actions Workflow
|
||||
|
||||
- [Create a personal access token](https://github.com/settings/tokens?type=beta) with read/write scope for
|
||||
This repository includes a GitHub Actions workflow that can automatically attempt to fix individual issues labeled with 'fix-me'.
|
||||
Follow these steps to use this workflow in your own repository:
|
||||
|
||||
- "contents"
|
||||
- "issues"
|
||||
- "pull requests"
|
||||
- "workflows"
|
||||
1. [Create a personal access token](https://github.com/settings/tokens?type=beta) with read/write scope for "contents", "issues", "pull requests", and "workflows"
|
||||
|
||||
- Create an LLM API key (e,g [Claude API](https://www.anthropic.com/api))
|
||||
Note: If you're working with an organizational repository, you may need to configure the organization's personal access token policy first. See [Setting a personal access token policy for your organization](https://docs.github.com/en/organizations/managing-programmatic-access-to-your-organization/setting-a-personal-access-token-policy-for-your-organization) for details.
|
||||
|
||||
### Installation
|
||||
2. Create an API key for the [Claude API](https://www.anthropic.com/api) (recommended) or another supported LLM service
|
||||
|
||||
1. Copy `examples/openhands-resolver.yml` to your repository's `.github/workflows/` directory
|
||||
3. Copy `examples/openhands-resolver.yml` to your repository's `.github/workflows/` directory
|
||||
|
||||
2. Configure repository permissions:
|
||||
4. Configure repository permissions:
|
||||
- Go to `Settings -> Actions -> General -> Workflow permissions`
|
||||
- Select "Read and write permissions"
|
||||
- Enable "Allow Github Actions to create and approve pull requests"
|
||||
|
||||
- Go to `Settings -> Actions -> General -> Workflow permissions`
|
||||
- Select **Read and write permissions**
|
||||
- Enable **Allow Github Actions to create and approve pull requests**
|
||||
|
||||
> If "Read and write permissions" is greyed out:
|
||||
>
|
||||
> - Check organization settings first
|
||||
> - Otherwise, permissions might need to be set in [Enterprise policy settings](https://docs.github.com/en/enterprise-cloud@latest/admin/enforcing-policies/enforcing-policies-for-your-enterprise/enforcing-policies-for-github-actions-in-your-enterprise#enforcing-a-policy-for-workflow-permissions-in-your-enterprise)
|
||||
|
||||
3. Set up [GitHub secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions):
|
||||
Note: If the "Read and write permissions" option is greyed out:
|
||||
- First check if permissions need to be set at the organization level
|
||||
- If still greyed out at the organization level, permissions need to be set in the [Enterprise policy settings](https://docs.github.com/en/enterprise-cloud@latest/admin/enforcing-policies/enforcing-policies-for-your-enterprise/enforcing-policies-for-github-actions-in-your-enterprise#enforcing-a-policy-for-workflow-permissions-in-your-enterprise)
|
||||
|
||||
5. Set up [GitHub secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions):
|
||||
- Required:
|
||||
- `LLM_API_KEY`: Your LLM API key
|
||||
- `LLM_API_KEY`: Your LLM API key
|
||||
- Optional:
|
||||
- `PAT_USERNAME`: GitHub username for the personal access token
|
||||
- `PAT_TOKEN`: The personal access token
|
||||
- `LLM_BASE_URL`: Base URL for LLM API (only if using a proxy)
|
||||
- [See how to customize more configurations](https://docs.all-hands.dev/modules/usage/how-to/github-action#custom-configurations)
|
||||
|
||||
## 2. Setting up GitLab (CI Runner)
|
||||
Note: You can set these secrets at the organization level to use across multiple repositories.
|
||||
|
||||
### Prerequisites
|
||||
6. Set up any [custom configurations required](https://docs.all-hands.dev/modules/usage/how-to/github-action#custom-configurations)
|
||||
|
||||
Create a GitLab Personal Access Token with API, read/write access
|
||||
7. Usage:
|
||||
There are two ways to trigger the OpenHands agent:
|
||||
|
||||
### Installation
|
||||
a. Using the 'fix-me' label:
|
||||
- Add the 'fix-me' label to any issue you want the AI to resolve
|
||||
- The agent will consider all comments in the issue thread when resolving
|
||||
- The workflow will:
|
||||
1. Attempt to resolve the issue using OpenHands
|
||||
2. Create a draft PR if successful, or push a branch if unsuccessful
|
||||
3. Comment on the issue with the results
|
||||
4. Remove the 'fix-me' label once processed
|
||||
|
||||
## 3. Triggering OpenHands Agent
|
||||
b. Using `@openhands-agent` mention:
|
||||
- Create a new comment containing `@openhands-agent` in any issue
|
||||
- The agent will only consider the comment where it's mentioned
|
||||
- The workflow will:
|
||||
1. Attempt to resolve the issue based on the specific comment
|
||||
2. Create a draft PR if successful, or push a branch if unsuccessful
|
||||
3. Comment on the issue with the results
|
||||
|
||||
You can trigger OpenHands in two shared ways (works for both GitHub and GitLab):
|
||||
Need help? Feel free to [open an issue](https://github.com/all-hands-ai/openhands/issues) or email us at [contact@all-hands.dev](mailto:contact@all-hands.dev).
|
||||
|
||||
Using the 'fix-me' label:
|
||||
## Manual Installation
|
||||
|
||||
- Add the 'fix-me' label to any issue you want the AI to resolve
|
||||
- The agent will consider all comments in the issue thread when resolving
|
||||
If you prefer to run the resolver programmatically instead of using GitHub Actions, follow these steps:
|
||||
|
||||
Using `@openhands-agent` in an issue/pr comment:
|
||||
|
||||
- Create a new comment containing `@openhands-agent`
|
||||
- The agent will only consider the comment + comment thread where it's mentioned
|
||||
|
||||
## 4. Running Locally
|
||||
|
||||
### Installation
|
||||
1. Install the package:
|
||||
|
||||
```bash
|
||||
pip install openhands-ai
|
||||
```
|
||||
|
||||
### Setup
|
||||
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 GitHub or GitLab access token with appropriate permissions
|
||||
- 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'
|
||||
|
||||
Set up environment variables:
|
||||
3. Set up environment variables:
|
||||
|
||||
```bash
|
||||
# GitHub credentials
|
||||
export GITHUB_TOKEN="your-github-token"
|
||||
export GIT_USERNAME="your-github-username"
|
||||
|
||||
# GitLab credentials (if using GitLab)
|
||||
# GitHub credentials
|
||||
|
||||
export GITHUB_TOKEN="your-github-token"
|
||||
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"
|
||||
export GIT_USERNAME="your-gitlab-username" # Optional, defaults to token owner
|
||||
|
||||
# LLM configuration
|
||||
export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"
|
||||
|
||||
export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022" # Recommended
|
||||
export LLM_API_KEY="your-llm-api-key"
|
||||
export LLM_BASE_URL="your-api-url" # Optional
|
||||
export LLM_BASE_URL="your-api-url" # Optional, for API proxies
|
||||
```
|
||||
|
||||
### Resolving Issues
|
||||
Note: OpenHands works best with powerful models like Anthropic's Claude or OpenAI's GPT-4. While other models are supported, they may not perform as well for complex issue resolution.
|
||||
|
||||
Resolve a single issue:
|
||||
## Resolving Issues
|
||||
|
||||
The resolver can automatically attempt to fix a single issue in your repository using the following command:
|
||||
|
||||
```bash
|
||||
python -m openhands.resolver.resolve_issue --selected-repo [OWNER]/[REPO] --issue-number [NUMBER]
|
||||
```
|
||||
|
||||
### Responding to PR Comments
|
||||
For instance, if you want to resolve issue #100 in this repo, you would run:
|
||||
|
||||
Respond to comments on pull requests:
|
||||
```bash
|
||||
python -m openhands.resolver.resolve_issue --selected-repo all-hands-ai/openhands --issue-number 100
|
||||
```
|
||||
|
||||
The output will be written to the `output/` directory.
|
||||
|
||||
If you've installed the package from source using poetry, you can use:
|
||||
|
||||
```bash
|
||||
poetry run python openhands/resolver/resolve_issue.py --selected-repo all-hands-ai/openhands --issue-number 100
|
||||
```
|
||||
|
||||
## Responding to PR Comments
|
||||
|
||||
The resolver can also respond to comments on pull requests using:
|
||||
|
||||
```bash
|
||||
python -m openhands.resolver.send_pull_request --issue-number PR_NUMBER --issue-type pr
|
||||
```
|
||||
|
||||
### Visualizing Results
|
||||
This functionality is available both through the GitHub Actions workflow and when running the resolver locally.
|
||||
|
||||
View successful PRs:
|
||||
## Visualizing successful PRs
|
||||
|
||||
To find successful PRs, you can run the following command:
|
||||
|
||||
```bash
|
||||
grep '"success":true' output/output.jsonl | sed 's/.*\("number":[0-9]*\).*/\1/g'
|
||||
```
|
||||
|
||||
Visualize specific PR:
|
||||
Then you can go through and visualize the ones you'd like.
|
||||
|
||||
```bash
|
||||
python -m openhands.resolver.visualize_resolver_output --issue-number ISSUE_NUMBER --vis-method json
|
||||
```
|
||||
|
||||
### Uploading PRs
|
||||
## Uploading PRs
|
||||
|
||||
Upload your changes in one of three ways:
|
||||
If you find any PRs that were successful, you can upload them.
|
||||
There are three ways you can upload:
|
||||
|
||||
1. `branch` - upload a branch without creating a PR
|
||||
2. `draft` - create a draft PR
|
||||
3. `ready` - create a non-draft PR that's ready for review
|
||||
|
||||
```bash
|
||||
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type [branch|draft|ready]
|
||||
python -m openhands.resolver.send_pull_request --issue-number ISSUE_NUMBER --username YOUR_GITHUB_OR_GITLAB_USERNAME --pr-type draft
|
||||
```
|
||||
|
||||
## Custom Instructions
|
||||
If you want to upload to a fork, you can do so by specifying the `fork-owner`:
|
||||
|
||||
Add repository-specific instructions by creating a file at `.openhands/microagents/repo.md` in your repository. For more information about repository microagents, see [Repository Instructions](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents#2-repository-instructions-private).
|
||||
```bash
|
||||
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
|
||||
|
||||
You can customize how the AI agent approaches issue resolution by adding a repository microagent file at `.openhands/microagents/repo.md` in your repository. This file's contents will be automatically loaded in the prompt when working with your repository. For more information about repository microagents, see [Repository Instructions](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents#2-repository-instructions-private).
|
||||
|
||||
## 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).
|
||||
|
||||
Generated
+67
-80
@@ -496,18 +496,18 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.38.3"
|
||||
version = "1.38.2"
|
||||
description = "The AWS SDK for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "boto3-1.38.3-py3-none-any.whl", hash = "sha256:9218f86e2164e1bddb75d435bbde4fa651aa58687213d7e3e1b50f7eb8868f66"},
|
||||
{file = "boto3-1.38.3.tar.gz", hash = "sha256:655d51abcd68a40a33c52dbaa2ca73fc63c746b894e2ae22ed8ddc1912ddd93f"},
|
||||
{file = "boto3-1.38.2-py3-none-any.whl", hash = "sha256:ef3237b169cd906a44a32c03b3229833d923c9e9733355b329ded2151f91ec0b"},
|
||||
{file = "boto3-1.38.2.tar.gz", hash = "sha256:53c8d44b231251fa9421dd13d968236d59fe2cf0421e077afedbf3821653fb3b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.38.3,<1.39.0"
|
||||
botocore = ">=1.38.2,<1.39.0"
|
||||
jmespath = ">=0.7.1,<2.0.0"
|
||||
s3transfer = ">=0.12.0,<0.13.0"
|
||||
|
||||
@@ -516,14 +516,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "boto3-stubs"
|
||||
version = "1.38.3"
|
||||
description = "Type annotations for boto3 1.38.3 generated with mypy-boto3-builder 8.10.1"
|
||||
version = "1.38.2"
|
||||
description = "Type annotations for boto3 1.38.2 generated with mypy-boto3-builder 8.10.1"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "boto3_stubs-1.38.3-py3-none-any.whl", hash = "sha256:93a2c38987dd0ee19a661e8fd9a77fb4b4a30e56f63115701c307bfc55e2695c"},
|
||||
{file = "boto3_stubs-1.38.3.tar.gz", hash = "sha256:e406626de8daf537984678355ad0e32d838865c4ea3d223268964d4e6fb44534"},
|
||||
{file = "boto3_stubs-1.38.2-py3-none-any.whl", hash = "sha256:e18f2dc194c4b8a29f61275ba039689d063c4775a78560e35a5ce820ec257fb5"},
|
||||
{file = "boto3_stubs-1.38.2.tar.gz", hash = "sha256:405cd777d41530cf8ed009d20b04daef1f7d4bd2fd9fd3636ac86eccdb55159c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -579,7 +579,7 @@ bedrock-data-automation-runtime = ["mypy-boto3-bedrock-data-automation-runtime (
|
||||
bedrock-runtime = ["mypy-boto3-bedrock-runtime (>=1.38.0,<1.39.0)"]
|
||||
billing = ["mypy-boto3-billing (>=1.38.0,<1.39.0)"]
|
||||
billingconductor = ["mypy-boto3-billingconductor (>=1.38.0,<1.39.0)"]
|
||||
boto3 = ["boto3 (==1.38.3)"]
|
||||
boto3 = ["boto3 (==1.38.2)"]
|
||||
braket = ["mypy-boto3-braket (>=1.38.0,<1.39.0)"]
|
||||
budgets = ["mypy-boto3-budgets (>=1.38.0,<1.39.0)"]
|
||||
ce = ["mypy-boto3-ce (>=1.38.0,<1.39.0)"]
|
||||
@@ -943,14 +943,14 @@ xray = ["mypy-boto3-xray (>=1.38.0,<1.39.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.38.3"
|
||||
version = "1.38.2"
|
||||
description = "Low-level, data-driven core of boto 3."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "botocore-1.38.3-py3-none-any.whl", hash = "sha256:96f823240fe3704b99c17d1d1b2fd2d1679cf56d2a55b095f00255b76087cbf0"},
|
||||
{file = "botocore-1.38.3.tar.gz", hash = "sha256:790f8f966201781f5fcf486d48b4492e9f734446bbf9d19ef8159d08be854243"},
|
||||
{file = "botocore-1.38.2-py3-none-any.whl", hash = "sha256:5d9cffedb1c759a058b43793d16647ed44ec87072f98a1bd6cd673ac0ae6b81d"},
|
||||
{file = "botocore-1.38.2.tar.gz", hash = "sha256:b688a9bd17211a1eaae3a6c965ba9f3973e5435efaaa4fa201f499d3467830e1"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2663,7 +2663,7 @@ grpcio = {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_versi
|
||||
grpcio-status = {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
]
|
||||
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0"
|
||||
requests = ">=2.18.0,<3.0.0.dev0"
|
||||
@@ -2878,7 +2878,7 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
grpc-google-iam-v1 = ">=0.14.0,<1.0.0dev"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
{version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
@@ -3794,14 +3794,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "json-repair"
|
||||
version = "0.43.0"
|
||||
version = "0.42.0"
|
||||
description = "A package to repair broken json strings"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "json_repair-0.43.0-py3-none-any.whl", hash = "sha256:3f2b66819c9f5e29edd5dd4851223b72d10ed816b6423b3c92e424090c3ffc1d"},
|
||||
{file = "json_repair-0.43.0.tar.gz", hash = "sha256:77cc6eda6f407ff5fe9544f962e42b332cca1e8c9f3f9f9dc660327028e0d651"},
|
||||
{file = "json_repair-0.42.0-py3-none-any.whl", hash = "sha256:7b6805162053dfe65722e961bc51b5eecec0582ec8a8e0fd218d33e8de757daf"},
|
||||
{file = "json_repair-0.42.0.tar.gz", hash = "sha256:1a901f706c5b6b4325f0f79b53b0d998c5b327070e98b530da71cc5a3eda8616"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4882,14 +4882,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "modal"
|
||||
version = "0.74.30"
|
||||
version = "0.74.23"
|
||||
description = "Python client library for Modal"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "modal-0.74.30-py3-none-any.whl", hash = "sha256:46006cb57309171fe36ee41528a7cc8c0e67c88afd9bf04a9900313c18925aa4"},
|
||||
{file = "modal-0.74.30.tar.gz", hash = "sha256:14bd2ea0ebc9ab1ebce29ea76ddf12047f23599983725c5f82990ae97bea05c7"},
|
||||
{file = "modal-0.74.23-py3-none-any.whl", hash = "sha256:96c397487ed5f499ad040b5edf5f378ada8e0676da17523a2d6fadb3f1d384e1"},
|
||||
{file = "modal-0.74.23.tar.gz", hash = "sha256:3a042cdf482975b43341da0b33fa6a6adae06978ead69a086ca658a7dcb0cd6d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6343,67 +6343,54 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pyarrow"
|
||||
version = "20.0.0"
|
||||
version = "19.0.1"
|
||||
description = "Python library for Apache Arrow"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:c7dd06fd7d7b410ca5dc839cc9d485d2bc4ae5240851bcd45d85105cc90a47d7"},
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d5382de8dc34c943249b01c19110783d0d64b207167c728461add1ecc2db88e4"},
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6415a0d0174487456ddc9beaead703d0ded5966129fa4fd3114d76b5d1c5ceae"},
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15aa1b3b2587e74328a730457068dc6c89e6dcbf438d4369f572af9d320a25ee"},
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5605919fbe67a7948c1f03b9f3727d82846c053cd2ce9303ace791855923fd20"},
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a5704f29a74b81673d266e5ec1fe376f060627c2e42c5c7651288ed4b0db29e9"},
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:00138f79ee1b5aca81e2bdedb91e3739b987245e11fa3c826f9e57c5d102fb75"},
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f2d67ac28f57a362f1a2c1e6fa98bfe2f03230f7e15927aecd067433b1e70ce8"},
|
||||
{file = "pyarrow-20.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:4a8b029a07956b8d7bd742ffca25374dd3f634b35e46cc7a7c3fa4c75b297191"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:24ca380585444cb2a31324c546a9a56abbe87e26069189e14bdba19c86c049f0"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:95b330059ddfdc591a3225f2d272123be26c8fa76e8c9ee1a77aad507361cfdb"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f0fb1041267e9968c6d0d2ce3ff92e3928b243e2b6d11eeb84d9ac547308232"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8ff87cc837601532cc8242d2f7e09b4e02404de1b797aee747dd4ba4bd6313f"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7a3a5dcf54286e6141d5114522cf31dd67a9e7c9133d150799f30ee302a7a1ab"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a6ad3e7758ecf559900261a4df985662df54fb7fdb55e8e3b3aa99b23d526b62"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6bb830757103a6cb300a04610e08d9636f0cd223d32f388418ea893a3e655f1c"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96e37f0766ecb4514a899d9a3554fadda770fb57ddf42b63d80f14bc20aa7db3"},
|
||||
{file = "pyarrow-20.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:3346babb516f4b6fd790da99b98bed9708e3f02e734c84971faccb20736848dc"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:75a51a5b0eef32727a247707d4755322cb970be7e935172b6a3a9f9ae98404ba"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:211d5e84cecc640c7a3ab900f930aaff5cd2702177e0d562d426fb7c4f737781"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ba3cf4182828be7a896cbd232aa8dd6a31bd1f9e32776cc3796c012855e1199"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c3a01f313ffe27ac4126f4c2e5ea0f36a5fc6ab51f8726cf41fee4b256680bd"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a2791f69ad72addd33510fec7bb14ee06c2a448e06b649e264c094c5b5f7ce28"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4250e28a22302ce8692d3a0e8ec9d9dde54ec00d237cff4dfa9c1fbf79e472a8"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:89e030dc58fc760e4010148e6ff164d2f44441490280ef1e97a542375e41058e"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6102b4864d77102dbbb72965618e204e550135a940c2534711d5ffa787df2a5a"},
|
||||
{file = "pyarrow-20.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:96d6a0a37d9c98be08f5ed6a10831d88d52cac7b13f5287f1e0f625a0de8062b"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a15532e77b94c61efadde86d10957950392999503b3616b2ffcef7621a002893"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dd43f58037443af715f34f1322c782ec463a3c8a94a85fdb2d987ceb5658e061"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0d288143a8585806e3cc7c39566407aab646fb9ece164609dac1cfff45f6ae"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6953f0114f8d6f3d905d98e987d0924dabce59c3cda380bdfaa25a6201563b4"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:991f85b48a8a5e839b2128590ce07611fae48a904cae6cab1f089c5955b57eb5"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:97c8dc984ed09cb07d618d57d8d4b67a5100a30c3818c2fb0b04599f0da2de7b"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9b71daf534f4745818f96c214dbc1e6124d7daf059167330b610fc69b6f3d3e3"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8b88758f9303fa5a83d6c90e176714b2fd3852e776fc2d7e42a22dd6c2fb368"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:30b3051b7975801c1e1d387e17c588d8ab05ced9b1e14eec57915f79869b5031"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ca151afa4f9b7bc45bcc791eb9a89e90a9eb2772767d0b1e5389609c7d03db63"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:4680f01ecd86e0dd63e39eb5cd59ef9ff24a9d166db328679e36c108dc993d4c"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f4c8534e2ff059765647aa69b75d6543f9fef59e2cd4c6d18015192565d2b70"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e1f8a47f4b4ae4c69c4d702cfbdfe4d41e18e5c7ef6f1bb1c50918c1e81c57b"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a1f60dc14658efaa927f8214734f6a01a806d7690be4b3232ba526836d216122"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:204a846dca751428991346976b914d6d2a82ae5b8316a6ed99789ebf976551e6"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f3b117b922af5e4c6b9a9115825726cac7d8b1421c37c2b5e24fbacc8930612c"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e724a3fd23ae5b9c010e7be857f4405ed5e679db5c93e66204db1a69f733936a"},
|
||||
{file = "pyarrow-20.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:82f1ee5133bd8f49d31be1299dc07f585136679666b502540db854968576faf9"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:1bcbe471ef3349be7714261dea28fe280db574f9d0f77eeccc195a2d161fd861"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:a18a14baef7d7ae49247e75641fd8bcbb39f44ed49a9fc4ec2f65d5031aa3b96"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb497649e505dc36542d0e68eca1a3c94ecbe9799cb67b578b55f2441a247fbc"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11529a2283cb1f6271d7c23e4a8f9f8b7fd173f7360776b668e509d712a02eec"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fc1499ed3b4b57ee4e090e1cea6eb3584793fe3d1b4297bbf53f09b434991a5"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:db53390eaf8a4dab4dbd6d93c85c5cf002db24902dbff0ca7d988beb5c9dd15b"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:851c6a8260ad387caf82d2bbf54759130534723e37083111d4ed481cb253cc0d"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e22f80b97a271f0a7d9cd07394a7d348f80d3ac63ed7cc38b6d1b696ab3b2619"},
|
||||
{file = "pyarrow-20.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:9965a050048ab02409fb7cbbefeedba04d3d67f2cc899eff505cc084345959ca"},
|
||||
{file = "pyarrow-20.0.0.tar.gz", hash = "sha256:febc4a913592573c8d5805091a6c2b5064c8bd6e002131f01061797d91c783c1"},
|
||||
{file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:fc28912a2dc924dddc2087679cc8b7263accc71b9ff025a1362b004711661a69"},
|
||||
{file = "pyarrow-19.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fca15aabbe9b8355800d923cc2e82c8ef514af321e18b437c3d782aa884eaeec"},
|
||||
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad76aef7f5f7e4a757fddcdcf010a8290958f09e3470ea458c80d26f4316ae89"},
|
||||
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d03c9d6f2a3dffbd62671ca070f13fc527bb1867b4ec2b98c7eeed381d4f389a"},
|
||||
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:65cf9feebab489b19cdfcfe4aa82f62147218558d8d3f0fc1e9dea0ab8e7905a"},
|
||||
{file = "pyarrow-19.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:41f9706fbe505e0abc10e84bf3a906a1338905cbbcf1177b71486b03e6ea6608"},
|
||||
{file = "pyarrow-19.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6cb2335a411b713fdf1e82a752162f72d4a7b5dbc588e32aa18383318b05866"},
|
||||
{file = "pyarrow-19.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:cc55d71898ea30dc95900297d191377caba257612f384207fe9f8293b5850f90"},
|
||||
{file = "pyarrow-19.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:7a544ec12de66769612b2d6988c36adc96fb9767ecc8ee0a4d270b10b1c51e00"},
|
||||
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0148bb4fc158bfbc3d6dfe5001d93ebeed253793fff4435167f6ce1dc4bddeae"},
|
||||
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f24faab6ed18f216a37870d8c5623f9c044566d75ec586ef884e13a02a9d62c5"},
|
||||
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4982f8e2b7afd6dae8608d70ba5bd91699077323f812a0448d8b7abdff6cb5d3"},
|
||||
{file = "pyarrow-19.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:49a3aecb62c1be1d822f8bf629226d4a96418228a42f5b40835c1f10d42e4db6"},
|
||||
{file = "pyarrow-19.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:008a4009efdb4ea3d2e18f05cd31f9d43c388aad29c636112c2966605ba33466"},
|
||||
{file = "pyarrow-19.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:80b2ad2b193e7d19e81008a96e313fbd53157945c7be9ac65f44f8937a55427b"},
|
||||
{file = "pyarrow-19.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee8dec072569f43835932a3b10c55973593abc00936c202707a4ad06af7cb294"},
|
||||
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d5d1ec7ec5324b98887bdc006f4d2ce534e10e60f7ad995e7875ffa0ff9cb14"},
|
||||
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ad4c0eb4e2a9aeb990af6c09e6fa0b195c8c0e7b272ecc8d4d2b6574809d34"},
|
||||
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d383591f3dcbe545f6cc62daaef9c7cdfe0dff0fb9e1c8121101cabe9098cfa6"},
|
||||
{file = "pyarrow-19.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b4c4156a625f1e35d6c0b2132635a237708944eb41df5fbe7d50f20d20c17832"},
|
||||
{file = "pyarrow-19.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5bd1618ae5e5476b7654c7b55a6364ae87686d4724538c24185bbb2952679960"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e45274b20e524ae5c39d7fc1ca2aa923aab494776d2d4b316b49ec7572ca324c"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d9dedeaf19097a143ed6da37f04f4051aba353c95ef507764d344229b2b740ae"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebfb5171bb5f4a52319344ebbbecc731af3f021e49318c74f33d520d31ae0c4"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a21d39fbdb948857f67eacb5bbaaf36802de044ec36fbef7a1c8f0dd3a4ab2"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:99bc1bec6d234359743b01e70d4310d0ab240c3d6b0da7e2a93663b0158616f6"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1b93ef2c93e77c442c979b0d596af45e4665d8b96da598db145b0fec014b9136"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9d46e06846a41ba906ab25302cf0fd522f81aa2a85a71021826f34639ad31ef"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:c0fe3dbbf054a00d1f162fda94ce236a899ca01123a798c561ba307ca38af5f0"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:96606c3ba57944d128e8a8399da4812f56c7f61de8c647e3470b417f795d0ef9"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f04d49a6b64cf24719c080b3c2029a3a5b16417fd5fd7c4041f94233af732f3"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a9137cf7e1640dce4c190551ee69d478f7121b5c6f323553b319cac936395f6"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7c1bca1897c28013db5e4c83944a2ab53231f541b9e0c3f4791206d0c0de389a"},
|
||||
{file = "pyarrow-19.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:58d9397b2e273ef76264b45531e9d552d8ec8a6688b7390b5be44c02a37aade8"},
|
||||
{file = "pyarrow-19.0.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:b9766a47a9cb56fefe95cb27f535038b5a195707a08bf61b180e642324963b46"},
|
||||
{file = "pyarrow-19.0.1-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:6c5941c1aac89a6c2f2b16cd64fe76bcdb94b2b1e99ca6459de4e6f07638d755"},
|
||||
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd44d66093a239358d07c42a91eebf5015aa54fccba959db899f932218ac9cc8"},
|
||||
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:335d170e050bcc7da867a1ed8ffb8b44c57aaa6e0843b156a501298657b1e972"},
|
||||
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:1c7556165bd38cf0cd992df2636f8bcdd2d4b26916c6b7e646101aff3c16f76f"},
|
||||
{file = "pyarrow-19.0.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:699799f9c80bebcf1da0983ba86d7f289c5a2a5c04b945e2f2bcf7e874a91911"},
|
||||
{file = "pyarrow-19.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:8464c9fbe6d94a7fe1599e7e8965f350fd233532868232ab2596a71586c5a429"},
|
||||
{file = "pyarrow-19.0.1.tar.gz", hash = "sha256:3bf266b485df66a400f282ac0b6d1b500b9d2ae73314a153dbe97d6d5cc8a99e"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -7990,14 +7977,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "runloop-api-client"
|
||||
version = "0.32.0"
|
||||
version = "0.31.0"
|
||||
description = "The official Python library for the runloop API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "runloop_api_client-0.32.0-py3-none-any.whl", hash = "sha256:37f156f711b1aa4cef86c0f779cc27afa43ce3d3f6b1976d7f68667466317a6d"},
|
||||
{file = "runloop_api_client-0.32.0.tar.gz", hash = "sha256:735a967d96b5c3e8a08b89072722adcbe2b10ed904268d3f45785b7cfd5420d1"},
|
||||
{file = "runloop_api_client-0.31.0-py3-none-any.whl", hash = "sha256:1eb716a20b268e081bdbcf5b5d1df9ab6eb258a0b929130210a3b643048159c7"},
|
||||
{file = "runloop_api_client-0.31.0.tar.gz", hash = "sha256:78992595fd34f98470aa73b8f5b92983414e4878218239e531a9371c5570a13d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -10269,4 +10256,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "d3f933e9abf6be481ec137e14f8f7ac502afd591a9ba74b315737fd894ca5cfe"
|
||||
content-hash = "ce44ae3718979f1878cbdd4e2f9595a6d73c2ffb0b9256e3ec163397a3916b25"
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.34.0"
|
||||
version = "0.35.1"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = ["OpenHands"]
|
||||
license = "MIT"
|
||||
@@ -58,7 +58,7 @@ 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.75.0"
|
||||
runloop-api-client = "0.32.0"
|
||||
runloop-api-client = "0.31.0"
|
||||
libtmux = ">=0.37,<0.40"
|
||||
pygithub = "^2.5.0"
|
||||
joblib = "*"
|
||||
@@ -146,7 +146,7 @@ browsergym-webarena = "0.13.3"
|
||||
browsergym-miniwob = "0.13.3"
|
||||
browsergym-visualwebarena = "0.13.3"
|
||||
boto3-stubs = {extras = ["s3"], version = "^1.37.19"}
|
||||
pyarrow = "20.0.0" # transitive dependency, pinned here to avoid conflicts
|
||||
pyarrow = "19.0.1" # transitive dependency, pinned here to avoid conflicts
|
||||
datasets = "*"
|
||||
|
||||
[tool.poetry-dynamic-versioning]
|
||||
|
||||
@@ -22,6 +22,7 @@ from openhands.events.observation import (
|
||||
ErrorObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import RecallObservation
|
||||
from openhands.events.observation.commands import CmdOutputObservation
|
||||
from openhands.events.observation.empty import NullObservation
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.llm import LLM
|
||||
@@ -764,7 +765,7 @@ async def test_context_window_exceeded_error_handling(
|
||||
|
||||
# We do that by playing the role of the recall module -- subscribe to the
|
||||
# event stream and respond to recall actions by inserting fake recall
|
||||
# observations.
|
||||
# obesrvations.
|
||||
def on_event_memory(event: Event):
|
||||
if isinstance(event, RecallAction):
|
||||
microagent_obs = RecallObservation(
|
||||
@@ -806,19 +807,13 @@ async def test_context_window_exceeded_error_handling(
|
||||
# size (because we return a message action, which triggers a recall, which
|
||||
# triggers a recall response). But if the pre/post-views are on the turn
|
||||
# when we throw the context window exceeded error, we should see the
|
||||
# post-step view compressed (or rather, a CondensationAction added).
|
||||
# post-step view compressed.
|
||||
for index, (first_view, second_view) in enumerate(
|
||||
zip(step_state.views[:-1], step_state.views[1:])
|
||||
):
|
||||
if index == error_after:
|
||||
# Verify that the CondensationAction is present in the second view (after error)
|
||||
# but not in the first view (before error)
|
||||
assert not any(isinstance(e, CondensationAction) for e in first_view.events)
|
||||
assert any(isinstance(e, CondensationAction) for e in second_view.events)
|
||||
# The length might not strictly decrease due to CondensationAction being added
|
||||
assert len(first_view) == len(second_view)
|
||||
assert len(first_view) > len(second_view)
|
||||
else:
|
||||
# Before the error, the view length should increase
|
||||
assert len(first_view) < len(second_view)
|
||||
|
||||
# The final state's history should contain:
|
||||
@@ -891,7 +886,7 @@ async def test_run_controller_with_context_window_exceeded_with_truncation(
|
||||
def step(self, state: State):
|
||||
# If the state has more than one message and we haven't errored yet,
|
||||
# throw the context window exceeded error
|
||||
if len(state.history) > 5 and not self.has_errored:
|
||||
if len(state.history) > 3 and not self.has_errored:
|
||||
error = ContextWindowExceededError(
|
||||
message='prompt is too long: 233885 tokens > 200000 maximum',
|
||||
model='',
|
||||
@@ -1472,6 +1467,126 @@ def test_agent_controller_should_step_with_null_observation_cause_zero(mock_agen
|
||||
), 'should_step should return False for NullObservation with cause = 0'
|
||||
|
||||
|
||||
def test_apply_conversation_window_basic(mock_event_stream, mock_agent):
|
||||
"""Test that the _apply_conversation_window method correctly prunes a list of events."""
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test_apply_conversation_window_basic',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
# Create a sequence of events with IDs
|
||||
first_msg = MessageAction(content='Hello, start task', wait_for_response=False)
|
||||
first_msg._source = EventSource.USER
|
||||
first_msg._id = 1
|
||||
|
||||
# Add agent question
|
||||
agent_msg = MessageAction(
|
||||
content='What task would you like me to perform?', wait_for_response=True
|
||||
)
|
||||
agent_msg._source = EventSource.AGENT
|
||||
agent_msg._id = 2
|
||||
|
||||
# Add user response
|
||||
user_response = MessageAction(
|
||||
content='Please list all files and show me current directory',
|
||||
wait_for_response=False,
|
||||
)
|
||||
user_response._source = EventSource.USER
|
||||
user_response._id = 3
|
||||
|
||||
cmd1 = CmdRunAction(command='ls')
|
||||
cmd1._id = 4
|
||||
obs1 = CmdOutputObservation(command='ls', content='file1.txt', command_id=4)
|
||||
obs1._id = 5
|
||||
obs1._cause = 4
|
||||
|
||||
cmd2 = CmdRunAction(command='pwd')
|
||||
cmd2._id = 6
|
||||
obs2 = CmdOutputObservation(command='pwd', content='/home', command_id=6)
|
||||
obs2._id = 7
|
||||
obs2._cause = 6
|
||||
|
||||
events = [first_msg, agent_msg, user_response, cmd1, obs1, cmd2, obs2]
|
||||
|
||||
# Apply truncation
|
||||
truncated = controller._apply_conversation_window(events)
|
||||
|
||||
# Verify truncation occured
|
||||
# Should keep first user message and roughly half of other events
|
||||
assert (
|
||||
3 <= len(truncated) < len(events)
|
||||
) # First message + at least one action-observation pair
|
||||
assert truncated[0] == first_msg # First message always preserved
|
||||
assert controller.state.start_id == first_msg._id
|
||||
|
||||
# Verify pairs aren't split
|
||||
for i, event in enumerate(truncated[1:]):
|
||||
if isinstance(event, CmdOutputObservation):
|
||||
assert any(e._id == event._cause for e in truncated[: i + 1])
|
||||
|
||||
|
||||
def test_history_restoration_after_truncation(mock_event_stream, mock_agent):
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test_truncation',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
# Create events with IDs
|
||||
first_msg = MessageAction(content='Start task', wait_for_response=False)
|
||||
first_msg._source = EventSource.USER
|
||||
first_msg._id = 1
|
||||
|
||||
events = [first_msg]
|
||||
for i in range(5):
|
||||
cmd = CmdRunAction(command=f'cmd{i}')
|
||||
cmd._id = i + 2
|
||||
obs = CmdOutputObservation(
|
||||
command=f'cmd{i}', content=f'output{i}', command_id=cmd._id
|
||||
)
|
||||
obs._cause = cmd._id
|
||||
events.extend([cmd, obs])
|
||||
|
||||
# Set up initial history
|
||||
controller.state.history = events.copy()
|
||||
|
||||
# Force truncation
|
||||
controller.state.history = controller._apply_conversation_window(
|
||||
controller.state.history
|
||||
)
|
||||
|
||||
# Save state
|
||||
saved_start_id = controller.state.start_id
|
||||
saved_history_len = len(controller.state.history)
|
||||
|
||||
# Set up mock event stream for new controller
|
||||
mock_event_stream.get_events.return_value = controller.state.history
|
||||
|
||||
# Create new controller with saved state
|
||||
new_controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test_truncation',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
new_controller.state.start_id = saved_start_id
|
||||
new_controller.state.history = mock_event_stream.get_events()
|
||||
|
||||
# Verify restoration
|
||||
assert len(new_controller.state.history) == saved_history_len
|
||||
assert new_controller.state.history[0] == first_msg
|
||||
assert new_controller.state.start_id == saved_start_id
|
||||
|
||||
|
||||
def test_system_message_in_event_stream(mock_agent, test_event_stream):
|
||||
"""Test that SystemMessageAction is added to event stream in AgentController."""
|
||||
_ = AgentController(
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.agent_controller import AgentController
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import AppConfig
|
||||
from openhands.events import EventSource
|
||||
from openhands.events.action import CmdRunAction, MessageAction, RecallAction
|
||||
from openhands.events.action.message import SystemMessageAction
|
||||
from openhands.events.event import RecallType
|
||||
from openhands.events.observation import (
|
||||
CmdOutputObservation,
|
||||
Observation,
|
||||
RecallObservation,
|
||||
)
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
|
||||
|
||||
# Helper function to create events with sequential IDs and causes
|
||||
def create_events(event_data):
|
||||
events = []
|
||||
# Import necessary types here to avoid repeated imports inside the loop
|
||||
from openhands.events.action import CmdRunAction, RecallAction
|
||||
from openhands.events.observation import CmdOutputObservation, RecallObservation
|
||||
|
||||
for i, data in enumerate(event_data):
|
||||
event_type = data['type']
|
||||
source = data.get('source', EventSource.AGENT)
|
||||
kwargs = {} # Arguments for the event constructor
|
||||
|
||||
# Determine arguments based on event type
|
||||
if event_type == RecallAction:
|
||||
kwargs['query'] = data.get('query', '')
|
||||
kwargs['recall_type'] = data.get('recall_type', RecallType.KNOWLEDGE)
|
||||
elif event_type == RecallObservation:
|
||||
kwargs['content'] = data.get('content', '')
|
||||
kwargs['recall_type'] = data.get('recall_type', RecallType.KNOWLEDGE)
|
||||
elif event_type == CmdRunAction:
|
||||
kwargs['command'] = data.get('command', '')
|
||||
elif event_type == CmdOutputObservation:
|
||||
# Required args for CmdOutputObservation
|
||||
kwargs['content'] = data.get('content', '')
|
||||
kwargs['command'] = data.get('command', '')
|
||||
# Pass command_id via kwargs if present in data
|
||||
if 'command_id' in data:
|
||||
kwargs['command_id'] = data['command_id']
|
||||
# Pass metadata if present
|
||||
if 'metadata' in data:
|
||||
kwargs['metadata'] = data['metadata']
|
||||
else: # Default for MessageAction, SystemMessageAction, etc.
|
||||
kwargs['content'] = data.get('content', '')
|
||||
|
||||
# Instantiate the event
|
||||
event = event_type(**kwargs)
|
||||
|
||||
# Assign internal attributes AFTER instantiation
|
||||
event._id = i + 1 # Assign sequential IDs starting from 1
|
||||
event._source = source
|
||||
# Assign _cause using cause_id from data, AFTER event._id is set
|
||||
if 'cause_id' in data:
|
||||
event._cause = data['cause_id']
|
||||
# If command_id was NOT passed via kwargs but cause_id exists,
|
||||
# pass cause_id as command_id to __init__ via kwargs for legacy handling
|
||||
# This needs to happen *before* instantiation if we want __init__ to handle it
|
||||
# Let's adjust the logic slightly:
|
||||
if event_type == CmdOutputObservation:
|
||||
if 'command_id' not in kwargs and 'cause_id' in data:
|
||||
kwargs['command_id'] = data['cause_id'] # Let __init__ handle this
|
||||
# Re-instantiate if we added command_id
|
||||
if 'command_id' in kwargs and event.command_id != kwargs['command_id']:
|
||||
event = event_type(**kwargs)
|
||||
event._id = i + 1
|
||||
event._source = source
|
||||
|
||||
# Now assign _cause if it exists in data, after potential re-instantiation
|
||||
if 'cause_id' in data:
|
||||
event._cause = data['cause_id']
|
||||
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller_fixture():
|
||||
mock_agent = MagicMock(spec=Agent)
|
||||
mock_agent.llm = MagicMock(spec=LLM)
|
||||
mock_agent.llm.metrics = Metrics()
|
||||
mock_agent.llm.config = AppConfig().get_llm_config()
|
||||
mock_agent.config = AppConfig().get_agent_config('CodeActAgent')
|
||||
|
||||
mock_event_stream = MagicMock(spec=EventStream)
|
||||
mock_event_stream.sid = 'test_sid'
|
||||
mock_event_stream.file_store = InMemoryFileStore({})
|
||||
# Ensure get_latest_event_id returns an integer
|
||||
mock_event_stream.get_latest_event_id.return_value = -1
|
||||
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test_sid',
|
||||
)
|
||||
controller.state = State(session_id='test_sid')
|
||||
|
||||
# Mock _first_user_message directly on the instance
|
||||
mock_first_user_message = MagicMock(spec=MessageAction)
|
||||
controller._first_user_message = MagicMock(return_value=mock_first_user_message)
|
||||
|
||||
return controller, mock_first_user_message
|
||||
|
||||
|
||||
# =============================================
|
||||
# Test Cases for _apply_conversation_window
|
||||
# =============================================
|
||||
|
||||
|
||||
def test_basic_truncation(controller_fixture):
|
||||
controller, mock_first_user_message = controller_fixture
|
||||
|
||||
controller.state.history = create_events(
|
||||
[
|
||||
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 1',
|
||||
'source': EventSource.USER,
|
||||
}, # 2
|
||||
{'type': RecallAction, 'query': 'User Task 1'}, # 3
|
||||
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
|
||||
{'type': CmdRunAction, 'command': 'ls'}, # 5
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'file1',
|
||||
'command': 'ls',
|
||||
'cause_id': 5,
|
||||
}, # 6
|
||||
{'type': CmdRunAction, 'command': 'pwd'}, # 7
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': '/dir',
|
||||
'command': 'pwd',
|
||||
'cause_id': 7,
|
||||
}, # 8
|
||||
{'type': CmdRunAction, 'command': 'cat file1'}, # 9
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'content',
|
||||
'command': 'cat file1',
|
||||
'cause_id': 9,
|
||||
}, # 10
|
||||
]
|
||||
)
|
||||
mock_first_user_message.id = 2 # Set the ID of the mocked first user message
|
||||
|
||||
# Calculation (RecallAction now essential):
|
||||
# History len = 10
|
||||
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
|
||||
# Non-essential count = 10 - 4 = 6
|
||||
# num_recent_to_keep = max(1, 6 // 2) = 3
|
||||
# slice_start_index = 10 - 3 = 7
|
||||
# recent_events_slice = history[7:] = [obs2(8), cmd3(9), obs3(10)]
|
||||
# Validation: remove leading obs2(8). validated_slice = [cmd3(9), obs3(10)]
|
||||
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4), cmd3(9), obs3(10)]
|
||||
# Expected IDs: [1, 2, 3, 4, 9, 10]. Length 6.
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
|
||||
assert len(truncated_events) == 6
|
||||
expected_ids = [1, 2, 3, 4, 9, 10]
|
||||
actual_ids = [e.id for e in truncated_events]
|
||||
assert actual_ids == expected_ids
|
||||
# Check no dangling observations at the start of the recent slice part
|
||||
# The first event of the validated slice is cmd3(9)
|
||||
assert not isinstance(truncated_events[4], Observation) # Index adjusted
|
||||
|
||||
|
||||
def test_no_system_message(controller_fixture):
|
||||
controller, mock_first_user_message = controller_fixture
|
||||
|
||||
controller.state.history = create_events(
|
||||
[
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 1',
|
||||
'source': EventSource.USER,
|
||||
}, # 1
|
||||
{'type': RecallAction, 'query': 'User Task 1'}, # 2
|
||||
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 2}, # 3
|
||||
{'type': CmdRunAction, 'command': 'ls'}, # 4
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'file1',
|
||||
'command': 'ls',
|
||||
'cause_id': 4,
|
||||
}, # 5
|
||||
{'type': CmdRunAction, 'command': 'pwd'}, # 6
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': '/dir',
|
||||
'command': 'pwd',
|
||||
'cause_id': 6,
|
||||
}, # 7
|
||||
{'type': CmdRunAction, 'command': 'cat file1'}, # 8
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'content',
|
||||
'command': 'cat file1',
|
||||
'cause_id': 8,
|
||||
}, # 9
|
||||
]
|
||||
)
|
||||
mock_first_user_message.id = 1
|
||||
|
||||
# Calculation (RecallAction now essential):
|
||||
# History len = 9
|
||||
# Essentials = [user(1), recall_act(2), recall_obs(3)] (len=3)
|
||||
# Non-essential count = 9 - 3 = 6
|
||||
# num_recent_to_keep = max(1, 6 // 2) = 3
|
||||
# slice_start_index = 9 - 3 = 6
|
||||
# recent_events_slice = history[6:] = [obs2(7), cmd3(8), obs3(9)]
|
||||
# Validation: remove leading obs2(7). validated_slice = [cmd3(8), obs3(9)]
|
||||
# Final = essentials + validated_slice = [user(1), recall_act(2), recall_obs(3), cmd3(8), obs3(9)]
|
||||
# Expected IDs: [1, 2, 3, 8, 9]. Length 5.
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
|
||||
assert len(truncated_events) == 5
|
||||
expected_ids = [1, 2, 3, 8, 9]
|
||||
actual_ids = [e.id for e in truncated_events]
|
||||
assert actual_ids == expected_ids
|
||||
|
||||
|
||||
def test_no_recall_observation(controller_fixture):
|
||||
controller, mock_first_user_message = controller_fixture
|
||||
|
||||
controller.state.history = create_events(
|
||||
[
|
||||
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 1',
|
||||
'source': EventSource.USER,
|
||||
}, # 2
|
||||
{'type': RecallAction, 'query': 'User Task 1'}, # 3 (Recall Action exists)
|
||||
# Recall Observation is missing
|
||||
{'type': CmdRunAction, 'command': 'ls'}, # 4
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'file1',
|
||||
'command': 'ls',
|
||||
'cause_id': 4,
|
||||
}, # 5
|
||||
{'type': CmdRunAction, 'command': 'pwd'}, # 6
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': '/dir',
|
||||
'command': 'pwd',
|
||||
'cause_id': 6,
|
||||
}, # 7
|
||||
{'type': CmdRunAction, 'command': 'cat file1'}, # 8
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'content',
|
||||
'command': 'cat file1',
|
||||
'cause_id': 8,
|
||||
}, # 9
|
||||
]
|
||||
)
|
||||
mock_first_user_message.id = 2
|
||||
|
||||
# Calculation (RecallAction essential only if RecallObs exists):
|
||||
# History len = 9
|
||||
# Essentials = [sys(1), user(2)] (len=2) - RecallObs missing, so RecallAction not essential here
|
||||
# Non-essential count = 9 - 2 = 7
|
||||
# num_recent_to_keep = max(1, 7 // 2) = 3
|
||||
# slice_start_index = 9 - 3 = 6
|
||||
# recent_events_slice = history[6:] = [obs2(7), cmd3(8), obs3(9)]
|
||||
# Validation: remove leading obs2(7). validated_slice = [cmd3(8), obs3(9)]
|
||||
# Final = essentials + validated_slice = [sys(1), user(2), recall_action(3), cmd_cat(8), obs_cat(9)]
|
||||
# Expected IDs: [1, 2, 3, 8, 9]. Length 5.
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
|
||||
assert len(truncated_events) == 5
|
||||
expected_ids = [1, 2, 3, 8, 9]
|
||||
actual_ids = [e.id for e in truncated_events]
|
||||
assert actual_ids == expected_ids
|
||||
|
||||
|
||||
def test_short_history_no_truncation(controller_fixture):
|
||||
controller, mock_first_user_message = controller_fixture
|
||||
|
||||
history = create_events(
|
||||
[
|
||||
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 1',
|
||||
'source': EventSource.USER,
|
||||
}, # 2
|
||||
{'type': RecallAction, 'query': 'User Task 1'}, # 3
|
||||
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
|
||||
{'type': CmdRunAction, 'command': 'ls'}, # 5
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'file1',
|
||||
'command': 'ls',
|
||||
'cause_id': 5,
|
||||
}, # 6
|
||||
]
|
||||
)
|
||||
controller.state.history = history
|
||||
mock_first_user_message.id = 2
|
||||
|
||||
# Calculation (RecallAction now essential):
|
||||
# History len = 6
|
||||
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
|
||||
# Non-essential count = 6 - 4 = 2
|
||||
# num_recent_to_keep = max(1, 2 // 2) = 1
|
||||
# slice_start_index = 6 - 1 = 5
|
||||
# recent_events_slice = history[5:] = [obs1(6)]
|
||||
# Validation: remove leading obs1(6). validated_slice = []
|
||||
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4)]
|
||||
# Expected IDs: [1, 2, 3, 4]. Length 4.
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
|
||||
assert len(truncated_events) == 4
|
||||
expected_ids = [1, 2, 3, 4]
|
||||
actual_ids = [e.id for e in truncated_events]
|
||||
assert actual_ids == expected_ids
|
||||
|
||||
|
||||
def test_only_essential_events(controller_fixture):
|
||||
controller, mock_first_user_message = controller_fixture
|
||||
|
||||
history = create_events(
|
||||
[
|
||||
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 1',
|
||||
'source': EventSource.USER,
|
||||
}, # 2
|
||||
{'type': RecallAction, 'query': 'User Task 1'}, # 3
|
||||
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
|
||||
]
|
||||
)
|
||||
controller.state.history = history
|
||||
mock_first_user_message.id = 2
|
||||
|
||||
# Calculation (RecallAction now essential):
|
||||
# History len = 4
|
||||
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
|
||||
# Non-essential count = 4 - 4 = 0
|
||||
# num_recent_to_keep = max(1, 0 // 2) = 1
|
||||
# slice_start_index = 4 - 1 = 3
|
||||
# recent_events_slice = history[3:] = [recall_obs(4)]
|
||||
# Validation: remove leading recall_obs(4). validated_slice = []
|
||||
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4)]
|
||||
# Expected IDs: [1, 2, 3, 4]. Length 4.
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
|
||||
assert len(truncated_events) == 4
|
||||
expected_ids = [1, 2, 3, 4]
|
||||
actual_ids = [e.id for e in truncated_events]
|
||||
assert actual_ids == expected_ids
|
||||
|
||||
|
||||
def test_dangling_observations_at_cut_point(controller_fixture):
|
||||
controller, mock_first_user_message = controller_fixture
|
||||
|
||||
history_forced_dangle = create_events(
|
||||
[
|
||||
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 1',
|
||||
'source': EventSource.USER,
|
||||
}, # 2
|
||||
{'type': RecallAction, 'query': 'User Task 1'}, # 3
|
||||
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
|
||||
# --- Slice calculation should start here ---
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'dangle1',
|
||||
'command': 'cmd_unknown',
|
||||
}, # 5 (Dangling)
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'dangle2',
|
||||
'command': 'cmd_unknown',
|
||||
}, # 6 (Dangling)
|
||||
{'type': CmdRunAction, 'command': 'cmd1'}, # 7
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'obs1',
|
||||
'command': 'cmd1',
|
||||
'cause_id': 7,
|
||||
}, # 8
|
||||
{'type': CmdRunAction, 'command': 'cmd2'}, # 9
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'obs2',
|
||||
'command': 'cmd2',
|
||||
'cause_id': 9,
|
||||
}, # 10
|
||||
]
|
||||
) # 10 events total
|
||||
controller.state.history = history_forced_dangle
|
||||
mock_first_user_message.id = 2
|
||||
|
||||
# Calculation (RecallAction now essential):
|
||||
# History len = 10
|
||||
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
|
||||
# Non-essential count = 10 - 4 = 6
|
||||
# num_recent_to_keep = max(1, 6 // 2) = 3
|
||||
# slice_start_index = 10 - 3 = 7
|
||||
# recent_events_slice = history[7:] = [obs1(8), cmd2(9), obs2(10)]
|
||||
# Validation: remove leading obs1(8). validated_slice = [cmd2(9), obs2(10)]
|
||||
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4), cmd2(9), obs2(10)]
|
||||
# Expected IDs: [1, 2, 3, 4, 9, 10]. Length 6.
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
|
||||
assert len(truncated_events) == 6
|
||||
expected_ids = [1, 2, 3, 4, 9, 10]
|
||||
actual_ids = [e.id for e in truncated_events]
|
||||
assert actual_ids == expected_ids
|
||||
# Verify dangling observations 5 and 6 were removed (implicitly by slice start and validation)
|
||||
|
||||
|
||||
def test_only_dangling_observations_in_recent_slice(controller_fixture):
|
||||
controller, mock_first_user_message = controller_fixture
|
||||
|
||||
history = create_events(
|
||||
[
|
||||
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 1',
|
||||
'source': EventSource.USER,
|
||||
}, # 2
|
||||
{'type': RecallAction, 'query': 'User Task 1'}, # 3
|
||||
{'type': RecallObservation, 'content': 'Recall result', 'cause_id': 3}, # 4
|
||||
# --- Slice calculation should start here ---
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'dangle1',
|
||||
'command': 'cmd_unknown',
|
||||
}, # 5 (Dangling)
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'dangle2',
|
||||
'command': 'cmd_unknown',
|
||||
}, # 6 (Dangling)
|
||||
]
|
||||
) # 6 events total
|
||||
controller.state.history = history
|
||||
mock_first_user_message.id = 2
|
||||
|
||||
# Calculation (RecallAction now essential):
|
||||
# History len = 6
|
||||
# Essentials = [sys(1), user(2), recall_act(3), recall_obs(4)] (len=4)
|
||||
# Non-essential count = 6 - 4 = 2
|
||||
# num_recent_to_keep = max(1, 2 // 2) = 1
|
||||
# slice_start_index = 6 - 1 = 5
|
||||
# recent_events_slice = history[5:] = [dangle2(6)]
|
||||
# Validation: remove leading dangle2(6). validated_slice = [] (Corrected based on user feedback/bugfix)
|
||||
# Final = essentials + validated_slice = [sys(1), user(2), recall_act(3), recall_obs(4)]
|
||||
# Expected IDs: [1, 2, 3, 4]. Length 4.
|
||||
with patch(
|
||||
'openhands.controller.agent_controller.logger.warning'
|
||||
) as mock_log_warning:
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
|
||||
assert len(truncated_events) == 4
|
||||
expected_ids = [1, 2, 3, 4]
|
||||
actual_ids = [e.id for e in truncated_events]
|
||||
assert actual_ids == expected_ids
|
||||
# Verify dangling observations 5 and 6 were removed
|
||||
|
||||
# Check that the specific warning was logged exactly once
|
||||
assert mock_log_warning.call_count == 1
|
||||
|
||||
# Check the essential parts of the arguments, allowing for variations like stacklevel
|
||||
call_args, call_kwargs = mock_log_warning.call_args
|
||||
expected_message_substring = 'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.'
|
||||
assert expected_message_substring in call_args[0]
|
||||
assert 'extra' in call_kwargs
|
||||
assert call_kwargs['extra'].get('session_id') == 'test_sid'
|
||||
|
||||
|
||||
def test_empty_history(controller_fixture):
|
||||
controller, _ = controller_fixture
|
||||
controller.state.history = []
|
||||
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
assert truncated_events == []
|
||||
|
||||
|
||||
def test_multiple_user_messages(controller_fixture):
|
||||
controller, mock_first_user_message = controller_fixture
|
||||
|
||||
history = create_events(
|
||||
[
|
||||
{'type': SystemMessageAction, 'content': 'System Prompt'}, # 1
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 1',
|
||||
'source': EventSource.USER,
|
||||
}, # 2 (First)
|
||||
{'type': RecallAction, 'query': 'User Task 1'}, # 3
|
||||
{
|
||||
'type': RecallObservation,
|
||||
'content': 'Recall result 1',
|
||||
'cause_id': 3,
|
||||
}, # 4
|
||||
{'type': CmdRunAction, 'command': 'cmd1'}, # 5
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'obs1',
|
||||
'command': 'cmd1',
|
||||
'cause_id': 5,
|
||||
}, # 6
|
||||
{
|
||||
'type': MessageAction,
|
||||
'content': 'User Task 2',
|
||||
'source': EventSource.USER,
|
||||
}, # 7 (Second)
|
||||
{'type': RecallAction, 'query': 'User Task 2'}, # 8
|
||||
{
|
||||
'type': RecallObservation,
|
||||
'content': 'Recall result 2',
|
||||
'cause_id': 8,
|
||||
}, # 9
|
||||
{'type': CmdRunAction, 'command': 'cmd2'}, # 10
|
||||
{
|
||||
'type': CmdOutputObservation,
|
||||
'content': 'obs2',
|
||||
'command': 'cmd2',
|
||||
'cause_id': 10,
|
||||
}, # 11
|
||||
]
|
||||
) # 11 events total
|
||||
controller.state.history = history
|
||||
mock_first_user_message.id = 2 # Explicitly set the first user message ID
|
||||
|
||||
# Calculation (RecallAction now essential):
|
||||
# History len = 11
|
||||
# Essentials = [sys(1), user1(2), recall_act1(3), recall_obs1(4)] (len=4)
|
||||
# Non-essential count = 11 - 4 = 7
|
||||
# num_recent_to_keep = max(1, 7 // 2) = 3
|
||||
# slice_start_index = 11 - 3 = 8
|
||||
# recent_events_slice = history[8:] = [recall_obs2(9), cmd2(10), obs2(11)]
|
||||
# Validation: remove leading recall_obs2(9). validated_slice = [cmd2(10), obs2(11)]
|
||||
# Final = essentials + validated_slice = [sys(1), user1(2), recall_act1(3), recall_obs1(4)] + [cmd2(10), obs2(11)]
|
||||
# Expected IDs: [1, 2, 3, 4, 10, 11]. Length 6.
|
||||
truncated_events = controller._apply_conversation_window()
|
||||
|
||||
assert len(truncated_events) == 6
|
||||
expected_ids = [1, 2, 3, 4, 10, 11]
|
||||
actual_ids = [e.id for e in truncated_events]
|
||||
assert actual_ids == expected_ids
|
||||
|
||||
# Verify the second user message (ID 7) was NOT kept
|
||||
assert not any(event.id == 7 for event in truncated_events)
|
||||
# Verify the first user message (ID 2) is present
|
||||
assert any(event.id == 2 for event in truncated_events)
|
||||
+17
-65
@@ -44,7 +44,6 @@ from openhands.events.observation.commands import (
|
||||
)
|
||||
from openhands.events.tool import ToolCallMetadata
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.memory.condenser import View
|
||||
|
||||
|
||||
@pytest.fixture(params=['CodeActAgent', 'ReadOnlyAgent'])
|
||||
@@ -98,12 +97,6 @@ def test_reset(agent):
|
||||
action._source = EventSource.AGENT
|
||||
agent.pending_actions.append(action)
|
||||
|
||||
# Create a mock state with initial user message
|
||||
mock_state = Mock(spec=State)
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
mock_state.history = [initial_user_message]
|
||||
|
||||
# Reset
|
||||
agent.reset()
|
||||
|
||||
@@ -117,14 +110,8 @@ def test_step_with_pending_actions(agent):
|
||||
pending_action._source = EventSource.AGENT
|
||||
agent.pending_actions.append(pending_action)
|
||||
|
||||
# Create a mock state with initial user message
|
||||
mock_state = Mock(spec=State)
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
mock_state.history = [initial_user_message]
|
||||
|
||||
# Step should return the pending action
|
||||
result = agent.step(mock_state)
|
||||
result = agent.step(Mock())
|
||||
assert result == pending_action
|
||||
assert len(agent.pending_actions) == 0
|
||||
|
||||
@@ -273,11 +260,6 @@ def test_step_with_no_pending_actions(mock_state: State):
|
||||
mock_state.latest_user_message_llm_metrics = None
|
||||
mock_state.latest_user_message_tool_call_metadata = None
|
||||
|
||||
# Add initial user message to history
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
mock_state.history = [initial_user_message]
|
||||
|
||||
action = agent.step(mock_state)
|
||||
assert isinstance(action, MessageAction)
|
||||
assert action.content == 'Task completed'
|
||||
@@ -348,56 +330,42 @@ def test_mismatched_tool_call_events_and_auto_add_system_message(
|
||||
)
|
||||
|
||||
action = CmdRunAction('foo')
|
||||
action._source = EventSource.AGENT
|
||||
action._source = 'agent'
|
||||
action.tool_call_metadata = tool_call_metadata
|
||||
|
||||
observation = CmdOutputObservation(content='', command_id=0, command='foo')
|
||||
observation.tool_call_metadata = tool_call_metadata
|
||||
|
||||
# Add initial user message
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
|
||||
# When both events are provided, the agent should get three messages:
|
||||
# 1. The system message (added automatically for backward compatibility)
|
||||
# 2. The action message
|
||||
# 3. The observation message
|
||||
mock_state.history = [initial_user_message, action, observation]
|
||||
messages = agent._get_messages(mock_state.history, initial_user_message)
|
||||
assert len(messages) == 4 # System + initial user + action + observation
|
||||
mock_state.history = [action, observation]
|
||||
messages = agent._get_messages(mock_state.history)
|
||||
assert len(messages) == 3
|
||||
assert messages[0].role == 'system' # First message should be the system message
|
||||
assert (
|
||||
messages[1].role == 'user'
|
||||
) # Second message should be the initial user message
|
||||
assert messages[2].role == 'assistant' # Third message should be the action
|
||||
assert messages[3].role == 'tool' # Fourth message should be the observation
|
||||
assert messages[1].role == 'assistant' # Second message should be the action
|
||||
assert messages[2].role == 'tool' # Third message should be the observation
|
||||
|
||||
# The same should hold if the events are presented out-of-order
|
||||
mock_state.history = [initial_user_message, observation, action]
|
||||
messages = agent._get_messages(mock_state.history, initial_user_message)
|
||||
assert len(messages) == 4
|
||||
mock_state.history = [observation, action]
|
||||
messages = agent._get_messages(mock_state.history)
|
||||
assert len(messages) == 3
|
||||
assert messages[0].role == 'system' # First message should be the system message
|
||||
assert (
|
||||
messages[1].role == 'user'
|
||||
) # Second message should be the initial user message
|
||||
|
||||
# If only one of the two events is present, then we should just get the system message
|
||||
# plus any valid message from the event
|
||||
mock_state.history = [initial_user_message, action]
|
||||
messages = agent._get_messages(mock_state.history, initial_user_message)
|
||||
mock_state.history = [action]
|
||||
messages = agent._get_messages(mock_state.history)
|
||||
assert (
|
||||
len(messages) == 2
|
||||
) # System + initial user message, action is waiting for its observation
|
||||
len(messages) == 1
|
||||
) # Only system message, action is waiting for its observation
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[1].role == 'user'
|
||||
|
||||
mock_state.history = [initial_user_message, observation]
|
||||
messages = agent._get_messages(mock_state.history, initial_user_message)
|
||||
assert (
|
||||
len(messages) == 2
|
||||
) # System + initial user message, observation has no matching action
|
||||
mock_state.history = [observation]
|
||||
messages = agent._get_messages(mock_state.history)
|
||||
assert len(messages) == 1 # Only system message, observation has no matching action
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[1].role == 'user'
|
||||
|
||||
|
||||
def test_grep_tool():
|
||||
@@ -502,19 +470,3 @@ def test_get_system_message():
|
||||
assert len(result.tools) > 0
|
||||
assert any(tool['function']['name'] == 'execute_bash' for tool in result.tools)
|
||||
assert result._source == EventSource.AGENT
|
||||
|
||||
|
||||
def test_step_raises_error_if_no_initial_user_message(
|
||||
agent: CodeActAgent, mock_state: State
|
||||
):
|
||||
"""Tests that step raises ValueError if the initial user message is not found."""
|
||||
# Ensure history does NOT contain a user MessageAction
|
||||
assistant_message = MessageAction(content='Assistant message')
|
||||
assistant_message._source = EventSource.AGENT
|
||||
mock_state.history = [assistant_message]
|
||||
# Mock the condenser to return the history as is
|
||||
agent.condenser = Mock()
|
||||
agent.condenser.condensed_history.return_value = View(events=mock_state.history)
|
||||
|
||||
with pytest.raises(ValueError, match='Initial user message not found'):
|
||||
agent.step(mock_state)
|
||||
|
||||
@@ -8,7 +8,6 @@ from openhands.core.cli_commands import (
|
||||
handle_help_command,
|
||||
handle_init_command,
|
||||
handle_new_command,
|
||||
handle_resume_command,
|
||||
handle_settings_command,
|
||||
handle_status_command,
|
||||
)
|
||||
@@ -462,27 +461,3 @@ class TestHandleSettingsCommand:
|
||||
# Verify correct behavior
|
||||
mock_display_settings.assert_called_once_with(config)
|
||||
mock_cli_confirm.assert_called_once()
|
||||
|
||||
|
||||
class TestHandleResumeCommand:
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_resume_command(self):
|
||||
"""Test that handle_resume_command adds the 'continue' message to the event stream."""
|
||||
# Create a mock event stream
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
|
||||
# Call the function
|
||||
close_repl, new_session_requested = await handle_resume_command(event_stream)
|
||||
|
||||
# Check that the event stream add_event was called with the correct message action
|
||||
event_stream.add_event.assert_called_once()
|
||||
args, kwargs = event_stream.add_event.call_args
|
||||
message_action, source = args
|
||||
|
||||
assert isinstance(message_action, MessageAction)
|
||||
assert message_action.content == 'continue'
|
||||
assert source == EventSource.USER
|
||||
|
||||
# Check the return values
|
||||
assert close_repl is True
|
||||
assert new_session_requested is False
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.keys import Keys
|
||||
|
||||
from openhands.core.cli_tui import process_agent_pause
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events import EventSource
|
||||
from openhands.events.action import ChangeAgentStateAction
|
||||
from openhands.events.observation import AgentStateChangedObservation
|
||||
from openhands.events.stream import EventStream
|
||||
|
||||
|
||||
class TestProcessAgentPause:
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_tui.create_input')
|
||||
@patch('openhands.core.cli_tui.print_formatted_text')
|
||||
async def test_process_agent_pause_ctrl_p(self, mock_print, mock_create_input):
|
||||
"""Test that process_agent_pause sets the done event when Ctrl+P is pressed."""
|
||||
# Create the done event
|
||||
done = asyncio.Event()
|
||||
|
||||
# Set up the mock input
|
||||
mock_input = MagicMock()
|
||||
mock_create_input.return_value = mock_input
|
||||
|
||||
# Mock the context managers
|
||||
mock_raw_mode = MagicMock()
|
||||
mock_input.raw_mode.return_value = mock_raw_mode
|
||||
mock_raw_mode.__enter__ = MagicMock()
|
||||
mock_raw_mode.__exit__ = MagicMock()
|
||||
|
||||
mock_attach = MagicMock()
|
||||
mock_input.attach.return_value = mock_attach
|
||||
mock_attach.__enter__ = MagicMock()
|
||||
mock_attach.__exit__ = MagicMock()
|
||||
|
||||
# Capture the keys_ready function
|
||||
keys_ready_func = None
|
||||
|
||||
def fake_attach(callback):
|
||||
nonlocal keys_ready_func
|
||||
keys_ready_func = callback
|
||||
return mock_attach
|
||||
|
||||
mock_input.attach.side_effect = fake_attach
|
||||
|
||||
# Create a task to run process_agent_pause
|
||||
task = asyncio.create_task(process_agent_pause(done))
|
||||
|
||||
# Give it a moment to start and capture the callback
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Make sure we captured the callback
|
||||
assert keys_ready_func is not None
|
||||
|
||||
# Create a key press that simulates Ctrl+P
|
||||
key_press = MagicMock()
|
||||
key_press.key = Keys.ControlP
|
||||
mock_input.read_keys.return_value = [key_press]
|
||||
|
||||
# Manually call the callback to simulate key press
|
||||
keys_ready_func()
|
||||
|
||||
# Verify done was set
|
||||
assert done.is_set()
|
||||
|
||||
# Verify print was called with the pause message
|
||||
assert mock_print.call_count == 2
|
||||
assert mock_print.call_args_list[0] == call('')
|
||||
|
||||
# Check that the second call contains the pause message HTML
|
||||
second_call = mock_print.call_args_list[1][0][0]
|
||||
assert isinstance(second_call, HTML)
|
||||
assert 'Pausing the agent' in str(second_call)
|
||||
|
||||
# Cancel the task
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
class TestCliPauseResumeInRunSession:
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_event_async_pause_processing(self):
|
||||
"""Test that on_event_async processes the pause event when is_paused is set."""
|
||||
# Create a mock event
|
||||
event = MagicMock()
|
||||
|
||||
# Create mock dependencies
|
||||
event_stream = MagicMock()
|
||||
is_paused = asyncio.Event()
|
||||
reload_microagents = False
|
||||
config = MagicMock()
|
||||
|
||||
# Patch the display_event function
|
||||
with patch('openhands.core.cli.display_event') as mock_display_event, patch(
|
||||
'openhands.core.cli.update_usage_metrics'
|
||||
) as mock_update_metrics:
|
||||
# Create a closure to capture the current context
|
||||
async def test_func():
|
||||
# Set the pause event
|
||||
is_paused.set()
|
||||
|
||||
# Create a context similar to run_session to call on_event_async
|
||||
# We're creating a function that mimics the environment of on_event_async
|
||||
async def on_event_async_test(event):
|
||||
nonlocal reload_microagents, is_paused
|
||||
mock_display_event(event, config)
|
||||
mock_update_metrics(event, usage_metrics=MagicMock())
|
||||
|
||||
# Pause the agent if the pause event is set (through Ctrl-P)
|
||||
if is_paused.is_set():
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.PAUSED),
|
||||
EventSource.USER,
|
||||
)
|
||||
is_paused.clear()
|
||||
|
||||
# Call our test function
|
||||
await on_event_async_test(event)
|
||||
|
||||
# Check that the event_stream.add_event was called with the correct action
|
||||
event_stream.add_event.assert_called_once()
|
||||
args, kwargs = event_stream.add_event.call_args
|
||||
action, source = args
|
||||
|
||||
assert isinstance(action, ChangeAgentStateAction)
|
||||
assert action.agent_state == AgentState.PAUSED
|
||||
assert source == EventSource.USER
|
||||
|
||||
# Check that is_paused was cleared
|
||||
assert not is_paused.is_set()
|
||||
|
||||
# Run the test function
|
||||
await test_func()
|
||||
|
||||
|
||||
class TestCliCommandsPauseResume:
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli_commands.handle_resume_command')
|
||||
async def test_handle_commands_resume(self, mock_handle_resume):
|
||||
"""Test that the handle_commands function properly calls handle_resume_command."""
|
||||
# Import the handle_commands function
|
||||
from openhands.core.cli_commands import handle_commands
|
||||
|
||||
# Set up mocks
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock()
|
||||
sid = 'test-session-id'
|
||||
config = MagicMock()
|
||||
current_dir = '/test/dir'
|
||||
settings_store = MagicMock()
|
||||
|
||||
# Set the return value for handle_resume_command
|
||||
mock_handle_resume.return_value = (False, False)
|
||||
|
||||
# Call handle_commands with the resume command
|
||||
close_repl, reload_microagents, new_session_requested = await handle_commands(
|
||||
'/resume',
|
||||
event_stream,
|
||||
usage_metrics,
|
||||
sid,
|
||||
config,
|
||||
current_dir,
|
||||
settings_store,
|
||||
)
|
||||
|
||||
# Check that handle_resume_command was called with the correct arguments
|
||||
mock_handle_resume.assert_called_once_with(event_stream)
|
||||
|
||||
# Check the return values
|
||||
assert close_repl is False
|
||||
assert reload_microagents is False
|
||||
assert new_session_requested is False
|
||||
|
||||
|
||||
class TestAgentStatePauseResume:
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli.display_agent_running_message')
|
||||
@patch('openhands.core.cli.process_agent_pause')
|
||||
async def test_agent_running_enables_pause(
|
||||
self, mock_process_agent_pause, mock_display_message
|
||||
):
|
||||
"""Test that when the agent is running, pause functionality is enabled."""
|
||||
# Create mock dependencies
|
||||
event = MagicMock()
|
||||
# AgentStateChangedObservation requires a content parameter
|
||||
event.observation = AgentStateChangedObservation(
|
||||
agent_state=AgentState.RUNNING, content='Agent state changed to RUNNING'
|
||||
)
|
||||
|
||||
# Create a context similar to run_session to call on_event_async
|
||||
loop = MagicMock()
|
||||
is_paused = asyncio.Event()
|
||||
config = MagicMock()
|
||||
config.security.confirmation_mode = False
|
||||
|
||||
# Create a closure to capture the current context
|
||||
async def test_func():
|
||||
# Call our simplified on_event_async
|
||||
async def on_event_async_test(event):
|
||||
if isinstance(event.observation, AgentStateChangedObservation):
|
||||
if event.observation.agent_state == AgentState.RUNNING:
|
||||
# Enable pause/resume functionality only if the confirmation mode is disabled
|
||||
if not config.security.confirmation_mode:
|
||||
mock_display_message()
|
||||
loop.create_task(mock_process_agent_pause(is_paused))
|
||||
|
||||
# Call the function
|
||||
await on_event_async_test(event)
|
||||
|
||||
# Check that the message was displayed
|
||||
mock_display_message.assert_called_once()
|
||||
|
||||
# Check that process_agent_pause was called with the right arguments
|
||||
mock_process_agent_pause.assert_called_once_with(is_paused)
|
||||
|
||||
# Check that loop.create_task was called
|
||||
loop.create_task.assert_called_once()
|
||||
|
||||
# Run the test function
|
||||
await test_func()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.core.cli.display_event')
|
||||
@patch('openhands.core.cli.update_usage_metrics')
|
||||
async def test_pause_event_changes_agent_state(
|
||||
self, mock_update_metrics, mock_display_event
|
||||
):
|
||||
"""Test that when is_paused is set, a PAUSED state change event is added to the stream."""
|
||||
# Create mock dependencies
|
||||
event = MagicMock()
|
||||
event_stream = MagicMock()
|
||||
is_paused = asyncio.Event()
|
||||
config = MagicMock()
|
||||
reload_microagents = False
|
||||
|
||||
# Set the pause event
|
||||
is_paused.set()
|
||||
|
||||
# Create a closure to capture the current context
|
||||
async def test_func():
|
||||
# Create a context similar to run_session to call on_event_async
|
||||
async def on_event_async_test(event):
|
||||
nonlocal reload_microagents
|
||||
mock_display_event(event, config)
|
||||
mock_update_metrics(event, MagicMock())
|
||||
|
||||
# Pause the agent if the pause event is set (through Ctrl-P)
|
||||
if is_paused.is_set():
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.PAUSED),
|
||||
EventSource.USER,
|
||||
)
|
||||
is_paused.clear()
|
||||
|
||||
# Call the function
|
||||
await on_event_async_test(event)
|
||||
|
||||
# Check that the event_stream.add_event was called with the correct action
|
||||
event_stream.add_event.assert_called_once()
|
||||
args, kwargs = event_stream.add_event.call_args
|
||||
action, source = args
|
||||
|
||||
assert isinstance(action, ChangeAgentStateAction)
|
||||
assert action.agent_state == AgentState.PAUSED
|
||||
assert source == EventSource.USER
|
||||
|
||||
# Check that is_paused was cleared
|
||||
assert not is_paused.is_set()
|
||||
|
||||
# Run the test
|
||||
await test_func()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paused_agent_awaits_input(self):
|
||||
"""Test that when the agent is paused, it awaits user input."""
|
||||
# Create mock dependencies
|
||||
event = MagicMock()
|
||||
# AgentStateChangedObservation requires a content parameter
|
||||
event.observation = AgentStateChangedObservation(
|
||||
agent_state=AgentState.PAUSED, content='Agent state changed to PAUSED'
|
||||
)
|
||||
reload_microagents = False
|
||||
memory = MagicMock()
|
||||
runtime = MagicMock()
|
||||
prompt_task = MagicMock()
|
||||
|
||||
# Create a closure to capture the current context
|
||||
async def test_func():
|
||||
# Create a simplified version of on_event_async
|
||||
async def on_event_async_test(event):
|
||||
nonlocal reload_microagents, prompt_task
|
||||
|
||||
if isinstance(event.observation, AgentStateChangedObservation):
|
||||
if event.observation.agent_state in [
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
AgentState.PAUSED,
|
||||
]:
|
||||
# Reload microagents after initialization of repo.md
|
||||
if reload_microagents:
|
||||
microagents = runtime.get_microagents_from_selected_repo(
|
||||
None
|
||||
)
|
||||
memory.load_user_workspace_microagents(microagents)
|
||||
reload_microagents = False
|
||||
|
||||
# Since prompt_for_next_task is a nested function in cli.py,
|
||||
# we'll just check that we've reached this code path
|
||||
prompt_task = 'Prompt for next task would be called here'
|
||||
|
||||
# Call the function
|
||||
await on_event_async_test(event)
|
||||
|
||||
# Check that we reached the code path where prompt_for_next_task would be called
|
||||
assert prompt_task == 'Prompt for next task would be called here'
|
||||
|
||||
# Run the test
|
||||
await test_func()
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
from openhands.core.cli_tui import (
|
||||
@@ -51,9 +52,12 @@ class TestDisplayFunctions:
|
||||
|
||||
@patch('openhands.core.cli_tui.print_formatted_text')
|
||||
def test_display_banner(self, mock_print):
|
||||
# Create a mock loaded event
|
||||
is_loaded = asyncio.Event()
|
||||
is_loaded.set()
|
||||
session_id = 'test-session-id'
|
||||
|
||||
display_banner(session_id)
|
||||
display_banner(session_id, is_loaded)
|
||||
|
||||
# Verify banner calls
|
||||
assert mock_print.call_count >= 3
|
||||
|
||||
@@ -100,7 +100,6 @@ def test_process_events_with_message_action(conversation_memory):
|
||||
# Process events
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[system_message, user_message, assistant_message],
|
||||
initial_user_action=user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
@@ -109,178 +108,10 @@ def test_process_events_with_message_action(conversation_memory):
|
||||
assert len(messages) == 3
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[0].content[0].text == 'System message'
|
||||
|
||||
|
||||
# Test cases for _ensure_system_message
|
||||
def test_ensure_system_message_adds_if_missing(conversation_memory):
|
||||
"""Test that _ensure_system_message adds a system message if none exists."""
|
||||
user_message = MessageAction(content='User message')
|
||||
user_message._source = EventSource.USER
|
||||
events = [user_message]
|
||||
conversation_memory._ensure_system_message(events)
|
||||
assert len(events) == 2
|
||||
assert isinstance(events[0], SystemMessageAction)
|
||||
assert events[0].content == 'System message' # From fixture
|
||||
assert isinstance(events[1], MessageAction) # Original event is still there
|
||||
|
||||
|
||||
def test_ensure_system_message_does_nothing_if_present(conversation_memory):
|
||||
"""Test that _ensure_system_message does nothing if a system message is already present."""
|
||||
original_system_message = SystemMessageAction(content='Existing system message')
|
||||
user_message = MessageAction(content='User message')
|
||||
user_message._source = EventSource.USER
|
||||
events = [
|
||||
original_system_message,
|
||||
user_message,
|
||||
]
|
||||
original_events = list(events) # Copy before modification
|
||||
conversation_memory._ensure_system_message(events)
|
||||
assert events == original_events # List should be unchanged
|
||||
|
||||
|
||||
# Test cases for _ensure_initial_user_message
|
||||
@pytest.fixture
|
||||
def initial_user_action():
|
||||
msg = MessageAction(content='Initial User Message')
|
||||
msg._source = EventSource.USER
|
||||
return msg
|
||||
|
||||
|
||||
def test_ensure_initial_user_message_adds_if_only_system(
|
||||
conversation_memory, initial_user_action
|
||||
):
|
||||
"""Test adding the initial user message when only the system message exists."""
|
||||
system_message = SystemMessageAction(content='System')
|
||||
system_message._source = EventSource.AGENT
|
||||
events = [system_message]
|
||||
conversation_memory._ensure_initial_user_message(events, initial_user_action)
|
||||
assert len(events) == 2
|
||||
assert events[0] == system_message
|
||||
assert events[1] == initial_user_action
|
||||
|
||||
|
||||
def test_ensure_initial_user_message_correct_already_present(
|
||||
conversation_memory, initial_user_action
|
||||
):
|
||||
"""Test that nothing changes if the correct initial user message is at index 1."""
|
||||
system_message = SystemMessageAction(content='System')
|
||||
agent_message = MessageAction(content='Assistant')
|
||||
agent_message._source = EventSource.USER
|
||||
events = [
|
||||
system_message,
|
||||
initial_user_action,
|
||||
agent_message,
|
||||
]
|
||||
original_events = list(events)
|
||||
conversation_memory._ensure_initial_user_message(events, initial_user_action)
|
||||
assert events == original_events
|
||||
|
||||
|
||||
def test_ensure_initial_user_message_incorrect_at_index_1(
|
||||
conversation_memory, initial_user_action
|
||||
):
|
||||
"""Test inserting the correct initial user message when an incorrect message is at index 1."""
|
||||
system_message = SystemMessageAction(content='System')
|
||||
incorrect_second_message = MessageAction(content='Assistant')
|
||||
incorrect_second_message._source = EventSource.AGENT
|
||||
events = [system_message, incorrect_second_message]
|
||||
conversation_memory._ensure_initial_user_message(events, initial_user_action)
|
||||
assert len(events) == 3
|
||||
assert events[0] == system_message
|
||||
assert events[1] == initial_user_action # Correct one inserted
|
||||
assert events[2] == incorrect_second_message # Original second message shifted
|
||||
|
||||
|
||||
def test_ensure_initial_user_message_correct_present_later(
|
||||
conversation_memory, initial_user_action
|
||||
):
|
||||
"""Test inserting the correct initial user message at index 1 even if it exists later."""
|
||||
system_message = SystemMessageAction(content='System')
|
||||
incorrect_second_message = MessageAction(content='Assistant')
|
||||
incorrect_second_message._source = EventSource.AGENT
|
||||
# Correct initial message is present, but later in the list
|
||||
events = [system_message, incorrect_second_message]
|
||||
conversation_memory._ensure_system_message(events)
|
||||
conversation_memory._ensure_initial_user_message(events, initial_user_action)
|
||||
assert len(events) == 3 # Should still insert at index 1, not remove the later one
|
||||
assert events[0] == system_message
|
||||
assert events[1] == initial_user_action # Correct one inserted at index 1
|
||||
assert events[2] == incorrect_second_message # Original second message shifted
|
||||
# The duplicate initial_user_action originally at index 2 is now at index 3 (implicitly tested by length and content)
|
||||
|
||||
|
||||
def test_ensure_initial_user_message_different_user_msg_at_index_1(
|
||||
conversation_memory, initial_user_action
|
||||
):
|
||||
"""Test inserting the correct initial user message when a *different* user message is at index 1."""
|
||||
system_message = SystemMessageAction(content='System')
|
||||
different_user_message = MessageAction(content='Different User Message')
|
||||
different_user_message._source = EventSource.USER
|
||||
events = [system_message, different_user_message]
|
||||
conversation_memory._ensure_initial_user_message(events, initial_user_action)
|
||||
assert len(events) == 2
|
||||
assert events[0] == system_message
|
||||
assert events[1] == different_user_message # Original second message remains
|
||||
|
||||
|
||||
def test_ensure_initial_user_message_different_user_msg_at_index_1_and_orphaned_obs(
|
||||
conversation_memory, initial_user_action
|
||||
):
|
||||
"""
|
||||
Test process_events when an incorrect user message is at index 1 AND
|
||||
an orphaned observation (with tool_call_metadata but no matching action) exists.
|
||||
Expect: System msg, CORRECT initial user msg, the incorrect user msg (shifted).
|
||||
The orphaned observation should be filtered out.
|
||||
"""
|
||||
system_message = SystemMessageAction(content='System')
|
||||
different_user_message = MessageAction(content='Different User Message')
|
||||
different_user_message._source = EventSource.USER
|
||||
|
||||
# Create an orphaned observation (no matching action/tool call request will exist)
|
||||
# Use a dictionary that mimics ModelResponse structure to satisfy Pydantic
|
||||
mock_response = {
|
||||
'id': 'mock_response_id',
|
||||
'choices': [{'message': {'content': None, 'tool_calls': []}}],
|
||||
'created': 0,
|
||||
'model': '',
|
||||
'object': '',
|
||||
'usage': {'completion_tokens': 0, 'prompt_tokens': 0, 'total_tokens': 0},
|
||||
}
|
||||
orphaned_obs = CmdOutputObservation(
|
||||
command='orphan_cmd',
|
||||
content='Orphaned output',
|
||||
command_id=99,
|
||||
exit_code=0,
|
||||
)
|
||||
orphaned_obs.tool_call_metadata = ToolCallMetadata(
|
||||
tool_call_id='orphan_call_id',
|
||||
function_name='execute_bash',
|
||||
model_response=mock_response,
|
||||
total_calls_in_response=1,
|
||||
)
|
||||
|
||||
# Initial events list: system, wrong user message, orphaned observation
|
||||
events = [system_message, different_user_message, orphaned_obs]
|
||||
|
||||
# Call the main process_events method
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=events,
|
||||
initial_user_action=initial_user_action, # Provide the *correct* initial action
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Assertions on the final messages list
|
||||
assert len(messages) == 2
|
||||
# 1. System message should be first
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[0].content[0].text == 'System'
|
||||
|
||||
# 2. The different user message should be left at index 1
|
||||
assert messages[1].role == 'user'
|
||||
assert messages[1].content[0].text == different_user_message.content
|
||||
|
||||
# Implicitly assert that the orphaned_obs was filtered out by checking the length (2)
|
||||
assert messages[1].content[0].text == 'Hello'
|
||||
assert messages[2].role == 'assistant'
|
||||
assert messages[2].content[0].text == 'Hi there'
|
||||
|
||||
|
||||
def test_process_events_with_cmd_output_observation(conversation_memory):
|
||||
@@ -294,17 +125,14 @@ def test_process_events_with_cmd_output_observation(conversation_memory):
|
||||
),
|
||||
)
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + initial user + result
|
||||
result = messages[2] # The actual result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -320,17 +148,14 @@ def test_process_events_with_ipython_run_cell_observation(conversation_memory):
|
||||
content='IPython output\n',
|
||||
)
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + initial user + result
|
||||
result = messages[2] # The actual result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -347,17 +172,14 @@ def test_process_events_with_agent_delegate_observation(conversation_memory):
|
||||
content='Content', outputs={'content': 'Delegated agent output'}
|
||||
)
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + initial user + result
|
||||
result = messages[2] # The actual result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -367,17 +189,14 @@ def test_process_events_with_agent_delegate_observation(conversation_memory):
|
||||
def test_process_events_with_error_observation(conversation_memory):
|
||||
obs = ErrorObservation('Error message')
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + initial user + result
|
||||
result = messages[2] # The actual result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -388,13 +207,10 @@ def test_process_events_with_error_observation(conversation_memory):
|
||||
def test_process_events_with_unknown_observation(conversation_memory):
|
||||
# Create a mock that inherits from Event but not Action or Observation
|
||||
obs = Mock(spec=Event)
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
|
||||
with pytest.raises(ValueError, match='Unknown event type'):
|
||||
conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
@@ -410,17 +226,14 @@ def test_process_events_with_file_edit_observation(conversation_memory):
|
||||
impl_source=FileEditSource.LLM_BASED_EDIT,
|
||||
)
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + initial user + result
|
||||
result = messages[2] # The actual result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -434,21 +247,18 @@ def test_process_events_with_file_read_observation(conversation_memory):
|
||||
impl_source=FileReadSource.DEFAULT,
|
||||
)
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + initial user + result
|
||||
result = messages[2] # The actual result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == '\n\nFile content'
|
||||
assert result.content[0].text == 'File content'
|
||||
|
||||
|
||||
def test_process_events_with_browser_output_observation(conversation_memory):
|
||||
@@ -460,17 +270,14 @@ def test_process_events_with_browser_output_observation(conversation_memory):
|
||||
error=False,
|
||||
)
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + initial user + result
|
||||
result = messages[2] # The actual result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -480,17 +287,14 @@ def test_process_events_with_browser_output_observation(conversation_memory):
|
||||
def test_process_events_with_user_reject_observation(conversation_memory):
|
||||
obs = UserRejectObservation('Action rejected')
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + initial user + result
|
||||
result = messages[2] # The actual result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -513,17 +317,14 @@ def test_process_events_with_empty_environment_info(conversation_memory):
|
||||
content='Retrieved environment info',
|
||||
)
|
||||
|
||||
initial_user_message = MessageAction(content='Initial user message')
|
||||
initial_user_message._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[empty_obs],
|
||||
initial_user_action=initial_user_message,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Should only contain system message and initial user message
|
||||
assert len(messages) == 2
|
||||
# Should only contain no messages except system message
|
||||
assert len(messages) == 1
|
||||
|
||||
# Verify that build_workspace_context was NOT called since all input values were empty
|
||||
conversation_memory.prompt_manager.build_workspace_context.assert_not_called()
|
||||
@@ -547,20 +348,14 @@ def test_process_events_with_function_calling_observation(conversation_memory):
|
||||
model_response=mock_response,
|
||||
total_calls_in_response=1,
|
||||
)
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# No direct message when using function calling
|
||||
assert (
|
||||
len(messages) == 2
|
||||
) # should be no messages except system message and initial user message
|
||||
assert len(messages) == 1 # should be no messages except system message
|
||||
|
||||
|
||||
def test_process_events_with_message_action_with_image(conversation_memory):
|
||||
@@ -570,18 +365,14 @@ def test_process_events_with_message_action_with_image(conversation_memory):
|
||||
)
|
||||
action._source = EventSource.AGENT
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[action],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=True,
|
||||
)
|
||||
|
||||
assert len(messages) == 3
|
||||
result = messages[2]
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'assistant'
|
||||
assert len(result.content) == 2
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -594,18 +385,14 @@ def test_process_events_with_user_cmd_action(conversation_memory):
|
||||
action = CmdRunAction(command='ls -l')
|
||||
action._source = EventSource.USER
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[action],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3
|
||||
result = messages[2]
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -631,18 +418,14 @@ def test_process_events_with_agent_finish_action_with_tool_metadata(
|
||||
total_calls_in_response=1,
|
||||
)
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[action],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3
|
||||
result = messages[2]
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'assistant'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -678,22 +461,18 @@ def test_process_events_with_environment_microagent_observation(conversation_mem
|
||||
content='Retrieved environment info',
|
||||
)
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3
|
||||
result = messages[2]
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == '\n\nFormatted repository and runtime info'
|
||||
assert result.content[0].text == 'Formatted repository and runtime info'
|
||||
|
||||
# Verify the prompt_manager was called with the correct parameters
|
||||
conversation_memory.prompt_manager.build_workspace_context.assert_called_once()
|
||||
@@ -737,18 +516,14 @@ def test_process_events_with_knowledge_microagent_microagent_observation(
|
||||
content='Retrieved knowledge from microagents',
|
||||
)
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
assert len(messages) == 3 # System + Initial User + Result
|
||||
result = messages[2] # Result is now at index 2
|
||||
assert len(messages) == 2
|
||||
result = messages[1]
|
||||
assert result.role == 'user'
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
@@ -784,18 +559,14 @@ def test_process_events_with_microagent_observation_extensions_disabled(
|
||||
content='Retrieved environment info',
|
||||
)
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# When prompt extensions are disabled, the RecallObservation should be ignored
|
||||
assert len(messages) == 2 # System + Initial User
|
||||
assert len(messages) == 1 # should be no messages except system message
|
||||
|
||||
# Verify the prompt_manager was not called
|
||||
conversation_memory.prompt_manager.build_workspace_context.assert_not_called()
|
||||
@@ -810,18 +581,14 @@ def test_process_events_with_empty_microagent_knowledge(conversation_memory):
|
||||
content='Retrieved knowledge from microagents',
|
||||
)
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# The implementation returns an empty string and it doesn't creates a message
|
||||
assert len(messages) == 2 # System + Initial User
|
||||
assert len(messages) == 1 # should be no messages except system message
|
||||
|
||||
# When there are no triggered agents, build_microagent_info is not called
|
||||
conversation_memory.prompt_manager.build_microagent_info.assert_not_called()
|
||||
@@ -1026,23 +793,19 @@ def test_process_events_with_microagent_observation_deduplication(conversation_m
|
||||
content='Third retrieval',
|
||||
)
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs1, obs2, obs3],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Verify that only the first occurrence of content for each agent is included
|
||||
assert len(messages) == 3 # System + Initial User + Result
|
||||
# Result is now at index 2
|
||||
assert len(messages) == 2 # with system message
|
||||
|
||||
# First microagent should include all agents since they appear here first
|
||||
assert 'Image best practices v1' in messages[2].content[0].text
|
||||
assert 'Git best practices v1' in messages[2].content[0].text
|
||||
assert 'Python best practices v1' in messages[2].content[0].text
|
||||
assert 'Image best practices v1' in messages[1].content[0].text
|
||||
assert 'Git best practices v1' in messages[1].content[0].text
|
||||
assert 'Python best practices v1' in messages[1].content[0].text
|
||||
|
||||
|
||||
def test_process_events_with_microagent_observation_deduplication_disabled_agents(
|
||||
@@ -1079,22 +842,18 @@ def test_process_events_with_microagent_observation_deduplication_disabled_agent
|
||||
content='Second retrieval',
|
||||
)
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs1, obs2],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Verify that disabled agents are filtered out and only the first occurrence of enabled agents is included
|
||||
assert len(messages) == 3 # System + Initial User + Result
|
||||
# Result is now at index 2
|
||||
assert len(messages) == 2
|
||||
|
||||
# First microagent should include enabled_agent but not disabled_agent
|
||||
assert 'Disabled agent content' not in messages[2].content[0].text
|
||||
assert 'Enabled agent content v1' in messages[2].content[0].text
|
||||
assert 'Disabled agent content' not in messages[1].content[0].text
|
||||
assert 'Enabled agent content v1' in messages[1].content[0].text
|
||||
|
||||
|
||||
def test_process_events_with_microagent_observation_deduplication_empty(
|
||||
@@ -1107,22 +866,17 @@ def test_process_events_with_microagent_observation_deduplication_empty(
|
||||
content='Empty retrieval',
|
||||
)
|
||||
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[obs],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Verify that empty RecallObservations are handled gracefully
|
||||
assert (
|
||||
len(messages) == 2 # System + Initial User
|
||||
) # an empty microagent is not added to Messages
|
||||
len(messages) == 1
|
||||
) # an empty microagent is not added to Messages, only system message is found
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[1].role == 'user' # Initial user message
|
||||
|
||||
|
||||
def test_has_agent_in_earlier_events(conversation_memory):
|
||||
@@ -1334,183 +1088,13 @@ def test_system_message_in_events(conversation_memory):
|
||||
system_message._source = EventSource.AGENT
|
||||
|
||||
# Process events with the system message in condensed_history
|
||||
# Define initial user action
|
||||
initial_user_action = MessageAction(content='Initial user message')
|
||||
initial_user_action._source = EventSource.USER
|
||||
messages = conversation_memory.process_events(
|
||||
condensed_history=[system_message],
|
||||
initial_user_action=initial_user_action,
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Check that the system message was processed correctly
|
||||
assert len(messages) == 2 # System + Initial User
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == 'system'
|
||||
assert messages[0].content[0].text == 'System message'
|
||||
assert messages[1].role == 'user' # Initial user message
|
||||
|
||||
|
||||
# Helper function to create mock tool call metadata
|
||||
def _create_mock_tool_call_metadata(
|
||||
tool_call_id: str, function_name: str, response_id: str = 'mock_response_id'
|
||||
) -> ToolCallMetadata:
|
||||
# Use a dictionary that mimics ModelResponse structure to satisfy Pydantic
|
||||
mock_response = {
|
||||
'id': response_id,
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'role': 'assistant',
|
||||
'content': None, # Content is None for tool calls
|
||||
'tool_calls': [
|
||||
{
|
||||
'id': tool_call_id,
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': function_name,
|
||||
'arguments': '{}',
|
||||
}, # Args don't matter for this test
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
],
|
||||
'created': 0,
|
||||
'model': 'mock_model',
|
||||
'object': 'chat.completion',
|
||||
'usage': {'completion_tokens': 0, 'prompt_tokens': 0, 'total_tokens': 0},
|
||||
}
|
||||
return ToolCallMetadata(
|
||||
tool_call_id=tool_call_id,
|
||||
function_name=function_name,
|
||||
model_response=mock_response,
|
||||
total_calls_in_response=1,
|
||||
)
|
||||
|
||||
|
||||
def test_process_events_partial_history(conversation_memory):
|
||||
"""
|
||||
Tests process_events with full and partial histories to verify
|
||||
_ensure_system_message, _ensure_initial_user_message, and tool call matching logic.
|
||||
"""
|
||||
# --- Define Common Events ---
|
||||
system_message = SystemMessageAction(content='System message')
|
||||
system_message._source = EventSource.AGENT
|
||||
|
||||
user_message = MessageAction(
|
||||
content='Initial user query'
|
||||
) # This is the crucial initial_user_action
|
||||
user_message._source = EventSource.USER
|
||||
|
||||
recall_obs = RecallObservation(
|
||||
recall_type=RecallType.WORKSPACE_CONTEXT,
|
||||
repo_name='test-repo',
|
||||
repo_directory='/path/to/repo',
|
||||
content='Retrieved environment info',
|
||||
)
|
||||
recall_obs._source = EventSource.AGENT
|
||||
|
||||
cmd_action = CmdRunAction(command='ls', thought='Running ls')
|
||||
cmd_action._source = EventSource.AGENT
|
||||
cmd_action.tool_call_metadata = _create_mock_tool_call_metadata(
|
||||
tool_call_id='call_ls_1', function_name='execute_bash', response_id='resp_ls_1'
|
||||
)
|
||||
|
||||
cmd_obs = CmdOutputObservation(
|
||||
command_id=1, command='ls', content='file1.txt\nfile2.py', exit_code=0
|
||||
)
|
||||
cmd_obs._source = EventSource.AGENT
|
||||
cmd_obs.tool_call_metadata = _create_mock_tool_call_metadata(
|
||||
tool_call_id='call_ls_1', function_name='execute_bash', response_id='resp_ls_1'
|
||||
)
|
||||
|
||||
# --- Scenario 1: Full History ---
|
||||
full_history: list[Event] = [
|
||||
system_message,
|
||||
user_message, # Correct initial user message at index 1
|
||||
recall_obs,
|
||||
cmd_action,
|
||||
cmd_obs,
|
||||
]
|
||||
messages_full = conversation_memory.process_events(
|
||||
condensed_history=list(full_history), # Pass a copy
|
||||
initial_user_action=user_message, # Provide the initial action
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Expected: System, User, Recall (formatted), Assistant (tool call), Tool Response
|
||||
assert len(messages_full) == 5
|
||||
assert messages_full[0].role == 'system'
|
||||
assert messages_full[0].content[0].text == 'System message'
|
||||
assert messages_full[1].role == 'user'
|
||||
assert messages_full[1].content[0].text == 'Initial user query'
|
||||
assert messages_full[2].role == 'user' # Recall obs becomes user message
|
||||
assert (
|
||||
'Formatted repository and runtime info' in messages_full[2].content[0].text
|
||||
) # From fixture mock
|
||||
assert messages_full[3].role == 'assistant'
|
||||
assert messages_full[3].tool_calls is not None
|
||||
assert len(messages_full[3].tool_calls) == 1
|
||||
assert messages_full[3].tool_calls[0].id == 'call_ls_1'
|
||||
assert messages_full[4].role == 'tool'
|
||||
assert messages_full[4].tool_call_id == 'call_ls_1'
|
||||
assert 'file1.txt' in messages_full[4].content[0].text
|
||||
|
||||
# --- Scenario 2: Partial History (Action + Observation) ---
|
||||
# Simulates processing only the last action/observation pair
|
||||
partial_history_action_obs: list[Event] = [
|
||||
cmd_action,
|
||||
cmd_obs,
|
||||
]
|
||||
messages_partial_action_obs = conversation_memory.process_events(
|
||||
condensed_history=list(partial_history_action_obs), # Pass a copy
|
||||
initial_user_action=user_message, # Provide the initial action
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Expected: System (added), Initial User (added), Assistant (tool call), Tool Response
|
||||
assert len(messages_partial_action_obs) == 4
|
||||
assert (
|
||||
messages_partial_action_obs[0].role == 'system'
|
||||
) # Added by _ensure_system_message
|
||||
assert messages_partial_action_obs[0].content[0].text == 'System message'
|
||||
assert (
|
||||
messages_partial_action_obs[1].role == 'user'
|
||||
) # Added by _ensure_initial_user_message
|
||||
assert messages_partial_action_obs[1].content[0].text == 'Initial user query'
|
||||
assert messages_partial_action_obs[2].role == 'assistant'
|
||||
assert messages_partial_action_obs[2].tool_calls is not None
|
||||
assert len(messages_partial_action_obs[2].tool_calls) == 1
|
||||
assert messages_partial_action_obs[2].tool_calls[0].id == 'call_ls_1'
|
||||
assert messages_partial_action_obs[3].role == 'tool'
|
||||
assert messages_partial_action_obs[3].tool_call_id == 'call_ls_1'
|
||||
assert 'file1.txt' in messages_partial_action_obs[3].content[0].text
|
||||
|
||||
# --- Scenario 3: Partial History (Observation Only) ---
|
||||
# Simulates processing only the last observation
|
||||
partial_history_obs_only: list[Event] = [
|
||||
cmd_obs,
|
||||
]
|
||||
messages_partial_obs_only = conversation_memory.process_events(
|
||||
condensed_history=list(partial_history_obs_only), # Pass a copy
|
||||
initial_user_action=user_message, # Provide the initial action
|
||||
max_message_chars=None,
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Expected: System (added), Initial User (added).
|
||||
# The CmdOutputObservation has tool_call_metadata, but there's no corresponding
|
||||
# assistant message (from CmdRunAction) with the matching tool_call.id in the input history.
|
||||
# Therefore, _filter_unmatched_tool_calls should remove the tool response message.
|
||||
assert len(messages_partial_obs_only) == 2
|
||||
assert (
|
||||
messages_partial_obs_only[0].role == 'system'
|
||||
) # Added by _ensure_system_message
|
||||
assert messages_partial_obs_only[0].content[0].text == 'System message'
|
||||
assert (
|
||||
messages_partial_obs_only[1].role == 'user'
|
||||
) # Added by _ensure_initial_user_message
|
||||
assert messages_partial_obs_only[1].content[0].text == 'Initial user query'
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_get_messages(codeact_agent: CodeActAgent):
|
||||
history.append(message_action_5)
|
||||
|
||||
codeact_agent.reset()
|
||||
messages = codeact_agent._get_messages(history, message_action_1)
|
||||
messages = codeact_agent._get_messages(history)
|
||||
|
||||
assert (
|
||||
len(messages) == 6
|
||||
@@ -106,19 +106,16 @@ def test_get_messages_prompt_caching(codeact_agent: CodeActAgent):
|
||||
history.append(system_message_action)
|
||||
|
||||
# Add multiple user and agent messages
|
||||
initial_user_message = None # Keep track of the first user message
|
||||
for i in range(15):
|
||||
message_action_user = MessageAction(f'User message {i}')
|
||||
message_action_user._source = 'user'
|
||||
if initial_user_message is None:
|
||||
initial_user_message = message_action_user # Store the first one
|
||||
history.append(message_action_user)
|
||||
message_action_agent = MessageAction(f'Agent message {i}')
|
||||
message_action_agent._source = 'agent'
|
||||
history.append(message_action_agent)
|
||||
|
||||
codeact_agent.reset()
|
||||
messages = codeact_agent._get_messages(history, initial_user_message)
|
||||
messages = codeact_agent._get_messages(history)
|
||||
|
||||
# Check that only the last two user messages have cache_prompt=True
|
||||
cached_user_messages = [
|
||||
|
||||
Reference in New Issue
Block a user