mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
49 Commits
openhands/
...
openhands-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
154907dd28 | ||
|
|
2631294e79 | ||
|
|
47776ae2ad | ||
|
|
0ad411e162 | ||
|
|
7bc56e0d74 | ||
|
|
e450a3a603 | ||
|
|
17e32af6fe | ||
|
|
4b303ec9b4 | ||
|
|
eb954164a5 | ||
|
|
0c1c2163b1 | ||
|
|
dd2a62c992 | ||
|
|
f3d9faef34 | ||
|
|
134c122026 | ||
|
|
523b40dbfc | ||
|
|
6a5b915088 | ||
|
|
a5c5133961 | ||
|
|
eea1e7f4e1 | ||
|
|
e2d990f3a0 | ||
|
|
f258eafa37 | ||
|
|
19634f364e | ||
|
|
aa6446038c | ||
|
|
dbddc1868e | ||
|
|
cd967ef4bc | ||
|
|
e34c13ea3c | ||
|
|
1f35a73cc4 | ||
|
|
267528fa82 | ||
|
|
49f360d021 | ||
|
|
9520da668c | ||
|
|
9d19292619 | ||
|
|
fc9a87550d | ||
|
|
490d3dba10 | ||
|
|
5ed1dde2e9 | ||
|
|
a68576b876 | ||
|
|
722124ae83 | ||
|
|
44578664ed | ||
|
|
9efe6eb776 | ||
|
|
6d137e883f | ||
|
|
2889f736d9 | ||
|
|
531683abae | ||
|
|
fab64a51b7 | ||
|
|
cc18a18874 | ||
|
|
7525a95af0 | ||
|
|
640f50d525 | ||
|
|
6f2f85073d | ||
|
|
9f3b2425ec | ||
|
|
1ebc3ab04e | ||
|
|
9bd0566e4e | ||
|
|
d82972e126 | ||
|
|
e1b94732a8 |
33
.github/pull_request_template.md
vendored
33
.github/pull_request_template.md
vendored
@@ -1,12 +1,31 @@
|
||||
- [ ] This change is worth documenting at https://docs.all-hands.dev/
|
||||
- [ ] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below
|
||||
## Summary of PR
|
||||
|
||||
**End-user friendly description of the problem this fixes or functionality this introduces.**
|
||||
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
|
||||
|
||||
## Change Type
|
||||
|
||||
---
|
||||
**Summarize what the PR does, explaining any non-trivial design decisions.**
|
||||
<!-- Choose the types that apply to your PR and remove the rest. -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Refactor
|
||||
- [ ] Other (dependency update, docs, typo fixes, etc.)
|
||||
|
||||
---
|
||||
**Link of any specific issues this addresses:**
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read and reviewed the code and I understand what the code is doing.
|
||||
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
|
||||
|
||||
## Fixes
|
||||
|
||||
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
|
||||
|
||||
Resolves #(issue)
|
||||
|
||||
## Release Notes
|
||||
|
||||
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
|
||||
end-user friendly description for your change below the checkbox. -->
|
||||
|
||||
- [ ] Include this change in the Release Notes.
|
||||
|
||||
4
.github/workflows/ghcr-build.yml
vendored
4
.github/workflows/ghcr-build.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
else
|
||||
json=$(jq -n -c '[
|
||||
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
|
||||
{ image: "ghcr.io/all-hands-ai/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
|
||||
{ image: "ghcr.io/openhands/python-nodejs:python3.13-nodejs22-trixie", tag: "trixie" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
]')
|
||||
fi
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/all-hands-ai/enterprise-server
|
||||
images: ghcr.io/openhands/enterprise-server
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
|
||||
16
.vscode/settings.json
vendored
16
.vscode/settings.json
vendored
@@ -3,4 +3,20 @@
|
||||
"files.eol": "\n",
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"files.insertFinalNewline": true,
|
||||
|
||||
"python.defaultInterpreterPath": "./.venv/bin/python",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.autoSearchPaths": true,
|
||||
"python.analysis.extraPaths": [
|
||||
"./.venv/lib/python3.12/site-packages"
|
||||
],
|
||||
"python.analysis.packageIndexDepths": [
|
||||
{
|
||||
"name": "openhands",
|
||||
"depth": 10,
|
||||
"includeAllSymbols": true
|
||||
}
|
||||
],
|
||||
"python.analysis.stubPath": "./.venv/lib/python3.12/site-packages",
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.59-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.59-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
32
README.md
32
README.md
@@ -55,9 +55,35 @@ which comes with $20 in free credits for new users.
|
||||
|
||||
## 💻 Running OpenHands Locally
|
||||
|
||||
### Option 1: CLI Launcher (Recommended)
|
||||
### Option 1: Homebrew (macOS - Recommended)
|
||||
|
||||
The easiest way to run OpenHands locally is using the CLI launcher with [uv](https://docs.astral.sh/uv/). This provides better isolation from your current project's virtual environment and is required for OpenHands' default MCP servers.
|
||||
The easiest way to install OpenHands CLI on macOS is using Homebrew:
|
||||
|
||||
```bash
|
||||
# Add the OpenHands tap
|
||||
brew tap All-Hands-AI/openhands https://github.com/All-Hands-AI/OpenHands.git
|
||||
|
||||
# Install OpenHands CLI
|
||||
brew install openhands
|
||||
```
|
||||
|
||||
**Launch OpenHands**:
|
||||
```bash
|
||||
# Launch the CLI (short command)
|
||||
oh
|
||||
|
||||
# Or use the full name
|
||||
openhands
|
||||
|
||||
# Launch the GUI server
|
||||
oh serve
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000) (for GUI mode)!
|
||||
|
||||
### Option 2: CLI Launcher with uv
|
||||
|
||||
You can also run OpenHands using the CLI launcher with [uv](https://docs.astral.sh/uv/). This provides better isolation from your current project's virtual environment and is required for OpenHands' default MCP servers.
|
||||
|
||||
**Install uv** (if you haven't already):
|
||||
|
||||
@@ -74,7 +100,7 @@ uvx --python 3.12 --from openhands-ai openhands
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000) (for GUI mode)!
|
||||
|
||||
### Option 2: Docker
|
||||
### Option 3: Docker
|
||||
|
||||
<details>
|
||||
<summary>Click to expand Docker command</summary>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_ORG=all-hands-ai
|
||||
DOCKER_ORG=openhands
|
||||
DOCKER_IMAGE=openhands
|
||||
DOCKER_BASE_DIR="."
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.59-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.59-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_ORG=all-hands-ai
|
||||
DOCKER_ORG=openhands
|
||||
DOCKER_BASE_DIR="./containers/runtime"
|
||||
DOCKER_IMAGE=runtime
|
||||
# These variables will be appended by the runtime_build.py script
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ARG OPENHANDS_VERSION=latest
|
||||
ARG BASE="ghcr.io/all-hands-ai/openhands"
|
||||
ARG BASE="ghcr.io/openhands/openhands"
|
||||
FROM ${BASE}:${OPENHANDS_VERSION}
|
||||
|
||||
# Datadog labels
|
||||
|
||||
274
enterprise/enterprise_local/README.md
Normal file
274
enterprise/enterprise_local/README.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Instructions for developing SAAS locally
|
||||
|
||||
You have a few options here, which are expanded on below:
|
||||
|
||||
- A simple local development setup, with live reloading for both OSS and this repo
|
||||
- A more complex setup that includes Redis
|
||||
- An even more complex setup that includes GitHub events
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting, make sure you have the following tools installed:
|
||||
|
||||
### Required for all options:
|
||||
|
||||
- [gcloud CLI](https://cloud.google.com/sdk/docs/install) - For authentication and secrets management
|
||||
- [sops](https://github.com/mozilla/sops) - For secrets decryption
|
||||
- macOS: `brew install sops`
|
||||
- Linux: `sudo apt-get install sops` or download from GitHub releases
|
||||
- Windows: Install via Chocolatey `choco install sops` or download from GitHub releases
|
||||
|
||||
### Additional requirements for enabling GitHub webhook events
|
||||
|
||||
- make
|
||||
- Python development tools (build-essential, python3-dev)
|
||||
- [ngrok](https://ngrok.com/download) - For creating tunnels to localhost
|
||||
|
||||
## Option 1: Simple local development
|
||||
|
||||
This option will allow you to modify the both the OSS code and the code in this repo,
|
||||
and see the changes in real-time.
|
||||
|
||||
This option works best for most scenarios. The only thing it's missing is
|
||||
the GitHub events webhook, which is not necessary for most development.
|
||||
|
||||
### 1. OpenHands location
|
||||
|
||||
The open source OpenHands repo should be cloned as a sibling directory,
|
||||
in `../OpenHands`. This is hard-coded in the pyproject.toml (edit if necessary)
|
||||
|
||||
If you're doing this the first time, you may need to run
|
||||
|
||||
```
|
||||
poetry update openhands-ai
|
||||
```
|
||||
|
||||
### 2. Set up env
|
||||
|
||||
First run this to retrieve Github App secrets
|
||||
|
||||
```
|
||||
gcloud auth application-default login
|
||||
gcloud config set project global-432717
|
||||
local/decrypt_env.sh
|
||||
```
|
||||
|
||||
Now run this to generate a `.env` file, which will used to run SAAS locally
|
||||
|
||||
```
|
||||
python -m pip install PyYAML
|
||||
export LITE_LLM_API_KEY=<your LLM API key>
|
||||
python enterprise_local/convert_to_env.py
|
||||
```
|
||||
|
||||
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
|
||||
|
||||
```
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
|
||||
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
|
||||
```
|
||||
|
||||
By default the application will log in json, you can override.
|
||||
|
||||
```
|
||||
export LOG_PLAIN_TEXT=1
|
||||
```
|
||||
|
||||
### 3. Start the OpenHands frontend
|
||||
|
||||
Start the frontend like you normally would in the open source OpenHands repo.
|
||||
|
||||
### 4. Start the SaaS backend
|
||||
|
||||
```
|
||||
make build
|
||||
|
||||
make start-backend
|
||||
```
|
||||
|
||||
You should have a server running on `localhost:3000`, similar to the open source backend.
|
||||
Oauth should work properly.
|
||||
|
||||
## Option 2: With Redis
|
||||
|
||||
Follow all the steps above, then setup redis:
|
||||
|
||||
```bash
|
||||
docker run -p 6379:6379 --name openhands-redis -d redis
|
||||
export REDIS_HOST=host.docker.internal # you may want this to be localhost
|
||||
export REDIS_PORT=6379
|
||||
```
|
||||
|
||||
## Option 3: Work with GitHub events
|
||||
|
||||
### 1. Setup env file
|
||||
|
||||
(see above)
|
||||
|
||||
### 2. Build OSS Openhands
|
||||
|
||||
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
|
||||
|
||||
```
|
||||
docker build -f containers/app/Dockerfile -t openhands .
|
||||
```
|
||||
|
||||
### 3. Build SAAS Openhands
|
||||
|
||||
Build the SAAS image locally inside Deploy repo. Note that `openhands` is the name of the image built in Step 2
|
||||
|
||||
```
|
||||
docker build -t openhands-saas ./app/ --build-arg BASE="openhands"
|
||||
```
|
||||
|
||||
### 4. Create a tunnel
|
||||
|
||||
Run in a separate terminal
|
||||
|
||||
```
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
There will be a line
|
||||
|
||||
```
|
||||
Forwarding https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app
|
||||
```
|
||||
|
||||
Remember this URL as it will be used in Step 5 and 6
|
||||
|
||||
### 5. Setup Staging Github App callback/webhook urls
|
||||
|
||||
Using the URL found in Step 4, add another callback URL (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app/oauth/github/callback`)
|
||||
|
||||
### 6. Run
|
||||
|
||||
This is the last step! Run SAAS openhands locally using
|
||||
|
||||
```
|
||||
docker run --env-file ./app/.env -p 3000:3000 openhands-saas
|
||||
```
|
||||
|
||||
Note `--env-file` is what injects the `.env` file created in Step 1
|
||||
|
||||
Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app`)
|
||||
|
||||
### Local Debugging with VSCode
|
||||
|
||||
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of the OSS OpenHands project running.
|
||||
|
||||
#### Redis
|
||||
|
||||
A Local redis instance is required for clustered communication between server nodes. The standard docker instance will suffice.
|
||||
`docker run -it -p 6379:6379 --name my-redis -d redis`
|
||||
|
||||
#### Postgres
|
||||
|
||||
A Local postgres instance is required. I used the official docker image:
|
||||
`docker run -p 5432:5432 --name my-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=openhands -d postgres`
|
||||
Run the alembic migrations:
|
||||
`poetry run alembic upgrade head `
|
||||
|
||||
#### VSCode launch.json
|
||||
|
||||
The VSCode launch.json below sets up 2 servers to test clustering, running independently on localhost:3030 and localhost:3031. Running only the server on 3030 is usually sufficient unless tests of the clustered functionality are required. Secrets may be harvested directly from staging by connecting...
|
||||
`kubectl exec --stdin --tty <POD_NAME> -n <NAMESPACE> -- /bin/bash`
|
||||
And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by the time you read this, nobody will have access.)
|
||||
|
||||
```
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python Debugger: Python File",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"program": "${file}"
|
||||
},
|
||||
{
|
||||
"name": "OpenHands Deploy",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"args": [
|
||||
"saas_server:app",
|
||||
"--reload",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"3030"
|
||||
],
|
||||
"env": {
|
||||
"DEBUG": "1",
|
||||
"FILE_STORE": "local",
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
|
||||
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
|
||||
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
|
||||
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
|
||||
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
|
||||
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
|
||||
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
|
||||
},
|
||||
"justMyCode": false,
|
||||
"cwd": "${workspaceFolder}/app"
|
||||
},
|
||||
{
|
||||
"name": "OpenHands Deploy 2",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "uvicorn",
|
||||
"args": [
|
||||
"saas_server:app",
|
||||
"--reload",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"3031"
|
||||
],
|
||||
"env": {
|
||||
"DEBUG": "1",
|
||||
"FILE_STORE": "local",
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
|
||||
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
|
||||
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
|
||||
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
|
||||
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
|
||||
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
|
||||
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
|
||||
},
|
||||
"justMyCode": false,
|
||||
"cwd": "${workspaceFolder}/app"
|
||||
},
|
||||
{
|
||||
"name": "Unit Tests",
|
||||
"type": "debugpy",
|
||||
"request": "launch",
|
||||
"module": "pytest",
|
||||
"args": [
|
||||
"./tests/unit",
|
||||
//"./tests/unit/test_clustered_conversation_manager.py",
|
||||
"--durations=0"
|
||||
],
|
||||
"env": {
|
||||
"DEBUG": "1"
|
||||
},
|
||||
"justMyCode": false,
|
||||
"cwd": "${workspaceFolder}/app"
|
||||
},
|
||||
// set working directory...
|
||||
]
|
||||
}
|
||||
```
|
||||
127
enterprise/enterprise_local/convert_to_env.py
Normal file
127
enterprise/enterprise_local/convert_to_env.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def convert_yaml_to_env(yaml_file, target_parameters, output_env_file, prefix):
|
||||
"""Converts a YAML file into .env file format for specified target parameters under 'stringData' and 'data'.
|
||||
|
||||
:param yaml_file: Path to the YAML file.
|
||||
:param target_parameters: List of keys to extract from the YAML file.
|
||||
:param output_env_file: Path to the output .env file.
|
||||
:param prefix: Prefix for environment variables.
|
||||
"""
|
||||
try:
|
||||
# Load the YAML file
|
||||
with open(yaml_file, 'r') as file:
|
||||
yaml_data = yaml.safe_load(file)
|
||||
|
||||
# Extract sections
|
||||
string_data = yaml_data.get('stringData', None)
|
||||
data = yaml_data.get('data', None)
|
||||
|
||||
if string_data:
|
||||
env_source = string_data
|
||||
process_base64 = False
|
||||
elif data:
|
||||
env_source = data
|
||||
process_base64 = True
|
||||
else:
|
||||
print(
|
||||
"Error: Neither 'stringData' nor 'data' section found in the YAML file."
|
||||
)
|
||||
return
|
||||
|
||||
env_lines = []
|
||||
|
||||
for param in target_parameters:
|
||||
if param in env_source:
|
||||
value = env_source[param]
|
||||
if process_base64:
|
||||
try:
|
||||
decoded_value = base64.b64decode(value).decode('utf-8')
|
||||
formatted_value = (
|
||||
decoded_value.replace('\n', '\\n')
|
||||
if '\n' in decoded_value
|
||||
else decoded_value
|
||||
)
|
||||
except Exception as decode_error:
|
||||
print(f"Error decoding base64 for '{param}': {decode_error}")
|
||||
continue
|
||||
else:
|
||||
formatted_value = (
|
||||
value.replace('\n', '\\n')
|
||||
if isinstance(value, str) and '\n' in value
|
||||
else value
|
||||
)
|
||||
|
||||
new_key = prefix + param.upper().replace('-', '_')
|
||||
env_lines.append(f'{new_key}={formatted_value}')
|
||||
else:
|
||||
print(
|
||||
f"Warning: Parameter '{param}' not found in the selected section."
|
||||
)
|
||||
|
||||
# Write to the .env file
|
||||
with open(output_env_file, 'a') as env_file:
|
||||
env_file.write('\n'.join(env_lines) + '\n')
|
||||
|
||||
except Exception as e:
|
||||
print(f'Error: {e}')
|
||||
|
||||
|
||||
lite_llm_api_key = os.getenv('LITE_LLM_API_KEY')
|
||||
if not lite_llm_api_key:
|
||||
print('Set the LITE_LLM_API_KEY environment variable to your API key')
|
||||
sys.exit(1)
|
||||
|
||||
yaml_file = 'github_decrypted.yaml'
|
||||
target_parameters = ['client-id', 'client-secret', 'webhook-secret', 'private-key']
|
||||
output_env_file = './enterprise/.env'
|
||||
|
||||
if os.path.exists(output_env_file):
|
||||
os.remove(output_env_file)
|
||||
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'GITHUB_APP_')
|
||||
os.remove(yaml_file)
|
||||
|
||||
yaml_file = 'keycloak_realm_decrypted.yaml'
|
||||
target_parameters = ['client-id', 'client-secret', 'provider-name', 'realm-name']
|
||||
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
|
||||
os.remove(yaml_file)
|
||||
|
||||
yaml_file = 'keycloak_admin_decrypted.yaml'
|
||||
target_parameters = ['admin-password']
|
||||
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
|
||||
os.remove(yaml_file)
|
||||
|
||||
lines = []
|
||||
lines.append('KEYCLOAK_SERVER_URL=https://auth.staging.all-hands.dev/')
|
||||
lines.append('KEYCLOAK_SERVER_URL_EXT=https://auth.staging.all-hands.dev/')
|
||||
lines.append('OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig')
|
||||
lines.append(
|
||||
'OPENHANDS_GITHUB_SERVICE_CLS=integrations.github.github_service.SaaSGitHubService'
|
||||
)
|
||||
lines.append(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS=integrations.gitlab.gitlab_service.SaaSGitLabService'
|
||||
)
|
||||
lines.append(
|
||||
'OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService'
|
||||
)
|
||||
lines.append(
|
||||
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
|
||||
)
|
||||
lines.append('POSTHOG_CLIENT_KEY=test')
|
||||
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
|
||||
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
|
||||
lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev')
|
||||
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514')
|
||||
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
|
||||
lines.append('LOCAL_DEPLOYMENT=true')
|
||||
lines.append('DB_HOST=localhost')
|
||||
|
||||
with open(output_env_file, 'a') as env_file:
|
||||
env_file.write('\n'.join(lines))
|
||||
|
||||
print(f'.env file created at: {output_env_file}')
|
||||
27
enterprise/enterprise_local/decrypt_env.sh
Normal file
27
enterprise/enterprise_local/decrypt_env.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Check if DEPLOY_DIR argument was provided
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: $0 <DEPLOY_DIR>"
|
||||
echo "Example: $0 /path/to/deploy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Normalize path (remove trailing slash)
|
||||
DEPLOY_DIR="${DEPLOY_DIR%/}"
|
||||
|
||||
# Function to decrypt and rename
|
||||
decrypt_and_move() {
|
||||
local secret_path="$1"
|
||||
local output_name="$2"
|
||||
|
||||
${DEPLOY_DIR}/scripts/decrypt.sh "${DEPLOY_DIR}/${secret_path}"
|
||||
mv decrypted.yaml "${output_name}"
|
||||
echo "Moved decrypted.yaml to ${output_name}"
|
||||
}
|
||||
|
||||
# Decrypt each secret file
|
||||
decrypt_and_move "openhands/envs/feature/secrets/github-app.yaml" "github_decrypted.yaml"
|
||||
decrypt_and_move "openhands/envs/staging/secrets/keycloak-realm.yaml" "keycloak_realm_decrypted.yaml"
|
||||
decrypt_and_move "openhands/envs/staging/secrets/keycloak-admin.yaml" "keycloak_admin_decrypted.yaml"
|
||||
@@ -1,18 +1,47 @@
|
||||
from uuid import UUID
|
||||
|
||||
from experiments.constants import (
|
||||
ENABLE_EXPERIMENT_MANAGER,
|
||||
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
)
|
||||
from experiments.experiment_versions import (
|
||||
handle_condenser_max_step_experiment,
|
||||
handle_system_prompt_experiment,
|
||||
)
|
||||
from experiments.experiment_versions._004_condenser_max_step_experiment import (
|
||||
handle_condenser_max_step_experiment__v1,
|
||||
)
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.experiments.experiment_manager import ExperimentManager
|
||||
from openhands.sdk import Agent
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
class SaaSExperimentManager(ExperimentManager):
|
||||
@staticmethod
|
||||
def run_agent_variant_tests__v1(
|
||||
user_id: str | None, conversation_id: UUID, agent: Agent
|
||||
) -> Agent:
|
||||
if not ENABLE_EXPERIMENT_MANAGER:
|
||||
logger.info(
|
||||
'experiment_manager:run_conversation_variant_test:skipped',
|
||||
extra={'reason': 'experiment_manager_disabled'},
|
||||
)
|
||||
return agent
|
||||
|
||||
agent = handle_condenser_max_step_experiment__v1(
|
||||
user_id, conversation_id, agent
|
||||
)
|
||||
|
||||
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
|
||||
agent = agent.model_copy(
|
||||
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
@staticmethod
|
||||
def run_conversation_variant_test(
|
||||
user_id, conversation_id, conversation_settings
|
||||
|
||||
@@ -5,12 +5,18 @@ This module contains the handler for the condenser max step experiment that test
|
||||
different max_size values for the condenser configuration.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import posthog
|
||||
from experiments.constants import EXPERIMENT_CONDENSER_MAX_STEP
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import Agent
|
||||
from openhands.sdk.context.condenser import (
|
||||
LLMSummarizingCondenser,
|
||||
)
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
@@ -190,3 +196,37 @@ def handle_condenser_max_step_experiment(
|
||||
return conversation_settings
|
||||
|
||||
return conversation_settings
|
||||
|
||||
|
||||
def handle_condenser_max_step_experiment__v1(
|
||||
user_id: str | None,
|
||||
conversation_id: UUID,
|
||||
agent: Agent,
|
||||
) -> Agent:
|
||||
enabled_variant = _get_condenser_max_step_variant(user_id, str(conversation_id))
|
||||
|
||||
if enabled_variant is None:
|
||||
return agent
|
||||
|
||||
if enabled_variant == 'control':
|
||||
condenser_max_size = 120
|
||||
elif enabled_variant == 'treatment':
|
||||
condenser_max_size = 80
|
||||
else:
|
||||
logger.error(
|
||||
'condenser_max_step_experiment:unknown_variant',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'reason': 'unknown variant; returning original conversation settings',
|
||||
},
|
||||
)
|
||||
return agent
|
||||
|
||||
condenser_llm = agent.llm.model_copy(update={'usage_id': 'condenser'})
|
||||
condenser = LLMSummarizingCondenser(
|
||||
llm=condenser_llm, max_size=condenser_max_size, keep_first=4
|
||||
)
|
||||
|
||||
return agent.model_copy(update={'condenser': condenser})
|
||||
|
||||
@@ -24,7 +24,7 @@ from server.config import get_config
|
||||
from storage.database import session_maker
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
@@ -61,20 +61,19 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
def _get_setting():
|
||||
with session_maker() as session:
|
||||
settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
|
||||
if not settings or settings.enable_proactive_conversation_starters is None:
|
||||
return False
|
||||
settings = await call_sync_from_async(
|
||||
settings_store.get_user_settings_by_keycloak_id, user_id
|
||||
)
|
||||
|
||||
return settings.enable_proactive_conversation_starters
|
||||
if not settings or settings.enable_proactive_conversation_starters is None:
|
||||
return False
|
||||
|
||||
return await call_sync_from_async(_get_setting)
|
||||
return settings.enable_proactive_conversation_starters
|
||||
|
||||
|
||||
# =================================================
|
||||
|
||||
@@ -132,8 +132,10 @@ class JiraExistingConversationView(JiraViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -135,8 +135,10 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -132,8 +132,10 @@ class LinearExistingConversationView(LinearViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -14,6 +14,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.services.conversation_service import (
|
||||
create_new_conversation,
|
||||
setup_init_conversation_settings,
|
||||
@@ -188,19 +189,27 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
user_secrets = await self.saas_user_auth.get_user_secrets()
|
||||
user_instructions, conversation_instructions = self._get_instructions(jinja)
|
||||
|
||||
# Determine git provider from repository
|
||||
git_provider = None
|
||||
if self.selected_repo and provider_tokens:
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
repository = await provider_handler.verify_repo_provider(self.selected_repo)
|
||||
git_provider = repository.git_provider
|
||||
|
||||
agent_loop_info = await create_new_conversation(
|
||||
user_id=self.slack_to_openhands_user.keycloak_user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=self.selected_repo,
|
||||
selected_branch=None,
|
||||
initial_user_msg=user_instructions,
|
||||
conversation_instructions=conversation_instructions
|
||||
if conversation_instructions
|
||||
else None,
|
||||
conversation_instructions=(
|
||||
conversation_instructions if conversation_instructions else None
|
||||
),
|
||||
image_urls=None,
|
||||
replay_json=None,
|
||||
conversation_trigger=ConversationTrigger.SLACK,
|
||||
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
|
||||
git_provider=git_provider,
|
||||
)
|
||||
|
||||
self.conversation_id = agent_loop_info.conversation_id
|
||||
@@ -263,8 +272,10 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
# Check if conversation has been deleted
|
||||
# Update logic when soft delete is implemented
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await saas_user_auth.get_provider_tokens()
|
||||
|
||||
27
enterprise/migrations/versions/077_drop_settings_table.py
Normal file
27
enterprise/migrations/versions/077_drop_settings_table.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""drop settings table
|
||||
|
||||
Revision ID: 077
|
||||
Revises: 076
|
||||
Create Date: 2025-10-21 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '077'
|
||||
down_revision: Union[str, None] = '076'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Drop the deprecated settings table."""
|
||||
op.execute('DROP TABLE IF EXISTS settings')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""No-op downgrade since the settings table is deprecated."""
|
||||
pass
|
||||
3943
enterprise/poetry.lock
generated
3943
enterprise/poetry.lock
generated
File diff suppressed because one or more lines are too long
@@ -293,11 +293,12 @@ class TokenManager:
|
||||
refresh_token_expires_at: int,
|
||||
) -> dict[str, str | int] | None:
|
||||
current_time = int(time.time())
|
||||
# expire access_token ten minutes before actual expiration
|
||||
# expire access_token four hours before actual expiration
|
||||
# This ensures tokens are refreshed on resume to have at least 4 hours validity
|
||||
access_expired = (
|
||||
False
|
||||
if access_token_expires_at == 0
|
||||
else access_token_expires_at < current_time + 600
|
||||
else access_token_expires_at < current_time + 14400
|
||||
)
|
||||
refresh_expired = (
|
||||
False
|
||||
|
||||
@@ -3,10 +3,11 @@ from datetime import UTC, datetime
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from server.config import get_config
|
||||
from server.constants import LITE_LLM_API_KEY, LITE_LLM_API_URL
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.database import session_maker
|
||||
from storage.user_settings import UserSettings
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
@@ -16,30 +17,30 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
# Helper functions for BYOR API key management
|
||||
async def get_byor_key_from_db(user_id: str) -> str | None:
|
||||
"""Get the BYOR key from the database for a user."""
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
|
||||
def _get_byor_key():
|
||||
with session_maker() as session:
|
||||
user_db_settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if user_db_settings and user_db_settings.llm_api_key_for_byor:
|
||||
return user_db_settings.llm_api_key_for_byor
|
||||
return None
|
||||
|
||||
return await call_sync_from_async(_get_byor_key)
|
||||
user_db_settings = await call_sync_from_async(
|
||||
settings_store.get_user_settings_by_keycloak_id, user_id
|
||||
)
|
||||
if user_db_settings and user_db_settings.llm_api_key_for_byor:
|
||||
return user_db_settings.llm_api_key_for_byor
|
||||
return None
|
||||
|
||||
|
||||
async def store_byor_key_in_db(user_id: str, key: str) -> None:
|
||||
"""Store the BYOR key in the database for a user."""
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
|
||||
def _update_user_settings():
|
||||
with session_maker() as session:
|
||||
user_db_settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == user_id)
|
||||
.first()
|
||||
user_db_settings = settings_store.get_user_settings_by_keycloak_id(
|
||||
user_id, session
|
||||
)
|
||||
if user_db_settings:
|
||||
user_db_settings.llm_api_key_for_byor = key
|
||||
|
||||
@@ -16,10 +16,11 @@ from server.auth.constants import (
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import sign_token
|
||||
from server.config import get_config, sign_token
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
from server.routes.event_webhook import _get_session_api_key, _get_user_id
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -212,16 +213,14 @@ async def keycloak_callback(
|
||||
f'&state={state}'
|
||||
)
|
||||
|
||||
has_accepted_tos = False
|
||||
with session_maker() as session:
|
||||
user_settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
has_accepted_tos = (
|
||||
user_settings is not None and user_settings.accepted_tos is not None
|
||||
)
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
user_settings = settings_store.get_user_settings_by_keycloak_id(user_id)
|
||||
has_accepted_tos = (
|
||||
user_settings is not None and user_settings.accepted_tos is not None
|
||||
)
|
||||
|
||||
# If the user hasn't accepted the TOS, redirect to the TOS page
|
||||
if not has_accepted_tos:
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from integrations import stripe_service
|
||||
from pydantic import BaseModel
|
||||
from server.config import get_config
|
||||
from server.constants import (
|
||||
LITE_LLM_API_KEY,
|
||||
LITE_LLM_API_URL,
|
||||
@@ -22,8 +23,8 @@ from server.constants import (
|
||||
from server.logger import logger
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -31,6 +32,37 @@ stripe.api_key = STRIPE_API_KEY
|
||||
billing_router = APIRouter(prefix='/api/billing')
|
||||
|
||||
|
||||
# TODO: Add a new app_mode named "ON_PREM" to support self-hosted customers instead of doing this
|
||||
# and members should comment out the "validate_saas_environment" function if they are developing and testing locally.
|
||||
def is_all_hands_saas_environment(request: Request) -> bool:
|
||||
"""Check if the current domain is an All Hands SaaS environment.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
||||
Returns:
|
||||
True if the current domain contains "all-hands.dev" or "openhands.dev" postfix
|
||||
"""
|
||||
hostname = request.url.hostname or ''
|
||||
return hostname.endswith('all-hands.dev') or hostname.endswith('openhands.dev')
|
||||
|
||||
|
||||
def validate_saas_environment(request: Request) -> None:
|
||||
"""Validate that the request is coming from an All Hands SaaS environment.
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
||||
Raises:
|
||||
HTTPException: If the request is not from an All Hands SaaS environment
|
||||
"""
|
||||
if not is_all_hands_saas_environment(request):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='Checkout sessions are only available for All Hands SaaS environments',
|
||||
)
|
||||
|
||||
|
||||
class BillingSessionType(Enum):
|
||||
DIRECT_PAYMENT = 'DIRECT_PAYMENT'
|
||||
MONTHLY_SUBSCRIPTION = 'MONTHLY_SUBSCRIPTION'
|
||||
@@ -196,6 +228,8 @@ async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONRespon
|
||||
async def create_customer_setup_session(
|
||||
request: Request, user_id: str = Depends(get_user_id)
|
||||
) -> CreateBillingSessionResponse:
|
||||
validate_saas_environment(request)
|
||||
|
||||
customer_id = await stripe_service.find_or_create_customer(user_id)
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_id,
|
||||
@@ -214,6 +248,8 @@ async def create_checkout_session(
|
||||
request: Request,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CreateBillingSessionResponse:
|
||||
validate_saas_environment(request)
|
||||
|
||||
customer_id = await stripe_service.find_or_create_customer(user_id)
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_id,
|
||||
@@ -268,6 +304,8 @@ async def create_subscription_checkout_session(
|
||||
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CreateBillingSessionResponse:
|
||||
validate_saas_environment(request)
|
||||
|
||||
# Prevent duplicate subscriptions for the same user
|
||||
with session_maker() as session:
|
||||
now = datetime.now(UTC)
|
||||
@@ -343,6 +381,8 @@ async def create_subscription_checkout_session_via_get(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> RedirectResponse:
|
||||
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)."""
|
||||
validate_saas_environment(request)
|
||||
|
||||
response = await create_subscription_checkout_session(
|
||||
request, billing_session_type, user_id
|
||||
)
|
||||
@@ -578,11 +618,14 @@ async def stripe_webhook(request: Request) -> JSONResponse:
|
||||
|
||||
def reset_user_to_free_tier_settings(user_id: str) -> None:
|
||||
"""Reset user settings to free tier defaults when subscription ends."""
|
||||
config = get_config()
|
||||
settings_store = SaasSettingsStore(
|
||||
user_id=user_id, session_maker=session_maker, config=config
|
||||
)
|
||||
|
||||
with session_maker() as session:
|
||||
user_settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == user_id)
|
||||
.first()
|
||||
user_settings = settings_store.get_user_settings_by_keycloak_id(
|
||||
user_id, session
|
||||
)
|
||||
|
||||
if user_settings:
|
||||
|
||||
@@ -24,7 +24,6 @@ from server.constants import (
|
||||
from server.logger import logger
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.database import session_maker
|
||||
from storage.stored_settings import StoredSettings
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
@@ -40,15 +39,46 @@ class SaasSettingsStore(SettingsStore):
|
||||
session_maker: sessionmaker
|
||||
config: OpenHandsConfig
|
||||
|
||||
def get_user_settings_by_keycloak_id(
|
||||
self, keycloak_user_id: str, session=None
|
||||
) -> UserSettings | None:
|
||||
"""
|
||||
Get UserSettings by keycloak_user_id.
|
||||
|
||||
Args:
|
||||
keycloak_user_id: The keycloak user ID to search for
|
||||
session: Optional existing database session. If not provided, creates a new one.
|
||||
|
||||
Returns:
|
||||
UserSettings object if found, None otherwise
|
||||
"""
|
||||
if not keycloak_user_id:
|
||||
return None
|
||||
|
||||
def _get_settings():
|
||||
if session:
|
||||
# Use provided session
|
||||
return (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == keycloak_user_id)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
# Create new session
|
||||
with self.session_maker() as new_session:
|
||||
return (
|
||||
new_session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == keycloak_user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return _get_settings()
|
||||
|
||||
async def load(self) -> Settings | None:
|
||||
if not self.user_id:
|
||||
return None
|
||||
with self.session_maker() as session:
|
||||
settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == self.user_id)
|
||||
.first()
|
||||
)
|
||||
settings = self.get_user_settings_by_keycloak_id(self.user_id, session)
|
||||
|
||||
if not settings or settings.user_version != CURRENT_USER_SETTINGS_VERSION:
|
||||
logger.info(
|
||||
@@ -72,12 +102,8 @@ class SaasSettingsStore(SettingsStore):
|
||||
if item:
|
||||
kwargs = item.model_dump(context={'expose_secrets': True})
|
||||
self._encrypt_kwargs(kwargs)
|
||||
query = session.query(UserSettings).filter(
|
||||
UserSettings.keycloak_user_id == self.user_id
|
||||
)
|
||||
|
||||
# First check if we have an existing entry in the new table
|
||||
existing = query.first()
|
||||
existing = self.get_user_settings_by_keycloak_id(self.user_id, session)
|
||||
|
||||
kwargs = {
|
||||
key: value
|
||||
@@ -144,33 +170,6 @@ class SaasSettingsStore(SettingsStore):
|
||||
await self.store(settings)
|
||||
return settings
|
||||
|
||||
def load_legacy_db_settings(self, github_user_id: str) -> Settings | None:
|
||||
if not github_user_id:
|
||||
return None
|
||||
|
||||
with self.session_maker() as session:
|
||||
settings = (
|
||||
session.query(StoredSettings)
|
||||
.filter(StoredSettings.id == github_user_id)
|
||||
.first()
|
||||
)
|
||||
if settings is None:
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
'saas_settings_store:load_legacy_db_settings:found',
|
||||
extra={'github_user_id': github_user_id},
|
||||
)
|
||||
kwargs = {
|
||||
c.name: getattr(settings, c.name)
|
||||
for c in StoredSettings.__table__.columns
|
||||
if c.name in Settings.model_fields
|
||||
}
|
||||
self._decrypt_kwargs(kwargs)
|
||||
del kwargs['secrets_store']
|
||||
settings = Settings(**kwargs)
|
||||
return settings
|
||||
|
||||
async def load_legacy_file_store_settings(self, github_user_id: str):
|
||||
if not github_user_id:
|
||||
return None
|
||||
@@ -235,10 +234,8 @@ class SaasSettingsStore(SettingsStore):
|
||||
spend = user_info.get('spend') or 0
|
||||
|
||||
with session_maker() as session:
|
||||
user_settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == self.user_id)
|
||||
.first()
|
||||
user_settings = self.get_user_settings_by_keycloak_id(
|
||||
self.user_id, session
|
||||
)
|
||||
# In upgrade to V4, we no longer use billing margin, but instead apply this directly
|
||||
# in litellm. The default billing marign was 2 before this (hence the magic numbers below)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Column, Float, Integer, String
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class StoredSettings(Base): # type: ignore
|
||||
"""
|
||||
Legacy user settings storage. This should be considered deprecated - use UserSettings isntead
|
||||
"""
|
||||
|
||||
__tablename__ = 'settings'
|
||||
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
language = Column(String, nullable=True)
|
||||
agent = Column(String, nullable=True)
|
||||
max_iterations = Column(Integer, nullable=True)
|
||||
security_analyzer = Column(String, nullable=True)
|
||||
confirmation_mode = Column(Boolean, nullable=True, default=False)
|
||||
llm_model = Column(String, nullable=True)
|
||||
llm_api_key = Column(String, nullable=True)
|
||||
llm_base_url = Column(String, nullable=True)
|
||||
remote_runtime_resource_factor = Column(Integer, nullable=True)
|
||||
enable_default_condenser = Column(Boolean, nullable=False, default=True)
|
||||
user_consents_to_analytics = Column(Boolean, nullable=True)
|
||||
margin = Column(Float, nullable=True)
|
||||
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
|
||||
sandbox_base_container_image = Column(String, nullable=True)
|
||||
sandbox_runtime_container_image = Column(String, nullable=True)
|
||||
secrets_store = Column(JSON, nullable=True)
|
||||
@@ -17,7 +17,6 @@ from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_offline_token import StoredOfflineToken
|
||||
from storage.stored_settings import StoredSettings
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
@@ -85,7 +84,7 @@ def add_minimal_fixtures(session_maker):
|
||||
updated_at=datetime.fromisoformat('2025-03-08'),
|
||||
)
|
||||
)
|
||||
session.add(StoredSettings(id='mock-user-id', user_consents_to_analytics=True))
|
||||
|
||||
session.add(
|
||||
StripeCustomer(
|
||||
keycloak_user_id='mock-user-id',
|
||||
|
||||
1
enterprise/tests/unit/experiments/__init__.py
Normal file
1
enterprise/tests/unit/experiments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests for experiments module."""
|
||||
@@ -0,0 +1,137 @@
|
||||
# tests/test_condenser_max_step_experiment_v1.py
|
||||
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from experiments.experiment_manager import SaaSExperimentManager
|
||||
|
||||
# SUT imports (update the module path if needed)
|
||||
from experiments.experiment_versions._004_condenser_max_step_experiment import (
|
||||
handle_condenser_max_step_experiment__v1,
|
||||
)
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.sdk import LLM, Agent
|
||||
from openhands.sdk.context.condenser import LLMSummarizingCondenser
|
||||
|
||||
|
||||
def make_agent() -> Agent:
|
||||
"""Build a minimal valid Agent."""
|
||||
llm = LLM(
|
||||
usage_id='primary-llm',
|
||||
model='provider/model',
|
||||
api_key=SecretStr('sk-test'),
|
||||
)
|
||||
return Agent(llm=llm)
|
||||
|
||||
|
||||
def _patch_variant(monkeypatch, return_value):
|
||||
"""Patch the internal variant getter to return a specific value."""
|
||||
monkeypatch.setattr(
|
||||
'experiments.experiment_versions._004_condenser_max_step_experiment._get_condenser_max_step_variant',
|
||||
lambda user_id, conv_id: return_value,
|
||||
raising=True,
|
||||
)
|
||||
|
||||
|
||||
def test_control_variant_sets_condenser_with_max_size_120(monkeypatch):
|
||||
_patch_variant(monkeypatch, 'control')
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = handle_condenser_max_step_experiment__v1('user-1', conv_id, agent)
|
||||
|
||||
# Should be a new Agent instance with a condenser installed
|
||||
assert result is not agent
|
||||
assert isinstance(result.condenser, LLMSummarizingCondenser)
|
||||
|
||||
# The condenser should have its own LLM (usage_id overridden to "condenser")
|
||||
assert result.condenser.llm.usage_id == 'condenser'
|
||||
# The original agent LLM remains unchanged
|
||||
assert agent.llm.usage_id == 'primary-llm'
|
||||
|
||||
# Control: max_size = 120, keep_first = 4
|
||||
assert result.condenser.max_size == 120
|
||||
assert result.condenser.keep_first == 4
|
||||
|
||||
|
||||
def test_treatment_variant_sets_condenser_with_max_size_80(monkeypatch):
|
||||
_patch_variant(monkeypatch, 'treatment')
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = handle_condenser_max_step_experiment__v1('user-2', conv_id, agent)
|
||||
|
||||
assert result is not agent
|
||||
assert isinstance(result.condenser, LLMSummarizingCondenser)
|
||||
assert result.condenser.llm.usage_id == 'condenser'
|
||||
assert result.condenser.max_size == 80
|
||||
assert result.condenser.keep_first == 4
|
||||
|
||||
|
||||
def test_none_variant_returns_original_agent_without_changes(monkeypatch):
|
||||
_patch_variant(monkeypatch, None)
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = handle_condenser_max_step_experiment__v1('user-3', conv_id, agent)
|
||||
|
||||
# No changes—same instance and no condenser attribute added
|
||||
assert result is agent
|
||||
assert getattr(result, 'condenser', None) is None
|
||||
|
||||
|
||||
def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
|
||||
_patch_variant(monkeypatch, 'weird-variant')
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = handle_condenser_max_step_experiment__v1('user-4', conv_id, agent)
|
||||
|
||||
assert result is agent
|
||||
assert getattr(result, 'condenser', None) is None
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
|
||||
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
|
||||
mock_handle_condenser,
|
||||
):
|
||||
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = SaaSExperimentManager.run_agent_variant_tests__v1(
|
||||
user_id='user-123',
|
||||
conversation_id=conv_id,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
# Same object returned (no copy)
|
||||
assert result is agent
|
||||
# Handler should not have been called
|
||||
mock_handle_condenser.assert_not_called()
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
|
||||
@patch('experiments.experiment_manager.EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT', True)
|
||||
def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeypatch):
|
||||
"""When enabled, it should call the condenser experiment handler and set the long-horizon system prompt."""
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
_patch_variant(monkeypatch, 'treatment')
|
||||
|
||||
result: Agent = SaaSExperimentManager.run_agent_variant_tests__v1(
|
||||
user_id='user-abc',
|
||||
conversation_id=conv_id,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
# Should be a different instance than the original (copied after handler runs)
|
||||
assert result is not agent
|
||||
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
|
||||
|
||||
# The condenser returned by the handler must be preserved after the system-prompt override copy
|
||||
assert isinstance(result.condenser, LLMSummarizingCondenser)
|
||||
assert result.condenser.max_size == 80
|
||||
@@ -137,7 +137,9 @@ class TestJiraExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -137,7 +137,9 @@ class TestJiraDcExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -137,7 +137,9 @@ class TestLinearExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -36,6 +36,46 @@ def session_maker(engine):
|
||||
return sessionmaker(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
"""Create a mock request object with proper URL structure for testing."""
|
||||
return Request(
|
||||
scope={
|
||||
'type': 'http',
|
||||
'path': '/api/billing/test',
|
||||
'server': ('test.com', 80),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_checkout_request():
|
||||
"""Create a mock request object for checkout session tests."""
|
||||
request = Request(
|
||||
scope={
|
||||
'type': 'http',
|
||||
'path': '/api/billing/create-checkout-session',
|
||||
'server': ('test.com', 80),
|
||||
}
|
||||
)
|
||||
request._base_url = URL('http://test.com/')
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_subscription_request():
|
||||
"""Create a mock request object for subscription checkout session tests."""
|
||||
request = Request(
|
||||
scope={
|
||||
'type': 'http',
|
||||
'path': '/api/billing/subscription-checkout-session',
|
||||
'server': ('test.com', 80),
|
||||
}
|
||||
)
|
||||
request._base_url = URL('http://test.com/')
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_credits_lite_llm_error():
|
||||
mock_request = Request(scope={'type': 'http', 'state': {'user_id': 'mock_user'}})
|
||||
@@ -90,14 +130,10 @@ async def test_get_credits_success():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_checkout_session_stripe_error(session_maker):
|
||||
async def test_create_checkout_session_stripe_error(
|
||||
session_maker, mock_checkout_request
|
||||
):
|
||||
"""Test handling of Stripe API errors."""
|
||||
mock_request = Request(
|
||||
scope={
|
||||
'type': 'http',
|
||||
}
|
||||
)
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
mock_customer = stripe.Customer(
|
||||
id='mock-customer', metadata={'user_id': 'mock-user'}
|
||||
@@ -118,17 +154,16 @@ async def test_create_checkout_session_stripe_error(session_maker):
|
||||
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
|
||||
AsyncMock(return_value={'email': 'testy@tester.com'}),
|
||||
),
|
||||
patch('server.routes.billing.validate_saas_environment'),
|
||||
):
|
||||
await create_checkout_session(
|
||||
CreateCheckoutSessionRequest(amount=25), mock_request, 'mock_user'
|
||||
CreateCheckoutSessionRequest(amount=25), mock_checkout_request, 'mock_user'
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_checkout_session_success(session_maker):
|
||||
async def test_create_checkout_session_success(session_maker, mock_checkout_request):
|
||||
"""Test successful creation of checkout session."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.url = 'https://checkout.stripe.com/test-session'
|
||||
@@ -152,12 +187,13 @@ async def test_create_checkout_session_success(session_maker):
|
||||
'server.auth.token_manager.TokenManager.get_user_info_from_user_id',
|
||||
AsyncMock(return_value={'email': 'testy@tester.com'}),
|
||||
),
|
||||
patch('server.routes.billing.validate_saas_environment'),
|
||||
):
|
||||
mock_db_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_db_session
|
||||
|
||||
result = await create_checkout_session(
|
||||
CreateCheckoutSessionRequest(amount=25), mock_request, 'mock_user'
|
||||
CreateCheckoutSessionRequest(amount=25), mock_checkout_request, 'mock_user'
|
||||
)
|
||||
|
||||
assert isinstance(result, CreateBillingSessionResponse)
|
||||
@@ -590,7 +626,9 @@ async def test_cancel_subscription_stripe_error():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subscription_checkout_session_duplicate_prevention():
|
||||
async def test_create_subscription_checkout_session_duplicate_prevention(
|
||||
mock_subscription_request,
|
||||
):
|
||||
"""Test that creating a subscription when user already has active subscription raises error."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
@@ -609,11 +647,9 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
|
||||
cancelled_at=None,
|
||||
)
|
||||
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
patch('server.routes.billing.validate_saas_environment'),
|
||||
):
|
||||
# Setup mock session to return existing active subscription
|
||||
mock_session = MagicMock()
|
||||
@@ -623,7 +659,7 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
|
||||
# Call the function and expect HTTPException
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_subscription_checkout_session(
|
||||
mock_request, user_id='test_user'
|
||||
mock_subscription_request, user_id='test_user'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -634,10 +670,10 @@ async def test_create_subscription_checkout_session_duplicate_prevention():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subscription_checkout_session_allows_after_cancellation():
|
||||
async def test_create_subscription_checkout_session_allows_after_cancellation(
|
||||
mock_subscription_request,
|
||||
):
|
||||
"""Test that creating a subscription is allowed when previous subscription was cancelled."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
mock_session_obj = MagicMock()
|
||||
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
|
||||
@@ -657,6 +693,7 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
|
||||
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
|
||||
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
|
||||
),
|
||||
patch('server.routes.billing.validate_saas_environment'),
|
||||
):
|
||||
# Setup mock session - the query should return None because cancelled subscriptions are filtered out
|
||||
mock_session = MagicMock()
|
||||
@@ -665,7 +702,7 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
|
||||
|
||||
# Should succeed
|
||||
result = await create_subscription_checkout_session(
|
||||
mock_request, user_id='test_user'
|
||||
mock_subscription_request, user_id='test_user'
|
||||
)
|
||||
|
||||
assert isinstance(result, CreateBillingSessionResponse)
|
||||
@@ -673,10 +710,10 @@ async def test_create_subscription_checkout_session_allows_after_cancellation():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subscription_checkout_session_success_no_existing():
|
||||
async def test_create_subscription_checkout_session_success_no_existing(
|
||||
mock_subscription_request,
|
||||
):
|
||||
"""Test successful subscription creation when no existing subscription."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
mock_session_obj = MagicMock()
|
||||
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
|
||||
@@ -696,6 +733,7 @@ async def test_create_subscription_checkout_session_success_no_existing():
|
||||
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
|
||||
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
|
||||
),
|
||||
patch('server.routes.billing.validate_saas_environment'),
|
||||
):
|
||||
# Setup mock session to return no existing subscription
|
||||
mock_session = MagicMock()
|
||||
@@ -704,7 +742,7 @@ async def test_create_subscription_checkout_session_success_no_existing():
|
||||
|
||||
# Should succeed
|
||||
result = await create_subscription_checkout_session(
|
||||
mock_request, user_id='test_user'
|
||||
mock_subscription_request, user_id='test_user'
|
||||
)
|
||||
|
||||
assert isinstance(result, CreateBillingSessionResponse)
|
||||
|
||||
@@ -8,8 +8,8 @@ pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# Mock the call_sync_from_async function to return the result of the function directly
|
||||
def mock_call_sync_from_async(func):
|
||||
return func()
|
||||
def mock_call_sync_from_async(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -8,7 +8,6 @@ from server.constants import (
|
||||
LITE_LLM_TEAM_ID,
|
||||
)
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
from storage.stored_settings import StoredSettings
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
@@ -303,26 +302,6 @@ async def test_create_default_settings_require_payment_disabled(
|
||||
assert settings.language == 'en'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_with_existing_llm_key(
|
||||
settings_store, mock_stripe, mock_github_user, mock_litellm_api, session_maker
|
||||
):
|
||||
# Test that existing llm_api_key is preserved and not overwritten with litellm default
|
||||
with (
|
||||
patch('storage.saas_settings_store.REQUIRE_PAYMENT', False),
|
||||
patch('storage.saas_settings_store.LITE_LLM_API_KEY', 'mock-api-key'),
|
||||
patch('storage.saas_settings_store.session_maker', session_maker),
|
||||
):
|
||||
with settings_store.session_maker() as session:
|
||||
kwargs = {'id': '12345', 'language': 'en', 'llm_api_key': 'existing_key'}
|
||||
settings_store._encrypt_kwargs(kwargs)
|
||||
session.merge(StoredSettings(**kwargs))
|
||||
session.commit()
|
||||
updated_settings = await settings_store.create_default_settings(None)
|
||||
assert updated_settings is not None
|
||||
assert updated_settings.llm_api_key.get_secret_value() == 'test_api_key'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_lite_llm_settings_no_api_config(settings_store):
|
||||
with (
|
||||
|
||||
@@ -13,7 +13,6 @@ from integrations.stripe_service import (
|
||||
)
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.stored_settings import Base as StoredBase
|
||||
from storage.stripe_customer import Base as StripeCustomerBase
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
from storage.user_settings import Base as UserBase
|
||||
@@ -22,7 +21,7 @@ from storage.user_settings import Base as UserBase
|
||||
@pytest.fixture
|
||||
def engine():
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
StoredBase.metadata.create_all(engine)
|
||||
|
||||
UserBase.metadata.create_all(engine)
|
||||
StripeCustomerBase.metadata.create_all(engine)
|
||||
return engine
|
||||
|
||||
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
|
||||
|
||||
# 2. Prepare DATA
|
||||
echo "==== Prepare SWE-bench data ===="
|
||||
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
|
||||
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
|
||||
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
|
||||
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
|
||||
if [ -d $EVAL_WORKSPACE/eval_data ]; then
|
||||
|
||||
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
|
||||
|
||||
# 2. Prepare DATA
|
||||
echo "==== Prepare SWE-bench data ===="
|
||||
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
|
||||
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
|
||||
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
|
||||
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
|
||||
if [ -d $EVAL_WORKSPACE/eval_data ]; then
|
||||
|
||||
@@ -12,7 +12,7 @@ git clone -b $OH_SWE_BENCH_REPO_BRANCH $OH_SWE_BENCH_REPO_PATH $EVAL_WORKSPACE/O
|
||||
|
||||
# 2. Prepare DATA
|
||||
echo "==== Prepare SWE-bench data ===="
|
||||
EVAL_IMAGE=ghcr.io/all-hands-ai/eval-swe-bench:builder_with_conda
|
||||
EVAL_IMAGE=ghcr.io/openhands/eval-swe-bench:builder_with_conda
|
||||
EVAL_WORKSPACE=$(realpath $EVAL_WORKSPACE)
|
||||
chmod +x $EVAL_WORKSPACE/OH-SWE-bench/swebench/harness/prepare_data.sh
|
||||
if [ -d $EVAL_WORKSPACE/eval_data ]; then
|
||||
|
||||
@@ -162,7 +162,7 @@ while IFS= read -r task_image; do
|
||||
|
||||
# Prune unused images and volumes
|
||||
docker image rm "$task_image"
|
||||
docker images "ghcr.io/all-hands-ai/runtime" -q | xargs -r docker rmi -f
|
||||
docker images "ghcr.io/openhands/runtime" -q | xargs -r docker rmi -f
|
||||
docker volume prune -f
|
||||
docker system prune -f
|
||||
done < "$temp_file"
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import {
|
||||
FILE_VARIANTS_1,
|
||||
FILE_VARIANTS_2,
|
||||
} from "#/mocks/file-service-handlers";
|
||||
|
||||
/**
|
||||
* File service API tests. The actual API calls are mocked using MSW.
|
||||
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
|
||||
*/
|
||||
|
||||
describe("ConversationService File API", () => {
|
||||
it("should get a list of files", async () => {
|
||||
await expect(
|
||||
ConversationService.getFiles("test-conversation-id"),
|
||||
).resolves.toEqual(FILE_VARIANTS_1);
|
||||
|
||||
await expect(
|
||||
ConversationService.getFiles("test-conversation-id-2"),
|
||||
).resolves.toEqual(FILE_VARIANTS_2);
|
||||
});
|
||||
|
||||
it("should get content of a file", async () => {
|
||||
await expect(
|
||||
ConversationService.getFile("test-conversation-id", "file1.txt"),
|
||||
).resolves.toEqual("Content of file1.txt");
|
||||
});
|
||||
});
|
||||
187
frontend/__tests__/build-websocket-url.test.ts
Normal file
187
frontend/__tests__/build-websocket-url.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
|
||||
describe("buildWebSocketUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Basic URL construction", () => {
|
||||
it("should build WebSocket URL with conversation ID and URL", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use wss:// protocol when window.location.protocol is https:", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "https:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"https://example.com:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("wss://example.com:8080/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should extract host and port from conversation URL", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-456",
|
||||
"http://agent-server.com:9000/api/conversations/conv-456",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query parameters handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include query parameters in the URL (handled by useWebSocket hook)", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
expect(result).not.toContain("?");
|
||||
expect(result).not.toContain("session_api_key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fallback to window.location.host", () => {
|
||||
it("should use window.location.host when conversation URL is null", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", null);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is undefined", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", undefined);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is relative path", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is invalid", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", "not-a-valid-url");
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when conversationId is undefined", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
undefined,
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when conversationId is empty string", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle conversation URLs with non-standard ports", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://example.com:12345/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://example.com:12345/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should handle conversation URLs without port (default port)", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://example.com/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://example.com/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should handle conversation IDs with special characters", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123-abc_def",
|
||||
"http://localhost:8080/api/conversations/conv-123-abc_def",
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
"ws://localhost:8080/sockets/events/conv-123-abc_def",
|
||||
);
|
||||
});
|
||||
|
||||
it("should build URL without query parameters", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
expect(result).not.toContain("?");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("#/stores/error-message-store");
|
||||
vi.mock("#/stores/optimistic-user-message-store");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-upload-files");
|
||||
vi.mock("#/hooks/mutation/use-unified-upload-files");
|
||||
|
||||
// Mock React Router hooks at the top level
|
||||
vi.mock("react-router", async () => {
|
||||
@@ -128,7 +128,7 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
@@ -267,7 +267,7 @@ describe("ChatInterface - Empty state", () => {
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
|
||||
@@ -8,6 +8,14 @@ import { ConversationPanel } from "#/components/features/conversation-panel/conv
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
// Mock the unified stop conversation hook
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedPauseConversationSandbox: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ConversationPanel", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -73,7 +81,7 @@ describe("ConversationPanel", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
mockStopConversationMutate.mockClear();
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
@@ -430,19 +438,6 @@ describe("ConversationPanel", () => {
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const stopConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"stopConversation",
|
||||
);
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
const conversation = mockData.find((conv) => conv.conversation_id === id);
|
||||
if (conversation) {
|
||||
conversation.status = "STOPPED";
|
||||
return conversation;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
@@ -465,9 +460,12 @@ describe("ConversationPanel", () => {
|
||||
screen.queryByRole("button", { name: /confirm/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify the API was called
|
||||
expect(stopConversationSpy).toHaveBeenCalledWith("1");
|
||||
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
|
||||
// Verify the mutation was called
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "1",
|
||||
version: undefined,
|
||||
});
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should only show stop button for STARTING or RUNNING conversations", async () => {
|
||||
|
||||
@@ -6,25 +6,25 @@ import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the custom hooks
|
||||
const mockStartConversationMutate = vi.fn();
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
|
||||
useStartConversation: () => ({
|
||||
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
|
||||
useUnifiedStartConversation: () => ({
|
||||
mutate: mockStartConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
|
||||
useStopConversation: () => ({
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedStopConversation: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
@@ -41,6 +41,19 @@ vi.mock("#/hooks/use-user-providers", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-task-polling", () => ({
|
||||
useTaskPolling: () => ({
|
||||
isTask: false,
|
||||
taskId: null,
|
||||
conversationId: "test-conversation-id",
|
||||
task: null,
|
||||
taskStatus: null,
|
||||
taskDetail: null,
|
||||
taskError: null,
|
||||
isLoadingTask: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
@@ -66,12 +79,14 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("ServerStatus", () => {
|
||||
// Helper function to mock agent store with specific state
|
||||
// Mock functions for handlers
|
||||
const mockHandleStop = vi.fn();
|
||||
const mockHandleResumeAgent = vi.fn();
|
||||
|
||||
// Helper function to mock agent state with specific state
|
||||
const mockAgentStore = (agentState: AgentState) => {
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,20 +100,42 @@ describe("ServerStatus", () => {
|
||||
|
||||
// Test RUNNING status
|
||||
const { rerender } = renderWithProviders(
|
||||
<ServerStatus conversationStatus="RUNNING" />,
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test STOPPED status
|
||||
rerender(<ServerStatus conversationStatus="STOPPED" />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
|
||||
|
||||
// Test STARTING status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(<ServerStatus conversationStatus="STARTING" />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test null status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(<ServerStatus conversationStatus={null} />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -108,7 +145,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -128,7 +171,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -148,7 +197,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -165,12 +220,18 @@ describe("ServerStatus", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockStopConversationMutate.mockClear();
|
||||
mockHandleStop.mockClear();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -178,21 +239,25 @@ describe("ServerStatus", () => {
|
||||
const stopButton = screen.getByTestId("stop-server-button");
|
||||
await user.click(stopButton);
|
||||
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call start conversation mutation when start server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockStartConversationMutate.mockClear();
|
||||
mockHandleResumeAgent.mockClear();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -200,10 +265,7 @@ describe("ServerStatus", () => {
|
||||
const startButton = screen.getByTestId("start-server-button");
|
||||
await user.click(startButton);
|
||||
|
||||
expect(mockStartConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
providers: [],
|
||||
});
|
||||
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after stop server action", async () => {
|
||||
@@ -212,7 +274,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -221,9 +289,7 @@ describe("ServerStatus", () => {
|
||||
await user.click(stopButton);
|
||||
|
||||
// Context menu should be closed (handled by the component)
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after start server action", async () => {
|
||||
@@ -232,7 +298,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -250,7 +322,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus={null} />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusText = screen.getByText("Running");
|
||||
expect(statusText).toBeInTheDocument();
|
||||
|
||||
@@ -5,12 +5,12 @@ import { MemoryRouter } from "react-router";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation store
|
||||
@@ -57,14 +57,11 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
|
||||
describe("InteractiveChatBox", () => {
|
||||
const onSubmitMock = vi.fn();
|
||||
const onStopMock = vi.fn();
|
||||
|
||||
// Helper function to mock stores
|
||||
const mockStores = (agentState: AgentState = AgentState.INIT) => {
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mocked(useConversationStore).mockReturnValue({
|
||||
@@ -103,14 +100,13 @@ describe("InteractiveChatBox", () => {
|
||||
};
|
||||
|
||||
// Helper function to render with Router context
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) => {
|
||||
return renderWithProviders(
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) =>
|
||||
renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox {...props} />
|
||||
</MemoryRouter>,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
global.URL.createObjectURL = vi
|
||||
@@ -127,7 +123,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const chatBox = screen.getByTestId("interactive-chat-box");
|
||||
@@ -140,7 +135,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const textbox = screen.getByTestId("chat-input");
|
||||
@@ -157,7 +151,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
// Create a larger file to ensure it passes validation
|
||||
@@ -184,7 +177,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
|
||||
@@ -209,7 +201,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
@@ -240,7 +231,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const button = screen.getByTestId("submit-button");
|
||||
@@ -250,33 +240,14 @@ describe("InteractiveChatBox", () => {
|
||||
expect(onSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the stop button when agent is running and call onStop when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockStores(AgentState.RUNNING);
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
// The stop button should be available when agent is running
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
await user.click(stopButton);
|
||||
expect(onStopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle image upload and message submission correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
mockStores(AgentState.AWAITING_USER_INPUT);
|
||||
|
||||
const { rerender } = renderInteractiveChatBox({
|
||||
onSubmit: onSubmit,
|
||||
onStop: onStop,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// Verify text input has the initial value
|
||||
@@ -296,7 +267,7 @@ describe("InteractiveChatBox", () => {
|
||||
// Simulate parent component updating the value prop
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox onSubmit={onSubmit} onStop={onStop} />
|
||||
<InteractiveChatBox onSubmit={onSubmit} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
|
||||
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
@@ -30,11 +30,9 @@ describe("JupyterEditor", () => {
|
||||
});
|
||||
|
||||
it("should have a scrollable container", () => {
|
||||
// Mock agent store to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.RUNNING,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
|
||||
@@ -5,11 +5,11 @@ import { renderWithProviders } from "test-utils";
|
||||
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation ID hook
|
||||
@@ -50,11 +50,9 @@ describe("MicroagentsModal - Refresh Button", () => {
|
||||
microagents: mockMicroagents,
|
||||
});
|
||||
|
||||
// Mock the agent store to return a ready state
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
// Mock the agent state to return a ready state
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render } from "@testing-library/react";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
@@ -19,16 +19,34 @@ import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
|
||||
beforeAll(() => mswServer.listen());
|
||||
beforeAll(() => {
|
||||
// The global MSW server from vitest.setup.ts is already running
|
||||
// We just need to start our WebSocket-specific server
|
||||
mswServer.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mswServer.resetHandlers();
|
||||
// Clean up any React components
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the WebSocket MSW server
|
||||
mswServer.close();
|
||||
|
||||
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
afterAll(() => mswServer.close());
|
||||
|
||||
// Helper function to render components with ConversationWebSocketProvider
|
||||
function renderWithWebSocketContext(
|
||||
children: React.ReactNode,
|
||||
conversationId = "test-conversation-default",
|
||||
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-default",
|
||||
sessionApiKey: string | null = null,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -39,7 +57,11 @@ function renderWithWebSocketContext(
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationWebSocketProvider conversationId={conversationId}>
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversationUrl}
|
||||
sessionApiKey={sessionApiKey}
|
||||
>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
</QueryClientProvider>,
|
||||
@@ -394,4 +416,98 @@ describe("Conversation WebSocket Handler", () => {
|
||||
it.todo("should send user actions through WebSocket when connected");
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
|
||||
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashAction event
|
||||
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBashActionEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context (we don't need a component, just need the provider to be active)
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the command to be added to the store
|
||||
await waitFor(() => {
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands.length).toBe(1);
|
||||
});
|
||||
|
||||
// Verify the command was added with correct type and content
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands[0].type).toBe("input");
|
||||
expect(commands[0].content).toBe("npm test");
|
||||
});
|
||||
|
||||
it("should append output to store when ExecuteBashObservation event is received", async () => {
|
||||
const { createMockExecuteBashObservationEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashObservation event
|
||||
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
"npm test",
|
||||
);
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBashObservationEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the output to be added to the store
|
||||
await waitFor(() => {
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands.length).toBe(1);
|
||||
});
|
||||
|
||||
// Verify the output was added with correct type and content
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands[0].type).toBe("output");
|
||||
expect(commands[0].content).toBe(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,9 @@ export const createWebSocketTestSetup = (
|
||||
|
||||
/**
|
||||
* Standard WebSocket test setup for conversation WebSocket handler tests
|
||||
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
|
||||
*/
|
||||
export const conversationWebSocketTestSetup = () =>
|
||||
createWebSocketTestSetup("ws://localhost/events/socket");
|
||||
createWebSocketTestSetup(
|
||||
"ws://localhost:3000/sockets/events/test-conversation-default",
|
||||
);
|
||||
|
||||
@@ -10,11 +10,13 @@ import { OpenHandsEvent } from "#/types/v1/core";
|
||||
* Test component to access and display WebSocket connection state
|
||||
*/
|
||||
export function ConnectionStatusComponent() {
|
||||
const { connectionState } = useConversationWebSocket();
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">{connectionState}</div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,22 @@ vi.mock("#/context/ws-client-provider", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useActiveConversation
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
data: {
|
||||
id: "test-conversation-id",
|
||||
conversation_version: "V0",
|
||||
},
|
||||
isFetched: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useConversationWebSocket (returns null for V0 conversations)
|
||||
vi.mock("#/contexts/conversation-websocket-context", () => ({
|
||||
useConversationWebSocket: () => null,
|
||||
}));
|
||||
|
||||
function TestTerminalComponent() {
|
||||
const ref = useTerminal();
|
||||
return <div ref={ref} />;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ws } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { useWebSocket } from "#/hooks/use-websocket";
|
||||
|
||||
describe.skip("useWebSocket", () => {
|
||||
describe("useWebSocket", () => {
|
||||
// MSW WebSocket mock setup
|
||||
const wsLink = ws.link("ws://acme.com/ws");
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => mockUseIsAuthed(),
|
||||
}));
|
||||
|
||||
// Mock useIsAllHandsSaaSEnvironment hook
|
||||
const mockUseIsAllHandsSaaSEnvironment = vi.fn();
|
||||
vi.mock("#/hooks/use-is-all-hands-saas-environment", () => ({
|
||||
useIsAllHandsSaaSEnvironment: () => mockUseIsAllHandsSaaSEnvironment(),
|
||||
}));
|
||||
|
||||
const renderLlmSettingsScreen = () =>
|
||||
render(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
@@ -48,6 +54,9 @@ beforeEach(() => {
|
||||
|
||||
// Default mock for useIsAuthed - returns authenticated by default
|
||||
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
|
||||
|
||||
// Default mock for useIsAllHandsSaaSEnvironment - returns true for SaaS environment
|
||||
mockUseIsAllHandsSaaSEnvironment.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
@@ -104,11 +113,17 @@ describe("Content", () => {
|
||||
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should conditionally show security analyzer based on confirmation mode", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Enable advanced mode first
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
const confirmation = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
@@ -135,9 +150,7 @@ describe("Content", () => {
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should render the advanced form if the switch is toggled", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
@@ -615,7 +628,7 @@ describe("Form submission", () => {
|
||||
expect.objectContaining({
|
||||
llm_model: "openhands/claude-sonnet-4-20250514",
|
||||
llm_base_url: "",
|
||||
confirmation_mode: true, // Confirmation mode is now a basic setting, should be preserved
|
||||
confirmation_mode: false, // Confirmation mode is now an advanced setting, should be cleared when saving basic settings
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -776,9 +789,6 @@ describe("SaaS mode", () => {
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
// Inputs should be disabled
|
||||
@@ -786,9 +796,13 @@ describe("SaaS mode", () => {
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, so it's not visible in basic view
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to interact with inputs - they should not respond
|
||||
await userEvent.click(providerInput);
|
||||
await userEvent.type(apiKeyInput, "test-key");
|
||||
@@ -935,19 +949,17 @@ describe("SaaS mode", () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify that form elements are disabled for unsubscribed users
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
// Verify that basic form elements are disabled for unsubscribed users
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
expect(confirmationModeSwitch).not.toBeChecked();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Try to click the disabled confirmation mode switch - it should not change state
|
||||
await userEvent.click(confirmationModeSwitch);
|
||||
expect(confirmationModeSwitch).not.toBeChecked(); // Should remain unchecked
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to submit the form - button should remain disabled
|
||||
await userEvent.click(submitButton);
|
||||
@@ -1107,14 +1119,17 @@ describe("SaaS mode", () => {
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("Check for hardcoded English strings", () => {
|
||||
test("InteractiveChatBox should not have hardcoded English strings", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />
|
||||
<InteractiveChatBox onSubmit={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
CreateMicroagent,
|
||||
FileUploadSuccessResponse,
|
||||
GetFilesResponse,
|
||||
GetFileResponse,
|
||||
} from "../open-hands.types";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { Provider } from "#/types/settings";
|
||||
@@ -159,19 +158,6 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
*/
|
||||
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/zip-directory`;
|
||||
const response = await openHands.get(url, {
|
||||
responseType: "blob",
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the web hosts
|
||||
* @returns Array of web hosts
|
||||
@@ -201,7 +187,7 @@ class ConversationService {
|
||||
static async getRuntimeId(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/config`;
|
||||
const url = `/api/conversations/${conversationId}/config`;
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(url, {
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
@@ -379,22 +365,6 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Code content of the file
|
||||
*/
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/select-file`;
|
||||
const { data } = await openHands.get<GetFileResponse>(url, {
|
||||
params: { file: path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import axios from "axios";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import type {
|
||||
V1SendMessageRequest,
|
||||
V1SendMessageResponse,
|
||||
V1AppConversationStartRequest,
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
private static buildSessionHeaders(
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param path The API path (e.g., "/api/vscode/url")
|
||||
* @returns Full URL to the runtime endpoint
|
||||
*/
|
||||
private static buildRuntimeUrl(
|
||||
conversationUrl: string | null | undefined,
|
||||
path: string,
|
||||
): string {
|
||||
const baseUrl = buildHttpBaseUrl(conversationUrl);
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param message The message to send
|
||||
* @returns The sent message response
|
||||
*/
|
||||
static async sendMessage(
|
||||
conversationId: string,
|
||||
message: V1SendMessageRequest,
|
||||
): Promise<V1SendMessageResponse> {
|
||||
const { data } = await openHands.post<V1SendMessageResponse>(
|
||||
`/api/conversations/${conversationId}/events`,
|
||||
message,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new V1 conversation using the app-conversations API
|
||||
* Returns the start task immediately with app_conversation_id as null.
|
||||
* You must poll getStartTask() until status is READY to get the conversation ID.
|
||||
*
|
||||
* @returns AppConversationStartTask with task ID
|
||||
*/
|
||||
static async createConversation(
|
||||
selectedRepository?: string,
|
||||
git_provider?: Provider,
|
||||
initialUserMsg?: string,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
trigger?: ConversationTrigger,
|
||||
): Promise<V1AppConversationStartTask> {
|
||||
const body: V1AppConversationStartRequest = {
|
||||
selected_repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
title: conversationInstructions,
|
||||
trigger,
|
||||
};
|
||||
|
||||
// Add initial message if provided
|
||||
if (initialUserMsg) {
|
||||
body.initial_message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: initialUserMsg,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = await openHands.post<V1AppConversationStartTask>(
|
||||
"/api/v1/app-conversations",
|
||||
body,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a start task by ID
|
||||
* Poll this endpoint until status is READY to get the app_conversation_id
|
||||
*
|
||||
* @param taskId The task UUID
|
||||
* @returns AppConversationStartTask or null
|
||||
*/
|
||||
static async getStartTask(
|
||||
taskId: string,
|
||||
): Promise<V1AppConversationStartTask | null> {
|
||||
const { data } = await openHands.get<(V1AppConversationStartTask | null)[]>(
|
||||
`/api/v1/app-conversations/start-tasks?ids=${taskId}`,
|
||||
);
|
||||
|
||||
return data[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for start tasks (ongoing tasks that haven't completed yet)
|
||||
* Use this to find tasks that were started but the user navigated away
|
||||
*
|
||||
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
|
||||
* filter the results client-side after fetching.
|
||||
*
|
||||
* @param limit Maximum number of tasks to return (max 100)
|
||||
* @returns Array of start tasks
|
||||
*/
|
||||
static async searchStartTasks(
|
||||
limit: number = 100,
|
||||
): Promise<V1AppConversationStartTask[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
|
||||
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VSCode URL for a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
* Note: V1 endpoint doesn't require conversationId in the URL path - it's identified via session API key header
|
||||
*
|
||||
* @param _conversationId The conversation ID (not used in V1, kept for interface compatibility)
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns VSCode URL response
|
||||
*/
|
||||
static async getVSCodeUrl(
|
||||
_conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
||||
// Map it to match the expected interface
|
||||
const { data } = await axios.get<{ url: string | null }>(url, { headers });
|
||||
return {
|
||||
vscode_url: data.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
*
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseConversation(
|
||||
conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<{ success: boolean }> {
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/pause`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
{},
|
||||
{ headers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/pause endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to pause
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/resume endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to resume
|
||||
* @returns Success response
|
||||
*/
|
||||
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/resume`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 app conversations by their IDs
|
||||
* Returns null for any missing conversations
|
||||
*
|
||||
* @param ids Array of conversation IDs (max 100)
|
||||
* @returns Array of conversations or null for missing ones
|
||||
*/
|
||||
static async batchGetAppConversations(
|
||||
ids: string[],
|
||||
): Promise<(V1AppConversation | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 conversations at once");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("ids", id));
|
||||
|
||||
const { data } = await openHands.get<(V1AppConversation | null)[]>(
|
||||
`/api/v1/app-conversations?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single file to the V1 conversation workspace
|
||||
* V1 API endpoint: POST /api/file/upload/{path}
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @param file The file to upload
|
||||
* @param path The absolute path where the file should be uploaded (defaults to /workspace/{file.name})
|
||||
* @returns void on success, throws on error
|
||||
*/
|
||||
static async uploadFile(
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey: string | null | undefined,
|
||||
file: File,
|
||||
path?: string,
|
||||
): Promise<void> {
|
||||
// Default to /workspace/{filename} if no path provided (must be absolute)
|
||||
const uploadPath = path || `/workspace/${file.name}`;
|
||||
const encodedPath = encodeURIComponent(uploadPath);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/file/upload/${encodedPath}`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Create FormData with the file
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
// Upload file
|
||||
await axios.post(url, formData, {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ConversationTrigger } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
// V1 API Types for requests
|
||||
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
|
||||
export interface V1MessageContent {
|
||||
type: "text" | "image_url";
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
type V1Role = "user" | "system" | "assistant" | "tool";
|
||||
|
||||
export interface V1SendMessageRequest {
|
||||
role: V1Role;
|
||||
content: V1MessageContent[];
|
||||
}
|
||||
|
||||
export interface V1AppConversationStartRequest {
|
||||
sandbox_id?: string | null;
|
||||
initial_message?: V1SendMessageRequest | null;
|
||||
processors?: unknown[]; // EventCallbackProcessor - keeping as unknown for now
|
||||
llm_model?: string | null;
|
||||
selected_repository?: string | null;
|
||||
selected_branch?: string | null;
|
||||
git_provider?: Provider | null;
|
||||
title?: string | null;
|
||||
trigger?: ConversationTrigger | null;
|
||||
pr_number?: number[];
|
||||
}
|
||||
|
||||
export type V1AppConversationStartTaskStatus =
|
||||
| "WORKING"
|
||||
| "WAITING_FOR_SANDBOX"
|
||||
| "PREPARING_REPOSITORY"
|
||||
| "RUNNING_SETUP_SCRIPT"
|
||||
| "SETTING_UP_GIT_HOOKS"
|
||||
| "STARTING_CONVERSATION"
|
||||
| "READY"
|
||||
| "ERROR";
|
||||
|
||||
export interface V1AppConversationStartTask {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
status: V1AppConversationStartTaskStatus;
|
||||
detail: string | null;
|
||||
app_conversation_id: string | null;
|
||||
sandbox_id: string | null;
|
||||
agent_server_url: string | null;
|
||||
request: V1AppConversationStartRequest;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface V1SendMessageResponse {
|
||||
role: "user" | "system" | "assistant" | "tool";
|
||||
content: V1MessageContent[];
|
||||
}
|
||||
|
||||
export interface V1AppConversationStartTaskPage {
|
||||
items: V1AppConversationStartTask[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
export type V1SandboxStatus =
|
||||
| "MISSING"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "PAUSED";
|
||||
|
||||
export type V1AgentExecutionStatus =
|
||||
| "RUNNING"
|
||||
| "AWAITING_USER_INPUT"
|
||||
| "AWAITING_USER_CONFIRMATION"
|
||||
| "FINISHED"
|
||||
| "PAUSED"
|
||||
| "STOPPED";
|
||||
|
||||
export interface V1AppConversation {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
sandbox_id: string;
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: Provider | null;
|
||||
title: string | null;
|
||||
trigger: ConversationTrigger | null;
|
||||
pr_number: number[];
|
||||
llm_model: string | null;
|
||||
metrics: unknown | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sandbox_status: V1SandboxStatus;
|
||||
agent_status: V1AgentExecutionStatus | null;
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export interface Conversation {
|
||||
url: string | null;
|
||||
session_api_key: string | null;
|
||||
pr_number?: number[] | null;
|
||||
conversation_version?: "V0" | "V1";
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -8,16 +8,16 @@ import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { TypingIndicator } from "./typing-indicator";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { Messages as V0Messages } from "./messages";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ScrollProvider } from "#/context/scroll-context";
|
||||
import { useInitialQueryStore } from "#/stores/initial-query-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -30,12 +30,23 @@ import {
|
||||
hasUserEvent,
|
||||
shouldRenderEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import {
|
||||
Messages as V1Messages,
|
||||
hasUserEvent as hasV1UserEvent,
|
||||
shouldRenderEvent as shouldRenderV1Event,
|
||||
} from "#/components/v1/chat";
|
||||
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
import {
|
||||
isV0Event,
|
||||
isV1Event,
|
||||
isSystemPromptEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -48,8 +59,10 @@ function getEntryPoint(
|
||||
|
||||
export function ChatInterface() {
|
||||
const { setMessageToSend } = useConversationStore();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { send, isLoadingMessages } = useWsClient();
|
||||
const { isLoadingMessages } = useWsClient();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
@@ -65,7 +78,7 @@ export function ChatInterface() {
|
||||
} = useScrollToBottom(scrollRef);
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
|
||||
"positive" | "negative"
|
||||
@@ -73,15 +86,24 @@ export function ChatInterface() {
|
||||
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
|
||||
const { selectedRepository, replayJson } = useInitialQueryStore();
|
||||
const params = useParams();
|
||||
const { mutateAsync: uploadFiles } = useUploadFiles();
|
||||
const { mutateAsync: uploadFiles } = useUnifiedUploadFiles();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const events = storeEvents
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events
|
||||
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1Events.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
@@ -93,6 +115,14 @@ export function ChatInterface() {
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
) ||
|
||||
storeEvents
|
||||
.filter(isV1Event)
|
||||
.some(
|
||||
(event) =>
|
||||
event.source === "agent" &&
|
||||
!isSystemPromptEvent(event) &&
|
||||
!isConversationStateUpdateEvent(event),
|
||||
),
|
||||
[storeEvents],
|
||||
);
|
||||
@@ -105,7 +135,7 @@ export function ChatInterface() {
|
||||
// Create mutable copies of the arrays
|
||||
const images = [...originalImages];
|
||||
const files = [...originalFiles];
|
||||
if (events.length === 0) {
|
||||
if (totalEvents === 0) {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: getEntryPoint(
|
||||
selectedRepository !== null,
|
||||
@@ -116,7 +146,7 @@ export function ChatInterface() {
|
||||
});
|
||||
} else {
|
||||
posthog.capture("user_message_sent", {
|
||||
session_message_count: events.length,
|
||||
session_message_count: totalEvents,
|
||||
current_message_length: content.length,
|
||||
});
|
||||
}
|
||||
@@ -151,11 +181,6 @@ export function ChatInterface() {
|
||||
setMessageToSend("");
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
posthog.capture("stop_button_clicked");
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const onClickShareFeedbackActionButton = async (
|
||||
polarity: "positive" | "negative",
|
||||
) => {
|
||||
@@ -174,7 +199,9 @@ export function ChatInterface() {
|
||||
onChatBodyScroll,
|
||||
};
|
||||
|
||||
const userEventsExist = hasUserEvent(events);
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1Events);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
return (
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
@@ -193,15 +220,24 @@ export function ChatInterface() {
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
|
||||
>
|
||||
{isLoadingMessages && (
|
||||
{isLoadingMessages && !isV1Conversation && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && userEventsExist && (
|
||||
<Messages
|
||||
messages={events}
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{v1UserEventsExist && (
|
||||
<V1Messages
|
||||
messages={v1Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
@@ -213,7 +249,7 @@ export function ChatInterface() {
|
||||
<div className="flex justify-between relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<ConfirmationModeEnabled />
|
||||
{events.length > 0 && (
|
||||
{totalEvents > 0 && !isV1Conversation && (
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={() =>
|
||||
onClickShareFeedbackActionButton("positive")
|
||||
@@ -235,13 +271,10 @@ export function ChatInterface() {
|
||||
|
||||
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
|
||||
|
||||
<InteractiveChatBox
|
||||
onSubmit={handleSendMessage}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<InteractiveChatBox onSubmit={handleSendMessage} />
|
||||
</div>
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
{config?.APP_MODE !== "saas" && !isV1Conversation && (
|
||||
<FeedbackModal
|
||||
isOpen={feedbackModalIsOpen}
|
||||
onClose={() => setFeedbackModalIsOpen(false)}
|
||||
|
||||
@@ -2,33 +2,73 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { Tools } from "../../controls/tools";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
disabled: boolean;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputActions({
|
||||
conversationStatus,
|
||||
disabled,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onStop,
|
||||
}: ChatInputActionsProps) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
|
||||
const resumeConversationSandboxMutation =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { conversationId } = useConversationId();
|
||||
const { providers } = useUserProviders();
|
||||
const { send } = useSendMessage();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const handleStopClick = () => {
|
||||
pauseConversationSandboxMutation.mutate({ conversationId });
|
||||
};
|
||||
|
||||
const handlePauseAgent = () => {
|
||||
if (isV1Conversation) {
|
||||
// V1: Empty function for now
|
||||
return;
|
||||
}
|
||||
|
||||
// V0: Send agent state change event to stop the agent
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
resumeConversationSandboxMutation.mutate({ conversationId, providers });
|
||||
};
|
||||
|
||||
const isPausing = pauseConversationSandboxMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
handleStop={handleStopClick}
|
||||
handleResumeAgent={handleStartClick}
|
||||
/>
|
||||
</div>
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
handleStop={() => handleStop(onStop)}
|
||||
handleStop={handlePauseAgent}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
isPausing={isPausing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ interface ChatInputContainerProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
handleSubmit: () => void;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
@@ -25,7 +24,6 @@ interface ChatInputContainerProps {
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputContainer({
|
||||
@@ -38,7 +36,6 @@ export function ChatInputContainer({
|
||||
chatInputRef,
|
||||
handleFileIconClick,
|
||||
handleSubmit,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
@@ -48,7 +45,6 @@ export function ChatInputContainer({
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onStop,
|
||||
}: ChatInputContainerProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -80,9 +76,7 @@ export function ChatInputContainer({
|
||||
<ChatInputActions
|
||||
conversationStatus={conversationStatus}
|
||||
disabled={disabled}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface CustomChatInputProps {
|
||||
showButton?: boolean;
|
||||
conversationStatus?: ConversationStatus | null;
|
||||
onSubmit: (message: string) => void;
|
||||
onStop?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onFilesPaste?: (files: File[]) => void;
|
||||
@@ -28,7 +27,6 @@ export function CustomChatInput({
|
||||
showButton = true,
|
||||
conversationStatus = null,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onFilesPaste,
|
||||
@@ -88,7 +86,7 @@ export function CustomChatInput({
|
||||
messageToSend,
|
||||
);
|
||||
|
||||
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
|
||||
const { handleSubmit, handleResumeAgent } = useChatSubmission(
|
||||
chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
fileInputRef as React.RefObject<HTMLInputElement | null>,
|
||||
smartResize,
|
||||
@@ -143,7 +141,6 @@ export function CustomChatInput({
|
||||
chatInputRef={chatInputRef}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -153,7 +150,6 @@ export function CustomChatInput({
|
||||
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { GitControlBarPullButton } from "./git-control-bar-pull-button";
|
||||
import { GitControlBarPushButton } from "./git-control-bar-push-button";
|
||||
import { GitControlBarPrButton } from "./git-control-bar-pr-button";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
|
||||
@@ -17,10 +18,16 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { repositoryInfo } = useTaskPolling();
|
||||
|
||||
const selectedRepository = conversation?.selected_repository;
|
||||
const gitProvider = conversation?.git_provider as Provider;
|
||||
const selectedBranch = conversation?.selected_branch;
|
||||
// Priority: conversation data > task data
|
||||
// This ensures we show repository info immediately from task, then transition to conversation data
|
||||
const selectedRepository =
|
||||
conversation?.selected_repository || repositoryInfo?.selectedRepository;
|
||||
const gitProvider = (conversation?.git_provider ||
|
||||
repositoryInfo?.gitProvider) as Provider;
|
||||
const selectedBranch =
|
||||
conversation?.selected_branch || repositoryInfo?.selectedBranch;
|
||||
|
||||
const hasRepository = !!selectedRepository;
|
||||
|
||||
|
||||
@@ -6,18 +6,14 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { GitControlBar } from "./git-control-bar";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { processFiles, processImages } from "#/utils/file-processing";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({
|
||||
onSubmit,
|
||||
onStop,
|
||||
}: InteractiveChatBoxProps) {
|
||||
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
const {
|
||||
images,
|
||||
files,
|
||||
@@ -29,7 +25,7 @@ export function InteractiveChatBox({
|
||||
addImageLoading,
|
||||
removeImageLoading,
|
||||
} = useConversationStore();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
// Helper function to validate and filter files
|
||||
@@ -145,7 +141,6 @@ export function InteractiveChatBox({
|
||||
<CustomChatInput
|
||||
disabled={isDisabled}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
onFilesPaste={handleUpload}
|
||||
conversationStatus={conversation?.status || null}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
|
||||
typeof evt === "object" &&
|
||||
@@ -51,6 +52,11 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useUserConversation(conversationId);
|
||||
const { data: activeConversation } = useActiveConversation();
|
||||
|
||||
// TODO: Hide microagent actions for V1 conversations
|
||||
// This is a temporary measure and may be re-enabled in the future
|
||||
const isV1Conversation = activeConversation?.conversation_version === "V1";
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
@@ -236,7 +242,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
)}
|
||||
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
|
||||
actions={
|
||||
conversation?.selected_repository
|
||||
conversation?.selected_repository && !isV1Conversation
|
||||
? [
|
||||
{
|
||||
icon: (
|
||||
@@ -259,6 +265,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
<ChatMessage type="user" message={optimisticUserMessage} />
|
||||
)}
|
||||
{conversation?.selected_repository &&
|
||||
!isV1Conversation &&
|
||||
showLaunchMicroagentModal &&
|
||||
selectedEventId &&
|
||||
createPortal(
|
||||
|
||||
@@ -14,6 +14,7 @@ import { LoadingMicroagentBody } from "./loading-microagent-body";
|
||||
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
|
||||
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
interface LaunchMicroagentModalProps {
|
||||
onClose: () => void;
|
||||
@@ -32,6 +33,7 @@ export function LaunchMicroagentModal({
|
||||
}: LaunchMicroagentModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { runtimeActive } = useHandleRuntimeActive();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: prompt, isLoading: promptIsLoading } =
|
||||
useMicroagentPrompt(eventId);
|
||||
|
||||
@@ -40,6 +42,15 @@ export function LaunchMicroagentModal({
|
||||
|
||||
const [triggers, setTriggers] = React.useState<string[]>([]);
|
||||
|
||||
// TODO: Hide LaunchMicroagentModal for V1 conversations
|
||||
// This is a temporary measure and may be re-enabled in the future
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Don't render anything for V1 conversations
|
||||
if (isV1Conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const query = formData.get("query-input")?.toString();
|
||||
const target = formData.get("target-input")?.toString();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useStatusStore } from "#/state/status-store";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { getStatusCode } from "#/utils/status";
|
||||
import { ChatStopButton } from "../chat/chat-stop-button";
|
||||
@@ -12,13 +11,15 @@ import { cn } from "#/utils/utils";
|
||||
import { AgentLoading } from "./agent-loading";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import CircleErrorIcon from "#/icons/circle-error.svg?react";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||
|
||||
export interface AgentStatusProps {
|
||||
className?: string;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
disabled?: boolean;
|
||||
isPausing?: boolean;
|
||||
}
|
||||
|
||||
export function AgentStatus({
|
||||
@@ -26,12 +27,13 @@ export function AgentStatus({
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
disabled = false,
|
||||
isPausing = false,
|
||||
}: AgentStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setShouldShownAgentLoading } = useConversationStore();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curStatusMessage } = useStatusStore();
|
||||
const { webSocketStatus } = useWsClient();
|
||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const statusCode = getStatusCode(
|
||||
@@ -43,6 +45,7 @@ export function AgentStatus({
|
||||
);
|
||||
|
||||
const shouldShownAgentLoading =
|
||||
isPausing ||
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING ||
|
||||
webSocketStatus === "CONNECTING";
|
||||
|
||||
@@ -5,31 +5,29 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ServerStatusContextMenu } from "./server-status-context-menu";
|
||||
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
|
||||
export interface ServerStatusProps {
|
||||
className?: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
isPausing?: boolean;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ServerStatus({
|
||||
className = "",
|
||||
conversationStatus,
|
||||
isPausing = false,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
}: ServerStatusProps) {
|
||||
const [showContextMenu, setShowContextMenu] = useState(false);
|
||||
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
// Mutation hooks
|
||||
const stopConversationMutation = useStopConversation();
|
||||
const startConversationMutation = useStartConversation();
|
||||
const { providers } = useUserProviders();
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
@@ -38,6 +36,19 @@ export function ServerStatus({
|
||||
|
||||
// Get the appropriate color based on agent status
|
||||
const getStatusColor = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return "#FFD600";
|
||||
}
|
||||
@@ -52,6 +63,31 @@ export function ServerStatus({
|
||||
|
||||
// Get the appropriate status text based on agent status
|
||||
const getStatusText = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return t(I18nKey.COMMON$STOPPING);
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return (
|
||||
taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION)
|
||||
);
|
||||
}
|
||||
if (taskStatus === "READY") {
|
||||
return t(I18nKey.CONVERSATION$READY);
|
||||
}
|
||||
// Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox"
|
||||
return (
|
||||
taskDetail ||
|
||||
taskStatus
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return t(I18nKey.COMMON$STARTING);
|
||||
}
|
||||
@@ -76,16 +112,13 @@ export function ServerStatus({
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
stopConversationMutation.mutate({ conversationId });
|
||||
handleStop();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
startConversationMutation.mutate({
|
||||
conversationId,
|
||||
providers,
|
||||
});
|
||||
handleResumeAgent();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -28,17 +28,23 @@ interface ToolsContextMenuProps {
|
||||
onClose: () => void;
|
||||
onShowMicroagents: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowAgentTools: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
shouldShowAgentTools?: boolean;
|
||||
}
|
||||
|
||||
export function ToolsContextMenu({
|
||||
onClose,
|
||||
onShowMicroagents,
|
||||
onShowAgentTools,
|
||||
shouldShowAgentTools = true,
|
||||
}: ToolsContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
// TODO: Hide microagent menu items for V1 conversations
|
||||
// This is a temporary measure and may be re-enabled in the future
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const [activeSubmenu, setActiveSubmenu] = useState<"git" | "macros" | null>(
|
||||
null,
|
||||
);
|
||||
@@ -64,7 +70,7 @@ export function ToolsContextMenu({
|
||||
testId="tools-context-menu"
|
||||
position="top"
|
||||
alignment="left"
|
||||
className="left-[-16px] mb-2 bottom-full overflow-visible"
|
||||
className="left-[-16px] mb-2 bottom-full overflow-visible min-w-[200px]"
|
||||
>
|
||||
{/* Git Tools */}
|
||||
{showGitTools && (
|
||||
@@ -122,33 +128,37 @@ export function ToolsContextMenu({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
{(!isV1Conversation || shouldShowAgentTools) && <Divider />}
|
||||
|
||||
{/* Show Available Microagents */}
|
||||
<ContextMenuListItem
|
||||
testId="show-microagents-button"
|
||||
onClick={onShowMicroagents}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ToolsContextMenuIconText
|
||||
icon={<RobotIcon width={16} height={16} />}
|
||||
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
|
||||
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
{/* Show Available Microagents - Hidden for V1 conversations */}
|
||||
{!isV1Conversation && (
|
||||
<ContextMenuListItem
|
||||
testId="show-microagents-button"
|
||||
onClick={onShowMicroagents}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ToolsContextMenuIconText
|
||||
icon={<RobotIcon width={16} height={16} />}
|
||||
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
|
||||
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{/* Show Agent Tools and Metadata */}
|
||||
<ContextMenuListItem
|
||||
testId="show-agent-tools-button"
|
||||
onClick={onShowAgentTools}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ToolsContextMenuIconText
|
||||
icon={<ToolsIcon width={16} height={16} />}
|
||||
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
|
||||
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
{/* Show Agent Tools and Metadata - Only show if system message is available */}
|
||||
{shouldShowAgentTools && (
|
||||
<ContextMenuListItem
|
||||
testId="show-agent-tools-button"
|
||||
onClick={onShowAgentTools}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ToolsContextMenuIconText
|
||||
icon={<ToolsIcon width={16} height={16} />}
|
||||
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
|
||||
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export function Tools() {
|
||||
microagentsModalVisible,
|
||||
setMicroagentsModalVisible,
|
||||
systemMessage,
|
||||
shouldShowAgentTools,
|
||||
} = useConversationNameContextMenu({
|
||||
conversationId,
|
||||
conversationStatus: conversation?.status,
|
||||
@@ -52,6 +53,7 @@ export function Tools() {
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
onShowMicroagents={handleShowMicroagents}
|
||||
onShowAgentTools={handleShowAgentTools}
|
||||
shouldShowAgentTools={shouldShowAgentTools}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
interface ConversationCardContextMenuProps {
|
||||
onClose: () => void;
|
||||
@@ -41,6 +42,11 @@ export function ConversationCardContextMenu({
|
||||
}: ConversationCardContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
// TODO: Hide microagent menu items for V1 conversations
|
||||
// This is a temporary measure and may be re-enabled in the future
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const hasEdit = Boolean(onEdit);
|
||||
const hasDownload = Boolean(onDownloadViaVSCode);
|
||||
@@ -97,7 +103,7 @@ export function ConversationCardContextMenu({
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{onShowMicroagents && (
|
||||
{onShowMicroagents && !isV1Conversation && (
|
||||
<ContextMenuListItem
|
||||
testId="show-microagents-button"
|
||||
onClick={onShowMicroagents}
|
||||
|
||||
@@ -27,6 +27,8 @@ export function ConversationCardActions({
|
||||
conversationId,
|
||||
showOptions,
|
||||
}: ConversationCardActionsProps) {
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
<button
|
||||
@@ -37,7 +39,10 @@ export function ConversationCardActions({
|
||||
event.stopPropagation();
|
||||
onContextMenuToggle(!contextMenuOpen);
|
||||
}}
|
||||
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5"
|
||||
className={cn(
|
||||
"cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<EllipsisIcon />
|
||||
</button>
|
||||
|
||||
@@ -5,22 +5,32 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { NoRepository } from "./no-repository";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
|
||||
interface ConversationCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
}
|
||||
|
||||
export function ConversationCardFooter({
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
createdAt,
|
||||
conversationStatus,
|
||||
}: ConversationCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-row justify-between items-center mt-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row justify-between items-center mt-1",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{selectedRepository?.selected_repository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
|
||||
@@ -2,12 +2,14 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ConversationCardTitle } from "./conversation-card-title";
|
||||
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
|
||||
import { ConversationStatusBadges } from "./conversation-status-badges";
|
||||
import { ConversationVersionBadge } from "./conversation-version-badge";
|
||||
|
||||
interface ConversationCardHeaderProps {
|
||||
title: string;
|
||||
titleMode: "view" | "edit";
|
||||
onTitleSave: (title: string) => void;
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationVersion?: "V0" | "V1";
|
||||
}
|
||||
|
||||
export function ConversationCardHeader({
|
||||
@@ -15,7 +17,10 @@ export function ConversationCardHeader({
|
||||
titleMode,
|
||||
onTitleSave,
|
||||
conversationStatus,
|
||||
conversationVersion,
|
||||
}: ConversationCardHeaderProps) {
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
@@ -26,10 +31,16 @@ export function ConversationCardHeader({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Version Badge */}
|
||||
<ConversationVersionBadge
|
||||
version={conversationVersion}
|
||||
isConversationArchived={isConversationArchived}
|
||||
/>
|
||||
<ConversationCardTitle
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onSave={onTitleSave}
|
||||
isConversationArchived={isConversationArchived}
|
||||
/>
|
||||
{/* Status Badges */}
|
||||
{conversationStatus && (
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export type ConversationCardTitleMode = "view" | "edit";
|
||||
|
||||
export type ConversationCardTitleProps = {
|
||||
titleMode: ConversationCardTitleMode;
|
||||
title: string;
|
||||
onSave: (title: string) => void;
|
||||
isConversationArchived?: boolean;
|
||||
};
|
||||
|
||||
export function ConversationCardTitle({
|
||||
titleMode,
|
||||
title,
|
||||
onSave,
|
||||
isConversationArchived,
|
||||
}: ConversationCardTitleProps) {
|
||||
if (titleMode === "edit") {
|
||||
return (
|
||||
@@ -40,7 +44,10 @@ export function ConversationCardTitle({
|
||||
return (
|
||||
<p
|
||||
data-testid="conversation-card-title"
|
||||
className="text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden"
|
||||
className={cn(
|
||||
"text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface ConversationCardProps {
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationId?: string; // Optional conversation ID for VS Code URL
|
||||
conversationVersion?: "V0" | "V1";
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
@@ -39,6 +40,7 @@ export function ConversationCard({
|
||||
createdAt,
|
||||
conversationId,
|
||||
conversationStatus,
|
||||
conversationVersion,
|
||||
contextMenuOpen = false,
|
||||
onContextMenuToggle,
|
||||
}: ConversationCardProps) {
|
||||
@@ -108,7 +110,6 @@ export function ConversationCard({
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"data-[context-menu-open=false]:hover:bg-[#454545]",
|
||||
conversationStatus === "ARCHIVED" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
@@ -117,6 +118,7 @@ export function ConversationCard({
|
||||
titleMode={titleMode}
|
||||
onTitleSave={onTitleSave}
|
||||
conversationStatus={conversationStatus}
|
||||
conversationVersion={conversationVersion}
|
||||
/>
|
||||
|
||||
{hasContextMenu && (
|
||||
@@ -138,6 +140,7 @@ export function ConversationCard({
|
||||
selectedRepository={selectedRepository}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
createdAt={createdAt}
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ConversationStatusBadges({
|
||||
|
||||
if (conversationStatus === "ARCHIVED") {
|
||||
return (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full">
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full opacity-60">
|
||||
<FaArchive size={10} className="text-white" />
|
||||
<span>{t(I18nKey.COMMON$ARCHIVED)}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConversationVersionBadgeProps {
|
||||
version?: "V0" | "V1";
|
||||
isConversationArchived?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationVersionBadge({
|
||||
version,
|
||||
isConversationArchived,
|
||||
}: ConversationVersionBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!version) return null;
|
||||
|
||||
const tooltipText =
|
||||
version === "V1"
|
||||
? t(I18nKey.CONVERSATION$VERSION_V1_NEW)
|
||||
: t(I18nKey.CONVERSATION$VERSION_V0_LEGACY);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipText} placement="top">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold shrink-0 cursor-help lowercase",
|
||||
version === "V1"
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: "bg-neutral-500/20 text-neutral-400",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{version}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import { NavLink, useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
|
||||
import { useStartTasks } from "#/hooks/query/use-start-tasks";
|
||||
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "./confirm-stop-modal";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -15,6 +16,7 @@ import { Provider } from "#/types/settings";
|
||||
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { ConversationCard } from "./conversation-card/conversation-card";
|
||||
import { StartTaskCard } from "./start-task-card/start-task-card";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
onClose: () => void;
|
||||
@@ -37,6 +39,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const [selectedConversationId, setSelectedConversationId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedConversationVersion, setSelectedConversationVersion] =
|
||||
React.useState<"V0" | "V1" | undefined>(undefined);
|
||||
const [openContextMenuId, setOpenContextMenuId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -50,11 +54,15 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
fetchNextPage,
|
||||
} = usePaginatedConversations();
|
||||
|
||||
// Fetch in-progress start tasks
|
||||
const { data: startTasks } = useStartTasks();
|
||||
|
||||
// Flatten all pages into a single array of conversations
|
||||
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: pauseConversationSandbox } =
|
||||
useUnifiedPauseConversationSandbox();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
// Set up infinite scroll
|
||||
@@ -70,9 +78,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
setSelectedConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleStopConversation = (conversationId: string) => {
|
||||
const handleStopConversation = (
|
||||
conversationId: string,
|
||||
version?: "V0" | "V1",
|
||||
) => {
|
||||
setConfirmStopModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
setSelectedConversationVersion(version);
|
||||
};
|
||||
|
||||
const handleConversationTitleChange = async (
|
||||
@@ -106,7 +118,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const handleConfirmStop = () => {
|
||||
if (selectedConversationId) {
|
||||
stopConversation({ conversationId: selectedConversationId });
|
||||
pauseConversationSandbox({
|
||||
conversationId: selectedConversationId,
|
||||
version: selectedConversationVersion,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,13 +146,24 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && conversations?.length === 0 && (
|
||||
{!isFetching && conversations?.length === 0 && !startTasks?.length && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_CONVERSATIONS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Render in-progress start tasks first */}
|
||||
{startTasks?.map((task) => (
|
||||
<NavLink
|
||||
key={task.id}
|
||||
to={`/conversations/task-${task.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<StartTaskCard task={task} />
|
||||
</NavLink>
|
||||
))}
|
||||
{/* Then render completed conversations */}
|
||||
{conversations?.map((project) => (
|
||||
<NavLink
|
||||
key={project.conversation_id}
|
||||
@@ -146,7 +172,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
>
|
||||
<ConversationCard
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onStop={() => handleStopConversation(project.conversation_id)}
|
||||
onStop={() =>
|
||||
handleStopConversation(
|
||||
project.conversation_id,
|
||||
project.conversation_version,
|
||||
)
|
||||
}
|
||||
onChangeTitle={(title) =>
|
||||
handleConversationTitleChange(project.conversation_id, title)
|
||||
}
|
||||
@@ -160,6 +191,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
createdAt={project.created_at}
|
||||
conversationStatus={project.status}
|
||||
conversationId={project.conversation_id}
|
||||
conversationVersion={project.conversation_version}
|
||||
contextMenuOpen={openContextMenuId === project.conversation_id}
|
||||
onContextMenuToggle={(isOpen) =>
|
||||
setOpenContextMenuId(isOpen ? project.conversation_id : null)
|
||||
|
||||
@@ -10,7 +10,8 @@ import { MicroagentsModalHeader } from "./microagents-modal-header";
|
||||
import { MicroagentsLoadingState } from "./microagents-loading-state";
|
||||
import { MicroagentsEmptyState } from "./microagents-empty-state";
|
||||
import { MicroagentItem } from "./microagent-item";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
@@ -18,7 +19,8 @@ interface MicroagentsModalProps {
|
||||
|
||||
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
@@ -30,6 +32,15 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
isRefetching,
|
||||
} = useConversationMicroagents();
|
||||
|
||||
// TODO: Hide MicroagentsModal for V1 conversations
|
||||
// This is a temporary measure and may be re-enabled in the future
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Don't render anything for V1 conversations
|
||||
if (isV1Conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toggleAgent = (agentName: string) => {
|
||||
setExpandedAgents((prev) => ({
|
||||
...prev,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationRepoLink } from "../conversation-card/conversation-repo-link";
|
||||
import { NoRepository } from "../conversation-card/no-repository";
|
||||
import type { RepositorySelection } from "#/api/open-hands.types";
|
||||
|
||||
interface StartTaskCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
createdAt: string; // ISO 8601
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export function StartTaskCardFooter({
|
||||
selectedRepository,
|
||||
createdAt,
|
||||
detail,
|
||||
}: StartTaskCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1 mt-1")}>
|
||||
{/* Repository Info */}
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
{selectedRepository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
<NoRepository />
|
||||
)}
|
||||
{createdAt && (
|
||||
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
|
||||
<time>
|
||||
{`${formatTimeDelta(new Date(createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Detail */}
|
||||
{detail && (
|
||||
<div className="text-xs text-neutral-500 truncate">{detail}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { ConversationVersionBadge } from "../conversation-card/conversation-version-badge";
|
||||
import { StartTaskStatusIndicator } from "./start-task-status-indicator";
|
||||
import { StartTaskStatusBadge } from "./start-task-status-badge";
|
||||
|
||||
interface StartTaskCardHeaderProps {
|
||||
title: string;
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskCardHeader({
|
||||
title,
|
||||
taskStatus,
|
||||
}: StartTaskCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
<div className="flex items-center">
|
||||
<StartTaskStatusIndicator taskStatus={taskStatus} />
|
||||
</div>
|
||||
|
||||
{/* Version Badge - V1 tasks are always V1 */}
|
||||
<ConversationVersionBadge version="V1" />
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-sm font-medium text-neutral-100 truncate flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Status Badge */}
|
||||
<StartTaskStatusBadge taskStatus={taskStatus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { V1AppConversationStartTask } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { StartTaskCardHeader } from "./start-task-card-header";
|
||||
import { StartTaskCardFooter } from "./start-task-card-footer";
|
||||
|
||||
interface StartTaskCardProps {
|
||||
task: V1AppConversationStartTask;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function StartTaskCard({ task, onClick }: StartTaskCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const title =
|
||||
task.request.title ||
|
||||
task.detail ||
|
||||
t(I18nKey.CONVERSATION$STARTING_CONVERSATION);
|
||||
|
||||
const selectedRepository = task.request.selected_repository
|
||||
? {
|
||||
selected_repository: task.request.selected_repository,
|
||||
selected_branch: task.request.selected_branch || null,
|
||||
git_provider: task.request.git_provider || null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="start-task-card"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"hover:bg-[#454545]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<StartTaskCardHeader title={title} taskStatus={task.status} />
|
||||
</div>
|
||||
|
||||
<StartTaskCardFooter
|
||||
selectedRepository={selectedRepository}
|
||||
createdAt={task.created_at}
|
||||
detail={task.detail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StartTaskStatusBadgeProps {
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskStatusBadge({
|
||||
taskStatus,
|
||||
}: StartTaskStatusBadgeProps) {
|
||||
// Don't show badge for WORKING status (most common, clutters UI)
|
||||
if (taskStatus === "WORKING") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format status for display
|
||||
const formatStatus = (status: string) =>
|
||||
status
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
// Get status color
|
||||
const getStatusStyle = () => {
|
||||
switch (taskStatus) {
|
||||
case "READY":
|
||||
return "bg-green-500/10 text-green-400 border-green-500/20";
|
||||
case "ERROR":
|
||||
return "bg-red-500/10 text-red-400 border-red-500/20";
|
||||
default:
|
||||
return "bg-yellow-500/10 text-yellow-400 border-yellow-500/20";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded border flex-shrink-0",
|
||||
getStatusStyle(),
|
||||
)}
|
||||
>
|
||||
{formatStatus(taskStatus)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StartTaskStatusIndicatorProps {
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskStatusIndicator({
|
||||
taskStatus,
|
||||
}: StartTaskStatusIndicatorProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (taskStatus) {
|
||||
case "READY":
|
||||
return "bg-green-500";
|
||||
case "ERROR":
|
||||
return "bg-red-500";
|
||||
case "WORKING":
|
||||
case "WAITING_FOR_SANDBOX":
|
||||
case "PREPARING_REPOSITORY":
|
||||
case "RUNNING_SETUP_SCRIPT":
|
||||
case "SETTING_UP_GIT_HOOKS":
|
||||
case "STARTING_CONVERSATION":
|
||||
return "bg-yellow-500 animate-pulse";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor())}
|
||||
aria-label={`Task status: ${taskStatus}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
import EditIcon from "#/icons/u-edit.svg?react";
|
||||
import RobotIcon from "#/icons/u-robot.svg?react";
|
||||
@@ -52,6 +53,11 @@ export function ConversationNameContextMenu({
|
||||
|
||||
const { t } = useTranslation();
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
// TODO: Hide microagent menu items for V1 conversations
|
||||
// This is a temporary measure and may be re-enabled in the future
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const hasDownload = Boolean(onDownloadViaVSCode);
|
||||
const hasExport = Boolean(onExportConversation);
|
||||
@@ -85,7 +91,7 @@ export function ConversationNameContextMenu({
|
||||
|
||||
{hasTools && <Divider testId="separator-tools" />}
|
||||
|
||||
{onShowMicroagents && (
|
||||
{onShowMicroagents && !isV1Conversation && (
|
||||
<ContextMenuListItem
|
||||
testId="show-microagents-button"
|
||||
onClick={onShowMicroagents}
|
||||
@@ -113,9 +119,11 @@ export function ConversationNameContextMenu({
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{(hasExport || hasDownload) && <Divider testId="separator-export" />}
|
||||
{(hasExport || hasDownload) && !isV1Conversation && (
|
||||
<Divider testId="separator-export" />
|
||||
)}
|
||||
|
||||
{onExportConversation && (
|
||||
{onExportConversation && !isV1Conversation && (
|
||||
<ContextMenuListItem
|
||||
testId="export-conversation-button"
|
||||
onClick={onExportConversation}
|
||||
@@ -129,7 +137,7 @@ export function ConversationNameContextMenu({
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{onDownloadViaVSCode && (
|
||||
{onDownloadViaVSCode && !isV1Conversation && (
|
||||
<ContextMenuListItem
|
||||
testId="download-vscode-button"
|
||||
onClick={onDownloadViaVSCode}
|
||||
@@ -143,7 +151,9 @@ export function ConversationNameContextMenu({
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{(hasInfo || hasControl) && <Divider testId="separator-info-control" />}
|
||||
{(hasInfo || hasControl) && !isV1Conversation && (
|
||||
<Divider testId="separator-info-control" />
|
||||
)}
|
||||
|
||||
{onDisplayCost && (
|
||||
<ContextMenuListItem
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MicroagentsModal } from "../conversation-panel/microagents-modal";
|
||||
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
|
||||
import { MetricsModal } from "./metrics-modal/metrics-modal";
|
||||
import { ConversationVersionBadge } from "../conversation-panel/conversation-card/conversation-version-badge";
|
||||
|
||||
export function ConversationName() {
|
||||
const { t } = useTranslation();
|
||||
@@ -148,6 +149,12 @@ export function ConversationName() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{titleMode !== "edit" && (
|
||||
<ConversationVersionBadge
|
||||
version={conversation.conversation_version}
|
||||
/>
|
||||
)}
|
||||
|
||||
{titleMode !== "edit" && (
|
||||
<div className="relative flex items-center">
|
||||
<EllipsisButton fill="#B1B9D3" onClick={handleEllipsisClick} />
|
||||
|
||||
@@ -5,10 +5,10 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
export function VSCodeTooltipContent() {
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { Feedback } from "#/api/open-hands.types";
|
||||
import { useSubmitFeedback } from "#/hooks/mutation/use-submit-feedback";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
const FEEDBACK_VERSION = "1.0";
|
||||
const VIEWER_PAGE = "https://www.all-hands.dev/share";
|
||||
@@ -16,6 +17,7 @@ interface FeedbackFormProps {
|
||||
|
||||
export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const copiedToClipboardToast = () => {
|
||||
hotToast(t(I18nKey.FEEDBACK$PASSWORD_COPIED_MESSAGE), {
|
||||
@@ -60,6 +62,15 @@ export function FeedbackForm({ onClose, polarity }: FeedbackFormProps) {
|
||||
|
||||
const { mutate: submitFeedback, isPending } = useSubmitFeedback();
|
||||
|
||||
// TODO: Hide FeedbackForm for V1 conversations
|
||||
// This is a temporary measure and may be re-enabled in the future
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Don't render anything for V1 conversations
|
||||
if (isV1Conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useSubmitConversationFeedback } from "#/hooks/mutation/use-submit-conversation-feedback";
|
||||
import { ScrollContext } from "#/context/scroll-context";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
// Global timeout duration in milliseconds
|
||||
const AUTO_SUBMIT_TIMEOUT = 10000;
|
||||
@@ -23,6 +24,7 @@ export function LikertScale({
|
||||
initialReason,
|
||||
}: LikertScaleProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const [selectedRating, setSelectedRating] = useState<number | null>(
|
||||
initialRating || null,
|
||||
@@ -77,6 +79,56 @@ export function LikertScale({
|
||||
}
|
||||
}, [initialReason]);
|
||||
|
||||
// Countdown effect
|
||||
useEffect(() => {
|
||||
if (countdown > 0 && showReasons && !isSubmitted) {
|
||||
const timer = setTimeout(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return () => {};
|
||||
}, [countdown, showReasons, isSubmitted]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (reasonTimeout) {
|
||||
clearTimeout(reasonTimeout);
|
||||
}
|
||||
},
|
||||
[reasonTimeout],
|
||||
);
|
||||
|
||||
// Scroll to bottom when component mounts, but only if user is already at the bottom
|
||||
useEffect(() => {
|
||||
if (scrollToBottom && autoScroll && !isSubmitted) {
|
||||
// Small delay to ensure the component is fully rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
}, [scrollToBottom, autoScroll, isSubmitted]);
|
||||
|
||||
// Scroll to bottom when reasons are shown, but only if user is already at the bottom
|
||||
useEffect(() => {
|
||||
if (scrollToBottom && autoScroll && showReasons) {
|
||||
// Small delay to ensure the reasons are fully rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
}, [scrollToBottom, autoScroll, showReasons]);
|
||||
|
||||
// TODO: Hide LikertScale for V1 conversations
|
||||
// This is a temporary measure and may be re-enabled in the future
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Don't render anything for V1 conversations
|
||||
if (isV1Conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Submit feedback and disable the component
|
||||
const submitFeedback = (rating: number, reason?: string) => {
|
||||
submitConversationFeedback(
|
||||
@@ -137,47 +189,6 @@ export function LikertScale({
|
||||
}
|
||||
};
|
||||
|
||||
// Countdown effect
|
||||
useEffect(() => {
|
||||
if (countdown > 0 && showReasons && !isSubmitted) {
|
||||
const timer = setTimeout(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return () => {};
|
||||
}, [countdown, showReasons, isSubmitted]);
|
||||
|
||||
// Clean up timeout on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (reasonTimeout) {
|
||||
clearTimeout(reasonTimeout);
|
||||
}
|
||||
},
|
||||
[reasonTimeout],
|
||||
);
|
||||
|
||||
// Scroll to bottom when component mounts, but only if user is already at the bottom
|
||||
useEffect(() => {
|
||||
if (scrollToBottom && autoScroll && !isSubmitted) {
|
||||
// Small delay to ensure the component is fully rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
}, [scrollToBottom, autoScroll, isSubmitted]);
|
||||
|
||||
// Scroll to bottom when reasons are shown, but only if user is already at the bottom
|
||||
useEffect(() => {
|
||||
if (scrollToBottom && autoScroll && showReasons) {
|
||||
// Small delay to ensure the reasons are fully rendered
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}
|
||||
}, [scrollToBottom, autoScroll, showReasons]);
|
||||
|
||||
// Helper function to get button class based on state
|
||||
const getButtonClass = (rating: number) => {
|
||||
if (isSubmitted) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
interface JupyterEditorProps {
|
||||
@@ -15,7 +15,7 @@ interface JupyterEditorProps {
|
||||
}
|
||||
|
||||
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const cells = useJupyterStore((state) => state.cells);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
@@ -23,7 +23,7 @@ export function SetupPaymentModal() {
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.BILLING$YOUVE_GOT_50)}
|
||||
|
||||
@@ -3,10 +3,10 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
function Terminal() {
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
@@ -98,7 +98,7 @@ export function AuthModal({
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER)}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
|
||||
export function ReauthModal() {
|
||||
const { t } = useTranslation();
|
||||
@@ -11,7 +11,7 @@ export function ReauthModal() {
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.AUTH$LOGGING_BACK_IN)}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { ActionTooltip } from "../action-tooltip";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { ActionSecurityRisk } from "#/stores/security-analyzer-store";
|
||||
@@ -12,6 +11,7 @@ import WarningIcon from "#/icons/u-warning.svg?react";
|
||||
import { useEventMessageStore } from "#/stores/event-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV0Event } from "#/types/v1/type-guards";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
|
||||
export function ConfirmationButtons() {
|
||||
const submittedEventIds = useEventMessageStore(
|
||||
@@ -23,7 +23,7 @@ export function ConfirmationButtons() {
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { send } = useWsClient();
|
||||
const { send } = useSendMessage();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
// Find the most recent action awaiting confirmation
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
@@ -12,7 +12,7 @@ export function OpenHandsLogoButton() {
|
||||
ariaLabel={t(I18nKey.BRANDING$OPENHANDS_LOGO)}
|
||||
navLinkTo="/"
|
||||
>
|
||||
<AllHandsLogo width={46} height={30} />
|
||||
<OpenHandsLogo width={46} height={30} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user