mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
26 Commits
openhands/
...
cb/test-v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a05c031e8e | ||
|
|
8babc4baae | ||
|
|
fc6d774f03 | ||
|
|
b6b3566182 | ||
|
|
5a50098f8a | ||
|
|
46b17003d8 | ||
|
|
d2479b07b8 | ||
|
|
556b2e31a9 | ||
|
|
ec3c7467ac | ||
|
|
b7e1e4c3db | ||
|
|
4fa32efe00 | ||
|
|
a05e9c2cc0 | ||
|
|
496b23243e | ||
|
|
b2adb60723 | ||
|
|
81f7993fd3 | ||
|
|
e2bf717d2e | ||
|
|
9f94f0a047 | ||
|
|
ca5400f116 | ||
|
|
d4861fc221 | ||
|
|
16db9086a3 | ||
|
|
e23238d6cc | ||
|
|
0c3ed2ab9c | ||
|
|
c28bcf9277 | ||
|
|
6bbcbf7340 | ||
|
|
08d173f55a | ||
|
|
e9be62f767 |
29
AGENTS.md
29
AGENTS.md
@@ -39,8 +39,6 @@ then re-run the command to ensure it passes. Common issues include:
|
||||
## Repository Structure
|
||||
Backend:
|
||||
- Located in the `openhands` directory
|
||||
- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`.
|
||||
- For V1 web-app docs, LLM setup should point users to the Settings UI.
|
||||
- Testing:
|
||||
- All tests are in `tests/unit/test_*.py`
|
||||
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
|
||||
@@ -344,30 +342,3 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo
|
||||
- Models appear in CLI provider selection based on the verified arrays
|
||||
- The `organize_models_and_providers` function groups models by provider
|
||||
- Default model selection prioritizes verified models for each provider
|
||||
|
||||
### Sandbox Settings API (SDK Credential Inheritance)
|
||||
|
||||
The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials
|
||||
(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox,
|
||||
never through the SDK client.
|
||||
|
||||
#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`):
|
||||
- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`)
|
||||
- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only)
|
||||
|
||||
Auth requirements for `expose_secrets=true`:
|
||||
- Bearer token (proves user identity via `OPENHANDS_API_KEY`)
|
||||
- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user)
|
||||
|
||||
Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key.
|
||||
|
||||
#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`):
|
||||
- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values)
|
||||
- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox)
|
||||
|
||||
#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()`
|
||||
|
||||
#### Related SDK code (in `software-agent-sdk` repo):
|
||||
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
|
||||
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
|
||||
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`
|
||||
|
||||
165
CONTRIBUTING.md
165
CONTRIBUTING.md
@@ -1,105 +1,83 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey.
|
||||
Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions.
|
||||
|
||||
## Our Vision
|
||||
## Understanding OpenHands's CodeBase
|
||||
|
||||
The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone.
|
||||
To understand the codebase, please refer to the README in each module:
|
||||
- [frontend](./frontend/README.md)
|
||||
- [openhands](./openhands/README.md)
|
||||
- [agenthub](./openhands/agenthub/README.md)
|
||||
- [server](./openhands/server/README.md)
|
||||
|
||||
We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources.
|
||||
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
|
||||
|
||||
## Getting Started
|
||||
## Setting up Your Development Environment
|
||||
|
||||
### Quick Ways to Contribute
|
||||
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
|
||||
you how to set up a development workflow.
|
||||
|
||||
- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter
|
||||
- **Give feedback** using the thumbs-up/thumbs-down buttons after each session
|
||||
- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands)
|
||||
- **Share OpenHands** with other developers
|
||||
## How Can I Contribute?
|
||||
|
||||
### Set Up Your Development Environment
|
||||
There are many ways that you can contribute:
|
||||
|
||||
- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+
|
||||
- **Quick setup**: `make build`
|
||||
- **Run locally**: `make run`
|
||||
- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts
|
||||
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
|
||||
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
|
||||
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
|
||||
|
||||
Full details in our [Development Guide](./Development.md).
|
||||
## What Can I Build?
|
||||
|
||||
### Find Your First Issue
|
||||
Here are a few ways you can help improve the codebase.
|
||||
|
||||
- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue)
|
||||
- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks
|
||||
- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help
|
||||
#### UI/UX
|
||||
|
||||
## Understanding the Codebase
|
||||
We're always looking to improve the look and feel of the application. If you've got a small fix
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
|
||||
to gather consensus from our design team first.
|
||||
|
||||
## What Can You Build?
|
||||
#### Improving the agent
|
||||
|
||||
### Frontend & UI/UX
|
||||
- React & TypeScript development
|
||||
- UI/UX improvements
|
||||
- Mobile responsiveness
|
||||
- Component libraries
|
||||
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
|
||||
|
||||
For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first.
|
||||
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
|
||||
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
|
||||
locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent
|
||||
is getting better over time.
|
||||
|
||||
### Agent Development
|
||||
- Prompt engineering
|
||||
- New agent types
|
||||
- Agent evaluation
|
||||
- Multi-agent systems
|
||||
We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation
|
||||
channel in Slack to learn more.
|
||||
|
||||
We use [SWE-bench](https://www.swebench.com/) to evaluate agents.
|
||||
#### Adding a new agent
|
||||
|
||||
### Backend & Infrastructure
|
||||
- Python development
|
||||
- Runtime systems (Docker containers, sandboxes)
|
||||
- Cloud integrations
|
||||
- Performance optimization
|
||||
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
|
||||
to help expand the capabilities of OpenHands.
|
||||
|
||||
### Testing & Quality Assurance
|
||||
- Unit testing
|
||||
- Integration testing
|
||||
- Bug hunting
|
||||
- Performance testing
|
||||
#### Adding a new runtime
|
||||
|
||||
### Documentation & Education
|
||||
- Technical documentation
|
||||
- Translation
|
||||
- Community support
|
||||
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
|
||||
to do this by default. But there are other ways of creating a sandbox for the agent.
|
||||
|
||||
## Pull Request Process
|
||||
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
|
||||
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
|
||||
|
||||
### Small Improvements
|
||||
- Quick review and approval
|
||||
- Ensure CI tests pass
|
||||
- Include clear description of changes
|
||||
#### Testing
|
||||
|
||||
### Core Agent Changes
|
||||
These are evaluated based on:
|
||||
- **Accuracy** - Does it make the agent better at solving problems?
|
||||
- **Efficiency** - Does it improve speed or reduce resource usage?
|
||||
- **Code Quality** - Is the code maintainable and well-tested?
|
||||
|
||||
Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first.
|
||||
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
|
||||
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
|
||||
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
|
||||
quality of the project.
|
||||
|
||||
## Sending Pull Requests to OpenHands
|
||||
|
||||
You'll need to fork our repository to send us a Pull Request. You can learn more
|
||||
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
|
||||
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
### Pull Request title
|
||||
|
||||
### Pull Request Title Format
|
||||
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
|
||||
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
|
||||
|
||||
- `feat`: A new feature
|
||||
- `fix`: A bug fix
|
||||
@@ -117,16 +95,45 @@ For example, a PR title could be:
|
||||
- `refactor: modify package path`
|
||||
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
|
||||
|
||||
### Pull Request Description
|
||||
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
|
||||
|
||||
- Explain what the PR does and why
|
||||
- Link to related issues
|
||||
- Include screenshots for UI changes
|
||||
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
|
||||
please include a short message that we can add to our changelog
|
||||
### Pull Request description
|
||||
|
||||
## Need Help?
|
||||
- If your PR is small (such as a typo fix), you can go brief.
|
||||
- If it contains a lot of changes, it's better to write more details.
|
||||
|
||||
- **Slack**: [Join our community](https://openhands.dev/joinslack)
|
||||
- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues)
|
||||
- **Email**: contact@openhands.dev
|
||||
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
|
||||
please include a short message that we can add to our changelog.
|
||||
|
||||
## How to Make Effective Contributions
|
||||
|
||||
### Opening Issues
|
||||
|
||||
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
|
||||
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
|
||||
the community has interest/effort for.
|
||||
|
||||
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
|
||||
|
||||
### Making Pull Requests
|
||||
|
||||
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
|
||||
|
||||
#### For Small Improvements
|
||||
|
||||
Small improvements with few downsides are typically reviewed and approved quickly.
|
||||
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
|
||||
before getting a review.
|
||||
|
||||
#### For Core Agent Changes
|
||||
|
||||
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
|
||||
evaluated based on three key metrics:
|
||||
|
||||
1. **Accuracy**
|
||||
2. **Efficiency**
|
||||
3. **Code Complexity**
|
||||
|
||||
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
|
||||
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
|
||||
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
|
||||
|
||||
386
Development.md
386
Development.md
@@ -6,196 +6,22 @@ If you wish to contribute your changes, check out the
|
||||
on how to clone and setup the project initially before moving on. Otherwise,
|
||||
you can clone the OpenHands project directly.
|
||||
|
||||
## Choose Your Setup
|
||||
## Start the Server for Development
|
||||
|
||||
Select your operating system to see the specific setup instructions:
|
||||
### 1. Requirements
|
||||
|
||||
- [macOS](#macos-setup)
|
||||
- [Linux](#linux-setup)
|
||||
- [Windows WSL](#windows-wsl-setup)
|
||||
- [Dev Container](#dev-container)
|
||||
- [Developing in Docker](#developing-in-docker)
|
||||
- [No sudo access?](#develop-without-sudo-access)
|
||||
- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
|
||||
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
|
||||
- [Python](https://www.python.org/downloads/) = 3.12
|
||||
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
|
||||
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
|
||||
- OS-specific dependencies:
|
||||
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
|
||||
- WSL: netcat => `sudo apt-get install netcat`
|
||||
|
||||
---
|
||||
Make sure you have all these dependencies installed before moving on to `make build`.
|
||||
|
||||
## macOS Setup
|
||||
|
||||
### 1. Install Prerequisites
|
||||
|
||||
You'll need the following installed:
|
||||
|
||||
- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this).
|
||||
- **Node.js >= 22** — `brew install node`
|
||||
- **Poetry >= 1.8** — `brew install poetry`
|
||||
- **Docker Desktop** — `brew install --cask docker`
|
||||
- After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"**
|
||||
|
||||
### 2. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 3. Configure the Language Model
|
||||
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
|
||||
For the V1 web app, start OpenHands and configure your model and API key in the Settings UI.
|
||||
|
||||
If you are running headless or CLI workflows, you can prepare local defaults with:
|
||||
|
||||
```bash
|
||||
make setup-config
|
||||
```
|
||||
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`.
|
||||
|
||||
---
|
||||
|
||||
## Linux Setup
|
||||
|
||||
This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly.
|
||||
|
||||
### 1. Install Prerequisites
|
||||
|
||||
```bash
|
||||
# Update package list
|
||||
sudo apt update
|
||||
|
||||
# Install system dependencies
|
||||
sudo apt install -y build-essential curl netcat software-properties-common
|
||||
|
||||
# Install Python 3.12
|
||||
# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if
|
||||
# python3.12 --version already works on your system.
|
||||
# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older:
|
||||
sudo add-apt-repository -y ppa:deadsnakes/ppa
|
||||
sudo apt update
|
||||
sudo apt install -y python3.12 python3.12-dev python3.12-venv
|
||||
|
||||
# Install Node.js 22.x
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
|
||||
# Install Poetry
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
# Add Poetry to your PATH
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# Install Docker
|
||||
# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/
|
||||
# Quick version:
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
|
||||
sudo chmod a+r /etc/apt/keyrings/docker.asc
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for Docker group changes to take effect
|
||||
```
|
||||
|
||||
### 2. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 3. Configure the Language Model
|
||||
|
||||
See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI.
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Windows WSL Setup
|
||||
|
||||
WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations.
|
||||
|
||||
### 1. Install WSL2
|
||||
|
||||
**Option A: Windows 11 (Microsoft Store)**
|
||||
The easiest way on Windows 11:
|
||||
1. Open the **Microsoft Store** app
|
||||
2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"**
|
||||
3. Click **Install**
|
||||
4. Launch Ubuntu from the Start menu
|
||||
|
||||
**Option B: PowerShell**
|
||||
```powershell
|
||||
# Run this in PowerShell as Administrator
|
||||
wsl --install -d Ubuntu-22.04
|
||||
```
|
||||
|
||||
After installation, restart your computer and open Ubuntu.
|
||||
|
||||
### 2. Install Prerequisites (in WSL Ubuntu)
|
||||
|
||||
Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below.
|
||||
|
||||
### 3. Configure Docker for WSL2
|
||||
|
||||
1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
|
||||
2. Open Docker Desktop > Settings > General
|
||||
3. Enable: "Use the WSL 2 based engine"
|
||||
4. Go to Settings > Resources > WSL Integration
|
||||
5. Enable integration with your Ubuntu distribution
|
||||
|
||||
**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower.
|
||||
|
||||
### 4. Build and Setup the Environment
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
### 5. Configure the Language Model
|
||||
|
||||
See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows.
|
||||
|
||||
### 6. Run the Application
|
||||
|
||||
```bash
|
||||
# Run both backend and frontend
|
||||
make run
|
||||
|
||||
# Or run separately:
|
||||
make start-backend # Backend only on port 3000
|
||||
make start-frontend # Frontend only on port 3001
|
||||
```
|
||||
|
||||
Access the frontend at `http://localhost:3001` from your Windows browser.
|
||||
|
||||
---
|
||||
|
||||
## Dev Container
|
||||
#### Dev container
|
||||
|
||||
There is a [dev container](https://containers.dev/) available which provides a
|
||||
pre-configured environment with all the necessary dependencies installed if you
|
||||
@@ -206,38 +32,7 @@ extension installed, you can open the project in a dev container by using the
|
||||
_Dev Container: Reopen in Container_ command from the Command Palette
|
||||
(Ctrl+Shift+P).
|
||||
|
||||
---
|
||||
|
||||
## Developing in Docker
|
||||
|
||||
If you don't want to install dependencies on your host machine, you can develop inside a Docker container.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
For more details, see the [dev container documentation](./containers/dev/README.md).
|
||||
|
||||
### Alternative: Docker Run
|
||||
|
||||
If you just want to run OpenHands without setting up a dev environment:
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
If you don't have `make` installed, run:
|
||||
|
||||
```bash
|
||||
cd ./containers/dev
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Develop without sudo access
|
||||
#### Develop without sudo access
|
||||
|
||||
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
|
||||
`conda` or `mamba` to manage the packages for you:
|
||||
@@ -253,90 +48,159 @@ mamba install conda-forge::nodejs
|
||||
mamba install conda-forge::poetry
|
||||
```
|
||||
|
||||
---
|
||||
### 2. Build and Setup The Environment
|
||||
|
||||
## Running OpenHands with OpenHands
|
||||
|
||||
You can use OpenHands to develop and improve OpenHands itself!
|
||||
|
||||
### Quick Start
|
||||
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
|
||||
that OpenHands is ready to run on your system:
|
||||
|
||||
```bash
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
make build
|
||||
```
|
||||
|
||||
Access the interface at:
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
### 3. Configuring the Language Model
|
||||
|
||||
For external access:
|
||||
```bash
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
|
||||
|
||||
---
|
||||
|
||||
## LLM Debugging
|
||||
|
||||
If you encounter issues with the Language Model, enable debug logging:
|
||||
To configure the LM of your choice, run:
|
||||
|
||||
```bash
|
||||
export DEBUG=1
|
||||
# Restart the backend
|
||||
make start-backend
|
||||
make setup-config
|
||||
```
|
||||
|
||||
Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting.
|
||||
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
|
||||
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
|
||||
please set the model in the UI.
|
||||
|
||||
---
|
||||
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
|
||||
variables in your terminal. The final configurations are set from highest to lowest priority:
|
||||
Environment variables > config.toml variables > default variables
|
||||
|
||||
## Testing
|
||||
**Note on Alternative Models:**
|
||||
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
|
||||
|
||||
### Unit Tests
|
||||
### 4. Running the application
|
||||
|
||||
#### Option A: Run the Full Application
|
||||
|
||||
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
|
||||
|
||||
```bash
|
||||
poetry run pytest ./tests/unit/test_*.py
|
||||
make run
|
||||
```
|
||||
|
||||
---
|
||||
#### Option B: Individual Server Startup
|
||||
|
||||
## Adding Dependencies
|
||||
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
|
||||
backend-related tasks or configurations.
|
||||
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
|
||||
2. Update the lock file: `poetry lock --no-update`
|
||||
```bash
|
||||
make start-backend
|
||||
```
|
||||
|
||||
---
|
||||
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
|
||||
components or interface enhancements.
|
||||
```bash
|
||||
make start-frontend
|
||||
```
|
||||
|
||||
## Using Existing Docker Images
|
||||
### 5. Running OpenHands with OpenHands
|
||||
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
|
||||
|
||||
```bash
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
#### Quick Start
|
||||
|
||||
---
|
||||
1. **Build and run OpenHands:**
|
||||
|
||||
## Help
|
||||
```bash
|
||||
export INSTALL_DOCKER=0
|
||||
export RUNTIME=local
|
||||
make build && make run
|
||||
```
|
||||
|
||||
2. **Access the interface:**
|
||||
|
||||
- Local development: http://localhost:3001
|
||||
- Remote/cloud environments: Use the appropriate external URL
|
||||
|
||||
3. **Configure for external access (if needed):**
|
||||
```bash
|
||||
# For external access (e.g., cloud environments)
|
||||
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
|
||||
```
|
||||
|
||||
### 6. LLM Debugging
|
||||
|
||||
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
|
||||
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
|
||||
|
||||
### 7. Help
|
||||
|
||||
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
|
||||
|
||||
```bash
|
||||
make help
|
||||
```
|
||||
|
||||
---
|
||||
### 8. Testing
|
||||
|
||||
To run tests, refer to the following:
|
||||
|
||||
#### Unit tests
|
||||
|
||||
```bash
|
||||
poetry run pytest ./tests/unit/test_*.py
|
||||
```
|
||||
|
||||
### 9. Add or update dependency
|
||||
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`.
|
||||
|
||||
### 10. Use existing Docker image
|
||||
|
||||
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/openhands/runtime:1.2-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
TL;DR
|
||||
|
||||
```bash
|
||||
make docker-dev
|
||||
```
|
||||
|
||||
See more details [here](./containers/dev/README.md).
|
||||
|
||||
If you are just interested in running `OpenHands` without installing all the required tools on your host.
|
||||
|
||||
```bash
|
||||
make docker-run
|
||||
```
|
||||
|
||||
If you do not have `make` on your host, run:
|
||||
|
||||
```bash
|
||||
cd ./containers/dev
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though.
|
||||
|
||||
## Key Documentation Resources
|
||||
|
||||
Here's a guide to the important documentation files in the repository:
|
||||
|
||||
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
|
||||
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
|
||||
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
|
||||
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
|
||||
- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules
|
||||
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
|
||||
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
|
||||
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
@@ -51,6 +51,6 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
|
||||
## User ID vs User Token
|
||||
|
||||
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completely ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
|
||||
|
||||
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Enterprise Architecture Documentation
|
||||
|
||||
Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment
|
||||
- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations
|
||||
|
||||
## Related Documentation
|
||||
|
||||
For core OpenHands architecture (applicable to all deployments), see:
|
||||
- [Core Architecture Documentation](../../../openhands/architecture/README.md)
|
||||
@@ -1,58 +0,0 @@
|
||||
# Authentication Flow (SaaS Deployment)
|
||||
|
||||
OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as User (Browser)
|
||||
participant App as App Server
|
||||
participant KC as Keycloak
|
||||
participant IdP as Identity Provider<br/>(GitHub, Google, etc.)
|
||||
participant DB as User Database
|
||||
|
||||
Note over User,DB: OAuth 2.0 / OIDC Authentication Flow
|
||||
|
||||
User->>App: Access OpenHands
|
||||
App->>User: Redirect to Keycloak
|
||||
User->>KC: Login request
|
||||
KC->>User: Show login options
|
||||
User->>KC: Select provider (e.g., GitHub)
|
||||
KC->>IdP: OAuth redirect
|
||||
User->>IdP: Authenticate
|
||||
IdP-->>KC: OAuth callback + tokens
|
||||
Note over KC: Create/update user session
|
||||
KC-->>User: Redirect with auth code
|
||||
User->>App: Auth code
|
||||
App->>KC: Exchange code for tokens
|
||||
KC-->>App: Access token + Refresh token
|
||||
Note over App: Create signed JWT cookie
|
||||
App->>DB: Store/update user record
|
||||
App-->>User: Set keycloak_auth cookie
|
||||
|
||||
Note over User,DB: Subsequent Requests
|
||||
|
||||
User->>App: Request with cookie
|
||||
Note over App: Verify JWT signature
|
||||
App->>KC: Validate token (if needed)
|
||||
KC-->>App: Token valid
|
||||
Note over App: Extract user context
|
||||
App-->>User: Authorized response
|
||||
```
|
||||
|
||||
### Authentication Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Keycloak** | Identity provider, SSO, token management | External service |
|
||||
| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` |
|
||||
| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` |
|
||||
| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` |
|
||||
| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` |
|
||||
|
||||
### Token Flow
|
||||
|
||||
1. **Keycloak Access Token**: Short-lived token for API access
|
||||
2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens
|
||||
3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens
|
||||
4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations)
|
||||
@@ -1,88 +0,0 @@
|
||||
# External Integrations
|
||||
|
||||
OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Ext as External Service<br/>(GitHub/Slack/Jira)
|
||||
participant App as App Server
|
||||
participant IntRouter as Integration Router
|
||||
participant Manager as Integration Manager
|
||||
participant Conv as Conversation Service
|
||||
participant Sandbox as Sandbox
|
||||
|
||||
Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created)
|
||||
|
||||
Ext->>App: POST /api/integration/{service}/events
|
||||
App->>IntRouter: Route to service handler
|
||||
Note over IntRouter: Verify signature (HMAC)
|
||||
|
||||
IntRouter->>Manager: Parse event payload
|
||||
Note over Manager: Extract context (repo, issue, user)
|
||||
Note over Manager: Map external user → OpenHands user
|
||||
|
||||
Manager->>Conv: Create conversation (with issue context)
|
||||
Conv->>Sandbox: Provision sandbox
|
||||
Sandbox-->>Conv: Ready
|
||||
|
||||
Manager->>Sandbox: Start agent with task
|
||||
|
||||
Note over Ext,Sandbox: Agent Works on Task...
|
||||
|
||||
Sandbox-->>Manager: Task complete
|
||||
Manager->>Ext: POST result<br/>(PR, comment, etc.)
|
||||
|
||||
Note over Ext,Sandbox: Callback Flow (Agent → External Service)
|
||||
|
||||
Sandbox->>App: Webhook callback<br/>/api/v1/webhooks
|
||||
App->>Manager: Process callback
|
||||
Manager->>Ext: Update external service
|
||||
```
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
| Integration | Trigger Events | Agent Actions |
|
||||
|-------------|----------------|---------------|
|
||||
| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits |
|
||||
| **GitLab** | Issue created, MR opened | Create MR, comment, push commits |
|
||||
| **Slack** | @mention in channel | Reply in thread, create tasks |
|
||||
| **Jira** | Issue created/updated | Update ticket, add comments |
|
||||
| **Linear** | Issue created | Update status, add comments |
|
||||
|
||||
### Integration Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` |
|
||||
| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` |
|
||||
| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` |
|
||||
| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` |
|
||||
|
||||
### Integration Authentication
|
||||
|
||||
```
|
||||
External Service (e.g., GitHub)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ GitHub App Installation │
|
||||
│ - Webhook secret for signature │
|
||||
│ - App private key for API calls │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ User Account Linking │
|
||||
│ - Keycloak user ID │
|
||||
│ - GitHub user ID │
|
||||
│ - Stored OAuth tokens │
|
||||
└─────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────┐
|
||||
│ Agent Execution │
|
||||
│ - Uses linked tokens for API │
|
||||
│ - Can push, create PRs, comment │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
@@ -60,9 +60,7 @@ class ResolverUserContext(UserContext):
|
||||
return provider_token.token.get_secret_value()
|
||||
return None
|
||||
|
||||
async def get_provider_tokens(
|
||||
self, as_env_vars: bool = False
|
||||
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
|
||||
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
|
||||
return await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
async def get_secrets(self) -> dict[str, SecretSource]:
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Add sandbox_grouping_strategy column to user, org, and user_settings tables.
|
||||
|
||||
Revision ID: 100
|
||||
Revises: 099
|
||||
Create Date: 2025-03-12
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = '100'
|
||||
down_revision = '099'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'org',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
sa.Column('sandbox_grouping_strategy', sa.String, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user_settings', 'sandbox_grouping_strategy')
|
||||
op.drop_column('org', 'sandbox_grouping_strategy')
|
||||
op.drop_column('user', 'sandbox_grouping_strategy')
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Add pending_messages table for server-side message queuing
|
||||
|
||||
Revision ID: 101
|
||||
Revises: 100
|
||||
Create Date: 2025-03-15 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '101'
|
||||
down_revision: Union[str, None] = '100'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create pending_messages table for storing messages before conversation is ready.
|
||||
|
||||
Messages are stored temporarily until the conversation becomes ready, then
|
||||
delivered and deleted regardless of success or failure.
|
||||
"""
|
||||
op.create_table(
|
||||
'pending_messages',
|
||||
sa.Column('id', sa.String(), primary_key=True),
|
||||
sa.Column('conversation_id', sa.String(), nullable=False, index=True),
|
||||
sa.Column('role', sa.String(20), nullable=False, server_default='user'),
|
||||
sa.Column('content', sa.JSON, nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove pending_messages table."""
|
||||
op.drop_table('pending_messages')
|
||||
208
enterprise/poetry.lock
generated
208
enterprise/poetry.lock
generated
@@ -602,14 +602,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.6.9"
|
||||
version = "1.6.7"
|
||||
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
|
||||
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
|
||||
{file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"},
|
||||
{file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6190,14 +6190,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.14.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1"},
|
||||
{file = "openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488"},
|
||||
{file = "openhands_agent_server-1.13.0-py3-none-any.whl", hash = "sha256:88bb8bfb03ff0cc7a7d32ffabd108d0a284f4333f33a9de27ce158b6d828bc29"},
|
||||
{file = "openhands_agent_server-1.13.0.tar.gz", hash = "sha256:6f8b296c0f26a478d4eb49668a353e2b6997c39022c2bbcc36325f5f08887a7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6259,12 +6259,11 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.14"
|
||||
openhands-sdk = "1.14"
|
||||
openhands-tools = "1.14"
|
||||
openhands-agent-server = "1.13"
|
||||
openhands-sdk = "1.13"
|
||||
openhands-tools = "1.13"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
orjson = ">=3.11.6"
|
||||
pathspec = ">=0.12.1"
|
||||
pexpect = "*"
|
||||
pg8000 = ">=1.31.5"
|
||||
@@ -6276,7 +6275,7 @@ protobuf = ">=5.29.6,<6"
|
||||
psutil = "*"
|
||||
pybase62 = ">=1"
|
||||
pygithub = ">=2.5"
|
||||
pyjwt = ">=2.12.0"
|
||||
pyjwt = ">=2.9"
|
||||
pylatexenc = "*"
|
||||
pypdf = ">=6.7.2"
|
||||
python-docx = "*"
|
||||
@@ -6316,14 +6315,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.14.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090"},
|
||||
{file = "openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615"},
|
||||
{file = "openhands_sdk-1.13.0-py3-none-any.whl", hash = "sha256:ec83f9fa2934aae9c4ce1c0365a7037f7e17869affa44a40e71ba49d2bef7185"},
|
||||
{file = "openhands_sdk-1.13.0.tar.gz", hash = "sha256:fbb2a2dc4852ea23cc697a36fb3f95ca47cfef432b0d195c496de6f374caad9c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6346,14 +6345,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.14.0"
|
||||
version = "1.13.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615"},
|
||||
{file = "openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5"},
|
||||
{file = "openhands_tools-1.13.0-py3-none-any.whl", hash = "sha256:87073b868e20f9c769497f480e0d15b14ca41314c3d1cb5076029f37408a1d68"},
|
||||
{file = "openhands_tools-1.13.0.tar.gz", hash = "sha256:e1181701efab5bc3133566e3b1640027824147438959cd8ce7430c941896704d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6561,86 +6560,99 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.7"
|
||||
version = "3.11.5"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de"},
|
||||
{file = "orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471"},
|
||||
{file = "orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74"},
|
||||
{file = "orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757"},
|
||||
{file = "orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:de0a37f21d0d364954ad5de1970491d7fbd0fb1ef7417d4d56a36dc01ba0c0a0"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c2428d358d85e8da9d37cba18b8c4047c55222007a84f97156a5b22028dfbfc0"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c4bc6c6ac52cdaa267552544c73e486fecbd710b7ac09bc024d5a78555a22f6"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd0d68edd7dfca1b2eca9361a44ac9f24b078de3481003159929a0573f21a6bf"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:623ad1b9548ef63886319c16fa317848e465a21513b31a6ad7b57443c3e0dcf5"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6e776b998ac37c0396093d10290e60283f59cfe0fc3fccbd0ccc4bd04dd19892"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:652c6c3af76716f4a9c290371ba2e390ede06f6603edb277b481daf37f6f464e"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a56df3239294ea5964adf074c54bcc4f0ccd21636049a2cf3ca9cf03b5d03cf1"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bda117c4148e81f746655d5a3239ae9bd00cb7bc3ca178b5fc5a5997e9744183"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:23d6c20517a97a9daf1d48b580fcdc6f0516c6f4b5038823426033690b4d2650"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8ff206156006da5b847c9304b6308a01e8cdbc8cce824e2779a5ba71c3def141"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:962d046ee1765f74a1da723f4b33e3b228fe3a48bd307acce5021dfefe0e29b2"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win32.whl", hash = "sha256:89e13dd3f89f1c38a9c9eba5fbf7cdc2d1feca82f5f290864b4b7a6aac704576"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win_amd64.whl", hash = "sha256:845c3e0d8ded9c9271cd79596b9b552448b885b97110f628fb687aee2eed11c1"},
|
||||
{file = "orjson-3.11.7-cp314-cp314-win_arm64.whl", hash = "sha256:4a2e9c5be347b937a2e0203866f12bba36082e89b402ddb9e927d5822e43088d"},
|
||||
{file = "orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8"},
|
||||
{file = "orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1"},
|
||||
{file = "orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875"},
|
||||
{file = "orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583"},
|
||||
{file = "orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310"},
|
||||
{file = "orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1b280e2d2d284a6713b0cfec7b08918ebe57df23e3f76b27586197afca3cb1e9"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d8a112b274fae8c5f0f01954cb0480137072c271f3f4958127b010dfefaec"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0a2ae6f09ac7bd47d2d5a5305c1d9ed08ac057cda55bb0a49fa506f0d2da00"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0d87bd1896faac0d10b4f849016db81a63e4ec5df38757ffae84d45ab38aa71"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:801a821e8e6099b8c459ac7540b3c32dba6013437c57fdcaec205b169754f38c"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69a0f6ac618c98c74b7fbc8c0172ba86f9e01dbf9f62aa0b1776c2231a7bffe5"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fea7339bdd22e6f1060c55ac31b6a755d86a5b2ad3657f2669ec243f8e3b2bdb"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4dad582bc93cef8f26513e12771e76385a7e6187fd713157e971c784112aad56"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:0522003e9f7fba91982e83a97fec0708f5a714c96c4209db7104e6b9d132f111"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:7403851e430a478440ecc1258bcbacbfbd8175f9ac1e39031a7121dd0de05ff8"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5f691263425d3177977c8d1dd896cde7b98d93cbf390b2544a090675e83a6a0a"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-win32.whl", hash = "sha256:61026196a1c4b968e1b1e540563e277843082e9e97d78afa03eb89315af531f1"},
|
||||
{file = "orjson-3.11.5-cp39-cp39-win_amd64.whl", hash = "sha256:09b94b947ac08586af635ef922d69dc9bc63321527a3a04647f4986a73f4bd30"},
|
||||
{file = "orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7917,14 +7929,14 @@ windows-terminal = ["colorama (>=0.4.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
version = "2.10.1"
|
||||
description = "JSON Web Token implementation in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
|
||||
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
|
||||
{file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"},
|
||||
{file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7932,9 +7944,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp
|
||||
|
||||
[package.extras]
|
||||
crypto = ["cryptography (>=3.4.0)"]
|
||||
dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
|
||||
tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"]
|
||||
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pylatexenc"
|
||||
|
||||
@@ -77,7 +77,6 @@ PERMITTED_CORS_ORIGINS = [
|
||||
)
|
||||
]
|
||||
|
||||
# Controls whether new orgs/users default to V1 API (env: DEFAULT_V1_ENABLED)
|
||||
DEFAULT_V1_ENABLED = os.getenv('DEFAULT_V1_ENABLED', '1').lower() in ('1', 'true')
|
||||
|
||||
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
"""Enterprise injector for PendingMessageService with SAAS filtering."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user import User
|
||||
|
||||
from openhands.agent_server.models import ImageContent, TextContent
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.pending_messages.pending_message_models import (
|
||||
PendingMessageResponse,
|
||||
)
|
||||
from openhands.app_server.pending_messages.pending_message_service import (
|
||||
PendingMessageService,
|
||||
PendingMessageServiceInjector,
|
||||
SQLPendingMessageService,
|
||||
)
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import ADMIN
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
|
||||
|
||||
class SaasSQLPendingMessageService(SQLPendingMessageService):
|
||||
"""Extended SQLPendingMessageService with user and organization-based filtering.
|
||||
|
||||
This enterprise version ensures that:
|
||||
- Users can only queue messages for conversations they own
|
||||
- Organization isolation is enforced for multi-tenant deployments
|
||||
"""
|
||||
|
||||
def __init__(self, db_session, user_context: UserContext):
|
||||
super().__init__(db_session=db_session)
|
||||
self.user_context = user_context
|
||||
|
||||
async def _get_current_user(self) -> User | None:
|
||||
"""Get the current user using the existing db_session.
|
||||
|
||||
Returns:
|
||||
User object or None if no user_id is available
|
||||
"""
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str:
|
||||
return None
|
||||
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
result = await self.db_session.execute(
|
||||
select(User).where(User.id == user_id_uuid)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def _validate_conversation_ownership(self, conversation_id: str) -> None:
|
||||
"""Validate that the current user owns the conversation.
|
||||
|
||||
This ensures multi-tenant isolation by checking:
|
||||
- The conversation belongs to the current user
|
||||
- The conversation belongs to the user's current organization
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to validate (can be task-id or UUID)
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation or authentication fails
|
||||
"""
|
||||
# For internal operations (e.g., processing pending messages during startup)
|
||||
# we need a mode that bypasses filtering. The ADMIN context enables this.
|
||||
if self.user_context == ADMIN:
|
||||
return
|
||||
|
||||
user_id_str = await self.user_context.get_user_id()
|
||||
if not user_id_str:
|
||||
raise AuthError('User authentication required')
|
||||
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
|
||||
# Check conversation ownership via SAAS metadata
|
||||
query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
saas_metadata = result.scalar_one_or_none()
|
||||
|
||||
# If no SAAS metadata exists, the conversation might be a new task-id
|
||||
# that hasn't been linked to a conversation yet. Allow access in this case
|
||||
# as the message will be validated when the conversation is created.
|
||||
if saas_metadata is None:
|
||||
return
|
||||
|
||||
# Verify user ownership
|
||||
if saas_metadata.user_id != user_id_uuid:
|
||||
raise AuthError('You do not have access to this conversation')
|
||||
|
||||
# Verify organization ownership if applicable
|
||||
user = await self._get_current_user()
|
||||
if user and user.current_org_id is not None:
|
||||
if saas_metadata.org_id != user.current_org_id:
|
||||
raise AuthError('Conversation belongs to a different organization')
|
||||
|
||||
async def add_message(
|
||||
self,
|
||||
conversation_id: str,
|
||||
content: list[TextContent | ImageContent],
|
||||
role: str = 'user',
|
||||
) -> PendingMessageResponse:
|
||||
"""Queue a message with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to queue the message for
|
||||
content: Message content
|
||||
role: Message role (default: 'user')
|
||||
|
||||
Returns:
|
||||
PendingMessageResponse with the queued message info
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().add_message(conversation_id, content, role)
|
||||
|
||||
async def get_pending_messages(self, conversation_id: str):
|
||||
"""Get pending messages with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to get messages for
|
||||
|
||||
Returns:
|
||||
List of pending messages
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().get_pending_messages(conversation_id)
|
||||
|
||||
async def count_pending_messages(self, conversation_id: str) -> int:
|
||||
"""Count pending messages with ownership validation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to count messages for
|
||||
|
||||
Returns:
|
||||
Number of pending messages
|
||||
|
||||
Raises:
|
||||
AuthError: If user doesn't own the conversation
|
||||
"""
|
||||
await self._validate_conversation_ownership(conversation_id)
|
||||
return await super().count_pending_messages(conversation_id)
|
||||
|
||||
|
||||
class SaasPendingMessageServiceInjector(PendingMessageServiceInjector):
|
||||
"""Enterprise injector for PendingMessageService with SAAS filtering."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[PendingMessageService, None]:
|
||||
from openhands.app_server.config import (
|
||||
get_db_session,
|
||||
get_user_context,
|
||||
)
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
service = SaasSQLPendingMessageService(
|
||||
db_session=db_session, user_context=user_context
|
||||
)
|
||||
yield service
|
||||
@@ -29,14 +29,7 @@ KEY_VERIFICATION_TIMEOUT = 5.0
|
||||
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
|
||||
UNLIMITED_BUDGET_SETTING = 1000000000.0
|
||||
|
||||
try:
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
|
||||
if DEFAULT_INITIAL_BUDGET < 0:
|
||||
raise ValueError(
|
||||
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}'
|
||||
)
|
||||
except ValueError as e:
|
||||
raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e
|
||||
DEFAULT_INITIAL_BUDGET = float(os.getenv('DEFAULT_INITIAL_BUDGET', '0.0'))
|
||||
|
||||
|
||||
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
|
||||
|
||||
@@ -47,7 +47,6 @@ class Org(Base): # type: ignore
|
||||
conversation_expiration = Column(Integer, nullable=True)
|
||||
condenser_max_size = Column(Integer, nullable=True)
|
||||
byor_export_enabled = Column(Boolean, nullable=False, default=False)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
org_members = relationship('OrgMember', back_populates='org')
|
||||
|
||||
@@ -117,9 +117,6 @@ class SaasSettingsStore(SettingsStore):
|
||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
||||
if org.v1_enabled is None:
|
||||
kwargs['v1_enabled'] = True
|
||||
# Apply default if sandbox_grouping_strategy is None in the database
|
||||
if kwargs.get('sandbox_grouping_strategy') is None:
|
||||
kwargs.pop('sandbox_grouping_strategy', None)
|
||||
|
||||
settings = Settings(**kwargs)
|
||||
return settings
|
||||
|
||||
@@ -25,10 +25,10 @@ class SlackConversationStore:
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_slack_conversation(
|
||||
self, slack_conversation: SlackConversation
|
||||
self, slack_converstion: SlackConversation
|
||||
) -> None:
|
||||
async with a_session_maker() as session:
|
||||
await session.merge(slack_conversation)
|
||||
session.merge(slack_converstion)
|
||||
await session.commit()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -33,7 +33,6 @@ class User(Base): # type: ignore
|
||||
email_verified = Column(Boolean, nullable=True)
|
||||
git_user_name = Column(String, nullable=True)
|
||||
git_user_email = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
role = relationship('Role', back_populates='users')
|
||||
|
||||
@@ -27,7 +27,6 @@ class UserSettings(Base): # type: ignore
|
||||
)
|
||||
sandbox_base_container_image = Column(String, nullable=True)
|
||||
sandbox_runtime_container_image = Column(String, nullable=True)
|
||||
sandbox_grouping_strategy = Column(String, nullable=True)
|
||||
user_version = Column(Integer, nullable=False, default=0)
|
||||
accepted_tos = Column(DateTime, nullable=True)
|
||||
mcp_config = Column(JSON, nullable=True)
|
||||
|
||||
@@ -242,10 +242,6 @@ class UserStore:
|
||||
if hasattr(org, key):
|
||||
setattr(org, key, value)
|
||||
|
||||
# Apply DEFAULT_V1_ENABLED for migrated orgs if v1_enabled was not set
|
||||
if org.v1_enabled is None:
|
||||
org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
|
||||
user_kwargs = UserStore.get_kwargs_from_user_settings(
|
||||
decrypted_user_settings
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ from storage.org import Org
|
||||
from storage.org_invitation import OrgInvitation # noqa: F401
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
from storage.slack_conversation import SlackConversation # noqa: F401
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import (
|
||||
StoredConversationMetadataSaas,
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Unit tests for SlackConversationStore."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def slack_conversation_store():
|
||||
"""Create SlackConversationStore instance."""
|
||||
return SlackConversationStore()
|
||||
|
||||
|
||||
class TestSlackConversationStore:
|
||||
"""Test cases for SlackConversationStore."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_slack_conversation_persists_to_database(
|
||||
self, slack_conversation_store, async_session_maker
|
||||
):
|
||||
"""Test that create_slack_conversation actually stores data in the database.
|
||||
|
||||
This test verifies that the await statement is present before session.merge().
|
||||
Without the await, the data won't be persisted and subsequent lookups will
|
||||
return None even though we just created the conversation.
|
||||
"""
|
||||
channel_id = 'C123456'
|
||||
parent_id = '1234567890.123456'
|
||||
conversation_id = 'conv-test-123'
|
||||
keycloak_user_id = 'user-123'
|
||||
|
||||
slack_conversation = SlackConversation(
|
||||
conversation_id=conversation_id,
|
||||
channel_id=channel_id,
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
with patch(
|
||||
'storage.slack_conversation_store.a_session_maker', async_session_maker
|
||||
):
|
||||
# Create the slack conversation
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
# Verify we can retrieve the conversation using the store method
|
||||
result = await slack_conversation_store.get_slack_conversation(
|
||||
channel_id=channel_id,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
# This assertion would fail if the await was missing before session.merge()
|
||||
# because the data wouldn't be persisted to the database
|
||||
assert result is not None, (
|
||||
'Slack conversation was not persisted to the database. '
|
||||
'Ensure await is used before session.merge() in create_slack_conversation.'
|
||||
)
|
||||
assert result.conversation_id == conversation_id
|
||||
assert result.channel_id == channel_id
|
||||
assert result.parent_id == parent_id
|
||||
assert result.keycloak_user_id == keycloak_user_id
|
||||
|
||||
# Also verify directly in the database
|
||||
async with async_session_maker() as session:
|
||||
db_result = await session.execute(
|
||||
select(SlackConversation).where(
|
||||
SlackConversation.channel_id == channel_id,
|
||||
SlackConversation.parent_id == parent_id,
|
||||
)
|
||||
)
|
||||
db_conversation = db_result.scalar_one_or_none()
|
||||
assert db_conversation is not None
|
||||
assert db_conversation.conversation_id == conversation_id
|
||||
@@ -2,9 +2,7 @@
|
||||
Unit tests for LiteLlmManager class.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
@@ -23,71 +21,6 @@ from storage.user_settings import UserSettings
|
||||
from openhands.server.settings import Settings
|
||||
|
||||
|
||||
class TestDefaultInitialBudget:
|
||||
"""Test cases for DEFAULT_INITIAL_BUDGET configuration."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_module_state(self):
|
||||
"""Ensure module is properly restored after each test."""
|
||||
# Save original module if it exists
|
||||
original_module = sys.modules.get('storage.lite_llm_manager')
|
||||
|
||||
yield
|
||||
|
||||
# Restore module state after each test
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env var
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
|
||||
# Restore original module or reimport fresh
|
||||
if original_module is not None:
|
||||
sys.modules['storage.lite_llm_manager'] = original_module
|
||||
else:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
|
||||
def test_default_initial_budget_defaults_to_zero(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set."""
|
||||
# Temporarily remove the module so we can reimport with different env vars
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
# Clear the env var and reimport
|
||||
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 0.0
|
||||
|
||||
def test_default_initial_budget_uses_env_var(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
|
||||
module = importlib.import_module('storage.lite_llm_manager')
|
||||
assert module.DEFAULT_INITIAL_BUDGET == 100.0
|
||||
|
||||
def test_default_initial_budget_rejects_invalid_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
assert 'Invalid DEFAULT_INITIAL_BUDGET' in str(exc_info.value)
|
||||
|
||||
def test_default_initial_budget_rejects_negative_value(self):
|
||||
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for negative values."""
|
||||
if 'storage.lite_llm_manager' in sys.modules:
|
||||
del sys.modules['storage.lite_llm_manager']
|
||||
|
||||
os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0'
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
importlib.import_module('storage.lite_llm_manager')
|
||||
assert 'must be non-negative' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestLiteLlmManager:
|
||||
"""Test cases for LiteLlmManager class."""
|
||||
|
||||
@@ -309,10 +242,10 @@ class TestLiteLlmManager:
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == 30.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_new_org_uses_default_initial_budget(
|
||||
async def test_create_entries_new_org_uses_zero_budget(
|
||||
self, mock_settings, mock_response
|
||||
):
|
||||
"""Test that create_entries uses DEFAULT_INITIAL_BUDGET for new org."""
|
||||
"""Test that create_entries uses budget=0 for new org (team doesn't exist)."""
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
@@ -340,7 +273,6 @@ class TestLiteLlmManager:
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
|
||||
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
|
||||
patch('httpx.AsyncClient', mock_client_class),
|
||||
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', 0.0),
|
||||
):
|
||||
result = await LiteLlmManager.create_entries(
|
||||
'test-org-id', 'test-user-id', mock_settings, create_user=False
|
||||
@@ -348,67 +280,16 @@ class TestLiteLlmManager:
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify _create_team was called with DEFAULT_INITIAL_BUDGET (0.0)
|
||||
# Verify _create_team was called with budget=0
|
||||
create_team_call = mock_client.post.call_args_list[0]
|
||||
assert 'team/new' in create_team_call[0][0]
|
||||
assert create_team_call[1]['json']['max_budget'] == 0.0
|
||||
|
||||
# Verify _add_user_to_team was called with DEFAULT_INITIAL_BUDGET (0.0)
|
||||
# Verify _add_user_to_team was called with budget=0
|
||||
add_user_call = mock_client.post.call_args_list[1]
|
||||
assert 'team/member_add' in add_user_call[0][0]
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_new_org_uses_custom_default_budget(
|
||||
self, mock_settings, mock_response
|
||||
):
|
||||
"""Test that create_entries uses custom DEFAULT_INITIAL_BUDGET for new org."""
|
||||
mock_404_response = MagicMock()
|
||||
mock_404_response.status_code = 404
|
||||
mock_404_response.is_success = False
|
||||
|
||||
mock_token_manager = MagicMock()
|
||||
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
|
||||
return_value={'email': 'test@example.com'}
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_404_response
|
||||
mock_client.get.return_value.raise_for_status.side_effect = (
|
||||
httpx.HTTPStatusError(
|
||||
message='Not Found', request=MagicMock(), response=mock_404_response
|
||||
)
|
||||
)
|
||||
mock_client.post.return_value = mock_response
|
||||
|
||||
mock_client_class = MagicMock()
|
||||
mock_client_class.return_value.__aenter__.return_value = mock_client
|
||||
|
||||
custom_budget = 50.0
|
||||
with (
|
||||
patch.dict(os.environ, {'LOCAL_DEPLOYMENT': ''}),
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'),
|
||||
patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'),
|
||||
patch('storage.lite_llm_manager.TokenManager', mock_token_manager),
|
||||
patch('httpx.AsyncClient', mock_client_class),
|
||||
patch('storage.lite_llm_manager.DEFAULT_INITIAL_BUDGET', custom_budget),
|
||||
):
|
||||
result = await LiteLlmManager.create_entries(
|
||||
'test-org-id', 'test-user-id', mock_settings, create_user=False
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
|
||||
# Verify _create_team was called with custom DEFAULT_INITIAL_BUDGET
|
||||
create_team_call = mock_client.post.call_args_list[0]
|
||||
assert 'team/new' in create_team_call[0][0]
|
||||
assert create_team_call[1]['json']['max_budget'] == custom_budget
|
||||
|
||||
# Verify _add_user_to_team was called with custom DEFAULT_INITIAL_BUDGET
|
||||
add_user_call = mock_client.post.call_args_list[1]
|
||||
assert 'team/member_add' in add_user_call[0][0]
|
||||
assert add_user_call[1]['json']['max_budget_in_team'] == custom_budget
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_entries_propagates_non_404_errors(self, mock_settings):
|
||||
"""Test that create_entries propagates non-404 errors from _get_team."""
|
||||
|
||||
@@ -144,86 +144,6 @@ async def test_create_org(async_session_maker, mock_litellm_api):
|
||||
assert org.id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_defaults_to_true_when_default_is_true(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True and org.v1_enabled is not specified (None)
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should be set to True
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
|
||||
):
|
||||
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-true'})
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_defaults_to_false_when_default_is_false(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False and org.v1_enabled is not specified (None)
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should be set to False
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
|
||||
):
|
||||
org = await OrgStore.create_org(kwargs={'name': 'test-org-v1-default-false'})
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_explicit_false_overrides_default_true(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True but org.v1_enabled is explicitly set to False
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should stay False (explicit value wins over default)
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', True),
|
||||
):
|
||||
org = await OrgStore.create_org(
|
||||
kwargs={'name': 'test-org-v1-explicit-false', 'v1_enabled': False}
|
||||
)
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_org_v1_enabled_explicit_true_overrides_default_false(
|
||||
async_session_maker, mock_litellm_api
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False but org.v1_enabled is explicitly set to True
|
||||
WHEN: create_org is called
|
||||
THEN: org.v1_enabled should stay True (explicit value wins over default)
|
||||
"""
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', async_session_maker),
|
||||
patch('storage.org_store.DEFAULT_V1_ENABLED', False),
|
||||
):
|
||||
org = await OrgStore.create_org(
|
||||
kwargs={'name': 'test-org-v1-explicit-true', 'v1_enabled': True}
|
||||
)
|
||||
|
||||
assert org is not None
|
||||
assert org.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_by_name(async_session_maker, mock_litellm_api):
|
||||
# Test getting org by name
|
||||
|
||||
@@ -101,72 +101,6 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
|
||||
assert settings.llm_base_url == 'http://test.url'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_v1_enabled_true_when_default_is_true(
|
||||
mock_litellm_api,
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is True
|
||||
WHEN: create_default_settings is called
|
||||
THEN: The default_settings.v1_enabled should be set to True
|
||||
"""
|
||||
org_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Track the settings passed to LiteLlmManager.create_entries
|
||||
captured_settings = None
|
||||
|
||||
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
|
||||
nonlocal captured_settings
|
||||
captured_settings = settings
|
||||
return settings
|
||||
|
||||
with (
|
||||
patch('storage.user_store.DEFAULT_V1_ENABLED', True),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.create_entries',
|
||||
side_effect=capture_create_entries,
|
||||
),
|
||||
):
|
||||
await UserStore.create_default_settings(org_id, user_id)
|
||||
|
||||
assert captured_settings is not None
|
||||
assert captured_settings.v1_enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_settings_v1_enabled_false_when_default_is_false(
|
||||
mock_litellm_api,
|
||||
):
|
||||
"""
|
||||
GIVEN: DEFAULT_V1_ENABLED is False
|
||||
WHEN: create_default_settings is called
|
||||
THEN: The default_settings.v1_enabled should be set to False
|
||||
"""
|
||||
org_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
|
||||
# Track the settings passed to LiteLlmManager.create_entries
|
||||
captured_settings = None
|
||||
|
||||
async def capture_create_entries(_org_id, _user_id, settings, _create_user):
|
||||
nonlocal captured_settings
|
||||
captured_settings = settings
|
||||
return settings
|
||||
|
||||
with (
|
||||
patch('storage.user_store.DEFAULT_V1_ENABLED', False),
|
||||
patch(
|
||||
'storage.lite_llm_manager.LiteLlmManager.create_entries',
|
||||
side_effect=capture_create_entries,
|
||||
),
|
||||
):
|
||||
await UserStore.create_default_settings(org_id, user_id)
|
||||
|
||||
assert captured_settings is not None
|
||||
assert captured_settings.v1_enabled is False
|
||||
|
||||
|
||||
# --- Tests for get_user_by_id ---
|
||||
|
||||
|
||||
@@ -1309,19 +1243,3 @@ async def test_migrate_user_sql_multiple_conversations(async_session_maker):
|
||||
assert (
|
||||
row.org_id == user_uuid_str
|
||||
), f'org_id should match: {row.org_id} vs {user_uuid_str}'
|
||||
|
||||
|
||||
# Note: The v1_enabled logic in migrate_user follows the same pattern as OrgStore.create_org:
|
||||
# if org.v1_enabled is None:
|
||||
# org.v1_enabled = DEFAULT_V1_ENABLED
|
||||
#
|
||||
# This behavior is tested in test_org_store.py via:
|
||||
# - test_create_org_v1_enabled_defaults_to_true_when_default_is_true
|
||||
# - test_create_org_v1_enabled_defaults_to_false_when_default_is_false
|
||||
# - test_create_org_v1_enabled_explicit_false_overrides_default_true
|
||||
# - test_create_org_v1_enabled_explicit_true_overrides_default_false
|
||||
#
|
||||
# Testing migrate_user directly is impractical due to its complex raw SQL migration
|
||||
# statements that have SQLite/UUID compatibility issues in the test environment.
|
||||
# The SQL migration tests above (test_migrate_user_sql_type_handling, etc.) verify
|
||||
# the SQL operations work correctly with proper type handling.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest";
|
||||
import axios from "axios";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() }));
|
||||
@@ -7,8 +6,6 @@ vi.mock("#/api/open-hands-axios", () => ({
|
||||
openHands: { get: mockGet },
|
||||
}));
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
describe("V1ConversationService", () => {
|
||||
describe("readConversationFile", () => {
|
||||
it("uses default plan path when filePath is not provided", async () => {
|
||||
@@ -27,91 +24,4 @@ describe("V1ConversationService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uploadFile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.post as Mock).mockResolvedValue({ data: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("uses query params for file upload path", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "test.txt", { type: "text/plain" });
|
||||
const uploadPath = "/workspace/custom/path.txt";
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
uploadPath,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
|
||||
|
||||
// Verify URL uses query params format
|
||||
expect(callUrl).toContain("/api/file/upload?");
|
||||
expect(callUrl).toContain("path=%2Fworkspace%2Fcustom%2Fpath.txt");
|
||||
|
||||
// Verify it's NOT using path params format
|
||||
expect(callUrl).not.toContain("/api/file/upload/%2F");
|
||||
});
|
||||
|
||||
it("uses default workspace path when no path provided", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "myfile.txt", { type: "text/plain" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (axios.post as Mock).mock.calls[0][0] as string;
|
||||
|
||||
// Default path should be /workspace/{filename}
|
||||
expect(callUrl).toContain("path=%2Fworkspace%2Fmyfile.txt");
|
||||
});
|
||||
|
||||
it("sends file as FormData with correct headers", async () => {
|
||||
// Arrange
|
||||
const conversationUrl = "http://localhost:54928/api/conversations/conv-123";
|
||||
const sessionApiKey = "test-api-key";
|
||||
const file = new File(["test content"], "test.txt", { type: "text/plain" });
|
||||
|
||||
// Act
|
||||
await V1ConversationService.uploadFile(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
file,
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(axios.post).toHaveBeenCalledTimes(1);
|
||||
const callArgs = (axios.post as Mock).mock.calls[0];
|
||||
|
||||
// Verify FormData is sent
|
||||
const formData = callArgs[1];
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
expect(formData.get("file")).toBe(file);
|
||||
|
||||
// Verify headers include session API key and content type
|
||||
const headers = callArgs[2].headers;
|
||||
expect(headers).toHaveProperty("X-Session-API-Key", sessionApiKey);
|
||||
expect(headers).toHaveProperty("Content-Type", "multipart/form-data");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,6 @@ describe("SystemMessage UI Rendering", () => {
|
||||
<ToolsContextMenu
|
||||
onClose={() => {}}
|
||||
onShowSkills={() => {}}
|
||||
onShowHooks={() => {}}
|
||||
onShowAgentTools={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { HookEventItem } from "#/components/features/conversation-panel/hook-event-item";
|
||||
import { HooksEmptyState } from "#/components/features/conversation-panel/hooks-empty-state";
|
||||
import { HooksLoadingState } from "#/components/features/conversation-panel/hooks-loading-state";
|
||||
import { HooksModalHeader } from "#/components/features/conversation-panel/hooks-modal-header";
|
||||
import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
HOOKS_MODAL$TITLE: "Available Hooks",
|
||||
HOOKS_MODAL$HOOK_COUNT: `${params?.count ?? 0} hooks`,
|
||||
HOOKS_MODAL$EVENT_PRE_TOOL_USE: "Pre Tool Use",
|
||||
HOOKS_MODAL$EVENT_POST_TOOL_USE: "Post Tool Use",
|
||||
HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT: "User Prompt Submit",
|
||||
HOOKS_MODAL$EVENT_SESSION_START: "Session Start",
|
||||
HOOKS_MODAL$EVENT_SESSION_END: "Session End",
|
||||
HOOKS_MODAL$EVENT_STOP: "Stop",
|
||||
HOOKS_MODAL$MATCHER: "Matcher",
|
||||
HOOKS_MODAL$COMMANDS: "Commands",
|
||||
HOOKS_MODAL$TYPE: `Type: ${params?.type ?? ""}`,
|
||||
HOOKS_MODAL$TIMEOUT: `Timeout: ${params?.timeout ?? 0}s`,
|
||||
HOOKS_MODAL$ASYNC: "Async",
|
||||
COMMON$FETCH_ERROR: "Failed to fetch data",
|
||||
CONVERSATION$NO_HOOKS: "No hooks configured",
|
||||
BUTTON$REFRESH: "Refresh",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("HooksLoadingState", () => {
|
||||
it("should render loading spinner", () => {
|
||||
render(<HooksLoadingState />);
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HooksEmptyState", () => {
|
||||
it("should render no hooks message when not error", () => {
|
||||
render(<HooksEmptyState isError={false} />);
|
||||
expect(screen.getByText("No hooks configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error message when isError is true", () => {
|
||||
render(<HooksEmptyState isError={true} />);
|
||||
expect(screen.getByText("Failed to fetch data")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HooksModalHeader", () => {
|
||||
const defaultProps = {
|
||||
isAgentReady: true,
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
onRefresh: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render title", () => {
|
||||
render(<HooksModalHeader {...defaultProps} />);
|
||||
expect(screen.getByText("Available Hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render refresh button when agent is ready", () => {
|
||||
render(<HooksModalHeader {...defaultProps} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render refresh button when agent is not ready", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isAgentReady={false} />);
|
||||
expect(screen.queryByTestId("refresh-hooks")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onRefresh when refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRefresh = vi.fn();
|
||||
render(<HooksModalHeader {...defaultProps} onRefresh={onRefresh} />);
|
||||
|
||||
await user.click(screen.getByTestId("refresh-hooks"));
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should disable refresh button when loading", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should disable refresh button when refetching", () => {
|
||||
render(<HooksModalHeader {...defaultProps} isRefetching={true} />);
|
||||
expect(screen.getByTestId("refresh-hooks")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HookEventItem", () => {
|
||||
const mockHookEvent: HookEvent = {
|
||||
event_type: "stop",
|
||||
matchers: [
|
||||
{
|
||||
matcher: "*",
|
||||
hooks: [
|
||||
{
|
||||
type: "command",
|
||||
command: ".openhands/hooks/on_stop.sh",
|
||||
timeout: 30,
|
||||
async: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
hookEvent: mockHookEvent,
|
||||
isExpanded: false,
|
||||
onToggle: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render event type label using i18n", () => {
|
||||
render(<HookEventItem {...defaultProps} />);
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render hook count", () => {
|
||||
render(<HookEventItem {...defaultProps} />);
|
||||
expect(screen.getByText("1 hooks")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onToggle when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggle = vi.fn();
|
||||
render(<HookEventItem {...defaultProps} onToggle={onToggle} />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledWith("stop");
|
||||
});
|
||||
|
||||
it("should show collapsed state by default", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={false} />);
|
||||
// Matcher content should not be visible when collapsed
|
||||
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show expanded state with matcher content", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={true} />);
|
||||
// Matcher content should be visible when expanded
|
||||
expect(screen.getByText("*")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render async badge for async hooks", () => {
|
||||
render(<HookEventItem {...defaultProps} isExpanded={true} />);
|
||||
expect(screen.getByText("Async")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render different event types with correct i18n labels", () => {
|
||||
const eventTypes = [
|
||||
{ type: "pre_tool_use", label: "Pre Tool Use" },
|
||||
{ type: "post_tool_use", label: "Post Tool Use" },
|
||||
{ type: "user_prompt_submit", label: "User Prompt Submit" },
|
||||
{ type: "session_start", label: "Session Start" },
|
||||
{ type: "session_end", label: "Session End" },
|
||||
{ type: "stop", label: "Stop" },
|
||||
];
|
||||
|
||||
eventTypes.forEach(({ type, label }) => {
|
||||
const { unmount } = render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={{ ...mockHookEvent, event_type: type }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("should fallback to event_type when no i18n key exists", () => {
|
||||
render(
|
||||
<HookEventItem
|
||||
{...defaultProps}
|
||||
hookEvent={{ ...mockHookEvent, event_type: "unknown_event" }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("unknown_event")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -72,7 +72,7 @@ vi.mock("react-i18next", async () => {
|
||||
CONVERSATION$SHOW_SKILLS: "Show Skills",
|
||||
BUTTON$DISPLAY_COST: "Display Cost",
|
||||
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
|
||||
"Close Conversation (Stop Sandbox)",
|
||||
"Close Conversation (Stop Runtime)",
|
||||
COMMON$DELETE_CONVERSATION: "Delete Conversation",
|
||||
CONVERSATION$SHARE_PUBLICLY: "Share Publicly",
|
||||
CONVERSATION$LINK_COPIED: "Link copied to clipboard",
|
||||
@@ -565,7 +565,7 @@ describe("ConversationNameContextMenu", () => {
|
||||
"Delete Conversation",
|
||||
);
|
||||
expect(screen.getByTestId("stop-button")).toHaveTextContent(
|
||||
"Close Conversation (Stop Sandbox)",
|
||||
"Close Conversation (Stop Runtime)",
|
||||
);
|
||||
expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
|
||||
"Display Cost",
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
|
||||
|
||||
const mockCapture = vi.fn();
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: () => ({
|
||||
capture: mockCapture,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
describe("EnterpriseBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Feature Flag", () => {
|
||||
it("should not render when proj_user_journey feature flag is disabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
|
||||
const { container } = renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render when proj_user_journey feature flag is enabled", () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rendering", () => {
|
||||
it("should render the self-hosted label", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$SELF_HOSTED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the enterprise title", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the enterprise description", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(screen.getByText("ENTERPRISE$DESCRIPTION")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all four enterprise feature items", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_DATA_PRIVACY"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_DEPLOYMENT"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ENTERPRISE$FEATURE_SSO")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("ENTERPRISE$FEATURE_SUPPORT"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the learn more link", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveTextContent("ENTERPRISE$LEARN_MORE");
|
||||
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Learn More Link Interaction", () => {
|
||||
it("should capture PostHog event when learn more link is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
await user.click(link);
|
||||
|
||||
expect(mockCapture).toHaveBeenCalledWith("saas_selfhosted_inquiry");
|
||||
});
|
||||
|
||||
it("should have correct href attribute for opening in new tab", () => {
|
||||
renderWithProviders(<EnterpriseBanner />);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
});
|
||||
expect(link).toHaveAttribute("href", "https://openhands.dev/enterprise");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -198,9 +198,9 @@ describe("InteractiveChatBox", () => {
|
||||
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [], []);
|
||||
});
|
||||
|
||||
it("should disable the submit button when awaiting user confirmation", async () => {
|
||||
it("should disable the submit button when agent is loading", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockStores(AgentState.AWAITING_USER_CONFIRMATION);
|
||||
mockStores(AgentState.LOADING);
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getObservationContent } from "#/components/v1/chat/event-content-helpers/get-observation-content";
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import {
|
||||
BrowserObservation,
|
||||
GlobObservation,
|
||||
GrepObservation,
|
||||
} from "#/types/v1/core/base/observation";
|
||||
import { BrowserObservation } from "#/types/v1/core/base/observation";
|
||||
|
||||
describe("getObservationContent - BrowserObservation", () => {
|
||||
it("should return output content when available", () => {
|
||||
@@ -94,212 +90,3 @@ describe("getObservationContent - BrowserObservation", () => {
|
||||
expect(result).toBe("**Output:**\nPage loaded successfully");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getObservationContent - GlobObservation", () => {
|
||||
it("should display files found when glob matches files", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Found 2 files", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: ["/workspace/src/index.ts", "/workspace/src/app.ts"],
|
||||
pattern: "**/*.ts",
|
||||
search_path: "/workspace",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `**/*.ts`");
|
||||
expect(result).toContain("**Search Path:** `/workspace`");
|
||||
expect(result).toContain("**Files Found (2):**");
|
||||
expect(result).toContain("- `/workspace/src/index.ts`");
|
||||
expect(result).toContain("- `/workspace/src/app.ts`");
|
||||
});
|
||||
|
||||
it("should display no files found message when glob matches nothing", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "No files found", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: [],
|
||||
pattern: "**/*.xyz",
|
||||
search_path: "/workspace",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `**/*.xyz`");
|
||||
expect(result).toContain("**Result:** No files found.");
|
||||
});
|
||||
|
||||
it("should display error when glob operation fails", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Permission denied", cache_prompt: false }],
|
||||
is_error: true,
|
||||
files: [],
|
||||
pattern: "**/*",
|
||||
search_path: "/restricted",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Error:**");
|
||||
expect(result).toContain("Permission denied");
|
||||
});
|
||||
|
||||
it("should indicate truncation when results exceed limit", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GlobObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "glob",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GlobObservation",
|
||||
content: [{ type: "text", text: "Found files", cache_prompt: false }],
|
||||
is_error: false,
|
||||
files: ["/workspace/file1.ts"],
|
||||
pattern: "**/*.ts",
|
||||
search_path: "/workspace",
|
||||
truncated: true,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Files Found (1+, truncated):**");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getObservationContent - GrepObservation", () => {
|
||||
it("should display matches found when grep finds results", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "Found 2 matches", cache_prompt: false }],
|
||||
is_error: false,
|
||||
matches: ["/workspace/src/api.ts", "/workspace/src/routes.ts"],
|
||||
pattern: "fetchData",
|
||||
search_path: "/workspace",
|
||||
include_pattern: "*.ts",
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `fetchData`");
|
||||
expect(result).toContain("**Search Path:** `/workspace`");
|
||||
expect(result).toContain("**Include:** `*.ts`");
|
||||
expect(result).toContain("**Matches (2):**");
|
||||
expect(result).toContain("- `/workspace/src/api.ts`");
|
||||
});
|
||||
|
||||
it("should display no matches found when grep finds nothing", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "No matches", cache_prompt: false }],
|
||||
is_error: false,
|
||||
matches: [],
|
||||
pattern: "nonExistentFunction",
|
||||
search_path: "/workspace",
|
||||
include_pattern: null,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Pattern:** `nonExistentFunction`");
|
||||
expect(result).toContain("**Result:** No matches found.");
|
||||
expect(result).not.toContain("**Include:**");
|
||||
});
|
||||
|
||||
it("should display error when grep operation fails", () => {
|
||||
// Arrange
|
||||
const mockEvent: ObservationEvent<GrepObservation> = {
|
||||
id: "test-id",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
source: "environment",
|
||||
tool_name: "grep",
|
||||
tool_call_id: "call-id",
|
||||
action_id: "action-id",
|
||||
observation: {
|
||||
kind: "GrepObservation",
|
||||
content: [{ type: "text", text: "Invalid regex pattern", cache_prompt: false }],
|
||||
is_error: true,
|
||||
matches: [],
|
||||
pattern: "[invalid",
|
||||
search_path: "/workspace",
|
||||
include_pattern: null,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getObservationContent(mockEvent);
|
||||
|
||||
// Assert
|
||||
expect(result).toContain("**Error:**");
|
||||
expect(result).toContain("Invalid regex pattern");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ const createUserMessageEvent = (id: string): MessageEvent => ({
|
||||
const createPlanningObservationEvent = (
|
||||
id: string,
|
||||
actionId: string = "action-1",
|
||||
path: string = "/workspace/PLAN.md",
|
||||
): ObservationEvent<PlanningFileEditorObservation> => ({
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -41,7 +40,7 @@ const createPlanningObservationEvent = (
|
||||
content: [{ type: "text", text: "Plan content" }],
|
||||
is_error: false,
|
||||
command: "create",
|
||||
path,
|
||||
path: "/workspace/PLAN.md",
|
||||
prev_exist: false,
|
||||
old_content: null,
|
||||
new_content: "Plan content",
|
||||
@@ -173,31 +172,6 @@ describe("usePlanPreviewEvents", () => {
|
||||
expect(result.current.size).toBe(1);
|
||||
expect(result.current.has("plan-obs-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should exclude PlanningFileEditorObservation for non-Plan.md paths", () => {
|
||||
const events: OpenHandsEvent[] = [
|
||||
createUserMessageEvent("user-1"),
|
||||
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
|
||||
createPlanningObservationEvent("plan-obs-2", "action-2", "use-add-mcp.ts"),
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => usePlanPreviewEvents(events));
|
||||
|
||||
expect(result.current.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should include only Plan.md observations when mixed with other file edits", () => {
|
||||
const events: OpenHandsEvent[] = [
|
||||
createUserMessageEvent("user-1"),
|
||||
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
|
||||
createPlanningObservationEvent("plan-obs-2", "action-2", "/workspace/PLAN.md"),
|
||||
];
|
||||
|
||||
const { result } = renderHook(() => usePlanPreviewEvents(events));
|
||||
|
||||
expect(result.current.size).toBe(1);
|
||||
expect(result.current.has("plan-obs-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowPlanPreview", () => {
|
||||
|
||||
@@ -229,231 +229,4 @@ describe("conversation localStorage utilities", () => {
|
||||
expect(parsed.subConversationTaskId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftMessage persistence", () => {
|
||||
describe("getConversationState", () => {
|
||||
it("returns default draftMessage as null when no state exists", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-1";
|
||||
|
||||
// Act
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
// Assert
|
||||
expect(state.draftMessage).toBeNull();
|
||||
});
|
||||
|
||||
it("retrieves draftMessage from localStorage when it exists", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-2";
|
||||
const draftText = "This is my saved draft message";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: draftText,
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
const state = getConversationState(conversationId);
|
||||
|
||||
// Assert
|
||||
expect(state.draftMessage).toBe(draftText);
|
||||
});
|
||||
|
||||
it("returns null draftMessage for task conversation IDs (not persisted)", () => {
|
||||
// Arrange
|
||||
const taskId = "task-uuid-123";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`;
|
||||
|
||||
// Even if somehow there's data in localStorage for a task ID
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: "Should not be returned",
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
const state = getConversationState(taskId);
|
||||
|
||||
// Assert - should return default state, not the stored value
|
||||
expect(state.draftMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConversationState", () => {
|
||||
it("persists draftMessage to localStorage", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-3";
|
||||
const draftText = "New draft message to save";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
// Act
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: draftText,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
expect(stored).not.toBeNull();
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.draftMessage).toBe(draftText);
|
||||
});
|
||||
|
||||
it("does not persist draftMessage for task conversation IDs", () => {
|
||||
// Arrange
|
||||
const taskId = "task-draft-xyz";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${taskId}`;
|
||||
|
||||
// Act
|
||||
setConversationState(taskId, {
|
||||
draftMessage: "Draft for task ID",
|
||||
});
|
||||
|
||||
// Assert - nothing should be stored
|
||||
expect(localStorage.getItem(consolidatedKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("merges draftMessage with existing state without overwriting other fields", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-4";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
selectedTab: "terminal",
|
||||
rightPanelShown: false,
|
||||
unpinnedTabs: ["tab-1", "tab-2"],
|
||||
conversationMode: "plan",
|
||||
subConversationTaskId: "task-123",
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: "Updated draft",
|
||||
});
|
||||
|
||||
// Assert
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
const parsed = JSON.parse(stored!);
|
||||
|
||||
expect(parsed.draftMessage).toBe("Updated draft");
|
||||
expect(parsed.selectedTab).toBe("terminal");
|
||||
expect(parsed.rightPanelShown).toBe(false);
|
||||
expect(parsed.unpinnedTabs).toEqual(["tab-1", "tab-2"]);
|
||||
expect(parsed.conversationMode).toBe("plan");
|
||||
expect(parsed.subConversationTaskId).toBe("task-123");
|
||||
});
|
||||
|
||||
it("clears draftMessage when set to null", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-5";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: "Existing draft",
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.draftMessage).toBeNull();
|
||||
});
|
||||
|
||||
it("clears draftMessage when set to empty string (stored as empty string)", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-draft-6";
|
||||
const consolidatedKey = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-${conversationId}`;
|
||||
|
||||
localStorage.setItem(
|
||||
consolidatedKey,
|
||||
JSON.stringify({
|
||||
draftMessage: "Existing draft",
|
||||
}),
|
||||
);
|
||||
|
||||
// Act
|
||||
setConversationState(conversationId, {
|
||||
draftMessage: "",
|
||||
});
|
||||
|
||||
// Assert
|
||||
const stored = localStorage.getItem(consolidatedKey);
|
||||
const parsed = JSON.parse(stored!);
|
||||
expect(parsed.draftMessage).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation-specific draft isolation", () => {
|
||||
it("stores drafts separately for different conversations", () => {
|
||||
// Arrange
|
||||
const convA = "conv-A";
|
||||
const convB = "conv-B";
|
||||
const draftA = "Draft for conversation A";
|
||||
const draftB = "Draft for conversation B";
|
||||
|
||||
// Act
|
||||
setConversationState(convA, { draftMessage: draftA });
|
||||
setConversationState(convB, { draftMessage: draftB });
|
||||
|
||||
// Assert
|
||||
const stateA = getConversationState(convA);
|
||||
const stateB = getConversationState(convB);
|
||||
|
||||
expect(stateA.draftMessage).toBe(draftA);
|
||||
expect(stateB.draftMessage).toBe(draftB);
|
||||
});
|
||||
|
||||
it("updating one conversation draft does not affect another", () => {
|
||||
// Arrange
|
||||
const convA = "conv-isolated-A";
|
||||
const convB = "conv-isolated-B";
|
||||
|
||||
setConversationState(convA, { draftMessage: "Original draft A" });
|
||||
setConversationState(convB, { draftMessage: "Original draft B" });
|
||||
|
||||
// Act - update only conversation A
|
||||
setConversationState(convA, { draftMessage: "Updated draft A" });
|
||||
|
||||
// Assert - conversation B should be unchanged
|
||||
const stateA = getConversationState(convA);
|
||||
const stateB = getConversationState(convB);
|
||||
|
||||
expect(stateA.draftMessage).toBe("Updated draft A");
|
||||
expect(stateB.draftMessage).toBe("Original draft B");
|
||||
});
|
||||
|
||||
it("clearing one conversation draft does not affect another", () => {
|
||||
// Arrange
|
||||
const convA = "conv-clear-A";
|
||||
const convB = "conv-clear-B";
|
||||
|
||||
setConversationState(convA, { draftMessage: "Draft A" });
|
||||
setConversationState(convB, { draftMessage: "Draft B" });
|
||||
|
||||
// Act - clear draft for conversation A
|
||||
setConversationState(convA, { draftMessage: null });
|
||||
|
||||
// Assert
|
||||
const stateA = getConversationState(convA);
|
||||
const stateB = getConversationState(convB);
|
||||
|
||||
expect(stateA.draftMessage).toBeNull();
|
||||
expect(stateB.draftMessage).toBe("Draft B");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
@@ -9,7 +8,7 @@ import {
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { screen, waitFor, render, cleanup, act } from "@testing-library/react";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
@@ -683,242 +682,8 @@ describe("Conversation WebSocket Handler", () => {
|
||||
|
||||
// 7. Message Sending Tests
|
||||
describe("Message Sending", () => {
|
||||
it("should send user actions through WebSocket when connected", async () => {
|
||||
// Arrange
|
||||
const conversationId = "test-conversation-send";
|
||||
let receivedMessage: unknown = null;
|
||||
|
||||
// Set up MSW to capture sent messages
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Capture messages sent from client
|
||||
client.addEventListener("message", (event) => {
|
||||
receivedMessage = JSON.parse(event.data as string);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create ref to store sendMessage function
|
||||
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
|
||||
? R extends { sendMessage: infer S }
|
||||
? S
|
||||
: null
|
||||
: null = null;
|
||||
|
||||
function TestComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (context?.sendMessage) {
|
||||
sendMessageFn = context.sendMessage;
|
||||
}
|
||||
}, [context?.sendMessage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithWebSocketContext(
|
||||
<TestComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Send a message
|
||||
await waitFor(() => {
|
||||
expect(sendMessageFn).not.toBeNull();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await sendMessageFn!({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello from test" }],
|
||||
});
|
||||
});
|
||||
|
||||
// Assert - message should have been received by mock server
|
||||
await waitFor(() => {
|
||||
expect(receivedMessage).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello from test" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should not throw error when sendMessage is called with WebSocket connected", async () => {
|
||||
// This test verifies that sendMessage doesn't throw an error
|
||||
// when the WebSocket is connected.
|
||||
const conversationId = "test-conversation-no-throw";
|
||||
let sendError: Error | null = null;
|
||||
|
||||
// Set up MSW to connect and receive messages
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ server }) => {
|
||||
server.connect();
|
||||
}),
|
||||
);
|
||||
|
||||
// Create ref to store sendMessage function
|
||||
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
|
||||
? R extends { sendMessage: infer S }
|
||||
? S
|
||||
: null
|
||||
: null = null;
|
||||
|
||||
function TestComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (context?.sendMessage) {
|
||||
sendMessageFn = context.sendMessage;
|
||||
}
|
||||
}, [context?.sendMessage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithWebSocketContext(
|
||||
<TestComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the context to be available
|
||||
await waitFor(() => {
|
||||
expect(sendMessageFn).not.toBeNull();
|
||||
});
|
||||
|
||||
// Try to send a message
|
||||
await act(async () => {
|
||||
try {
|
||||
await sendMessageFn!({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Test message" }],
|
||||
});
|
||||
} catch (error) {
|
||||
sendError = error as Error;
|
||||
}
|
||||
});
|
||||
|
||||
// Assert - should NOT throw an error
|
||||
expect(sendError).toBeNull();
|
||||
});
|
||||
|
||||
it("should send multiple messages through WebSocket in order", async () => {
|
||||
// Arrange
|
||||
const conversationId = "test-conversation-multi";
|
||||
const receivedMessages: unknown[] = [];
|
||||
|
||||
// Set up MSW to capture sent messages
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Capture messages sent from client
|
||||
client.addEventListener("message", (event) => {
|
||||
receivedMessages.push(JSON.parse(event.data as string));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create ref to store sendMessage function
|
||||
let sendMessageFn: typeof useConversationWebSocket extends () => infer R
|
||||
? R extends { sendMessage: infer S }
|
||||
? S
|
||||
: null
|
||||
: null = null;
|
||||
|
||||
function TestComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (context?.sendMessage) {
|
||||
sendMessageFn = context.sendMessage;
|
||||
}
|
||||
}, [context?.sendMessage]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Act
|
||||
renderWithWebSocketContext(
|
||||
<TestComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendMessageFn).not.toBeNull();
|
||||
});
|
||||
|
||||
// Send multiple messages
|
||||
await act(async () => {
|
||||
await sendMessageFn!({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Message 1" }],
|
||||
});
|
||||
await sendMessageFn!({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Message 2" }],
|
||||
});
|
||||
});
|
||||
|
||||
// Assert - both messages should have been received in order
|
||||
await waitFor(() => {
|
||||
expect(receivedMessages.length).toBe(2);
|
||||
});
|
||||
|
||||
expect(receivedMessages[0]).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Message 1" }],
|
||||
});
|
||||
expect(receivedMessages[1]).toEqual({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Message 2" }],
|
||||
});
|
||||
});
|
||||
it.todo("should send user actions through WebSocket when connected");
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
|
||||
// 8. History Loading State Tests
|
||||
|
||||
@@ -1,594 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useDraftPersistence } from "#/hooks/chat/use-draft-persistence";
|
||||
import * as conversationLocalStorage from "#/utils/conversation-local-storage";
|
||||
|
||||
// Mock the entire module
|
||||
vi.mock("#/utils/conversation-local-storage", () => ({
|
||||
useConversationLocalStorageState: vi.fn(),
|
||||
getConversationState: vi.fn(),
|
||||
setConversationState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the getTextContent utility
|
||||
vi.mock("#/components/features/chat/utils/chat-input.utils", () => ({
|
||||
getTextContent: vi.fn((el: HTMLDivElement | null) => el?.textContent || ""),
|
||||
}));
|
||||
|
||||
describe("useDraftPersistence", () => {
|
||||
let mockSetDraftMessage: (message: string | null) => void;
|
||||
|
||||
// Create a mock ref to contentEditable div
|
||||
const createMockChatInputRef = (initialContent = "") => {
|
||||
const div = document.createElement("div");
|
||||
div.setAttribute("contenteditable", "true");
|
||||
div.textContent = initialContent;
|
||||
return { current: div };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
|
||||
mockSetDraftMessage = vi.fn<(message: string | null) => void>();
|
||||
|
||||
// Default mock for useConversationLocalStorageState
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
});
|
||||
|
||||
// Default mock for getConversationState
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("draft restoration on mount", () => {
|
||||
it("restores draft from localStorage when mounting with existing draft", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-restore-1";
|
||||
const savedDraft = "Previously saved draft message";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: savedDraft,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
|
||||
|
||||
// Assert - draft should be restored to the DOM element
|
||||
expect(chatInputRef.current?.textContent).toBe(savedDraft);
|
||||
});
|
||||
|
||||
it("clears input on mount then restores draft if exists", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-restore-2";
|
||||
const existingContent = "Stale content from previous conversation";
|
||||
const savedDraft = "Saved draft";
|
||||
const chatInputRef = createMockChatInputRef(existingContent);
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: savedDraft,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
|
||||
|
||||
// Assert - input cleared then draft restored
|
||||
expect(chatInputRef.current?.textContent).toBe(savedDraft);
|
||||
});
|
||||
|
||||
it("clears input when no draft exists for conversation", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-no-draft";
|
||||
const chatInputRef = createMockChatInputRef("Some stale content");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
// Act
|
||||
renderHook(() => useDraftPersistence(conversationId, chatInputRef));
|
||||
|
||||
// Assert - content should be cleared since there's no draft
|
||||
expect(chatInputRef.current?.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("debounced saving", () => {
|
||||
it("saves draft after debounce period", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-debounce-1";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Act - simulate user typing
|
||||
chatInputRef.current!.textContent = "New draft content";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Assert - should not save immediately
|
||||
expect(mockSetDraftMessage).not.toHaveBeenCalled();
|
||||
|
||||
// Fast forward past debounce period (500ms)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - should save after debounce
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledWith("New draft content");
|
||||
});
|
||||
|
||||
it("cancels pending save when new input arrives before debounce", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-debounce-2";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Act - first input
|
||||
chatInputRef.current!.textContent = "First";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Wait 200ms (less than debounce)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
// Second input before debounce completes
|
||||
chatInputRef.current!.textContent = "First Second";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Complete the second debounce
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - should only save the final value once
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledWith("First Second");
|
||||
});
|
||||
|
||||
it("does not save if content matches existing draft", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-no-change";
|
||||
const existingDraft = "Existing draft";
|
||||
const chatInputRef = createMockChatInputRef(existingDraft);
|
||||
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: existingDraft,
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
});
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: existingDraft,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Act - try to save same content
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - should not save since content is the same
|
||||
expect(mockSetDraftMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearDraft", () => {
|
||||
it("clears the draft from localStorage", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-clear-1";
|
||||
const chatInputRef = createMockChatInputRef("Some content");
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Act
|
||||
act(() => {
|
||||
result.current.clearDraft();
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("cancels any pending debounced save when clearing", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-clear-2";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Start a save
|
||||
chatInputRef.current!.textContent = "Pending draft";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Clear before debounce completes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
result.current.clearDraft();
|
||||
});
|
||||
|
||||
// Complete the original debounce period
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - only the clear should have been called (the pending save should be cancelled)
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetDraftMessage).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation switching", () => {
|
||||
it("clears input when switching to a new conversation without a draft", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef("Draft from conv A");
|
||||
|
||||
// First conversation has a draft
|
||||
vi.mocked(conversationLocalStorage.getConversationState)
|
||||
.mockReturnValueOnce({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Draft from conv A",
|
||||
})
|
||||
.mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Act - switch to conversation B
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Assert - input should be cleared (no draft for conv-B)
|
||||
expect(chatInputRef.current?.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("restores draft when switching to a conversation with an existing draft", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
const draftForConvB = "Saved draft for conversation B";
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState)
|
||||
.mockReturnValueOnce({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
})
|
||||
.mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: draftForConvB,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Act - switch to conversation B
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Assert - draft for conv-B should be restored
|
||||
expect(chatInputRef.current?.textContent).toBe(draftForConvB);
|
||||
});
|
||||
|
||||
it("cancels pending save when switching conversations", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Start typing in conv-A
|
||||
chatInputRef.current!.textContent = "Draft for conv-A";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Switch conversation before debounce completes
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Complete the debounce period
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - the save should NOT have happened because conversation changed
|
||||
expect(mockSetDraftMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("task ID to real conversation ID transition", () => {
|
||||
it("transfers draft from task ID to real conversation ID during transition", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef("Draft typed during init");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "task-abc-123" } },
|
||||
);
|
||||
|
||||
// Simulate user typing during task initialization
|
||||
chatInputRef.current!.textContent = "Draft typed during init";
|
||||
|
||||
// Act - transition to real conversation ID
|
||||
rerender({ conversationId: "conv-real-123" });
|
||||
|
||||
// Assert - draft should be saved to the new real conversation ID
|
||||
expect(conversationLocalStorage.setConversationState).toHaveBeenCalledWith(
|
||||
"conv-real-123",
|
||||
{ draftMessage: "Draft typed during init" },
|
||||
);
|
||||
|
||||
// And the draft should remain visible in the input
|
||||
expect(chatInputRef.current?.textContent).toBe("Draft typed during init");
|
||||
});
|
||||
|
||||
it("does not transfer empty draft during task-to-real transition", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef("");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "task-abc-123" } },
|
||||
);
|
||||
|
||||
// Act - transition to real conversation ID with empty input
|
||||
rerender({ conversationId: "conv-real-123" });
|
||||
|
||||
// Assert - no draft should be saved (input is cleared, checked by hook)
|
||||
// The setConversationState should not be called with draftMessage
|
||||
expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not transfer draft for non-task ID transitions", () => {
|
||||
// Arrange
|
||||
const chatInputRef = createMockChatInputRef("Some draft");
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
{ initialProps: { conversationId: "conv-A" } },
|
||||
);
|
||||
|
||||
// Act - normal conversation switch (not task-to-real)
|
||||
rerender({ conversationId: "conv-B" });
|
||||
|
||||
// Assert - should not use setConversationState directly
|
||||
// (the normal path uses setDraftMessage from the hook)
|
||||
expect(conversationLocalStorage.setConversationState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasDraft and isRestored state", () => {
|
||||
it("returns hasDraft true when draft exists in hook state", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-has-draft";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
vi.mocked(conversationLocalStorage.useConversationLocalStorageState).mockReturnValue({
|
||||
state: {
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Existing draft",
|
||||
},
|
||||
setSelectedTab: vi.fn(),
|
||||
setRightPanelShown: vi.fn(),
|
||||
setUnpinnedTabs: vi.fn(),
|
||||
setConversationMode: vi.fn(),
|
||||
setDraftMessage: mockSetDraftMessage,
|
||||
});
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.current.hasDraft).toBe(true);
|
||||
});
|
||||
|
||||
it("returns hasDraft false when no draft exists", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-no-draft";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.current.hasDraft).toBe(false);
|
||||
});
|
||||
|
||||
it("sets isRestored to true after restoration completes", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-restored";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
vi.mocked(conversationLocalStorage.getConversationState).mockReturnValue({
|
||||
selectedTab: "editor",
|
||||
rightPanelShown: true,
|
||||
unpinnedTabs: [],
|
||||
conversationMode: "code",
|
||||
subConversationTaskId: null,
|
||||
draftMessage: "Draft to restore",
|
||||
});
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.current.isRestored).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup on unmount", () => {
|
||||
it("clears pending timeout on unmount", () => {
|
||||
// Arrange
|
||||
const conversationId = "conv-unmount";
|
||||
const chatInputRef = createMockChatInputRef();
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useDraftPersistence(conversationId, chatInputRef),
|
||||
);
|
||||
|
||||
// Start a save
|
||||
chatInputRef.current!.textContent = "Draft";
|
||||
act(() => {
|
||||
result.current.saveDraft();
|
||||
});
|
||||
|
||||
// Unmount before debounce completes
|
||||
unmount();
|
||||
|
||||
// Complete the debounce period
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Assert - save should not have been called after unmount
|
||||
expect(mockSetDraftMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,6 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: null,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,7 +117,6 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
@@ -157,7 +155,6 @@ describe("useHandlePlanClick", () => {
|
||||
unpinnedTabs: [],
|
||||
subConversationTaskId: storedTaskId,
|
||||
conversationMode: "code",
|
||||
draftMessage: null,
|
||||
});
|
||||
|
||||
renderHook(() => useHandlePlanClick());
|
||||
|
||||
@@ -1,577 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useSandboxRecovery } from "#/hooks/use-sandbox-recovery";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import * as customToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: [{ provider: "github", token: "test-token" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers");
|
||||
vi.mock("#/hooks/mutation/use-unified-start-conversation");
|
||||
|
||||
describe("useSandboxRecovery", () => {
|
||||
let mockMutate: ReturnType<typeof vi.fn>;
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockMutate = vi.fn();
|
||||
|
||||
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: true,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: vi.fn(),
|
||||
status: "idle",
|
||||
variables: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
|
||||
|
||||
// Reset document.visibilityState
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial load recovery", () => {
|
||||
it("should call resumeSandbox on initial load when conversation is STOPPED", () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith(
|
||||
{
|
||||
conversationId: "conv-123",
|
||||
providers: [{ provider: "github", token: "test-token" }],
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox on initial load when conversation is RUNNING", () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when conversationId is undefined", () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: undefined,
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when conversationStatus is undefined", () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: undefined,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should only call resumeSandbox once per conversation on initial load", () => {
|
||||
const { rerender } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rerender with same props - should not trigger again
|
||||
rerender();
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call resumeSandbox for a new conversation after navigating", async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ conversationId }) =>
|
||||
useSandboxRecovery({
|
||||
conversationId,
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
initialProps: { conversationId: "conv-123" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: "conv-123" }),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
// Navigate to a different conversation
|
||||
rerender({ conversationId: "conv-456" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(mockMutate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ conversationId: "conv-456" }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tab focus recovery", () => {
|
||||
it("should call resumeSandbox when tab becomes visible and refetch returns STOPPED", async () => {
|
||||
// Start with tab hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const mockRefetch = vi.fn().mockResolvedValue({
|
||||
data: { status: "STOPPED" },
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING", // Cached status is RUNNING
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// No initial recovery for RUNNING
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch should be called to get fresh status
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
// Recovery should trigger because fresh status is STOPPED
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when tab becomes visible and refetch returns RUNNING", async () => {
|
||||
const mockRefetch = vi.fn().mockResolvedValue({
|
||||
data: { status: "RUNNING" },
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// No initial recovery for RUNNING
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
|
||||
// Simulate tab becoming visible
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch was called but status is still RUNNING
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when tab becomes visible but refetchConversation is not provided", async () => {
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
// No refetchConversation provided
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Initial load triggers recovery
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
mockMutate.mockClear();
|
||||
|
||||
// Simulate tab becoming visible
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// No recovery on tab focus without refetchConversation
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when tab becomes hidden", async () => {
|
||||
const mockRefetch = vi.fn().mockResolvedValue({
|
||||
data: { status: "STOPPED" },
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Initial load triggers recovery
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
mockMutate.mockClear();
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch should NOT be called when tab is hidden
|
||||
expect(mockRefetch).not.toHaveBeenCalled();
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should clean up visibility event listener on unmount", () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when tab becomes visible while isPending is true", async () => {
|
||||
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: vi.fn(),
|
||||
status: "pending",
|
||||
variables: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
|
||||
|
||||
const mockRefetch = vi.fn().mockResolvedValue({
|
||||
data: { status: "STOPPED" },
|
||||
});
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch will be called when isPending is true
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
// resumeSandbox should NOT be called
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle refetch errors gracefully without crashing", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const mockRefetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
refetchConversation: mockRefetch,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
// Refetch was called
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
// Error was logged
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Failed to refetch conversation on visibility change:",
|
||||
expect.any(Error),
|
||||
);
|
||||
// No recovery attempt was made (due to error)
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("recovery callbacks", () => {
|
||||
it("should return isResuming=false when no recovery is in progress", () => {
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(result.current.isResuming).toBe(false);
|
||||
});
|
||||
|
||||
it("should return isResuming=true when mutation is pending", () => {
|
||||
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: vi.fn(),
|
||||
status: "pending",
|
||||
variables: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
expect(result.current.isResuming).toBe(true);
|
||||
});
|
||||
|
||||
it("should call onSuccess callback when recovery succeeds", () => {
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
onSuccess,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Get the onSuccess callback passed to mutate
|
||||
const mutateCall = mockMutate.mock.calls[0];
|
||||
const options = mutateCall[1];
|
||||
|
||||
// Simulate successful mutation
|
||||
act(() => {
|
||||
options.onSuccess();
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onError callback and display toast when recovery fails", () => {
|
||||
const onError = vi.fn();
|
||||
const testError = new Error("Resume failed");
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
onError,
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Get the onError callback passed to mutate
|
||||
const mutateCall = mockMutate.mock.calls[0];
|
||||
const options = mutateCall[1];
|
||||
|
||||
// Simulate failed mutation
|
||||
act(() => {
|
||||
options.onError(testError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith(testError);
|
||||
expect(vi.mocked(customToastHandlers.displayErrorToast)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT call resumeSandbox when isPending is true", () => {
|
||||
vi.mocked(useUnifiedResumeConversationSandbox).mockReturnValue({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: true,
|
||||
isSuccess: false,
|
||||
isError: false,
|
||||
isIdle: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: vi.fn(),
|
||||
status: "pending",
|
||||
variables: undefined,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
submittedAt: 0,
|
||||
context: undefined,
|
||||
} as unknown as ReturnType<typeof useUnifiedResumeConversationSandbox>);
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "STOPPED",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// Should not call mutate because isPending is true
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket disconnect (negative test)", () => {
|
||||
it("should NOT have any mechanism to auto-resume on WebSocket disconnect", () => {
|
||||
// This test documents the intended behavior: the hook does NOT
|
||||
// listen for WebSocket disconnects. Recovery only happens on:
|
||||
// 1. Initial page load (STOPPED status)
|
||||
// 2. Tab focus (visibilitychange event)
|
||||
//
|
||||
// There is intentionally NO onDisconnect handler or WebSocket listener.
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useSandboxRecovery({
|
||||
conversationId: "conv-123",
|
||||
conversationStatus: "RUNNING",
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
);
|
||||
|
||||
// The hook should only expose isResuming - no disconnect-related functionality
|
||||
expect(result.current).toEqual({
|
||||
isResuming: expect.any(Boolean),
|
||||
});
|
||||
|
||||
// No calls should have been made for RUNNING status
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useVisibilityChange } from "#/hooks/use-visibility-change";
|
||||
|
||||
describe("useVisibilityChange", () => {
|
||||
beforeEach(() => {
|
||||
// Reset document.visibilityState to visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("initial state", () => {
|
||||
it("should return isVisible=true when document is visible", () => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("should return isVisible=false when document is hidden", () => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibility change events", () => {
|
||||
it("should update isVisible when visibility changes to hidden", () => {
|
||||
const { result } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("should update isVisible when visibility changes to visible", () => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(result.current.isVisible).toBe(false);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(result.current.isVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callbacks", () => {
|
||||
it("should call onVisibilityChange with the new state", () => {
|
||||
const onVisibilityChange = vi.fn();
|
||||
|
||||
renderHook(() => useVisibilityChange({ onVisibilityChange }));
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisibilityChange).toHaveBeenCalledWith("hidden");
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisibilityChange).toHaveBeenCalledWith("visible");
|
||||
});
|
||||
|
||||
it("should call onVisible only when tab becomes visible", () => {
|
||||
const onVisible = vi.fn();
|
||||
const onHidden = vi.fn();
|
||||
|
||||
renderHook(() => useVisibilityChange({ onVisible, onHidden }));
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
expect(onHidden).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).toHaveBeenCalledTimes(1);
|
||||
expect(onHidden).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onHidden only when tab becomes hidden", () => {
|
||||
const onHidden = vi.fn();
|
||||
|
||||
renderHook(() => useVisibilityChange({ onHidden }));
|
||||
|
||||
// Simulate tab becoming hidden
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "hidden",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onHidden).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Simulate tab becoming visible (should not call onHidden)
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onHidden).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enabled option", () => {
|
||||
it("should not listen for events when enabled=false", () => {
|
||||
const onVisible = vi.fn();
|
||||
|
||||
renderHook(() => useVisibilityChange({ onVisible, enabled: false }));
|
||||
|
||||
// Simulate tab becoming visible
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
value: "visible",
|
||||
writable: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should start listening when enabled changes from false to true", () => {
|
||||
const onVisible = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) => useVisibilityChange({ onVisible, enabled }),
|
||||
{ initialProps: { enabled: false } },
|
||||
);
|
||||
|
||||
// Simulate event while disabled
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).not.toHaveBeenCalled();
|
||||
|
||||
// Enable the hook
|
||||
rerender({ enabled: true });
|
||||
|
||||
// Now events should be captured
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should remove event listener on unmount", () => {
|
||||
const addEventListenerSpy = vi.spyOn(document, "addEventListener");
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { unmount } = renderHook(() => useVisibilityChange());
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove event listener when enabled changes to false", () => {
|
||||
const removeEventListenerSpy = vi.spyOn(document, "removeEventListener");
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) => useVisibilityChange({ enabled }),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
|
||||
rerender({ enabled: false });
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"visibilitychange",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("callback stability", () => {
|
||||
it("should handle callback updates without missing events", () => {
|
||||
const onVisible1 = vi.fn();
|
||||
const onVisible2 = vi.fn();
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ onVisible }) => useVisibilityChange({ onVisible }),
|
||||
{ initialProps: { onVisible: onVisible1 } },
|
||||
);
|
||||
|
||||
// Update callback
|
||||
rerender({ onVisible: onVisible2 });
|
||||
|
||||
// Simulate visibility change
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
expect(onVisible1).not.toHaveBeenCalled();
|
||||
expect(onVisible2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,659 +0,0 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import DeviceVerify from "#/routes/device-verify";
|
||||
|
||||
const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
|
||||
useIsAuthedMock: vi.fn(() => ({
|
||||
data: false as boolean | undefined,
|
||||
isLoading: false,
|
||||
})),
|
||||
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => useIsAuthedMock(),
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js/react", () => ({
|
||||
usePostHog: () => ({
|
||||
capture: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/feature-flags", () => ({
|
||||
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: DeviceVerify,
|
||||
path: "/device-verify",
|
||||
},
|
||||
]);
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe("DeviceVerify", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("close", vi.fn());
|
||||
// Mock fetch for API calls
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Enable feature flag by default
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Loading State", () => {
|
||||
it("should show loading spinner while checking authentication", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Not Authenticated State", () => {
|
||||
it("should show authentication required message when not authenticated", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$AUTH_REQUIRED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$SIGN_IN_PROMPT")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authenticated without User Code", () => {
|
||||
it("should show manual code entry form when authenticated but no code in URL", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ENTER_CODE_PROMPT")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CONTINUE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should submit manually entered code", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(<RouterStub initialEntries={["/device-verify"]} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("DEVICE$CODE_INPUT_LABEL")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText("DEVICE$CODE_INPUT_LABEL");
|
||||
await user.type(input, "TESTCODE");
|
||||
|
||||
const submitButton = screen.getByRole("button", {
|
||||
name: "DEVICE$CONTINUE",
|
||||
});
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/oauth/device/verify-authenticated",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "user_code=TESTCODE",
|
||||
credentials: "include",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authenticated with User Code", () => {
|
||||
it("should show authorization confirmation when authenticated with code in URL", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_REQUEST"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$CODE_LABEL")).toBeInTheDocument();
|
||||
expect(screen.getByText("ABC-123")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$SECURITY_NOTICE")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$SECURITY_WARNING")).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$CONFIRM_PROMPT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show cancel and authorize buttons", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CANCEL" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should include the EnterpriseBanner component when feature flag is enabled", async () => {
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("ENTERPRISE$TITLE")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => {
|
||||
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("DEVICE$AUTHORIZATION_REQUEST"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Banner should not be rendered
|
||||
expect(screen.queryByText("ENTERPRISE$TITLE")).not.toBeInTheDocument();
|
||||
|
||||
// Container should use max-w-md (centered layout) instead of max-w-4xl
|
||||
const container = document.querySelector(".max-w-md");
|
||||
expect(container).toBeInTheDocument();
|
||||
expect(document.querySelector(".max-w-4xl")).not.toBeInTheDocument();
|
||||
|
||||
// Authorization card should have mx-auto for centering
|
||||
const authCard = container?.querySelector(".mx-auto");
|
||||
expect(authCard).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call window.close when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$CANCEL" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "DEVICE$CANCEL" });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(window.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should submit device verification when authorize button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/oauth/device/verify-authenticated",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "user_code=ABC-123",
|
||||
credentials: "include",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Processing State", () => {
|
||||
it("should show processing spinner during verification", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Make fetch hang to show processing state
|
||||
const mockFetch = vi.fn(() => new Promise(() => {}));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const spinner = document.querySelector(".animate-spin");
|
||||
expect(spinner).toBeInTheDocument();
|
||||
expect(screen.getByText("DEVICE$PROCESSING")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Success State", () => {
|
||||
it("should show success message after successful verification", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$SUCCESS_MESSAGE")).toBeInTheDocument();
|
||||
// Should show success icon (checkmark)
|
||||
const successIcon = document.querySelector(".text-green-600");
|
||||
expect(successIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show try again button on success", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({}),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$SUCCESS_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error State", () => {
|
||||
it("should show error message when verification fails with non-ok response", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ error: "invalid_code" }),
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ERROR_FAILED")).toBeInTheDocument();
|
||||
// Should show error icon (X)
|
||||
const errorIcon = document.querySelector(".text-red-600");
|
||||
expect(errorIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error message when fetch throws an exception", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() => Promise.reject(new Error("Network error")));
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=ABC-123"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("DEVICE$ERROR_TITLE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("DEVICE$ERROR_OCCURRED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show try again button on error", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reload page when try again button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
useIsAuthedMock.mockReturnValue({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const mockFetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const reloadMock = vi.fn();
|
||||
vi.stubGlobal("location", { reload: reloadMock });
|
||||
|
||||
render(
|
||||
<RouterStub initialEntries={["/device-verify?user_code=INVALID"]} />,
|
||||
{
|
||||
wrapper: createWrapper(),
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$AUTHORIZE" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const authorizeButton = screen.getByRole("button", {
|
||||
name: "DEVICE$AUTHORIZE",
|
||||
});
|
||||
await user.click(authorizeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "DEVICE$TRY_AGAIN" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const tryAgainButton = screen.getByRole("button", {
|
||||
name: "DEVICE$TRY_AGAIN",
|
||||
});
|
||||
await user.click(tryAgainButton);
|
||||
|
||||
expect(reloadMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,29 +2,27 @@ import { describe, it, expect } from "vitest";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
|
||||
describe("getGitPath", () => {
|
||||
const conversationId = "abc123";
|
||||
|
||||
it("should return /workspace/project/{conversationId} when no repository is selected", () => {
|
||||
expect(getGitPath(conversationId, null)).toBe(`/workspace/project/${conversationId}`);
|
||||
expect(getGitPath(conversationId, undefined)).toBe(`/workspace/project/${conversationId}`);
|
||||
it("should return /workspace/project when no repository is selected", () => {
|
||||
expect(getGitPath(null)).toBe("/workspace/project");
|
||||
expect(getGitPath(undefined)).toBe("/workspace/project");
|
||||
});
|
||||
|
||||
it("should handle standard owner/repo format (GitHub)", () => {
|
||||
expect(getGitPath(conversationId, "OpenHands/OpenHands")).toBe(`/workspace/project/${conversationId}/OpenHands`);
|
||||
expect(getGitPath(conversationId, "facebook/react")).toBe(`/workspace/project/${conversationId}/react`);
|
||||
expect(getGitPath("OpenHands/OpenHands")).toBe("/workspace/project/OpenHands");
|
||||
expect(getGitPath("facebook/react")).toBe("/workspace/project/react");
|
||||
});
|
||||
|
||||
it("should handle nested group paths (GitLab)", () => {
|
||||
expect(getGitPath(conversationId, "modernhealth/frontend-guild/pan")).toBe(`/workspace/project/${conversationId}/pan`);
|
||||
expect(getGitPath(conversationId, "group/subgroup/repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
expect(getGitPath(conversationId, "a/b/c/d/repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
expect(getGitPath("modernhealth/frontend-guild/pan")).toBe("/workspace/project/pan");
|
||||
expect(getGitPath("group/subgroup/repo")).toBe("/workspace/project/repo");
|
||||
expect(getGitPath("a/b/c/d/repo")).toBe("/workspace/project/repo");
|
||||
});
|
||||
|
||||
it("should handle single segment paths", () => {
|
||||
expect(getGitPath(conversationId, "repo")).toBe(`/workspace/project/${conversationId}/repo`);
|
||||
expect(getGitPath("repo")).toBe("/workspace/project/repo");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(getGitPath(conversationId, "")).toBe(`/workspace/project/${conversationId}`);
|
||||
expect(getGitPath("")).toBe("/workspace/project");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { getRandomTip, TIPS, Tip } from "#/utils/tips";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
describe("Tips System", () => {
|
||||
describe("TIPS array", () => {
|
||||
it("should contain tips with valid keys", () => {
|
||||
expect(TIPS.length).toBeGreaterThan(0);
|
||||
TIPS.forEach((tip) => {
|
||||
expect(tip.key).toBeDefined();
|
||||
expect(typeof tip.key).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have GitHub hook tip with github provider", () => {
|
||||
const githubTip = TIPS.find(
|
||||
(tip) => tip.key === I18nKey.TIPS$GITHUB_HOOK,
|
||||
);
|
||||
expect(githubTip).toBeDefined();
|
||||
expect(githubTip?.providers).toEqual(["github"]);
|
||||
});
|
||||
|
||||
it("should have GitLab hook tip with gitlab provider", () => {
|
||||
const gitlabTip = TIPS.find(
|
||||
(tip) => tip.key === I18nKey.TIPS$GITLAB_HOOK,
|
||||
);
|
||||
expect(gitlabTip).toBeDefined();
|
||||
expect(gitlabTip?.providers).toEqual(["gitlab"]);
|
||||
});
|
||||
|
||||
it("should have generic tips without providers", () => {
|
||||
const genericTips = TIPS.filter((tip) => !tip.providers);
|
||||
expect(genericTips.length).toBeGreaterThan(0);
|
||||
// Verify some specific generic tips exist
|
||||
expect(genericTips.some((t) => t.key === I18nKey.TIPS$BLOG_SIGNUP)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
genericTips.some((t) => t.key === I18nKey.TIPS$CUSTOMIZE_MICROAGENT),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRandomTip", () => {
|
||||
let mathRandomSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mathRandomSpy = vi.spyOn(Math, "random");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mathRandomSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should return a tip when no providers are specified", () => {
|
||||
mathRandomSpy.mockReturnValue(0);
|
||||
const tip = getRandomTip();
|
||||
expect(tip).toBeDefined();
|
||||
expect(tip.key).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return only generic tips when userProviders is undefined", () => {
|
||||
// Call multiple times to verify no provider-specific tips are returned
|
||||
for (let i = 0; i < 10; i++) {
|
||||
mathRandomSpy.mockReturnValue(i / 10);
|
||||
const tip = getRandomTip(undefined);
|
||||
expect(
|
||||
tip.providers === undefined,
|
||||
`Expected generic tip but got tip with providers: ${tip.providers}`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return only generic tips when userProviders is empty array", () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
mathRandomSpy.mockReturnValue(i / 10);
|
||||
const tip = getRandomTip([]);
|
||||
expect(tip.providers).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should include GitHub-specific tips for GitHub users", () => {
|
||||
const userProviders: Provider[] = ["github"];
|
||||
|
||||
// Get all tips that would be shown to GitHub users
|
||||
const eligibleTips = TIPS.filter(
|
||||
(tip) =>
|
||||
!tip.providers ||
|
||||
tip.providers.some((p) => userProviders.includes(p)),
|
||||
);
|
||||
|
||||
// Verify GitHub tip is in eligible tips
|
||||
expect(
|
||||
eligibleTips.some((t) => t.key === I18nKey.TIPS$GITHUB_HOOK),
|
||||
).toBe(true);
|
||||
|
||||
// Verify GitLab tip is NOT in eligible tips
|
||||
expect(
|
||||
eligibleTips.some((t) => t.key === I18nKey.TIPS$GITLAB_HOOK),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should include GitLab-specific tips for GitLab users", () => {
|
||||
const userProviders: Provider[] = ["gitlab"];
|
||||
|
||||
// Get all tips that would be shown to GitLab users
|
||||
const eligibleTips = TIPS.filter(
|
||||
(tip) =>
|
||||
!tip.providers ||
|
||||
tip.providers.some((p) => userProviders.includes(p)),
|
||||
);
|
||||
|
||||
// Verify GitLab tip is in eligible tips
|
||||
expect(
|
||||
eligibleTips.some((t) => t.key === I18nKey.TIPS$GITLAB_HOOK),
|
||||
).toBe(true);
|
||||
|
||||
// Verify GitHub tip is NOT in eligible tips
|
||||
expect(
|
||||
eligibleTips.some((t) => t.key === I18nKey.TIPS$GITHUB_HOOK),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should include both provider-specific tips for users with multiple providers", () => {
|
||||
const userProviders: Provider[] = ["github", "gitlab"];
|
||||
|
||||
// Get all tips that would be shown to users with both providers
|
||||
const eligibleTips = TIPS.filter(
|
||||
(tip) =>
|
||||
!tip.providers ||
|
||||
tip.providers.some((p) => userProviders.includes(p)),
|
||||
);
|
||||
|
||||
// Verify both tips are in eligible tips
|
||||
expect(
|
||||
eligibleTips.some((t) => t.key === I18nKey.TIPS$GITHUB_HOOK),
|
||||
).toBe(true);
|
||||
expect(
|
||||
eligibleTips.some((t) => t.key === I18nKey.TIPS$GITLAB_HOOK),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should always include generic tips regardless of provider", () => {
|
||||
const testCases: (Provider[] | undefined)[] = [
|
||||
undefined,
|
||||
[],
|
||||
["github"],
|
||||
["gitlab"],
|
||||
["bitbucket"],
|
||||
["github", "gitlab"],
|
||||
];
|
||||
|
||||
testCases.forEach((providers) => {
|
||||
// Check that we can get generic tips for any provider configuration
|
||||
const genericTips = TIPS.filter((tip) => !tip.providers);
|
||||
expect(genericTips.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not return GitHub tip for users without GitHub provider", () => {
|
||||
const userProviders: Provider[] = ["gitlab", "bitbucket"];
|
||||
|
||||
// Get all tips that would be shown
|
||||
const eligibleTips = TIPS.filter(
|
||||
(tip) =>
|
||||
!tip.providers ||
|
||||
tip.providers.some((p) => userProviders.includes(p)),
|
||||
);
|
||||
|
||||
// GitHub tip should not be included
|
||||
expect(
|
||||
eligibleTips.some((t) => t.key === I18nKey.TIPS$GITHUB_HOOK),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return valid tip even when Math.random returns edge values", () => {
|
||||
const userProviders: Provider[] = ["github"];
|
||||
|
||||
// Test with 0
|
||||
mathRandomSpy.mockReturnValue(0);
|
||||
let tip = getRandomTip(userProviders);
|
||||
expect(tip).toBeDefined();
|
||||
|
||||
// Test with value just under 1
|
||||
mathRandomSpy.mockReturnValue(0.9999);
|
||||
tip = getRandomTip(userProviders);
|
||||
expect(tip).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle all supported provider types", () => {
|
||||
const allProviders: Provider[] = [
|
||||
"github",
|
||||
"gitlab",
|
||||
"bitbucket",
|
||||
"bitbucket_data_center",
|
||||
"azure_devops",
|
||||
"forgejo",
|
||||
"enterprise_sso",
|
||||
];
|
||||
|
||||
mathRandomSpy.mockReturnValue(0.5);
|
||||
const tip = getRandomTip(allProviders);
|
||||
expect(tip).toBeDefined();
|
||||
expect(tip.key).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,7 @@ import type {
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
V1AppConversationPage,
|
||||
GetSkillsResponse,
|
||||
GetHooksResponse,
|
||||
V1RuntimeConversationInfo,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
@@ -255,7 +253,7 @@ class V1ConversationService {
|
||||
|
||||
/**
|
||||
* Upload a single file to the V1 conversation workspace
|
||||
* V1 API endpoint: POST /api/file/upload?path={path}
|
||||
* 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)
|
||||
@@ -271,11 +269,10 @@ class V1ConversationService {
|
||||
): Promise<void> {
|
||||
// Default to /workspace/{filename} if no path provided (must be absolute)
|
||||
const uploadPath = path || `/workspace/${file.name}`;
|
||||
const params = new URLSearchParams();
|
||||
params.append("path", uploadPath);
|
||||
const encodedPath = encodeURIComponent(uploadPath);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/file/upload?${params.toString()}`,
|
||||
`/api/file/upload/${encodedPath}`,
|
||||
);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
@@ -401,18 +398,6 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hooks associated with a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns The available hooks associated with the conversation
|
||||
*/
|
||||
static async getHooks(conversationId: string): Promise<GetHooksResponse> {
|
||||
const { data } = await openHands.get<GetHooksResponse>(
|
||||
`/api/v1/app-conversations/${conversationId}/hooks`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation info directly from the runtime for a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
@@ -438,28 +423,6 @@ class V1ConversationService {
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for V1 conversations by sandbox ID
|
||||
*
|
||||
* @param sandboxId The sandbox ID to filter by
|
||||
* @param limit Maximum number of results (default: 100)
|
||||
* @returns Array of conversations in the specified sandbox
|
||||
*/
|
||||
static async searchConversationsBySandboxId(
|
||||
sandboxId: string,
|
||||
limit: number = 100,
|
||||
): Promise<V1AppConversation[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("sandbox_id__eq", sandboxId);
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
const { data } = await openHands.get<V1AppConversationPage>(
|
||||
`/api/v1/app-conversations/search?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data.items;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
|
||||
@@ -119,11 +119,6 @@ export interface V1AppConversation {
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface V1AppConversationPage {
|
||||
items: V1AppConversation[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
type: "repo" | "knowledge" | "agentskills";
|
||||
@@ -135,27 +130,6 @@ export interface GetSkillsResponse {
|
||||
skills: Skill[];
|
||||
}
|
||||
|
||||
export interface HookDefinition {
|
||||
type: string; // 'command' or 'prompt'
|
||||
command: string;
|
||||
timeout: number;
|
||||
async?: boolean;
|
||||
}
|
||||
|
||||
export interface HookMatcher {
|
||||
matcher: string; // Pattern: '*', exact match, or regex
|
||||
hooks: HookDefinition[];
|
||||
}
|
||||
|
||||
export interface HookEvent {
|
||||
event_type: string; // e.g., 'stop', 'pre_tool_use', 'post_tool_use'
|
||||
matchers: HookMatcher[];
|
||||
}
|
||||
|
||||
export interface GetHooksResponse {
|
||||
hooks: HookEvent[];
|
||||
}
|
||||
|
||||
// Runtime conversation types (from agent server)
|
||||
export interface V1RuntimeConversationStats {
|
||||
usage_to_metrics: Record<string, V1RuntimeMetrics>;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* Pending Message Service
|
||||
*
|
||||
* This service handles server-side message queuing for V1 conversations.
|
||||
* Messages can be queued when the WebSocket is not connected and will be
|
||||
* delivered automatically when the conversation becomes ready.
|
||||
*/
|
||||
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import type {
|
||||
PendingMessageResponse,
|
||||
QueuePendingMessageRequest,
|
||||
} from "./pending-message-service.types";
|
||||
|
||||
class PendingMessageService {
|
||||
/**
|
||||
* Queue a message for delivery when conversation becomes ready.
|
||||
*
|
||||
* This endpoint allows users to submit messages even when the conversation's
|
||||
* WebSocket connection is not yet established. Messages are stored server-side
|
||||
* and delivered automatically when the conversation transitions to READY status.
|
||||
*
|
||||
* @param conversationId The conversation ID (can be task ID before conversation is ready)
|
||||
* @param message The message to queue
|
||||
* @returns PendingMessageResponse with the message ID and queue position
|
||||
* @throws Error if too many pending messages (limit: 10 per conversation)
|
||||
*/
|
||||
static async queueMessage(
|
||||
conversationId: string,
|
||||
message: QueuePendingMessageRequest,
|
||||
): Promise<PendingMessageResponse> {
|
||||
const { data } = await openHands.post<PendingMessageResponse>(
|
||||
`/api/v1/conversations/${conversationId}/pending-messages`,
|
||||
message,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default PendingMessageService;
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Types for the pending message service
|
||||
*/
|
||||
|
||||
import type { V1MessageContent } from "../conversation-service/v1-conversation-service.types";
|
||||
|
||||
/**
|
||||
* Response when queueing a pending message
|
||||
*/
|
||||
export interface PendingMessageResponse {
|
||||
id: string;
|
||||
queued: boolean;
|
||||
position: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request to queue a pending message
|
||||
*/
|
||||
export interface QueuePendingMessageRequest {
|
||||
role?: "user";
|
||||
content: V1MessageContent[];
|
||||
}
|
||||
@@ -190,14 +190,8 @@ export function ChatInterface() {
|
||||
const prompt =
|
||||
uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content;
|
||||
|
||||
const result = await send(
|
||||
createChatMessage(prompt, imageUrls, uploadedFiles, timestamp),
|
||||
);
|
||||
// Only show optimistic UI if message was sent immediately via WebSocket
|
||||
// If queued for later delivery, the message will appear when actually delivered
|
||||
if (!result.queued) {
|
||||
setOptimisticUserMessage(content);
|
||||
}
|
||||
send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp));
|
||||
setOptimisticUserMessage(content);
|
||||
setMessageToSend("");
|
||||
};
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ export function CustomChatInput({
|
||||
messageToSend,
|
||||
checkIsContentEmpty,
|
||||
clearEmptyContentHandler,
|
||||
saveDraft,
|
||||
} = useChatInputLogic();
|
||||
|
||||
const {
|
||||
@@ -159,7 +158,6 @@ export function CustomChatInput({
|
||||
onInput={() => {
|
||||
handleInput();
|
||||
updateSlashMenu();
|
||||
saveDraft();
|
||||
}}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message";
|
||||
@@ -8,4 +8,3 @@ export { ObservationPairEventMessage } from "./observation-pair-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { MicroagentStatusWrapper } from "./microagent-status-wrapper";
|
||||
export { LikertScaleWrapper } from "./likert-scale-wrapper";
|
||||
export { HookExecutionEventMessage } from "./hook-execution-event-message";
|
||||
|
||||
@@ -142,9 +142,8 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
handleSubmit(suggestion);
|
||||
};
|
||||
|
||||
// Allow users to submit messages during LOADING state - they will be
|
||||
// queued server-side and delivered when the conversation becomes ready
|
||||
const isDisabled =
|
||||
curAgentState === AgentState.LOADING ||
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION ||
|
||||
isTaskPolling(subConversationTaskStatus);
|
||||
|
||||
|
||||
@@ -27,19 +27,15 @@ const contextMenuListItemClassName = cn(
|
||||
interface ToolsContextMenuProps {
|
||||
onClose: () => void;
|
||||
onShowSkills: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowHooks: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowAgentTools: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
shouldShowAgentTools?: boolean;
|
||||
shouldShowHooks?: boolean;
|
||||
}
|
||||
|
||||
export function ToolsContextMenu({
|
||||
onClose,
|
||||
onShowSkills,
|
||||
onShowHooks,
|
||||
onShowAgentTools,
|
||||
shouldShowAgentTools = true,
|
||||
shouldShowHooks = false,
|
||||
}: ToolsContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
@@ -145,21 +141,6 @@ export function ToolsContextMenu({
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
|
||||
{/* Show Hooks - Only show for V1 conversations */}
|
||||
{shouldShowHooks && (
|
||||
<ContextMenuListItem
|
||||
testId="show-hooks-button"
|
||||
onClick={onShowHooks}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ToolsContextMenuIconText
|
||||
icon={<ToolsIcon width={16} height={16} />}
|
||||
text={t(I18nKey.CONVERSATION$SHOW_HOOKS)}
|
||||
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{/* Show Agent Tools and Metadata - Only show if system message is available */}
|
||||
{shouldShowAgentTools && (
|
||||
<ContextMenuListItem
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-co
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
|
||||
import { SkillsModal } from "../conversation-panel/skills-modal";
|
||||
import { HooksModal } from "../conversation-panel/hooks-modal";
|
||||
|
||||
export function Tools() {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,16 +18,12 @@ export function Tools() {
|
||||
const {
|
||||
handleShowAgentTools,
|
||||
handleShowSkills,
|
||||
handleShowHooks,
|
||||
systemModalVisible,
|
||||
setSystemModalVisible,
|
||||
skillsModalVisible,
|
||||
setSkillsModalVisible,
|
||||
hooksModalVisible,
|
||||
setHooksModalVisible,
|
||||
systemMessage,
|
||||
shouldShowAgentTools,
|
||||
shouldShowHooks,
|
||||
} = useConversationNameContextMenu({
|
||||
conversationId,
|
||||
conversationStatus: conversation?.status,
|
||||
@@ -57,10 +52,8 @@ export function Tools() {
|
||||
<ToolsContextMenu
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
onShowSkills={handleShowSkills}
|
||||
onShowHooks={handleShowHooks}
|
||||
onShowAgentTools={handleShowAgentTools}
|
||||
shouldShowAgentTools={shouldShowAgentTools}
|
||||
shouldShowHooks={shouldShowHooks}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -75,11 +68,6 @@ export function Tools() {
|
||||
{skillsModalVisible && (
|
||||
<SkillsModal onClose={() => setSkillsModalVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Hooks Modal */}
|
||||
{hooksModalVisible && (
|
||||
<HooksModal onClose={() => setHooksModalVisible(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,71 +7,17 @@ import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConversationsInSandbox } from "#/hooks/query/use-conversations-in-sandbox";
|
||||
|
||||
interface ConfirmStopModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
sandboxId: string | null;
|
||||
}
|
||||
|
||||
function ConversationsList({
|
||||
conversations,
|
||||
isLoading,
|
||||
isError,
|
||||
t,
|
||||
}: {
|
||||
conversations: { id: string; title: string | null }[] | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="text-sm text-content-secondary"
|
||||
data-testid="conversations-loading"
|
||||
>
|
||||
{t(I18nKey.HOME$LOADING)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-sm text-danger" data-testid="conversations-error">
|
||||
{t(I18nKey.COMMON$ERROR)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (conversations && conversations.length > 0) {
|
||||
return (
|
||||
<ul
|
||||
className="list-disc list-inside text-sm text-content-secondary"
|
||||
data-testid="conversations-list"
|
||||
>
|
||||
{conversations.map((conv) => (
|
||||
<li key={conv.id}>{conv.title || conv.id}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ConfirmStopModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
sandboxId,
|
||||
}: ConfirmStopModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: conversations,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useConversationsInSandbox(sandboxId);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
@@ -83,12 +29,6 @@ export function ConfirmStopModal({
|
||||
<BaseModalDescription
|
||||
description={t(I18nKey.CONVERSATION$CLOSE_CONVERSATION_WARNING)}
|
||||
/>
|
||||
<ConversationsList
|
||||
conversations={conversations}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col gap-2 w-full"
|
||||
|
||||
@@ -44,9 +44,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
React.useState<string | null>(null);
|
||||
const [selectedConversationVersion, setSelectedConversationVersion] =
|
||||
React.useState<"V0" | "V1" | undefined>(undefined);
|
||||
const [selectedSandboxId, setSelectedSandboxId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [openContextMenuId, setOpenContextMenuId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -88,12 +85,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const handleStopConversation = (
|
||||
conversationId: string,
|
||||
version?: "V0" | "V1",
|
||||
sandboxId?: string | null,
|
||||
) => {
|
||||
setConfirmStopModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
setSelectedConversationVersion(version);
|
||||
setSelectedSandboxId(sandboxId ?? null);
|
||||
};
|
||||
|
||||
const handleConversationTitleChange = async (
|
||||
@@ -190,7 +185,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
handleStopConversation(
|
||||
project.conversation_id,
|
||||
project.conversation_version,
|
||||
project.sandbox_id,
|
||||
)
|
||||
}
|
||||
onChangeTitle={(title) =>
|
||||
@@ -244,7 +238,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
setConfirmStopModalVisible(false);
|
||||
}}
|
||||
onCancel={() => setConfirmStopModalVisible(false)}
|
||||
sandboxId={selectedSandboxId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { HookEvent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { HookMatcherContent } from "./hook-matcher-content";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface HookEventItemProps {
|
||||
hookEvent: HookEvent;
|
||||
isExpanded: boolean;
|
||||
onToggle: (eventType: string) => void;
|
||||
}
|
||||
|
||||
const EVENT_TYPE_I18N_KEYS: Record<string, I18nKey> = {
|
||||
pre_tool_use: I18nKey.HOOKS_MODAL$EVENT_PRE_TOOL_USE,
|
||||
post_tool_use: I18nKey.HOOKS_MODAL$EVENT_POST_TOOL_USE,
|
||||
user_prompt_submit: I18nKey.HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT,
|
||||
session_start: I18nKey.HOOKS_MODAL$EVENT_SESSION_START,
|
||||
session_end: I18nKey.HOOKS_MODAL$EVENT_SESSION_END,
|
||||
stop: I18nKey.HOOKS_MODAL$EVENT_STOP,
|
||||
};
|
||||
|
||||
export function HookEventItem({
|
||||
hookEvent,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: HookEventItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const i18nKey = EVENT_TYPE_I18N_KEYS[hookEvent.event_type];
|
||||
const eventTypeLabel = i18nKey ? t(i18nKey) : hookEvent.event_type;
|
||||
|
||||
const totalHooks = hookEvent.matchers.reduce(
|
||||
(sum, matcher) => sum + matcher.hooks.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(hookEvent.event_type)}
|
||||
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Typography.Text className="font-bold text-gray-100">
|
||||
{eventTypeLabel}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Typography.Text className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
|
||||
{t(I18nKey.HOOKS_MODAL$HOOK_COUNT, { count: totalHooks })}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-gray-300">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} />
|
||||
) : (
|
||||
<ChevronRight size={18} />
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-3 pt-1">
|
||||
{hookEvent.matchers.map((matcher, index) => (
|
||||
<HookMatcherContent
|
||||
key={`${hookEvent.event_type}-${matcher.matcher}-${index}`}
|
||||
matcher={matcher}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { Pre } from "#/ui/pre";
|
||||
import { HookMatcher } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
interface HookMatcherContentProps {
|
||||
matcher: HookMatcher;
|
||||
}
|
||||
|
||||
export function HookMatcherContent({ matcher }: HookMatcherContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mb-4 p-3 bg-gray-800 rounded-md">
|
||||
<div className="mb-2">
|
||||
<Typography.Text className="text-sm font-semibold text-gray-300">
|
||||
{t(I18nKey.HOOKS_MODAL$MATCHER)}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="ml-2 px-2 py-1 text-xs rounded-full bg-blue-900">
|
||||
{matcher.matcher}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Typography.Text className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.HOOKS_MODAL$COMMANDS)}
|
||||
</Typography.Text>
|
||||
{matcher.hooks.map((hook, index) => (
|
||||
<div key={`${hook.command}-${index}`} className="mt-2">
|
||||
<Pre
|
||||
size="default"
|
||||
font="mono"
|
||||
lineHeight="relaxed"
|
||||
background="dark"
|
||||
textColor="light"
|
||||
padding="medium"
|
||||
borderRadius="medium"
|
||||
shadow="inner"
|
||||
maxHeight="small"
|
||||
overflow="auto"
|
||||
>
|
||||
{hook.command}
|
||||
</Pre>
|
||||
<div className="flex gap-4 mt-1 text-xs text-gray-400">
|
||||
<span>{t(I18nKey.HOOKS_MODAL$TYPE, { type: hook.type })}</span>
|
||||
<span>
|
||||
{t(I18nKey.HOOKS_MODAL$TIMEOUT, { timeout: hook.timeout })}
|
||||
</span>
|
||||
{hook.async ? (
|
||||
<span className="rounded-full bg-emerald-900 px-2 py-0.5 text-emerald-300">
|
||||
{t(I18nKey.HOOKS_MODAL$ASYNC)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface HooksEmptyStateProps {
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function HooksEmptyState({ isError }: HooksEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<Typography.Text className="text-gray-400">
|
||||
{isError
|
||||
? t(I18nKey.COMMON$FETCH_ERROR)
|
||||
: t(I18nKey.CONVERSATION$NO_HOOKS)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function HooksLoadingState() {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
|
||||
interface HooksModalHeaderProps {
|
||||
isAgentReady: boolean;
|
||||
isLoading: boolean;
|
||||
isRefetching: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function HooksModalHeader({
|
||||
isAgentReady,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
onRefresh,
|
||||
}: HooksModalHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<BaseModalTitle title={t(I18nKey.HOOKS_MODAL$TITLE)} />
|
||||
{isAgentReady && (
|
||||
<BrandButton
|
||||
testId="refresh-hooks"
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="flex items-center gap-2"
|
||||
onClick={onRefresh}
|
||||
isDisabled={isLoading || isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t(I18nKey.BUTTON$REFRESH)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useState } from "react";
|
||||
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 { useConversationHooks } from "#/hooks/query/use-conversation-hooks";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { HooksModalHeader } from "./hooks-modal-header";
|
||||
import { HooksLoadingState } from "./hooks-loading-state";
|
||||
import { HooksEmptyState } from "./hooks-empty-state";
|
||||
import { HookEventItem } from "./hook-event-item";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
interface HooksModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function HooksModal({ onClose }: HooksModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const [expandedEvents, setExpandedEvents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const {
|
||||
data: hooks,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useConversationHooks();
|
||||
|
||||
const toggleEvent = (eventType: string) => {
|
||||
setExpandedEvents((prev) => ({
|
||||
...prev,
|
||||
[eventType]: !prev[eventType],
|
||||
}));
|
||||
};
|
||||
|
||||
const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes(
|
||||
curAgentState,
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<ModalBody
|
||||
width="medium"
|
||||
className="max-h-[80vh] flex flex-col items-start"
|
||||
testID="hooks-modal"
|
||||
>
|
||||
<HooksModalHeader
|
||||
isAgentReady={isAgentReady}
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
|
||||
{isAgentReady && (
|
||||
<Typography.Text className="text-sm text-gray-400">
|
||||
{t(I18nKey.HOOKS_MODAL$WARNING)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<div className="w-full h-[60vh] overflow-auto rounded-md custom-scrollbar-always">
|
||||
{!isAgentReady && (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
<Typography.Text>
|
||||
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <HooksLoadingState />}
|
||||
|
||||
{!isLoading &&
|
||||
isAgentReady &&
|
||||
(isError || !hooks || hooks.length === 0) && (
|
||||
<HooksEmptyState isError={isError} />
|
||||
)}
|
||||
|
||||
{!isLoading && isAgentReady && hooks && hooks.length > 0 && (
|
||||
<div className="p-2 space-y-3">
|
||||
{hooks.map((hookEvent) => {
|
||||
const isExpanded =
|
||||
expandedEvents[hookEvent.event_type] || false;
|
||||
|
||||
return (
|
||||
<HookEventItem
|
||||
key={hookEvent.event_type}
|
||||
hookEvent={hookEvent}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={toggleEvent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,6 @@ interface ConversationNameContextMenuProps {
|
||||
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowSkills?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowHooks?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onExportConversation?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onTogglePublic?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
@@ -53,7 +52,6 @@ export function ConversationNameContextMenu({
|
||||
onDisplayCost,
|
||||
onShowAgentTools,
|
||||
onShowSkills,
|
||||
onShowHooks,
|
||||
onExportConversation,
|
||||
onDownloadViaVSCode,
|
||||
onTogglePublic,
|
||||
@@ -79,7 +77,7 @@ export function ConversationNameContextMenu({
|
||||
|
||||
const hasDownload = Boolean(onDownloadViaVSCode || onDownloadConversation);
|
||||
const hasExport = Boolean(onExportConversation);
|
||||
const hasTools = Boolean(onShowAgentTools || onShowSkills || onShowHooks);
|
||||
const hasTools = Boolean(onShowAgentTools || onShowSkills);
|
||||
const hasInfo = Boolean(onDisplayCost);
|
||||
const hasControl = Boolean(onStop || onDelete);
|
||||
|
||||
@@ -121,20 +119,6 @@ export function ConversationNameContextMenu({
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{onShowHooks && (
|
||||
<ContextMenuListItem
|
||||
testId="show-hooks-button"
|
||||
onClick={onShowHooks}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ConversationNameContextMenuIconText
|
||||
icon={<ToolsIcon width={16} height={16} />}
|
||||
text={t(I18nKey.CONVERSATION$SHOW_HOOKS)}
|
||||
className={CONTEXT_MENU_ICON_TEXT_CLASSNAME}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{onShowAgentTools && (
|
||||
<ContextMenuListItem
|
||||
testId="show-agent-tools-button"
|
||||
|
||||
@@ -10,7 +10,6 @@ import { EllipsisButton } from "../conversation-panel/ellipsis-button";
|
||||
import { ConversationNameContextMenu } from "./conversation-name-context-menu";
|
||||
import { SystemMessageModal } from "../conversation-panel/system-message-modal";
|
||||
import { SkillsModal } from "../conversation-panel/skills-modal";
|
||||
import { HooksModal } from "../conversation-panel/hooks-modal";
|
||||
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
|
||||
import { MetricsModal } from "./metrics-modal/metrics-modal";
|
||||
@@ -35,7 +34,6 @@ export function ConversationName() {
|
||||
handleDisplayCost,
|
||||
handleShowAgentTools,
|
||||
handleShowSkills,
|
||||
handleShowHooks,
|
||||
handleExportConversation,
|
||||
handleTogglePublic,
|
||||
handleCopyShareLink,
|
||||
@@ -48,8 +46,6 @@ export function ConversationName() {
|
||||
setSystemModalVisible,
|
||||
skillsModalVisible,
|
||||
setSkillsModalVisible,
|
||||
hooksModalVisible,
|
||||
setHooksModalVisible,
|
||||
confirmDeleteModalVisible,
|
||||
setConfirmDeleteModalVisible,
|
||||
confirmStopModalVisible,
|
||||
@@ -62,7 +58,6 @@ export function ConversationName() {
|
||||
shouldShowDisplayCost,
|
||||
shouldShowAgentTools,
|
||||
shouldShowSkills,
|
||||
shouldShowHooks,
|
||||
} = useConversationNameContextMenu({
|
||||
conversationId,
|
||||
conversationStatus: conversation?.status,
|
||||
@@ -185,7 +180,6 @@ export function ConversationName() {
|
||||
shouldShowAgentTools ? handleShowAgentTools : undefined
|
||||
}
|
||||
onShowSkills={shouldShowSkills ? handleShowSkills : undefined}
|
||||
onShowHooks={shouldShowHooks ? handleShowHooks : undefined}
|
||||
onExportConversation={
|
||||
shouldShowExport ? handleExportConversation : undefined
|
||||
}
|
||||
@@ -225,11 +219,6 @@ export function ConversationName() {
|
||||
<SkillsModal onClose={() => setSkillsModalVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Hooks Modal */}
|
||||
{hooksModalVisible && (
|
||||
<HooksModal onClose={() => setHooksModalVisible(false)} />
|
||||
)}
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
@@ -244,7 +233,6 @@ export function ConversationName() {
|
||||
<ConfirmStopModal
|
||||
onConfirm={handleConfirmStop}
|
||||
onCancel={() => setConfirmStopModalVisible(false)}
|
||||
sandboxId={conversation?.sandbox_id ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { H2, Text } from "#/ui/typography";
|
||||
import CheckCircleFillIcon from "#/icons/check-circle-fill.svg?react";
|
||||
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
const ENTERPRISE_FEATURE_KEYS: I18nKey[] = [
|
||||
I18nKey.ENTERPRISE$FEATURE_DATA_PRIVACY,
|
||||
I18nKey.ENTERPRISE$FEATURE_DEPLOYMENT,
|
||||
I18nKey.ENTERPRISE$FEATURE_SSO,
|
||||
I18nKey.ENTERPRISE$FEATURE_SUPPORT,
|
||||
];
|
||||
|
||||
export function EnterpriseBanner() {
|
||||
const { t } = useTranslation();
|
||||
const posthog = usePostHog();
|
||||
|
||||
if (!PROJ_USER_JOURNEY()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLearnMore = () => {
|
||||
posthog?.capture("saas_selfhosted_inquiry");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto lg:mx-0 lg:w-80 p-6 rounded-lg bg-gradient-to-b from-slate-800 to-slate-900 border border-slate-700 h-fit">
|
||||
{/* Self-Hosted Label */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="px-8 py-0.5 rounded-full bg-gradient-to-r from-blue-900 to-blue-950 border border-blue-800">
|
||||
<Text className="text-xs font-medium text-blue-400 tracking-wider uppercase">
|
||||
{t(I18nKey.ENTERPRISE$SELF_HOSTED)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<H2 className="text-center mb-3">{t(I18nKey.ENTERPRISE$TITLE)}</H2>
|
||||
|
||||
{/* Description */}
|
||||
<Text className="text-sm text-gray-400 text-center mb-6 block">
|
||||
{t(I18nKey.ENTERPRISE$DESCRIPTION)}
|
||||
</Text>
|
||||
|
||||
{/* Features List */}
|
||||
<ul className="space-y-3 mb-6">
|
||||
{ENTERPRISE_FEATURE_KEYS.map((featureKey) => (
|
||||
<li key={featureKey} className="flex items-center gap-2">
|
||||
<CheckCircleFillIcon className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
<Text className="text-sm text-gray-300">{t(featureKey)}</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Learn More Button */}
|
||||
<a
|
||||
href="https://openhands.dev/enterprise"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={handleLearnMore}
|
||||
aria-label={t(I18nKey.ENTERPRISE$LEARN_MORE_ARIA)}
|
||||
className="block w-full py-2.5 px-4 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors text-center"
|
||||
>
|
||||
{t(I18nKey.ENTERPRISE$LEARN_MORE)}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { getRandomTip } from "#/utils/tips";
|
||||
|
||||
export function RandomTip() {
|
||||
const { t } = useTranslation();
|
||||
const { providers } = useUserProviders();
|
||||
const [randomTip, setRandomTip] = React.useState(() =>
|
||||
getRandomTip(providers),
|
||||
);
|
||||
const [randomTip, setRandomTip] = React.useState(getRandomTip());
|
||||
|
||||
// Update the random tip when the component mounts or providers change
|
||||
// Update the random tip when the component mounts
|
||||
React.useEffect(() => {
|
||||
setRandomTip(getRandomTip(providers));
|
||||
}, [providers]);
|
||||
setRandomTip(getRandomTip());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isHookExecutionEvent } from "#/types/v1/type-guards";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "#/components/features/chat/generic-event-message";
|
||||
|
||||
interface HookExecutionEventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
}
|
||||
|
||||
function getHookIcon(hookType: string, blocked: boolean): string {
|
||||
if (blocked) {
|
||||
return "🚫";
|
||||
}
|
||||
|
||||
switch (hookType) {
|
||||
case "PreToolUse":
|
||||
return "⏳";
|
||||
case "PostToolUse":
|
||||
return "✅";
|
||||
case "UserPromptSubmit":
|
||||
return "📝";
|
||||
case "SessionStart":
|
||||
return "🚀";
|
||||
case "SessionEnd":
|
||||
return "🏁";
|
||||
case "Stop":
|
||||
return "⏹️";
|
||||
default:
|
||||
return "🔗";
|
||||
}
|
||||
}
|
||||
|
||||
function formatHookCommand(command: string): string {
|
||||
// Truncate long commands for display
|
||||
if (command.length > 80) {
|
||||
return `${command.slice(0, 77)}...`;
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function getStatusText(blocked: boolean, success: boolean): string {
|
||||
if (blocked) return "blocked";
|
||||
if (success) return "ok";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function getStatusClassName(blocked: boolean, success: boolean): string {
|
||||
if (blocked) return "bg-amber-900/50 text-amber-300";
|
||||
if (success) return "bg-green-900/50 text-green-300";
|
||||
return "bg-red-900/50 text-red-300";
|
||||
}
|
||||
|
||||
export function HookExecutionEventMessage({
|
||||
event,
|
||||
}: HookExecutionEventMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isHookExecutionEvent(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = getHookIcon(event.hook_event_type, event.blocked);
|
||||
const statusText = getStatusText(event.blocked, event.success);
|
||||
const statusClassName = getStatusClassName(event.blocked, event.success);
|
||||
|
||||
// Determine the overall success indicator for GenericEventMessage.
|
||||
// When blocked, suppress the success indicator entirely — the amber "blocked"
|
||||
// badge in the title is the authoritative status signal.
|
||||
const getSuccessStatus = (): "success" | "error" | undefined => {
|
||||
if (event.blocked) return undefined;
|
||||
return event.success ? "success" : "error";
|
||||
};
|
||||
const successStatus = getSuccessStatus();
|
||||
|
||||
const title = (
|
||||
<span>
|
||||
{icon} {t("HOOK$HOOK_LABEL")}: {event.hook_event_type}
|
||||
{event.tool_name && (
|
||||
<span className="text-neutral-400 ml-2">({event.tool_name})</span>
|
||||
)}
|
||||
<span className={`ml-2 px-1 py-0.5 rounded text-xs ${statusClassName}`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
const details = (
|
||||
<div className="flex flex-col gap-2 text-neutral-400">
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$COMMAND")}:</span>{" "}
|
||||
<code className="text-xs bg-neutral-800 px-1 py-0.5 rounded">
|
||||
{formatHookCommand(event.hook_command)}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{event.exit_code !== null && (
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$EXIT_CODE")}:</span>{" "}
|
||||
{event.exit_code}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.blocked && event.reason && (
|
||||
<div className="text-amber-400">
|
||||
<span className="text-neutral-500">{t("HOOK$BLOCKED_REASON")}:</span>{" "}
|
||||
{event.reason}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.additional_context && (
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$CONTEXT")}:</span>{" "}
|
||||
{event.additional_context}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.error && (
|
||||
<div className="text-red-400">
|
||||
<span className="text-neutral-500">{t("HOOK$ERROR")}:</span>{" "}
|
||||
{event.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.stdout && (
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$OUTPUT")}:</span>
|
||||
<pre className="text-xs bg-neutral-800 p-2 rounded mt-1 overflow-x-auto max-h-40 overflow-y-auto">
|
||||
{event.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.stderr && (
|
||||
<div>
|
||||
<span className="text-neutral-500">{t("HOOK$STDERR")}:</span>
|
||||
<pre className="text-xs bg-neutral-800 p-2 rounded mt-1 overflow-x-auto max-h-40 overflow-y-auto text-amber-300">
|
||||
{event.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericEventMessage
|
||||
title={title}
|
||||
details={details}
|
||||
success={successStatus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
BrowserListTabsAction,
|
||||
BrowserSwitchTabAction,
|
||||
BrowserCloseTabAction,
|
||||
GlobAction,
|
||||
GrepAction,
|
||||
} from "#/types/v1/core/base/action";
|
||||
|
||||
const getRiskText = (risk: SecurityRisk) => {
|
||||
@@ -41,28 +39,6 @@ const getRiskText = (risk: SecurityRisk) => {
|
||||
|
||||
const getNoContentActionContent = (): string => "";
|
||||
|
||||
// Grep/Glob search actions
|
||||
const getSearchActionContent = (
|
||||
event: ActionEvent<GlobAction | GrepAction>,
|
||||
): string => {
|
||||
const { action } = event;
|
||||
const parts: string[] = [];
|
||||
if (action.pattern) {
|
||||
parts.push(`**Pattern:** \`${action.pattern}\``);
|
||||
}
|
||||
if (action.path) {
|
||||
parts.push(`**Path:** \`${action.path}\``);
|
||||
}
|
||||
if ("include" in action && action.include) {
|
||||
parts.push(`**Include:** \`${action.include}\``);
|
||||
}
|
||||
const { summary } = event as { summary?: string };
|
||||
if (summary) {
|
||||
parts.push(`**Summary:** ${summary}`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join("\n") : getNoContentActionContent();
|
||||
};
|
||||
|
||||
// File Editor Actions
|
||||
const getFileEditorActionContent = (
|
||||
action: FileEditorAction | StrReplaceEditorAction,
|
||||
@@ -252,12 +228,6 @@ export const getActionContent = (event: ActionEvent): string => {
|
||||
case "BrowserCloseTabAction":
|
||||
return getBrowserActionContent(action);
|
||||
|
||||
case "GrepAction":
|
||||
case "GlobAction":
|
||||
return getSearchActionContent(
|
||||
event as ActionEvent<GlobAction | GrepAction>,
|
||||
);
|
||||
|
||||
default:
|
||||
return getDefaultEventContent(event);
|
||||
}
|
||||
|
||||
@@ -84,24 +84,6 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
case "TaskTrackerAction":
|
||||
actionKey = "ACTION_MESSAGE$TASK_TRACKING";
|
||||
break;
|
||||
case "GrepAction":
|
||||
actionKey = "ACTION_MESSAGE$GREP";
|
||||
actionValues = {
|
||||
pattern:
|
||||
"pattern" in event.action && event.action.pattern
|
||||
? trimText(String(event.action.pattern), 50)
|
||||
: "",
|
||||
};
|
||||
break;
|
||||
case "GlobAction":
|
||||
actionKey = "ACTION_MESSAGE$GLOB";
|
||||
actionValues = {
|
||||
pattern:
|
||||
"pattern" in event.action && event.action.pattern
|
||||
? trimText(String(event.action.pattern), 50)
|
||||
: "",
|
||||
};
|
||||
break;
|
||||
case "BrowserNavigateAction":
|
||||
case "BrowserClickAction":
|
||||
case "BrowserTypeAction":
|
||||
@@ -180,22 +162,6 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
case "ThinkObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$THINK";
|
||||
break;
|
||||
case "GlobObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$GLOB";
|
||||
observationValues = {
|
||||
pattern: event.observation.pattern
|
||||
? trimText(event.observation.pattern, 50)
|
||||
: "",
|
||||
};
|
||||
break;
|
||||
case "GrepObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$GREP";
|
||||
observationValues = {
|
||||
pattern: event.observation.pattern
|
||||
? trimText(event.observation.pattern, 50)
|
||||
: "",
|
||||
};
|
||||
break;
|
||||
default:
|
||||
// For unknown observations, use the type name
|
||||
return observationType.replace("Observation", "").toUpperCase();
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
FileEditorObservation,
|
||||
StrReplaceEditorObservation,
|
||||
TaskTrackerObservation,
|
||||
GlobObservation,
|
||||
GrepObservation,
|
||||
} from "#/types/v1/core/base/observation";
|
||||
|
||||
// File Editor Observations
|
||||
@@ -223,72 +221,6 @@ const getFinishObservationContent = (
|
||||
return content;
|
||||
};
|
||||
|
||||
// Glob Observations
|
||||
const getGlobObservationContent = (
|
||||
event: ObservationEvent<GlobObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
// Extract text content from the observation
|
||||
const textContent = observation.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
let content = `**Pattern:** \`${observation.pattern}\`\n`;
|
||||
content += `**Search Path:** \`${observation.search_path}\`\n\n`;
|
||||
|
||||
if (observation.is_error) {
|
||||
content += `**Error:**\n${textContent}`;
|
||||
} else if (observation.files.length === 0) {
|
||||
content += "**Result:** No files found.";
|
||||
} else {
|
||||
content += `**Files Found (${observation.files.length}${observation.truncated ? "+, truncated" : ""}):**\n`;
|
||||
content += observation.files.map((f) => `- \`${f}\``).join("\n");
|
||||
}
|
||||
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
// Grep Observations
|
||||
const getGrepObservationContent = (
|
||||
event: ObservationEvent<GrepObservation>,
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
// Extract text content from the observation
|
||||
const textContent = observation.content
|
||||
.filter((c) => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
|
||||
let content = `**Pattern:** \`${observation.pattern}\`\n`;
|
||||
content += `**Search Path:** \`${observation.search_path}\`\n`;
|
||||
if (observation.include_pattern) {
|
||||
content += `**Include:** \`${observation.include_pattern}\`\n`;
|
||||
}
|
||||
content += "\n";
|
||||
|
||||
if (observation.is_error) {
|
||||
content += `**Error:**\n${textContent}`;
|
||||
} else if (observation.matches.length === 0) {
|
||||
content += "**Result:** No matches found.";
|
||||
} else {
|
||||
content += `**Matches (${observation.matches.length}${observation.truncated ? "+, truncated" : ""}):**\n`;
|
||||
content += observation.matches.map((f) => `- \`${f}\``).join("\n");
|
||||
}
|
||||
|
||||
if (content.length > MAX_CONTENT_LENGTH) {
|
||||
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
export const getObservationContent = (event: ObservationEvent): string => {
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
@@ -332,16 +264,6 @@ export const getObservationContent = (event: ObservationEvent): string => {
|
||||
event as ObservationEvent<FinishObservation>,
|
||||
);
|
||||
|
||||
case "GlobObservation":
|
||||
return getGlobObservationContent(
|
||||
event as ObservationEvent<GlobObservation>,
|
||||
);
|
||||
|
||||
case "GrepObservation":
|
||||
return getGrepObservationContent(
|
||||
event as ObservationEvent<GrepObservation>,
|
||||
);
|
||||
|
||||
default:
|
||||
return getDefaultEventContent(event);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
isMessageEvent,
|
||||
isAgentErrorEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
isHookExecutionEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
|
||||
export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
@@ -51,11 +50,6 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render hook execution events
|
||||
if (isHookExecutionEvent(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't render any other event types (system events, etc.)
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { HookExecutionEventMessage } from "#/components/shared/hook-execution-event-message";
|
||||
@@ -4,4 +4,3 @@ export { ErrorEventMessage } from "./error-event-message";
|
||||
export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { ThoughtEventMessage } from "./thought-event-message";
|
||||
export { HookExecutionEventMessage } from "./hook-execution-event-message";
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
isAgentErrorEvent,
|
||||
isUserMessageEvent,
|
||||
isPlanningFileEditorObservationEvent,
|
||||
isHookExecutionEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
FinishEventMessage,
|
||||
GenericEventMessageWrapper,
|
||||
ThoughtEventMessage,
|
||||
HookExecutionEventMessage,
|
||||
} from "./event-message-components";
|
||||
import { createSkillReadyEvent } from "./event-content-helpers/create-skill-ready-event";
|
||||
import { PlanPreview } from "../../features/chat/plan-preview";
|
||||
@@ -190,11 +188,6 @@ export function EventMessage({
|
||||
return <ErrorEventMessage event={event} {...commonProps} />;
|
||||
}
|
||||
|
||||
// Hook execution events
|
||||
if (isHookExecutionEvent(event)) {
|
||||
return <HookExecutionEventMessage event={event} />;
|
||||
}
|
||||
|
||||
// Finish actions
|
||||
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
||||
return (
|
||||
|
||||
@@ -38,24 +38,19 @@ function groupEventsByPhase(events: OpenHandsEvent[]): OpenHandsEvent[][] {
|
||||
return phases;
|
||||
}
|
||||
|
||||
const isPlanFilePath = (path: string | null): boolean =>
|
||||
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
|
||||
|
||||
/**
|
||||
* Finds the last PlanningFileEditorObservation for Plan.md in a phase.
|
||||
* Finds the last PlanningFileEditorObservation in a phase.
|
||||
*
|
||||
* @param phase - Array of events in a phase
|
||||
* @returns The event ID of the last Plan.md observation, or null
|
||||
* @returns The event ID of the last PlanningFileEditorObservation, or null
|
||||
*/
|
||||
function findLastPlanningObservationInPhase(
|
||||
phase: OpenHandsEvent[],
|
||||
): string | null {
|
||||
// Iterate backwards to find the last one
|
||||
for (let i = phase.length - 1; i >= 0; i -= 1) {
|
||||
const event = phase[i];
|
||||
if (
|
||||
isPlanningFileEditorObservationEvent(event) &&
|
||||
isPlanFilePath(event.observation.path)
|
||||
) {
|
||||
if (isPlanningFileEditorObservationEvent(event)) {
|
||||
return event.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ import type {
|
||||
V1SendMessageRequest,
|
||||
} from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
import PendingMessageService from "#/api/pending-message-service/pending-message-service.api";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { isBudgetOrCreditError, trackError } from "#/utils/error-handler";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
@@ -48,7 +47,6 @@ import { useReadConversationFile } from "#/hooks/mutation/use-read-conversation-
|
||||
import useMetricsStore from "#/stores/metrics-store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConversationHistory } from "#/hooks/query/use-conversation-history";
|
||||
import { setConversationState } from "#/utils/conversation-local-storage";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V1_WebSocketConnectionState =
|
||||
@@ -57,13 +55,9 @@ export type V1_WebSocketConnectionState =
|
||||
| "CLOSED"
|
||||
| "CLOSING";
|
||||
|
||||
interface SendMessageResult {
|
||||
queued: boolean; // true if message was queued for later delivery, false if sent immediately
|
||||
}
|
||||
|
||||
interface ConversationWebSocketContextType {
|
||||
connectionState: V1_WebSocketConnectionState;
|
||||
sendMessage: (message: V1SendMessageRequest) => Promise<SendMessageResult>;
|
||||
sendMessage: (message: V1SendMessageRequest) => Promise<void>;
|
||||
isLoadingHistory: boolean;
|
||||
}
|
||||
|
||||
@@ -78,6 +72,7 @@ export function ConversationWebSocketProvider({
|
||||
sessionApiKey,
|
||||
subConversations,
|
||||
subConversationIds,
|
||||
onDisconnect,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
conversationId?: string;
|
||||
@@ -85,6 +80,7 @@ export function ConversationWebSocketProvider({
|
||||
sessionApiKey?: string | null;
|
||||
subConversations?: V1AppConversation[];
|
||||
subConversationIds?: string[];
|
||||
onDisconnect?: () => void;
|
||||
}) {
|
||||
// Separate connection state tracking for each WebSocket
|
||||
const [mainConnectionState, setMainConnectionState] =
|
||||
@@ -126,15 +122,13 @@ export function ConversationWebSocketProvider({
|
||||
const receivedEventCountRefMain = useRef(0);
|
||||
const receivedEventCountRefPlanning = useRef(0);
|
||||
|
||||
// Track the latest PlanningFileEditorObservation for Plan.md during history replay
|
||||
// Track the latest PlanningFileEditorObservation event during history replay
|
||||
// We'll only call the API once after history loading completes
|
||||
const latestPlanningFileEventRef = useRef<{
|
||||
path: string;
|
||||
conversationId: string;
|
||||
} | null>(null);
|
||||
|
||||
const isPlanFilePath = (path: string | null): boolean =>
|
||||
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
|
||||
|
||||
// Helper function to update metrics from stats event
|
||||
const updateMetricsFromStats = useCallback(
|
||||
(event: ConversationStateUpdateEventStats) => {
|
||||
@@ -403,10 +397,6 @@ export function ConversationWebSocketProvider({
|
||||
// Clear optimistic user message when a user message is confirmed
|
||||
if (isUserMessageEvent(event)) {
|
||||
removeOptimisticUserMessage();
|
||||
// Clear draft from localStorage - message was successfully delivered
|
||||
if (conversationId) {
|
||||
setConversationState(conversationId, { draftMessage: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cache invalidation for ActionEvent
|
||||
@@ -566,11 +556,6 @@ export function ConversationWebSocketProvider({
|
||||
// Clear optimistic user message when a user message is confirmed
|
||||
if (isUserMessageEvent(event)) {
|
||||
removeOptimisticUserMessage();
|
||||
// Clear draft from localStorage - message was successfully delivered
|
||||
// Use main conversationId since user types in main conversation input
|
||||
if (conversationId) {
|
||||
setConversationState(conversationId, { draftMessage: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cache invalidation for ActionEvent
|
||||
@@ -614,39 +599,37 @@ export function ConversationWebSocketProvider({
|
||||
appendOutput(textContent);
|
||||
}
|
||||
|
||||
// Handle PlanningFileEditorObservation - only update plan for Plan.md
|
||||
// Handle PlanningFileEditorObservation events - read and update plan content
|
||||
if (isPlanningFileEditorObservationEvent(event)) {
|
||||
const { path } = event.observation;
|
||||
if (isPlanFilePath(path)) {
|
||||
const planningAgentConversation = subConversations?.[0];
|
||||
const planningConversationId = planningAgentConversation?.id;
|
||||
const planningAgentConversation = subConversations?.[0];
|
||||
const planningConversationId = planningAgentConversation?.id;
|
||||
|
||||
if (planningConversationId && path) {
|
||||
if (isLoadingHistoryPlanning) {
|
||||
latestPlanningFileEventRef.current = {
|
||||
path,
|
||||
if (planningConversationId && event.observation.path) {
|
||||
// During history replay, track the latest event but don't call API
|
||||
// After history loading completes, we'll call the API once with the latest event
|
||||
if (isLoadingHistoryPlanning) {
|
||||
latestPlanningFileEventRef.current = {
|
||||
path: event.observation.path,
|
||||
conversationId: planningConversationId,
|
||||
};
|
||||
} else {
|
||||
// History loading is complete - this is a new real-time event
|
||||
// Call the API immediately for real-time updates
|
||||
readConversationFile(
|
||||
{
|
||||
conversationId: planningConversationId,
|
||||
};
|
||||
} else {
|
||||
readConversationFile(
|
||||
{
|
||||
conversationId: planningConversationId,
|
||||
filePath: path,
|
||||
filePath: event.observation.path,
|
||||
},
|
||||
{
|
||||
onSuccess: (fileContent) => {
|
||||
setPlanContent(fileContent);
|
||||
},
|
||||
{
|
||||
onSuccess: (fileContent) => {
|
||||
setPlanContent(fileContent);
|
||||
},
|
||||
onError: (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"Failed to read conversation file:",
|
||||
error,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to read conversation file:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,10 +699,13 @@ export function ConversationWebSocketProvider({
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
onClose: (event: CloseEvent) => {
|
||||
setMainConnectionState("CLOSED");
|
||||
// Recovery is handled by useSandboxRecovery on tab focus/page refresh
|
||||
// No error message needed - silent recovery provides better UX
|
||||
// Trigger silent recovery on unexpected disconnect
|
||||
// Do NOT show error message - recovery happens automatically
|
||||
if (event.code !== 1000 && hasConnectedRefMain.current) {
|
||||
onDisconnect?.();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setMainConnectionState("CLOSED");
|
||||
@@ -737,6 +723,7 @@ export function ConversationWebSocketProvider({
|
||||
sessionApiKey,
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
onDisconnect,
|
||||
]);
|
||||
|
||||
// Separate WebSocket options for planning agent connection
|
||||
@@ -783,10 +770,13 @@ export function ConversationWebSocketProvider({
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
onClose: (event: CloseEvent) => {
|
||||
setPlanningConnectionState("CLOSED");
|
||||
// Recovery is handled by useSandboxRecovery on tab focus/page refresh
|
||||
// No error message needed - silent recovery provides better UX
|
||||
// Trigger silent recovery on unexpected disconnect
|
||||
// Do NOT show error message - recovery happens automatically
|
||||
if (event.code !== 1000 && hasConnectedRefPlanning.current) {
|
||||
onDisconnect?.();
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setPlanningConnectionState("CLOSED");
|
||||
@@ -803,6 +793,7 @@ export function ConversationWebSocketProvider({
|
||||
removeErrorMessage,
|
||||
sessionApiKey,
|
||||
subConversations,
|
||||
onDisconnect,
|
||||
]);
|
||||
|
||||
// Only attempt WebSocket connection when we have a valid URL
|
||||
@@ -819,44 +810,21 @@ export function ConversationWebSocketProvider({
|
||||
);
|
||||
|
||||
// V1 send message function via WebSocket
|
||||
// Falls back to REST API queue when WebSocket is not connected
|
||||
const sendMessage = useCallback(
|
||||
async (message: V1SendMessageRequest): Promise<SendMessageResult> => {
|
||||
async (message: V1SendMessageRequest) => {
|
||||
const currentMode = useConversationStore.getState().conversationMode;
|
||||
const currentSocket =
|
||||
currentMode === "plan" ? planningAgentSocket : mainSocket;
|
||||
|
||||
if (!currentSocket || currentSocket.readyState !== WebSocket.OPEN) {
|
||||
// WebSocket not connected - queue message via REST API
|
||||
// Message will be delivered automatically when conversation becomes ready
|
||||
if (!conversationId) {
|
||||
const error = new Error("No conversation ID available");
|
||||
setErrorMessage(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await PendingMessageService.queueMessage(conversationId, {
|
||||
role: "user",
|
||||
content: message.content,
|
||||
});
|
||||
// Message queued successfully - it will be delivered when ready
|
||||
// Return queued: true so caller knows not to show optimistic UI
|
||||
return { queued: true };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to queue message for delivery";
|
||||
setErrorMessage(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
const error = "WebSocket is not connected";
|
||||
setErrorMessage(error);
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Send message through WebSocket as JSON
|
||||
currentSocket.send(JSON.stringify(message));
|
||||
return { queued: false };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Failed to send message";
|
||||
@@ -864,7 +832,7 @@ export function ConversationWebSocketProvider({
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[mainSocket, planningAgentSocket, setErrorMessage, conversationId],
|
||||
[mainSocket, planningAgentSocket, setErrorMessage],
|
||||
);
|
||||
|
||||
// Track main socket state changes
|
||||
|
||||
@@ -3,8 +3,7 @@ import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSubConversations } from "#/hooks/query/use-sub-conversations";
|
||||
import { useSandboxRecovery } from "#/hooks/use-sandbox-recovery";
|
||||
import { isTaskConversationId } from "#/utils/conversation-local-storage";
|
||||
import { useWebSocketRecovery } from "#/hooks/use-websocket-recovery";
|
||||
|
||||
interface WebSocketProviderWrapperProps {
|
||||
children: React.ReactNode;
|
||||
@@ -19,6 +18,18 @@ interface WebSocketProviderWrapperProps {
|
||||
* @param version - 0 for old WsClientProvider, 1 for new ConversationWebSocketProvider
|
||||
* @param conversationId - The conversation ID to pass to the provider
|
||||
* @param children - The child components to wrap
|
||||
*
|
||||
* @example
|
||||
* // Use the old v0 provider
|
||||
* <WebSocketProviderWrapper version={0} conversationId="conv-123">
|
||||
* <ChatComponent />
|
||||
* </WebSocketProviderWrapper>
|
||||
*
|
||||
* @example
|
||||
* // Use the new v1 provider
|
||||
* <WebSocketProviderWrapper version={1} conversationId="conv-123">
|
||||
* <ChatComponent />
|
||||
* </WebSocketProviderWrapper>
|
||||
*/
|
||||
export function WebSocketProviderWrapper({
|
||||
children,
|
||||
@@ -26,11 +37,7 @@ export function WebSocketProviderWrapper({
|
||||
version,
|
||||
}: WebSocketProviderWrapperProps) {
|
||||
// Get conversation data for V1 provider
|
||||
const {
|
||||
data: conversation,
|
||||
refetch: refetchConversation,
|
||||
isFetched,
|
||||
} = useActiveConversation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
// Get sub-conversation data for V1 provider
|
||||
const { data: subConversations } = useSubConversations(
|
||||
conversation?.sub_conversation_ids ?? [],
|
||||
@@ -41,15 +48,9 @@ export function WebSocketProviderWrapper({
|
||||
(subConversation) => subConversation !== null,
|
||||
);
|
||||
|
||||
const isConversationReady =
|
||||
!isTaskConversationId(conversationId) && isFetched && !!conversation;
|
||||
// Recovery for V1 conversations - handles page refresh and tab focus
|
||||
// Does NOT resume on WebSocket disconnect (server pauses after 20 min inactivity)
|
||||
useSandboxRecovery({
|
||||
conversationId,
|
||||
conversationStatus: conversation?.status,
|
||||
refetchConversation: isConversationReady ? refetchConversation : undefined,
|
||||
});
|
||||
// Silent recovery for V1 WebSocket disconnections
|
||||
const { reconnectKey, handleDisconnect } =
|
||||
useWebSocketRecovery(conversationId);
|
||||
|
||||
if (version === 0) {
|
||||
return (
|
||||
@@ -62,11 +63,13 @@ export function WebSocketProviderWrapper({
|
||||
if (version === 1) {
|
||||
return (
|
||||
<ConversationWebSocketProvider
|
||||
key={reconnectKey}
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversation?.url}
|
||||
sessionApiKey={conversation?.session_api_key}
|
||||
subConversationIds={conversation?.sub_conversation_ids}
|
||||
subConversations={filteredSubConversations}
|
||||
onDisconnect={handleDisconnect}
|
||||
>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
|
||||
@@ -5,15 +5,12 @@ import {
|
||||
getTextContent,
|
||||
} from "#/components/features/chat/utils/chat-input.utils";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useDraftPersistence } from "./use-draft-persistence";
|
||||
|
||||
/**
|
||||
* Hook for managing chat input content logic
|
||||
*/
|
||||
export const useChatInputLogic = () => {
|
||||
const chatInputRef = useRef<HTMLDivElement | null>(null);
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
const {
|
||||
messageToSend,
|
||||
@@ -22,12 +19,6 @@ export const useChatInputLogic = () => {
|
||||
setIsRightPanelShown,
|
||||
} = useConversationStore();
|
||||
|
||||
// Draft persistence - saves to localStorage, restores on mount
|
||||
const { saveDraft, clearDraft } = useDraftPersistence(
|
||||
conversationId,
|
||||
chatInputRef,
|
||||
);
|
||||
|
||||
// Save current input value when drawer state changes
|
||||
useEffect(() => {
|
||||
if (chatInputRef.current) {
|
||||
@@ -60,7 +51,5 @@ export const useChatInputLogic = () => {
|
||||
checkIsContentEmpty,
|
||||
clearEmptyContentHandler,
|
||||
getCurrentMessage,
|
||||
saveDraft,
|
||||
clearDraft,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import {
|
||||
useConversationLocalStorageState,
|
||||
getConversationState,
|
||||
setConversationState,
|
||||
} from "#/utils/conversation-local-storage";
|
||||
import { getTextContent } from "#/components/features/chat/utils/chat-input.utils";
|
||||
|
||||
/**
|
||||
* Check if a conversation ID is a temporary task ID.
|
||||
* Task IDs have the format "task-{uuid}" and are used during V1 conversation initialization.
|
||||
*/
|
||||
const isTaskId = (id: string): boolean => id.startsWith("task-");
|
||||
|
||||
const DRAFT_SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
/**
|
||||
* Hook for persisting draft messages to localStorage.
|
||||
* Handles debounced saving on input, restoration on mount, and clearing on confirmed delivery.
|
||||
*/
|
||||
export const useDraftPersistence = (
|
||||
conversationId: string,
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>,
|
||||
) => {
|
||||
const { state, setDraftMessage } =
|
||||
useConversationLocalStorageState(conversationId);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasRestoredRef = useRef(false);
|
||||
const [isRestored, setIsRestored] = useState(false);
|
||||
|
||||
// Track current conversationId to prevent saving draft to wrong conversation
|
||||
const currentConversationIdRef = useRef(conversationId);
|
||||
// Track if this is the first mount to handle initial cleanup
|
||||
const isFirstMountRef = useRef(true);
|
||||
|
||||
// IMPORTANT: This effect must run FIRST when conversation changes.
|
||||
// It handles three concerns:
|
||||
// 1. Cleanup: Cancel pending saves from previous conversation
|
||||
// 2. Task-to-real transition: Preserve draft typed during initialization
|
||||
// 3. DOM reset: Clear stale content before restoration effect runs
|
||||
useEffect(() => {
|
||||
const previousConversationId = currentConversationIdRef.current;
|
||||
const isInitialMount = isFirstMountRef.current;
|
||||
currentConversationIdRef.current = conversationId;
|
||||
isFirstMountRef.current = false;
|
||||
|
||||
// --- 1. Cancel pending saves from previous conversation ---
|
||||
// Prevents draft from being saved to wrong conversation if user switched quickly
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const element = chatInputRef.current;
|
||||
|
||||
// --- 2. Handle task-to-real ID transition (preserve draft during initialization) ---
|
||||
// When a new V1 conversation initializes, it starts with a temporary "task-xxx" ID
|
||||
// that transitions to a real conversation ID once ready. Task IDs don't persist
|
||||
// to localStorage, so any draft typed during this phase would be lost.
|
||||
// We detect this transition and transfer the draft to the new real ID.
|
||||
if (!isInitialMount && previousConversationId !== conversationId) {
|
||||
const wasTaskId = isTaskId(previousConversationId);
|
||||
const isNowRealId = !isTaskId(conversationId);
|
||||
|
||||
if (wasTaskId && isNowRealId && element) {
|
||||
const currentText = getTextContent(element).trim();
|
||||
if (currentText) {
|
||||
// Transfer draft to the new (real) conversation ID
|
||||
setConversationState(conversationId, { draftMessage: currentText });
|
||||
// Keep draft visible in DOM and mark as restored to prevent overwrite
|
||||
hasRestoredRef.current = true;
|
||||
setIsRestored(true);
|
||||
return; // Skip normal cleanup - draft is already in correct state
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Clear stale DOM content (will be restored by next effect if draft exists) ---
|
||||
// This prevents stale drafts from appearing in new conversations due to:
|
||||
// - Browser form restoration on back/forward navigation
|
||||
// - React DOM recycling between conversation switches
|
||||
// The restoration effect will then populate with the correct saved draft
|
||||
if (element) {
|
||||
element.textContent = "";
|
||||
}
|
||||
|
||||
// Reset restoration flag so the restoration effect will run for new conversation
|
||||
hasRestoredRef.current = false;
|
||||
setIsRestored(false);
|
||||
}, [conversationId, chatInputRef]);
|
||||
|
||||
// Restore draft from localStorage - reads directly to avoid state sync timing issues
|
||||
useEffect(() => {
|
||||
if (hasRestoredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = chatInputRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read directly from localStorage to avoid stale state from useConversationLocalStorageState
|
||||
// The hook's state may not have synced yet after conversationId change
|
||||
const { draftMessage } = getConversationState(conversationId);
|
||||
|
||||
// Only restore if there's a saved draft and the input is empty
|
||||
if (draftMessage && getTextContent(element).trim() === "") {
|
||||
element.textContent = draftMessage;
|
||||
// Move cursor to end
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
hasRestoredRef.current = true;
|
||||
setIsRestored(true);
|
||||
}, [chatInputRef, conversationId]);
|
||||
|
||||
// Debounced save function - called from onInput handler
|
||||
const saveDraft = useCallback(() => {
|
||||
// Clear any pending save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Capture the conversationId at the time of input
|
||||
const capturedConversationId = conversationId;
|
||||
|
||||
saveTimeoutRef.current = setTimeout(() => {
|
||||
// Verify we're still on the same conversation before saving
|
||||
// This prevents saving draft to wrong conversation if user switched quickly
|
||||
if (capturedConversationId !== currentConversationIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = chatInputRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = getTextContent(element).trim();
|
||||
// Only save if content has changed
|
||||
if (text !== (state.draftMessage || "")) {
|
||||
setDraftMessage(text || null);
|
||||
}
|
||||
}, DRAFT_SAVE_DEBOUNCE_MS);
|
||||
}, [chatInputRef, state.draftMessage, setDraftMessage, conversationId]);
|
||||
|
||||
// Clear draft - called after message delivery is confirmed
|
||||
const clearDraft = useCallback(() => {
|
||||
// Cancel any pending save
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
setDraftMessage(null);
|
||||
}, [setDraftMessage]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimeoutRef.current) {
|
||||
clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
saveDraft,
|
||||
clearDraft,
|
||||
isRestored,
|
||||
hasDraft: !!state.draftMessage,
|
||||
};
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useSettings } from "./use-settings";
|
||||
|
||||
export const useConversationHooks = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["conversation", conversationId, "hooks", settings?.v1_enabled],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) {
|
||||
throw new Error("No conversation ID provided");
|
||||
}
|
||||
|
||||
// Hooks are only available for V1 conversations
|
||||
if (!settings?.v1_enabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await V1ConversationService.getHooks(conversationId);
|
||||
return data.hooks;
|
||||
},
|
||||
enabled:
|
||||
!!conversationId &&
|
||||
!!settings?.v1_enabled &&
|
||||
curAgentState !== AgentState.LOADING &&
|
||||
curAgentState !== AgentState.INIT,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
export const useConversationsInSandbox = (sandboxId: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["conversations", "sandbox", sandboxId],
|
||||
queryFn: () =>
|
||||
sandboxId
|
||||
? V1ConversationService.searchConversationsBySandboxId(sandboxId)
|
||||
: Promise.resolve([]),
|
||||
enabled: !!sandboxId,
|
||||
staleTime: 0, // Always consider data stale for confirmation dialogs
|
||||
gcTime: 1000 * 60, // 1 minute
|
||||
refetchOnMount: true, // Always fetch fresh data when modal opens
|
||||
});
|
||||
@@ -18,9 +18,6 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
git_user_email: settings.git_user_email || DEFAULT_SETTINGS.git_user_email,
|
||||
is_new_user: false,
|
||||
v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled,
|
||||
sandbox_grouping_strategy:
|
||||
settings.sandbox_grouping_strategy ??
|
||||
DEFAULT_SETTINGS.sandbox_grouping_strategy,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const useUnifiedGetGitChanges = () => {
|
||||
|
||||
// Calculate git path based on selected repository
|
||||
const gitPath = React.useMemo(
|
||||
() => getGitPath(conversationId, selectedRepository),
|
||||
() => getGitPath(selectedRepository),
|
||||
[selectedRepository],
|
||||
);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
|
||||
const absoluteFilePath = React.useMemo(() => {
|
||||
if (!isV1Conversation) return config.filePath;
|
||||
|
||||
const gitPath = getGitPath(conversationId, selectedRepository);
|
||||
const gitPath = getGitPath(selectedRepository);
|
||||
return `${gitPath}/${config.filePath}`;
|
||||
}, [isV1Conversation, selectedRepository, config.filePath]);
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ export function useConversationNameContextMenu({
|
||||
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
|
||||
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
|
||||
const [skillsModalVisible, setSkillsModalVisible] = React.useState(false);
|
||||
const [hooksModalVisible, setHooksModalVisible] = React.useState(false);
|
||||
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
|
||||
React.useState(false);
|
||||
const [confirmStopModalVisible, setConfirmStopModalVisible] =
|
||||
@@ -188,12 +187,6 @@ export function useConversationNameContextMenu({
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleShowHooks = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
setHooksModalVisible(true);
|
||||
onContextMenuToggle?.(false);
|
||||
};
|
||||
|
||||
const handleTogglePublic = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -240,7 +233,6 @@ export function useConversationNameContextMenu({
|
||||
handleDisplayCost,
|
||||
handleShowAgentTools,
|
||||
handleShowSkills,
|
||||
handleShowHooks,
|
||||
handleTogglePublic,
|
||||
handleCopyShareLink,
|
||||
shareUrl,
|
||||
@@ -254,8 +246,6 @@ export function useConversationNameContextMenu({
|
||||
setSystemModalVisible,
|
||||
skillsModalVisible,
|
||||
setSkillsModalVisible,
|
||||
hooksModalVisible,
|
||||
setHooksModalVisible,
|
||||
confirmDeleteModalVisible,
|
||||
setConfirmDeleteModalVisible,
|
||||
confirmStopModalVisible,
|
||||
@@ -277,11 +267,5 @@ export function useConversationNameContextMenu({
|
||||
shouldShowDisplayCost: showOptions,
|
||||
shouldShowAgentTools: Boolean(showOptions && systemMessage),
|
||||
shouldShowSkills: Boolean(showOptions && conversationId),
|
||||
shouldShowHooks: Boolean(
|
||||
showOptions &&
|
||||
conversationId &&
|
||||
conversation?.conversation_version === "V1" &&
|
||||
conversationStatus === "RUNNING",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUnifiedResumeConversationSandbox } from "./mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "./use-user-providers";
|
||||
import { useVisibilityChange } from "./use-visibility-change";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { ConversationStatus } from "#/types/conversation-status";
|
||||
import type { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
interface UseSandboxRecoveryOptions {
|
||||
conversationId: string | undefined;
|
||||
conversationStatus: ConversationStatus | undefined;
|
||||
/** Function to refetch the conversation data - used to get fresh status on tab focus */
|
||||
refetchConversation?: () => Promise<{
|
||||
data: Conversation | null | undefined;
|
||||
}>;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that handles sandbox recovery based on user intent.
|
||||
*
|
||||
* Recovery triggers:
|
||||
* - Page refresh: Resumes the sandbox on initial load if it was paused/stopped
|
||||
* - Tab gains focus: Resumes the sandbox if it was paused/stopped
|
||||
*
|
||||
* What does NOT trigger recovery:
|
||||
* - WebSocket disconnect: Does NOT automatically resume the sandbox
|
||||
* (The server pauses sandboxes after 20 minutes of inactivity,
|
||||
* and sandboxes should only be resumed when the user explicitly shows intent)
|
||||
*
|
||||
* @param options.conversationId - The conversation ID to recover
|
||||
* @param options.conversationStatus - The current conversation status
|
||||
* @param options.refetchConversation - Function to refetch conversation data on tab focus
|
||||
* @param options.onSuccess - Callback when recovery succeeds
|
||||
* @param options.onError - Callback when recovery fails
|
||||
* @returns isResuming - Whether a recovery is in progress
|
||||
*/
|
||||
export function useSandboxRecovery({
|
||||
conversationId,
|
||||
conversationStatus,
|
||||
refetchConversation,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: UseSandboxRecoveryOptions) {
|
||||
const { t } = useTranslation();
|
||||
const { providers } = useUserProviders();
|
||||
const { mutate: resumeSandbox, isPending: isResuming } =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
|
||||
// Track which conversation ID we've already processed for initial load recovery
|
||||
const processedConversationIdRef = React.useRef<string | null>(null);
|
||||
|
||||
const attemptRecovery = React.useCallback(
|
||||
(statusOverride?: ConversationStatus) => {
|
||||
const status = statusOverride ?? conversationStatus;
|
||||
/**
|
||||
* Only recover if sandbox is paused (status === STOPPED) and not already resuming
|
||||
*
|
||||
* Note: ConversationStatus uses different terminology than SandboxStatus:
|
||||
* - SandboxStatus.PAUSED → ConversationStatus.STOPPED : the runtime is not running but may be restarted
|
||||
* - SandboxStatus.MISSING → ConversationStatus.ARCHIVED : the runtime is not running and will not restart due to deleted files.
|
||||
*/
|
||||
if (!conversationId || status !== "STOPPED" || isResuming) {
|
||||
return;
|
||||
}
|
||||
|
||||
resumeSandbox(
|
||||
{ conversationId, providers },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
displayErrorToast(
|
||||
t(I18nKey.CONVERSATION$FAILED_TO_START_WITH_ERROR, {
|
||||
error: error.message,
|
||||
}),
|
||||
);
|
||||
onError?.(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
conversationId,
|
||||
conversationStatus,
|
||||
isResuming,
|
||||
providers,
|
||||
resumeSandbox,
|
||||
onSuccess,
|
||||
onError,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle page refresh (initial load) and conversation navigation
|
||||
React.useEffect(() => {
|
||||
if (!conversationId || !conversationStatus) return;
|
||||
|
||||
// Only attempt recovery once per conversation (handles both initial load and navigation)
|
||||
if (processedConversationIdRef.current === conversationId) return;
|
||||
|
||||
processedConversationIdRef.current = conversationId;
|
||||
|
||||
if (conversationStatus === "STOPPED") {
|
||||
attemptRecovery();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversationId, conversationStatus]);
|
||||
|
||||
const handleVisible = React.useCallback(async () => {
|
||||
// Skip if no conversation or refetch function
|
||||
if (!conversationId || !refetchConversation) return;
|
||||
|
||||
try {
|
||||
// Refetch to get fresh status - cached status may be stale if sandbox was paused while tab was inactive
|
||||
const { data } = await refetchConversation();
|
||||
attemptRecovery(data?.status);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
"Failed to refetch conversation on visibility change:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}, [conversationId, refetchConversation, isResuming, attemptRecovery]);
|
||||
|
||||
// Handle tab focus (visibility change) - refetch conversation status and resume if needed
|
||||
useVisibilityChange({
|
||||
enabled: !!conversationId,
|
||||
onVisible: handleVisible,
|
||||
});
|
||||
|
||||
return { isResuming };
|
||||
}
|
||||
@@ -5,10 +5,6 @@ import { useConversationWebSocket } from "#/contexts/conversation-websocket-cont
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { V1MessageContent } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
|
||||
interface SendResult {
|
||||
queued: boolean; // true if message was queued for later delivery
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook for sending messages that works with both V0 and V1 conversations
|
||||
* - For V0 conversations: Uses Socket.IO WebSocket via useWsClient
|
||||
@@ -30,7 +26,7 @@ export function useSendMessage() {
|
||||
conversation?.conversation_version === "V1";
|
||||
|
||||
const send = useCallback(
|
||||
async (event: Record<string, unknown>): Promise<SendResult> => {
|
||||
async (event: Record<string, unknown>) => {
|
||||
if (isV1Conversation && v1Context) {
|
||||
// V1: Convert V0 event format to V1 message format
|
||||
const { action, args } = event as {
|
||||
@@ -61,20 +57,19 @@ export function useSendMessage() {
|
||||
}
|
||||
|
||||
// Send via V1 WebSocket context (uses correct host/port)
|
||||
const result = await v1Context.sendMessage({
|
||||
await v1Context.sendMessage({
|
||||
role: "user",
|
||||
content,
|
||||
});
|
||||
return result;
|
||||
} else {
|
||||
// For non-message events, fall back to V0 send
|
||||
// (e.g., agent state changes, other control events)
|
||||
v0Send(event);
|
||||
}
|
||||
// For non-message events, fall back to V0 send
|
||||
// (e.g., agent state changes, other control events)
|
||||
} else {
|
||||
// V0: Use Socket.IO
|
||||
v0Send(event);
|
||||
return { queued: false };
|
||||
}
|
||||
// V0: Use Socket.IO
|
||||
v0Send(event);
|
||||
return { queued: false };
|
||||
},
|
||||
[isV1Conversation, v1Context, v0Send, conversationId],
|
||||
);
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
type VisibilityState = "visible" | "hidden";
|
||||
|
||||
interface UseVisibilityChangeOptions {
|
||||
/** Callback fired when visibility changes to the specified state */
|
||||
onVisibilityChange?: (state: VisibilityState) => void;
|
||||
/** Callback fired only when tab becomes visible */
|
||||
onVisible?: () => void;
|
||||
/** Callback fired only when tab becomes hidden */
|
||||
onHidden?: () => void;
|
||||
/** Whether to listen for visibility changes (default: true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that listens for browser tab visibility changes.
|
||||
*
|
||||
* Useful for:
|
||||
* - Resuming operations when user returns to the tab
|
||||
* - Pausing expensive operations when tab is hidden
|
||||
* - Tracking user engagement
|
||||
*
|
||||
* @param options.onVisibilityChange - Callback with the new visibility state
|
||||
* @param options.onVisible - Callback fired only when tab becomes visible
|
||||
* @param options.onHidden - Callback fired only when tab becomes hidden
|
||||
* @param options.enabled - Whether to listen for changes (default: true)
|
||||
* @returns isVisible - Current visibility state of the tab
|
||||
*/
|
||||
export function useVisibilityChange({
|
||||
onVisibilityChange,
|
||||
onVisible,
|
||||
onHidden,
|
||||
enabled = true,
|
||||
}: UseVisibilityChangeOptions = {}) {
|
||||
const [isVisible, setIsVisible] = React.useState(
|
||||
() => document.visibilityState === "visible",
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
const state = document.visibilityState as VisibilityState;
|
||||
setIsVisible(state === "visible");
|
||||
|
||||
onVisibilityChange?.(state);
|
||||
|
||||
if (state === "visible") {
|
||||
onVisible?.();
|
||||
} else {
|
||||
onHidden?.();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [enabled, onVisibilityChange, onVisible, onHidden]);
|
||||
|
||||
return { isVisible };
|
||||
}
|
||||
110
frontend/src/hooks/use-websocket-recovery.ts
Normal file
110
frontend/src/hooks/use-websocket-recovery.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
const MAX_RECOVERY_ATTEMPTS = 3;
|
||||
const RECOVERY_COOLDOWN_MS = 5000;
|
||||
const RECOVERY_SETTLED_DELAY_MS = 2000;
|
||||
|
||||
/**
|
||||
* Hook that handles silent WebSocket recovery by resuming the sandbox
|
||||
* when a WebSocket disconnection is detected.
|
||||
*
|
||||
* @param conversationId - The conversation ID to recover
|
||||
* @returns reconnectKey - Key to force provider remount (resets connection state)
|
||||
* @returns handleDisconnect - Callback to trigger recovery on WebSocket disconnect
|
||||
*/
|
||||
export function useWebSocketRecovery(conversationId: string) {
|
||||
// Recovery state (refs to avoid re-renders)
|
||||
const recoveryAttemptsRef = React.useRef(0);
|
||||
const recoveryInProgressRef = React.useRef(false);
|
||||
const lastRecoveryAttemptRef = React.useRef<number | null>(null);
|
||||
|
||||
// Key to force remount of provider after recovery (resets connection state to "CONNECTING")
|
||||
const [reconnectKey, setReconnectKey] = React.useState(0);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate: resumeConversation } = useUnifiedResumeConversationSandbox();
|
||||
const { providers } = useUserProviders();
|
||||
const setErrorMessage = useErrorMessageStore(
|
||||
(state) => state.setErrorMessage,
|
||||
);
|
||||
|
||||
// Reset recovery state when conversation changes
|
||||
React.useEffect(() => {
|
||||
recoveryAttemptsRef.current = 0;
|
||||
recoveryInProgressRef.current = false;
|
||||
lastRecoveryAttemptRef.current = null;
|
||||
}, [conversationId]);
|
||||
|
||||
// Silent recovery callback - resumes sandbox when WebSocket disconnects
|
||||
const handleDisconnect = React.useCallback(() => {
|
||||
// Prevent concurrent recovery attempts
|
||||
if (recoveryInProgressRef.current) return;
|
||||
|
||||
// Check cooldown
|
||||
const now = Date.now();
|
||||
if (
|
||||
lastRecoveryAttemptRef.current &&
|
||||
now - lastRecoveryAttemptRef.current < RECOVERY_COOLDOWN_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check max attempts - notify user when recovery is exhausted
|
||||
if (recoveryAttemptsRef.current >= MAX_RECOVERY_ATTEMPTS) {
|
||||
setErrorMessage(I18nKey.STATUS$CONNECTION_LOST);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start silent recovery
|
||||
recoveryInProgressRef.current = true;
|
||||
lastRecoveryAttemptRef.current = now;
|
||||
recoveryAttemptsRef.current += 1;
|
||||
|
||||
resumeConversation(
|
||||
{ conversationId, providers },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
// Invalidate and wait for refetch to complete before remounting
|
||||
// This ensures the provider remounts with fresh data (url: null during startup)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversation", conversationId],
|
||||
});
|
||||
|
||||
// Force remount to reset connection state to "CONNECTING"
|
||||
setReconnectKey((k) => k + 1);
|
||||
|
||||
// Reset recovery state on success
|
||||
recoveryAttemptsRef.current = 0;
|
||||
recoveryInProgressRef.current = false;
|
||||
lastRecoveryAttemptRef.current = null;
|
||||
},
|
||||
onError: () => {
|
||||
// If this was the last attempt, show error to user
|
||||
if (recoveryAttemptsRef.current >= MAX_RECOVERY_ATTEMPTS) {
|
||||
setErrorMessage(I18nKey.STATUS$CONNECTION_LOST);
|
||||
}
|
||||
// recoveryInProgressRef will be reset by onSettled
|
||||
},
|
||||
onSettled: () => {
|
||||
// Allow next attempt after a delay (covers both success and error)
|
||||
setTimeout(() => {
|
||||
recoveryInProgressRef.current = false;
|
||||
}, RECOVERY_SETTLED_DELAY_MS);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [
|
||||
conversationId,
|
||||
providers,
|
||||
resumeConversation,
|
||||
queryClient,
|
||||
setErrorMessage,
|
||||
]);
|
||||
|
||||
return { reconnectKey, handleDisconnect };
|
||||
}
|
||||
@@ -175,12 +175,6 @@ export enum I18nKey {
|
||||
SETTINGS$MAX_BUDGET_PER_CONVERSATION = "SETTINGS$MAX_BUDGET_PER_CONVERSATION",
|
||||
SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS",
|
||||
SETTINGS$SOLVABILITY_ANALYSIS = "SETTINGS$SOLVABILITY_ANALYSIS",
|
||||
SETTINGS$SANDBOX_GROUPING_STRATEGY = "SETTINGS$SANDBOX_GROUPING_STRATEGY",
|
||||
SETTINGS$SANDBOX_GROUPING_NO_GROUPING = "SETTINGS$SANDBOX_GROUPING_NO_GROUPING",
|
||||
SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST = "SETTINGS$SANDBOX_GROUPING_GROUP_BY_NEWEST",
|
||||
SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED = "SETTINGS$SANDBOX_GROUPING_LEAST_RECENTLY_USED",
|
||||
SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS = "SETTINGS$SANDBOX_GROUPING_FEWEST_CONVERSATIONS",
|
||||
SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY = "SETTINGS$SANDBOX_GROUPING_ADD_TO_ANY",
|
||||
SETTINGS$SEARCH_API_KEY = "SETTINGS$SEARCH_API_KEY",
|
||||
SETTINGS$SEARCH_API_KEY_OPTIONAL = "SETTINGS$SEARCH_API_KEY_OPTIONAL",
|
||||
SETTINGS$SEARCH_API_KEY_INSTRUCTIONS = "SETTINGS$SEARCH_API_KEY_INSTRUCTIONS",
|
||||
@@ -533,8 +527,6 @@ export enum I18nKey {
|
||||
ACTION_MESSAGE$SYSTEM = "ACTION_MESSAGE$SYSTEM",
|
||||
ACTION_MESSAGE$CONDENSATION = "ACTION_MESSAGE$CONDENSATION",
|
||||
ACTION_MESSAGE$TASK_TRACKING = "ACTION_MESSAGE$TASK_TRACKING",
|
||||
ACTION_MESSAGE$GREP = "ACTION_MESSAGE$GREP",
|
||||
ACTION_MESSAGE$GLOB = "ACTION_MESSAGE$GLOB",
|
||||
OBSERVATION_MESSAGE$RUN = "OBSERVATION_MESSAGE$RUN",
|
||||
OBSERVATION_MESSAGE$RUN_IPYTHON = "OBSERVATION_MESSAGE$RUN_IPYTHON",
|
||||
OBSERVATION_MESSAGE$READ = "OBSERVATION_MESSAGE$READ",
|
||||
@@ -544,8 +536,6 @@ export enum I18nKey {
|
||||
OBSERVATION_MESSAGE$MCP = "OBSERVATION_MESSAGE$MCP",
|
||||
OBSERVATION_MESSAGE$RECALL = "OBSERVATION_MESSAGE$RECALL",
|
||||
OBSERVATION_MESSAGE$THINK = "OBSERVATION_MESSAGE$THINK",
|
||||
OBSERVATION_MESSAGE$GLOB = "OBSERVATION_MESSAGE$GLOB",
|
||||
OBSERVATION_MESSAGE$GREP = "OBSERVATION_MESSAGE$GREP",
|
||||
OBSERVATION_MESSAGE$TASK_TRACKING_PLAN = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN",
|
||||
OBSERVATION_MESSAGE$TASK_TRACKING_VIEW = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW",
|
||||
EXPANDABLE_MESSAGE$SHOW_DETAILS = "EXPANDABLE_MESSAGE$SHOW_DETAILS",
|
||||
@@ -685,8 +675,6 @@ export enum I18nKey {
|
||||
TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING",
|
||||
TIPS$CUSTOMIZE_MICROAGENT = "TIPS$CUSTOMIZE_MICROAGENT",
|
||||
CONVERSATION$NO_SKILLS = "CONVERSATION$NO_SKILLS",
|
||||
CONVERSATION$NO_HOOKS = "CONVERSATION$NO_HOOKS",
|
||||
CONVERSATION$SHOW_HOOKS = "CONVERSATION$SHOW_HOOKS",
|
||||
CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS",
|
||||
MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE",
|
||||
SKILLS_MODAL$WARNING = "SKILLS_MODAL$WARNING",
|
||||
@@ -703,7 +691,6 @@ export enum I18nKey {
|
||||
TIPS$HEADLESS_MODE = "TIPS$HEADLESS_MODE",
|
||||
TIPS$CLI_MODE = "TIPS$CLI_MODE",
|
||||
TIPS$GITHUB_HOOK = "TIPS$GITHUB_HOOK",
|
||||
TIPS$GITLAB_HOOK = "TIPS$GITLAB_HOOK",
|
||||
TIPS$BLOG_SIGNUP = "TIPS$BLOG_SIGNUP",
|
||||
TIPS$API_USAGE = "TIPS$API_USAGE",
|
||||
TIPS$LEARN_MORE = "TIPS$LEARN_MORE",
|
||||
@@ -1083,28 +1070,6 @@ export enum I18nKey {
|
||||
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
|
||||
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
|
||||
CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED",
|
||||
HOOKS_MODAL$TITLE = "HOOKS_MODAL$TITLE",
|
||||
HOOKS_MODAL$WARNING = "HOOKS_MODAL$WARNING",
|
||||
HOOKS_MODAL$MATCHER = "HOOKS_MODAL$MATCHER",
|
||||
HOOKS_MODAL$COMMANDS = "HOOKS_MODAL$COMMANDS",
|
||||
HOOKS_MODAL$HOOK_COUNT = "HOOKS_MODAL$HOOK_COUNT",
|
||||
HOOKS_MODAL$TYPE = "HOOKS_MODAL$TYPE",
|
||||
HOOKS_MODAL$TIMEOUT = "HOOKS_MODAL$TIMEOUT",
|
||||
HOOKS_MODAL$ASYNC = "HOOKS_MODAL$ASYNC",
|
||||
HOOKS_MODAL$EVENT_PRE_TOOL_USE = "HOOKS_MODAL$EVENT_PRE_TOOL_USE",
|
||||
HOOKS_MODAL$EVENT_POST_TOOL_USE = "HOOKS_MODAL$EVENT_POST_TOOL_USE",
|
||||
HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT = "HOOKS_MODAL$EVENT_USER_PROMPT_SUBMIT",
|
||||
HOOKS_MODAL$EVENT_SESSION_START = "HOOKS_MODAL$EVENT_SESSION_START",
|
||||
HOOKS_MODAL$EVENT_SESSION_END = "HOOKS_MODAL$EVENT_SESSION_END",
|
||||
HOOKS_MODAL$EVENT_STOP = "HOOKS_MODAL$EVENT_STOP",
|
||||
HOOK$HOOK_LABEL = "HOOK$HOOK_LABEL",
|
||||
HOOK$COMMAND = "HOOK$COMMAND",
|
||||
HOOK$EXIT_CODE = "HOOK$EXIT_CODE",
|
||||
HOOK$BLOCKED_REASON = "HOOK$BLOCKED_REASON",
|
||||
HOOK$CONTEXT = "HOOK$CONTEXT",
|
||||
HOOK$ERROR = "HOOK$ERROR",
|
||||
HOOK$OUTPUT = "HOOK$OUTPUT",
|
||||
HOOK$STDERR = "HOOK$STDERR",
|
||||
COMMON$TYPE_EMAIL_AND_PRESS_SPACE = "COMMON$TYPE_EMAIL_AND_PRESS_SPACE",
|
||||
ORG$INVITE_ORG_MEMBERS = "ORG$INVITE_ORG_MEMBERS",
|
||||
ORG$MANAGE_ORGANIZATION = "ORG$MANAGE_ORGANIZATION",
|
||||
@@ -1139,34 +1104,4 @@ export enum I18nKey {
|
||||
ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON",
|
||||
ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON",
|
||||
ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON",
|
||||
ENTERPRISE$SELF_HOSTED = "ENTERPRISE$SELF_HOSTED",
|
||||
ENTERPRISE$TITLE = "ENTERPRISE$TITLE",
|
||||
ENTERPRISE$DESCRIPTION = "ENTERPRISE$DESCRIPTION",
|
||||
ENTERPRISE$FEATURE_DATA_PRIVACY = "ENTERPRISE$FEATURE_DATA_PRIVACY",
|
||||
ENTERPRISE$FEATURE_DEPLOYMENT = "ENTERPRISE$FEATURE_DEPLOYMENT",
|
||||
ENTERPRISE$FEATURE_SSO = "ENTERPRISE$FEATURE_SSO",
|
||||
ENTERPRISE$FEATURE_SUPPORT = "ENTERPRISE$FEATURE_SUPPORT",
|
||||
ENTERPRISE$LEARN_MORE = "ENTERPRISE$LEARN_MORE",
|
||||
ENTERPRISE$LEARN_MORE_ARIA = "ENTERPRISE$LEARN_MORE_ARIA",
|
||||
DEVICE$SUCCESS_TITLE = "DEVICE$SUCCESS_TITLE",
|
||||
DEVICE$ERROR_TITLE = "DEVICE$ERROR_TITLE",
|
||||
DEVICE$SUCCESS_MESSAGE = "DEVICE$SUCCESS_MESSAGE",
|
||||
DEVICE$ERROR_FAILED = "DEVICE$ERROR_FAILED",
|
||||
DEVICE$ERROR_OCCURRED = "DEVICE$ERROR_OCCURRED",
|
||||
DEVICE$TRY_AGAIN = "DEVICE$TRY_AGAIN",
|
||||
DEVICE$PROCESSING = "DEVICE$PROCESSING",
|
||||
DEVICE$AUTHORIZATION_REQUEST = "DEVICE$AUTHORIZATION_REQUEST",
|
||||
DEVICE$CODE_LABEL = "DEVICE$CODE_LABEL",
|
||||
DEVICE$SECURITY_NOTICE = "DEVICE$SECURITY_NOTICE",
|
||||
DEVICE$SECURITY_WARNING = "DEVICE$SECURITY_WARNING",
|
||||
DEVICE$CONFIRM_PROMPT = "DEVICE$CONFIRM_PROMPT",
|
||||
DEVICE$CANCEL = "DEVICE$CANCEL",
|
||||
DEVICE$AUTHORIZE = "DEVICE$AUTHORIZE",
|
||||
DEVICE$AUTHORIZATION_TITLE = "DEVICE$AUTHORIZATION_TITLE",
|
||||
DEVICE$ENTER_CODE_PROMPT = "DEVICE$ENTER_CODE_PROMPT",
|
||||
DEVICE$CODE_INPUT_LABEL = "DEVICE$CODE_INPUT_LABEL",
|
||||
DEVICE$CODE_PLACEHOLDER = "DEVICE$CODE_PLACEHOLDER",
|
||||
DEVICE$CONTINUE = "DEVICE$CONTINUE",
|
||||
DEVICE$AUTH_REQUIRED = "DEVICE$AUTH_REQUIRED",
|
||||
DEVICE$SIGN_IN_PROMPT = "DEVICE$SIGN_IN_PROMPT",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 279 B |
@@ -8,7 +8,6 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LanguageInput } from "#/components/features/settings/app-settings/language-input";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
@@ -20,11 +19,6 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"
|
||||
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
|
||||
import {
|
||||
SandboxGroupingStrategy,
|
||||
SandboxGroupingStrategyOptions,
|
||||
} from "#/types/settings";
|
||||
import { ENABLE_SANDBOX_GROUPING } from "#/utils/feature-flags";
|
||||
import { createPermissionGuard } from "#/utils/org/permission-guard";
|
||||
|
||||
export const clientLoader = createPermissionGuard(
|
||||
@@ -55,12 +49,6 @@ function AppSettingsScreen() {
|
||||
solvabilityAnalysisSwitchHasChanged,
|
||||
setSolvabilityAnalysisSwitchHasChanged,
|
||||
] = React.useState(false);
|
||||
const [
|
||||
sandboxGroupingStrategyHasChanged,
|
||||
setSandboxGroupingStrategyHasChanged,
|
||||
] = React.useState(false);
|
||||
const [selectedSandboxGroupingStrategy, setSelectedSandboxGroupingStrategy] =
|
||||
React.useState<SandboxGroupingStrategy | null>(null);
|
||||
const [maxBudgetPerTaskHasChanged, setMaxBudgetPerTaskHasChanged] =
|
||||
React.useState(false);
|
||||
const [gitUserNameHasChanged, setGitUserNameHasChanged] =
|
||||
@@ -87,11 +75,6 @@ function AppSettingsScreen() {
|
||||
const enableSolvabilityAnalysis =
|
||||
formData.get("enable-solvability-analysis-switch")?.toString() === "on";
|
||||
|
||||
const sandboxGroupingStrategy =
|
||||
selectedSandboxGroupingStrategy ||
|
||||
settings?.sandbox_grouping_strategy ||
|
||||
DEFAULT_SETTINGS.sandbox_grouping_strategy;
|
||||
|
||||
const maxBudgetPerTaskValue = formData
|
||||
.get("max-budget-per-task-input")
|
||||
?.toString();
|
||||
@@ -111,7 +94,6 @@ function AppSettingsScreen() {
|
||||
enable_sound_notifications: enableSoundNotifications,
|
||||
enable_proactive_conversation_starters: enableProactiveConversations,
|
||||
enable_solvability_analysis: enableSolvabilityAnalysis,
|
||||
sandbox_grouping_strategy: sandboxGroupingStrategy,
|
||||
max_budget_per_task: maxBudgetPerTask,
|
||||
git_user_name: gitUserName,
|
||||
git_user_email: gitUserEmail,
|
||||
@@ -130,8 +112,6 @@ function AppSettingsScreen() {
|
||||
setAnalyticsSwitchHasChanged(false);
|
||||
setSoundNotificationsSwitchHasChanged(false);
|
||||
setProactiveConversationsSwitchHasChanged(false);
|
||||
setSandboxGroupingStrategyHasChanged(false);
|
||||
setSelectedSandboxGroupingStrategy(null);
|
||||
setMaxBudgetPerTaskHasChanged(false);
|
||||
setGitUserNameHasChanged(false);
|
||||
setGitUserEmailHasChanged(false);
|
||||
@@ -179,15 +159,6 @@ function AppSettingsScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSandboxGroupingStrategyChange = (key: React.Key | null) => {
|
||||
const newStrategy = key?.toString() as SandboxGroupingStrategy | undefined;
|
||||
setSelectedSandboxGroupingStrategy(newStrategy || null);
|
||||
const currentStrategy =
|
||||
settings?.sandbox_grouping_strategy ||
|
||||
DEFAULT_SETTINGS.sandbox_grouping_strategy;
|
||||
setSandboxGroupingStrategyHasChanged(newStrategy !== currentStrategy);
|
||||
};
|
||||
|
||||
const checkIfMaxBudgetPerTaskHasChanged = (value: string) => {
|
||||
const newValue = parseMaxBudgetPerTask(value);
|
||||
const currentValue = settings?.max_budget_per_task;
|
||||
@@ -210,7 +181,6 @@ function AppSettingsScreen() {
|
||||
!soundNotificationsSwitchHasChanged &&
|
||||
!proactiveConversationsSwitchHasChanged &&
|
||||
!solvabilityAnalysisSwitchHasChanged &&
|
||||
!sandboxGroupingStrategyHasChanged &&
|
||||
!maxBudgetPerTaskHasChanged &&
|
||||
!gitUserNameHasChanged &&
|
||||
!gitUserEmailHasChanged;
|
||||
@@ -274,26 +244,6 @@ function AppSettingsScreen() {
|
||||
</SettingsSwitch>
|
||||
)}
|
||||
|
||||
{ENABLE_SANDBOX_GROUPING() && (
|
||||
<SettingsDropdownInput
|
||||
testId="sandbox-grouping-strategy-input"
|
||||
name="sandbox-grouping-strategy-input"
|
||||
label={t(I18nKey.SETTINGS$SANDBOX_GROUPING_STRATEGY)}
|
||||
items={Object.keys(SandboxGroupingStrategyOptions).map((key) => ({
|
||||
key,
|
||||
label: t(`SETTINGS$SANDBOX_GROUPING_${key}` as I18nKey),
|
||||
}))}
|
||||
selectedKey={
|
||||
selectedSandboxGroupingStrategy ||
|
||||
settings.sandbox_grouping_strategy ||
|
||||
DEFAULT_SETTINGS.sandbox_grouping_strategy
|
||||
}
|
||||
isClearable={false}
|
||||
onSelectionChange={handleSandboxGroupingStrategyChange}
|
||||
wrapperClassName="w-full max-w-[680px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!settings?.v1_enabled && (
|
||||
<SettingsInput
|
||||
testId="max-budget-per-task-input"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
|
||||
import { ConversationNameWithStatus } from "#/components/features/conversation/conversation-name-with-status";
|
||||
@@ -25,6 +26,7 @@ import { ConversationNameWithStatus } from "#/components/features/conversation/c
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
@@ -37,8 +39,11 @@ function AppContent() {
|
||||
// Handle both task IDs (task-{uuid}) and regular conversation IDs
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
|
||||
const { data: conversation, isFetched } = useActiveConversation();
|
||||
const { data: conversation, isFetched, refetch } = useActiveConversation();
|
||||
const { mutate: startConversation, isPending: isStarting } =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
const { resetConversationState } = useConversationStore();
|
||||
const navigate = useNavigate();
|
||||
const clearTerminal = useCommandStore((state) => state.clearTerminal);
|
||||
@@ -49,6 +54,9 @@ function AppContent() {
|
||||
(state) => state.removeErrorMessage,
|
||||
);
|
||||
|
||||
// Track which conversation ID we've auto-started to prevent auto-restart after manual stop
|
||||
const processedConversationId = React.useRef<string | null>(null);
|
||||
|
||||
// Fetch batch feedback data when conversation is loaded
|
||||
useBatchFeedback();
|
||||
|
||||
@@ -59,6 +67,12 @@ function AppContent() {
|
||||
setCurrentAgentState(AgentState.LOADING);
|
||||
removeErrorMessage();
|
||||
clearEvents();
|
||||
|
||||
// Reset tracking ONLY if we're navigating to a DIFFERENT conversation
|
||||
// Don't reset on StrictMode remounts (conversationId is the same)
|
||||
if (processedConversationId.current !== conversationId) {
|
||||
processedConversationId.current = null;
|
||||
}
|
||||
}, [
|
||||
conversationId,
|
||||
clearTerminal,
|
||||
@@ -77,8 +91,7 @@ function AppContent() {
|
||||
}
|
||||
}, [isTask, taskStatus, taskDetail, t]);
|
||||
|
||||
// 3. Handle conversation not found
|
||||
// NOTE: Resuming STOPPED conversations is handled by useSandboxRecovery in WebSocketProviderWrapper
|
||||
// 3. Auto-start Effect - handles conversation not found and auto-starting STOPPED conversations
|
||||
React.useEffect(() => {
|
||||
// Wait for data to be fetched
|
||||
if (!isFetched || !isAuthed) return;
|
||||
@@ -87,8 +100,50 @@ function AppContent() {
|
||||
if (!conversation) {
|
||||
displayErrorToast(t(I18nKey.CONVERSATION$NOT_EXIST_OR_NO_PERMISSION));
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
}, [conversation, isFetched, isAuthed, navigate, t]);
|
||||
|
||||
const currentConversationId = conversation.conversation_id;
|
||||
const currentStatus = conversation.status;
|
||||
|
||||
// Skip if we've already processed this conversation
|
||||
if (processedConversationId.current === currentConversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as processed immediately to prevent duplicate calls
|
||||
processedConversationId.current = currentConversationId;
|
||||
|
||||
// Auto-start STOPPED conversations on initial load only
|
||||
if (currentStatus === "STOPPED" && !isStarting) {
|
||||
startConversation(
|
||||
{ conversationId: currentConversationId, providers },
|
||||
{
|
||||
onError: (error) => {
|
||||
displayErrorToast(
|
||||
t(I18nKey.CONVERSATION$FAILED_TO_START_WITH_ERROR, {
|
||||
error: error.message,
|
||||
}),
|
||||
);
|
||||
refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
// NOTE: conversation?.status is intentionally NOT in dependencies
|
||||
// We only want to run when conversation ID changes, not when status changes
|
||||
// This prevents duplicate calls when stale cache data is replaced with fresh data
|
||||
}, [
|
||||
conversation?.conversation_id,
|
||||
isFetched,
|
||||
isAuthed,
|
||||
isStarting,
|
||||
providers,
|
||||
startConversation,
|
||||
navigate,
|
||||
refetch,
|
||||
t,
|
||||
]);
|
||||
|
||||
const isV0Conversation = conversation?.conversation_version === "V0";
|
||||
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import React, { useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { EnterpriseBanner } from "#/components/features/device-verify/enterprise-banner";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { H1 } from "#/ui/typography";
|
||||
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
|
||||
|
||||
export default function DeviceVerify() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: isAuthed, isLoading: isAuthLoading } = useIsAuthed();
|
||||
const [verificationResult, setVerificationResult] = useState<{
|
||||
success: boolean;
|
||||
messageKey: I18nKey;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const showEnterpriseBanner = PROJ_USER_JOURNEY();
|
||||
|
||||
// Get user_code from URL parameters
|
||||
const userCode = searchParams.get("user_code");
|
||||
@@ -39,18 +33,21 @@ export default function DeviceVerify() {
|
||||
// Show success message
|
||||
setVerificationResult({
|
||||
success: true,
|
||||
messageKey: I18nKey.DEVICE$SUCCESS_MESSAGE,
|
||||
message:
|
||||
"Device authorized successfully! You can now return to your CLI and close this window.",
|
||||
});
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
setVerificationResult({
|
||||
success: false,
|
||||
messageKey: I18nKey.DEVICE$ERROR_FAILED,
|
||||
message: errorText || "Failed to authorize device. Please try again.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setVerificationResult({
|
||||
success: false,
|
||||
messageKey: I18nKey.DEVICE$ERROR_OCCURRED,
|
||||
message:
|
||||
"An error occurred while authorizing the device. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
@@ -108,12 +105,10 @@ export default function DeviceVerify() {
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{verificationResult.success
|
||||
? t(I18nKey.DEVICE$SUCCESS_TITLE)
|
||||
: t(I18nKey.DEVICE$ERROR_TITLE)}
|
||||
{verificationResult.success ? "Success!" : "Error"}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t(verificationResult.messageKey)}
|
||||
{verificationResult.message}
|
||||
</p>
|
||||
{!verificationResult.success && (
|
||||
<button
|
||||
@@ -121,7 +116,7 @@ export default function DeviceVerify() {
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
{t(I18nKey.DEVICE$TRY_AGAIN)}
|
||||
Try Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -138,7 +133,7 @@ export default function DeviceVerify() {
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{t(I18nKey.DEVICE$PROCESSING)}
|
||||
Processing device verification...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,56 +144,63 @@ export default function DeviceVerify() {
|
||||
// Show device authorization confirmation if user is authenticated and code is provided
|
||||
if (isAuthed && userCode) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||
<div
|
||||
className={`flex flex-col lg:flex-row items-center lg:items-start gap-6 w-full ${showEnterpriseBanner ? "max-w-4xl" : "max-w-md"}`}
|
||||
>
|
||||
{/* Device Authorization Card */}
|
||||
<div
|
||||
className={`flex-1 min-w-0 max-w-md w-full mx-auto p-6 bg-card rounded-lg shadow-lg border border-neutral-700 ${showEnterpriseBanner ? "lg:mx-0" : ""}`}
|
||||
>
|
||||
<H1 className="text-2xl mb-4 text-center">
|
||||
{t(I18nKey.DEVICE$AUTHORIZATION_REQUEST)}
|
||||
</H1>
|
||||
<div className="mb-6 p-4 bg-neutral-900 rounded-lg border border-neutral-700">
|
||||
<p className="text-xs text-neutral-500 mb-2 text-center uppercase tracking-wider">
|
||||
{t(I18nKey.DEVICE$CODE_LABEL)}
|
||||
</p>
|
||||
<p className="text-xl font-mono font-semibold text-center tracking-[0.3em]">
|
||||
{userCode}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 p-4 bg-amber-950/50 border-l-2 border-amber-500 rounded-r-lg">
|
||||
<p className="text-sm font-medium text-amber-500 mb-1">
|
||||
{t(I18nKey.DEVICE$SECURITY_NOTICE)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t(I18nKey.DEVICE$SECURITY_WARNING)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-6 text-center">
|
||||
{t(I18nKey.DEVICE$CONFIRM_PROMPT)}
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="max-w-md w-full mx-auto p-6 bg-card rounded-lg shadow-lg">
|
||||
<h1 className="text-2xl font-bold mb-4 text-center">
|
||||
Device Authorization Request
|
||||
</h1>
|
||||
<div className="mb-6 p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground mb-2">Device Code:</p>
|
||||
<p className="text-lg font-mono font-semibold text-center tracking-wider">
|
||||
{userCode}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="flex-1 px-4 py-2 border border-neutral-600 rounded-md hover:bg-muted text-gray-300"
|
||||
</div>
|
||||
<div className="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-start">
|
||||
<svg
|
||||
className="w-5 h-5 text-yellow-600 mt-0.5 mr-2 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{t(I18nKey.DEVICE$CANCEL)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => processDeviceVerification(userCode)}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
{t(I18nKey.DEVICE$AUTHORIZE)}
|
||||
</button>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-yellow-800 mb-1">
|
||||
Security Notice
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700">
|
||||
Only authorize this device if you initiated this request from
|
||||
your CLI or application.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enterprise Banner */}
|
||||
{showEnterpriseBanner && <EnterpriseBanner />}
|
||||
<p className="text-muted-foreground mb-6 text-center">
|
||||
Do you want to authorize this device to access your OpenHands
|
||||
account?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="flex-1 px-4 py-2 border border-input rounded-md hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => processDeviceVerification(userCode)}
|
||||
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Authorize Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -209,11 +211,11 @@ export default function DeviceVerify() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="max-w-md w-full mx-auto p-6 bg-card rounded-lg shadow-lg">
|
||||
<H1 className="text-2xl mb-4 text-center">
|
||||
{t(I18nKey.DEVICE$AUTHORIZATION_TITLE)}
|
||||
</H1>
|
||||
<h1 className="text-2xl font-bold mb-4 text-center">
|
||||
Device Authorization
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-6 text-center">
|
||||
{t(I18nKey.DEVICE$ENTER_CODE_PROMPT)}
|
||||
Enter the code displayed on your device:
|
||||
</p>
|
||||
<form onSubmit={handleManualSubmit}>
|
||||
<div className="mb-4">
|
||||
@@ -221,7 +223,7 @@ export default function DeviceVerify() {
|
||||
htmlFor="user_code"
|
||||
className="block text-sm font-medium mb-2"
|
||||
>
|
||||
{t(I18nKey.DEVICE$CODE_INPUT_LABEL)}
|
||||
Device Code:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -229,14 +231,14 @@ export default function DeviceVerify() {
|
||||
name="user_code"
|
||||
required
|
||||
className="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder={t(I18nKey.DEVICE$CODE_PLACEHOLDER)}
|
||||
placeholder="Enter your device code"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
|
||||
>
|
||||
{t(I18nKey.DEVICE$CONTINUE)}
|
||||
Continue
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -251,7 +253,7 @@ export default function DeviceVerify() {
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
{t(I18nKey.DEVICE$PROCESSING)}
|
||||
Processing device verification...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -262,9 +264,9 @@ export default function DeviceVerify() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="max-w-md w-full mx-auto p-6 bg-card rounded-lg shadow-lg text-center">
|
||||
<H1 className="text-2xl mb-4">{t(I18nKey.DEVICE$AUTH_REQUIRED)}</H1>
|
||||
<h1 className="text-2xl font-bold mb-4">Authentication Required</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{t(I18nKey.DEVICE$SIGN_IN_PROMPT)}
|
||||
Please sign in to authorize your device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
git_user_name: "openhands",
|
||||
git_user_email: "openhands@all-hands.dev",
|
||||
v1_enabled: false,
|
||||
sandbox_grouping_strategy: "NO_GROUPING",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,7 +21,7 @@ export type OpenHandsEventType =
|
||||
| "task_tracking"
|
||||
| "user_rejected";
|
||||
|
||||
export type OpenHandsSourceType = "agent" | "user" | "environment" | "hook";
|
||||
export type OpenHandsSourceType = "agent" | "user" | "environment";
|
||||
|
||||
interface OpenHandsBaseEvent {
|
||||
id: number;
|
||||
|
||||
@@ -8,17 +8,6 @@ export const ProviderOptions = {
|
||||
enterprise_sso: "enterprise_sso",
|
||||
} as const;
|
||||
|
||||
export const SandboxGroupingStrategyOptions = {
|
||||
NO_GROUPING: "NO_GROUPING",
|
||||
GROUP_BY_NEWEST: "GROUP_BY_NEWEST",
|
||||
LEAST_RECENTLY_USED: "LEAST_RECENTLY_USED",
|
||||
FEWEST_CONVERSATIONS: "FEWEST_CONVERSATIONS",
|
||||
ADD_TO_ANY: "ADD_TO_ANY",
|
||||
} as const;
|
||||
|
||||
export type SandboxGroupingStrategy =
|
||||
keyof typeof SandboxGroupingStrategyOptions;
|
||||
|
||||
export type Provider = keyof typeof ProviderOptions;
|
||||
|
||||
export type ProviderToken = {
|
||||
@@ -78,5 +67,4 @@ export type Settings = {
|
||||
git_user_name?: string;
|
||||
git_user_email?: string;
|
||||
v1_enabled?: boolean;
|
||||
sandbox_grouping_strategy?: SandboxGroupingStrategy;
|
||||
};
|
||||
|
||||
@@ -244,32 +244,6 @@ export interface PlanningFileEditorAction extends ActionBase<"PlanningFileEditor
|
||||
view_range: [number, number] | null;
|
||||
}
|
||||
|
||||
export interface GlobAction extends ActionBase<"GlobAction"> {
|
||||
/**
|
||||
* The glob pattern to match files against.
|
||||
*/
|
||||
pattern: string;
|
||||
/**
|
||||
* The directory to search in.
|
||||
*/
|
||||
path: string | null;
|
||||
}
|
||||
|
||||
export interface GrepAction extends ActionBase<"GrepAction"> {
|
||||
/**
|
||||
* The regex pattern to search for in file contents.
|
||||
*/
|
||||
pattern: string;
|
||||
/**
|
||||
* The file or directory to search in.
|
||||
*/
|
||||
path: string | null;
|
||||
/**
|
||||
* Glob pattern to filter files.
|
||||
*/
|
||||
include: string | null;
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| MCPToolAction
|
||||
| FinishAction
|
||||
@@ -289,6 +263,4 @@ export type Action =
|
||||
| BrowserGoBackAction
|
||||
| BrowserListTabsAction
|
||||
| BrowserSwitchTabAction
|
||||
| BrowserCloseTabAction
|
||||
| GlobAction
|
||||
| GrepAction;
|
||||
| BrowserCloseTabAction;
|
||||
|
||||
@@ -23,17 +23,11 @@ type ActionOnlyType =
|
||||
|
||||
type ObservationOnlyType = "Browser";
|
||||
|
||||
type ActionEventType =
|
||||
| `${ActionOnlyType}Action`
|
||||
| `${EventType}Action`
|
||||
| "GlobAction"
|
||||
| "GrepAction";
|
||||
type ActionEventType = `${ActionOnlyType}Action` | `${EventType}Action`;
|
||||
type ObservationEventType =
|
||||
| `${ObservationOnlyType}Observation`
|
||||
| `${EventType}Observation`
|
||||
| "TerminalObservation"
|
||||
| "GlobObservation"
|
||||
| "GrepObservation";
|
||||
| "TerminalObservation";
|
||||
|
||||
export interface ActionBase<T extends ActionEventType = ActionEventType> {
|
||||
kind: T;
|
||||
|
||||
@@ -53,7 +53,7 @@ export type EventID = string;
|
||||
export type ToolCallID = string;
|
||||
|
||||
// Source type for events
|
||||
export type SourceType = "agent" | "user" | "environment" | "hook";
|
||||
export type SourceType = "agent" | "user" | "environment";
|
||||
|
||||
// Security risk levels
|
||||
export enum SecurityRisk {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user