mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
58 Commits
0.59.0
...
openhands/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4068f56481 | ||
|
|
9d19292619 | ||
|
|
fc9a87550d | ||
|
|
490d3dba10 | ||
|
|
5ed1dde2e9 | ||
|
|
a68576b876 | ||
|
|
722124ae83 | ||
|
|
44578664ed | ||
|
|
9efe6eb776 | ||
|
|
6d137e883f | ||
|
|
2889f736d9 | ||
|
|
531683abae | ||
|
|
fab64a51b7 | ||
|
|
cc18a18874 | ||
|
|
7525a95af0 | ||
|
|
640f50d525 | ||
|
|
6f2f85073d | ||
|
|
9f3b2425ec | ||
|
|
1ebc3ab04e | ||
|
|
9bd0566e4e | ||
|
|
d82972e126 | ||
|
|
e1b94732a8 | ||
|
|
5219f85bfa | ||
|
|
a237b578c0 | ||
|
|
f42a4f75cb | ||
|
|
3e645f8649 | ||
|
|
5182388323 | ||
|
|
471d272c7c | ||
|
|
0522734875 | ||
|
|
f4fd8ea907 | ||
|
|
e9413aaded | ||
|
|
ef004962cc | ||
|
|
58d67a2480 | ||
|
|
72179f45d3 | ||
|
|
15e7709ff6 | ||
|
|
bb563d6dd1 | ||
|
|
d991b9880d | ||
|
|
fe82cfd277 | ||
|
|
16fa8ea7be | ||
|
|
f292f3a84d | ||
|
|
5076f21e86 | ||
|
|
2640d43159 | ||
|
|
609fefc1b6 | ||
|
|
5db0d495d4 | ||
|
|
60fa7b3d01 | ||
|
|
cca2a55166 | ||
|
|
c5e58572d5 | ||
|
|
baaa41ed99 | ||
|
|
19bae5ac0f | ||
|
|
93e1cd44c6 | ||
|
|
c0ce78c64a | ||
|
|
399bf92ed1 | ||
|
|
2bbe15a329 | ||
|
|
6f22092d07 | ||
|
|
c034cc5dfb | ||
|
|
9bd02440b0 | ||
|
|
c9d8782566 | ||
|
|
ef49994700 |
33
.github/pull_request_template.md
vendored
33
.github/pull_request_template.md
vendored
@@ -1,12 +1,31 @@
|
||||
- [ ] This change is worth documenting at https://docs.all-hands.dev/
|
||||
- [ ] Include this change in the Release Notes. If checked, you **must** provide an **end-user friendly** description for your change below
|
||||
## Summary of PR
|
||||
|
||||
**End-user friendly description of the problem this fixes or functionality this introduces.**
|
||||
<!-- Summarize what the PR does, explaining any non-trivial design decisions. -->
|
||||
|
||||
## Change Type
|
||||
|
||||
---
|
||||
**Summarize what the PR does, explaining any non-trivial design decisions.**
|
||||
<!-- Choose the types that apply to your PR and remove the rest. -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Refactor
|
||||
- [ ] Other (dependency update, docs, typo fixes, etc.)
|
||||
|
||||
---
|
||||
**Link of any specific issues this addresses:**
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read and reviewed the code and I understand what the code is doing.
|
||||
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
|
||||
|
||||
## Fixes
|
||||
|
||||
<!-- If this resolves an issue, link it here so it will close automatically upon merge. -->
|
||||
|
||||
Resolves #(issue)
|
||||
|
||||
## Release Notes
|
||||
|
||||
<!-- Check the box if this change is worth adding to the release notes. If checked, you must provide an
|
||||
end-user friendly description for your change below the checkbox. -->
|
||||
|
||||
- [ ] Include this change in the Release Notes.
|
||||
|
||||
@@ -12,17 +12,28 @@ on:
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create releases or upload assets
|
||||
|
||||
# Cancel previous runs if a new commit is pushed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test-binary:
|
||||
name: Build and test binary executable
|
||||
build-binary:
|
||||
name: Build binary executable
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
include:
|
||||
# Build on Ubuntu 22.04 for maximum GLIBC compatibility (GLIBC 2.31)
|
||||
- os: ubuntu-22.04
|
||||
platform: linux
|
||||
artifact_name: openhands-cli-linux
|
||||
# Build on macOS for macOS users
|
||||
- os: macos-15
|
||||
platform: macos
|
||||
artifact_name: openhands-cli-macos
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
@@ -60,18 +71,17 @@ jobs:
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
|
||||
- name: Upload binary artifact (for releases only)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openhands-cli-${{ matrix.os }}
|
||||
name: ${{ matrix.artifact_name }}
|
||||
path: openhands-cli/dist/openhands*
|
||||
retention-days: 30
|
||||
|
||||
create-github-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-test-binary
|
||||
needs: build-binary
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -85,12 +95,12 @@ jobs:
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
# Rename binaries to include OS in filename
|
||||
if [ -f artifacts/openhands-cli-ubuntu-latest/openhands ]; then
|
||||
cp artifacts/openhands-cli-ubuntu-latest/openhands release-assets/openhands-linux
|
||||
# Copy binaries with appropriate names for release
|
||||
if [ -f artifacts/openhands-cli-linux/openhands ]; then
|
||||
cp artifacts/openhands-cli-linux/openhands release-assets/openhands-linux
|
||||
fi
|
||||
if [ -f artifacts/openhands-cli-macos-latest/openhands ]; then
|
||||
cp artifacts/openhands-cli-macos-latest/openhands release-assets/openhands-macos
|
||||
if [ -f artifacts/openhands-cli-macos/openhands ]; then
|
||||
cp artifacts/openhands-cli-macos/openhands release-assets/openhands-macos
|
||||
fi
|
||||
ls -la release-assets/
|
||||
|
||||
@@ -101,4 +111,4 @@ jobs:
|
||||
draft: true
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.CLI_RELEASE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
107
.github/workflows/cli-build-test.yml
vendored
107
.github/workflows/cli-build-test.yml
vendored
@@ -1,107 +0,0 @@
|
||||
# Workflow that builds and tests the CLI binary executable
|
||||
name: CLI - Build and Test Binary
|
||||
|
||||
# Run on pushes to main branch and CLI tags, and on pull requests when CLI files change
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*-cli"
|
||||
pull_request:
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
|
||||
permissions:
|
||||
contents: write # needed to create releases or upload assets
|
||||
|
||||
# Cancel previous runs if a new commit is pushed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test-binary:
|
||||
name: Build and test binary executable
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
uv sync
|
||||
|
||||
- name: Build binary executable
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
./build.sh --install-pyinstaller | tee output.log
|
||||
echo "Full output:"
|
||||
cat output.log
|
||||
|
||||
if grep -q "❌" output.log; then
|
||||
echo "❌ Found failure marker in output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
|
||||
- name: Upload binary artifact (for releases only)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: openhands-cli-${{ matrix.os }}
|
||||
path: openhands-cli/dist/openhands*
|
||||
retention-days: 30
|
||||
|
||||
create-github-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-test-binary
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
# Rename binaries to include OS in filename
|
||||
if [ -f artifacts/openhands-cli-ubuntu-latest/openhands ]; then
|
||||
cp artifacts/openhands-cli-ubuntu-latest/openhands release-assets/openhands-linux
|
||||
fi
|
||||
if [ -f artifacts/openhands-cli-macos-latest/openhands ]; then
|
||||
cp artifacts/openhands-cli-macos-latest/openhands release-assets/openhands-macos
|
||||
fi
|
||||
ls -la release-assets/
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-assets/*
|
||||
draft: true
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
52
.github/workflows/enterprise-check-migrations.yml
vendored
Normal file
52
.github/workflows/enterprise-check-migrations.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Enterprise Check Migrations
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'enterprise/migrations/**'
|
||||
|
||||
jobs:
|
||||
check-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
- name: Fetch base branch
|
||||
run: git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Check if base branch is ancestor of PR
|
||||
id: check_up_to_date
|
||||
shell: bash
|
||||
run: |
|
||||
BASE="origin/${{ github.event.pull_request.base.ref }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
if git merge-base --is-ancestor "$BASE" "$HEAD"; then
|
||||
echo "We're up to date with base $BASE"
|
||||
exit 0
|
||||
else
|
||||
echo "NOT up to date with base $BASE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: |
|
||||
⚠️ This PR contains **migrations**
|
||||
|
||||
- name: Comment warning on PR
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
⚠️ This PR contains **migrations**. Please synchronize before merging to prevent conflicts.
|
||||
2
.github/workflows/ghcr-build.yml
vendored
2
.github/workflows/ghcr-build.yml
vendored
@@ -126,7 +126,7 @@ jobs:
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
|
||||
- name: Create source distribution and Dockerfile
|
||||
run: poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
|
||||
run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
|
||||
- name: Lowercase Repository Owner
|
||||
run: |
|
||||
echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
run: pip install pre-commit==4.2.0
|
||||
- name: Run pre-commit hooks
|
||||
working-directory: ./enterprise
|
||||
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
|
||||
run: pre-commit run --all-files --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
lint-cli-python:
|
||||
name: Lint CLI python
|
||||
|
||||
23
.github/workflows/pypi-release.yml
vendored
23
.github/workflows/pypi-release.yml
vendored
@@ -1,14 +1,17 @@
|
||||
# Publishes the OpenHands PyPi package
|
||||
name: Publish PyPi Package
|
||||
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Reason for manual trigger'
|
||||
description: "What are you publishing?"
|
||||
required: true
|
||||
default: ''
|
||||
type: choice
|
||||
options:
|
||||
- app server
|
||||
- cli
|
||||
default: app server
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
@@ -16,8 +19,10 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
# Only run for tags that don't contain '-cli'
|
||||
if: startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli')
|
||||
# Run when manually dispatched for "app server" OR for tag pushes that don't contain '-cli'
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'app server')
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli'))
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: useblacksmith/setup-python@v6
|
||||
@@ -38,8 +43,10 @@ jobs:
|
||||
release-cli:
|
||||
name: Publish CLI to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
# Only run for tags that contain '-cli'
|
||||
if: startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-cli')
|
||||
# Run when manually dispatched for "cli" OR for tag pushes that contain '-cli'
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.reason == 'cli')
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && contains(github.ref, '-cli'))
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -64,4 +71,4 @@ jobs:
|
||||
- name: Publish CLI to PyPI
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
uv publish --token ${{ secrets.PYPI_TOKEN }}
|
||||
uv publish --token ${{ secrets.PYPI_TOKEN_OPENHANDS }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<a name="readme-top"></a>
|
||||
|
||||
<div align="center">
|
||||
<img src="./docs/static/img/logo.png" alt="Logo" width="200">
|
||||
<img src="https://raw.githubusercontent.com/All-Hands-AI/docs/main/openhands/static/img/logo.png" alt="Logo" width="200">
|
||||
<h1 align="center">OpenHands: Code Less, Make More</h1>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,12 @@ call APIs, and yes—even copy code snippets from StackOverflow.
|
||||
|
||||
Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for OpenHands Cloud](https://app.all-hands.dev) to get started.
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Upcoming change**: We are renaming our GitHub Org from `All-Hands-AI` to `OpenHands` on October 20th, 2025.
|
||||
> Check the [tracking issue](https://github.com/All-Hands-AI/OpenHands/issues/11376) for more information.
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Using OpenHands for work? We'd love to chat! Fill out
|
||||
> [this short form](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
|
||||
|
||||
@@ -6,7 +6,7 @@ that depends on the `base_image` **AND** a [Python source distribution](https://
|
||||
The following command will generate a `Dockerfile` file for `nikolaik/python-nodejs:python3.12-nodejs22` (the default base image), an updated `config.sh` and the runtime source distribution files/folders into `containers/runtime`:
|
||||
|
||||
```bash
|
||||
poetry run python3 openhands/runtime/utils/runtime_build.py \
|
||||
poetry run python3 -m openhands.runtime.utils.runtime_build \
|
||||
--base_image nikolaik/python-nodejs:python3.12-nodejs22 \
|
||||
--build_folder containers/runtime
|
||||
```
|
||||
|
||||
@@ -1,18 +1,47 @@
|
||||
from uuid import UUID
|
||||
|
||||
from experiments.constants import (
|
||||
ENABLE_EXPERIMENT_MANAGER,
|
||||
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
)
|
||||
from experiments.experiment_versions import (
|
||||
handle_condenser_max_step_experiment,
|
||||
handle_system_prompt_experiment,
|
||||
)
|
||||
from experiments.experiment_versions._004_condenser_max_step_experiment import (
|
||||
handle_condenser_max_step_experiment__v1,
|
||||
)
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.experiments.experiment_manager import ExperimentManager
|
||||
from openhands.sdk import Agent
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
class SaaSExperimentManager(ExperimentManager):
|
||||
@staticmethod
|
||||
def run_agent_variant_tests__v1(
|
||||
user_id: str | None, conversation_id: UUID, agent: Agent
|
||||
) -> Agent:
|
||||
if not ENABLE_EXPERIMENT_MANAGER:
|
||||
logger.info(
|
||||
'experiment_manager:run_conversation_variant_test:skipped',
|
||||
extra={'reason': 'experiment_manager_disabled'},
|
||||
)
|
||||
return agent
|
||||
|
||||
agent = handle_condenser_max_step_experiment__v1(
|
||||
user_id, conversation_id, agent
|
||||
)
|
||||
|
||||
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
|
||||
agent = agent.model_copy(
|
||||
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
@staticmethod
|
||||
def run_conversation_variant_test(
|
||||
user_id, conversation_id, conversation_settings
|
||||
|
||||
@@ -5,12 +5,18 @@ This module contains the handler for the condenser max step experiment that test
|
||||
different max_size values for the condenser configuration.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
import posthog
|
||||
from experiments.constants import EXPERIMENT_CONDENSER_MAX_STEP
|
||||
from server.constants import IS_FEATURE_ENV
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.sdk import Agent
|
||||
from openhands.sdk.context.condenser import (
|
||||
LLMSummarizingCondenser,
|
||||
)
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
@@ -190,3 +196,37 @@ def handle_condenser_max_step_experiment(
|
||||
return conversation_settings
|
||||
|
||||
return conversation_settings
|
||||
|
||||
|
||||
def handle_condenser_max_step_experiment__v1(
|
||||
user_id: str | None,
|
||||
conversation_id: UUID,
|
||||
agent: Agent,
|
||||
) -> Agent:
|
||||
enabled_variant = _get_condenser_max_step_variant(user_id, str(conversation_id))
|
||||
|
||||
if enabled_variant is None:
|
||||
return agent
|
||||
|
||||
if enabled_variant == 'control':
|
||||
condenser_max_size = 120
|
||||
elif enabled_variant == 'treatment':
|
||||
condenser_max_size = 80
|
||||
else:
|
||||
logger.error(
|
||||
'condenser_max_step_experiment:unknown_variant',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'convo_id': conversation_id,
|
||||
'variant': enabled_variant,
|
||||
'reason': 'unknown variant; returning original conversation settings',
|
||||
},
|
||||
)
|
||||
return agent
|
||||
|
||||
condenser_llm = agent.llm.model_copy(update={'usage_id': 'condenser'})
|
||||
condenser = LLMSummarizingCondenser(
|
||||
llm=condenser_llm, max_size=condenser_max_size, keep_first=4
|
||||
)
|
||||
|
||||
return agent.model_copy(update={'condenser': condenser})
|
||||
|
||||
@@ -13,7 +13,7 @@ from integrations.solvability.models.report import SolvabilityReport
|
||||
from integrations.solvability.models.summary import SolvabilitySummary
|
||||
from integrations.utils import ENABLE_SOLVABILITY_ANALYSIS
|
||||
from pydantic import ValidationError
|
||||
from server.auth.token_manager import get_config
|
||||
from server.config import get_config
|
||||
from storage.database import session_maker
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ from integrations.utils import (
|
||||
from jinja2 import Environment
|
||||
from pydantic.dataclasses import dataclass
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager, get_config
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.database import session_maker
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
@@ -4,7 +4,8 @@ from integrations.models import Message
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import HOST, get_oh_labels, has_exact_mention
|
||||
from jinja2 import Environment
|
||||
from server.auth.token_manager import TokenManager, get_config
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.database import session_maker
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
|
||||
@@ -132,8 +132,10 @@ class JiraExistingConversationView(JiraViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -135,8 +135,10 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -132,8 +132,10 @@ class LinearExistingConversationView(LinearViewInterface):
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
|
||||
@@ -263,8 +263,10 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
# Check if conversation has been deleted
|
||||
# Update logic when soft delete is implemented
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
metadata = await conversation_store.get_metadata(self.conversation_id)
|
||||
if not metadata:
|
||||
|
||||
try:
|
||||
await conversation_store.get_metadata(self.conversation_id)
|
||||
except FileNotFoundError:
|
||||
raise StartingConvoException('Conversation no longer exists.')
|
||||
|
||||
provider_tokens = await saas_user_auth.get_provider_tokens()
|
||||
|
||||
5
enterprise/migrations/README.md
Normal file
5
enterprise/migrations/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Enterprise Migrations
|
||||
|
||||
## Migration conflicts
|
||||
|
||||
OpenHands PRs can fall out of sync with `main` quickly. When adding a migration, it's safest to sync the PR with main before merging to ensure you are caught up to any others that have been added.
|
||||
259
enterprise/migrations/versions/076_add_v1_tables.py
Normal file
259
enterprise/migrations/versions/076_add_v1_tables.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Sync DB with Models
|
||||
|
||||
Revision ID: 076
|
||||
Revises: 075
|
||||
Create Date: 2025-10-05 11:28:41.772294
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartTaskStatus,
|
||||
)
|
||||
from openhands.app_server.event_callback.event_callback_result_models import (
|
||||
EventCallbackResultStatus,
|
||||
)
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '076'
|
||||
down_revision: Union[str, Sequence[str], None] = '075'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('max_budget_per_task', sa.Float(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('cache_read_tokens', sa.Integer(), server_default='0'),
|
||||
)
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('cache_write_tokens', sa.Integer(), server_default='0'),
|
||||
)
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('reasoning_tokens', sa.Integer(), server_default='0'),
|
||||
)
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('context_window', sa.Integer(), server_default='0'),
|
||||
)
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('per_turn_token', sa.Integer(), server_default='0'),
|
||||
)
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column(
|
||||
'conversation_version', sa.String(), nullable=False, server_default='V0'
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_conversation_metadata_conversation_version'),
|
||||
'conversation_metadata',
|
||||
['conversation_version'],
|
||||
unique=False,
|
||||
)
|
||||
op.add_column('conversation_metadata', sa.Column('sandbox_id', sa.String()))
|
||||
op.create_index(
|
||||
op.f('ix_conversation_metadata_sandbox_id'),
|
||||
'conversation_metadata',
|
||||
['sandbox_id'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
'app_conversation_start_task',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_by_user_id', sa.String(), nullable=True),
|
||||
sa.Column('status', sa.Enum(AppConversationStartTaskStatus), nullable=True),
|
||||
sa.Column('detail', sa.String(), nullable=True),
|
||||
sa.Column('app_conversation_id', sa.UUID(), nullable=True),
|
||||
sa.Column('sandbox_id', sa.String(), nullable=True),
|
||||
sa.Column('agent_server_url', sa.String(), nullable=True),
|
||||
sa.Column('request', sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_app_conversation_start_task_created_at'),
|
||||
'app_conversation_start_task',
|
||||
['created_at'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_app_conversation_start_task_created_by_user_id'),
|
||||
'app_conversation_start_task',
|
||||
['created_by_user_id'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_app_conversation_start_task_updated_at'),
|
||||
'app_conversation_start_task',
|
||||
['updated_at'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
'event_callback',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('conversation_id', sa.UUID(), nullable=True),
|
||||
sa.Column('processor', sa.JSON(), nullable=True),
|
||||
sa.Column('event_kind', sa.String(), nullable=True),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'),
|
||||
nullable=True,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_created_at'),
|
||||
'event_callback',
|
||||
['created_at'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
'event_callback_result',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('status', sa.Enum(EventCallbackResultStatus), nullable=True),
|
||||
sa.Column('event_callback_id', sa.UUID(), nullable=True),
|
||||
sa.Column('event_id', sa.UUID(), nullable=True),
|
||||
sa.Column('conversation_id', sa.UUID(), nullable=True),
|
||||
sa.Column('detail', sa.String(), nullable=True),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'),
|
||||
nullable=True,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_conversation_id'),
|
||||
'event_callback_result',
|
||||
['conversation_id'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_created_at'),
|
||||
'event_callback_result',
|
||||
['created_at'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_event_callback_id'),
|
||||
'event_callback_result',
|
||||
['event_callback_id'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_event_id'),
|
||||
'event_callback_result',
|
||||
['event_id'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
'v1_remote_sandbox',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('created_by_user_id', sa.String(), nullable=True),
|
||||
sa.Column('sandbox_spec_id', sa.String(), nullable=True),
|
||||
sa.Column(
|
||||
'created_at',
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text('(CURRENT_TIMESTAMP)'),
|
||||
nullable=True,
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_v1_remote_sandbox_created_at'),
|
||||
'v1_remote_sandbox',
|
||||
['created_at'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_v1_remote_sandbox_created_by_user_id'),
|
||||
'v1_remote_sandbox',
|
||||
['created_by_user_id'],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_v1_remote_sandbox_sandbox_spec_id'),
|
||||
'v1_remote_sandbox',
|
||||
['sandbox_spec_id'],
|
||||
unique=False,
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(
|
||||
op.f('ix_v1_remote_sandbox_sandbox_spec_id'), table_name='v1_remote_sandbox'
|
||||
)
|
||||
op.drop_index(
|
||||
op.f('ix_v1_remote_sandbox_created_by_user_id'), table_name='v1_remote_sandbox'
|
||||
)
|
||||
op.drop_index(
|
||||
op.f('ix_v1_remote_sandbox_created_at'), table_name='v1_remote_sandbox'
|
||||
)
|
||||
op.drop_table('v1_remote_sandbox')
|
||||
op.drop_index(
|
||||
op.f('ix_event_callback_result_event_id'),
|
||||
table_name='event_callback_result',
|
||||
)
|
||||
op.drop_index(
|
||||
op.f('ix_event_callback_result_event_callback_id'),
|
||||
table_name='event_callback_result',
|
||||
)
|
||||
op.drop_index(
|
||||
op.f('ix_event_callback_result_created_at'),
|
||||
table_name='event_callback_result',
|
||||
)
|
||||
op.drop_index(
|
||||
op.f('ix_event_callback_result_conversation_id'),
|
||||
table_name='event_callback_result',
|
||||
)
|
||||
op.drop_table('event_callback_result')
|
||||
op.drop_index(op.f('ix_event_callback_created_at'), table_name='event_callback')
|
||||
op.drop_table('event_callback')
|
||||
op.drop_index(
|
||||
op.f('ix_app_conversation_start_task_updated_at'),
|
||||
table_name='app_conversation_start_task',
|
||||
)
|
||||
op.drop_index(
|
||||
op.f('ix_app_conversation_start_task_created_by_user_id'),
|
||||
table_name='app_conversation_start_task',
|
||||
)
|
||||
op.drop_index(
|
||||
op.f('ix_app_conversation_start_task_created_at'),
|
||||
table_name='app_conversation_start_task',
|
||||
)
|
||||
op.drop_table('app_conversation_start_task')
|
||||
op.drop_column('conversation_metadata', 'sandbox_id')
|
||||
op.drop_column('conversation_metadata', 'conversation_version')
|
||||
op.drop_column('conversation_metadata', 'per_turn_token')
|
||||
op.drop_column('conversation_metadata', 'context_window')
|
||||
op.drop_column('conversation_metadata', 'reasoning_tokens')
|
||||
op.drop_column('conversation_metadata', 'cache_write_tokens')
|
||||
op.drop_column('conversation_metadata', 'cache_read_tokens')
|
||||
op.drop_column('conversation_metadata', 'max_budget_per_task')
|
||||
op.execute('DROP TYPE appconversationstarttaskstatus')
|
||||
op.execute('DROP TYPE eventcallbackresultstatus')
|
||||
# ### end Alembic commands ###
|
||||
4201
enterprise/poetry.lock
generated
4201
enterprise/poetry.lock
generated
File diff suppressed because one or more lines are too long
@@ -13,7 +13,8 @@ from server.auth.auth_error import (
|
||||
ExpiredError,
|
||||
NoCredentialsError,
|
||||
)
|
||||
from server.auth.token_manager import TokenManager, get_config
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from server.logger import logger
|
||||
from server.rate_limit import RateLimiter, create_redis_rate_limiter
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
@@ -223,6 +224,16 @@ class SaasUserAuth(UserAuth):
|
||||
await rate_limiter.hit('auth_uid', user_id)
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
async def get_for_user(cls, user_id: str) -> UserAuth:
|
||||
offline_token = await token_manager.load_offline_token(user_id)
|
||||
assert offline_token is not None
|
||||
return SaasUserAuth(
|
||||
user_id=user_id,
|
||||
refresh_token=SecretStr(offline_token),
|
||||
auth_type=AuthType.BEARER,
|
||||
)
|
||||
|
||||
|
||||
def get_api_key_from_header(request: Request):
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
@@ -26,6 +26,7 @@ from server.auth.constants import (
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
)
|
||||
from server.auth.keycloak_manager import get_keycloak_admin, get_keycloak_openid
|
||||
from server.config import get_config
|
||||
from server.logger import logger
|
||||
from sqlalchemy import String as SQLString
|
||||
from sqlalchemy import type_coerce
|
||||
@@ -35,19 +36,8 @@ from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.offline_token_store import OfflineTokenStore
|
||||
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
|
||||
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
# Create a function to get config to avoid circular imports
|
||||
_config = None
|
||||
|
||||
|
||||
def get_config():
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = load_openhands_config()
|
||||
return _config
|
||||
|
||||
|
||||
def _before_sleep_callback(retry_state: RetryCallState) -> None:
|
||||
logger.info(f'Retry attempt {retry_state.attempt_number} for Keycloak operation')
|
||||
|
||||
@@ -19,10 +19,21 @@ from server.auth.constants import (
|
||||
GITLAB_APP_CLIENT_ID,
|
||||
)
|
||||
|
||||
from openhands.core.config.utils import load_openhands_config
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
# Create a function to get config to avoid circular imports
|
||||
_config = None
|
||||
|
||||
|
||||
def get_config():
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = load_openhands_config()
|
||||
return _config
|
||||
|
||||
|
||||
def sign_token(payload: dict[str, object], jwt_secret: str, algorithm='HS256') -> str:
|
||||
"""Signs a JWT token."""
|
||||
|
||||
@@ -424,7 +424,7 @@ async def refresh_tokens(
|
||||
provider_handler = ProviderHandler(
|
||||
create_provider_tokens_object([provider]), external_auth_id=user_id
|
||||
)
|
||||
service = provider_handler._get_service(provider)
|
||||
service = provider_handler.get_service(provider)
|
||||
token = await service.get_latest_token()
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -784,6 +784,7 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
env_vars['SKIP_DEPENDENCY_CHECK'] = '1'
|
||||
env_vars['INITIAL_NUM_WARM_SERVERS'] = '1'
|
||||
env_vars['INIT_GIT_IN_EMPTY_WORKSPACE'] = '1'
|
||||
env_vars['ENABLE_V1'] = '0'
|
||||
|
||||
# We need this for LLM traces tracking to identify the source of the LLM calls
|
||||
env_vars['WEB_HOST'] = WEB_HOST
|
||||
|
||||
@@ -195,14 +195,11 @@ def update_active_working_seconds(
|
||||
file_store: The FileStore instance for accessing conversation data
|
||||
"""
|
||||
try:
|
||||
# Get all events for the conversation
|
||||
events = list(event_store.get_events())
|
||||
|
||||
# Track agent state changes and calculate running time
|
||||
running_start_time = None
|
||||
total_running_seconds = 0.0
|
||||
|
||||
for event in events:
|
||||
for event in event_store.search_events():
|
||||
if isinstance(event, AgentStateChangedObservation) and event.timestamp:
|
||||
event_timestamp = datetime.fromisoformat(event.timestamp).timestamp()
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
Unified SQLAlchemy declarative base for all models.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from openhands.app_server.utils.sql_utils import Base
|
||||
|
||||
Base = declarative_base()
|
||||
__all__ = ['Base']
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from google.cloud.sql.connector import Connector
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -26,6 +25,8 @@ def _get_db_engine():
|
||||
if GCP_DB_INSTANCE: # GCP environments
|
||||
|
||||
def get_db_connection():
|
||||
from google.cloud.sql.connector import Connector
|
||||
|
||||
connector = Connector()
|
||||
instance_string = f'{GCP_PROJECT}:{GCP_REGION}:{GCP_DB_INSTANCE}'
|
||||
return connector.connect(
|
||||
@@ -52,6 +53,8 @@ def _get_db_engine():
|
||||
|
||||
|
||||
async def async_creator():
|
||||
from google.cloud.sql.connector import Connector
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
async with Connector(loop=loop) as connector:
|
||||
conn = await connector.connect_async(
|
||||
|
||||
@@ -52,6 +52,14 @@ class SaasConversationStore(ConversationStore):
|
||||
# Convert string to ProviderType enum
|
||||
kwargs['git_provider'] = ProviderType(kwargs['git_provider'])
|
||||
|
||||
# Remove V1 attributes
|
||||
kwargs.pop('max_budget_per_task', None)
|
||||
kwargs.pop('cache_read_tokens', None)
|
||||
kwargs.pop('cache_write_tokens', None)
|
||||
kwargs.pop('reasoning_tokens', None)
|
||||
kwargs.pop('context_window', None)
|
||||
kwargs.pop('per_turn_token', None)
|
||||
|
||||
return ConversationMetadata(**kwargs)
|
||||
|
||||
async def save_metadata(self, metadata: ConversationMetadata):
|
||||
|
||||
@@ -1,41 +1,8 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
StoredConversationMetadata as _StoredConversationMetadata,
|
||||
)
|
||||
|
||||
from sqlalchemy import JSON, Column, DateTime, Float, Integer, String
|
||||
from storage.base import Base
|
||||
StoredConversationMetadata = _StoredConversationMetadata
|
||||
|
||||
|
||||
class StoredConversationMetadata(Base): # type: ignore
|
||||
__tablename__ = 'conversation_metadata'
|
||||
conversation_id = Column(
|
||||
String, primary_key=True, default=lambda: str(uuid.uuid4())
|
||||
)
|
||||
github_user_id = Column(String, nullable=True) # The GitHub user ID
|
||||
user_id = Column(String, nullable=False) # The Keycloak User ID
|
||||
selected_repository = Column(String, nullable=True)
|
||||
selected_branch = Column(String, nullable=True)
|
||||
git_provider = Column(
|
||||
String, nullable=True
|
||||
) # The git provider (GitHub, GitLab, etc.)
|
||||
title = Column(String, nullable=True)
|
||||
last_updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
)
|
||||
trigger = Column(String, nullable=True)
|
||||
pr_number = Column(
|
||||
JSON, nullable=True
|
||||
) # List of PR numbers associated with the conversation
|
||||
|
||||
# Cost and token metrics
|
||||
accumulated_cost = Column(Float, default=0.0)
|
||||
prompt_tokens = Column(Integer, default=0)
|
||||
completion_tokens = Column(Integer, default=0)
|
||||
total_tokens = Column(Integer, default=0)
|
||||
|
||||
# LLM model used for the conversation
|
||||
llm_model = Column(String, nullable=True)
|
||||
__all__ = ['StoredConversationMetadata']
|
||||
|
||||
1
enterprise/tests/unit/experiments/__init__.py
Normal file
1
enterprise/tests/unit/experiments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit tests for experiments module."""
|
||||
@@ -0,0 +1,137 @@
|
||||
# tests/test_condenser_max_step_experiment_v1.py
|
||||
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from experiments.experiment_manager import SaaSExperimentManager
|
||||
|
||||
# SUT imports (update the module path if needed)
|
||||
from experiments.experiment_versions._004_condenser_max_step_experiment import (
|
||||
handle_condenser_max_step_experiment__v1,
|
||||
)
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.sdk import LLM, Agent
|
||||
from openhands.sdk.context.condenser import LLMSummarizingCondenser
|
||||
|
||||
|
||||
def make_agent() -> Agent:
|
||||
"""Build a minimal valid Agent."""
|
||||
llm = LLM(
|
||||
usage_id='primary-llm',
|
||||
model='provider/model',
|
||||
api_key=SecretStr('sk-test'),
|
||||
)
|
||||
return Agent(llm=llm)
|
||||
|
||||
|
||||
def _patch_variant(monkeypatch, return_value):
|
||||
"""Patch the internal variant getter to return a specific value."""
|
||||
monkeypatch.setattr(
|
||||
'experiments.experiment_versions._004_condenser_max_step_experiment._get_condenser_max_step_variant',
|
||||
lambda user_id, conv_id: return_value,
|
||||
raising=True,
|
||||
)
|
||||
|
||||
|
||||
def test_control_variant_sets_condenser_with_max_size_120(monkeypatch):
|
||||
_patch_variant(monkeypatch, 'control')
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = handle_condenser_max_step_experiment__v1('user-1', conv_id, agent)
|
||||
|
||||
# Should be a new Agent instance with a condenser installed
|
||||
assert result is not agent
|
||||
assert isinstance(result.condenser, LLMSummarizingCondenser)
|
||||
|
||||
# The condenser should have its own LLM (usage_id overridden to "condenser")
|
||||
assert result.condenser.llm.usage_id == 'condenser'
|
||||
# The original agent LLM remains unchanged
|
||||
assert agent.llm.usage_id == 'primary-llm'
|
||||
|
||||
# Control: max_size = 120, keep_first = 4
|
||||
assert result.condenser.max_size == 120
|
||||
assert result.condenser.keep_first == 4
|
||||
|
||||
|
||||
def test_treatment_variant_sets_condenser_with_max_size_80(monkeypatch):
|
||||
_patch_variant(monkeypatch, 'treatment')
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = handle_condenser_max_step_experiment__v1('user-2', conv_id, agent)
|
||||
|
||||
assert result is not agent
|
||||
assert isinstance(result.condenser, LLMSummarizingCondenser)
|
||||
assert result.condenser.llm.usage_id == 'condenser'
|
||||
assert result.condenser.max_size == 80
|
||||
assert result.condenser.keep_first == 4
|
||||
|
||||
|
||||
def test_none_variant_returns_original_agent_without_changes(monkeypatch):
|
||||
_patch_variant(monkeypatch, None)
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = handle_condenser_max_step_experiment__v1('user-3', conv_id, agent)
|
||||
|
||||
# No changes—same instance and no condenser attribute added
|
||||
assert result is agent
|
||||
assert getattr(result, 'condenser', None) is None
|
||||
|
||||
|
||||
def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
|
||||
_patch_variant(monkeypatch, 'weird-variant')
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = handle_condenser_max_step_experiment__v1('user-4', conv_id, agent)
|
||||
|
||||
assert result is agent
|
||||
assert getattr(result, 'condenser', None) is None
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
|
||||
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
|
||||
mock_handle_condenser,
|
||||
):
|
||||
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
result = SaaSExperimentManager.run_agent_variant_tests__v1(
|
||||
user_id='user-123',
|
||||
conversation_id=conv_id,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
# Same object returned (no copy)
|
||||
assert result is agent
|
||||
# Handler should not have been called
|
||||
mock_handle_condenser.assert_not_called()
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
|
||||
@patch('experiments.experiment_manager.EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT', True)
|
||||
def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeypatch):
|
||||
"""When enabled, it should call the condenser experiment handler and set the long-horizon system prompt."""
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
|
||||
_patch_variant(monkeypatch, 'treatment')
|
||||
|
||||
result: Agent = SaaSExperimentManager.run_agent_variant_tests__v1(
|
||||
user_id='user-abc',
|
||||
conversation_id=conv_id,
|
||||
agent=agent,
|
||||
)
|
||||
|
||||
# Should be a different instance than the original (copied after handler runs)
|
||||
assert result is not agent
|
||||
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
|
||||
|
||||
# The condenser returned by the handler must be preserved after the system-prompt override copy
|
||||
assert isinstance(result.condenser, LLMSummarizingCondenser)
|
||||
assert result.condenser.max_size == 80
|
||||
@@ -137,7 +137,9 @@ class TestJiraExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -137,7 +137,9 @@ class TestJiraDcExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -137,7 +137,9 @@ class TestLinearExistingConversationView:
|
||||
):
|
||||
"""Test conversation update with no metadata"""
|
||||
mock_store = AsyncMock()
|
||||
mock_store.get_metadata.return_value = None
|
||||
mock_store.get_metadata.side_effect = FileNotFoundError(
|
||||
'No such file or directory'
|
||||
)
|
||||
mock_store_impl.return_value = mock_store
|
||||
|
||||
with pytest.raises(
|
||||
|
||||
@@ -80,7 +80,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
events.append(event6)
|
||||
|
||||
# Configure the mock event store to return our test events
|
||||
mock_event_store.get_events.return_value = events
|
||||
mock_event_store.search_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -133,7 +133,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
|
||||
events = [event1, event2]
|
||||
|
||||
mock_event_store.get_events.return_value = events
|
||||
mock_event_store.search_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -178,7 +178,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
events = [event1, event2, event3]
|
||||
# No final state change - agent still running
|
||||
|
||||
mock_event_store.get_events.return_value = events
|
||||
mock_event_store.search_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -221,7 +221,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
|
||||
events = [event1, event2, event3]
|
||||
|
||||
mock_event_store.get_events.return_value = events
|
||||
mock_event_store.search_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -267,7 +267,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
|
||||
events = [event1, event2, event3, event4]
|
||||
|
||||
mock_event_store.get_events.return_value = events
|
||||
mock_event_store.search_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
@@ -297,7 +297,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
user_id = 'test_user_error'
|
||||
|
||||
# Configure the mock to raise an exception
|
||||
mock_event_store.get_events.side_effect = Exception('Test error')
|
||||
mock_event_store.search_events.side_effect = Exception('Test error')
|
||||
|
||||
# Call the function under test
|
||||
update_active_working_seconds(
|
||||
@@ -376,7 +376,7 @@ class TestUpdateActiveWorkingSeconds:
|
||||
event10.timestamp = '1970-01-01T00:00:37.000000'
|
||||
events.append(event10)
|
||||
|
||||
mock_event_store.get_events.return_value = events
|
||||
mock_event_store.search_events.return_value = events
|
||||
|
||||
# Call the function under test with mocked session_maker
|
||||
with patch(
|
||||
|
||||
@@ -20,7 +20,7 @@ def token_store(session_maker, mock_config):
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager():
|
||||
with patch('server.auth.token_manager.get_config') as mock_get_config:
|
||||
with patch('server.config.get_config') as mock_get_config:
|
||||
mock_config = mock_get_config.return_value
|
||||
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
|
||||
return TokenManager(external=False)
|
||||
|
||||
@@ -8,7 +8,7 @@ from openhands.integrations.service_types import ProviderType
|
||||
|
||||
@pytest.fixture
|
||||
def token_manager():
|
||||
with patch('server.auth.token_manager.get_config') as mock_get_config:
|
||||
with patch('server.config.get_config') as mock_get_config:
|
||||
mock_config = mock_get_config.return_value
|
||||
mock_config.jwt_secret.get_secret_value.return_value = 'test_secret'
|
||||
return TokenManager(external=False)
|
||||
|
||||
@@ -307,7 +307,7 @@ class TheoremqaTask(Task):
|
||||
|
||||
# Converting the string answer to a number/list/bool/option
|
||||
try:
|
||||
prediction = eval(prediction)
|
||||
prediction = ast.literal_eval(prediction)
|
||||
except Exception:
|
||||
LOGGER.warning(
|
||||
f'[TASK] Failed to convert the answer: {prediction}\n{traceback.format_exc()}'
|
||||
|
||||
@@ -111,15 +111,10 @@ for run_idx in $(seq 1 $N_RUNS); do
|
||||
echo "### Evaluating on $OUTPUT_FILE ... ###"
|
||||
OUTPUT_CONFIG_FILE="${OUTPUT_FILE%.jsonl}_config.json"
|
||||
export EVAL_SKIP_BUILD_ERRORS=true
|
||||
pip install multi-swe-bench --quiet --disable-pip-version-check > /dev/null 2>&1
|
||||
COMMAND="poetry run python ./evaluation/benchmarks/multi_swe_bench/scripts/eval/update_multi_swe_bench_config.py --input $OUTPUT_FILE --output $OUTPUT_CONFIG_FILE --dataset $EVAL_DATASET;
|
||||
python -m multi_swe_bench.harness.run_evaluation --config $OUTPUT_CONFIG_FILE
|
||||
poetry run python -m multi_swe_bench.harness.run_evaluation --config $OUTPUT_CONFIG_FILE
|
||||
"
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
echo "Running command: $COMMAND"
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
|
||||
@@ -16,6 +16,10 @@ At the end, you must test your code rigorously using the tools provided, and do
|
||||
|
||||
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
|
||||
|
||||
## Issue Description
|
||||
|
||||
{{ instance.problem_statement }}
|
||||
|
||||
# Workflow
|
||||
|
||||
## High-Level Problem Solving Strategy
|
||||
@@ -73,6 +77,7 @@ Carefully read the issue and think hard about a plan to solve it before coding.
|
||||
|
||||
## 8. Final Reflection and Additional Testing
|
||||
- Reflect carefully on the original intent of the user and the problem statement.
|
||||
- Compare your changes with the base commit {{ instance.base_commit }} to ensure minimal and focused modifications.
|
||||
- Think about potential edge cases or scenarios that may not be covered by existing tests.
|
||||
- Write additional tests that would need to pass to fully validate the correctness of your solution.
|
||||
- Run these new tests and ensure they all pass.
|
||||
|
||||
@@ -24,8 +24,8 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
@@ -166,7 +166,8 @@ def load_integration_tests() -> pd.DataFrame:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_arguments()
|
||||
parser = get_evaluation_parser()
|
||||
args, _ = parser.parse_known_args()
|
||||
integration_tests = load_integration_tests()
|
||||
|
||||
llm_config = None
|
||||
|
||||
60
frontend/__mocks__/zustand.ts
Normal file
60
frontend/__mocks__/zustand.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { act } from "@testing-library/react";
|
||||
import { vi, afterEach } from "vitest";
|
||||
import type * as ZustandExportedTypes from "zustand";
|
||||
|
||||
export * from "zustand";
|
||||
|
||||
const { create: actualCreate, createStore: actualCreateStore } =
|
||||
await vi.importActual<typeof ZustandExportedTypes>("zustand");
|
||||
|
||||
// a variable to hold reset functions for all stores declared in the app
|
||||
export const storeResetFns = new Set<() => void>();
|
||||
|
||||
const createUncurried = <T>(
|
||||
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
||||
) => {
|
||||
const store = actualCreate(stateCreator);
|
||||
const initialState = store.getInitialState();
|
||||
storeResetFns.add(() => {
|
||||
store.setState(initialState, true);
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||
export const create = (<T>(
|
||||
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
||||
) =>
|
||||
// to support curried version of create
|
||||
typeof stateCreator === "function"
|
||||
? createUncurried(stateCreator)
|
||||
: createUncurried) as typeof ZustandExportedTypes.create;
|
||||
|
||||
const createStoreUncurried = <T>(
|
||||
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
||||
) => {
|
||||
const store = actualCreateStore(stateCreator);
|
||||
const initialState = store.getInitialState();
|
||||
storeResetFns.add(() => {
|
||||
store.setState(initialState, true);
|
||||
});
|
||||
return store;
|
||||
};
|
||||
|
||||
// when creating a store, we get its initial state, create a reset function and add it in the set
|
||||
export const createStore = (<T>(
|
||||
stateCreator: ZustandExportedTypes.StateCreator<T>,
|
||||
) =>
|
||||
// to support curried version of createStore
|
||||
typeof stateCreator === "function"
|
||||
? createStoreUncurried(stateCreator)
|
||||
: createStoreUncurried) as typeof ZustandExportedTypes.createStore;
|
||||
|
||||
// reset all stores after each test run
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
storeResetFns.forEach((resetFn) => {
|
||||
resetFn();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import {
|
||||
FILE_VARIANTS_1,
|
||||
FILE_VARIANTS_2,
|
||||
} from "#/mocks/file-service-handlers";
|
||||
|
||||
/**
|
||||
* File service API tests. The actual API calls are mocked using MSW.
|
||||
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
|
||||
*/
|
||||
|
||||
describe("ConversationService File API", () => {
|
||||
it("should get a list of files", async () => {
|
||||
await expect(
|
||||
ConversationService.getFiles("test-conversation-id"),
|
||||
).resolves.toEqual(FILE_VARIANTS_1);
|
||||
|
||||
await expect(
|
||||
ConversationService.getFiles("test-conversation-id-2"),
|
||||
).resolves.toEqual(FILE_VARIANTS_2);
|
||||
});
|
||||
|
||||
it("should get content of a file", async () => {
|
||||
await expect(
|
||||
ConversationService.getFile("test-conversation-id", "file1.txt"),
|
||||
).resolves.toEqual("Content of file1.txt");
|
||||
});
|
||||
});
|
||||
187
frontend/__tests__/build-websocket-url.test.ts
Normal file
187
frontend/__tests__/build-websocket-url.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
|
||||
describe("buildWebSocketUrl", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Basic URL construction", () => {
|
||||
it("should build WebSocket URL with conversation ID and URL", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use wss:// protocol when window.location.protocol is https:", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "https:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"https://example.com:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("wss://example.com:8080/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should extract host and port from conversation URL", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-456",
|
||||
"http://agent-server.com:9000/api/conversations/conv-456",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query parameters handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not include query parameters in the URL (handled by useWebSocket hook)", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
expect(result).not.toContain("?");
|
||||
expect(result).not.toContain("session_api_key");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fallback to window.location.host", () => {
|
||||
it("should use window.location.host when conversation URL is null", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", null);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is undefined", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", undefined);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is relative path", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should use window.location.host when conversation URL is invalid", () => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "fallback-host:4000",
|
||||
});
|
||||
|
||||
const result = buildWebSocketUrl("conv-123", "not-a-valid-url");
|
||||
|
||||
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", {
|
||||
protocol: "http:",
|
||||
host: "localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when conversationId is undefined", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
undefined,
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when conversationId is empty string", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle conversation URLs with non-standard ports", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://example.com:12345/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://example.com:12345/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should handle conversation URLs without port (default port)", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://example.com/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://example.com/sockets/events/conv-123");
|
||||
});
|
||||
|
||||
it("should handle conversation IDs with special characters", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123-abc_def",
|
||||
"http://localhost:8080/api/conversations/conv-123-abc_def",
|
||||
);
|
||||
|
||||
expect(result).toBe(
|
||||
"ws://localhost:8080/sockets/events/conv-123-abc_def",
|
||||
);
|
||||
});
|
||||
|
||||
it("should build URL without query parameters", () => {
|
||||
const result = buildWebSocketUrl(
|
||||
"conv-123",
|
||||
"http://localhost:8080/api/conversations/conv-123",
|
||||
);
|
||||
|
||||
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
|
||||
expect(result).not.toContain("?");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
@@ -176,7 +177,7 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is a user message", () => {
|
||||
const userEvent: OpenHandsAction = {
|
||||
const mockUserEvent: OpenHandsAction = {
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
@@ -189,10 +190,11 @@ describe("ChatInterface - Chat Suggestions", () => {
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [userEvent],
|
||||
useEventStore.setState({
|
||||
events: [mockUserEvent],
|
||||
uiEvents: [],
|
||||
addEvent: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
@@ -8,6 +8,14 @@ import { ConversationPanel } from "#/components/features/conversation-panel/conv
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
// Mock the unified stop conversation hook
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedPauseConversationSandbox: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ConversationPanel", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const RouterStub = createRoutesStub([
|
||||
@@ -73,7 +81,7 @@ describe("ConversationPanel", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
mockStopConversationMutate.mockClear();
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
@@ -430,19 +438,6 @@ describe("ConversationPanel", () => {
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const stopConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"stopConversation",
|
||||
);
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
const conversation = mockData.find((conv) => conv.conversation_id === id);
|
||||
if (conversation) {
|
||||
conversation.status = "STOPPED";
|
||||
return conversation;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
@@ -465,9 +460,12 @@ describe("ConversationPanel", () => {
|
||||
screen.queryByRole("button", { name: /confirm/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify the API was called
|
||||
expect(stopConversationSpy).toHaveBeenCalledWith("1");
|
||||
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
|
||||
// Verify the mutation was called
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "1",
|
||||
version: undefined,
|
||||
});
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should only show stop button for STARTING or RUNNING conversations", async () => {
|
||||
|
||||
@@ -6,25 +6,25 @@ import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the custom hooks
|
||||
const mockStartConversationMutate = vi.fn();
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
|
||||
useStartConversation: () => ({
|
||||
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
|
||||
useUnifiedStartConversation: () => ({
|
||||
mutate: mockStartConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
|
||||
useStopConversation: () => ({
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedStopConversation: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
@@ -41,6 +41,19 @@ vi.mock("#/hooks/use-user-providers", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-task-polling", () => ({
|
||||
useTaskPolling: () => ({
|
||||
isTask: false,
|
||||
taskId: null,
|
||||
conversationId: "test-conversation-id",
|
||||
task: null,
|
||||
taskStatus: null,
|
||||
taskDetail: null,
|
||||
taskError: null,
|
||||
isLoadingTask: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
@@ -66,12 +79,14 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("ServerStatus", () => {
|
||||
// Helper function to mock agent store with specific state
|
||||
// Mock functions for handlers
|
||||
const mockHandleStop = vi.fn();
|
||||
const mockHandleResumeAgent = vi.fn();
|
||||
|
||||
// Helper function to mock agent state with specific state
|
||||
const mockAgentStore = (agentState: AgentState) => {
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,20 +100,42 @@ describe("ServerStatus", () => {
|
||||
|
||||
// Test RUNNING status
|
||||
const { rerender } = renderWithProviders(
|
||||
<ServerStatus conversationStatus="RUNNING" />,
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test STOPPED status
|
||||
rerender(<ServerStatus conversationStatus="STOPPED" />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
|
||||
|
||||
// Test STARTING status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(<ServerStatus conversationStatus="STARTING" />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test null status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(<ServerStatus conversationStatus={null} />);
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -108,7 +145,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -128,7 +171,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -148,7 +197,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
@@ -165,12 +220,18 @@ describe("ServerStatus", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockStopConversationMutate.mockClear();
|
||||
mockHandleStop.mockClear();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -178,21 +239,25 @@ describe("ServerStatus", () => {
|
||||
const stopButton = screen.getByTestId("stop-server-button");
|
||||
await user.click(stopButton);
|
||||
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call start conversation mutation when start server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockStartConversationMutate.mockClear();
|
||||
mockHandleResumeAgent.mockClear();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -200,10 +265,7 @@ describe("ServerStatus", () => {
|
||||
const startButton = screen.getByTestId("start-server-button");
|
||||
await user.click(startButton);
|
||||
|
||||
expect(mockStartConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
providers: [],
|
||||
});
|
||||
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after stop server action", async () => {
|
||||
@@ -212,7 +274,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -221,9 +289,7 @@ describe("ServerStatus", () => {
|
||||
await user.click(stopButton);
|
||||
|
||||
// Context menu should be closed (handled by the component)
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after start server action", async () => {
|
||||
@@ -232,7 +298,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
@@ -250,7 +322,13 @@ describe("ServerStatus", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus={null} />);
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusText = screen.getByText("Running");
|
||||
expect(statusText).toBeInTheDocument();
|
||||
|
||||
@@ -5,12 +5,12 @@ import { MemoryRouter } from "react-router";
|
||||
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation store
|
||||
@@ -57,14 +57,11 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
|
||||
|
||||
describe("InteractiveChatBox", () => {
|
||||
const onSubmitMock = vi.fn();
|
||||
const onStopMock = vi.fn();
|
||||
|
||||
// Helper function to mock stores
|
||||
const mockStores = (agentState: AgentState = AgentState.INIT) => {
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mocked(useConversationStore).mockReturnValue({
|
||||
@@ -103,14 +100,13 @@ describe("InteractiveChatBox", () => {
|
||||
};
|
||||
|
||||
// Helper function to render with Router context
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) => {
|
||||
return renderWithProviders(
|
||||
const renderInteractiveChatBox = (props: any, options: any = {}) =>
|
||||
renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox {...props} />
|
||||
</MemoryRouter>,
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
global.URL.createObjectURL = vi
|
||||
@@ -127,7 +123,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const chatBox = screen.getByTestId("interactive-chat-box");
|
||||
@@ -140,7 +135,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const textbox = screen.getByTestId("chat-input");
|
||||
@@ -157,7 +151,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
// Create a larger file to ensure it passes validation
|
||||
@@ -184,7 +177,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
|
||||
@@ -209,7 +201,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const textarea = screen.getByTestId("chat-input");
|
||||
@@ -240,7 +231,6 @@ describe("InteractiveChatBox", () => {
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
const button = screen.getByTestId("submit-button");
|
||||
@@ -250,33 +240,14 @@ describe("InteractiveChatBox", () => {
|
||||
expect(onSubmitMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the stop button when agent is running and call onStop when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockStores(AgentState.RUNNING);
|
||||
|
||||
renderInteractiveChatBox({
|
||||
onSubmit: onSubmitMock,
|
||||
onStop: onStopMock,
|
||||
});
|
||||
|
||||
// The stop button should be available when agent is running
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
await user.click(stopButton);
|
||||
expect(onStopMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should handle image upload and message submission correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const onStop = vi.fn();
|
||||
|
||||
mockStores(AgentState.AWAITING_USER_INPUT);
|
||||
|
||||
const { rerender } = renderInteractiveChatBox({
|
||||
onSubmit: onSubmit,
|
||||
onStop: onStop,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
// Verify text input has the initial value
|
||||
@@ -296,7 +267,7 @@ describe("InteractiveChatBox", () => {
|
||||
// Simulate parent component updating the value prop
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox onSubmit={onSubmit} onStop={onStop} />
|
||||
<InteractiveChatBox onSubmit={onSubmit} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
|
||||
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
@@ -30,11 +30,9 @@ describe("JupyterEditor", () => {
|
||||
});
|
||||
|
||||
it("should have a scrollable container", () => {
|
||||
// Mock agent store to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.RUNNING,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
|
||||
@@ -5,11 +5,11 @@ import { renderWithProviders } from "test-utils";
|
||||
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
// Mock the agent store
|
||||
vi.mock("#/stores/agent-store", () => ({
|
||||
useAgentStore: vi.fn(),
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the conversation ID hook
|
||||
@@ -50,11 +50,9 @@ describe("MicroagentsModal - Refresh Button", () => {
|
||||
microagents: mockMicroagents,
|
||||
});
|
||||
|
||||
// Mock the agent store to return a ready state
|
||||
vi.mocked(useAgentStore).mockReturnValue({
|
||||
// Mock the agent state to return a ready state
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
setCurrentAgentState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
513
frontend/__tests__/conversation-websocket-handler.test.tsx
Normal file
513
frontend/__tests__/conversation-websocket-handler.test.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
createMockMessageEvent,
|
||||
createMockUserMessageEvent,
|
||||
createMockAgentErrorEvent,
|
||||
} from "#/mocks/mock-ws-helpers";
|
||||
import {
|
||||
ConnectionStatusComponent,
|
||||
EventStoreComponent,
|
||||
OptimisticUserMessageStoreComponent,
|
||||
ErrorMessageStoreComponent,
|
||||
} from "./helpers/websocket-test-components";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
|
||||
beforeAll(() => {
|
||||
// The global MSW server from vitest.setup.ts is already running
|
||||
// We just need to start our WebSocket-specific server
|
||||
mswServer.listen({ onUnhandledRequest: "bypass" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mswServer.resetHandlers();
|
||||
// Clean up any React components
|
||||
cleanup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Close the WebSocket MSW server
|
||||
mswServer.close();
|
||||
|
||||
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to render components with ConversationWebSocketProvider
|
||||
function renderWithWebSocketContext(
|
||||
children: React.ReactNode,
|
||||
conversationId = "test-conversation-default",
|
||||
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-default",
|
||||
sessionApiKey: string | null = null,
|
||||
) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConversationWebSocketProvider
|
||||
conversationId={conversationId}
|
||||
conversationUrl={conversationUrl}
|
||||
sessionApiKey={sessionApiKey}
|
||||
>
|
||||
{children}
|
||||
</ConversationWebSocketProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Conversation WebSocket Handler", () => {
|
||||
// 1. Connection Lifecycle Tests
|
||||
describe("Connection Management", () => {
|
||||
it("should establish WebSocket connection to /events/socket URL", async () => {
|
||||
// This will fail because we haven't created the context yet
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Initially should be CONNECTING
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"CONNECTING",
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.todo("should provide manual disconnect functionality");
|
||||
});
|
||||
|
||||
// 2. Event Processing Tests
|
||||
describe("Event Stream Processing", () => {
|
||||
it("should update event store with received WebSocket events", async () => {
|
||||
// Create a mock MessageEvent to send through WebSocket
|
||||
const mockMessageEvent = createMockMessageEvent();
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockMessageEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and event store
|
||||
renderWithWebSocketContext(<EventStoreComponent />);
|
||||
|
||||
// Wait for connection and event processing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-count")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
// Verify the event was added to the store
|
||||
expect(screen.getByTestId("latest-event-id")).toHaveTextContent(
|
||||
"test-event-123",
|
||||
);
|
||||
expect(screen.getByTestId("ui-events-count")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
it("should handle malformed/invalid event data gracefully", async () => {
|
||||
// Set up MSW to send various invalid events when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Send invalid JSON
|
||||
client.send("invalid json string");
|
||||
|
||||
// Send valid JSON but missing required fields
|
||||
client.send(JSON.stringify({ message: "missing required fields" }));
|
||||
|
||||
// Send valid JSON with wrong data types
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
id: 123, // should be string
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
source: "agent",
|
||||
}),
|
||||
);
|
||||
|
||||
// Send null values for required fields
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
id: null,
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
source: "agent",
|
||||
}),
|
||||
);
|
||||
|
||||
// Send a valid event after invalid ones to ensure processing continues
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
id: "valid-event-123",
|
||||
timestamp: new Date().toISOString(),
|
||||
source: "agent",
|
||||
llm_message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Valid message after invalid ones" },
|
||||
],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and event store
|
||||
renderWithWebSocketContext(<EventStoreComponent />);
|
||||
|
||||
// Wait for connection and event processing
|
||||
// Only the valid event should be added to the store
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-count")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
// Verify only the valid event was added
|
||||
expect(screen.getByTestId("latest-event-id")).toHaveTextContent(
|
||||
"valid-event-123",
|
||||
);
|
||||
expect(screen.getByTestId("ui-events-count")).toHaveTextContent("1");
|
||||
});
|
||||
});
|
||||
|
||||
// 3. State Management Tests
|
||||
describe("State Management Integration", () => {
|
||||
it("should clear optimistic user messages when confirmed", async () => {
|
||||
// First, set an optimistic user message
|
||||
const { setOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore.getState();
|
||||
setOptimisticUserMessage("This is an optimistic message");
|
||||
|
||||
// Create a mock user MessageEvent to send through WebSocket
|
||||
const mockUserMessageEvent = createMockUserMessageEvent();
|
||||
|
||||
// Set up MSW to send the user message event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock user message event after connection
|
||||
client.send(JSON.stringify(mockUserMessageEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and optimistic user message store
|
||||
renderWithWebSocketContext(<OptimisticUserMessageStoreComponent />);
|
||||
|
||||
// Initially should show the optimistic message
|
||||
expect(screen.getByTestId("optimistic-user-message")).toHaveTextContent(
|
||||
"This is an optimistic message",
|
||||
);
|
||||
|
||||
// Wait for connection and user message event processing
|
||||
// The optimistic message should be cleared when user message is confirmed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("optimistic-user-message")).toHaveTextContent(
|
||||
"none",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Cache Management Tests
|
||||
describe("Cache Management", () => {
|
||||
it.todo(
|
||||
"should invalidate file changes cache on file edit/write/command events",
|
||||
);
|
||||
it.todo("should invalidate specific file diff cache on file modifications");
|
||||
it.todo("should prevent cache refetch during high message rates");
|
||||
it.todo("should not invalidate cache for non-file-related events");
|
||||
it.todo("should invalidate cache with correct conversation ID context");
|
||||
});
|
||||
|
||||
// 5. Error Handling Tests
|
||||
describe("Error Handling & Recovery", () => {
|
||||
it("should update error message store on AgentErrorEvent", async () => {
|
||||
// Create a mock AgentErrorEvent to send through WebSocket
|
||||
const mockAgentErrorEvent = createMockAgentErrorEvent();
|
||||
|
||||
// Set up MSW to send the error event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock error event after connection
|
||||
client.send(JSON.stringify(mockAgentErrorEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(<ErrorMessageStoreComponent />);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for connection and error event processing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent(
|
||||
"Failed to execute command: Permission denied",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set error message store on WebSocket connection errors", async () => {
|
||||
// Set up MSW to simulate connection error
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client }) => {
|
||||
// Simulate connection error by closing immediately
|
||||
client.close(1006, "Connection failed");
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(
|
||||
<>
|
||||
<ErrorMessageStoreComponent />
|
||||
<ConnectionStatusComponent />
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for connection error and error message to be set
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"CLOSED",
|
||||
);
|
||||
});
|
||||
|
||||
// Should set error message on connection failure
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).not.toHaveTextContent(
|
||||
"none",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should set error message store on WebSocket disconnect with error", async () => {
|
||||
// Set up MSW to connect first, then disconnect with error
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
|
||||
// Simulate disconnect with error after a short delay
|
||||
setTimeout(() => {
|
||||
client.close(1006, "Unexpected disconnect");
|
||||
}, 100);
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(
|
||||
<>
|
||||
<ErrorMessageStoreComponent />
|
||||
<ConnectionStatusComponent />
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for connection to be established first
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for disconnect and error message to be set
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"CLOSED",
|
||||
);
|
||||
});
|
||||
|
||||
// Should set error message on unexpected disconnect
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).not.toHaveTextContent(
|
||||
"none",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should clear error message store when connection is restored", async () => {
|
||||
let connectionAttempt = 0;
|
||||
|
||||
// Set up MSW to fail first connection, then succeed on retry
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
connectionAttempt += 1;
|
||||
|
||||
if (connectionAttempt === 1) {
|
||||
// First attempt fails
|
||||
client.close(1006, "Initial connection failed");
|
||||
} else {
|
||||
// Second attempt succeeds
|
||||
server.connect();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Render components that use both WebSocket and error message store
|
||||
renderWithWebSocketContext(
|
||||
<>
|
||||
<ErrorMessageStoreComponent />
|
||||
<ConnectionStatusComponent />
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially should show "none"
|
||||
expect(screen.getByTestId("error-message")).toHaveTextContent("none");
|
||||
|
||||
// Wait for first connection failure and error message
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"CLOSED",
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("error-message")).not.toHaveTextContent(
|
||||
"none",
|
||||
);
|
||||
});
|
||||
|
||||
// Simulate reconnection attempt (this would normally be triggered by the WebSocket context)
|
||||
// For now, we'll just verify the pattern - when connection is restored, error should clear
|
||||
// This test will fail until the WebSocket handler implements the clear logic
|
||||
|
||||
// Note: This test demonstrates the expected behavior but may need adjustment
|
||||
// based on how the actual reconnection logic is implemented
|
||||
});
|
||||
|
||||
it.todo("should track and display errors with proper metadata");
|
||||
it.todo("should set appropriate error states on connection failures");
|
||||
it.todo(
|
||||
"should handle WebSocket close codes appropriately (1000, 1006, etc.)",
|
||||
);
|
||||
});
|
||||
|
||||
// 6. Connection State Validation Tests
|
||||
describe("Connection State Management", () => {
|
||||
it.todo("should only connect when conversation is in RUNNING status");
|
||||
it.todo("should handle STARTING conversation state appropriately");
|
||||
it.todo("should disconnect when conversation is STOPPED");
|
||||
it.todo("should validate runtime status before connecting");
|
||||
});
|
||||
|
||||
// 7. Message Sending Tests
|
||||
describe("Message Sending", () => {
|
||||
it.todo("should send user actions through WebSocket when connected");
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
|
||||
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashAction event
|
||||
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBashActionEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context (we don't need a component, just need the provider to be active)
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the command to be added to the store
|
||||
await waitFor(() => {
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands.length).toBe(1);
|
||||
});
|
||||
|
||||
// Verify the command was added with correct type and content
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands[0].type).toBe("input");
|
||||
expect(commands[0].content).toBe("npm test");
|
||||
});
|
||||
|
||||
it("should append output to store when ExecuteBashObservation event is received", async () => {
|
||||
const { createMockExecuteBashObservationEvent } = await import(
|
||||
"#/mocks/mock-ws-helpers"
|
||||
);
|
||||
const { useCommandStore } = await import("#/state/command-store");
|
||||
|
||||
// Clear the command store before test
|
||||
useCommandStore.getState().clearTerminal();
|
||||
|
||||
// Create a mock ExecuteBashObservation event
|
||||
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
"npm test",
|
||||
);
|
||||
|
||||
// Set up MSW to send the event when connection is established
|
||||
mswServer.use(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send the mock event after connection
|
||||
client.send(JSON.stringify(mockBashObservationEvent));
|
||||
}),
|
||||
);
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
|
||||
// Wait for connection
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("connection-state")).toHaveTextContent(
|
||||
"OPEN",
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for the output to be added to the store
|
||||
await waitFor(() => {
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands.length).toBe(1);
|
||||
});
|
||||
|
||||
// Verify the output was added with correct type and content
|
||||
const { commands } = useCommandStore.getState();
|
||||
expect(commands[0].type).toBe("output");
|
||||
expect(commands[0].content).toBe(
|
||||
"PASS tests/example.test.js\n ✓ should work (2 ms)",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
frontend/__tests__/helpers/README.md
Normal file
46
frontend/__tests__/helpers/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Test Helpers
|
||||
|
||||
This directory contains reusable test utilities and components for the OpenHands frontend test suite.
|
||||
|
||||
## Files
|
||||
|
||||
### `websocket-test-components.tsx`
|
||||
Contains React test components for accessing and displaying WebSocket-related store values:
|
||||
- `ConnectionStatusComponent` - Displays WebSocket connection state
|
||||
- `EventStoreComponent` - Displays event store values (events count, UI events count, latest event ID)
|
||||
- `OptimisticUserMessageStoreComponent` - Displays optimistic user message store values
|
||||
- `ErrorMessageStoreComponent` - Displays error message store values
|
||||
|
||||
These components are designed to be used in tests to verify that WebSocket events are properly processed and stored.
|
||||
|
||||
|
||||
|
||||
### `msw-websocket-setup.ts`
|
||||
Contains MSW (Mock Service Worker) setup utilities for WebSocket testing:
|
||||
- `createWebSocketLink()` - Creates a WebSocket link for MSW testing
|
||||
- `createWebSocketMockServer()` - Creates and configures an MSW server for WebSocket testing
|
||||
- `createWebSocketTestSetup()` - Creates a complete WebSocket testing setup
|
||||
- `conversationWebSocketTestSetup()` - Standard setup for conversation WebSocket handler tests
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ConnectionStatusComponent,
|
||||
EventStoreComponent,
|
||||
} from "./__tests__/helpers/websocket-test-components";
|
||||
import { conversationWebSocketTestSetup } from "./__tests__/helpers/msw-websocket-setup";
|
||||
|
||||
// Set up MSW server
|
||||
const { wsLink, server } = conversationWebSocketTestSetup();
|
||||
|
||||
// Render components with WebSocket context (helper function defined in test file)
|
||||
renderWithWebSocketContext(<ConnectionStatusComponent />);
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Reusability**: Test components and utilities can be shared across multiple test files
|
||||
- **Maintainability**: Changes to test setup only need to be made in one place
|
||||
- **Consistency**: Ensures consistent test setup across different WebSocket-related tests
|
||||
- **Readability**: Test files are cleaner and focus on test logic rather than setup boilerplate
|
||||
45
frontend/__tests__/helpers/msw-websocket-setup.ts
Normal file
45
frontend/__tests__/helpers/msw-websocket-setup.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ws } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
|
||||
/**
|
||||
* Creates a WebSocket link for MSW testing
|
||||
* @param url - WebSocket URL to mock (default: "ws://localhost/events/socket")
|
||||
* @returns MSW WebSocket link
|
||||
*/
|
||||
export const createWebSocketLink = (url = "ws://localhost/events/socket") =>
|
||||
ws.link(url);
|
||||
|
||||
/**
|
||||
* Creates and configures an MSW server for WebSocket testing
|
||||
* @param wsLink - WebSocket link to use for the server
|
||||
* @returns Configured MSW server
|
||||
*/
|
||||
export const createWebSocketMockServer = (wsLink: ReturnType<typeof ws.link>) =>
|
||||
setupServer(
|
||||
wsLink.addEventListener("connection", ({ server }) => {
|
||||
server.connect();
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates a complete WebSocket testing setup with server and link
|
||||
* @param url - WebSocket URL to mock (default: "ws://localhost/events/socket")
|
||||
* @returns Object containing the WebSocket link and configured server
|
||||
*/
|
||||
export const createWebSocketTestSetup = (
|
||||
url = "ws://localhost/events/socket",
|
||||
) => {
|
||||
const wsLink = createWebSocketLink(url);
|
||||
const server = createWebSocketMockServer(wsLink);
|
||||
|
||||
return { wsLink, server };
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard WebSocket test setup for conversation WebSocket handler tests
|
||||
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
|
||||
*/
|
||||
export const conversationWebSocketTestSetup = () =>
|
||||
createWebSocketTestSetup(
|
||||
"ws://localhost:3000/sockets/events/test-conversation-default",
|
||||
);
|
||||
66
frontend/__tests__/helpers/websocket-test-components.tsx
Normal file
66
frontend/__tests__/helpers/websocket-test-components.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { isV1Event } from "#/types/v1/type-guards";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
|
||||
/**
|
||||
* Test component to access and display WebSocket connection state
|
||||
*/
|
||||
export function ConnectionStatusComponent() {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="connection-state">
|
||||
{context?.connectionState || "NOT_AVAILABLE"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test component to access and display event store values
|
||||
*/
|
||||
export function EventStoreComponent() {
|
||||
const { events, uiEvents } = useEventStore();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="events-count">{events.length}</div>
|
||||
<div data-testid="ui-events-count">{uiEvents.length}</div>
|
||||
<div data-testid="latest-event-id">
|
||||
{isV1Event(events[events.length - 1])
|
||||
? (events[events.length - 1] as OpenHandsEvent).id
|
||||
: "none"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test component to access and display optimistic user message store values
|
||||
*/
|
||||
export function OptimisticUserMessageStoreComponent() {
|
||||
const { optimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="optimistic-user-message">
|
||||
{optimisticUserMessage || "none"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test component to access and display error message store values
|
||||
*/
|
||||
export function ErrorMessageStoreComponent() {
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="error-message">{errorMessage || "none"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach } from "node:test";
|
||||
import { beforeAll, describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { useTerminal } from "#/hooks/use-terminal";
|
||||
import { Command, useCommandStore } from "#/state/command-store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
|
||||
// Mock the WsClient context
|
||||
vi.mock("#/context/ws-client-provider", () => ({
|
||||
@@ -16,15 +13,23 @@ vi.mock("#/context/ws-client-provider", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
interface TestTerminalComponentProps {
|
||||
commands: Command[];
|
||||
}
|
||||
// Mock useActiveConversation
|
||||
vi.mock("#/hooks/query/use-active-conversation", () => ({
|
||||
useActiveConversation: () => ({
|
||||
data: {
|
||||
id: "test-conversation-id",
|
||||
conversation_version: "V0",
|
||||
},
|
||||
isFetched: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
function TestTerminalComponent({ commands }: TestTerminalComponentProps) {
|
||||
// Set commands in Zustand store
|
||||
useCommandStore.setState({ commands });
|
||||
// Set agent state in Zustand store
|
||||
useAgentStore.setState({ curAgentState: AgentState.RUNNING });
|
||||
// Mock useConversationWebSocket (returns null for V0 conversations)
|
||||
vi.mock("#/contexts/conversation-websocket-context", () => ({
|
||||
useConversationWebSocket: () => null,
|
||||
}));
|
||||
|
||||
function TestTerminalComponent() {
|
||||
const ref = useTerminal();
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
@@ -57,10 +62,12 @@ describe("useTerminal", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset command store between tests
|
||||
useCommandStore.setState({ commands: [] });
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
renderWithProviders(<TestTerminalComponent commands={[]} />);
|
||||
renderWithProviders(<TestTerminalComponent />);
|
||||
});
|
||||
|
||||
it("should render the commands in the terminal", () => {
|
||||
@@ -69,26 +76,12 @@ describe("useTerminal", () => {
|
||||
{ content: "hello", type: "output" },
|
||||
];
|
||||
|
||||
renderWithProviders(<TestTerminalComponent commands={commands} />);
|
||||
// Set commands in store before rendering to ensure they're picked up during initialization
|
||||
useCommandStore.setState({ commands });
|
||||
|
||||
renderWithProviders(<TestTerminalComponent />);
|
||||
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(1, "echo hello");
|
||||
expect(mockTerminal.writeln).toHaveBeenNthCalledWith(2, "hello");
|
||||
});
|
||||
|
||||
// This test is no longer relevant as secrets filtering has been removed
|
||||
it.skip("should hide secrets in the terminal", () => {
|
||||
const secret = "super_secret_github_token";
|
||||
const anotherSecret = "super_secret_another_token";
|
||||
const commands: Command[] = [
|
||||
{
|
||||
content: `export GITHUB_TOKEN=${secret},${anotherSecret},${secret}`,
|
||||
type: "input",
|
||||
},
|
||||
{ content: secret, type: "output" },
|
||||
];
|
||||
|
||||
renderWithProviders(<TestTerminalComponent commands={commands} />);
|
||||
|
||||
// This test is no longer relevant as secrets filtering has been removed
|
||||
});
|
||||
});
|
||||
|
||||
316
frontend/__tests__/hooks/use-websocket.test.ts
Normal file
316
frontend/__tests__/hooks/use-websocket.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { ws } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { useWebSocket } from "#/hooks/use-websocket";
|
||||
|
||||
describe("useWebSocket", () => {
|
||||
// MSW WebSocket mock setup
|
||||
const wsLink = ws.link("ws://acme.com/ws");
|
||||
|
||||
const mswServer = setupServer(
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
// Establish the connection
|
||||
server.connect();
|
||||
|
||||
// Send a welcome message to confirm connection
|
||||
client.send("Welcome to the WebSocket!");
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(() => mswServer.listen());
|
||||
afterEach(() => mswServer.resetHandlers());
|
||||
afterAll(() => mswServer.close());
|
||||
|
||||
it("should establish a WebSocket connection", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Initially should not be connected
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.lastMessage).toBe(null);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Should receive the welcome message from our mock
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Welcome to the WebSocket!");
|
||||
});
|
||||
|
||||
// Confirm that the WebSocket connection is established when the hook is used
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle incoming messages correctly", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Should receive the welcome message from our mock
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Welcome to the WebSocket!");
|
||||
});
|
||||
|
||||
// Send another message from the mock server
|
||||
wsLink.broadcast("Hello from server!");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Hello from server!");
|
||||
});
|
||||
|
||||
// Should have a messages array with all received messages
|
||||
expect(result.current.messages).toEqual([
|
||||
"Welcome to the WebSocket!",
|
||||
"Hello from server!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle connection errors gracefully", async () => {
|
||||
// Create a mock that will simulate an error
|
||||
const errorLink = ws.link("ws://error-test.com/ws");
|
||||
mswServer.use(
|
||||
errorLink.addEventListener("connection", ({ client }) => {
|
||||
// Simulate an error by closing the connection immediately
|
||||
client.close(1006, "Connection failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useWebSocket("ws://error-test.com/ws"));
|
||||
|
||||
// Initially should not be connected and no error
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.error).toBe(null);
|
||||
|
||||
// Wait for the connection to fail
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
// Should have error information (the close event should trigger error state)
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).not.toBe(null);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
// Should have meaningful error message (could be from onerror or onclose)
|
||||
expect(
|
||||
result.current.error?.message.includes("WebSocket closed with code 1006"),
|
||||
).toBe(true);
|
||||
|
||||
// Should not crash the application
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should close the WebSocket connection on unmount", async () => {
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws"),
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Verify connection is active
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
|
||||
const closeSpy = vi.spyOn(result.current.socket!, "close");
|
||||
|
||||
// Unmount the component (this should trigger the useEffect cleanup)
|
||||
unmount();
|
||||
|
||||
// Verify that WebSocket close was called during cleanup
|
||||
expect(closeSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should support query parameters in WebSocket URL", async () => {
|
||||
const baseUrl = "ws://acme.com/ws";
|
||||
const queryParams = {
|
||||
token: "abc123",
|
||||
userId: "user456",
|
||||
version: "v1",
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useWebSocket(baseUrl, { queryParams }));
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Verify that the WebSocket was created with query parameters
|
||||
expect(result.current.socket).toBeTruthy();
|
||||
expect(result.current.socket!.url).toBe(
|
||||
"ws://acme.com/ws?token=abc123&userId=user456&version=v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onOpen handler when WebSocket connection opens", async () => {
|
||||
const onOpenSpy = vi.fn();
|
||||
const options = { onOpen: onOpenSpy };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
);
|
||||
|
||||
// Initially should not be connected
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(onOpenSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// onOpen handler should have been called
|
||||
expect(onOpenSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onClose handler when WebSocket connection closes", async () => {
|
||||
const onCloseSpy = vi.fn();
|
||||
const options = { onClose: onCloseSpy };
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
expect(onCloseSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount to trigger close
|
||||
unmount();
|
||||
|
||||
// Wait for onClose handler to be called
|
||||
await waitFor(() => {
|
||||
expect(onCloseSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onMessage handler when WebSocket receives a message", async () => {
|
||||
const onMessageSpy = vi.fn();
|
||||
const options = { onMessage: onMessageSpy };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Should receive the welcome message from our mock
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Welcome to the WebSocket!");
|
||||
});
|
||||
|
||||
// onMessage handler should have been called for the welcome message
|
||||
expect(onMessageSpy).toHaveBeenCalledOnce();
|
||||
|
||||
// Send another message from the mock server
|
||||
wsLink.broadcast("Hello from server!");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.lastMessage).toBe("Hello from server!");
|
||||
});
|
||||
|
||||
// onMessage handler should have been called twice now
|
||||
expect(onMessageSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should call onError handler when WebSocket encounters an error", async () => {
|
||||
const onErrorSpy = vi.fn();
|
||||
const options = { onError: onErrorSpy };
|
||||
|
||||
// Create a mock that will simulate an error
|
||||
const errorLink = ws.link("ws://error-test.com/ws");
|
||||
mswServer.use(
|
||||
errorLink.addEventListener("connection", ({ client }) => {
|
||||
// Simulate an error by closing the connection immediately
|
||||
client.close(1006, "Connection failed");
|
||||
}),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useWebSocket("ws://error-test.com/ws", options),
|
||||
);
|
||||
|
||||
// Initially should not be connected and no error
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(onErrorSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Wait for the connection to fail
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
// Should have error information
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).not.toBe(null);
|
||||
});
|
||||
|
||||
// onError handler should have been called
|
||||
expect(onErrorSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should provide sendMessage function to send messages to WebSocket", async () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Should have a sendMessage function
|
||||
expect(result.current.sendMessage).toBeDefined();
|
||||
expect(typeof result.current.sendMessage).toBe("function");
|
||||
|
||||
// Mock the WebSocket send method
|
||||
const sendSpy = vi.spyOn(result.current.socket!, "send");
|
||||
|
||||
// Send a message
|
||||
result.current.sendMessage("Hello WebSocket!");
|
||||
|
||||
// Verify that WebSocket.send was called with the correct message
|
||||
expect(sendSpy).toHaveBeenCalledOnce();
|
||||
expect(sendSpy).toHaveBeenCalledWith("Hello WebSocket!");
|
||||
});
|
||||
|
||||
it("should not send message when WebSocket is not connected", () => {
|
||||
const { result } = renderHook(() => useWebSocket("ws://acme.com/ws"));
|
||||
|
||||
// Initially should not be connected
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
expect(result.current.sendMessage).toBeDefined();
|
||||
|
||||
// Mock the WebSocket send method (even though socket might be null)
|
||||
const sendSpy = vi.fn();
|
||||
if (result.current.socket) {
|
||||
vi.spyOn(result.current.socket, "send").mockImplementation(sendSpy);
|
||||
}
|
||||
|
||||
// Try to send a message when not connected
|
||||
result.current.sendMessage("Hello WebSocket!");
|
||||
|
||||
// Verify that WebSocket.send was not called
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -105,10 +105,17 @@ describe("Content", () => {
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should conditionally show security analyzer based on confirmation mode", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Enable advanced mode first
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
const confirmation = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
@@ -135,9 +142,7 @@ describe("Content", () => {
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should render the advanced form if the switch is toggled", async () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
@@ -615,7 +620,7 @@ describe("Form submission", () => {
|
||||
expect.objectContaining({
|
||||
llm_model: "openhands/claude-sonnet-4-20250514",
|
||||
llm_base_url: "",
|
||||
confirmation_mode: true, // Confirmation mode is now a basic setting, should be preserved
|
||||
confirmation_mode: false, // Confirmation mode is now an advanced setting, should be cleared when saving basic settings
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -776,9 +781,6 @@ describe("SaaS mode", () => {
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
// Inputs should be disabled
|
||||
@@ -786,9 +788,13 @@ describe("SaaS mode", () => {
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, so it's not visible in basic view
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to interact with inputs - they should not respond
|
||||
await userEvent.click(providerInput);
|
||||
await userEvent.type(apiKeyInput, "test-key");
|
||||
@@ -935,19 +941,17 @@ describe("SaaS mode", () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify that form elements are disabled for unsubscribed users
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
// Verify that basic form elements are disabled for unsubscribed users
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
expect(confirmationModeSwitch).not.toBeChecked();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Try to click the disabled confirmation mode switch - it should not change state
|
||||
await userEvent.click(confirmationModeSwitch);
|
||||
expect(confirmationModeSwitch).not.toBeChecked(); // Should remain unchecked
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to submit the form - button should remain disabled
|
||||
await userEvent.click(submitButton);
|
||||
@@ -1107,14 +1111,17 @@ describe("SaaS mode", () => {
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
130
frontend/__tests__/stores/use-event-store.test.ts
Normal file
130
frontend/__tests__/stores/use-event-store.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import {
|
||||
ActionEvent,
|
||||
MessageEvent,
|
||||
ObservationEvent,
|
||||
SecurityRisk,
|
||||
} from "#/types/v1/core";
|
||||
|
||||
const mockUserMessageEvent: MessageEvent = {
|
||||
id: "test-event-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "user",
|
||||
llm_message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
};
|
||||
|
||||
const mockActionEvent: ActionEvent = {
|
||||
id: "test-action-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "agent",
|
||||
thought: [{ type: "text", text: "I need to execute a bash command" }],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "ExecuteBashAction",
|
||||
command: "echo hello",
|
||||
is_input: false,
|
||||
timeout: null,
|
||||
reset: false,
|
||||
},
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call_123",
|
||||
tool_call: {
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "execute_bash",
|
||||
arguments: '{"command": "echo hello"}',
|
||||
},
|
||||
},
|
||||
llm_response_id: "response_123",
|
||||
security_risk: SecurityRisk.UNKNOWN,
|
||||
};
|
||||
|
||||
const mockObservationEvent: ObservationEvent = {
|
||||
id: "test-observation-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "environment",
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call_123",
|
||||
observation: {
|
||||
kind: "ExecuteBashObservation",
|
||||
output: "hello\n",
|
||||
command: "echo hello",
|
||||
exit_code: 0,
|
||||
error: false,
|
||||
timeout: false,
|
||||
metadata: {
|
||||
exit_code: 0,
|
||||
pid: 12345,
|
||||
username: "user",
|
||||
hostname: "localhost",
|
||||
working_dir: "/home/user",
|
||||
py_interpreter_path: null,
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
},
|
||||
},
|
||||
action_id: "test-action-1",
|
||||
};
|
||||
|
||||
describe("useEventStore", () => {
|
||||
it("should render initial state correctly", () => {
|
||||
const { result } = renderHook(() => useEventStore());
|
||||
expect(result.current.events).toEqual([]);
|
||||
});
|
||||
|
||||
it("should add an event to the store", () => {
|
||||
const { result } = renderHook(() => useEventStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addEvent(mockUserMessageEvent);
|
||||
});
|
||||
|
||||
expect(result.current.events).toEqual([mockUserMessageEvent]);
|
||||
});
|
||||
|
||||
it("should retrieve events whose actions are replaced by their observations", () => {
|
||||
const { result } = renderHook(() => useEventStore());
|
||||
|
||||
act(() => {
|
||||
result.current.addEvent(mockUserMessageEvent);
|
||||
result.current.addEvent(mockActionEvent);
|
||||
result.current.addEvent(mockObservationEvent);
|
||||
});
|
||||
|
||||
expect(result.current.uiEvents).toEqual([
|
||||
mockUserMessageEvent,
|
||||
mockObservationEvent,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should clear all events when clearEvents is called", () => {
|
||||
const { result } = renderHook(() => useEventStore());
|
||||
|
||||
// Add some events first
|
||||
act(() => {
|
||||
result.current.addEvent(mockUserMessageEvent);
|
||||
result.current.addEvent(mockActionEvent);
|
||||
});
|
||||
|
||||
// Verify events were added
|
||||
expect(result.current.events).toHaveLength(2);
|
||||
expect(result.current.uiEvents).toHaveLength(2);
|
||||
|
||||
// Clear events
|
||||
act(() => {
|
||||
result.current.clearEvents();
|
||||
});
|
||||
|
||||
// Verify events were cleared
|
||||
expect(result.current.events).toEqual([]);
|
||||
expect(result.current.uiEvents).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ describe("Check for hardcoded English strings", () => {
|
||||
test("InteractiveChatBox should not have hardcoded English strings", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />
|
||||
<InteractiveChatBox onSubmit={() => {}} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
|
||||
141
frontend/__tests__/utils/handle-event-for-ui.test.ts
Normal file
141
frontend/__tests__/utils/handle-event-for-ui.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ActionEvent,
|
||||
ObservationEvent,
|
||||
MessageEvent,
|
||||
SecurityRisk,
|
||||
OpenHandsEvent,
|
||||
} from "#/types/v1/core";
|
||||
import { handleEventForUI } from "#/utils/handle-event-for-ui";
|
||||
|
||||
describe("handleEventForUI", () => {
|
||||
const mockObservationEvent: ObservationEvent = {
|
||||
id: "test-observation-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "environment",
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call_123",
|
||||
observation: {
|
||||
kind: "ExecuteBashObservation",
|
||||
output: "hello\n",
|
||||
command: "echo hello",
|
||||
exit_code: 0,
|
||||
error: false,
|
||||
timeout: false,
|
||||
metadata: {
|
||||
exit_code: 0,
|
||||
pid: 12345,
|
||||
username: "user",
|
||||
hostname: "localhost",
|
||||
working_dir: "/home/user",
|
||||
py_interpreter_path: null,
|
||||
prefix: "",
|
||||
suffix: "",
|
||||
},
|
||||
},
|
||||
action_id: "test-action-1",
|
||||
};
|
||||
|
||||
const mockActionEvent: ActionEvent = {
|
||||
id: "test-action-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "agent",
|
||||
thought: [{ type: "text", text: "I need to execute a bash command" }],
|
||||
thinking_blocks: [],
|
||||
action: {
|
||||
kind: "ExecuteBashAction",
|
||||
command: "echo hello",
|
||||
is_input: false,
|
||||
timeout: null,
|
||||
reset: false,
|
||||
},
|
||||
tool_name: "execute_bash",
|
||||
tool_call_id: "call_123",
|
||||
tool_call: {
|
||||
id: "call_123",
|
||||
type: "function",
|
||||
function: {
|
||||
name: "execute_bash",
|
||||
arguments: '{"command": "echo hello"}',
|
||||
},
|
||||
},
|
||||
llm_response_id: "response_123",
|
||||
security_risk: SecurityRisk.UNKNOWN,
|
||||
};
|
||||
|
||||
const mockMessageEvent: MessageEvent = {
|
||||
id: "test-event-1",
|
||||
timestamp: Date.now().toString(),
|
||||
source: "user",
|
||||
llm_message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
},
|
||||
activated_microagents: [],
|
||||
extended_content: [],
|
||||
};
|
||||
|
||||
it("should add non-observation events to the end of uiEvents", () => {
|
||||
const initialUiEvents = [mockMessageEvent];
|
||||
const result = handleEventForUI(mockActionEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([mockMessageEvent, mockActionEvent]);
|
||||
expect(result).not.toBe(initialUiEvents); // Should return a new array
|
||||
});
|
||||
|
||||
it("should replace corresponding action with observation when action exists", () => {
|
||||
const initialUiEvents = [mockMessageEvent, mockActionEvent];
|
||||
const result = handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([mockMessageEvent, mockObservationEvent]);
|
||||
expect(result).not.toBe(initialUiEvents); // Should return a new array
|
||||
});
|
||||
|
||||
it("should add observation to end when corresponding action is not found", () => {
|
||||
const initialUiEvents = [mockMessageEvent];
|
||||
const result = handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([mockMessageEvent, mockObservationEvent]);
|
||||
expect(result).not.toBe(initialUiEvents); // Should return a new array
|
||||
});
|
||||
|
||||
it("should handle empty uiEvents array", () => {
|
||||
const initialUiEvents: OpenHandsEvent[] = [];
|
||||
const result = handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([mockObservationEvent]);
|
||||
expect(result).not.toBe(initialUiEvents); // Should return a new array
|
||||
});
|
||||
|
||||
it("should not mutate the original uiEvents array", () => {
|
||||
const initialUiEvents = [mockMessageEvent, mockActionEvent];
|
||||
const originalLength = initialUiEvents.length;
|
||||
const originalFirstEvent = initialUiEvents[0];
|
||||
|
||||
handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(initialUiEvents).toHaveLength(originalLength);
|
||||
expect(initialUiEvents[0]).toBe(originalFirstEvent);
|
||||
expect(initialUiEvents[1]).toBe(mockActionEvent); // Should not be replaced
|
||||
});
|
||||
|
||||
it("should replace the correct action when multiple actions exist", () => {
|
||||
const anotherActionEvent: ActionEvent = {
|
||||
...mockActionEvent,
|
||||
id: "test-action-2",
|
||||
};
|
||||
|
||||
const initialUiEvents = [
|
||||
mockMessageEvent,
|
||||
mockActionEvent,
|
||||
anotherActionEvent,
|
||||
];
|
||||
const result = handleEventForUI(mockObservationEvent, initialUiEvents);
|
||||
|
||||
expect(result).toEqual([
|
||||
mockMessageEvent,
|
||||
mockObservationEvent,
|
||||
anotherActionEvent,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -24,4 +24,5 @@ test("mapProvider", () => {
|
||||
expect(mapProvider("replicate")).toBe("Replicate");
|
||||
expect(mapProvider("voyage")).toBe("Voyage AI");
|
||||
expect(mapProvider("openrouter")).toBe("OpenRouter");
|
||||
expect(mapProvider("clarifai")).toBe("Clarifai");
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
CreateMicroagent,
|
||||
FileUploadSuccessResponse,
|
||||
GetFilesResponse,
|
||||
GetFileResponse,
|
||||
} from "../open-hands.types";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { Provider } from "#/types/settings";
|
||||
@@ -121,7 +120,7 @@ class ConversationService {
|
||||
reason?: string;
|
||||
}>(url);
|
||||
return data;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Error checking if feedback exists
|
||||
return { exists: false };
|
||||
}
|
||||
@@ -159,19 +158,6 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
*/
|
||||
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/zip-directory`;
|
||||
const response = await openHands.get(url, {
|
||||
responseType: "blob",
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the web hosts
|
||||
* @returns Array of web hosts
|
||||
@@ -379,22 +365,6 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file
|
||||
* @param conversationId ID of the conversation
|
||||
* @param path Full path of the file to retrieve
|
||||
* @returns Code content of the file
|
||||
*/
|
||||
static async getFile(conversationId: string, path: string): Promise<string> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/select-file`;
|
||||
const { data } = await openHands.get<GetFileResponse>(url, {
|
||||
params: { file: path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
return data.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload multiple files to the workspace
|
||||
* @param conversationId ID of the conversation
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import axios from "axios";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import type {
|
||||
V1SendMessageRequest,
|
||||
V1SendMessageResponse,
|
||||
V1AppConversationStartRequest,
|
||||
V1AppConversationStartTask,
|
||||
V1AppConversationStartTaskPage,
|
||||
V1AppConversation,
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
private static buildSessionHeaders(
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param path The API path (e.g., "/api/vscode/url")
|
||||
* @returns Full URL to the runtime endpoint
|
||||
*/
|
||||
private static buildRuntimeUrl(
|
||||
conversationUrl: string | null | undefined,
|
||||
path: string,
|
||||
): string {
|
||||
const baseUrl = buildHttpBaseUrl(conversationUrl);
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param message The message to send
|
||||
* @returns The sent message response
|
||||
*/
|
||||
static async sendMessage(
|
||||
conversationId: string,
|
||||
message: V1SendMessageRequest,
|
||||
): Promise<V1SendMessageResponse> {
|
||||
const { data } = await openHands.post<V1SendMessageResponse>(
|
||||
`/api/conversations/${conversationId}/events`,
|
||||
message,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new V1 conversation using the app-conversations API
|
||||
* Returns the start task immediately with app_conversation_id as null.
|
||||
* You must poll getStartTask() until status is READY to get the conversation ID.
|
||||
*
|
||||
* @returns AppConversationStartTask with task ID
|
||||
*/
|
||||
static async createConversation(
|
||||
selectedRepository?: string,
|
||||
git_provider?: Provider,
|
||||
initialUserMsg?: string,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
trigger?: ConversationTrigger,
|
||||
): Promise<V1AppConversationStartTask> {
|
||||
const body: V1AppConversationStartRequest = {
|
||||
selected_repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
title: conversationInstructions,
|
||||
trigger,
|
||||
};
|
||||
|
||||
// Add initial message if provided
|
||||
if (initialUserMsg) {
|
||||
body.initial_message = {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: initialUserMsg,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = await openHands.post<V1AppConversationStartTask>(
|
||||
"/api/v1/app-conversations",
|
||||
body,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a start task by ID
|
||||
* Poll this endpoint until status is READY to get the app_conversation_id
|
||||
*
|
||||
* @param taskId The task UUID
|
||||
* @returns AppConversationStartTask or null
|
||||
*/
|
||||
static async getStartTask(
|
||||
taskId: string,
|
||||
): Promise<V1AppConversationStartTask | null> {
|
||||
const { data } = await openHands.get<(V1AppConversationStartTask | null)[]>(
|
||||
`/api/v1/app-conversations/start-tasks?ids=${taskId}`,
|
||||
);
|
||||
|
||||
return data[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for start tasks (ongoing tasks that haven't completed yet)
|
||||
* Use this to find tasks that were started but the user navigated away
|
||||
*
|
||||
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
|
||||
* filter the results client-side after fetching.
|
||||
*
|
||||
* @param limit Maximum number of tasks to return (max 100)
|
||||
* @returns Array of start tasks
|
||||
*/
|
||||
static async searchStartTasks(
|
||||
limit: number = 100,
|
||||
): Promise<V1AppConversationStartTask[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
|
||||
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
|
||||
);
|
||||
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VSCode URL for a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
* Note: V1 endpoint doesn't require conversationId in the URL path - it's identified via session API key header
|
||||
*
|
||||
* @param _conversationId The conversation ID (not used in V1, kept for interface compatibility)
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns VSCode URL response
|
||||
*/
|
||||
static async getVSCodeUrl(
|
||||
_conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
||||
// Map it to match the expected interface
|
||||
const { data } = await axios.get<{ url: string | null }>(url, { headers });
|
||||
return {
|
||||
vscode_url: data.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 conversation
|
||||
* Uses the custom runtime URL from the conversation
|
||||
*
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseConversation(
|
||||
conversationId: string,
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<{ success: boolean }> {
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/pause`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
{},
|
||||
{ headers },
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/pause endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to pause
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/resume endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to resume
|
||||
* @returns Success response
|
||||
*/
|
||||
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/resume`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 app conversations by their IDs
|
||||
* Returns null for any missing conversations
|
||||
*
|
||||
* @param ids Array of conversation IDs (max 100)
|
||||
* @returns Array of conversations or null for missing ones
|
||||
*/
|
||||
static async batchGetAppConversations(
|
||||
ids: string[],
|
||||
): Promise<(V1AppConversation | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 conversations at once");
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("ids", id));
|
||||
|
||||
const { data } = await openHands.get<(V1AppConversation | null)[]>(
|
||||
`/api/v1/app-conversations?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ConversationTrigger } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
// V1 API Types for requests
|
||||
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
|
||||
export interface V1MessageContent {
|
||||
type: "text" | "image_url";
|
||||
text?: string;
|
||||
image_url?: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
type V1Role = "user" | "system" | "assistant" | "tool";
|
||||
|
||||
export interface V1SendMessageRequest {
|
||||
role: V1Role;
|
||||
content: V1MessageContent[];
|
||||
}
|
||||
|
||||
export interface V1AppConversationStartRequest {
|
||||
sandbox_id?: string | null;
|
||||
initial_message?: V1SendMessageRequest | null;
|
||||
processors?: unknown[]; // EventCallbackProcessor - keeping as unknown for now
|
||||
llm_model?: string | null;
|
||||
selected_repository?: string | null;
|
||||
selected_branch?: string | null;
|
||||
git_provider?: Provider | null;
|
||||
title?: string | null;
|
||||
trigger?: ConversationTrigger | null;
|
||||
pr_number?: number[];
|
||||
}
|
||||
|
||||
export type V1AppConversationStartTaskStatus =
|
||||
| "WORKING"
|
||||
| "WAITING_FOR_SANDBOX"
|
||||
| "PREPARING_REPOSITORY"
|
||||
| "RUNNING_SETUP_SCRIPT"
|
||||
| "SETTING_UP_GIT_HOOKS"
|
||||
| "STARTING_CONVERSATION"
|
||||
| "READY"
|
||||
| "ERROR";
|
||||
|
||||
export interface V1AppConversationStartTask {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
status: V1AppConversationStartTaskStatus;
|
||||
detail: string | null;
|
||||
app_conversation_id: string | null;
|
||||
sandbox_id: string | null;
|
||||
agent_server_url: string | null;
|
||||
request: V1AppConversationStartRequest;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface V1SendMessageResponse {
|
||||
role: "user" | "system" | "assistant" | "tool";
|
||||
content: V1MessageContent[];
|
||||
}
|
||||
|
||||
export interface V1AppConversationStartTaskPage {
|
||||
items: V1AppConversationStartTask[];
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
export type V1SandboxStatus =
|
||||
| "MISSING"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "PAUSED";
|
||||
|
||||
export type V1AgentExecutionStatus =
|
||||
| "RUNNING"
|
||||
| "AWAITING_USER_INPUT"
|
||||
| "AWAITING_USER_CONFIRMATION"
|
||||
| "FINISHED"
|
||||
| "PAUSED"
|
||||
| "STOPPED";
|
||||
|
||||
export interface V1AppConversation {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
sandbox_id: string;
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: Provider | null;
|
||||
title: string | null;
|
||||
trigger: ConversationTrigger | null;
|
||||
pr_number: number[];
|
||||
llm_model: string | null;
|
||||
metrics: unknown | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sandbox_status: V1SandboxStatus;
|
||||
agent_status: V1AgentExecutionStatus | null;
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export interface Conversation {
|
||||
url: string | null;
|
||||
session_api_key: string | null;
|
||||
pr_number?: number[] | null;
|
||||
conversation_version?: "V0" | "V1";
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -7,33 +7,46 @@ import { TrajectoryActions } from "../trajectory/trajectory-actions";
|
||||
import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { isOpenHandsAction } from "#/types/core/guards";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { TypingIndicator } from "./typing-indicator";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { Messages as V0Messages } from "./messages";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ScrollProvider } from "#/context/scroll-context";
|
||||
import { useInitialQueryStore } from "#/stores/initial-query-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useErrorMessageStore } from "#/stores/error-message-store";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { ErrorMessageBanner } from "./error-message-banner";
|
||||
import {
|
||||
hasUserEvent,
|
||||
shouldRenderEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
import {
|
||||
Messages as V1Messages,
|
||||
hasUserEvent as hasV1UserEvent,
|
||||
shouldRenderEvent as shouldRenderV1Event,
|
||||
} from "#/components/v1/chat";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
|
||||
import {
|
||||
isV0Event,
|
||||
isV1Event,
|
||||
isSystemPromptEvent,
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -46,8 +59,11 @@ function getEntryPoint(
|
||||
|
||||
export function ChatInterface() {
|
||||
const { setMessageToSend } = useConversationStore();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { send, isLoadingMessages, parsedEvents } = useWsClient();
|
||||
const { isLoadingMessages } = useWsClient();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -62,7 +78,7 @@ export function ChatInterface() {
|
||||
} = useScrollToBottom(scrollRef);
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
|
||||
"positive" | "negative"
|
||||
@@ -74,18 +90,41 @@ export function ChatInterface() {
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const events = parsedEvents.filter(shouldRenderEvent);
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events
|
||||
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1Events.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
parsedEvents.some(
|
||||
(event) =>
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
),
|
||||
[parsedEvents],
|
||||
storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.some(
|
||||
(event) =>
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
) ||
|
||||
storeEvents
|
||||
.filter(isV1Event)
|
||||
.some(
|
||||
(event) =>
|
||||
event.source === "agent" &&
|
||||
!isSystemPromptEvent(event) &&
|
||||
!isConversationStateUpdateEvent(event),
|
||||
),
|
||||
[storeEvents],
|
||||
);
|
||||
|
||||
const handleSendMessage = async (
|
||||
@@ -96,7 +135,7 @@ export function ChatInterface() {
|
||||
// Create mutable copies of the arrays
|
||||
const images = [...originalImages];
|
||||
const files = [...originalFiles];
|
||||
if (events.length === 0) {
|
||||
if (totalEvents === 0) {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: getEntryPoint(
|
||||
selectedRepository !== null,
|
||||
@@ -107,7 +146,7 @@ export function ChatInterface() {
|
||||
});
|
||||
} else {
|
||||
posthog.capture("user_message_sent", {
|
||||
session_message_count: events.length,
|
||||
session_message_count: totalEvents,
|
||||
current_message_length: content.length,
|
||||
});
|
||||
}
|
||||
@@ -142,11 +181,6 @@ export function ChatInterface() {
|
||||
setMessageToSend("");
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
posthog.capture("stop_button_clicked");
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const onClickShareFeedbackActionButton = async (
|
||||
polarity: "positive" | "negative",
|
||||
) => {
|
||||
@@ -165,7 +199,9 @@ export function ChatInterface() {
|
||||
onChatBodyScroll,
|
||||
};
|
||||
|
||||
const userEventsExist = hasUserEvent(events);
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1Events);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
return (
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
@@ -184,15 +220,24 @@ export function ChatInterface() {
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
|
||||
>
|
||||
{isLoadingMessages && (
|
||||
{isLoadingMessages && !isV1Conversation && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && userEventsExist && (
|
||||
<Messages
|
||||
messages={events}
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{v1UserEventsExist && (
|
||||
<V1Messages
|
||||
messages={v1Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
@@ -204,7 +249,7 @@ export function ChatInterface() {
|
||||
<div className="flex justify-between relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<ConfirmationModeEnabled />
|
||||
{events.length > 0 && (
|
||||
{totalEvents > 0 && (
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={() =>
|
||||
onClickShareFeedbackActionButton("positive")
|
||||
@@ -226,10 +271,7 @@ export function ChatInterface() {
|
||||
|
||||
{errorMessage && <ErrorMessageBanner message={errorMessage} />}
|
||||
|
||||
<InteractiveChatBox
|
||||
onSubmit={handleSendMessage}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<InteractiveChatBox onSubmit={handleSendMessage} />
|
||||
</div>
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
|
||||
@@ -2,33 +2,73 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { Tools } from "../../controls/tools";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
disabled: boolean;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputActions({
|
||||
conversationStatus,
|
||||
disabled,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onStop,
|
||||
}: ChatInputActionsProps) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
|
||||
const resumeConversationSandboxMutation =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { conversationId } = useConversationId();
|
||||
const { providers } = useUserProviders();
|
||||
const { send } = useSendMessage();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const handleStopClick = () => {
|
||||
pauseConversationSandboxMutation.mutate({ conversationId });
|
||||
};
|
||||
|
||||
const handlePauseAgent = () => {
|
||||
if (isV1Conversation) {
|
||||
// V1: Empty function for now
|
||||
return;
|
||||
}
|
||||
|
||||
// V0: Send agent state change event to stop the agent
|
||||
send(generateAgentStateChangeEvent(AgentState.STOPPED));
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
resumeConversationSandboxMutation.mutate({ conversationId, providers });
|
||||
};
|
||||
|
||||
const isPausing = pauseConversationSandboxMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
handleStop={handleStopClick}
|
||||
handleResumeAgent={handleStartClick}
|
||||
/>
|
||||
</div>
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
handleStop={() => handleStop(onStop)}
|
||||
handleStop={handlePauseAgent}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
isPausing={isPausing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ interface ChatInputContainerProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
handleSubmit: () => void;
|
||||
handleStop: (onStop?: () => void) => void;
|
||||
handleResumeAgent: () => void;
|
||||
onDragOver: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
onDragLeave: (e: React.DragEvent, isDisabled: boolean) => void;
|
||||
@@ -25,7 +24,6 @@ interface ChatInputContainerProps {
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputContainer({
|
||||
@@ -38,7 +36,6 @@ export function ChatInputContainer({
|
||||
chatInputRef,
|
||||
handleFileIconClick,
|
||||
handleSubmit,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
@@ -48,7 +45,6 @@ export function ChatInputContainer({
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onStop,
|
||||
}: ChatInputContainerProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -80,9 +76,7 @@ export function ChatInputContainer({
|
||||
<ChatInputActions
|
||||
conversationStatus={conversationStatus}
|
||||
disabled={disabled}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface CustomChatInputProps {
|
||||
showButton?: boolean;
|
||||
conversationStatus?: ConversationStatus | null;
|
||||
onSubmit: (message: string) => void;
|
||||
onStop?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onFilesPaste?: (files: File[]) => void;
|
||||
@@ -28,7 +27,6 @@ export function CustomChatInput({
|
||||
showButton = true,
|
||||
conversationStatus = null,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onFilesPaste,
|
||||
@@ -88,7 +86,7 @@ export function CustomChatInput({
|
||||
messageToSend,
|
||||
);
|
||||
|
||||
const { handleSubmit, handleResumeAgent, handleStop } = useChatSubmission(
|
||||
const { handleSubmit, handleResumeAgent } = useChatSubmission(
|
||||
chatInputRef as React.RefObject<HTMLDivElement | null>,
|
||||
fileInputRef as React.RefObject<HTMLInputElement | null>,
|
||||
smartResize,
|
||||
@@ -143,7 +141,6 @@ export function CustomChatInput({
|
||||
chatInputRef={chatInputRef}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -153,7 +150,6 @@ export function CustomChatInput({
|
||||
onKeyDown={(e) => handleKeyDown(e, isDisabled, handleSubmit)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,18 +6,14 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { GitControlBar } from "./git-control-bar";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { processFiles, processImages } from "#/utils/file-processing";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({
|
||||
onSubmit,
|
||||
onStop,
|
||||
}: InteractiveChatBoxProps) {
|
||||
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
|
||||
const {
|
||||
images,
|
||||
files,
|
||||
@@ -29,7 +25,7 @@ export function InteractiveChatBox({
|
||||
addImageLoading,
|
||||
removeImageLoading,
|
||||
} = useConversationStore();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
// Helper function to validate and filter files
|
||||
@@ -120,7 +116,7 @@ export function InteractiveChatBox({
|
||||
|
||||
// Step 5: Handle failed results
|
||||
handleFailedFiles(fileResults, imageResults);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Clear loading states and show error
|
||||
clearLoadingStates(validFiles, validImages);
|
||||
displayErrorToast("An unexpected error occurred while processing files");
|
||||
@@ -145,7 +141,6 @@ export function InteractiveChatBox({
|
||||
<CustomChatInput
|
||||
disabled={isDisabled}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
onFilesPaste={handleUpload}
|
||||
conversationStatus={conversation?.status || null}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useStatusStore } from "#/state/status-store";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { getStatusCode } from "#/utils/status";
|
||||
import { ChatStopButton } from "../chat/chat-stop-button";
|
||||
@@ -12,13 +11,15 @@ import { cn } from "#/utils/utils";
|
||||
import { AgentLoading } from "./agent-loading";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import CircleErrorIcon from "#/icons/circle-error.svg?react";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||
|
||||
export interface AgentStatusProps {
|
||||
className?: string;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
disabled?: boolean;
|
||||
isPausing?: boolean;
|
||||
}
|
||||
|
||||
export function AgentStatus({
|
||||
@@ -26,12 +27,13 @@ export function AgentStatus({
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
disabled = false,
|
||||
isPausing = false,
|
||||
}: AgentStatusProps) {
|
||||
const { t } = useTranslation();
|
||||
const { setShouldShownAgentLoading } = useConversationStore();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { curStatusMessage } = useStatusStore();
|
||||
const { webSocketStatus } = useWsClient();
|
||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const statusCode = getStatusCode(
|
||||
@@ -43,6 +45,7 @@ export function AgentStatus({
|
||||
);
|
||||
|
||||
const shouldShownAgentLoading =
|
||||
isPausing ||
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING ||
|
||||
webSocketStatus === "CONNECTING";
|
||||
|
||||
@@ -5,31 +5,29 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ServerStatusContextMenu } from "./server-status-context-menu";
|
||||
import { useStartConversation } from "#/hooks/mutation/use-start-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
|
||||
export interface ServerStatusProps {
|
||||
className?: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
isPausing?: boolean;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ServerStatus({
|
||||
className = "",
|
||||
conversationStatus,
|
||||
isPausing = false,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
}: ServerStatusProps) {
|
||||
const [showContextMenu, setShowContextMenu] = useState(false);
|
||||
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
// Mutation hooks
|
||||
const stopConversationMutation = useStopConversation();
|
||||
const startConversationMutation = useStartConversation();
|
||||
const { providers } = useUserProviders();
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
@@ -38,6 +36,19 @@ export function ServerStatus({
|
||||
|
||||
// Get the appropriate color based on agent status
|
||||
const getStatusColor = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return "#FFD600";
|
||||
}
|
||||
@@ -52,6 +63,31 @@ export function ServerStatus({
|
||||
|
||||
// Get the appropriate status text based on agent status
|
||||
const getStatusText = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return t(I18nKey.COMMON$STOPPING);
|
||||
}
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return (
|
||||
taskDetail || t(I18nKey.CONVERSATION$ERROR_STARTING_CONVERSATION)
|
||||
);
|
||||
}
|
||||
if (taskStatus === "READY") {
|
||||
return t(I18nKey.CONVERSATION$READY);
|
||||
}
|
||||
// Format status text: "WAITING_FOR_SANDBOX" -> "Waiting for sandbox"
|
||||
return (
|
||||
taskDetail ||
|
||||
taskStatus
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return t(I18nKey.COMMON$STARTING);
|
||||
}
|
||||
@@ -76,16 +112,13 @@ export function ServerStatus({
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
stopConversationMutation.mutate({ conversationId });
|
||||
handleStop();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
startConversationMutation.mutate({
|
||||
conversationId,
|
||||
providers,
|
||||
});
|
||||
handleResumeAgent();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export function ConversationCardActions({
|
||||
conversationId,
|
||||
showOptions,
|
||||
}: ConversationCardActionsProps) {
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
<button
|
||||
@@ -37,7 +39,10 @@ export function ConversationCardActions({
|
||||
event.stopPropagation();
|
||||
onContextMenuToggle(!contextMenuOpen);
|
||||
}}
|
||||
className="cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5"
|
||||
className={cn(
|
||||
"cursor-pointer w-6 h-6 flex flex-row items-center justify-center translate-x-2.5",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<EllipsisIcon />
|
||||
</button>
|
||||
|
||||
@@ -5,22 +5,32 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import { NoRepository } from "./no-repository";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
|
||||
interface ConversationCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
}
|
||||
|
||||
export function ConversationCardFooter({
|
||||
selectedRepository,
|
||||
lastUpdatedAt,
|
||||
createdAt,
|
||||
conversationStatus,
|
||||
}: ConversationCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-row justify-between items-center mt-1")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-row justify-between items-center mt-1",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{selectedRepository?.selected_repository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
|
||||
@@ -2,12 +2,14 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ConversationCardTitle } from "./conversation-card-title";
|
||||
import { ConversationStatusIndicator } from "../../home/recent-conversations/conversation-status-indicator";
|
||||
import { ConversationStatusBadges } from "./conversation-status-badges";
|
||||
import { ConversationVersionBadge } from "./conversation-version-badge";
|
||||
|
||||
interface ConversationCardHeaderProps {
|
||||
title: string;
|
||||
titleMode: "view" | "edit";
|
||||
onTitleSave: (title: string) => void;
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationVersion?: "V0" | "V1";
|
||||
}
|
||||
|
||||
export function ConversationCardHeader({
|
||||
@@ -15,7 +17,10 @@ export function ConversationCardHeader({
|
||||
titleMode,
|
||||
onTitleSave,
|
||||
conversationStatus,
|
||||
conversationVersion,
|
||||
}: ConversationCardHeaderProps) {
|
||||
const isConversationArchived = conversationStatus === "ARCHIVED";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
@@ -26,10 +31,16 @@ export function ConversationCardHeader({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Version Badge */}
|
||||
<ConversationVersionBadge
|
||||
version={conversationVersion}
|
||||
isConversationArchived={isConversationArchived}
|
||||
/>
|
||||
<ConversationCardTitle
|
||||
title={title}
|
||||
titleMode={titleMode}
|
||||
onSave={onTitleSave}
|
||||
isConversationArchived={isConversationArchived}
|
||||
/>
|
||||
{/* Status Badges */}
|
||||
{conversationStatus && (
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export type ConversationCardTitleMode = "view" | "edit";
|
||||
|
||||
export type ConversationCardTitleProps = {
|
||||
titleMode: ConversationCardTitleMode;
|
||||
title: string;
|
||||
onSave: (title: string) => void;
|
||||
isConversationArchived?: boolean;
|
||||
};
|
||||
|
||||
export function ConversationCardTitle({
|
||||
titleMode,
|
||||
title,
|
||||
onSave,
|
||||
isConversationArchived,
|
||||
}: ConversationCardTitleProps) {
|
||||
if (titleMode === "edit") {
|
||||
return (
|
||||
@@ -40,7 +44,10 @@ export function ConversationCardTitle({
|
||||
return (
|
||||
<p
|
||||
data-testid="conversation-card-title"
|
||||
className="text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden"
|
||||
className={cn(
|
||||
"text-xs leading-6 font-semibold bg-transparent truncate overflow-hidden",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface ConversationCardProps {
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
conversationId?: string; // Optional conversation ID for VS Code URL
|
||||
conversationVersion?: "V0" | "V1";
|
||||
contextMenuOpen?: boolean;
|
||||
onContextMenuToggle?: (isOpen: boolean) => void;
|
||||
}
|
||||
@@ -39,6 +40,7 @@ export function ConversationCard({
|
||||
createdAt,
|
||||
conversationId,
|
||||
conversationStatus,
|
||||
conversationVersion,
|
||||
contextMenuOpen = false,
|
||||
onContextMenuToggle,
|
||||
}: ConversationCardProps) {
|
||||
@@ -90,7 +92,7 @@ export function ConversationCard({
|
||||
}
|
||||
}
|
||||
// VS Code URL not available
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Failed to fetch VS Code URL
|
||||
}
|
||||
}
|
||||
@@ -108,7 +110,6 @@ export function ConversationCard({
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"data-[context-menu-open=false]:hover:bg-[#454545]",
|
||||
conversationStatus === "ARCHIVED" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
@@ -117,6 +118,7 @@ export function ConversationCard({
|
||||
titleMode={titleMode}
|
||||
onTitleSave={onTitleSave}
|
||||
conversationStatus={conversationStatus}
|
||||
conversationVersion={conversationVersion}
|
||||
/>
|
||||
|
||||
{hasContextMenu && (
|
||||
@@ -138,6 +140,7 @@ export function ConversationCard({
|
||||
selectedRepository={selectedRepository}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
createdAt={createdAt}
|
||||
conversationStatus={conversationStatus}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ConversationStatusBadges({
|
||||
|
||||
if (conversationStatus === "ARCHIVED") {
|
||||
return (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full">
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-[#868E96] text-white text-xs font-medium rounded-full opacity-60">
|
||||
<FaArchive size={10} className="text-white" />
|
||||
<span>{t(I18nKey.COMMON$ARCHIVED)}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConversationVersionBadgeProps {
|
||||
version?: "V0" | "V1";
|
||||
isConversationArchived?: boolean;
|
||||
}
|
||||
|
||||
export function ConversationVersionBadge({
|
||||
version,
|
||||
isConversationArchived,
|
||||
}: ConversationVersionBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!version) return null;
|
||||
|
||||
const tooltipText =
|
||||
version === "V1"
|
||||
? t(I18nKey.CONVERSATION$VERSION_V1_NEW)
|
||||
: t(I18nKey.CONVERSATION$VERSION_V0_LEGACY);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltipText} placement="top">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold shrink-0 cursor-help lowercase",
|
||||
version === "V1"
|
||||
? "bg-green-500/20 text-green-500"
|
||||
: "bg-neutral-500/20 text-neutral-400",
|
||||
isConversationArchived && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{version}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,10 @@ import { NavLink, useParams, useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { usePaginatedConversations } from "#/hooks/query/use-paginated-conversations";
|
||||
import { useStartTasks } from "#/hooks/query/use-start-tasks";
|
||||
import { useInfiniteScroll } from "#/hooks/use-infinite-scroll";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "./confirm-stop-modal";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -15,6 +16,7 @@ import { Provider } from "#/types/settings";
|
||||
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { ConversationCard } from "./conversation-card/conversation-card";
|
||||
import { StartTaskCard } from "./start-task-card/start-task-card";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
onClose: () => void;
|
||||
@@ -37,6 +39,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const [selectedConversationId, setSelectedConversationId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedConversationVersion, setSelectedConversationVersion] =
|
||||
React.useState<"V0" | "V1" | undefined>(undefined);
|
||||
const [openContextMenuId, setOpenContextMenuId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
@@ -50,11 +54,15 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
fetchNextPage,
|
||||
} = usePaginatedConversations();
|
||||
|
||||
// Fetch in-progress start tasks
|
||||
const { data: startTasks } = useStartTasks();
|
||||
|
||||
// Flatten all pages into a single array of conversations
|
||||
const conversations = data?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: pauseConversationSandbox } =
|
||||
useUnifiedPauseConversationSandbox();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
// Set up infinite scroll
|
||||
@@ -70,9 +78,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
setSelectedConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleStopConversation = (conversationId: string) => {
|
||||
const handleStopConversation = (
|
||||
conversationId: string,
|
||||
version?: "V0" | "V1",
|
||||
) => {
|
||||
setConfirmStopModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
setSelectedConversationVersion(version);
|
||||
};
|
||||
|
||||
const handleConversationTitleChange = async (
|
||||
@@ -106,7 +118,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const handleConfirmStop = () => {
|
||||
if (selectedConversationId) {
|
||||
stopConversation({ conversationId: selectedConversationId });
|
||||
pauseConversationSandbox({
|
||||
conversationId: selectedConversationId,
|
||||
version: selectedConversationVersion,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,13 +146,24 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!isFetching && conversations?.length === 0 && (
|
||||
{!isFetching && conversations?.length === 0 && !startTasks?.length && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$NO_CONVERSATIONS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Render in-progress start tasks first */}
|
||||
{startTasks?.map((task) => (
|
||||
<NavLink
|
||||
key={task.id}
|
||||
to={`/conversations/task-${task.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<StartTaskCard task={task} />
|
||||
</NavLink>
|
||||
))}
|
||||
{/* Then render completed conversations */}
|
||||
{conversations?.map((project) => (
|
||||
<NavLink
|
||||
key={project.conversation_id}
|
||||
@@ -146,7 +172,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
>
|
||||
<ConversationCard
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onStop={() => handleStopConversation(project.conversation_id)}
|
||||
onStop={() =>
|
||||
handleStopConversation(
|
||||
project.conversation_id,
|
||||
project.conversation_version,
|
||||
)
|
||||
}
|
||||
onChangeTitle={(title) =>
|
||||
handleConversationTitleChange(project.conversation_id, title)
|
||||
}
|
||||
@@ -160,6 +191,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
createdAt={project.created_at}
|
||||
conversationStatus={project.status}
|
||||
conversationId={project.conversation_id}
|
||||
conversationVersion={project.conversation_version}
|
||||
contextMenuOpen={openContextMenuId === project.conversation_id}
|
||||
onContextMenuToggle={(isOpen) =>
|
||||
setOpenContextMenuId(isOpen ? project.conversation_id : null)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MicroagentsModalHeader } from "./microagents-modal-header";
|
||||
import { MicroagentsLoadingState } from "./microagents-loading-state";
|
||||
import { MicroagentsEmptyState } from "./microagents-empty-state";
|
||||
import { MicroagentItem } from "./microagent-item";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
@@ -18,7 +18,7 @@ interface MicroagentsModalProps {
|
||||
|
||||
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationRepoLink } from "../conversation-card/conversation-repo-link";
|
||||
import { NoRepository } from "../conversation-card/no-repository";
|
||||
import type { RepositorySelection } from "#/api/open-hands.types";
|
||||
|
||||
interface StartTaskCardFooterProps {
|
||||
selectedRepository: RepositorySelection | null;
|
||||
createdAt: string; // ISO 8601
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
export function StartTaskCardFooter({
|
||||
selectedRepository,
|
||||
createdAt,
|
||||
detail,
|
||||
}: StartTaskCardFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1 mt-1")}>
|
||||
{/* Repository Info */}
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
{selectedRepository ? (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
) : (
|
||||
<NoRepository />
|
||||
)}
|
||||
{createdAt && (
|
||||
<p className="text-xs text-[#A3A3A3] flex-1 text-right">
|
||||
<time>
|
||||
{`${formatTimeDelta(new Date(createdAt))} ${t(I18nKey.CONVERSATION$AGO)}`}
|
||||
</time>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Detail */}
|
||||
{detail && (
|
||||
<div className="text-xs text-neutral-500 truncate">{detail}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { ConversationVersionBadge } from "../conversation-card/conversation-version-badge";
|
||||
import { StartTaskStatusIndicator } from "./start-task-status-indicator";
|
||||
import { StartTaskStatusBadge } from "./start-task-status-badge";
|
||||
|
||||
interface StartTaskCardHeaderProps {
|
||||
title: string;
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskCardHeader({
|
||||
title,
|
||||
taskStatus,
|
||||
}: StartTaskCardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
|
||||
{/* Status Indicator */}
|
||||
<div className="flex items-center">
|
||||
<StartTaskStatusIndicator taskStatus={taskStatus} />
|
||||
</div>
|
||||
|
||||
{/* Version Badge - V1 tasks are always V1 */}
|
||||
<ConversationVersionBadge version="V1" />
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-sm font-medium text-neutral-100 truncate flex-1">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Status Badge */}
|
||||
<StartTaskStatusBadge taskStatus={taskStatus} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { V1AppConversationStartTask } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { StartTaskCardHeader } from "./start-task-card-header";
|
||||
import { StartTaskCardFooter } from "./start-task-card-footer";
|
||||
|
||||
interface StartTaskCardProps {
|
||||
task: V1AppConversationStartTask;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function StartTaskCard({ task, onClick }: StartTaskCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const title =
|
||||
task.request.title ||
|
||||
task.detail ||
|
||||
t(I18nKey.CONVERSATION$STARTING_CONVERSATION);
|
||||
|
||||
const selectedRepository = task.request.selected_repository
|
||||
? {
|
||||
selected_repository: task.request.selected_repository,
|
||||
selected_branch: task.request.selected_branch || null,
|
||||
git_provider: task.request.git_provider || null,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="start-task-card"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative h-auto w-full p-3.5 border-b border-neutral-600 cursor-pointer",
|
||||
"hover:bg-[#454545]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<StartTaskCardHeader title={title} taskStatus={task.status} />
|
||||
</div>
|
||||
|
||||
<StartTaskCardFooter
|
||||
selectedRepository={selectedRepository}
|
||||
createdAt={task.created_at}
|
||||
detail={task.detail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StartTaskStatusBadgeProps {
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskStatusBadge({
|
||||
taskStatus,
|
||||
}: StartTaskStatusBadgeProps) {
|
||||
// Don't show badge for WORKING status (most common, clutters UI)
|
||||
if (taskStatus === "WORKING") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format status for display
|
||||
const formatStatus = (status: string) =>
|
||||
status
|
||||
.toLowerCase()
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
// Get status color
|
||||
const getStatusStyle = () => {
|
||||
switch (taskStatus) {
|
||||
case "READY":
|
||||
return "bg-green-500/10 text-green-400 border-green-500/20";
|
||||
case "ERROR":
|
||||
return "bg-red-500/10 text-red-400 border-red-500/20";
|
||||
default:
|
||||
return "bg-yellow-500/10 text-yellow-400 border-yellow-500/20";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium px-2 py-0.5 rounded border flex-shrink-0",
|
||||
getStatusStyle(),
|
||||
)}
|
||||
>
|
||||
{formatStatus(taskStatus)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StartTaskStatusIndicatorProps {
|
||||
taskStatus: V1AppConversationStartTaskStatus;
|
||||
}
|
||||
|
||||
export function StartTaskStatusIndicator({
|
||||
taskStatus,
|
||||
}: StartTaskStatusIndicatorProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (taskStatus) {
|
||||
case "READY":
|
||||
return "bg-green-500";
|
||||
case "ERROR":
|
||||
return "bg-red-500";
|
||||
case "WORKING":
|
||||
case "WAITING_FOR_SANDBOX":
|
||||
case "PREPARING_REPOSITORY":
|
||||
case "RUNNING_SETUP_SCRIPT":
|
||||
case "SETTING_UP_GIT_HOOKS":
|
||||
case "STARTING_CONVERSATION":
|
||||
return "bg-yellow-500 animate-pulse";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-2 h-2 rounded-full flex-shrink-0", getStatusColor())}
|
||||
aria-label={`Task status: ${taskStatus}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { MicroagentsModal } from "../conversation-panel/microagents-modal";
|
||||
import { ConfirmDeleteModal } from "../conversation-panel/confirm-delete-modal";
|
||||
import { ConfirmStopModal } from "../conversation-panel/confirm-stop-modal";
|
||||
import { MetricsModal } from "./metrics-modal/metrics-modal";
|
||||
import { ConversationVersionBadge } from "../conversation-panel/conversation-card/conversation-version-badge";
|
||||
|
||||
export function ConversationName() {
|
||||
const { t } = useTranslation();
|
||||
@@ -148,6 +149,12 @@ export function ConversationName() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{titleMode !== "edit" && (
|
||||
<ConversationVersionBadge
|
||||
version={conversation.conversation_version}
|
||||
/>
|
||||
)}
|
||||
|
||||
{titleMode !== "edit" && (
|
||||
<div className="relative flex items-center">
|
||||
<EllipsisButton fill="#B1B9D3" onClick={handleEllipsisClick} />
|
||||
|
||||
@@ -5,10 +5,10 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
export function VSCodeTooltipContent() {
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useUrlSearch(inputValue: string, provider: Provider) {
|
||||
);
|
||||
|
||||
setUrlSearchResults(repositories);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setUrlSearchResults([]);
|
||||
} finally {
|
||||
setIsUrlSearchLoading(false);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
interface JupyterEditorProps {
|
||||
@@ -15,7 +15,7 @@ interface JupyterEditorProps {
|
||||
}
|
||||
|
||||
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const cells = useJupyterStore((state) => state.cells);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function CancelSubscriptionModal({
|
||||
await cancelSubscriptionMutation.mutateAsync();
|
||||
displaySuccessToast(t(I18nKey.PAYMENT$SUBSCRIPTION_CANCELLED));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
displayErrorToast(t(I18nKey.ERROR$GENERIC));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
@@ -23,7 +23,7 @@ export function SetupPaymentModal() {
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.BILLING$YOUVE_GOT_50)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function CreateApiKeyModal({
|
||||
onKeyCreated(newKey);
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_CREATED));
|
||||
setNewKeyName("");
|
||||
} catch (error) {
|
||||
} catch {
|
||||
displayErrorToast(t(I18nKey.ERROR$GENERIC));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export function DeleteApiKeyModal({
|
||||
await deleteApiKeyMutation.mutateAsync(keyToDelete.id);
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_DELETED));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
displayErrorToast(t(I18nKey.ERROR$GENERIC));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { useGitUser } from "#/hooks/query/use-git-user";
|
||||
import { UserActions } from "./user-actions";
|
||||
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||
import { OpenHandsLogoButton } from "#/components/shared/buttons/openhands-logo-button";
|
||||
import { NewProjectButton } from "#/components/shared/buttons/new-project-button";
|
||||
import { ConversationPanelButton } from "#/components/shared/buttons/conversation-panel-button";
|
||||
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
|
||||
@@ -74,7 +74,7 @@ export function Sidebar() {
|
||||
<nav className="flex flex-row md:flex-col items-center justify-between w-full h-auto md:w-auto md:h-full">
|
||||
<div className="flex flex-row md:flex-col items-center gap-[26px]">
|
||||
<div className="flex items-center justify-center">
|
||||
<AllHandsLogoButton />
|
||||
<OpenHandsLogoButton />
|
||||
</div>
|
||||
<div>
|
||||
<NewProjectButton disabled={settings?.EMAIL_VERIFIED === false} />
|
||||
|
||||
@@ -3,10 +3,10 @@ import "@xterm/xterm/css/xterm.css";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
|
||||
function Terminal() {
|
||||
const { curAgentState } = useAgentStore();
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
|
||||
import OpenHandsLogo from "#/assets/branding/openhands-logo.svg?react";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
@@ -98,7 +98,7 @@ export function AuthModal({
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="border border-tertiary">
|
||||
<AllHandsLogo width={68} height={46} />
|
||||
<OpenHandsLogo width={68} height={46} />
|
||||
<div className="flex flex-col gap-2 w-full items-center text-center">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t(I18nKey.AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user