Compare commits

..

27 Commits

Author SHA1 Message Date
amanape 836866915e Add security_policy field to replace confirmation_mode with three options: never, always, risky 2025-10-22 21:45:03 +04:00
Rohit Malhotra 523b40dbfc SAAS: drop deprecated table (#11469)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-22 10:52:10 -04:00
sp.wack 6a5b915088 Add unified file upload support for V0 and V1 conversations (#11457) 2025-10-22 17:44:38 +04:00
sp.wack a5c5133961 Remove queries from cache and do not refetch them after starting a conversation (#11453) 2025-10-22 13:42:09 +00:00
sp.wack eea1e7f4e1 Prevent calling V1 "start tasks” API if feature flag is disabled + always set “start tasks” query cache to stale (#11454) 2025-10-22 20:38:32 +07:00
Hiep Le e2d990f3a0 feat(backend): implement get_remote_runtime_config support for V1 conversations (#11466) 2025-10-22 15:38:25 +07:00
Hiep Le f258eafa37 feat(backend): add support for updating the title in V1 conversations (#11446) 2025-10-22 13:36:56 +07:00
Hiep Le 19634f364e fix(backend): repository pill does not display the selected repository when a conversation is initiated via slack (#11225) 2025-10-22 13:12:32 +07:00
Alona aa6446038c fix: remove accidentally committed Docker image tags from config.sh (#11470) 2025-10-22 04:48:17 +00:00
Tim O'Farrell dbddc1868e Fixes for VSCode code completion (#11449) 2025-10-21 21:39:50 +00:00
Rohit Malhotra cd967ef4bc SAAS: add local development helper scripts (#11459) 2025-10-21 21:26:23 +00:00
Tim O'Farrell e34c13ea3c Set dump mode to json to convert UUIDs to strings (#11467) 2025-10-21 19:20:56 +00:00
Hiep Le 1f35a73cc4 fix(frontend): display repository information after creating a V1 conversation (#11463) 2025-10-21 18:24:26 +00:00
Alona 267528fa82 fix: refresh provider tokens proactively and update git URLs on resume (#11296) 2025-10-22 01:19:08 +07:00
sp.wack 49f360d021 Fix toast dismissal to target specific toast IDs instead of all toasts (#11455) 2025-10-21 17:43:14 +00:00
sp.wack 9520da668c Prevent WebSocket provider remount by defaulting to V1 (#11458) 2025-10-21 17:11:15 +00:00
Rohit Malhotra 9d19292619 V1: Experiment manager (#11388)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-21 16:04:48 +00:00
sp.wack fc9a87550d Fix zero state not showing for V1 conversations (#11452) 2025-10-21 20:04:01 +04:00
sp.wack 490d3dba10 Remove toast notifications for starting/resuming conversation sandbox (#11456) 2025-10-21 20:03:45 +04:00
Rohit Malhotra 5ed1dde2e9 CLI Patch Release 1.0.2 (#11448) 2025-10-21 15:32:00 +00:00
sp.wack a68576b876 Clear conversation state when switching between V1 conversations (#11447) 2025-10-21 20:21:58 +07:00
mamoodi 722124ae83 Move Search API Key and Confirmation Mode to Advanced settings (#11390)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-21 08:51:21 -04:00
Tim O'Farrell 44578664ed Add Concurrency Limits to SandboxService (#11399)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-20 20:22:12 +00:00
Rohit Malhotra 9efe6eb776 Simplify security analyzer confirmation: replace two reject options with single 'Reject' option (#11443)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-10-20 19:45:42 +00:00
Tim O'Farrell 6d137e883f Add VSCode URL support and worker ports to sandbox services (#11426)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-20 18:43:08 +00:00
Xingyao Wang 2889f736d9 Use PyPI version of Agent-SDK (#11411) 2025-10-20 17:25:54 +00:00
sp.wack 531683abae feat(frontend): V1 conversation API (PARTIAL) (#11336)
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-10-20 20:57:40 +04:00
160 changed files with 16375 additions and 1981 deletions
+16
View File
@@ -3,4 +3,20 @@
"files.eol": "\n",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"python.defaultInterpreterPath": "./.venv/bin/python",
"python.terminal.activateEnvironment": true,
"python.analysis.autoImportCompletions": true,
"python.analysis.autoSearchPaths": true,
"python.analysis.extraPaths": [
"./.venv/lib/python3.12/site-packages"
],
"python.analysis.packageIndexDepths": [
{
"name": "openhands",
"depth": 10,
"includeAllSymbols": true
}
],
"python.analysis.stubPath": "./.venv/lib/python3.12/site-packages",
}
+274
View File
@@ -0,0 +1,274 @@
# Instructions for developing SAAS locally
You have a few options here, which are expanded on below:
- A simple local development setup, with live reloading for both OSS and this repo
- A more complex setup that includes Redis
- An even more complex setup that includes GitHub events
## Prerequisites
Before starting, make sure you have the following tools installed:
### Required for all options:
- [gcloud CLI](https://cloud.google.com/sdk/docs/install) - For authentication and secrets management
- [sops](https://github.com/mozilla/sops) - For secrets decryption
- macOS: `brew install sops`
- Linux: `sudo apt-get install sops` or download from GitHub releases
- Windows: Install via Chocolatey `choco install sops` or download from GitHub releases
### Additional requirements for enabling GitHub webhook events
- make
- Python development tools (build-essential, python3-dev)
- [ngrok](https://ngrok.com/download) - For creating tunnels to localhost
## Option 1: Simple local development
This option will allow you to modify the both the OSS code and the code in this repo,
and see the changes in real-time.
This option works best for most scenarios. The only thing it's missing is
the GitHub events webhook, which is not necessary for most development.
### 1. OpenHands location
The open source OpenHands repo should be cloned as a sibling directory,
in `../OpenHands`. This is hard-coded in the pyproject.toml (edit if necessary)
If you're doing this the first time, you may need to run
```
poetry update openhands-ai
```
### 2. Set up env
First run this to retrieve Github App secrets
```
gcloud auth application-default login
gcloud config set project global-432717
local/decrypt_env.sh
```
Now run this to generate a `.env` file, which will used to run SAAS locally
```
python -m pip install PyYAML
export LITE_LLM_API_KEY=<your LLM API key>
python enterprise_local/convert_to_env.py
```
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
```
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:main-nikolaik
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
```
By default the application will log in json, you can override.
```
export LOG_PLAIN_TEXT=1
```
### 3. Start the OpenHands frontend
Start the frontend like you normally would in the open source OpenHands repo.
### 4. Start the SaaS backend
```
make build
make start-backend
```
You should have a server running on `localhost:3000`, similar to the open source backend.
Oauth should work properly.
## Option 2: With Redis
Follow all the steps above, then setup redis:
```bash
docker run -p 6379:6379 --name openhands-redis -d redis
export REDIS_HOST=host.docker.internal # you may want this to be localhost
export REDIS_PORT=6379
```
## Option 3: Work with GitHub events
### 1. Setup env file
(see above)
### 2. Build OSS Openhands
Develop on [Openhands](https://github.com/All-Hands-AI/OpenHands) locally. When ready, run the following inside Openhands repo (not the Deploy repo)
```
docker build -f containers/app/Dockerfile -t openhands .
```
### 3. Build SAAS Openhands
Build the SAAS image locally inside Deploy repo. Note that `openhands` is the name of the image built in Step 2
```
docker build -t openhands-saas ./app/ --build-arg BASE="openhands"
```
### 4. Create a tunnel
Run in a separate terminal
```
ngrok http 3000
```
There will be a line
```
Forwarding https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app
```
Remember this URL as it will be used in Step 5 and 6
### 5. Setup Staging Github App callback/webhook urls
Using the URL found in Step 4, add another callback URL (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app/oauth/github/callback`)
### 6. Run
This is the last step! Run SAAS openhands locally using
```
docker run --env-file ./app/.env -p 3000:3000 openhands-saas
```
Note `--env-file` is what injects the `.env` file created in Step 1
Visit the tunnel domain found in Step 4 to run the app (`https://bc71-2603-7000-5000-1575-e4a6-697b-589e-5801.ngrok-free.app`)
### Local Debugging with VSCode
Local Development necessitates running a version of OpenHands that is as similar as possible to the version running in the SAAS Environment. Before running these steps, it is assumed you have a local development version of the OSS OpenHands project running.
#### Redis
A Local redis instance is required for clustered communication between server nodes. The standard docker instance will suffice.
`docker run -it -p 6379:6379 --name my-redis -d redis`
#### Postgres
A Local postgres instance is required. I used the official docker image:
`docker run -p 5432:5432 --name my-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=openhands -d postgres`
Run the alembic migrations:
`poetry run alembic upgrade head `
#### VSCode launch.json
The VSCode launch.json below sets up 2 servers to test clustering, running independently on localhost:3030 and localhost:3031. Running only the server on 3030 is usually sufficient unless tests of the clustered functionality are required. Secrets may be harvested directly from staging by connecting...
`kubectl exec --stdin --tty <POD_NAME> -n <NAMESPACE> -- /bin/bash`
And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by the time you read this, nobody will have access.)
```
{
"configurations": [
{
"name": "Python Debugger: Python File",
"type": "debugpy",
"request": "launch",
"program": "${file}"
},
{
"name": "OpenHands Deploy",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"saas_server:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
"3030"
],
"env": {
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/all-hands-ai/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
{
"name": "OpenHands Deploy 2",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"saas_server:app",
"--reload",
"--host",
"0.0.0.0",
"--port",
"3031"
],
"env": {
"DEBUG": "1",
"FILE_STORE": "local",
"REDIS_HOST": "localhost:6379",
"OPENHANDS": "<YOUR LOCAL OSS OPENHANDS DIR>",
"FRONTEND_DIRECTORY": "<YOUR LOCAL OSS OPENHANDS DIR>/frontend/build",
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/all-hands-ai/runtime:main-nikolaik",
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
"GITHUB_APP_ID": "1062351",
"GITHUB_APP_PRIVATE_KEY": "<GITHUB PRIVATE KEY>",
"GITHUB_APP_CLIENT_ID": "Iv23lis7eUWDQHIq8US0",
"GITHUB_APP_CLIENT_SECRET": "<GITHUB CLIENT SECRET>",
"POSTHOG_CLIENT_KEY": "<POSTHOG CLIENT KEY>",
"LITE_LLM_API_URL": "https://llm-proxy.staging.all-hands.dev",
"LITE_LLM_TEAM_ID": "62ea39c4-8886-44f3-b7ce-07ed4fe42d2c",
"LITE_LLM_API_KEY": "<LITE LLM API KEY>"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
{
"name": "Unit Tests",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"args": [
"./tests/unit",
//"./tests/unit/test_clustered_conversation_manager.py",
"--durations=0"
],
"env": {
"DEBUG": "1"
},
"justMyCode": false,
"cwd": "${workspaceFolder}/app"
},
// set working directory...
]
}
```
@@ -0,0 +1,127 @@
import base64
import os
import sys
import yaml
def convert_yaml_to_env(yaml_file, target_parameters, output_env_file, prefix):
"""Converts a YAML file into .env file format for specified target parameters under 'stringData' and 'data'.
:param yaml_file: Path to the YAML file.
:param target_parameters: List of keys to extract from the YAML file.
:param output_env_file: Path to the output .env file.
:param prefix: Prefix for environment variables.
"""
try:
# Load the YAML file
with open(yaml_file, 'r') as file:
yaml_data = yaml.safe_load(file)
# Extract sections
string_data = yaml_data.get('stringData', None)
data = yaml_data.get('data', None)
if string_data:
env_source = string_data
process_base64 = False
elif data:
env_source = data
process_base64 = True
else:
print(
"Error: Neither 'stringData' nor 'data' section found in the YAML file."
)
return
env_lines = []
for param in target_parameters:
if param in env_source:
value = env_source[param]
if process_base64:
try:
decoded_value = base64.b64decode(value).decode('utf-8')
formatted_value = (
decoded_value.replace('\n', '\\n')
if '\n' in decoded_value
else decoded_value
)
except Exception as decode_error:
print(f"Error decoding base64 for '{param}': {decode_error}")
continue
else:
formatted_value = (
value.replace('\n', '\\n')
if isinstance(value, str) and '\n' in value
else value
)
new_key = prefix + param.upper().replace('-', '_')
env_lines.append(f'{new_key}={formatted_value}')
else:
print(
f"Warning: Parameter '{param}' not found in the selected section."
)
# Write to the .env file
with open(output_env_file, 'a') as env_file:
env_file.write('\n'.join(env_lines) + '\n')
except Exception as e:
print(f'Error: {e}')
lite_llm_api_key = os.getenv('LITE_LLM_API_KEY')
if not lite_llm_api_key:
print('Set the LITE_LLM_API_KEY environment variable to your API key')
sys.exit(1)
yaml_file = 'github_decrypted.yaml'
target_parameters = ['client-id', 'client-secret', 'webhook-secret', 'private-key']
output_env_file = './enterprise/.env'
if os.path.exists(output_env_file):
os.remove(output_env_file)
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'GITHUB_APP_')
os.remove(yaml_file)
yaml_file = 'keycloak_realm_decrypted.yaml'
target_parameters = ['client-id', 'client-secret', 'provider-name', 'realm-name']
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
os.remove(yaml_file)
yaml_file = 'keycloak_admin_decrypted.yaml'
target_parameters = ['admin-password']
convert_yaml_to_env(yaml_file, target_parameters, output_env_file, 'KEYCLOAK_')
os.remove(yaml_file)
lines = []
lines.append('KEYCLOAK_SERVER_URL=https://auth.staging.all-hands.dev/')
lines.append('KEYCLOAK_SERVER_URL_EXT=https://auth.staging.all-hands.dev/')
lines.append('OPENHANDS_CONFIG_CLS=server.config.SaaSServerConfig')
lines.append(
'OPENHANDS_GITHUB_SERVICE_CLS=integrations.github.github_service.SaaSGitHubService'
)
lines.append(
'OPENHANDS_GITLAB_SERVICE_CLS=integrations.gitlab.gitlab_service.SaaSGitLabService'
)
lines.append(
'OPENHANDS_BITBUCKET_SERVICE_CLS=integrations.bitbucket.bitbucket_service.SaaSBitBucketService'
)
lines.append(
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
)
lines.append('POSTHOG_CLIENT_KEY=test')
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
lines.append('LITE_LLM_API_URL=https://llm-proxy.eval.all-hands.dev')
lines.append('LITELLM_DEFAULT_MODEL=litellm_proxy/claude-sonnet-4-20250514')
lines.append(f'LITE_LLM_API_KEY={lite_llm_api_key}')
lines.append('LOCAL_DEPLOYMENT=true')
lines.append('DB_HOST=localhost')
with open(output_env_file, 'a') as env_file:
env_file.write('\n'.join(lines))
print(f'.env file created at: {output_env_file}')
@@ -0,0 +1,27 @@
#!/bin/bash
set -euo pipefail
# Check if DEPLOY_DIR argument was provided
if [ $# -lt 1 ]; then
echo "Usage: $0 <DEPLOY_DIR>"
echo "Example: $0 /path/to/deploy"
exit 1
fi
# Normalize path (remove trailing slash)
DEPLOY_DIR="${DEPLOY_DIR%/}"
# Function to decrypt and rename
decrypt_and_move() {
local secret_path="$1"
local output_name="$2"
${DEPLOY_DIR}/scripts/decrypt.sh "${DEPLOY_DIR}/${secret_path}"
mv decrypted.yaml "${output_name}"
echo "Moved decrypted.yaml to ${output_name}"
}
# Decrypt each secret file
decrypt_and_move "openhands/envs/feature/secrets/github-app.yaml" "github_decrypted.yaml"
decrypt_and_move "openhands/envs/staging/secrets/keycloak-realm.yaml" "keycloak_realm_decrypted.yaml"
decrypt_and_move "openhands/envs/staging/secrets/keycloak-admin.yaml" "keycloak_admin_decrypted.yaml"
@@ -1,18 +1,47 @@
from uuid import UUID
from experiments.constants import (
ENABLE_EXPERIMENT_MANAGER,
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
)
from experiments.experiment_versions import (
handle_condenser_max_step_experiment,
handle_system_prompt_experiment,
)
from experiments.experiment_versions._004_condenser_max_step_experiment import (
handle_condenser_max_step_experiment__v1,
)
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.experiments.experiment_manager import ExperimentManager
from openhands.sdk import Agent
from openhands.server.session.conversation_init_data import ConversationInitData
class SaaSExperimentManager(ExperimentManager):
@staticmethod
def run_agent_variant_tests__v1(
user_id: str | None, conversation_id: UUID, agent: Agent
) -> Agent:
if not ENABLE_EXPERIMENT_MANAGER:
logger.info(
'experiment_manager:run_conversation_variant_test:skipped',
extra={'reason': 'experiment_manager_disabled'},
)
return agent
agent = handle_condenser_max_step_experiment__v1(
user_id, conversation_id, agent
)
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
agent = agent.model_copy(
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
)
return agent
@staticmethod
def run_conversation_variant_test(
user_id, conversation_id, conversation_settings
@@ -5,12 +5,18 @@ This module contains the handler for the condenser max step experiment that test
different max_size values for the condenser configuration.
"""
from uuid import UUID
import posthog
from experiments.constants import EXPERIMENT_CONDENSER_MAX_STEP
from server.constants import IS_FEATURE_ENV
from storage.experiment_assignment_store import ExperimentAssignmentStore
from openhands.core.logger import openhands_logger as logger
from openhands.sdk import Agent
from openhands.sdk.context.condenser import (
LLMSummarizingCondenser,
)
from openhands.server.session.conversation_init_data import ConversationInitData
@@ -190,3 +196,37 @@ def handle_condenser_max_step_experiment(
return conversation_settings
return conversation_settings
def handle_condenser_max_step_experiment__v1(
user_id: str | None,
conversation_id: UUID,
agent: Agent,
) -> Agent:
enabled_variant = _get_condenser_max_step_variant(user_id, str(conversation_id))
if enabled_variant is None:
return agent
if enabled_variant == 'control':
condenser_max_size = 120
elif enabled_variant == 'treatment':
condenser_max_size = 80
else:
logger.error(
'condenser_max_step_experiment:unknown_variant',
extra={
'user_id': user_id,
'convo_id': conversation_id,
'variant': enabled_variant,
'reason': 'unknown variant; returning original conversation settings',
},
)
return agent
condenser_llm = agent.llm.model_copy(update={'usage_id': 'condenser'})
condenser = LLMSummarizingCondenser(
llm=condenser_llm, max_size=condenser_max_size, keep_first=4
)
return agent.model_copy(update={'condenser': condenser})
+12 -3
View File
@@ -14,6 +14,7 @@ from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.serialization.event import event_to_dict
from openhands.integrations.provider import ProviderHandler
from openhands.server.services.conversation_service import (
create_new_conversation,
setup_init_conversation_settings,
@@ -188,19 +189,27 @@ class SlackNewConversationView(SlackViewInterface):
user_secrets = await self.saas_user_auth.get_user_secrets()
user_instructions, conversation_instructions = self._get_instructions(jinja)
# Determine git provider from repository
git_provider = None
if self.selected_repo and provider_tokens:
provider_handler = ProviderHandler(provider_tokens)
repository = await provider_handler.verify_repo_provider(self.selected_repo)
git_provider = repository.git_provider
agent_loop_info = await create_new_conversation(
user_id=self.slack_to_openhands_user.keycloak_user_id,
git_provider_tokens=provider_tokens,
selected_repository=self.selected_repo,
selected_branch=None,
initial_user_msg=user_instructions,
conversation_instructions=conversation_instructions
if conversation_instructions
else None,
conversation_instructions=(
conversation_instructions if conversation_instructions else None
),
image_urls=None,
replay_json=None,
conversation_trigger=ConversationTrigger.SLACK,
custom_secrets=user_secrets.custom_secrets if user_secrets else None,
git_provider=git_provider,
)
self.conversation_id = agent_loop_info.conversation_id
@@ -0,0 +1,27 @@
"""drop settings table
Revision ID: 077
Revises: 076
Create Date: 2025-10-21 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '077'
down_revision: Union[str, None] = '076'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Drop the deprecated settings table."""
op.execute('DROP TABLE IF EXISTS settings')
def downgrade() -> None:
"""No-op downgrade since the settings table is deprecated."""
pass
+3925 -18
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -293,11 +293,12 @@ class TokenManager:
refresh_token_expires_at: int,
) -> dict[str, str | int] | None:
current_time = int(time.time())
# expire access_token ten minutes before actual expiration
# expire access_token four hours before actual expiration
# This ensures tokens are refreshed on resume to have at least 4 hours validity
access_expired = (
False
if access_token_expires_at == 0
else access_token_expires_at < current_time + 600
else access_token_expires_at < current_time + 14400
)
refresh_expired = (
False
-28
View File
@@ -24,7 +24,6 @@ from server.constants import (
from server.logger import logger
from sqlalchemy.orm import sessionmaker
from storage.database import session_maker
from storage.stored_settings import StoredSettings
from storage.user_settings import UserSettings
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -144,33 +143,6 @@ class SaasSettingsStore(SettingsStore):
await self.store(settings)
return settings
def load_legacy_db_settings(self, github_user_id: str) -> Settings | None:
if not github_user_id:
return None
with self.session_maker() as session:
settings = (
session.query(StoredSettings)
.filter(StoredSettings.id == github_user_id)
.first()
)
if settings is None:
return None
logger.info(
'saas_settings_store:load_legacy_db_settings:found',
extra={'github_user_id': github_user_id},
)
kwargs = {
c.name: getattr(settings, c.name)
for c in StoredSettings.__table__.columns
if c.name in Settings.model_fields
}
self._decrypt_kwargs(kwargs)
del kwargs['secrets_store']
settings = Settings(**kwargs)
return settings
async def load_legacy_file_store_settings(self, github_user_id: str):
if not github_user_id:
return None
-29
View File
@@ -1,29 +0,0 @@
import uuid
from sqlalchemy import JSON, Boolean, Column, Float, Integer, String
from storage.base import Base
class StoredSettings(Base): # type: ignore
"""
Legacy user settings storage. This should be considered deprecated - use UserSettings isntead
"""
__tablename__ = 'settings'
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
language = Column(String, nullable=True)
agent = Column(String, nullable=True)
max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
llm_model = Column(String, nullable=True)
llm_api_key = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
user_consents_to_analytics = Column(Boolean, nullable=True)
margin = Column(Float, nullable=True)
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
sandbox_base_container_image = Column(String, nullable=True)
sandbox_runtime_container_image = Column(String, nullable=True)
secrets_store = Column(JSON, nullable=True)
+1 -18
View File
@@ -262,24 +262,7 @@ class VerifyWebhookStatus:
webhook_store = await GitlabWebhookStore.get_instance()
# Load chunks of rows that need processing (webhook_exists == False)
try:
webhooks_to_process = await self.fetch_rows(webhook_store)
except Exception as e:
# Check if this is a table not found error (likely due to missing migration)
if 'does not exist' in str(e) and ('gitlab_webhook' in str(e) or 'gitlab-webhook' in str(e)):
logger.error(
'gitlab_webhook table does not exist. This usually means database migration 032 '
'or later has not been applied. Please run database migrations: alembic upgrade head',
extra={
'error_type': type(e).__name__,
'error_message': str(e),
'migration_needed': '032_add_status_column_to_gitlab_webhook.py',
},
)
# Return early to avoid continuous error logging
return
# Re-raise other exceptions
raise
webhooks_to_process = await self.fetch_rows(webhook_store)
logger.info(
'Processing webhook chunks',
+1 -2
View File
@@ -17,7 +17,6 @@ from storage.github_app_installation import GithubAppInstallation
from storage.maintenance_task import MaintenanceTask, MaintenanceTaskStatus
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_offline_token import StoredOfflineToken
from storage.stored_settings import StoredSettings
from storage.stripe_customer import StripeCustomer
from storage.user_settings import UserSettings
@@ -85,7 +84,7 @@ def add_minimal_fixtures(session_maker):
updated_at=datetime.fromisoformat('2025-03-08'),
)
)
session.add(StoredSettings(id='mock-user-id', user_consents_to_analytics=True))
session.add(
StripeCustomer(
keycloak_user_id='mock-user-id',
@@ -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
@@ -8,7 +8,6 @@ from server.constants import (
LITE_LLM_TEAM_ID,
)
from storage.saas_settings_store import SaasSettingsStore
from storage.stored_settings import StoredSettings
from storage.user_settings import UserSettings
from openhands.core.config.openhands_config import OpenHandsConfig
@@ -303,26 +302,6 @@ async def test_create_default_settings_require_payment_disabled(
assert settings.language == 'en'
@pytest.mark.asyncio
async def test_create_default_settings_with_existing_llm_key(
settings_store, mock_stripe, mock_github_user, mock_litellm_api, session_maker
):
# Test that existing llm_api_key is preserved and not overwritten with litellm default
with (
patch('storage.saas_settings_store.REQUIRE_PAYMENT', False),
patch('storage.saas_settings_store.LITE_LLM_API_KEY', 'mock-api-key'),
patch('storage.saas_settings_store.session_maker', session_maker),
):
with settings_store.session_maker() as session:
kwargs = {'id': '12345', 'language': 'en', 'llm_api_key': 'existing_key'}
settings_store._encrypt_kwargs(kwargs)
session.merge(StoredSettings(**kwargs))
session.commit()
updated_settings = await settings_store.create_default_settings(None)
assert updated_settings is not None
assert updated_settings.llm_api_key.get_secret_value() == 'test_api_key'
@pytest.mark.asyncio
async def test_create_default_lite_llm_settings_no_api_config(settings_store):
with (
@@ -13,7 +13,6 @@ from integrations.stripe_service import (
)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from storage.stored_settings import Base as StoredBase
from storage.stripe_customer import Base as StripeCustomerBase
from storage.stripe_customer import StripeCustomer
from storage.user_settings import Base as UserBase
@@ -22,7 +21,7 @@ from storage.user_settings import Base as UserBase
@pytest.fixture
def engine():
engine = create_engine('sqlite:///:memory:')
StoredBase.metadata.create_all(engine)
UserBase.metadata.create_all(engine)
StripeCustomerBase.metadata.create_all(engine)
return engine
@@ -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");
});
});
@@ -0,0 +1,187 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { buildWebSocketUrl } from "#/utils/websocket-url";
describe("buildWebSocketUrl", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Basic URL construction", () => {
it("should build WebSocket URL with conversation ID and URL", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
});
it("should use wss:// protocol when window.location.protocol is https:", () => {
vi.stubGlobal("location", {
protocol: "https:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-123",
"https://example.com:8080/api/conversations/conv-123",
);
expect(result).toBe("wss://example.com:8080/sockets/events/conv-123");
});
it("should extract host and port from conversation URL", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
const result = buildWebSocketUrl(
"conv-456",
"http://agent-server.com:9000/api/conversations/conv-456",
);
expect(result).toBe("ws://agent-server.com:9000/sockets/events/conv-456");
});
});
describe("Query parameters handling", () => {
beforeEach(() => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
});
it("should not include query parameters in the URL (handled by useWebSocket hook)", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
expect(result).not.toContain("?");
expect(result).not.toContain("session_api_key");
});
});
describe("Fallback to window.location.host", () => {
it("should use window.location.host when conversation URL is null", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", null);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is undefined", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", undefined);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is relative path", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl(
"conv-123",
"/api/conversations/conv-123",
);
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
it("should use window.location.host when conversation URL is invalid", () => {
vi.stubGlobal("location", {
protocol: "http:",
host: "fallback-host:4000",
});
const result = buildWebSocketUrl("conv-123", "not-a-valid-url");
expect(result).toBe("ws://fallback-host:4000/sockets/events/conv-123");
});
});
describe("Edge cases", () => {
beforeEach(() => {
vi.stubGlobal("location", {
protocol: "http:",
host: "localhost:3000",
});
});
it("should return null when conversationId is undefined", () => {
const result = buildWebSocketUrl(
undefined,
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBeNull();
});
it("should return null when conversationId is empty string", () => {
const result = buildWebSocketUrl(
"",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBeNull();
});
it("should handle conversation URLs with non-standard ports", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://example.com:12345/api/conversations/conv-123",
);
expect(result).toBe("ws://example.com:12345/sockets/events/conv-123");
});
it("should handle conversation URLs without port (default port)", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://example.com/api/conversations/conv-123",
);
expect(result).toBe("ws://example.com/sockets/events/conv-123");
});
it("should handle conversation IDs with special characters", () => {
const result = buildWebSocketUrl(
"conv-123-abc_def",
"http://localhost:8080/api/conversations/conv-123-abc_def",
);
expect(result).toBe(
"ws://localhost:8080/sockets/events/conv-123-abc_def",
);
});
it("should build URL without query parameters", () => {
const result = buildWebSocketUrl(
"conv-123",
"http://localhost:8080/api/conversations/conv-123",
);
expect(result).toBe("ws://localhost:8080/sockets/events/conv-123");
expect(result).not.toContain("?");
});
});
});
@@ -21,7 +21,7 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useConfig } from "#/hooks/query/use-config";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { OpenHandsAction } from "#/types/core/actions";
import { useEventStore } from "#/stores/use-event-store";
@@ -31,7 +31,7 @@ vi.mock("#/stores/error-message-store");
vi.mock("#/stores/optimistic-user-message-store");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-upload-files");
vi.mock("#/hooks/mutation/use-unified-upload-files");
// Mock React Router hooks at the top level
vi.mock("react-router", async () => {
@@ -128,7 +128,7 @@ describe("ChatInterface - Chat Suggestions", () => {
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
@@ -267,7 +267,7 @@ describe("ChatInterface - Empty state", () => {
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
(useUnifiedUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
@@ -8,6 +8,14 @@ import { ConversationPanel } from "#/components/features/conversation-panel/conv
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
// Mock the unified stop conversation hook
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedPauseConversationSandbox: () => ({
mutate: mockStopConversationMutate,
}),
}));
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
const RouterStub = createRoutesStub([
@@ -73,7 +81,7 @@ describe("ConversationPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
mockStopConversationMutate.mockClear();
// Setup default mock for getUserConversations
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
results: [...mockConversations],
@@ -430,19 +438,6 @@ describe("ConversationPanel", () => {
next_page_id: null,
}));
const stopConversationSpy = vi.spyOn(
ConversationService,
"stopConversation",
);
stopConversationSpy.mockImplementation(async (id: string) => {
const conversation = mockData.find((conv) => conv.conversation_id === id);
if (conversation) {
conversation.status = "STOPPED";
return conversation;
}
return null;
});
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
@@ -465,9 +460,12 @@ describe("ConversationPanel", () => {
screen.queryByRole("button", { name: /confirm/i }),
).not.toBeInTheDocument();
// Verify the API was called
expect(stopConversationSpy).toHaveBeenCalledWith("1");
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
// Verify the mutation was called
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "1",
version: undefined,
});
expect(mockStopConversationMutate).toHaveBeenCalledTimes(1);
});
it("should only show stop button for STARTING or RUNNING conversations", async () => {
@@ -6,25 +6,25 @@ import { ServerStatus } from "#/components/features/controls/server-status";
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the custom hooks
const mockStartConversationMutate = vi.fn();
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
useStartConversation: () => ({
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
useUnifiedStartConversation: () => ({
mutate: mockStartConversationMutate,
}),
}));
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
useStopConversation: () => ({
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedStopConversation: () => ({
mutate: mockStopConversationMutate,
}),
}));
@@ -41,6 +41,19 @@ vi.mock("#/hooks/use-user-providers", () => ({
}),
}));
vi.mock("#/hooks/query/use-task-polling", () => ({
useTaskPolling: () => ({
isTask: false,
taskId: null,
conversationId: "test-conversation-id",
task: null,
taskStatus: null,
taskDetail: null,
taskError: null,
isLoadingTask: false,
}),
}));
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@@ -66,12 +79,14 @@ vi.mock("react-i18next", async () => {
});
describe("ServerStatus", () => {
// Helper function to mock agent store with specific state
// Mock functions for handlers
const mockHandleStop = vi.fn();
const mockHandleResumeAgent = vi.fn();
// Helper function to mock agent state with specific state
const mockAgentStore = (agentState: AgentState) => {
vi.mocked(useAgentStore).mockReturnValue({
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
};
@@ -85,20 +100,42 @@ describe("ServerStatus", () => {
// Test RUNNING status
const { rerender } = renderWithProviders(
<ServerStatus conversationStatus="RUNNING" />,
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test STOPPED status
rerender(<ServerStatus conversationStatus="STOPPED" />);
rerender(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
// Test STARTING status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus="STARTING" />);
rerender(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test null status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus={null} />);
rerender(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
});
@@ -108,7 +145,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
@@ -128,7 +171,13 @@ describe("ServerStatus", () => {
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
expect(statusContainer).toBeInTheDocument();
@@ -148,7 +197,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
renderWithProviders(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
@@ -165,12 +220,18 @@ describe("ServerStatus", () => {
const user = userEvent.setup();
// Clear previous calls
mockStopConversationMutate.mockClear();
mockHandleStop.mockClear();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
@@ -178,21 +239,25 @@ describe("ServerStatus", () => {
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
});
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should call start conversation mutation when start server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockStartConversationMutate.mockClear();
mockHandleResumeAgent.mockClear();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
@@ -200,10 +265,7 @@ describe("ServerStatus", () => {
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(mockStartConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
providers: [],
});
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
});
it("should close context menu after stop server action", async () => {
@@ -212,7 +274,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
@@ -221,9 +289,7 @@ describe("ServerStatus", () => {
await user.click(stopButton);
// Context menu should be closed (handled by the component)
expect(mockStopConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
});
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should close context menu after start server action", async () => {
@@ -232,7 +298,13 @@ describe("ServerStatus", () => {
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
@@ -250,7 +322,13 @@ describe("ServerStatus", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus={null} />);
renderWithProviders(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusText = screen.getByText("Running");
expect(statusText).toBeInTheDocument();
@@ -5,12 +5,12 @@ import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useConversationStore } from "#/state/conversation-store";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the conversation store
@@ -57,14 +57,11 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
const onStopMock = vi.fn();
// Helper function to mock stores
const mockStores = (agentState: AgentState = AgentState.INIT) => {
vi.mocked(useAgentStore).mockReturnValue({
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
vi.mocked(useConversationStore).mockReturnValue({
@@ -103,14 +100,13 @@ describe("InteractiveChatBox", () => {
};
// Helper function to render with Router context
const renderInteractiveChatBox = (props: any, options: any = {}) => {
return renderWithProviders(
const renderInteractiveChatBox = (props: any, options: any = {}) =>
renderWithProviders(
<MemoryRouter>
<InteractiveChatBox {...props} />
</MemoryRouter>,
options,
);
};
beforeAll(() => {
global.URL.createObjectURL = vi
@@ -127,7 +123,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const chatBox = screen.getByTestId("interactive-chat-box");
@@ -140,7 +135,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const textbox = screen.getByTestId("chat-input");
@@ -157,7 +151,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
// Create a larger file to ensure it passes validation
@@ -184,7 +177,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
@@ -209,7 +201,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const textarea = screen.getByTestId("chat-input");
@@ -240,7 +231,6 @@ describe("InteractiveChatBox", () => {
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
const button = screen.getByTestId("submit-button");
@@ -250,33 +240,14 @@ describe("InteractiveChatBox", () => {
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should display the stop button when agent is running and call onStop when clicked", async () => {
const user = userEvent.setup();
mockStores(AgentState.RUNNING);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
onStop: onStopMock,
});
// The stop button should be available when agent is running
const stopButton = screen.getByTestId("stop-button");
expect(stopButton).toBeInTheDocument();
await user.click(stopButton);
expect(onStopMock).toHaveBeenCalledOnce();
});
it("should handle image upload and message submission correctly", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onStop = vi.fn();
mockStores(AgentState.AWAITING_USER_INPUT);
const { rerender } = renderInteractiveChatBox({
onSubmit: onSubmit,
onStop: onStop,
onSubmit,
});
// Verify text input has the initial value
@@ -296,7 +267,7 @@ describe("InteractiveChatBox", () => {
// Simulate parent component updating the value prop
rerender(
<MemoryRouter>
<InteractiveChatBox onSubmit={onSubmit} onStop={onStop} />
<InteractiveChatBox onSubmit={onSubmit} />
</MemoryRouter>,
);
@@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react";
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { useJupyterStore } from "#/state/jupyter-store";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock react-i18next
@@ -30,11 +30,9 @@ describe("JupyterEditor", () => {
});
it("should have a scrollable container", () => {
// Mock agent store to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
vi.mocked(useAgentStore).mockReturnValue({
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.RUNNING,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
render(
@@ -5,11 +5,11 @@ import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
// Mock the agent store
vi.mock("#/stores/agent-store", () => ({
useAgentStore: vi.fn(),
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the conversation ID hook
@@ -50,11 +50,9 @@ describe("MicroagentsModal - Refresh Button", () => {
microagents: mockMicroagents,
});
// Mock the agent store to return a ready state
vi.mocked(useAgentStore).mockReturnValue({
// Mock the agent state to return a ready state
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.AWAITING_USER_INPUT,
setCurrentAgentState: vi.fn(),
reset: vi.fn(),
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import { screen, waitFor, render } from "@testing-library/react";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import {
@@ -19,16 +19,34 @@ import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
// MSW WebSocket mock setup
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
beforeAll(() => mswServer.listen());
beforeAll(() => {
// The global MSW server from vitest.setup.ts is already running
// We just need to start our WebSocket-specific server
mswServer.listen({ onUnhandledRequest: "bypass" });
});
afterEach(() => {
mswServer.resetHandlers();
// Clean up any React components
cleanup();
});
afterAll(async () => {
// Close the WebSocket MSW server
mswServer.close();
// Give time for any pending WebSocket connections to close. This is very important to prevent serious memory leaks
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
});
afterAll(() => mswServer.close());
// Helper function to render components with ConversationWebSocketProvider
function renderWithWebSocketContext(
children: React.ReactNode,
conversationId = "test-conversation-default",
conversationUrl = "http://localhost:3000/api/conversations/test-conversation-default",
sessionApiKey: string | null = null,
) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -39,7 +57,11 @@ function renderWithWebSocketContext(
return render(
<QueryClientProvider client={queryClient}>
<ConversationWebSocketProvider conversationId={conversationId}>
<ConversationWebSocketProvider
conversationId={conversationId}
conversationUrl={conversationUrl}
sessionApiKey={sessionApiKey}
>
{children}
</ConversationWebSocketProvider>
</QueryClientProvider>,
@@ -394,4 +416,98 @@ describe("Conversation WebSocket Handler", () => {
it.todo("should send user actions through WebSocket when connected");
it.todo("should handle send attempts when disconnected");
});
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
describe("Terminal I/O Integration", () => {
it("should append command to store when ExecuteBashAction event is received", async () => {
const { createMockExecuteBashActionEvent } = await import(
"#/mocks/mock-ws-helpers"
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
useCommandStore.getState().clearTerminal();
// Create a mock ExecuteBashAction event
const mockBashActionEvent = createMockExecuteBashActionEvent("npm test");
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBashActionEvent));
}),
);
// Render with WebSocket context (we don't need a component, just need the provider to be active)
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the command to be added to the store
await waitFor(() => {
const { commands } = useCommandStore.getState();
expect(commands.length).toBe(1);
});
// Verify the command was added with correct type and content
const { commands } = useCommandStore.getState();
expect(commands[0].type).toBe("input");
expect(commands[0].content).toBe("npm test");
});
it("should append output to store when ExecuteBashObservation event is received", async () => {
const { createMockExecuteBashObservationEvent } = await import(
"#/mocks/mock-ws-helpers"
);
const { useCommandStore } = await import("#/state/command-store");
// Clear the command store before test
useCommandStore.getState().clearTerminal();
// Create a mock ExecuteBashObservation event
const mockBashObservationEvent = createMockExecuteBashObservationEvent(
"PASS tests/example.test.js\n ✓ should work (2 ms)",
"npm test",
);
// Set up MSW to send the event when connection is established
mswServer.use(
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send the mock event after connection
client.send(JSON.stringify(mockBashObservationEvent));
}),
);
// Render with WebSocket context
renderWithWebSocketContext(<ConnectionStatusComponent />);
// Wait for connection
await waitFor(() => {
expect(screen.getByTestId("connection-state")).toHaveTextContent(
"OPEN",
);
});
// Wait for the output to be added to the store
await waitFor(() => {
const { commands } = useCommandStore.getState();
expect(commands.length).toBe(1);
});
// Verify the output was added with correct type and content
const { commands } = useCommandStore.getState();
expect(commands[0].type).toBe("output");
expect(commands[0].content).toBe(
"PASS tests/example.test.js\n ✓ should work (2 ms)",
);
});
});
});
@@ -37,6 +37,9 @@ export const createWebSocketTestSetup = (
/**
* Standard WebSocket test setup for conversation WebSocket handler tests
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
*/
export const conversationWebSocketTestSetup = () =>
createWebSocketTestSetup("ws://localhost/events/socket");
createWebSocketTestSetup(
"ws://localhost:3000/sockets/events/test-conversation-default",
);
@@ -10,11 +10,13 @@ import { OpenHandsEvent } from "#/types/v1/core";
* Test component to access and display WebSocket connection state
*/
export function ConnectionStatusComponent() {
const { connectionState } = useConversationWebSocket();
const context = useConversationWebSocket();
return (
<div>
<div data-testid="connection-state">{connectionState}</div>
<div data-testid="connection-state">
{context?.connectionState || "NOT_AVAILABLE"}
</div>
</div>
);
}
@@ -13,6 +13,22 @@ vi.mock("#/context/ws-client-provider", () => ({
}),
}));
// Mock useActiveConversation
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: {
id: "test-conversation-id",
conversation_version: "V0",
},
isFetched: true,
}),
}));
// Mock useConversationWebSocket (returns null for V0 conversations)
vi.mock("#/contexts/conversation-websocket-context", () => ({
useConversationWebSocket: () => null,
}));
function TestTerminalComponent() {
const ref = useTerminal();
return <div ref={ref} />;
@@ -12,7 +12,7 @@ import { ws } from "msw";
import { setupServer } from "msw/node";
import { useWebSocket } from "#/hooks/use-websocket";
describe.skip("useWebSocket", () => {
describe("useWebSocket", () => {
// MSW WebSocket mock setup
const wsLink = ws.link("ws://acme.com/ws");
+76 -54
View File
@@ -105,39 +105,55 @@ describe("Content", () => {
});
});
it("should conditionally show security analyzer based on confirmation mode", async () => {
});
describe("Advanced form", () => {
it("should conditionally show security analyzer based on security policy", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
// Enable advanced mode first
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
// Initially confirmation mode is false, so security analyzer should not be visible
expect(confirmation).not.toBeChecked();
const securityPolicy = screen.getByTestId("security-policy-input");
// Initially security policy is "never", so security analyzer should not be visible
expect(securityPolicy).toHaveValue("SETTINGS$SECURITY_POLICY_NEVER");
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
// Enable confirmation mode
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// Change security policy to "always"
await userEvent.click(securityPolicy);
const alwaysOption = screen.getByText("SETTINGS$SECURITY_POLICY_ALWAYS");
await userEvent.click(alwaysOption);
expect(securityPolicy).toHaveValue("SETTINGS$SECURITY_POLICY_ALWAYS");
// Security analyzer should now be visible
screen.getByTestId("security-analyzer-input");
// Disable confirmation mode again
await userEvent.click(confirmation);
expect(confirmation).not.toBeChecked();
// Change security policy to "risky"
await userEvent.click(securityPolicy);
const riskyOption = screen.getByText("SETTINGS$SECURITY_POLICY_RISKY");
await userEvent.click(riskyOption);
expect(securityPolicy).toHaveValue("SETTINGS$SECURITY_POLICY_RISKY");
// Security analyzer should still be visible
screen.getByTestId("security-analyzer-input");
// Change security policy back to "never"
await userEvent.click(securityPolicy);
const neverOption = screen.getByText("SETTINGS$SECURITY_POLICY_NEVER");
await userEvent.click(neverOption);
expect(securityPolicy).toHaveValue("SETTINGS$SECURITY_POLICY_NEVER");
// Security analyzer should be hidden again
expect(
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");
@@ -219,7 +235,7 @@ describe("Content", () => {
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
agent: "CoActAgent",
confirmation_mode: true,
security_policy: "always",
enable_default_condenser: false,
security_analyzer: "none",
});
@@ -231,11 +247,9 @@ describe("Content", () => {
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const securityPolicy = screen.getByTestId("security-policy-input");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
await waitFor(() => {
expect(model).toHaveValue("openai/gpt-4o");
@@ -245,7 +259,7 @@ describe("Content", () => {
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(agent).toHaveValue("CoActAgent");
expect(confirmation).toBeChecked();
expect(securityPolicy).toHaveValue("SETTINGS$SECURITY_POLICY_ALWAYS");
expect(condensor).not.toBeChecked();
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
});
@@ -305,7 +319,7 @@ describe("Form submission", () => {
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const securityPolicy = screen.getByTestId("security-policy-input");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
@@ -320,9 +334,11 @@ describe("Form submission", () => {
// enter api key
await userEvent.type(apiKey, "test-api-key");
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// select security policy "always"
await userEvent.click(securityPolicy);
const alwaysOption = screen.getByText("SETTINGS$SECURITY_POLICY_ALWAYS");
await userEvent.click(alwaysOption);
expect(securityPolicy).toHaveValue("SETTINGS$SECURITY_POLICY_ALWAYS");
// toggle memory condensor
await userEvent.click(condensor);
@@ -350,7 +366,7 @@ describe("Form submission", () => {
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
agent: "CoActAgent",
confirmation_mode: true,
security_policy: "always",
enable_default_condenser: false,
security_analyzer: null,
}),
@@ -407,7 +423,7 @@ describe("Form submission", () => {
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
confirmation_mode: true,
security_policy: "always",
});
renderLlmSettingsScreen();
@@ -425,10 +441,8 @@ describe("Form submission", () => {
"enable-memory-condenser-switch",
);
// Confirmation mode switch is now in basic settings, always visible
const confirmation = await screen.findByTestId(
"enable-confirmation-mode-switch",
);
// Security policy dropdown in advanced settings
const securityPolicy = await screen.findByTestId("security-policy-input");
// enter custom model
await userEvent.type(model, "-mini");
@@ -484,12 +498,18 @@ describe("Form submission", () => {
expect(agent).toHaveValue("CodeActAgent");
expect(submitButton).toBeDisabled();
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).not.toBeChecked();
// change security policy
await userEvent.click(securityPolicy);
const neverOption = screen.getByText("SETTINGS$SECURITY_POLICY_NEVER");
await userEvent.click(neverOption);
expect(securityPolicy).toHaveValue("SETTINGS$SECURITY_POLICY_NEVER");
expect(submitButton).not.toBeDisabled();
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// reset security policy back to "always"
await userEvent.click(securityPolicy);
const alwaysOption = screen.getByText("SETTINGS$SECURITY_POLICY_ALWAYS");
await userEvent.click(alwaysOption);
expect(securityPolicy).toHaveValue("SETTINGS$SECURITY_POLICY_ALWAYS");
expect(submitButton).toBeDisabled();
// toggle memory condensor
@@ -586,7 +606,7 @@ describe("Form submission", () => {
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
confirmation_mode: true,
security_policy: "always",
});
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
@@ -615,7 +635,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
security_policy: null, // Security policy is now an advanced setting, should be cleared when saving basic settings
}),
);
});
@@ -776,9 +796,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 +803,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 +956,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 +1126,17 @@ describe("SaaS mode", () => {
const providerInput = screen.getByTestId("llm-provider-input");
const modelInput = screen.getByTestId("llm-model-input");
const apiKeyInput = screen.getByTestId("llm-api-key-input");
const confirmationModeSwitch = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(providerInput).toBeDisabled();
expect(modelInput).toBeDisabled();
expect(apiKeyInput).toBeDisabled();
expect(confirmationModeSwitch).toBeDisabled();
expect(advancedSwitch).toBeDisabled();
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
expect(
screen.queryByTestId("enable-confirmation-mode-switch"),
).not.toBeInTheDocument();
});
});
});
@@ -60,7 +60,7 @@ describe("Check for hardcoded English strings", () => {
test("InteractiveChatBox should not have hardcoded English strings", () => {
const { container } = renderWithProviders(
<MemoryRouter>
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />
<InteractiveChatBox onSubmit={() => {}} />
</MemoryRouter>,
);
@@ -11,7 +11,6 @@ import {
CreateMicroagent,
FileUploadSuccessResponse,
GetFilesResponse,
GetFileResponse,
} from "../open-hands.types";
import { openHands } from "../open-hands-axios";
import { Provider } from "#/types/settings";
@@ -159,19 +158,6 @@ class ConversationService {
return data;
}
/**
* Get the blob of the workspace zip
* @returns Blob of the workspace zip
*/
static async getWorkspaceZip(conversationId: string): Promise<Blob> {
const url = `${this.getConversationUrl(conversationId)}/zip-directory`;
const response = await openHands.get(url, {
responseType: "blob",
headers: this.getConversationHeaders(),
});
return response.data;
}
/**
* Get the web hosts
* @returns Array of web hosts
@@ -201,7 +187,7 @@ class ConversationService {
static async getRuntimeId(
conversationId: string,
): Promise<{ runtime_id: string }> {
const url = `${this.getConversationUrl(conversationId)}/config`;
const url = `/api/conversations/${conversationId}/config`;
const { data } = await openHands.get<{ runtime_id: string }>(url, {
headers: this.getConversationHeaders(),
});
@@ -379,22 +365,6 @@ class ConversationService {
return data;
}
/**
* Retrieve the content of a file
* @param conversationId ID of the conversation
* @param path Full path of the file to retrieve
* @returns Code content of the file
*/
static async getFile(conversationId: string, path: string): Promise<string> {
const url = `${this.getConversationUrl(conversationId)}/select-file`;
const { data } = await openHands.get<GetFileResponse>(url, {
params: { file: path },
headers: this.getConversationHeaders(),
});
return data.code;
}
/**
* Upload multiple files to the workspace
* @param conversationId ID of the conversation
@@ -0,0 +1,296 @@
import axios from "axios";
import { openHands } from "../open-hands-axios";
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
import { Provider } from "#/types/settings";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import type {
V1SendMessageRequest,
V1SendMessageResponse,
V1AppConversationStartRequest,
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
} from "./v1-conversation-service.types";
class V1ConversationService {
/**
* Build headers for V1 API requests that require session authentication
* @param sessionApiKey Session API key for authentication
* @returns Headers object with X-Session-API-Key if provided
*/
private static buildSessionHeaders(
sessionApiKey?: string | null,
): Record<string, string> {
const headers: Record<string, string> = {};
if (sessionApiKey) {
headers["X-Session-API-Key"] = sessionApiKey;
}
return headers;
}
/**
* Build the full URL for V1 runtime-specific endpoints
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param path The API path (e.g., "/api/vscode/url")
* @returns Full URL to the runtime endpoint
*/
private static buildRuntimeUrl(
conversationUrl: string | null | undefined,
path: string,
): string {
const baseUrl = buildHttpBaseUrl(conversationUrl);
return `${baseUrl}${path}`;
}
/**
* Send a message to a V1 conversation
* @param conversationId The conversation ID
* @param message The message to send
* @returns The sent message response
*/
static async sendMessage(
conversationId: string,
message: V1SendMessageRequest,
): Promise<V1SendMessageResponse> {
const { data } = await openHands.post<V1SendMessageResponse>(
`/api/conversations/${conversationId}/events`,
message,
);
return data;
}
/**
* Create a new V1 conversation using the app-conversations API
* Returns the start task immediately with app_conversation_id as null.
* You must poll getStartTask() until status is READY to get the conversation ID.
*
* @returns AppConversationStartTask with task ID
*/
static async createConversation(
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
selected_branch?: string,
conversationInstructions?: string,
trigger?: ConversationTrigger,
): Promise<V1AppConversationStartTask> {
const body: V1AppConversationStartRequest = {
selected_repository: selectedRepository,
git_provider,
selected_branch,
title: conversationInstructions,
trigger,
};
// Add initial message if provided
if (initialUserMsg) {
body.initial_message = {
role: "user",
content: [
{
type: "text",
text: initialUserMsg,
},
],
};
}
const { data } = await openHands.post<V1AppConversationStartTask>(
"/api/v1/app-conversations",
body,
);
return data;
}
/**
* Get a start task by ID
* Poll this endpoint until status is READY to get the app_conversation_id
*
* @param taskId The task UUID
* @returns AppConversationStartTask or null
*/
static async getStartTask(
taskId: string,
): Promise<V1AppConversationStartTask | null> {
const { data } = await openHands.get<(V1AppConversationStartTask | null)[]>(
`/api/v1/app-conversations/start-tasks?ids=${taskId}`,
);
return data[0] || null;
}
/**
* Search for start tasks (ongoing tasks that haven't completed yet)
* Use this to find tasks that were started but the user navigated away
*
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
* filter the results client-side after fetching.
*
* @param limit Maximum number of tasks to return (max 100)
* @returns Array of start tasks
*/
static async searchStartTasks(
limit: number = 100,
): Promise<V1AppConversationStartTask[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
);
return data.items;
}
/**
* Get the VSCode URL for a V1 conversation
* Uses the custom runtime URL from the conversation
* Note: V1 endpoint doesn't require conversationId in the URL path - it's identified via session API key header
*
* @param _conversationId The conversation ID (not used in V1, kept for interface compatibility)
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @returns VSCode URL response
*/
static async getVSCodeUrl(
_conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<GetVSCodeUrlResponse> {
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
const headers = this.buildSessionHeaders(sessionApiKey);
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
// Map it to match the expected interface
const { data } = await axios.get<{ url: string | null }>(url, { headers });
return {
vscode_url: data.url,
};
}
/**
* Pause a V1 conversation
* Uses the custom runtime URL from the conversation
*
* @param conversationId The conversation ID
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @returns Success response
*/
static async pauseConversation(
conversationId: string,
conversationUrl: string | null | undefined,
sessionApiKey?: string | null,
): Promise<{ success: boolean }> {
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/conversations/${conversationId}/pause`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
const { data } = await axios.post<{ success: boolean }>(
url,
{},
{ headers },
);
return data;
}
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*
* @param sandboxId The sandbox ID to pause
* @returns Success response
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*
* @param sandboxId The sandbox ID to resume
* @returns Success response
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 app conversations by their IDs
* Returns null for any missing conversations
*
* @param ids Array of conversation IDs (max 100)
* @returns Array of conversations or null for missing ones
*/
static async batchGetAppConversations(
ids: string[],
): Promise<(V1AppConversation | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 conversations at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("ids", id));
const { data } = await openHands.get<(V1AppConversation | null)[]>(
`/api/v1/app-conversations?${params.toString()}`,
);
return data;
}
/**
* Upload a single file to the V1 conversation workspace
* V1 API endpoint: POST /api/file/upload/{path}
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @param file The file to upload
* @param path The absolute path where the file should be uploaded (defaults to /workspace/{file.name})
* @returns void on success, throws on error
*/
static async uploadFile(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
file: File,
path?: string,
): Promise<void> {
// Default to /workspace/{filename} if no path provided (must be absolute)
const uploadPath = path || `/workspace/${file.name}`;
const encodedPath = encodeURIComponent(uploadPath);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/file/upload/${encodedPath}`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
// Create FormData with the file
const formData = new FormData();
formData.append("file", file);
// Upload file
await axios.post(url, formData, {
headers: {
...headers,
"Content-Type": "multipart/form-data",
},
});
}
}
export default V1ConversationService;
@@ -0,0 +1,100 @@
import { ConversationTrigger } from "../open-hands.types";
import { Provider } from "#/types/settings";
// V1 API Types for requests
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
export interface V1MessageContent {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
};
}
type V1Role = "user" | "system" | "assistant" | "tool";
export interface V1SendMessageRequest {
role: V1Role;
content: V1MessageContent[];
}
export interface V1AppConversationStartRequest {
sandbox_id?: string | null;
initial_message?: V1SendMessageRequest | null;
processors?: unknown[]; // EventCallbackProcessor - keeping as unknown for now
llm_model?: string | null;
selected_repository?: string | null;
selected_branch?: string | null;
git_provider?: Provider | null;
title?: string | null;
trigger?: ConversationTrigger | null;
pr_number?: number[];
}
export type V1AppConversationStartTaskStatus =
| "WORKING"
| "WAITING_FOR_SANDBOX"
| "PREPARING_REPOSITORY"
| "RUNNING_SETUP_SCRIPT"
| "SETTING_UP_GIT_HOOKS"
| "STARTING_CONVERSATION"
| "READY"
| "ERROR";
export interface V1AppConversationStartTask {
id: string;
created_by_user_id: string | null;
status: V1AppConversationStartTaskStatus;
detail: string | null;
app_conversation_id: string | null;
sandbox_id: string | null;
agent_server_url: string | null;
request: V1AppConversationStartRequest;
created_at: string;
updated_at: string;
}
export interface V1SendMessageResponse {
role: "user" | "system" | "assistant" | "tool";
content: V1MessageContent[];
}
export interface V1AppConversationStartTaskPage {
items: V1AppConversationStartTask[];
next_page_id: string | null;
}
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
export type V1AgentExecutionStatus =
| "RUNNING"
| "AWAITING_USER_INPUT"
| "AWAITING_USER_CONFIRMATION"
| "FINISHED"
| "PAUSED"
| "STOPPED";
export interface V1AppConversation {
id: string;
created_by_user_id: string | null;
sandbox_id: string;
selected_repository: string | null;
selected_branch: string | null;
git_provider: Provider | null;
title: string | null;
trigger: ConversationTrigger | null;
pr_number: number[];
llm_model: string | null;
metrics: unknown | null;
created_at: string;
updated_at: string;
sandbox_status: V1SandboxStatus;
agent_status: V1AgentExecutionStatus | null;
conversation_url: string | null;
session_api_key: string | null;
}
+1
View File
@@ -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> {
@@ -8,16 +8,16 @@ import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { AgentState } from "#/types/agent-state";
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { Messages as V0Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ScrollProvider } from "#/context/scroll-context";
import { useInitialQueryStore } from "#/stores/initial-query-store";
import { useAgentStore } from "#/stores/agent-store";
import { useSendMessage } from "#/hooks/use-send-message";
import { useAgentState } from "#/hooks/use-agent-state";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
@@ -30,12 +30,23 @@ import {
hasUserEvent,
shouldRenderEvent,
} from "./event-content-helpers/should-render-event";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import {
Messages as V1Messages,
hasUserEvent as hasV1UserEvent,
shouldRenderEvent as shouldRenderV1Event,
} from "#/components/v1/chat";
import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
import { useConversationStore } from "#/state/conversation-store";
import ConfirmationModeEnabled from "./confirmation-mode-enabled";
import { isV0Event } from "#/types/v1/type-guards";
import {
isV0Event,
isV1Event,
isSystemPromptEvent,
isConversationStateUpdateEvent,
} from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
function getEntryPoint(
hasRepository: boolean | null,
@@ -48,8 +59,10 @@ function getEntryPoint(
export function ChatInterface() {
const { setMessageToSend } = useConversationStore();
const { data: conversation } = useActiveConversation();
const { errorMessage } = useErrorMessageStore();
const { send, isLoadingMessages } = useWsClient();
const { isLoadingMessages } = useWsClient();
const { send } = useSendMessage();
const storeEvents = useEventStore((state) => state.events);
const { setOptimisticUserMessage, getOptimisticUserMessage } =
useOptimisticUserMessageStore();
@@ -65,7 +78,7 @@ export function ChatInterface() {
} = useScrollToBottom(scrollRef);
const { data: config } = useConfig();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const [feedbackPolarity, setFeedbackPolarity] = React.useState<
"positive" | "negative"
@@ -73,15 +86,24 @@ export function ChatInterface() {
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const { selectedRepository, replayJson } = useInitialQueryStore();
const params = useParams();
const { mutateAsync: uploadFiles } = useUploadFiles();
const { mutateAsync: uploadFiles } = useUnifiedUploadFiles();
const optimisticUserMessage = getOptimisticUserMessage();
const events = storeEvents
const isV1Conversation = conversation?.conversation_version === "V1";
// Filter V0 events
const v0Events = storeEvents
.filter(isV0Event)
.filter(isActionOrObservation)
.filter(shouldRenderEvent);
// Filter V1 events
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
// Combined events count for tracking
const totalEvents = v0Events.length || v1Events.length;
// Check if there are any substantive agent actions (not just system messages)
const hasSubstantiveAgentActions = React.useMemo(
() =>
@@ -93,6 +115,14 @@ export function ChatInterface() {
isOpenHandsAction(event) &&
event.source === "agent" &&
event.action !== "system",
) ||
storeEvents
.filter(isV1Event)
.some(
(event) =>
event.source === "agent" &&
!isSystemPromptEvent(event) &&
!isConversationStateUpdateEvent(event),
),
[storeEvents],
);
@@ -105,7 +135,7 @@ export function ChatInterface() {
// Create mutable copies of the arrays
const images = [...originalImages];
const files = [...originalFiles];
if (events.length === 0) {
if (totalEvents === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(
selectedRepository !== null,
@@ -116,7 +146,7 @@ export function ChatInterface() {
});
} else {
posthog.capture("user_message_sent", {
session_message_count: events.length,
session_message_count: totalEvents,
current_message_length: content.length,
});
}
@@ -151,11 +181,6 @@ export function ChatInterface() {
setMessageToSend("");
};
const handleStop = () => {
posthog.capture("stop_button_clicked");
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
@@ -174,7 +199,9 @@ export function ChatInterface() {
onChatBodyScroll,
};
const userEventsExist = hasUserEvent(events);
const v0UserEventsExist = hasUserEvent(v0Events);
const v1UserEventsExist = hasV1UserEvent(v1Events);
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
return (
<ScrollProvider value={scrollProviderValue}>
@@ -193,15 +220,24 @@ export function ChatInterface() {
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
>
{isLoadingMessages && (
{isLoadingMessages && !isV1Conversation && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{!isLoadingMessages && userEventsExist && (
<Messages
messages={events}
{!isLoadingMessages && v0UserEventsExist && (
<V0Messages
messages={v0Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
)}
{v1UserEventsExist && (
<V1Messages
messages={v1Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
@@ -213,7 +249,7 @@ export function ChatInterface() {
<div className="flex justify-between relative">
<div className="flex items-center gap-1">
<ConfirmationModeEnabled />
{events.length > 0 && (
{totalEvents > 0 && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
@@ -235,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>
@@ -5,6 +5,7 @@ import { GitControlBarPullButton } from "./git-control-bar-pull-button";
import { GitControlBarPushButton } from "./git-control-bar-push-button";
import { GitControlBarPrButton } from "./git-control-bar-pr-button";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { Provider } from "#/types/settings";
import { I18nKey } from "#/i18n/declaration";
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
@@ -17,10 +18,16 @@ export function GitControlBar({ onSuggestionsClick }: GitControlBarProps) {
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const { repositoryInfo } = useTaskPolling();
const selectedRepository = conversation?.selected_repository;
const gitProvider = conversation?.git_provider as Provider;
const selectedBranch = conversation?.selected_branch;
// Priority: conversation data > task data
// This ensures we show repository info immediately from task, then transition to conversation data
const selectedRepository =
conversation?.selected_repository || repositoryInfo?.selectedRepository;
const gitProvider = (conversation?.git_provider ||
repositoryInfo?.gitProvider) as Provider;
const selectedBranch =
conversation?.selected_branch || repositoryInfo?.selectedBranch;
const hasRepository = !!selectedRepository;
@@ -6,18 +6,14 @@ import { AgentState } from "#/types/agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { GitControlBar } from "./git-control-bar";
import { useConversationStore } from "#/state/conversation-store";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
import { processFiles, processImages } from "#/utils/file-processing";
interface InteractiveChatBoxProps {
onSubmit: (message: string, images: File[], files: File[]) => void;
onStop: () => void;
}
export function InteractiveChatBox({
onSubmit,
onStop,
}: InteractiveChatBoxProps) {
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
const {
images,
files,
@@ -29,7 +25,7 @@ export function InteractiveChatBox({
addImageLoading,
removeImageLoading,
} = useConversationStore();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
const { data: conversation } = useActiveConversation();
// Helper function to validate and filter files
@@ -145,7 +141,6 @@ export function InteractiveChatBox({
<CustomChatInput
disabled={isDisabled}
onSubmit={handleSubmit}
onStop={onStop}
onFilesPaste={handleUpload}
conversationStatus={conversation?.status || null}
/>
@@ -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) {
@@ -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();
@@ -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);
@@ -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);
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { AgentState } from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { useWsClient } from "#/context/ws-client-provider";
import { ActionTooltip } from "../action-tooltip";
import { isOpenHandsAction, isActionOrObservation } from "#/types/core/guards";
import { ActionSecurityRisk } from "#/stores/security-analyzer-store";
@@ -12,6 +11,7 @@ import WarningIcon from "#/icons/u-warning.svg?react";
import { useEventMessageStore } from "#/stores/event-message-store";
import { useEventStore } from "#/stores/use-event-store";
import { isV0Event } from "#/types/v1/type-guards";
import { useSendMessage } from "#/hooks/use-send-message";
export function ConfirmationButtons() {
const submittedEventIds = useEventMessageStore(
@@ -23,7 +23,7 @@ export function ConfirmationButtons() {
const { t } = useTranslation();
const { send } = useWsClient();
const { send } = useSendMessage();
const events = useEventStore((state) => state.events);
// Find the most recent action awaiting confirmation
@@ -0,0 +1,198 @@
import { ActionEvent } from "#/types/v1/core";
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
import i18n from "#/i18n";
import { SecurityRisk } from "#/types/v1/core/base/common";
import {
ExecuteBashAction,
FileEditorAction,
StrReplaceEditorAction,
MCPToolAction,
ThinkAction,
FinishAction,
TaskTrackerAction,
BrowserNavigateAction,
BrowserClickAction,
BrowserTypeAction,
BrowserGetStateAction,
BrowserGetContentAction,
BrowserScrollAction,
BrowserGoBackAction,
BrowserListTabsAction,
BrowserSwitchTabAction,
BrowserCloseTabAction,
} from "#/types/v1/core/base/action";
const getRiskText = (risk: SecurityRisk) => {
switch (risk) {
case SecurityRisk.LOW:
return i18n.t("SECURITY$LOW_RISK");
case SecurityRisk.MEDIUM:
return i18n.t("SECURITY$MEDIUM_RISK");
case SecurityRisk.HIGH:
return i18n.t("SECURITY$HIGH_RISK");
case SecurityRisk.UNKNOWN:
default:
return i18n.t("SECURITY$UNKNOWN_RISK");
}
};
const getNoContentActionContent = (): string => "";
// File Editor Actions
const getFileEditorActionContent = (
action: FileEditorAction | StrReplaceEditorAction,
): string => {
// Early return if not a create command or no file text
if (action.command !== "create" || !action.file_text) {
return getNoContentActionContent();
}
// Process file text with length truncation
let fileText = action.file_text;
if (fileText.length > MAX_CONTENT_LENGTH) {
fileText = `${fileText.slice(0, MAX_CONTENT_LENGTH)}...`;
}
return `${action.path}\n${fileText}`;
};
// Command Actions
const getExecuteBashActionContent = (
event: ActionEvent<ExecuteBashAction>,
): string => {
let content = `Command:\n\`${event.action.command}\``;
// Add security risk information if it's HIGH or MEDIUM
if (
event.security_risk === SecurityRisk.HIGH ||
event.security_risk === SecurityRisk.MEDIUM
) {
content += `\n\n${getRiskText(event.security_risk)}`;
}
return content;
};
// Tool Actions
const getMCPToolActionContent = (action: MCPToolAction): string => {
// For V1, the tool name is in the event's tool_name property, not in the action
let details = `**MCP Tool Call**\n\n`;
details += `**Arguments:**\n\`\`\`json\n${JSON.stringify(action.data, null, 2)}\n\`\`\``;
return details;
};
// Simple Actions
const getThinkActionContent = (action: ThinkAction): string => action.thought;
const getFinishActionContent = (action: FinishAction): string =>
action.message.trim();
// Complex Actions
const getTaskTrackerActionContent = (action: TaskTrackerAction): string => {
let content = `**Command:** \`${action.command}\``;
// Handle plan command with task list
if (action.command === "plan") {
if (action.task_list && action.task_list.length > 0) {
content += `\n\n**Task List (${action.task_list.length} ${action.task_list.length === 1 ? "item" : "items"}):**\n`;
action.task_list.forEach((task, index: number) => {
const statusMap = {
todo: "⏳",
in_progress: "🔄",
done: "✅",
};
const statusIcon =
statusMap[task.status as keyof typeof statusMap] || "❓";
content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
if (task.notes) {
content += `\n *Notes: ${task.notes}*`;
}
});
} else {
content += "\n\n**Task List:** Empty";
}
}
return content;
};
// Browser Actions
type BrowserAction =
| BrowserNavigateAction
| BrowserClickAction
| BrowserTypeAction
| BrowserGetStateAction
| BrowserGetContentAction
| BrowserScrollAction
| BrowserGoBackAction
| BrowserListTabsAction
| BrowserSwitchTabAction
| BrowserCloseTabAction;
const getBrowserActionContent = (action: BrowserAction): string => {
switch (action.kind) {
case "BrowserNavigateAction":
if ("url" in action) {
return `Browsing ${action.url}`;
}
break;
case "BrowserClickAction":
case "BrowserTypeAction":
case "BrowserGetStateAction":
case "BrowserGetContentAction":
case "BrowserScrollAction":
case "BrowserGoBackAction":
case "BrowserListTabsAction":
case "BrowserSwitchTabAction":
case "BrowserCloseTabAction":
// These browser actions typically don't need detailed content display
return getNoContentActionContent();
default:
return getNoContentActionContent();
}
return getNoContentActionContent();
};
export const getActionContent = (event: ActionEvent): string => {
const { action } = event;
const actionType = action.kind;
switch (actionType) {
case "FileEditorAction":
case "StrReplaceEditorAction":
return getFileEditorActionContent(action);
case "ExecuteBashAction":
return getExecuteBashActionContent(
event as ActionEvent<ExecuteBashAction>,
);
case "MCPToolAction":
return getMCPToolActionContent(action);
case "ThinkAction":
return getThinkActionContent(action);
case "FinishAction":
return getFinishActionContent(action);
case "TaskTrackerAction":
return getTaskTrackerActionContent(action);
case "BrowserNavigateAction":
case "BrowserClickAction":
case "BrowserTypeAction":
case "BrowserGetStateAction":
case "BrowserGetContentAction":
case "BrowserScrollAction":
case "BrowserGoBackAction":
case "BrowserListTabsAction":
case "BrowserSwitchTabAction":
case "BrowserCloseTabAction":
return getBrowserActionContent(action);
default:
return getDefaultEventContent(event);
}
};
@@ -0,0 +1,168 @@
import { Trans } from "react-i18next";
import { OpenHandsEvent } from "#/types/v1/core";
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component";
import { getActionContent } from "./get-action-content";
import { getObservationContent } from "./get-observation-content";
import i18n from "#/i18n";
const trimText = (text: string, maxLength: number): string => {
if (!text) return "";
return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
};
// Helper function to create title from translation key
const createTitleFromKey = (
key: string,
values: Record<string, unknown>,
): React.ReactNode => {
if (!i18n.exists(key)) {
return key;
}
return (
<Trans
i18nKey={key}
values={values}
components={{
path: <PathComponent />,
cmd: <MonoComponent />,
}}
/>
);
};
// Action Event Processing
const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
// Early return if not an action event
if (!isActionEvent(event)) {
return "";
}
const actionType = event.action.kind;
let actionKey = "";
let actionValues: Record<string, unknown> = {};
switch (actionType) {
case "ExecuteBashAction":
actionKey = "ACTION_MESSAGE$RUN";
actionValues = {
command: trimText(event.action.command, 80),
};
break;
case "FileEditorAction":
case "StrReplaceEditorAction":
if (event.action.command === "view") {
actionKey = "ACTION_MESSAGE$READ";
} else if (event.action.command === "create") {
actionKey = "ACTION_MESSAGE$WRITE";
} else {
actionKey = "ACTION_MESSAGE$EDIT";
}
actionValues = {
path: event.action.path,
};
break;
case "MCPToolAction":
actionKey = "ACTION_MESSAGE$CALL_TOOL_MCP";
actionValues = {
mcp_tool_name: event.tool_name,
};
break;
case "ThinkAction":
actionKey = "ACTION_MESSAGE$THINK";
break;
case "FinishAction":
actionKey = "ACTION_MESSAGE$FINISH";
break;
case "TaskTrackerAction":
actionKey = "ACTION_MESSAGE$TASK_TRACKING";
break;
case "BrowserNavigateAction":
actionKey = "ACTION_MESSAGE$BROWSE";
break;
default:
// For unknown actions, use the type name
return actionType.replace("Action", "").toUpperCase();
}
if (actionKey) {
return createTitleFromKey(actionKey, actionValues);
}
return actionType;
};
// Observation Event Processing
const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
// Early return if not an observation event
if (!isObservationEvent(event)) {
return "";
}
const observationType = event.observation.kind;
let observationKey = "";
let observationValues: Record<string, unknown> = {};
switch (observationType) {
case "ExecuteBashObservation":
observationKey = "OBSERVATION_MESSAGE$RUN";
observationValues = {
command: event.observation.command
? trimText(event.observation.command, 80)
: "",
};
break;
case "FileEditorObservation":
case "StrReplaceEditorObservation":
if (event.observation.command === "view") {
observationKey = "OBSERVATION_MESSAGE$READ";
} else {
observationKey = "OBSERVATION_MESSAGE$EDIT";
}
observationValues = {
path: event.observation.path || "",
};
break;
case "MCPToolObservation":
observationKey = "OBSERVATION_MESSAGE$MCP";
observationValues = {
mcp_tool_name: event.observation.tool_name,
};
break;
case "BrowserObservation":
observationKey = "OBSERVATION_MESSAGE$BROWSE";
break;
case "TaskTrackerObservation":
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
break;
default:
// For unknown observations, use the type name
return observationType.replace("Observation", "").toUpperCase();
}
if (observationKey) {
return createTitleFromKey(observationKey, observationValues);
}
return observationType;
};
export const getEventContent = (event: OpenHandsEvent) => {
let title: React.ReactNode = "";
let details: string = "";
if (isActionEvent(event)) {
title = getActionEventTitle(event);
details = getActionContent(event);
} else if (isObservationEvent(event)) {
title = getObservationEventTitle(event);
details = getObservationContent(event);
}
return {
title: title || i18n.t("EVENT$UNKNOWN_EVENT"),
details: details || i18n.t("EVENT$UNKNOWN_EVENT"),
};
};
@@ -0,0 +1,203 @@
import { ObservationEvent } from "#/types/v1/core";
import { getObservationResult } from "./get-observation-result";
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
import i18n from "#/i18n";
import {
MCPToolObservation,
FinishObservation,
ThinkObservation,
BrowserObservation,
ExecuteBashObservation,
FileEditorObservation,
StrReplaceEditorObservation,
TaskTrackerObservation,
} from "#/types/v1/core/base/observation";
// File Editor Observations
const getFileEditorObservationContent = (
event: ObservationEvent<FileEditorObservation | StrReplaceEditorObservation>,
): string => {
const { observation } = event;
const successMessage = getObservationResult(event) === "success";
// For view commands or successful edits with content changes, format as code block
if (
(successMessage &&
"old_content" in observation &&
"new_content" in observation &&
observation.old_content &&
observation.new_content) ||
observation.command === "view"
) {
return `\`\`\`\n${observation.output}\n\`\`\``;
}
// For other commands, return the output as-is
return observation.output;
};
// Command Observations
const getExecuteBashObservationContent = (
event: ObservationEvent<ExecuteBashObservation>,
): string => {
const { observation } = event;
let { output } = observation;
if (output.length > MAX_CONTENT_LENGTH) {
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
}
return `Output:\n\`\`\`sh\n${output.trim() || i18n.t("OBSERVATION$COMMAND_NO_OUTPUT")}\n\`\`\``;
};
// Tool Observations
const getBrowserObservationContent = (
event: ObservationEvent<BrowserObservation>,
): string => {
const { observation } = event;
let contentDetails = "";
if ("error" in observation && observation.error) {
contentDetails += `**Error:**\n${observation.error}\n\n`;
}
contentDetails += `**Output:**\n${observation.output}`;
if (contentDetails.length > MAX_CONTENT_LENGTH) {
contentDetails = `${contentDetails.slice(0, MAX_CONTENT_LENGTH)}...(truncated)`;
}
return contentDetails;
};
const getMCPToolObservationContent = (
event: ObservationEvent<MCPToolObservation>,
): string => {
const { observation } = event;
// Extract text content from the observation
const textContent = observation.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
let content = `**Tool:** ${observation.tool_name}\n\n`;
if (observation.is_error) {
content += `**Error:**\n${textContent}`;
} else {
content += `**Result:**\n${textContent}`;
}
if (content.length > MAX_CONTENT_LENGTH) {
content = `${content.slice(0, MAX_CONTENT_LENGTH)}...`;
}
return content;
};
// Complex Observations
const getTaskTrackerObservationContent = (
event: ObservationEvent<TaskTrackerObservation>,
): string => {
const { observation } = event;
const { command, task_list: taskList } = observation;
let content = `**Command:** \`${command}\``;
if (command === "plan" && taskList.length > 0) {
content += `\n\n**Task List (${taskList.length} ${taskList.length === 1 ? "item" : "items"}):**\n`;
taskList.forEach((task, index: number) => {
const statusMap = {
todo: "⏳",
in_progress: "🔄",
done: "✅",
};
const statusIcon =
statusMap[task.status as keyof typeof statusMap] || "❓";
content += `\n${index + 1}. ${statusIcon} **[${task.status.toUpperCase().replace("_", " ")}]** ${task.title}`;
if (task.notes) {
content += `\n *Notes: ${task.notes}*`;
}
});
} else if (command === "plan") {
content += "\n\n**Task List:** Empty";
}
if (
"content" in observation &&
observation.content &&
observation.content.trim()
) {
content += `\n\n**Result:** ${observation.content.trim()}`;
}
return content;
};
// Simple Observations
const getThinkObservationContent = (
event: ObservationEvent<ThinkObservation>,
): string => {
const { observation } = event;
return observation.content || "";
};
const getFinishObservationContent = (
event: ObservationEvent<FinishObservation>,
): string => {
const { observation } = event;
return observation.message || "";
};
export const getObservationContent = (event: ObservationEvent): string => {
const observationType = event.observation.kind;
switch (observationType) {
case "FileEditorObservation":
case "StrReplaceEditorObservation":
return getFileEditorObservationContent(
event as ObservationEvent<
FileEditorObservation | StrReplaceEditorObservation
>,
);
case "ExecuteBashObservation":
return getExecuteBashObservationContent(
event as ObservationEvent<ExecuteBashObservation>,
);
case "BrowserObservation":
return getBrowserObservationContent(
event as ObservationEvent<BrowserObservation>,
);
case "MCPToolObservation":
return getMCPToolObservationContent(
event as ObservationEvent<MCPToolObservation>,
);
case "TaskTrackerObservation":
return getTaskTrackerObservationContent(
event as ObservationEvent<TaskTrackerObservation>,
);
case "ThinkObservation":
return getThinkObservationContent(
event as ObservationEvent<ThinkObservation>,
);
case "FinishObservation":
return getFinishObservationContent(
event as ObservationEvent<FinishObservation>,
);
default:
return getDefaultEventContent(event);
}
};
@@ -0,0 +1,30 @@
import { ObservationEvent } from "#/types/v1/core";
export type ObservationResultStatus = "success" | "error" | "timeout";
export const getObservationResult = (
event: ObservationEvent,
): ObservationResultStatus => {
const { observation } = event;
const observationType = observation.kind;
switch (observationType) {
case "ExecuteBashObservation": {
const exitCode = observation.exit_code;
if (exitCode === -1) return "timeout"; // Command timed out
if (exitCode === 0) return "success"; // Command executed successfully
return "error"; // Command failed
}
case "FileEditorObservation":
case "StrReplaceEditorObservation":
// Check if there's an error
if (observation.error) return "error";
return "success";
case "MCPToolObservation":
if (observation.is_error) return "error";
return "success";
default:
return "success";
}
};
@@ -0,0 +1,41 @@
import { MessageEvent } from "#/types/v1/core";
import i18n from "#/i18n";
export const parseMessageFromEvent = (event: MessageEvent): string => {
const message = event.llm_message;
// Safety check: ensure llm_message exists and has content
if (!message || !message.content) {
return "";
}
// Get the text content from the message
let textContent = "";
if (message.content) {
if (Array.isArray(message.content)) {
// Handle array of content blocks
textContent = message.content
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("\n");
} else if (typeof message.content === "string") {
// Handle string content
textContent = message.content;
}
}
// Check if there are image_urls in the message content
const hasImages =
Array.isArray(message.content) &&
message.content.some((content) => content.type === "image");
if (!hasImages) {
return textContent;
}
// If there are images, try to split by the augmented prompt delimiter
const delimiter = i18n.t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE");
const parts = textContent.split(delimiter);
return parts[0];
};
@@ -0,0 +1,6 @@
import { OpenHandsEvent } from "#/types/v1/core";
export const MAX_CONTENT_LENGTH = 1000;
export const getDefaultEventContent = (event: OpenHandsEvent): string =>
`\`\`\`json\n${JSON.stringify(event, null, 2)}\n\`\`\``;
@@ -0,0 +1,66 @@
import { OpenHandsEvent } from "#/types/v1/core";
import {
isActionEvent,
isObservationEvent,
isMessageEvent,
isAgentErrorEvent,
isConversationStateUpdateEvent,
} from "#/types/v1/type-guards";
// V1 events that should not be rendered
const NO_RENDER_ACTION_TYPES = [
"ThinkAction",
// Add more action types that should not be rendered
];
const NO_RENDER_OBSERVATION_TYPES = [
"ThinkObservation",
// Add more observation types that should not be rendered
];
export const shouldRenderEvent = (event: OpenHandsEvent) => {
// Explicitly exclude system events that should not be rendered in chat
if (isConversationStateUpdateEvent(event)) {
return false;
}
// Render action events (with filtering)
if (isActionEvent(event)) {
// For V1, action is an object with kind property
const actionType = event.action.kind;
// Hide user commands from the chat interface
if (actionType === "ExecuteBashAction" && event.source === "user") {
return false;
}
return !NO_RENDER_ACTION_TYPES.includes(actionType);
}
// Render observation events (with filtering)
if (isObservationEvent(event)) {
// For V1, observation is an object with kind property
const observationType = event.observation.kind;
// Note: ObservationEvent source is always "environment", not "user"
// So no need to check for user source here
return !NO_RENDER_OBSERVATION_TYPES.includes(observationType);
}
// Render message events (user and assistant messages)
if (isMessageEvent(event)) {
return true;
}
// Render agent error events
if (isAgentErrorEvent(event)) {
return true;
}
// Don't render any other event types (system events, etc.)
return false;
};
export const hasUserEvent = (events: OpenHandsEvent[]) =>
events.some((event) => event.source === "user");
@@ -0,0 +1,49 @@
import React from "react";
import { AgentErrorEvent } from "#/types/v1/core";
import { isAgentErrorEvent } from "#/types/v1/type-guards";
import { ErrorMessage } from "../../../features/chat/error-message";
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
import { MicroagentStatus } from "#/types/microagent-status";
interface ErrorEventMessageProps {
event: AgentErrorEvent;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
export function ErrorEventMessage({
event,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
}: ErrorEventMessageProps) {
if (!isAgentErrorEvent(event)) {
return null;
}
return (
<div>
<ErrorMessage
// V1 doesn't have error_id, use event.id instead
errorId={event.id}
defaultMessage={event.error}
/>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
{/* LikertScaleWrapper expects V0 event types, skip for now */}
</div>
);
}
@@ -0,0 +1,46 @@
import React from "react";
import { ActionEvent } from "#/types/v1/core";
import { FinishAction } from "#/types/v1/core/base/action";
import { ChatMessage } from "../../../features/chat/chat-message";
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
import { getEventContent } from "../event-content-helpers/get-event-content";
import { MicroagentStatus } from "#/types/microagent-status";
interface FinishEventMessageProps {
event: ActionEvent<FinishAction>;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
export function FinishEventMessage({
event,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
}: FinishEventMessageProps) {
return (
<>
<ChatMessage
type="agent"
message={getEventContent(event).details}
actions={actions}
/>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
{/* LikertScaleWrapper expects V0 event types, skip for now */}
</>
);
}
@@ -0,0 +1,33 @@
import React from "react";
import { OpenHandsEvent } from "#/types/v1/core";
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
import { getEventContent } from "../event-content-helpers/get-event-content";
import { getObservationResult } from "../event-content-helpers/get-observation-result";
import { isObservationEvent } from "#/types/v1/type-guards";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
interface GenericEventMessageWrapperProps {
event: OpenHandsEvent;
shouldShowConfirmationButtons: boolean;
}
export function GenericEventMessageWrapper({
event,
shouldShowConfirmationButtons,
}: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event);
return (
<div>
<GenericEventMessage
title={title}
details={details}
success={
isObservationEvent(event) ? getObservationResult(event) : undefined
}
initiallyExpanded={false}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
);
}
@@ -0,0 +1,5 @@
export { UserAssistantEventMessage } from "./user-assistant-event-message";
export { ObservationPairEventMessage } from "./observation-pair-event-message";
export { ErrorEventMessage } from "./error-event-message";
export { FinishEventMessage } from "./finish-event-message";
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
@@ -0,0 +1,59 @@
import React from "react";
import { ActionEvent } from "#/types/v1/core";
import { isActionEvent } from "#/types/v1/type-guards";
import { ChatMessage } from "../../../features/chat/chat-message";
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
import { MicroagentStatus } from "#/types/microagent-status";
interface ObservationPairEventMessageProps {
event: ActionEvent;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
export function ObservationPairEventMessage({
event,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
}: ObservationPairEventMessageProps) {
if (!isActionEvent(event)) {
return null;
}
// Check if there's thought content to display
const thoughtContent = event.thought
.filter((t) => t.type === "text")
.map((t) => t.text)
.join("\n");
if (thoughtContent && event.action.kind !== "ThinkAction") {
return (
<div>
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
</div>
);
}
return (
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
);
}
@@ -0,0 +1,65 @@
import React from "react";
import { MessageEvent } from "#/types/v1/core";
import { ChatMessage } from "../../../features/chat/chat-message";
import { ImageCarousel } from "../../../features/images/image-carousel";
// TODO: Implement file_urls support for V1 messages
// import { FileList } from "../../../features/files/file-list";
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
import { parseMessageFromEvent } from "../event-content-helpers/parse-message-from-event";
import { MicroagentStatus } from "#/types/microagent-status";
interface UserAssistantEventMessageProps {
event: MessageEvent;
shouldShowConfirmationButtons: boolean;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
export function UserAssistantEventMessage({
event,
shouldShowConfirmationButtons,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
}: UserAssistantEventMessageProps) {
const message = parseMessageFromEvent(event);
// Extract image URLs from the message content
const imageUrls: string[] = [];
if (Array.isArray(event.llm_message.content)) {
event.llm_message.content.forEach((content) => {
if (content.type === "image") {
imageUrls.push(...content.image_urls);
}
});
}
return (
<>
<ChatMessage type={event.source} message={message} actions={actions}>
{imageUrls.length > 0 && (
<ImageCarousel size="small" images={imageUrls} />
)}
{/* TODO: Handle file_urls if V1 messages support them */}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
{/* LikertScaleWrapper expects V0 event types, skip for now */}
</>
);
}
@@ -0,0 +1,119 @@
import React from "react";
import { OpenHandsEvent, MessageEvent, ActionEvent } from "#/types/v1/core";
import { FinishAction } from "#/types/v1/core/base/action";
import {
isActionEvent,
isObservationEvent,
isAgentErrorEvent,
} from "#/types/v1/type-guards";
import { MicroagentStatus } from "#/types/microagent-status";
import { useConfig } from "#/hooks/query/use-config";
// TODO: Implement V1 feedback functionality when API supports V1 event IDs
// import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
import {
ErrorEventMessage,
UserAssistantEventMessage,
FinishEventMessage,
ObservationPairEventMessage,
GenericEventMessageWrapper,
} from "./event-message-components";
interface EventMessageProps {
event: OpenHandsEvent;
hasObservationPair: boolean;
isAwaitingUserConfirmation: boolean;
isLastMessage: boolean;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
isInLast10Actions: boolean;
}
/* eslint-disable react/jsx-props-no-spreading */
export function EventMessage({
event,
hasObservationPair,
isAwaitingUserConfirmation,
isLastMessage,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isInLast10Actions,
}: EventMessageProps) {
const shouldShowConfirmationButtons =
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
const { data: config } = useConfig();
// V1 events use string IDs, but useFeedbackExists expects number
// For now, we'll skip feedback functionality for V1 events
const feedbackData = { exists: false };
const isCheckingFeedback = false;
// Common props for components that need them
const commonProps = {
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isLastMessage,
isInLast10Actions,
config,
isCheckingFeedback,
feedbackData,
};
// Agent error events
if (isAgentErrorEvent(event)) {
return <ErrorEventMessage event={event} {...commonProps} />;
}
// Observation pairs with actions
if (hasObservationPair && isActionEvent(event)) {
return (
<ObservationPairEventMessage
event={event}
microagentStatus={microagentStatus}
microagentConversationId={microagentConversationId}
microagentPRUrl={microagentPRUrl}
actions={actions}
/>
);
}
// Finish actions
if (isActionEvent(event) && event.action.kind === "FinishAction") {
return (
<FinishEventMessage
event={event as ActionEvent<FinishAction>}
{...commonProps}
/>
);
}
// Message events (user and assistant messages)
if (!isActionEvent(event) && !isObservationEvent(event)) {
// This is a MessageEvent
return (
<UserAssistantEventMessage
event={event as MessageEvent}
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
{...commonProps}
/>
);
}
// Generic fallback for all other events (including observation events)
return (
<GenericEventMessageWrapper
event={event}
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
/>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { Messages } from "./messages";
export { EventMessage } from "./event-message";
export * from "./event-message-components";
export { getEventContent } from "./event-content-helpers/get-event-content";
export {
shouldRenderEvent,
hasUserEvent,
} from "./event-content-helpers/should-render-event";
@@ -0,0 +1,73 @@
import React from "react";
import { OpenHandsEvent } from "#/types/v1/core";
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
import { EventMessage } from "./event-message";
import { ChatMessage } from "../../features/chat/chat-message";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
// TODO: Implement microagent functionality for V1 when APIs support V1 event IDs
// import { AgentState } from "#/types/agent-state";
// import MemoryIcon from "#/icons/memory_icon.svg?react";
interface MessagesProps {
messages: OpenHandsEvent[];
isAwaitingUserConfirmation: boolean;
}
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
const optimisticUserMessage = getOptimisticUserMessage();
const actionHasObservationPair = React.useCallback(
(event: OpenHandsEvent): boolean => {
if (isActionEvent(event)) {
// Check if there's a corresponding observation event
return !!messages.some(
(msg) => isObservationEvent(msg) && msg.action_id === event.id,
);
}
return false;
},
[messages],
);
// TODO: Implement microagent functionality for V1 if needed
// For now, we'll skip microagent features
return (
<>
{messages.map((message, index) => (
<EventMessage
key={message.id}
event={message}
hasObservationPair={actionHasObservationPair(message)}
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
isLastMessage={messages.length - 1 === index}
isInLast10Actions={messages.length - 1 - index < 10}
// Microagent props - not implemented yet for V1
// microagentStatus={undefined}
// microagentConversationId={undefined}
// microagentPRUrl={undefined}
// actions={undefined}
/>
))}
{optimisticUserMessage && (
<ChatMessage type="user" message={optimisticUserMessage} />
)}
</>
);
},
(prevProps, nextProps) => {
// Prevent re-renders if messages are the same length
if (prevProps.messages.length !== nextProps.messages.length) {
return false;
}
return true;
},
);
Messages.displayName = "Messages";
+1
View File
@@ -0,0 +1 @@
export * from "./chat";
+8 -3
View File
@@ -28,7 +28,12 @@ import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useEventStore } from "#/stores/use-event-store";
export type WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
/**
* @deprecated Use `V1_WebSocketConnectionState` from `conversation-websocket-context.tsx` instead.
* This type is for legacy V0 conversations only.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export type V0_WebSocketStatus = "CONNECTING" | "CONNECTED" | "DISCONNECTED";
const hasValidMessageProperty = (obj: unknown): obj is { message: string } =>
typeof obj === "object" &&
@@ -69,7 +74,7 @@ const isMessageAction = (
isUserMessage(event) || isAssistantMessage(event);
interface UseWsClient {
webSocketStatus: WebSocketStatus;
webSocketStatus: V0_WebSocketStatus;
isLoadingMessages: boolean;
send: (event: Record<string, unknown>) => void;
}
@@ -132,7 +137,7 @@ export function WsClientProvider({
const queryClient = useQueryClient();
const sioRef = React.useRef<Socket | null>(null);
const [webSocketStatus, setWebSocketStatus] =
React.useState<WebSocketStatus>("DISCONNECTED");
React.useState<V0_WebSocketStatus>("DISCONNECTED");
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
const { providers } = useUserProviders();
@@ -7,20 +7,37 @@ import React, {
useMemo,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useWebSocket } from "#/hooks/use-websocket";
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
import { useEventStore } from "#/stores/use-event-store";
import { useErrorMessageStore } from "#/stores/error-message-store";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
import { useCommandStore } from "#/state/command-store";
import {
isV1Event,
isAgentErrorEvent,
isUserMessageEvent,
isActionEvent,
isConversationStateUpdateEvent,
isFullStateConversationStateUpdateEvent,
isAgentStatusConversationStateUpdateEvent,
isExecuteBashActionEvent,
isExecuteBashObservationEvent,
} from "#/types/v1/type-guards";
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
import { buildWebSocketUrl } from "#/utils/websocket-url";
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
// eslint-disable-next-line @typescript-eslint/naming-convention
export type V1_WebSocketConnectionState =
| "CONNECTING"
| "OPEN"
| "CLOSED"
| "CLOSING";
interface ConversationWebSocketContextType {
connectionState: "CONNECTING" | "OPEN" | "CLOSED" | "CLOSING";
connectionState: V1_WebSocketConnectionState;
sendMessage: (message: V1SendMessageRequest) => Promise<void>;
}
const ConversationWebSocketContext = createContext<
@@ -30,22 +47,42 @@ const ConversationWebSocketContext = createContext<
export function ConversationWebSocketProvider({
children,
conversationId,
conversationUrl,
sessionApiKey,
}: {
children: React.ReactNode;
conversationId?: string;
conversationUrl?: string | null;
sessionApiKey?: string | null;
}) {
const [connectionState, setConnectionState] = useState<
"CONNECTING" | "OPEN" | "CLOSED" | "CLOSING"
>("CONNECTING");
const [connectionState, setConnectionState] =
useState<V1_WebSocketConnectionState>("CONNECTING");
// Track if we've ever successfully connected
// Don't show errors until after first successful connection
const hasConnectedRef = React.useRef(false);
const queryClient = useQueryClient();
const { addEvent } = useEventStore();
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
const { setAgentStatus } = useV1ConversationStateStore();
const { appendInput, appendOutput } = useCommandStore();
// Build WebSocket URL from props
const wsUrl = useMemo(
() => buildWebSocketUrl(conversationId, conversationUrl),
[conversationId, conversationUrl],
);
// Reset hasConnected flag when conversation changes
useEffect(() => {
hasConnectedRef.current = false;
}, [conversationId]);
const handleMessage = useCallback(
(messageEvent: MessageEvent) => {
try {
const event = JSON.parse(messageEvent.data);
// Use type guard to validate v1 event structure
if (isV1Event(event)) {
addEvent(event);
@@ -70,25 +107,68 @@ export function ConversationWebSocketProvider({
queryClient,
);
}
// Handle conversation state updates
// TODO: Tests
if (isConversationStateUpdateEvent(event)) {
if (isFullStateConversationStateUpdateEvent(event)) {
setAgentStatus(event.value.agent_status);
}
if (isAgentStatusConversationStateUpdateEvent(event)) {
setAgentStatus(event.value);
}
}
// Handle ExecuteBashAction events - add command as input to terminal
if (isExecuteBashActionEvent(event)) {
appendInput(event.action.command);
}
// Handle ExecuteBashObservation events - add output to terminal
if (isExecuteBashObservationEvent(event)) {
appendOutput(event.observation.output);
}
}
} catch (error) {
// eslint-disable-next-line no-console
console.warn("Failed to parse WebSocket message as JSON:", error);
}
},
[addEvent, setErrorMessage, removeOptimisticUserMessage, queryClient],
[
addEvent,
setErrorMessage,
removeOptimisticUserMessage,
queryClient,
conversationId,
setAgentStatus,
appendInput,
appendOutput,
],
);
const websocketOptions = useMemo(
() => ({
const websocketOptions: WebSocketHookOptions = useMemo(() => {
const queryParams: Record<string, string | boolean> = {
resend_all: true,
};
// Add session_api_key if available
if (sessionApiKey) {
queryParams.session_api_key = sessionApiKey;
}
return {
queryParams,
reconnect: { enabled: true },
onOpen: () => {
setConnectionState("OPEN");
hasConnectedRef.current = true; // Mark that we've successfully connected
removeErrorMessage(); // Clear any previous error messages on successful connection
},
onClose: (event: CloseEvent) => {
setConnectionState("CLOSED");
// Set error message for unexpected disconnects (not normal closure)
if (event.code !== 1000) {
// Only show error message if we've previously connected successfully
// This prevents showing errors during initial connection attempts (e.g., when auto-starting a conversation)
if (event.code !== 1000 && hasConnectedRef.current) {
setErrorMessage(
`Connection lost: ${event.reason || "Unexpected disconnect"}`,
);
@@ -96,20 +176,44 @@ export function ConversationWebSocketProvider({
},
onError: () => {
setConnectionState("CLOSED");
setErrorMessage("Failed to connect to server");
// Only show error message if we've previously connected successfully
if (hasConnectedRef.current) {
setErrorMessage("Failed to connect to server");
}
},
onMessage: handleMessage,
}),
[handleMessage, setErrorMessage, removeErrorMessage],
);
};
}, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
const { socket } = useWebSocket(
"ws://localhost/events/socket",
websocketOptions,
// Build a fallback URL to prevent hook from connecting if conversation data isn't ready
const websocketUrl = wsUrl || "ws://localhost/placeholder";
const { socket } = useWebSocket(websocketUrl, websocketOptions);
// V1 send message function via WebSocket
const sendMessage = useCallback(
async (message: V1SendMessageRequest) => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
const error = "WebSocket is not connected";
setErrorMessage(error);
throw new Error(error);
}
try {
// Send message through WebSocket as JSON
socket.send(JSON.stringify(message));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to send message";
setErrorMessage(errorMessage);
throw error;
}
},
[socket, setErrorMessage],
);
useEffect(() => {
if (socket) {
// Only process socket updates if we have a valid URL
if (socket && wsUrl) {
// Update state based on socket readyState
const updateState = () => {
switch (socket.readyState) {
@@ -133,9 +237,12 @@ export function ConversationWebSocketProvider({
updateState();
}
}, [socket]);
}, [socket, wsUrl]);
const contextValue = useMemo(() => ({ connectionState }), [connectionState]);
const contextValue = useMemo(
() => ({ connectionState, sendMessage }),
[connectionState, sendMessage],
);
return (
<ConversationWebSocketContext.Provider value={contextValue}>
@@ -145,12 +252,9 @@ export function ConversationWebSocketProvider({
}
export const useConversationWebSocket =
(): ConversationWebSocketContextType => {
(): ConversationWebSocketContextType | null => {
const context = useContext(ConversationWebSocketContext);
if (context === undefined) {
throw new Error(
"useConversationWebSocket must be used within a ConversationWebSocketProvider",
);
}
return context;
// Return null instead of throwing when not in provider
// This allows the hook to be called conditionally based on conversation version
return context || null;
};
@@ -1,6 +1,7 @@
import React from "react";
import { WsClientProvider } from "#/context/ws-client-provider";
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
interface WebSocketProviderWrapperProps {
children: React.ReactNode;
@@ -33,6 +34,9 @@ export function WebSocketProviderWrapper({
conversationId,
version,
}: WebSocketProviderWrapperProps) {
// Get conversation data for V1 provider
const { data: conversation } = useActiveConversation();
if (version === 0) {
return (
<WsClientProvider conversationId={conversationId}>
@@ -43,7 +47,11 @@ export function WebSocketProviderWrapper({
if (version === 1) {
return (
<ConversationWebSocketProvider conversationId={conversationId}>
<ConversationWebSocketProvider
conversationId={conversationId}
conversationUrl={conversation?.url}
sessionApiKey={conversation?.session_api_key}
>
{children}
</ConversationWebSocketProvider>
);
@@ -0,0 +1,122 @@
import { QueryClient } from "@tanstack/react-query";
import { Provider } from "#/types/settings";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
/**
* Gets the conversation version from the cache
*/
export const getConversationVersionFromQueryCache = (
queryClient: QueryClient,
conversationId: string,
): "V0" | "V1" => {
const conversation = queryClient.getQueryData<{
conversation_version?: string;
}>(["user", "conversation", conversationId]);
return conversation?.conversation_version === "V1" ? "V1" : "V0";
};
/**
* Fetches a V1 conversation's sandbox_id
*/
const fetchV1ConversationSandboxId = async (
conversationId: string,
): Promise<string> => {
const conversations = await V1ConversationService.batchGetAppConversations([
conversationId,
]);
const appConversation = conversations[0];
if (!appConversation) {
throw new Error(`V1 conversation not found: ${conversationId}`);
}
return appConversation.sandbox_id;
};
/**
* Pause a V1 conversation sandbox by fetching the sandbox_id and pausing it
*/
export const pauseV1ConversationSandbox = async (conversationId: string) => {
const sandboxId = await fetchV1ConversationSandboxId(conversationId);
return V1ConversationService.pauseSandbox(sandboxId);
};
/**
* Stops a V0 conversation using the legacy API
*/
export const stopV0Conversation = async (conversationId: string) =>
ConversationService.stopConversation(conversationId);
/**
* Resumes a V1 conversation sandbox by fetching the sandbox_id and resuming it
*/
export const resumeV1ConversationSandbox = async (conversationId: string) => {
const sandboxId = await fetchV1ConversationSandboxId(conversationId);
return V1ConversationService.resumeSandbox(sandboxId);
};
/**
* Starts a V0 conversation using the legacy API
*/
export const startV0Conversation = async (
conversationId: string,
providers?: Provider[],
) => ConversationService.startConversation(conversationId, providers);
/**
* Optimistically updates the conversation status in the cache
*/
export const updateConversationStatusInCache = (
queryClient: QueryClient,
conversationId: string,
status: string,
): void => {
// Update the individual conversation cache
queryClient.setQueryData<{ status: string }>(
["user", "conversation", conversationId],
(oldData) => {
if (!oldData) return oldData;
return { ...oldData, status };
},
);
// Update the conversations list cache
queryClient.setQueriesData<{
pages: Array<{
results: Array<{ conversation_id: string; status: string }>;
}>;
}>({ queryKey: ["user", "conversations"] }, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
results: page.results.map((conv) =>
conv.conversation_id === conversationId ? { ...conv, status } : conv,
),
})),
};
});
};
/**
* Invalidates all queries related to conversation mutations (start/stop)
*/
export const invalidateConversationQueries = (
queryClient: QueryClient,
conversationId: string,
): void => {
// Invalidate the specific conversation query to trigger automatic refetch
queryClient.invalidateQueries({
queryKey: ["user", "conversation", conversationId],
});
// Also invalidate the conversations list for consistency
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
// Invalidate V1 batch get queries
queryClient.invalidateQueries({
queryKey: ["v1-batch-get-app-conversations"],
});
};
@@ -1,9 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { SuggestedTask } from "#/utils/types";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
interface CreateConversationVariables {
query?: string;
@@ -17,12 +19,24 @@ interface CreateConversationVariables {
createMicroagent?: CreateMicroagent;
}
// Response type that combines both V1 and legacy responses
interface CreateConversationResponse extends Partial<Conversation> {
conversation_id: string;
session_api_key: string | null;
url: string | null;
// V1 specific fields
v1_task_id?: string;
is_v1?: boolean;
}
export const useCreateConversation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: CreateConversationVariables) => {
mutationFn: async (
variables: CreateConversationVariables,
): Promise<CreateConversationResponse> => {
const {
query,
repository,
@@ -31,7 +45,33 @@ export const useCreateConversation = () => {
createMicroagent,
} = variables;
return ConversationService.createConversation(
const useV1 = USE_V1_CONVERSATION_API();
if (useV1) {
// Use V1 API - creates a conversation start task
const startTask = await V1ConversationService.createConversation(
repository?.name,
repository?.gitProvider,
query,
repository?.branch,
conversationInstructions,
undefined, // trigger - will be set by backend
);
// Return a special task ID that the frontend will recognize
// Format: "task-{uuid}" so the conversation screen can poll the task
// Once the task is ready, it will navigate to the actual conversation ID
return {
conversation_id: `task-${startTask.id}`,
session_api_key: null,
url: startTask.agent_server_url,
v1_task_id: startTask.id,
is_v1: true,
};
}
// Use legacy API
const conversation = await ConversationService.createConversation(
repository?.name,
repository?.gitProvider,
query,
@@ -40,6 +80,11 @@ export const useCreateConversation = () => {
conversationInstructions,
createMicroagent,
);
return {
...conversation,
is_v1: false,
};
},
onSuccess: async (_, { query, repository }) => {
posthog.capture("initial_query_submitted", {
@@ -47,7 +92,7 @@ export const useCreateConversation = () => {
query_character_length: query?.length,
has_repository: !!repository,
});
await queryClient.invalidateQueries({
queryClient.removeQueries({
queryKey: ["user", "conversations"],
});
},
@@ -14,6 +14,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE,
confirmation_mode: settings.CONFIRMATION_MODE,
security_analyzer: settings.SECURITY_ANALYZER,
security_policy: settings.SECURITY_POLICY,
llm_api_key:
settings.llm_api_key === ""
? ""
@@ -0,0 +1,81 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Provider } from "#/types/settings";
import { useErrorMessageStore } from "#/stores/error-message-store";
import {
getConversationVersionFromQueryCache,
resumeV1ConversationSandbox,
startV0Conversation,
updateConversationStatusInCache,
invalidateConversationQueries,
} from "./conversation-mutation-utils";
/**
* Unified hook that automatically routes to the correct resume conversation sandbox implementation
* based on the conversation version (V0 or V1).
*
* This hook checks the cached conversation data to determine the version, then calls
* the appropriate API directly. Returns a single useMutation instance that all components share.
*
* Usage is the same as useStartConversation:
* const { mutate: startConversation } = useUnifiedResumeConversationSandbox();
* startConversation({ conversationId: "some-id", providers: [...] });
*/
export const useUnifiedResumeConversationSandbox = () => {
const queryClient = useQueryClient();
const removeErrorMessage = useErrorMessageStore(
(state) => state.removeErrorMessage,
);
return useMutation({
mutationKey: ["start-conversation"],
mutationFn: async (variables: {
conversationId: string;
providers?: Provider[];
version?: "V0" | "V1";
}) => {
// Use provided version or fallback to cache lookup
const version =
variables.version ||
getConversationVersionFromQueryCache(
queryClient,
variables.conversationId,
);
if (version === "V1") {
return resumeV1ConversationSandbox(variables.conversationId);
}
return startV0Conversation(variables.conversationId, variables.providers);
},
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
"user",
"conversations",
]);
return { previousConversations };
},
onError: (_, __, context) => {
if (context?.previousConversations) {
queryClient.setQueryData(
["user", "conversations"],
context.previousConversations,
);
}
},
onSettled: (_, __, variables) => {
invalidateConversationQueries(queryClient, variables.conversationId);
},
onSuccess: (_, variables) => {
// Clear error messages when starting/resuming conversation
removeErrorMessage();
updateConversationStatusInCache(
queryClient,
variables.conversationId,
"RUNNING",
);
},
});
};
@@ -0,0 +1,100 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import {
getConversationVersionFromQueryCache,
pauseV1ConversationSandbox,
stopV0Conversation,
updateConversationStatusInCache,
invalidateConversationQueries,
} from "./conversation-mutation-utils";
/**
* Unified hook that automatically routes to the correct pause conversation sandbox
* implementation based on the conversation version (V0 or V1).
*
* This hook checks the cached conversation data to determine the version, then calls
* the appropriate API directly. Returns a single useMutation instance that all components share.
*
* Usage is the same as useStopConversation:
* const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
* stopConversation({ conversationId: "some-id" });
*/
export const useUnifiedPauseConversationSandbox = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const params = useParams<{ conversationId: string }>();
return useMutation({
mutationKey: ["stop-conversation"],
mutationFn: async (variables: {
conversationId: string;
version?: "V0" | "V1";
}) => {
// Use provided version or fallback to cache lookup
const version =
variables.version ||
getConversationVersionFromQueryCache(
queryClient,
variables.conversationId,
);
if (version === "V1") {
return pauseV1ConversationSandbox(variables.conversationId);
}
return stopV0Conversation(variables.conversationId);
},
onMutate: async () => {
const toastId = toast.loading(
t(I18nKey.TOAST$STOPPING_CONVERSATION),
TOAST_OPTIONS,
);
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
const previousConversations = queryClient.getQueryData([
"user",
"conversations",
]);
return { previousConversations, toastId };
},
onError: (_, __, context) => {
if (context?.toastId) {
toast.dismiss(context.toastId);
}
toast.error(t(I18nKey.TOAST$FAILED_TO_STOP_CONVERSATION), TOAST_OPTIONS);
if (context?.previousConversations) {
queryClient.setQueryData(
["user", "conversations"],
context.previousConversations,
);
}
},
onSettled: (_, __, variables) => {
invalidateConversationQueries(queryClient, variables.conversationId);
},
onSuccess: (_, variables, context) => {
if (context?.toastId) {
toast.dismiss(context.toastId);
}
toast.success(t(I18nKey.TOAST$CONVERSATION_STOPPED), TOAST_OPTIONS);
updateConversationStatusInCache(
queryClient,
variables.conversationId,
"STOPPED",
);
// Only redirect if we're stopping the conversation we're currently viewing
if (params.conversationId === variables.conversationId) {
navigate("/");
}
},
});
};
@@ -0,0 +1,55 @@
import { useMutation } from "@tanstack/react-query";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useUploadFiles } from "./use-upload-files";
import { useV1UploadFiles } from "./use-v1-upload-files";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
interface UnifiedUploadFilesVariables {
conversationId: string;
files: File[];
}
/**
* Unified hook that automatically selects the correct file upload method
* based on the conversation version (V0 or V1).
*
* For V0 conversations: Uses the legacy multi-file upload endpoint
* For V1 conversations: Uses parallel single-file uploads
*
* @returns Mutation hook with the same interface as useUploadFiles
*/
export const useUnifiedUploadFiles = () => {
const { data: conversation } = useActiveConversation();
const isV1Conversation = conversation?.conversation_version === "V1";
// Initialize both hooks
const v0Upload = useUploadFiles();
const v1Upload = useV1UploadFiles();
// Create a unified mutation that delegates to the appropriate hook
return useMutation({
mutationKey: ["unified-upload-files"],
mutationFn: async (
variables: UnifiedUploadFilesVariables,
): Promise<FileUploadSuccessResponse> => {
const { conversationId, files } = variables;
if (isV1Conversation) {
// V1: Use conversation URL and session API key
return v1Upload.mutateAsync({
conversationUrl: conversation?.url,
sessionApiKey: conversation?.session_api_key,
files,
});
}
// V0: Use conversation ID
return v0Upload.mutateAsync({
conversationId,
files,
});
},
meta: {
disableToast: true,
},
});
};
@@ -0,0 +1,82 @@
import { useMutation } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { FileUploadSuccessResponse } from "#/api/open-hands.types";
interface V1UploadFilesVariables {
conversationUrl: string | null | undefined;
sessionApiKey: string | null | undefined;
files: File[];
}
/**
* Hook to upload multiple files in parallel to V1 conversations
* Uploads files concurrently using Promise.allSettled and aggregates results
*
* @returns Mutation hook with mutateAsync function
*/
export const useV1UploadFiles = () =>
useMutation({
mutationKey: ["v1-upload-files"],
mutationFn: async (
variables: V1UploadFilesVariables,
): Promise<FileUploadSuccessResponse> => {
const { conversationUrl, sessionApiKey, files } = variables;
// Upload all files in parallel
const uploadPromises = files.map(async (file) => {
try {
// Upload to /workspace/{filename}
const filePath = `/workspace/${file.name}`;
await V1ConversationService.uploadFile(
conversationUrl,
sessionApiKey,
file,
filePath,
);
return { success: true as const, fileName: file.name, filePath };
} catch (error) {
return {
success: false as const,
fileName: file.name,
filePath: `/workspace/${file.name}`,
error: error instanceof Error ? error.message : "Unknown error",
};
}
});
// Wait for all uploads to complete (both successful and failed)
const results = await Promise.allSettled(uploadPromises);
// Aggregate the results
const uploadedFiles: string[] = [];
const skippedFiles: { name: string; reason: string }[] = [];
results.forEach((result) => {
if (result.status === "fulfilled") {
if (result.value.success) {
// Return the absolute file path for V1
uploadedFiles.push(result.value.filePath);
} else {
skippedFiles.push({
name: result.value.fileName,
reason: result.value.error,
});
}
} else {
// Promise was rejected (shouldn't happen since we catch errors above)
skippedFiles.push({
name: "unknown",
reason: result.reason?.message || "Upload failed",
});
}
});
return {
uploaded_files: uploadedFiles,
skipped_files: skippedFiles,
};
},
meta: {
disableToast: true,
},
});
@@ -5,14 +5,23 @@ import ConversationService from "#/api/conversation-service/conversation-service
export const useActiveConversation = () => {
const { conversationId } = useConversationId();
const userConversation = useUserConversation(conversationId, (query) => {
if (query.state.data?.status === "STARTING") {
return 3000; // 3 seconds
}
// TODO: Return conversation title as a WS event to avoid polling
// This was changed from 5 minutes to 30 seconds to poll for updated conversation title after an auto update
return 30000; // 30 seconds
});
// Don't poll if this is a task ID (format: "task-{uuid}")
// Task polling is handled by useTaskPolling hook
const isTaskId = conversationId.startsWith("task-");
const actualConversationId = isTaskId ? null : conversationId;
const userConversation = useUserConversation(
actualConversationId,
(query) => {
if (query.state.data?.status === "STARTING") {
return 3000; // 3 seconds
}
// TODO: Return conversation title as a WS event to avoid polling
// This was changed from 5 minutes to 30 seconds to poll for updated conversation title after an auto update
return 30000; // 30 seconds
},
);
useEffect(() => {
const conversation = userConversation.data;
@@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "../use-conversation-id";
import { AgentState } from "#/types/agent-state";
import { useAgentStore } from "#/stores/agent-store";
import { useAgentState } from "#/hooks/use-agent-state";
export const useConversationMicroagents = () => {
const { conversationId } = useConversationId();
const { curAgentState } = useAgentStore();
const { curAgentState } = useAgentState();
return useQuery({
queryKey: ["conversation", conversationId, "microagents"],
+1
View File
@@ -17,6 +17,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
LANGUAGE: apiSettings.language,
CONFIRMATION_MODE: apiSettings.confirmation_mode,
SECURITY_ANALYZER: apiSettings.security_analyzer,
SECURITY_POLICY: apiSettings.security_policy,
LLM_API_KEY_SET: apiSettings.llm_api_key_set,
SEARCH_API_KEY_SET: apiSettings.search_api_key_set,
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
/**
* Hook to fetch in-progress V1 conversation start tasks
*
* Use case: Show tasks that are provisioning sandboxes, cloning repos, etc.
* These are conversations that started but haven't reached READY or ERROR status yet.
*
* Note: Filters out READY and ERROR status tasks client-side since backend doesn't support status filtering.
*
* @param limit Maximum number of tasks to return (max 100)
* @returns Query result with array of in-progress start tasks
*/
export const useStartTasks = (limit = 10) =>
useQuery({
queryKey: ["start-tasks", "search", limit],
queryFn: () => V1ConversationService.searchStartTasks(limit),
enabled: USE_V1_CONVERSATION_API(),
select: (tasks) =>
tasks.filter(
(task) => task.status !== "READY" && task.status !== "ERROR",
),
});
@@ -0,0 +1,78 @@
import { useEffect } from "react";
import { useNavigate } from "react-router";
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
/**
* Hook that polls V1 conversation start tasks and navigates when ready.
*
* This hook:
* - Detects if the conversationId URL param is a task ID (format: "task-{uuid}")
* - Polls the V1 start task API every 3 seconds until status is READY or ERROR
* - Automatically navigates to the conversation URL when the task becomes READY
* - Exposes task status and details for UI components to show loading states and errors
*
* URL patterns:
* - /conversations/task-{uuid} → Polls start task, then navigates to /conversations/{conversation-id}
* - /conversations/{uuid or hex} → No polling (handled by useActiveConversation)
*
* Note: This hook does NOT fetch conversation data. It only handles task polling and navigation.
*/
export const useTaskPolling = () => {
const { conversationId } = useConversationId();
const navigate = useNavigate();
// Check if this is a task ID (format: "task-{uuid}")
const isTask = conversationId.startsWith("task-");
const taskId = isTask ? conversationId.replace("task-", "") : null;
// Poll the task if this is a task ID
const taskQuery = useQuery({
queryKey: ["start-task", taskId],
queryFn: async () => {
if (!taskId) return null;
return V1ConversationService.getStartTask(taskId);
},
enabled: !!taskId,
refetchInterval: (query) => {
const task = query.state.data;
if (!task) return false;
// Stop polling if ready or error
if (task.status === "READY" || task.status === "ERROR") {
return false;
}
// Poll every 3 seconds while task is in progress
return 3000;
},
retry: false,
});
// Navigate to conversation ID when task is ready
useEffect(() => {
const task = taskQuery.data;
if (task?.status === "READY" && task.app_conversation_id) {
// Replace the URL with the actual conversation ID
navigate(`/conversations/${task.app_conversation_id}`, { replace: true });
}
}, [taskQuery.data, navigate]);
return {
isTask,
taskId,
conversationId: isTask ? null : conversationId,
task: taskQuery.data,
taskStatus: taskQuery.data?.status,
taskDetail: taskQuery.data?.detail,
taskError: taskQuery.error,
isLoadingTask: taskQuery.isLoading,
// Repository information from task request
repositoryInfo: {
selectedRepository: taskQuery.data?.request?.selected_repository,
selectedBranch: taskQuery.data?.request?.selected_branch,
gitProvider: taskQuery.data?.request?.git_provider,
},
};
};
@@ -6,6 +6,7 @@ import { Conversation } from "#/api/open-hands.types";
const FIVE_MINUTES = 1000 * 60 * 5;
const FIFTEEN_MINUTES = 1000 * 60 * 15;
type RefetchInterval = (
query: Query<
Conversation | null,
@@ -22,7 +23,11 @@ export const useUserConversation = (
useQuery({
queryKey: ["user", "conversation", cid],
queryFn: async () => {
const conversation = await ConversationService.getConversation(cid!);
if (!cid) return null;
// Use the legacy GET endpoint - it handles both V0 and V1 conversations
// V1 conversations are automatically detected by UUID format and converted
const conversation = await ConversationService.getConversation(cid);
return conversation;
},
enabled: !!cid,
+22 -2
View File
@@ -1,7 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { I18nKey } from "#/i18n/declaration";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
@@ -15,13 +17,31 @@ interface VSCodeUrlResult {
export const useVSCodeUrl = () => {
const { t } = useTranslation();
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
return useQuery<VSCodeUrlResult>({
queryKey: ["vscode_url", conversationId],
queryKey: [
"vscode_url",
conversationId,
isV1Conversation,
conversation?.url,
conversation?.session_api_key,
],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
const data = await ConversationService.getVSCodeUrl(conversationId);
// Use appropriate API based on conversation version
const data = isV1Conversation
? await V1ConversationService.getVSCodeUrl(
conversationId,
conversation?.url,
conversation?.session_api_key,
)
: await ConversationService.getVSCodeUrl(conversationId);
if (data.vscode_url) {
return {
url: transformVSCodeUrl(data.vscode_url),
+56
View File
@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { useAgentStore } from "#/stores/agent-store";
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { AgentState } from "#/types/agent-state";
import { V1AgentStatus } from "#/types/v1/core/base/common";
/**
* Maps V1 agent status to V0 AgentState
*/
function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
if (!status) {
return AgentState.LOADING;
}
switch (status) {
case V1AgentStatus.IDLE:
return AgentState.AWAITING_USER_INPUT;
case V1AgentStatus.RUNNING:
return AgentState.RUNNING;
case V1AgentStatus.PAUSED:
return AgentState.PAUSED;
case V1AgentStatus.WAITING_FOR_CONFIRMATION:
return AgentState.AWAITING_USER_CONFIRMATION;
case V1AgentStatus.FINISHED:
return AgentState.FINISHED;
case V1AgentStatus.ERROR:
return AgentState.ERROR;
case V1AgentStatus.STUCK:
return AgentState.ERROR; // Map STUCK to ERROR for now
default:
return AgentState.LOADING;
}
}
/**
* Unified hook that returns the current agent state
* - For V0 conversations: Returns state from useAgentStore
* - For V1 conversations: Returns mapped state from useV1ConversationStateStore
*/
export function useAgentState() {
const { data: conversation } = useActiveConversation();
const v0State = useAgentStore((state) => state.curAgentState);
const v1Status = useV1ConversationStateStore((state) => state.agent_status);
const isV1Conversation = conversation?.conversation_version === "V1";
const curAgentState = useMemo(() => {
if (isV1Conversation) {
return mapV1StatusToV0State(v1Status);
}
return v0State;
}, [isV1Conversation, v1Status, v0State]);
return { curAgentState };
}
@@ -8,7 +8,7 @@ import { isSystemMessage, isActionOrObservation } from "#/types/core/guards";
import { ConversationStatus } from "#/types/conversation-status";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useDeleteConversation } from "./mutation/use-delete-conversation";
import { useStopConversation } from "./mutation/use-stop-conversation";
import { useUnifiedPauseConversationSandbox } from "./mutation/use-unified-stop-conversation";
import { useGetTrajectory } from "./mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-trajectory";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
@@ -34,7 +34,7 @@ export function useConversationNameContextMenu({
const navigate = useNavigate();
const events = useEventStore((state) => state.events);
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const { mutate: stopConversation } = useUnifiedPauseConversationSandbox();
const { mutate: getTrajectory } = useGetTrajectory();
const metrics = useMetricsStore();

Some files were not shown because too many files have changed in this diff Show More