Compare commits

..

45 Commits

Author SHA1 Message Date
openhands
eb348a5f3d Remove KeyboardInterrupt exit behavior from main chat loop
- Change KeyboardInterrupt handler to continue loop instead of exiting
- Let signal handler manage Ctrl+C behavior completely
- Only exit on explicit /exit command or outer KeyboardInterrupt

This ensures that Ctrl+C during agent processing returns to chat loop
instead of exiting the entire application.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 17:09:16 +00:00
openhands
099dcb787f Fix Ctrl+C behavior to return to chat loop instead of exiting
- Remove os._exit(1) from second Ctrl+C handler
- Reset Ctrl+C counter after force killing process
- Add graceful handling in SimpleProcessRunner for killed processes
- Show user-friendly message that they can continue sending messages

This allows users to stop a running agent and continue with new messages
instead of having to restart the entire CLI application.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 17:07:47 +00:00
openhands
b3034a0d75 Fix multiprocessing serialization issues in SimpleProcessRunner
- Pass conversation_id and message_data instead of full objects to subprocess
- Recreate conversation and message objects in the subprocess
- Extract text content from Message objects for serialization
- Store conversation_id as string for subprocess recreation

This fixes the 'cannot pickle _asyncio.Future object' error by avoiding
passing non-serializable objects between processes.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:59:29 +00:00
openhands
459e224d37 Fix Message creation to include required role field
- Add role='user' to Message constructor in agent_chat.py
- This fixes the validation error when processing user messages

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:57:02 +00:00
openhands
97f13b7100 Fix SimpleProcessRunner to use proper SDK imports
- Replace incorrect openhands.core.main imports with openhands.sdk
- Use existing ConversationRunner from runner.py instead of run_controller
- Update SimpleProcessRunner to accept BaseConversation instead of setup function
- Update agent_chat.py to create conversation first, then pass to SimpleProcessRunner
- Fix process_message to use proper Message object with TextContent

This ensures the openhands-cli remains standalone and only uses the SDK library
as intended, without importing from the main OpenHands codebase.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:51:47 +00:00
openhands
6ecaca5b3c Simplify Ctrl+C handling implementation
- Replace complex ProcessSignalHandler with SimpleSignalHandler
  - Direct signal handling in main process instead of queue communication
  - Simple Ctrl+C counting with immediate force kill on second press
  - Reset functionality to clear count when starting new operations

- Replace ProcessBasedConversationRunner with SimpleProcessRunner
  - Minimal multiprocessing - only process_message runs in subprocess
  - Direct method calls for status, settings, and other operations
  - No unnecessary queue communication

- Update agent_chat.py to use simplified components
  - Reset Ctrl+C count when starting new message processing
  - Direct method calls for commands that don't need process isolation
  - Cleaner error handling and resource cleanup

- Update simple_main.py imports

Fixes issues where second Ctrl+C wouldn't register properly due to
complex queue-based communication and race conditions.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:46:43 +00:00
openhands
5351702d3a Implement improved Ctrl+C handling for OpenHands CLI
- First Ctrl+C attempts graceful pause of agent
- Second Ctrl+C (within 3 seconds) kills process immediately
- Added SignalHandler and ProcessSignalHandler classes for signal management
- Implemented ProcessBasedConversationRunner for separate process execution
- Modified pause_listener to remove Ctrl+C handling (now handled by signal handler)
- Updated agent_chat.py to use process-based runner with new signal management
- Updated simple_main.py to install basic signal handler
- Added comprehensive test script and documentation

Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:24:23 +00:00
Hiep Le
e51685dab4 fix(frontend): there is insufficient padding below the code block. (#11615) 2025-11-03 21:34:01 +07:00
Aphix
b85cc0c716 fix: Autodetect pwsh.exe & DLL path (Win/non-WSL) (#11044) 2025-11-03 08:27:30 -05:00
Hiep Le
7ef1720b5d fix(frontend): correct handling of OBSERVATION_MESSAGE messages for task events (#11613) 2025-11-03 18:57:11 +07:00
Hiep Le
a6385b4059 fix(frontend): agent status shows “Disconnected” when starting a new conversation until sandbox initializes (#11612) 2025-11-03 18:56:52 +07:00
sp.wack
7cfe667a3f fix(frontend): V1 event rendering to display thought + action, then thought + observation (#11596) 2025-11-03 14:07:35 +04:00
Engel Nyst
6e8be827b8 Fix deprecated links (#11605) 2025-11-01 12:37:32 -04:00
Tim O'Farrell
2ccc611e7c Regenerated poetry lock to update dependencies (#11593) 2025-10-31 20:25:01 +00:00
Rohit Malhotra
1f7dec4d94 CLI: patch release 1.0.5 (#11598) 2025-10-31 19:57:39 +00:00
sp.wack
966e4ae990 APP-125: Reset V1 terminal state when switching conversations by forcing remount (#11592)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-31 18:41:19 +00:00
Rohit Malhotra
231019974c CLI: fix binary build (#11591) 2025-10-31 18:01:29 +00:00
Rohit Malhotra
d246ab1a21 Hotfix(CLI): make settings page available even when conversation hasn't been created (#11588)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-31 17:19:53 +00:00
jpelletier1
15c207c401 Disables Copilot icon by default (#11589) 2025-10-31 17:06:15 +00:00
Rohit Malhotra
cf21cfed6c Hotfix(CLI): make sure to update condenser credentials (#11587)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-31 16:37:59 +00:00
Rohit Malhotra
12d57df6ac CLI Patch release 1.0.4 (#11585) 2025-10-31 14:59:39 +00:00
Rohit Malhotra
3239eb4027 Hotfix(CLI): Update README to use V1 CLI for serve command and point to new docker image artifacts (#11584) 2025-10-31 09:34:19 -04:00
Rohit Malhotra
9be673d553 CLI: Create conversation last minute (#11576)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-10-30 23:04:41 +00:00
Tim O'Farrell
7272eae758 Fix remote sandbox permissions (#11582) 2025-10-30 22:13:02 +00:00
mamoodi
ec670cd130 Rename LLM API Key to OpenHands LLM Key in settings (#11577)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-30 16:52:31 -04:00
Hiep Le
31702bf46b fix(frontend): delays in updating conversation titles before they are reflected in the user interface. (#11558)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-10-30 18:06:18 +00:00
Tim O'Farrell
5894d2675e V1 IDs without hyphens (#11564) 2025-10-30 16:33:16 +00:00
Hiep Le
59a992c0fb feat(frontend): allow all users to access the LLM page and disable Pro subscription functionality (#11573) 2025-10-30 22:01:30 +07:00
Rohit Malhotra
1939bd0fda CLI Release 1.0.3 (#11574) 2025-10-30 14:39:42 +00:00
Ray Myers
58e690ef75 Fix flaky test_condenser_metrics_included by creating new action objects (#11555)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-30 09:20:06 -05:00
Rohit Malhotra
97403dfbdb CLI: rename deprecated args (#11568) 2025-10-30 09:20:27 -04:00
sp.wack
2fc31e96d0 chore(frontend): Add V1 git service API with unified hooks for git changes and diffs (#11565) 2025-10-30 13:03:25 +00:00
Rohit Malhotra
6558b4f97d CLI: bump agent-sdk version (#11566)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-30 03:38:36 +00:00
Kevin Musgrave
12d6da8130 feat(evaluation): Filter task ids by difficulty for SWE Gym rollouts (#11490)
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-30 02:30:19 +00:00
mamoodi
38f2728cfa Release 0.60.0 (#11544)
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-10-29 16:17:46 -04:00
sp.wack
fab48fe864 chore(frontend): Remove Jupyter tab and features (#11563) 2025-10-29 17:57:48 +00:00
sp.wack
a196881ab0 chore(frontend): Make terminal read-only by removing user input handlers (#11546) 2025-10-29 21:30:10 +04:00
Rohit Malhotra
ca2c9546ad CLI: add unit test for default agent (#11562)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-29 13:11:06 -04:00
sp.wack
704fc6dd69 chore(frontend): Add history loading state for V1 conversations (#11536) 2025-10-29 16:11:25 +00:00
Hiep Le
6630d5dc4e fix(frontend): display error content when FileEditorAction encounters an error (#11560) 2025-10-29 20:03:25 +04:00
Hiep Le
0e7fefca7e fix(frontend): displaying observation result statuses (#11559) 2025-10-29 20:02:32 +04:00
sp.wack
4020448d64 chore(frontend): Add unified hooks for V1 sandbox URLs (VSCode and served hosts) (#11511)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-10-29 14:52:31 +00:00
Hiep Le
2fdd4d084a feat(frontend): display “waiting for user confirmation” when agent status is “awaiting_user_confirmation” (#11539) 2025-10-29 17:31:05 +04:00
Hiep Le
aba5d54a86 feat(frontend): V1 confirmation's call the right API (#11542) 2025-10-29 17:29:27 +04:00
sp.wack
6710a39621 hotfix(frontend): add unified conversation config hook with V1 support (#11547) 2025-10-29 17:26:37 +04:00
134 changed files with 3469 additions and 1762 deletions

View File

@@ -13,9 +13,9 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
--name openhands-app-${SHORT_SHA} \
docker.all-hands.dev/openhands/openhands:${SHORT_SHA}"
docker.openhands.dev/openhands/openhands:${SHORT_SHA}"
# Define the uvx command
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/OpenHands/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands"

View File

@@ -71,6 +71,14 @@ jobs:
echo "✅ Build & test finished without ❌ markers"
- name: Verify binary files exist
run: |
if ! ls openhands-cli/dist/openhands* 1> /dev/null 2>&1; then
echo "❌ No binaries found to upload!"
exit 1
fi
echo "✅ Found binaries to upload."
- name: Upload binary artifact
uses: actions/upload-artifact@v4
with:

View File

@@ -176,6 +176,7 @@ jobs:
coverage-comment:
name: Coverage Comment
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
needs: [test-on-linux, test-enterprise, test-cli-python]
@@ -195,23 +196,8 @@ jobs:
run: ln -sf openhands-cli/openhands_cli openhands_cli
- name: Coverage comment
# In PR mode leaves a comment, otherwise records coverage in branch python-coverage-comment-action-data.
id: coverage_comment
uses: py-cov-action/python-coverage-comment-action@v3
with:
GITHUB_TOKEN: ${{ github.token }}
MERGE_COVERAGE_FILES: true
- name: Enforce coverage
# Fail if on PR AND there are uncovered lines AND diff coverage is less than total coverage.
# To debug, try a step to log outputs like: `echo ${{ toJSON(steps.coverage_comment.outputs) }}`
# Once we track base branch, reference_percent_covered will be better to use than new_percent_covered.
if: ${{ github.event_name == 'pull_request' && fromJSON(steps.coverage_comment.outputs.diff_total_num_violations) > 0 && steps.coverage_comment.outputs.diff_total_percent_covered < steps.coverage_comment.outputs.new_percent_covered }}
run: |
echo "Coverage decreased, which is not allowed."
echo "Please add some unit tests for the modified code."
echo
echo " diff_total_num_violations: ${{ steps.coverage_comment.outputs.diff_total_num_violations }}"
echo " diff_total_percent_covered: ${{ steps.coverage_comment.outputs.diff_total_percent_covered}}"
echo " new_percent_covered: ${{ steps.coverage_comment.outputs.new_percent_covered}}"
exit 1

View File

@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.59-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.60-nikolaik`
## Develop inside Docker container

View File

@@ -66,10 +66,10 @@ See the [uv installation guide](https://docs.astral.sh/uv/getting-started/instal
**Launch OpenHands**:
```bash
# Launch the GUI server
uvx --python 3.12 --from openhands-ai openhands serve
uvx --python 3.12 openhands serve
# Or launch the CLI
uvx --python 3.12 --from openhands-ai openhands
uvx --python 3.12 openhands
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000) (for GUI mode)!
@@ -82,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik
docker pull docker.openhands.dev/openhands/runtime:0.60-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.60-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.59
docker.openhands.dev/openhands/openhands:0.60
```
</details>

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.59-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.60-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/openhands/runtime:0.59-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.60-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

20
enterprise/poetry.lock generated
View File

@@ -5759,13 +5759,13 @@ wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-ai"
version = "0.59.0"
version = "0.0.0-post.5456+15c207c40"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5805,9 +5805,9 @@ memory-profiler = "^0.61.0"
numpy = "*"
openai = "1.99.9"
openhands-aci = "0.3.2"
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-tools"}
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "3d8af53b2f0259dc98555a4acd4238f90e0afbce", subdirectory = "openhands-tools"}
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5887,8 +5887,8 @@ boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-sdk"
[[package]]
@@ -5914,8 +5914,8 @@ pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
subdirectory = "openhands-tools"
[[package]]

View File

@@ -0,0 +1,79 @@
import argparse
import fnmatch
import json
from collections import Counter
from pathlib import Path
def find_final_reports(base_dir, pattern=None):
base_path = Path(base_dir)
if not base_path.exists():
raise FileNotFoundError(f'Base directory does not exist: {base_dir}')
# Find all final_report.json files
all_reports = list(base_path.rglob('final_report.json'))
if pattern is None:
return all_reports
# Filter by pattern
filtered_reports = []
for report in all_reports:
# Get relative path from base_dir for matching
rel_path = report.relative_to(base_path)
if fnmatch.fnmatch(str(rel_path), pattern):
filtered_reports.append(report)
return filtered_reports
def collect_resolved_ids(report_files):
id_counter = Counter()
for report_file in report_files:
with open(report_file, 'r') as f:
data = json.load(f)
if 'resolved_ids' not in data:
raise KeyError(f"'resolved_ids' key not found in {report_file}")
resolved_ids = data['resolved_ids']
id_counter.update(resolved_ids)
return id_counter
def get_skip_ids(id_counter, threshold):
return [id_str for id_str, count in id_counter.items() if count >= threshold]
def main():
parser = argparse.ArgumentParser(
description='Compute SKIP_IDS from resolved IDs in final_report.json files'
)
parser.add_argument(
'threshold',
type=int,
help='Minimum number of times an ID must be resolved to be skipped',
)
parser.add_argument(
'--base-dir',
default='evaluation/evaluation_outputs/outputs',
help='Base directory to search for final_report.json files (default: evaluation/evaluation_outputs/outputs)',
)
parser.add_argument(
'--pattern',
default=None,
help='Glob pattern to filter paths (e.g., "*Multi-SWE-RL*/**/*gpt*")',
)
args = parser.parse_args()
report_files = find_final_reports(args.base_dir, args.pattern)
id_counter = collect_resolved_ids(report_files)
skip_ids = get_skip_ids(id_counter, args.threshold)
skip_ids = [s.replace('/', '__').replace(':pr-', '-') for s in skip_ids]
skip_ids = ','.join(sorted(skip_ids))
print(skip_ids)
if __name__ == '__main__':
main()

View File

@@ -747,10 +747,14 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
subset = dataset[dataset[filter_column].isin(selected_ids)]
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
return subset
skip_ids = os.environ.get('SKIP_IDS', '').split(',')
skip_ids = [id for id in os.environ.get('SKIP_IDS', '').split(',') if id]
if len(skip_ids) > 0:
logger.info(f'Dataset size before filtering: {dataset.shape[0]} tasks')
logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...')
return dataset[~dataset[filter_column].isin(skip_ids)]
logger.info(f'SKIP_IDS:\n{skip_ids}')
filtered_dataset = dataset[~dataset[filter_column].isin(skip_ids)]
logger.info(f'Dataset size after filtering: {filtered_dataset.shape[0]} tasks')
return filtered_dataset
return dataset
@@ -768,6 +772,11 @@ if __name__ == '__main__':
default='test',
help='split to evaluate on',
)
parser.add_argument(
'--filter_dataset_after_sampling',
action='store_true',
help='if provided, filter dataset after sampling instead of before',
)
args, _ = parser.parse_known_args()
# NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing
@@ -777,10 +786,24 @@ if __name__ == '__main__':
logger.info(f'Loading dataset {args.dataset} with split {args.split} ')
dataset = load_dataset('json', data_files=args.dataset)
dataset = dataset[args.split]
swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
logger.info(
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
)
swe_bench_tests = dataset.to_pandas()
# Determine filter strategy based on flag
filter_func = None
if args.filter_dataset_after_sampling:
# Pass filter as callback to apply after sampling
def filter_func(df):
return filter_dataset(df, 'instance_id')
logger.info(
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks (filtering will occur after sampling)'
)
else:
# Apply filter before sampling
swe_bench_tests = filter_dataset(swe_bench_tests, 'instance_id')
logger.info(
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
)
llm_config = None
if args.llm_config:
@@ -810,7 +833,9 @@ if __name__ == '__main__':
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
print(f'### OUTPUT FILE: {output_file} ###')
instances = prepare_dataset(swe_bench_tests, output_file, args.eval_n_limit)
instances = prepare_dataset(
swe_bench_tests, output_file, args.eval_n_limit, filter_func=filter_func
)
if len(instances) > 0 and not isinstance(
instances['FAIL_TO_PASS'][instances['FAIL_TO_PASS'].index[0]], str

View File

@@ -8,8 +8,14 @@
MODEL=$1 # eg your llm config name in config.toml (eg: "llm.claude-3-5-sonnet-20241022-t05")
EXP_NAME=$2 # "train-t05"
EVAL_DATASET=$3 # path to original dataset (jsonl file)
N_WORKERS=${4:-64}
N_RUNS=${5:-1}
MAX_ITER=$4
N_WORKERS=${5:-64}
N_RUNS=${6:-1}
EVAL_LIMIT=${7:-}
SKIP_IDS_THRESHOLD=$8
SKIP_IDS_PATTERN=$9
INPUT_SKIP_IDS=${10}
FILTER_DATASET_AFTER_SAMPLING=${11:-}
export EXP_NAME=$EXP_NAME
# use 2x resources for rollout since some codebases are pretty resource-intensive
@@ -17,6 +23,7 @@ export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
echo "MODEL: $MODEL"
echo "EXP_NAME: $EXP_NAME"
echo "EVAL_DATASET: $EVAL_DATASET"
echo "INPUT_SKIP_IDS: $INPUT_SKIP_IDS"
# Generate DATASET path by adding _with_runtime_ before .jsonl extension
DATASET="${EVAL_DATASET%.jsonl}_with_runtime_.jsonl" # path to converted dataset
@@ -35,9 +42,6 @@ else
export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
fi
#EVAL_LIMIT=3000
MAX_ITER=100
# ===== Run inference =====
source "evaluation/utils/version_control.sh"
@@ -69,17 +73,52 @@ function run_eval() {
--dataset $DATASET \
--split $SPLIT"
# Conditionally add filter flag
if [ "$FILTER_DATASET_AFTER_SAMPLING" = "true" ]; then
COMMAND="$COMMAND --filter_dataset_after_sampling"
fi
echo "Running command: $COMMAND"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
# Run the command
eval $COMMAND
}
for run_idx in $(seq 1 $N_RUNS); do
if [ -n "$SKIP_IDS_THRESHOLD" ]; then
echo "Computing SKIP_IDS for run $run_idx..."
SKIP_CMD="poetry run python evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py $SKIP_IDS_THRESHOLD"
if [ -n "$SKIP_IDS_PATTERN" ]; then
SKIP_CMD="$SKIP_CMD --pattern \"$SKIP_IDS_PATTERN\""
fi
COMPUTED_SKIP_IDS=$(eval $SKIP_CMD)
SKIP_STATUS=$?
if [ $SKIP_STATUS -ne 0 ]; then
echo "ERROR: Skip IDs computation failed with exit code $SKIP_STATUS"
exit $SKIP_STATUS
fi
echo "COMPUTED_SKIP_IDS: $COMPUTED_SKIP_IDS"
else
echo "SKIP_IDS_THRESHOLD not provided, skipping SKIP_IDS computation"
COMPUTED_SKIP_IDS=""
fi
# Concatenate COMPUTED_SKIP_IDS and INPUT_SKIP_IDS
if [ -n "$COMPUTED_SKIP_IDS" ] && [ -n "$INPUT_SKIP_IDS" ]; then
export SKIP_IDS="${COMPUTED_SKIP_IDS},${INPUT_SKIP_IDS}"
elif [ -n "$COMPUTED_SKIP_IDS" ]; then
export SKIP_IDS="$COMPUTED_SKIP_IDS"
elif [ -n "$INPUT_SKIP_IDS" ]; then
export SKIP_IDS="$INPUT_SKIP_IDS"
else
unset SKIP_IDS
fi
echo "FINAL SKIP_IDS: $SKIP_IDS"
echo ""
while true; do
echo "### Running inference... ###"

View File

@@ -9,7 +9,7 @@ import time
import traceback
from contextlib import contextmanager
from inspect import signature
from typing import Any, Awaitable, Callable, TextIO
from typing import Any, Awaitable, Callable, Optional, TextIO
import pandas as pd
from pydantic import BaseModel
@@ -222,6 +222,7 @@ def prepare_dataset(
eval_n_limit: int,
eval_ids: list[str] | None = None,
skip_num: int | None = None,
filter_func: Optional[Callable[[pd.DataFrame], pd.DataFrame]] = None,
):
assert 'instance_id' in dataset.columns, (
"Expected 'instance_id' column in the dataset. You should define your own unique identifier for each instance and use it as the 'instance_id' column."
@@ -265,6 +266,12 @@ def prepare_dataset(
f'Randomly sampling {eval_n_limit} unique instances with random seed 42.'
)
if filter_func is not None:
dataset = filter_func(dataset)
logger.info(
f'Applied filter after sampling: {len(dataset)} instances remaining'
)
def make_serializable(instance_dict: dict) -> dict:
import numpy as np

View File

@@ -188,172 +188,4 @@ describe("PaymentForm", () => {
expect(mockMutate).not.toHaveBeenCalled();
});
});
describe("Cancel Subscription", () => {
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
const cancelSubscriptionSpy = vi.spyOn(
BillingService,
"cancelSubscription",
);
beforeEach(() => {
// Mock active subscription
getSubscriptionAccessSpy.mockResolvedValue({
start_at: "2024-01-01T00:00:00Z",
end_at: "2024-12-31T23:59:59Z",
created_at: "2024-01-01T00:00:00Z",
});
});
it("should render cancel subscription button when user has active subscription", async () => {
renderPaymentForm();
await waitFor(() => {
const cancelButton = screen.getByTestId("cancel-subscription-button");
expect(cancelButton).toBeInTheDocument();
expect(cancelButton).toHaveTextContent("PAYMENT$CANCEL_SUBSCRIPTION");
});
});
it("should not render cancel subscription button when user has no subscription", async () => {
getSubscriptionAccessSpy.mockResolvedValue(null);
renderPaymentForm();
await waitFor(() => {
const cancelButton = screen.queryByTestId("cancel-subscription-button");
expect(cancelButton).not.toBeInTheDocument();
});
});
it("should show confirmation modal when cancel subscription button is clicked", async () => {
const user = userEvent.setup();
renderPaymentForm();
const cancelButton = await screen.findByTestId(
"cancel-subscription-button",
);
await user.click(cancelButton);
// Should show confirmation modal
expect(
screen.getByTestId("cancel-subscription-modal"),
).toBeInTheDocument();
expect(
screen.getByText("PAYMENT$CANCEL_SUBSCRIPTION_TITLE"),
).toBeInTheDocument();
// The message should be rendered (either with Trans component or regular text)
const modalContent = screen.getByTestId("cancel-subscription-modal");
expect(modalContent).toBeInTheDocument();
expect(screen.getByTestId("confirm-cancel-button")).toBeInTheDocument();
expect(screen.getByTestId("modal-cancel-button")).toBeInTheDocument();
});
it("should close modal when cancel button in modal is clicked", async () => {
const user = userEvent.setup();
renderPaymentForm();
const cancelButton = await screen.findByTestId(
"cancel-subscription-button",
);
await user.click(cancelButton);
// Modal should be visible
expect(
screen.getByTestId("cancel-subscription-modal"),
).toBeInTheDocument();
// Click cancel in modal
const modalCancelButton = screen.getByTestId("modal-cancel-button");
await user.click(modalCancelButton);
// Modal should be closed
expect(
screen.queryByTestId("cancel-subscription-modal"),
).not.toBeInTheDocument();
});
it("should call cancel subscription API when confirm button is clicked", async () => {
const user = userEvent.setup();
renderPaymentForm();
const cancelButton = await screen.findByTestId(
"cancel-subscription-button",
);
await user.click(cancelButton);
// Click confirm in modal
const confirmButton = screen.getByTestId("confirm-cancel-button");
await user.click(confirmButton);
// Should call the cancel subscription API
expect(cancelSubscriptionSpy).toHaveBeenCalled();
});
it("should close modal after successful cancellation", async () => {
const user = userEvent.setup();
cancelSubscriptionSpy.mockResolvedValue({
status: "success",
message: "Subscription cancelled successfully",
});
renderPaymentForm();
const cancelButton = await screen.findByTestId(
"cancel-subscription-button",
);
await user.click(cancelButton);
const confirmButton = screen.getByTestId("confirm-cancel-button");
await user.click(confirmButton);
// Wait for API call to complete and modal to close
await waitFor(() => {
expect(
screen.queryByTestId("cancel-subscription-modal"),
).not.toBeInTheDocument();
});
});
it("should show next billing date for active subscription", async () => {
// Mock active subscription with end_at as next billing date
getSubscriptionAccessSpy.mockResolvedValue({
start_at: "2024-01-01T00:00:00Z",
end_at: "2025-01-01T00:00:00Z",
created_at: "2024-01-01T00:00:00Z",
cancelled_at: null,
stripe_subscription_id: "sub_123",
});
renderPaymentForm();
await waitFor(() => {
const nextBillingInfo = screen.getByTestId("next-billing-date");
expect(nextBillingInfo).toBeInTheDocument();
// Check that it contains some date-related content (translation key or actual date)
expect(nextBillingInfo).toHaveTextContent(
/2025|PAYMENT.*BILLING.*DATE/,
);
});
});
it("should not show next billing date when subscription is cancelled", async () => {
// Mock cancelled subscription
getSubscriptionAccessSpy.mockResolvedValue({
start_at: "2024-01-01T00:00:00Z",
end_at: "2025-01-01T00:00:00Z",
created_at: "2024-01-01T00:00:00Z",
cancelled_at: "2024-06-15T10:30:00Z",
stripe_subscription_id: "sub_123",
});
renderPaymentForm();
await waitFor(() => {
const nextBillingInfo = screen.queryByTestId("next-billing-date");
expect(nextBillingInfo).not.toBeInTheDocument();
});
});
});
});

View File

@@ -1,47 +0,0 @@
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 { useAgentState } from "#/hooks/use-agent-state";
import { useJupyterStore } from "#/state/jupyter-store";
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("JupyterEditor", () => {
beforeEach(() => {
// Reset the Zustand store before each test
useJupyterStore.setState({
cells: Array(20).fill({
content: "Test cell content",
type: "input",
imageUrls: undefined,
}),
});
});
it("should have a scrollable container", () => {
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.RUNNING,
});
render(
<div style={{ height: "100vh" }}>
<JupyterEditor maxWidth={800} />
</div>,
);
const container = screen.getByTestId("jupyter-container");
expect(container).toHaveClass("flex-1 overflow-y-auto");
});
});

View File

@@ -11,6 +11,7 @@ const renderTerminal = (commands: Command[] = []) => {
};
describe.skip("Terminal", () => {
// Terminal is now read-only - no user input functionality
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
disconnect: vi.fn(),
@@ -21,8 +22,6 @@ describe.skip("Terminal", () => {
write: vi.fn(),
writeln: vi.fn(),
dispose: vi.fn(),
onKey: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
loadAddon: vi.fn(),
};

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import { screen, waitFor, render, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
import {
createMockMessageEvent,
@@ -13,8 +14,12 @@ import {
OptimisticUserMessageStoreComponent,
ErrorMessageStoreComponent,
} from "./helpers/websocket-test-components";
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
import {
ConversationWebSocketProvider,
useConversationWebSocket,
} from "#/contexts/conversation-websocket-context";
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
import { useEventStore } from "#/stores/use-event-store";
// MSW WebSocket mock setup
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
@@ -417,7 +422,206 @@ describe("Conversation WebSocket Handler", () => {
it.todo("should handle send attempts when disconnected");
});
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
// 8. History Loading State Tests
describe("History Loading State", () => {
it("should track history loading state using event count from API", async () => {
const conversationId = "test-conversation-with-history";
// Mock the event count API to return 3 events
const expectedEventCount = 3;
// Create 3 mock events to simulate history
const mockHistoryEvents = [
createMockUserMessageEvent({ id: "history-event-1" }),
createMockMessageEvent({ id: "history-event-2" }),
createMockMessageEvent({ id: "history-event-3" }),
];
// Set up MSW to mock both the HTTP API and WebSocket connection
mswServer.use(
http.get("/api/v1/events/count", ({ request }) => {
const url = new URL(request.url);
const conversationIdParam = url.searchParams.get(
"conversation_id__eq",
);
if (conversationIdParam === conversationId) {
return HttpResponse.json(expectedEventCount);
}
return HttpResponse.json(0);
}),
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send all history events
mockHistoryEvents.forEach((event) => {
client.send(JSON.stringify(event));
});
}),
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
const context = useConversationWebSocket();
const { events } = useEventStore();
return (
<div>
<div data-testid="is-loading-history">
{context?.isLoadingHistory ? "true" : "false"}
</div>
<div data-testid="events-received">{events.length}</div>
<div data-testid="expected-event-count">{expectedEventCount}</div>
</div>
);
};
// Render with WebSocket context
renderWithWebSocketContext(
<HistoryLoadingComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Initially should be loading history
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
// Wait for all events to be received
await waitFor(() => {
expect(screen.getByTestId("events-received")).toHaveTextContent("3");
});
// Once all events are received, loading should be complete
await waitFor(() => {
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
"false",
);
});
});
it("should handle empty conversation history", async () => {
const conversationId = "test-conversation-empty";
// Set up MSW to mock both the HTTP API and WebSocket connection
mswServer.use(
http.get("/api/v1/events/count", ({ request }) => {
const url = new URL(request.url);
const conversationIdParam = url.searchParams.get(
"conversation_id__eq",
);
if (conversationIdParam === conversationId) {
return HttpResponse.json(0);
}
return HttpResponse.json(0);
}),
wsLink.addEventListener("connection", ({ server }) => {
server.connect();
// No events sent for empty history
}),
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
const context = useConversationWebSocket();
return (
<div>
<div data-testid="is-loading-history">
{context?.isLoadingHistory ? "true" : "false"}
</div>
</div>
);
};
// Render with WebSocket context
renderWithWebSocketContext(
<HistoryLoadingComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Should quickly transition from loading to not loading when count is 0
await waitFor(() => {
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
"false",
);
});
});
it("should handle history loading with large event count", async () => {
const conversationId = "test-conversation-large-history";
// Create 50 mock events to simulate large history
const expectedEventCount = 50;
const mockHistoryEvents = Array.from({ length: 50 }, (_, i) =>
createMockMessageEvent({ id: `history-event-${i + 1}` }),
);
// Set up MSW to mock both the HTTP API and WebSocket connection
mswServer.use(
http.get("/api/v1/events/count", ({ request }) => {
const url = new URL(request.url);
const conversationIdParam = url.searchParams.get(
"conversation_id__eq",
);
if (conversationIdParam === conversationId) {
return HttpResponse.json(expectedEventCount);
}
return HttpResponse.json(0);
}),
wsLink.addEventListener("connection", ({ client, server }) => {
server.connect();
// Send all history events
mockHistoryEvents.forEach((event) => {
client.send(JSON.stringify(event));
});
}),
);
// Create a test component that displays loading state
const HistoryLoadingComponent = () => {
const context = useConversationWebSocket();
const { events } = useEventStore();
return (
<div>
<div data-testid="is-loading-history">
{context?.isLoadingHistory ? "true" : "false"}
</div>
<div data-testid="events-received">{events.length}</div>
</div>
);
};
// Render with WebSocket context
renderWithWebSocketContext(
<HistoryLoadingComponent />,
conversationId,
`http://localhost:3000/api/conversations/${conversationId}`,
);
// Initially should be loading history
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
// Wait for all events to be received
await waitFor(() => {
expect(screen.getByTestId("events-received")).toHaveTextContent("50");
});
// Once all events are received, loading should be complete
await waitFor(() => {
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
"false",
);
});
});
});
// 9. 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(

View File

@@ -38,8 +38,7 @@ export const createWebSocketTestSetup = (
/**
* Standard WebSocket test setup for conversation WebSocket handler tests
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
* Uses a wildcard pattern to match any conversation ID
*/
export const conversationWebSocketTestSetup = () =>
createWebSocketTestSetup(
"ws://localhost:3000/sockets/events/test-conversation-default",
);
createWebSocketTestSetup("ws://localhost:3000/sockets/events/*");

View File

@@ -35,13 +35,12 @@ function TestTerminalComponent() {
}
describe("useTerminal", () => {
// Terminal is read-only - no longer tests user input functionality
const mockTerminal = vi.hoisted(() => ({
loadAddon: vi.fn(),
open: vi.fn(),
write: vi.fn(),
writeln: vi.fn(),
onKey: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
dispose: vi.fn(),
}));

View File

@@ -4,14 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import LlmSettingsScreen from "#/routes/llm-settings";
import SettingsService from "#/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import BillingService from "#/api/billing-service/billing-service.api";
// Mock react-router hooks
const mockUseSearchParams = vi.fn();
@@ -25,12 +23,6 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => mockUseIsAuthed(),
}));
// Mock useIsAllHandsSaaSEnvironment hook
const mockUseIsAllHandsSaaSEnvironment = vi.fn();
vi.mock("#/hooks/use-is-all-hands-saas-environment", () => ({
useIsAllHandsSaaSEnvironment: () => mockUseIsAllHandsSaaSEnvironment(),
}));
const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
@@ -54,9 +46,6 @@ beforeEach(() => {
// Default mock for useIsAuthed - returns authenticated by default
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
// Default mock for useIsAllHandsSaaSEnvironment - returns true for SaaS environment
mockUseIsAllHandsSaaSEnvironment.mockReturnValue(true);
});
describe("Content", () => {
@@ -605,9 +594,14 @@ describe("Form submission", () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Component automatically shows advanced view when advanced settings exist
// Switch to basic view to test clearing advanced settings
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
// Now we should be in basic view
await screen.findByTestId("llm-settings-form-basic");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
@@ -731,405 +725,3 @@ describe("Status toasts", () => {
});
});
});
describe("SaaS mode", () => {
describe("SaaS subscription", () => {
// Common mock configurations
const MOCK_SAAS_CONFIG = {
APP_MODE: "saas" as const,
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
};
const MOCK_ACTIVE_SUBSCRIPTION = {
start_at: "2024-01-01",
end_at: "2024-12-31",
created_at: "2024-01-01",
};
it("should show upgrade banner and prevent all interactions for unsubscribed SaaS users", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
// Mock saveSettings to ensure it's not called
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Should show upgrade banner
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
// Should have a clickable upgrade button
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
expect(upgradeButton).toBeInTheDocument();
expect(upgradeButton).not.toBeDisabled();
// Form should be disabled
const form = screen.getByTestId("llm-settings-form-basic");
expect(form).toHaveAttribute("aria-disabled", "true");
// All form inputs should be disabled or non-interactive
const providerInput = screen.getByTestId("llm-provider-input");
const modelInput = screen.getByTestId("llm-model-input");
const apiKeyInput = screen.getByTestId("llm-api-key-input");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const submitButton = screen.getByTestId("submit-button");
// Inputs should be disabled
expect(providerInput).toBeDisabled();
expect(modelInput).toBeDisabled();
expect(apiKeyInput).toBeDisabled();
expect(advancedSwitch).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");
// Values should not change
expect(apiKeyInput).toHaveValue("");
// Try to submit form - should not call API
await userEvent.click(submitButton);
expect(saveSettingsSpy).not.toHaveBeenCalled();
});
it("should call subscription checkout API when upgrade button is clicked", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
// Mock the subscription checkout API call
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
BillingService,
"createSubscriptionCheckoutSession",
);
createSubscriptionCheckoutSessionSpy.mockResolvedValue({});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Click the upgrade button
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
await userEvent.click(upgradeButton);
// Should call the subscription checkout API
expect(createSubscriptionCheckoutSessionSpy).toHaveBeenCalled();
});
it("should disable upgrade button for unauthenticated users in SaaS mode", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
// Mock subscription checkout API
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
BillingService,
"createSubscriptionCheckoutSession",
);
// Mock authentication to return false (unauthenticated) from the start
mockUseIsAuthed.mockReturnValue({ data: false, isLoading: false });
// Mock settings to return default settings even when unauthenticated
// This is necessary because the useSettings hook is disabled when user is not authenticated
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderLlmSettingsScreen();
// Wait for either the settings screen or skeleton to appear
await waitFor(() => {
const settingsScreen = screen.queryByTestId("llm-settings-screen");
const skeleton = screen.queryByTestId("app-settings-skeleton");
expect(settingsScreen || skeleton).toBeInTheDocument();
});
// If we get the skeleton, the test scenario isn't valid - skip the rest
if (screen.queryByTestId("app-settings-skeleton")) {
// For unauthenticated users, the settings don't load, so no upgrade banner is shown
// This is the expected behavior - unauthenticated users see a skeleton loading state
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
return;
}
await screen.findByTestId("llm-settings-screen");
// Should show upgrade banner
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
// Upgrade button should be disabled for unauthenticated users
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
expect(upgradeButton).toBeInTheDocument();
expect(upgradeButton).toBeDisabled();
// Clicking disabled button should not call the API
await userEvent.click(upgradeButton);
expect(createSubscriptionCheckoutSessionSpy).not.toHaveBeenCalled();
});
it("should not show upgrade banner and allow form interaction for subscribed SaaS users", async () => {
// Mock SaaS mode with subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return active subscription
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Wait for subscription data to load
await waitFor(() => {
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
});
// Should NOT show upgrade banner
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
// Form should NOT be disabled
const form = screen.getByTestId("llm-settings-form-basic");
expect(form).not.toHaveAttribute("aria-disabled", "true");
});
it("should not call save settings API when making changes in disabled form for unsubscribed users", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
// Mock saveSettings to track calls
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Verify that basic form elements are disabled for unsubscribed users
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const submitButton = screen.getByTestId("submit-button");
expect(advancedSwitch).toBeDisabled();
expect(submitButton).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();
// Try to submit the form - button should remain disabled
await userEvent.click(submitButton);
// Should NOT call save settings API for unsubscribed users
expect(saveSettingsSpy).not.toHaveBeenCalled();
});
it("should show backdrop overlay for unsubscribed users", async () => {
// Mock SaaS mode without subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (no subscription)
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Wait for subscription data to load
await waitFor(() => {
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
});
// Should show upgrade banner
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
// Should show backdrop overlay
const backdrop = screen.getByTestId("settings-backdrop");
expect(backdrop).toBeInTheDocument();
});
it("should not show backdrop overlay for subscribed users", async () => {
// Mock SaaS mode with subscription
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return active subscription
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Wait for subscription data to load
await waitFor(() => {
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
});
// Should NOT show backdrop overlay
expect(screen.queryByTestId("settings-backdrop")).not.toBeInTheDocument();
});
it("should display success toast when redirected back with ?checkout=success parameter", async () => {
// Mock SaaS mode
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
// Mock toast handler
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
// Mock URL search params with ?checkout=success
mockUseSearchParams.mockReturnValue([
{
get: (param: string) => (param === "checkout" ? "success" : null),
},
vi.fn(),
]);
// Render component with checkout=success parameter
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Verify success toast is displayed with correct message
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
"SUBSCRIPTION$SUCCESS",
);
});
it("should display error toast when redirected back with ?checkout=cancel parameter", async () => {
// Mock SaaS mode
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
// Mock toast handler
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
// Mock URL search params with ?checkout=cancel
mockUseSearchParams.mockReturnValue([
{
get: (param: string) => (param === "checkout" ? "cancel" : null),
},
vi.fn(),
]);
// Render component with checkout=cancel parameter
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Verify error toast is displayed with correct message
expect(displayErrorToastSpy).toHaveBeenCalledWith("SUBSCRIPTION$FAILURE");
});
it("should show upgrade banner when subscription is expired or disabled", async () => {
// Mock SaaS mode
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
// Mock subscription access to return null (expired/disabled subscriptions return null from backend)
// The backend only returns active subscriptions within their validity period
const getSubscriptionAccessSpy = vi.spyOn(
BillingService,
"getSubscriptionAccess",
);
getSubscriptionAccessSpy.mockResolvedValue(null);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
// Wait for subscription data to load
await waitFor(() => {
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
});
// Should show upgrade banner for expired/disabled subscriptions (when API returns null)
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
// Form should be disabled
const form = screen.getByTestId("llm-settings-form-basic");
expect(form).toHaveAttribute("aria-disabled", "true");
// All form inputs should be disabled
const providerInput = screen.getByTestId("llm-provider-input");
const modelInput = screen.getByTestId("llm-model-input");
const apiKeyInput = screen.getByTestId("llm-api-key-input");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(providerInput).toBeDisabled();
expect(modelInput).toBeDisabled();
expect(apiKeyInput).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();
});
});
});

View File

@@ -5,7 +5,6 @@ import { ActionMessage } from "#/types/message";
// Mock the store and actions
const mockDispatch = vi.fn();
const mockAppendInput = vi.fn();
const mockAppendJupyterInput = vi.fn();
vi.mock("#/store", () => ({
default: {
@@ -21,14 +20,6 @@ vi.mock("#/state/command-store", () => ({
},
}));
vi.mock("#/state/jupyter-store", () => ({
useJupyterStore: {
getState: () => ({
appendJupyterInput: mockAppendJupyterInput,
}),
},
}));
vi.mock("#/state/metrics-slice", () => ({
setMetrics: vi.fn(),
}));
@@ -63,10 +54,9 @@ describe("handleActionMessage", () => {
// Check that appendInput was called with the command
expect(mockAppendInput).toHaveBeenCalledWith("ls -la");
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
});
it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => {
it("should handle RUN_IPYTHON actions as no-op (Jupyter removed)", async () => {
const { handleActionMessage } = await import("#/services/actions");
const ipythonAction: ActionMessage = {
@@ -84,10 +74,7 @@ describe("handleActionMessage", () => {
// Handle the action
handleActionMessage(ipythonAction);
// Check that appendJupyterInput was called with the code
expect(mockAppendJupyterInput).toHaveBeenCalledWith(
"print('Hello from Jupyter!')",
);
// Jupyter functionality has been removed, so nothing should be called
expect(mockAppendInput).not.toHaveBeenCalled();
});
@@ -112,6 +99,5 @@ describe("handleActionMessage", () => {
// Check that nothing was dispatched or called
expect(mockDispatch).not.toHaveBeenCalled();
expect(mockAppendInput).not.toHaveBeenCalled();
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.59.0",
"version": "0.60.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.59.0",
"version": "0.60.0",
"dependencies": {
"@heroui/react": "^2.8.4",
"@heroui/use-infinite-scroll": "^2.2.11",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.59.0",
"version": "0.60.0",
"private": true,
"type": "module",
"engines": {

View File

@@ -187,7 +187,7 @@ class ConversationService {
static async getRuntimeId(
conversationId: string,
): Promise<{ runtime_id: string }> {
const url = `/api/conversations/${conversationId}/config`;
const url = `${this.getConversationUrl(conversationId)}/config`;
const { data } = await openHands.get<{ runtime_id: string }>(url, {
headers: this.getConversationHeaders(),
});

View File

@@ -3,6 +3,7 @@ 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 { buildSessionHeaders } from "#/utils/utils";
import type {
V1SendMessageRequest,
V1SendMessageResponse,
@@ -10,24 +11,10 @@ import type {
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
V1SandboxInfo,
} 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/...")
@@ -160,7 +147,7 @@ class V1ConversationService {
sessionApiKey?: string | null,
): Promise<GetVSCodeUrlResponse> {
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
const headers = this.buildSessionHeaders(sessionApiKey);
const headers = buildSessionHeaders(sessionApiKey);
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
// Map it to match the expected interface
@@ -188,7 +175,7 @@ class V1ConversationService {
conversationUrl,
`/api/conversations/${conversationId}/pause`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
const headers = buildSessionHeaders(sessionApiKey);
const { data } = await axios.post<{ success: boolean }>(
url,
@@ -216,7 +203,7 @@ class V1ConversationService {
conversationUrl,
`/api/conversations/${conversationId}/run`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
const headers = buildSessionHeaders(sessionApiKey);
const { data } = await axios.post<{ success: boolean }>(
url,
@@ -282,6 +269,32 @@ class V1ConversationService {
return data;
}
/**
* Batch get V1 sandboxes by their IDs
* Returns null for any missing sandboxes
*
* @param ids Array of sandbox IDs (max 100)
* @returns Array of sandboxes or null for missing ones
*/
static async batchGetSandboxes(
ids: string[],
): Promise<(V1SandboxInfo | null)[]> {
if (ids.length === 0) {
return [];
}
if (ids.length > 100) {
throw new Error("Cannot request more than 100 sandboxes at once");
}
const params = new URLSearchParams();
ids.forEach((id) => params.append("id", id));
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
`/api/v1/sandboxes?${params.toString()}`,
);
return data;
}
/**
* Upload a single file to the V1 conversation workspace
* V1 API endpoint: POST /api/file/upload/{path}
@@ -305,7 +318,7 @@ class V1ConversationService {
conversationUrl,
`/api/file/upload/${encodedPath}`,
);
const headers = this.buildSessionHeaders(sessionApiKey);
const headers = buildSessionHeaders(sessionApiKey);
// Create FormData with the file
const formData = new FormData();
@@ -319,6 +332,37 @@ class V1ConversationService {
},
});
}
/**
* Get the conversation config (runtime_id) for a V1 conversation
* @param conversationId The conversation ID
* @returns Object containing runtime_id
*/
static async getConversationConfig(
conversationId: string,
): Promise<{ runtime_id: string }> {
const url = `/api/conversations/${conversationId}/config`;
const { data } = await openHands.get<{ runtime_id: string }>(url);
return data;
}
/**
* Get the count of events for a conversation
* Uses the V1 API endpoint: GET /api/v1/events/count
*
* @param conversationId The conversation ID to get event count for
* @returns The number of events in the conversation
*/
static async getEventCount(conversationId: string): Promise<number> {
const params = new URLSearchParams();
params.append("conversation_id__eq", conversationId);
const { data } = await openHands.get<number>(
`/api/v1/events/count?${params.toString()}`,
);
return data;
}
}
export default V1ConversationService;

View File

@@ -98,3 +98,18 @@ export interface V1AppConversation {
conversation_url: string | null;
session_api_key: string | null;
}
export interface V1ExposedUrl {
name: string;
url: string;
}
export interface V1SandboxInfo {
id: string;
created_by_user_id: string | null;
sandbox_spec_id: string;
status: V1SandboxStatus;
session_api_key: string | null;
exposed_urls: V1ExposedUrl[] | null;
created_at: string;
}

View File

@@ -0,0 +1,41 @@
import axios from "axios";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import { buildSessionHeaders } from "#/utils/utils";
import type {
ConfirmationResponseRequest,
ConfirmationResponseResponse,
} from "./event-service.types";
class EventService {
/**
* Respond to a confirmation request in a V1 conversation
* @param conversationId The conversation ID
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param request The confirmation response request
* @param sessionApiKey Session API key for authentication (required for V1)
* @returns The confirmation response
*/
static async respondToConfirmation(
conversationId: string,
conversationUrl: string,
request: ConfirmationResponseRequest,
sessionApiKey?: string | null,
): Promise<ConfirmationResponseResponse> {
// Build the runtime URL using the conversation URL
const runtimeUrl = buildHttpBaseUrl(conversationUrl);
// Build session headers for authentication
const headers = buildSessionHeaders(sessionApiKey);
// Make the API call to the runtime endpoint
const { data } = await axios.post<ConfirmationResponseResponse>(
`${runtimeUrl}/api/conversations/${conversationId}/events/respond_to_confirmation`,
request,
{ headers },
);
return data;
}
}
export default EventService;

View File

@@ -0,0 +1,8 @@
export interface ConfirmationResponseRequest {
accept: boolean;
reason?: string;
}
export interface ConfirmationResponseResponse {
success: boolean;
}

View File

@@ -0,0 +1,89 @@
import axios from "axios";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import { buildSessionHeaders } from "#/utils/utils";
import { mapV1ToV0Status } from "#/utils/git-status-mapper";
import type {
GitChange,
GitChangeDiff,
V1GitChangeStatus,
} from "../open-hands.types";
interface V1GitChange {
status: V1GitChangeStatus;
path: string;
}
class V1GitService {
/**
* 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/git/changes")
* @returns Full URL to the runtime endpoint
*/
private static buildRuntimeUrl(
conversationUrl: string | null | undefined,
path: string,
): string {
const baseUrl = buildHttpBaseUrl(conversationUrl);
return `${baseUrl}${path}`;
}
/**
* Get git changes for a V1 conversation
* Uses the agent server endpoint: GET /api/git/changes/{path}
* Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.)
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @param path The git repository path (e.g., /workspace/project or /workspace/project/OpenHands)
* @returns List of git changes with V0-compatible status types
*/
static async getGitChanges(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChange[]> {
const encodedPath = encodeURIComponent(path);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/git/changes/${encodedPath}`,
);
const headers = buildSessionHeaders(sessionApiKey);
// V1 API returns V1GitChangeStatus types, we need to map them to V0 format
const { data } = await axios.get<V1GitChange[]>(url, { headers });
// Map V1 statuses to V0 format for compatibility
return data.map((change) => ({
status: mapV1ToV0Status(change.status),
path: change.path,
}));
}
/**
* Get git change diff for a specific file in a V1 conversation
* Uses the agent server endpoint: GET /api/git/diff/{path}
*
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
* @param sessionApiKey Session API key for authentication (required for V1)
* @param path The file path to get diff for
* @returns Git change diff
*/
static async getGitChangeDiff(
conversationUrl: string | null | undefined,
sessionApiKey: string | null | undefined,
path: string,
): Promise<GitChangeDiff> {
const encodedPath = encodeURIComponent(path);
const url = this.buildRuntimeUrl(
conversationUrl,
`/api/git/diff/${encodedPath}`,
);
const headers = buildSessionHeaders(sessionApiKey);
const { data } = await axios.get<GitChangeDiff>(url, { headers });
return data;
}
}
export default V1GitService;

View File

@@ -84,8 +84,13 @@ export interface ResultSet<T> {
next_page_id: string | null;
}
/**
* @deprecated Use V1GitChangeStatus for new code. This type is maintained for backward compatibility with V0 API.
*/
export type GitChangeStatus = "M" | "A" | "D" | "R" | "U";
export type V1GitChangeStatus = "MOVED" | "ADDED" | "DELETED" | "UPDATED";
export interface GitChange {
status: GitChangeStatus;
path: string;

View File

@@ -48,6 +48,7 @@ import {
} from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
function getEntryPoint(
hasRepository: boolean | null,
@@ -64,8 +65,10 @@ export function ChatInterface() {
const { errorMessage } = useErrorMessageStore();
const { isLoadingMessages } = useWsClient();
const { isTask } = useTaskPolling();
const conversationWebSocket = useConversationWebSocket();
const { send } = useSendMessage();
const storeEvents = useEventStore((state) => state.events);
const uiEvents = useEventStore((state) => state.uiEvents);
const { setOptimisticUserMessage, getOptimisticUserMessage } =
useOptimisticUserMessageStore();
const { t } = useTranslation();
@@ -94,17 +97,38 @@ export function ChatInterface() {
const isV1Conversation = conversation?.conversation_version === "V1";
// Instantly scroll to bottom when history loading completes
const prevLoadingHistoryRef = React.useRef(
conversationWebSocket?.isLoadingHistory,
);
React.useEffect(() => {
const wasLoading = prevLoadingHistoryRef.current;
const isLoading = conversationWebSocket?.isLoadingHistory;
// When history loading transitions from true to false, instantly scroll to bottom
if (wasLoading && !isLoading && scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "instant",
});
}
prevLoadingHistoryRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory, scrollRef]);
// Filter V0 events
const v0Events = storeEvents
.filter(isV0Event)
.filter(isActionOrObservation)
.filter(shouldRenderEvent);
// Filter V1 events
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
const v1UiEvents = uiEvents.filter(isV1Event).filter(shouldRenderV1Event);
// Keep full v1 events for lookups (includes both actions and observations)
const v1FullEvents = storeEvents.filter(isV1Event);
// Combined events count for tracking
const totalEvents = v0Events.length || v1Events.length;
const totalEvents = v0Events.length || v1UiEvents.length;
// Check if there are any substantive agent actions (not just system messages)
const hasSubstantiveAgentActions = React.useMemo(
@@ -202,7 +226,7 @@ export function ChatInterface() {
};
const v0UserEventsExist = hasUserEvent(v0Events);
const v1UserEventsExist = hasV1UserEvent(v1Events);
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
return (
@@ -228,6 +252,14 @@ export function ChatInterface() {
</div>
)}
{conversationWebSocket?.isLoadingHistory &&
isV1Conversation &&
!isTask && (
<div className="flex justify-center">
<LoadingSpinner size="small" />
</div>
)}
{!isLoadingMessages && v0UserEventsExist && (
<V0Messages
messages={v0Events}
@@ -237,13 +269,8 @@ export function ChatInterface() {
/>
)}
{v1UserEventsExist && (
<V1Messages
messages={v1Events}
isAwaitingUserConfirmation={
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
/>
{!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
<V1Messages messages={v1UiEvents} allEvents={v1FullEvents} />
)}
</div>

View File

@@ -8,23 +8,23 @@ import { TabContentArea } from "./tab-content-area";
import { ConversationTabTitle } from "../conversation-tab-title";
import Terminal from "#/components/features/terminal/terminal";
import { useConversationStore } from "#/state/conversation-store";
import { useConversationId } from "#/hooks/use-conversation-id";
// Lazy load all tab components
const EditorTab = lazy(() => import("#/routes/changes-tab"));
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
const ServedTab = lazy(() => import("#/routes/served-tab"));
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
export function ConversationTabContent() {
const { selectedTab, shouldShownAgentLoading } = useConversationStore();
const { conversationId } = useConversationId();
const { t } = useTranslation();
// Determine which tab is active based on the current path
const isEditorActive = selectedTab === "editor";
const isBrowserActive = selectedTab === "browser";
const isJupyterActive = selectedTab === "jupyter";
const isServedActive = selectedTab === "served";
const isVSCodeActive = selectedTab === "vscode";
const isTerminalActive = selectedTab === "terminal";
@@ -37,11 +37,6 @@ export function ConversationTabContent() {
component: BrowserTab,
isActive: isBrowserActive,
},
{
key: "jupyter",
component: JupyterTab,
isActive: isJupyterActive,
},
{ key: "served", component: ServedTab, isActive: isServedActive },
{ key: "vscode", component: VSCodeTab, isActive: isVSCodeActive },
{
@@ -58,9 +53,6 @@ export function ConversationTabContent() {
if (isBrowserActive) {
return t(I18nKey.COMMON$BROWSER);
}
if (isJupyterActive) {
return t(I18nKey.COMMON$JUPYTER);
}
if (isServedActive) {
return t(I18nKey.COMMON$APP);
}
@@ -74,7 +66,6 @@ export function ConversationTabContent() {
}, [
isEditorActive,
isBrowserActive,
isJupyterActive,
isServedActive,
isVSCodeActive,
isTerminalActive,
@@ -89,7 +80,11 @@ export function ConversationTabContent() {
<ConversationTabTitle title={conversationTabTitle} />
<TabContentArea>
{tabs.map(({ key, component: Component, isActive }) => (
<TabWrapper key={key} isActive={isActive}>
<TabWrapper
// Force Terminal tab remount to reset XTerm buffer/state when conversationId changes
key={key === "terminal" ? `${key}-${conversationId}` : key}
isActive={isActive}
>
<Component />
</TabWrapper>
))}

View File

@@ -1,7 +1,6 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "@uidotdev/usehooks";
import JupyterIcon from "#/icons/jupyter.svg?react";
import TerminalIcon from "#/icons/terminal.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
import ServerIcon from "#/icons/server.svg?react";
@@ -108,13 +107,6 @@ export function ConversationTabs() {
tooltipContent: t(I18nKey.COMMON$TERMINAL),
tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL),
},
{
isActive: isTabActive("jupyter"),
icon: JupyterIcon,
onClick: () => onTabSelected("jupyter"),
tooltipContent: t(I18nKey.COMMON$JUPYTER),
tooltipAriaLabel: t(I18nKey.COMMON$JUPYTER),
},
{
isActive: isTabActive("served"),
icon: ServerIcon,

View File

@@ -7,7 +7,7 @@ import { GitChangeStatus } from "#/api/open-hands.types";
import { getLanguageFromPath } from "#/utils/get-language-from-path";
import { cn } from "#/utils/utils";
import ChevronUp from "#/icons/chveron-up.svg?react";
import { useGitDiff } from "#/hooks/query/use-get-diff";
import { useUnifiedGitDiff } from "#/hooks/query/use-unified-git-diff";
interface LoadingSpinnerProps {
className?: string;
@@ -64,7 +64,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
isLoading,
isSuccess,
isRefetching,
} = useGitDiff({
} = useUnifiedGitDiff({
filePath,
type,
enabled: !isCollapsed,

View File

@@ -1,22 +0,0 @@
import SyntaxHighlighter from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
interface JupytrerCellInputProps {
code: string;
}
export function JupytrerCellInput({ code }: JupytrerCellInputProps) {
return (
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
<div className="mb-1 text-gray-400">EXECUTE</div>
<pre
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
>
<SyntaxHighlighter language="python" style={atomOneDark} wrapLongLines>
{code}
</SyntaxHighlighter>
</pre>
</div>
);
}

View File

@@ -1,55 +0,0 @@
import Markdown from "react-markdown";
import SyntaxHighlighter from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { JupyterLine } from "#/utils/parse-cell-content";
import { paragraph } from "../markdown/paragraph";
interface JupyterCellOutputProps {
lines: JupyterLine[];
}
export function JupyterCellOutput({ lines }: JupyterCellOutputProps) {
const { t } = useTranslation();
return (
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
<div className="mb-1 text-gray-400">
{t(I18nKey.JUPYTER$OUTPUT_LABEL)}
</div>
<pre
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5 max-h-[60vh] bg-gray-800"
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
>
{/* display the lines as plaintext or image */}
{lines.map((line, index) => {
if (line.type === "image") {
// Use markdown to display the image
const imageMarkdown = line.url
? `![image](${line.url})`
: line.content;
return (
<div key={index}>
<Markdown
components={{
p: paragraph,
}}
urlTransform={(value: string) => value}
>
{imageMarkdown}
</Markdown>
</div>
);
}
return (
<div key={index}>
<SyntaxHighlighter language="plaintext" style={atomOneDark}>
{line.content}
</SyntaxHighlighter>
</div>
);
})}
</pre>
</div>
);
}

View File

@@ -1,23 +0,0 @@
import React from "react";
import { Cell } from "#/state/jupyter-store";
import { JupyterLine, parseCellContent } from "#/utils/parse-cell-content";
import { JupytrerCellInput } from "./jupyter-cell-input";
import { JupyterCellOutput } from "./jupyter-cell-output";
interface JupyterCellProps {
cell: Cell;
}
export function JupyterCell({ cell }: JupyterCellProps) {
const [lines, setLines] = React.useState<JupyterLine[]>([]);
React.useEffect(() => {
setLines(parseCellContent(cell.content, cell.imageUrls));
}, [cell.content, cell.imageUrls]);
if (cell.type === "input") {
return <JupytrerCellInput code={cell.content} />;
}
return <JupyterCellOutput lines={lines} />;
}

View File

@@ -1,63 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { JupyterCell } from "./jupyter-cell";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
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 { useAgentState } from "#/hooks/use-agent-state";
import { useJupyterStore } from "#/state/jupyter-store";
interface JupyterEditorProps {
maxWidth: number;
}
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
const { curAgentState } = useAgentState();
const cells = useJupyterStore((state) => state.cells);
const jupyterRef = React.useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const { hitBottom, scrollDomToBottom, onChatBodyScroll } =
useScrollToBottom(jupyterRef);
return (
<>
{isRuntimeInactive && <WaitingForRuntimeMessage />}
{!isRuntimeInactive && cells.length > 0 && (
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
<div
data-testid="jupyter-container"
className="flex-1 overflow-y-auto fast-smooth-scroll custom-scrollbar-always rounded-xl"
ref={jupyterRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
>
{cells.map((cell, index) => (
<JupyterCell key={index} cell={cell} />
))}
</div>
{!hitBottom && (
<div className="sticky bottom-2 flex items-center justify-center">
<ScrollToBottomButton onClick={scrollDomToBottom} />
</div>
)}
</div>
)}
{!isRuntimeInactive && cells.length === 0 && (
<div className="flex flex-col items-center justify-center w-full h-full p-10 gap-4">
<JupyterLargeIcon width={113} height={113} color="#A1A1A1" />
<span className="text-[#8D95A9] text-[19px] font-normal leading-5">
{t(I18nKey.COMMON$JUPYTER_EMPTY_MESSAGE)}
</span>
</div>
)}
</>
);
}

View File

@@ -7,5 +7,5 @@ export function paragraph({
}: React.ClassAttributes<HTMLParagraphElement> &
React.HTMLAttributes<HTMLParagraphElement> &
ExtraProps) {
return <p className="pb-[10px] last:pb-0">{children}</p>;
return <p className="py-2.5 first:pt-0 last:pb-0">{children}</p>;
}

View File

@@ -1,8 +1,7 @@
import React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
import { useBalance } from "#/hooks/query/use-balance";
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
import { cn } from "#/utils/utils";
import MoneyIcon from "#/icons/money.svg?react";
import { SettingsInput } from "../settings/settings-input";
@@ -11,24 +10,13 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { amountIsValid } from "#/utils/amount-is-valid";
import { I18nKey } from "#/i18n/declaration";
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
import { CancelSubscriptionModal } from "./cancel-subscription-modal";
export function PaymentForm() {
const { t } = useTranslation();
const { data: balance, isLoading } = useBalance();
const { data: subscriptionAccess } = useSubscriptionAccess();
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
const [showCancelModal, setShowCancelModal] = React.useState(false);
const subscriptionExpiredDate =
subscriptionAccess?.end_at &&
new Date(subscriptionAccess.end_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const billingFormAction = async (formData: FormData) => {
const amount = formData.get("top-up-input")?.toString();
@@ -94,50 +82,7 @@ export function PaymentForm() {
{isPending && <LoadingSpinner size="small" />}
<PoweredByStripeTag />
</div>
{/* Cancel Subscription Button or Cancellation Message */}
{subscriptionAccess && (
<div className="flex flex-col w-[680px] gap-2 mt-4">
{subscriptionAccess.cancelled_at ? (
<div className="text-red-500 text-sm">
<Trans
i18nKey={I18nKey.PAYMENT$SUBSCRIPTION_CANCELLED_EXPIRES}
values={{ date: subscriptionExpiredDate }}
components={{ date: <span className="underline" /> }}
/>
</div>
) : (
<div className="flex items-center gap-4">
<BrandButton
testId="cancel-subscription-button"
variant="ghost-danger"
type="button"
onClick={() => setShowCancelModal(true)}
>
{t(I18nKey.PAYMENT$CANCEL_SUBSCRIPTION)}
</BrandButton>
<div
className="text-sm text-gray-300"
data-testid="next-billing-date"
>
<Trans
i18nKey={I18nKey.PAYMENT$NEXT_BILLING_DATE}
values={{ date: subscriptionExpiredDate }}
components={{ date: <span className="underline" /> }}
/>
</div>
</div>
)}
</div>
)}
</div>
{/* Cancel Subscription Modal */}
<CancelSubscriptionModal
isOpen={showCancelModal}
onClose={() => setShowCancelModal(false)}
endDate={subscriptionExpiredDate}
/>
</form>
);
}

View File

@@ -11,13 +11,11 @@ interface NavigationItem {
interface SettingsLayoutProps {
children: React.ReactNode;
navigationItems: NavigationItem[];
isSaas: boolean;
}
export function SettingsLayout({
children,
navigationItems,
isSaas,
}: SettingsLayoutProps) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -44,7 +42,6 @@ export function SettingsLayout({
isMobileMenuOpen={isMobileMenuOpen}
onCloseMobileMenu={closeMobileMenu}
navigationItems={navigationItems}
isSaas={isSaas}
/>
{/* Main content */}

View File

@@ -5,7 +5,6 @@ import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import SettingsIcon from "#/icons/settings-gear.svg?react";
import CloseIcon from "#/icons/close.svg?react";
import { ProPill } from "./pro-pill";
interface NavigationItem {
to: string;
@@ -17,14 +16,12 @@ interface SettingsNavigationProps {
isMobileMenuOpen: boolean;
onCloseMobileMenu: () => void;
navigationItems: NavigationItem[];
isSaas: boolean;
}
export function SettingsNavigation({
isMobileMenuOpen,
onCloseMobileMenu,
navigationItems,
isSaas,
}: SettingsNavigationProps) {
const { t } = useTranslation();
@@ -85,7 +82,6 @@ export function SettingsNavigation({
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
{t(text as I18nKey)}
</Typography.Text>
{isSaas && to === "/settings" && <ProPill />}
</div>
</NavLink>
))}

View File

@@ -0,0 +1,141 @@
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { AgentState } from "#/types/agent-state";
import { ActionTooltip } from "../action-tooltip";
import { RiskAlert } from "#/components/shared/risk-alert";
import WarningIcon from "#/icons/u-warning.svg?react";
import { useEventMessageStore } from "#/stores/event-message-store";
import { useEventStore } from "#/stores/use-event-store";
import { isV1Event, isActionEvent } from "#/types/v1/type-guards";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useAgentState } from "#/hooks/use-agent-state";
import { useRespondToConfirmation } from "#/hooks/mutation/use-respond-to-confirmation";
import { SecurityRisk } from "#/types/v1/core/base/common";
export function V1ConfirmationButtons() {
const v1SubmittedEventIds = useEventMessageStore(
(state) => state.v1SubmittedEventIds,
);
const addV1SubmittedEventId = useEventMessageStore(
(state) => state.addV1SubmittedEventId,
);
const { t } = useTranslation();
const { data: conversation } = useActiveConversation();
const { curAgentState } = useAgentState();
const { mutate: respondToConfirmation } = useRespondToConfirmation();
const events = useEventStore((state) => state.events);
// Find the most recent V1 action awaiting confirmation
const awaitingAction = events
.filter(isV1Event)
.slice()
.reverse()
.find((ev) => {
if (ev.source !== "agent") return false;
// For V1, we check if the agent state is waiting for confirmation
return curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
});
const handleConfirmation = useCallback(
(accept: boolean) => {
if (!awaitingAction || !conversation) {
return;
}
// Mark event as submitted to prevent duplicate submissions
addV1SubmittedEventId(awaitingAction.id);
// Call the V1 API endpoint
respondToConfirmation({
conversationId: conversation.conversation_id,
conversationUrl: conversation.url || "",
sessionApiKey: conversation.session_api_key,
accept,
});
},
[
awaitingAction,
conversation,
addV1SubmittedEventId,
respondToConfirmation,
],
);
// Handle keyboard shortcuts
useEffect(() => {
if (!awaitingAction) {
return undefined;
}
const handleCancelShortcut = (event: KeyboardEvent) => {
if (event.shiftKey && event.metaKey && event.key === "Backspace") {
event.preventDefault();
handleConfirmation(false);
}
};
const handleContinueShortcut = (event: KeyboardEvent) => {
if (event.metaKey && event.key === "Enter") {
event.preventDefault();
handleConfirmation(true);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
// Cancel: Shift+Cmd+Backspace (⇧⌘⌫)
handleCancelShortcut(event);
// Continue: Cmd+Enter (⌘↩)
handleContinueShortcut(event);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [awaitingAction, handleConfirmation]);
// Only show if agent is waiting for confirmation and we haven't already submitted
if (
curAgentState !== AgentState.AWAITING_USER_CONFIRMATION ||
!awaitingAction ||
v1SubmittedEventIds.includes(awaitingAction.id)
) {
return null;
}
// Get security risk from the action (only ActionEvent has security_risk)
const risk = isActionEvent(awaitingAction)
? awaitingAction.security_risk
: SecurityRisk.UNKNOWN;
const isHighRisk = risk === SecurityRisk.HIGH;
return (
<div className="flex flex-col gap-2 pt-4">
{isHighRisk && (
<RiskAlert
content={t(I18nKey.CHAT_INTERFACE$HIGH_RISK_WARNING)}
icon={<WarningIcon width={16} height={16} color="#fff" />}
severity="high"
title={t(I18nKey.COMMON$HIGH_RISK)}
/>
)}
<div className="flex justify-between items-center">
<p className="text-sm font-normal text-white">
{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}
</p>
<div className="flex items-center gap-3">
<ActionTooltip
type="reject"
onClick={() => handleConfirmation(false)}
/>
<ActionTooltip
type="confirm"
onClick={() => handleConfirmation(true)}
/>
</div>
</div>
</div>
);
}

View File

@@ -134,9 +134,16 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
case "BrowserObservation":
observationKey = "OBSERVATION_MESSAGE$BROWSE";
break;
case "TaskTrackerObservation":
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
case "TaskTrackerObservation": {
const { command } = event.observation;
if (command === "plan") {
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN";
} else {
// command === "view"
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW";
}
break;
}
default:
// For unknown observations, use the type name
return observationType.replace("Observation", "").toUpperCase();

View File

@@ -19,6 +19,10 @@ const getFileEditorObservationContent = (
): string => {
const { observation } = event;
if (observation.error) {
return `**Error:**\n${observation.error}`;
}
const successMessage = getObservationResult(event) === "success";
// For view commands or successful edits with content changes, format as code block

View File

@@ -11,9 +11,10 @@ export const getObservationResult = (
switch (observationType) {
case "ExecuteBashObservation": {
const exitCode = observation.exit_code;
const { metadata } = observation;
if (exitCode === -1) return "timeout"; // Command timed out
if (exitCode === 0) return "success"; // Command executed successfully
if (exitCode === -1 || metadata.exit_code === -1) return "timeout"; // Command timed out
if (exitCode === 0 || metadata.exit_code === 0) return "success"; // Command executed successfully
return "error"; // Command failed
}
case "FileEditorObservation":

View File

@@ -7,17 +7,6 @@ import {
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)) {
@@ -34,18 +23,12 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
return false;
}
return !NO_RENDER_ACTION_TYPES.includes(actionType);
return true;
}
// Render observation events (with filtering)
// Render observation events
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);
return true;
}
// Render message events (user and assistant messages)

View File

@@ -1,19 +1,18 @@
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";
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
interface GenericEventMessageWrapperProps {
event: OpenHandsEvent;
shouldShowConfirmationButtons: boolean;
isLastMessage: boolean;
}
export function GenericEventMessageWrapper({
event,
shouldShowConfirmationButtons,
isLastMessage,
}: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event);
@@ -27,7 +26,7 @@ export function GenericEventMessageWrapper({
}
initiallyExpanded={false}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
{isLastMessage && <V1ConfirmationButtons />}
</div>
);
}

View File

@@ -3,3 +3,4 @@ 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";
export { ThoughtEventMessage } from "./thought-event-message";

View File

@@ -0,0 +1,32 @@
import React from "react";
import { ActionEvent } from "#/types/v1/core";
import { ChatMessage } from "../../../features/chat/chat-message";
interface ThoughtEventMessageProps {
event: ActionEvent;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
export function ThoughtEventMessage({
event,
actions,
}: ThoughtEventMessageProps) {
// Extract thought content from the action event
const thoughtContent = event.thought
.filter((t) => t.type === "text")
.map((t) => t.text)
.join("\n");
// If there's no thought content, don't render anything
if (!thoughtContent) {
return null;
}
return (
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
);
}

View File

@@ -4,7 +4,7 @@ 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 { V1ConfirmationButtons } from "#/components/shared/buttons/v1-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";
@@ -13,7 +13,6 @@ import { MicroagentStatus } from "#/types/microagent-status";
interface UserAssistantEventMessageProps {
event: MessageEvent;
shouldShowConfirmationButtons: boolean;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
@@ -22,15 +21,16 @@ interface UserAssistantEventMessageProps {
onClick: () => void;
tooltip?: string;
}>;
isLastMessage: boolean;
}
export function UserAssistantEventMessage({
event,
shouldShowConfirmationButtons,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isLastMessage,
}: UserAssistantEventMessageProps) {
const message = parseMessageFromEvent(event);
@@ -51,7 +51,7 @@ export function UserAssistantEventMessage({
<ImageCarousel size="small" images={imageUrls} />
)}
{/* TODO: Handle file_urls if V1 messages support them */}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
{isLastMessage && <V1ConfirmationButtons />}
</ChatMessage>
<MicroagentStatusWrapper
microagentStatus={microagentStatus}

View File

@@ -14,14 +14,13 @@ import {
ErrorEventMessage,
UserAssistantEventMessage,
FinishEventMessage,
ObservationPairEventMessage,
GenericEventMessageWrapper,
ThoughtEventMessage,
} from "./event-message-components";
interface EventMessageProps {
event: OpenHandsEvent;
hasObservationPair: boolean;
isAwaitingUserConfirmation: boolean;
messages: OpenHandsEvent[];
isLastMessage: boolean;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
@@ -37,8 +36,7 @@ interface EventMessageProps {
/* eslint-disable react/jsx-props-no-spreading */
export function EventMessage({
event,
hasObservationPair,
isAwaitingUserConfirmation,
messages,
isLastMessage,
microagentStatus,
microagentConversationId,
@@ -46,9 +44,6 @@ export function EventMessage({
actions,
isInLast10Actions,
}: EventMessageProps) {
const shouldShowConfirmationButtons =
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
const { data: config } = useConfig();
// V1 events use string IDs, but useFeedbackExists expects number
@@ -74,19 +69,6 @@ export function EventMessage({
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 (
@@ -97,23 +79,53 @@ export function EventMessage({
);
}
// Action events - render thought + action (will be replaced by thought + observation)
if (isActionEvent(event)) {
return (
<>
<ThoughtEventMessage event={event} actions={actions} />
<GenericEventMessageWrapper
event={event}
isLastMessage={isLastMessage}
/>
</>
);
}
// Observation events - find the corresponding action and render thought + observation
if (isObservationEvent(event)) {
// Find the action that this observation is responding to
const correspondingAction = messages.find(
(msg) => isActionEvent(msg) && msg.id === event.action_id,
);
return (
<>
{correspondingAction && isActionEvent(correspondingAction) && (
<ThoughtEventMessage event={correspondingAction} actions={actions} />
)}
<GenericEventMessageWrapper
event={event}
isLastMessage={isLastMessage}
/>
</>
);
}
// Message events (user and assistant messages)
if (!isActionEvent(event) && !isObservationEvent(event)) {
// This is a MessageEvent
return (
<UserAssistantEventMessage
event={event as MessageEvent}
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
{...commonProps}
isLastMessage={isLastMessage}
/>
);
}
// Generic fallback for all other events (including observation events)
// Generic fallback for all other events
return (
<GenericEventMessageWrapper
event={event}
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
/>
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
);
}

View File

@@ -1,6 +1,5 @@
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";
@@ -9,30 +8,16 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
// import MemoryIcon from "#/icons/memory_icon.svg?react";
interface MessagesProps {
messages: OpenHandsEvent[];
isAwaitingUserConfirmation: boolean;
messages: OpenHandsEvent[]; // UI events (actions replaced by observations)
allEvents: OpenHandsEvent[]; // Full event history (for action lookup)
}
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
({ messages, allEvents }) => {
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
@@ -42,8 +27,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
<EventMessage
key={message.id}
event={message}
hasObservationPair={actionHasObservationPair(message)}
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
messages={allEvents}
isLastMessage={messages.length - 1 === index}
isInLast10Actions={messages.length - 1 - index < 10}
// Microagent props - not implemented yet for V1

View File

@@ -5,6 +5,7 @@ import React, {
useState,
useCallback,
useMemo,
useRef,
} from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
@@ -27,6 +28,7 @@ import {
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
import { buildWebSocketUrl } from "#/utils/websocket-url";
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
// eslint-disable-next-line @typescript-eslint/naming-convention
export type V1_WebSocketConnectionState =
@@ -38,6 +40,7 @@ export type V1_WebSocketConnectionState =
interface ConversationWebSocketContextType {
connectionState: V1_WebSocketConnectionState;
sendMessage: (message: V1SendMessageRequest) => Promise<void>;
isLoadingHistory: boolean;
}
const ConversationWebSocketContext = createContext<
@@ -67,6 +70,13 @@ export function ConversationWebSocketProvider({
const { setAgentStatus } = useV1ConversationStateStore();
const { appendInput, appendOutput } = useCommandStore();
// History loading state
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [expectedEventCount, setExpectedEventCount] = useState<number | null>(
null,
);
const receivedEventCountRef = useRef(0);
// Build WebSocket URL from props
// Only build URL if we have both conversationId and conversationUrl
// This prevents connection attempts during task polling phase
@@ -78,16 +88,43 @@ export function ConversationWebSocketProvider({
return buildWebSocketUrl(conversationId, conversationUrl);
}, [conversationId, conversationUrl]);
// Reset hasConnected flag when conversation changes
// Reset hasConnected flag and history loading state when conversation changes
useEffect(() => {
hasConnectedRef.current = false;
setIsLoadingHistory(true);
setExpectedEventCount(null);
receivedEventCountRef.current = 0;
}, [conversationId]);
// Check if we've received all events when expectedEventCount becomes available
useEffect(() => {
if (
expectedEventCount !== null &&
receivedEventCountRef.current >= expectedEventCount &&
isLoadingHistory
) {
setIsLoadingHistory(false);
}
}, [expectedEventCount, isLoadingHistory]);
const handleMessage = useCallback(
(messageEvent: MessageEvent) => {
try {
const event = JSON.parse(messageEvent.data);
// Track received events for history loading (count ALL events from WebSocket)
// Always count when loading, even if we don't have the expected count yet
if (isLoadingHistory) {
receivedEventCountRef.current += 1;
if (
expectedEventCount !== null &&
receivedEventCountRef.current >= expectedEventCount
) {
setIsLoadingHistory(false);
}
}
// Use type guard to validate v1 event structure
if (isV1Event(event)) {
addEvent(event);
@@ -141,6 +178,8 @@ export function ConversationWebSocketProvider({
},
[
addEvent,
isLoadingHistory,
expectedEventCount,
setErrorMessage,
removeOptimisticUserMessage,
queryClient,
@@ -164,10 +203,27 @@ export function ConversationWebSocketProvider({
return {
queryParams,
reconnect: { enabled: true },
onOpen: () => {
onOpen: async () => {
setConnectionState("OPEN");
hasConnectedRef.current = true; // Mark that we've successfully connected
removeErrorMessage(); // Clear any previous error messages on successful connection
// Fetch expected event count for history loading detection
if (conversationId) {
try {
const count =
await V1ConversationService.getEventCount(conversationId);
setExpectedEventCount(count);
// If no events expected, mark as loaded immediately
if (count === 0) {
setIsLoadingHistory(false);
}
} catch (error) {
// Fall back to marking as loaded to avoid infinite loading state
setIsLoadingHistory(false);
}
}
},
onClose: (event: CloseEvent) => {
setConnectionState("CLOSED");
@@ -188,7 +244,13 @@ export function ConversationWebSocketProvider({
},
onMessage: handleMessage,
};
}, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
}, [
handleMessage,
setErrorMessage,
removeErrorMessage,
sessionApiKey,
conversationId,
]);
// Only attempt WebSocket connection when we have a valid URL
// This prevents connection attempts during task polling phase
@@ -246,8 +308,8 @@ export function ConversationWebSocketProvider({
}, [socket, wsUrl]);
const contextValue = useMemo(
() => ({ connectionState, sendMessage }),
[connectionState, sendMessage],
() => ({ connectionState, sendMessage, isLoadingHistory }),
[connectionState, sendMessage, isLoadingHistory],
);
return (

View File

@@ -0,0 +1,32 @@
import { useMutation } from "@tanstack/react-query";
import EventService from "#/api/event-service/event-service.api";
import type { ConfirmationResponseRequest } from "#/api/event-service/event-service.types";
interface UseRespondToConfirmationVariables {
conversationId: string;
conversationUrl: string;
sessionApiKey?: string | null;
accept: boolean;
}
export const useRespondToConfirmation = () =>
useMutation({
mutationKey: ["respond-to-confirmation"],
mutationFn: async ({
conversationId,
conversationUrl,
sessionApiKey,
accept,
}: UseRespondToConfirmationVariables) => {
const request: ConfirmationResponseRequest = {
accept,
};
return EventService.respondToConfirmation(
conversationId,
conversationUrl,
request,
sessionApiKey,
);
},
});

View File

@@ -26,6 +26,13 @@ export const useUpdateConversation = () => {
),
);
// Also optimistically update the active conversation query
queryClient.setQueryData(
["user", "conversation", variables.conversationId],
(old: { title: string } | undefined) =>
old ? { ...old, title: variables.newTitle } : old,
);
return { previousConversations };
},
onError: (err, variables, context) => {

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
export const useBatchAppConversations = (ids: string[]) =>
useQuery({
queryKey: ["v1-batch-get-app-conversations", ids],
queryFn: () => V1ConversationService.batchGetAppConversations(ids),
enabled: ids.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
export const useBatchSandboxes = (ids: string[]) =>
useQuery({
queryKey: ["sandboxes", "batch", ids],
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
enabled: ids.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -2,14 +2,20 @@ import { useQuery } from "@tanstack/react-query";
import React from "react";
import { useConversationId } from "#/hooks/use-conversation-id";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useRuntimeIsReady } from "../use-runtime-is-ready";
import { useActiveConversation } from "./use-active-conversation";
export const useConversationConfig = () => {
/**
* @deprecated This hook is for V0 conversations only. Use useUnifiedConversationConfig instead,
* or useV1ConversationConfig once we fully migrate to V1.
*/
export const useV0ConversationConfig = () => {
const { conversationId } = useConversationId();
const runtimeIsReady = useRuntimeIsReady();
const query = useQuery({
queryKey: ["conversation_config", conversationId],
queryKey: ["v0_conversation_config", conversationId],
queryFn: () => {
if (!conversationId) throw new Error("No conversation ID");
return ConversationService.getRuntimeId(conversationId);
@@ -34,3 +40,80 @@ export const useConversationConfig = () => {
return query;
};
export const useV1ConversationConfig = () => {
const { conversationId } = useConversationId();
const runtimeIsReady = useRuntimeIsReady();
const query = useQuery({
queryKey: ["v1_conversation_config", conversationId],
queryFn: () => {
if (!conversationId) throw new Error("No conversation ID");
return V1ConversationService.getConversationConfig(conversationId);
},
enabled: runtimeIsReady && !!conversationId,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
React.useEffect(() => {
if (query.data) {
const { runtime_id: runtimeId } = query.data;
// eslint-disable-next-line no-console
console.log(
"Runtime ID: %c%s",
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
runtimeId,
);
}
}, [query.data]);
return query;
};
/**
* Unified hook that switches between V0 and V1 conversation config endpoints based on conversation version.
*
* @temporary This hook is temporary during the V0 to V1 migration period.
* Once we fully migrate to V1, all code should use useV1ConversationConfig directly.
*/
export const useUnifiedConversationConfig = () => {
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
const query = useQuery({
queryKey: ["conversation_config", conversationId, isV1Conversation],
queryFn: () => {
if (!conversationId) throw new Error("No conversation ID");
if (isV1Conversation) {
return V1ConversationService.getConversationConfig(conversationId);
}
return ConversationService.getRuntimeId(conversationId);
},
enabled: runtimeIsReady && !!conversationId && conversation !== undefined,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
React.useEffect(() => {
if (query.data) {
const { runtime_id: runtimeId } = query.data;
// eslint-disable-next-line no-console
console.log(
"Runtime ID: %c%s",
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
runtimeId,
);
}
}, [query.data]);
return query;
};
// Keep the old export name for backward compatibility (uses unified approach)
export const useConversationConfig = useUnifiedConversationConfig;

View File

@@ -0,0 +1,99 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import axios from "axios";
import React from "react";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useBatchSandboxes } from "./use-batch-sandboxes";
import { useConversationConfig } from "./use-conversation-config";
/**
* Unified hook to get active web host for both legacy (V0) and V1 conversations
* - V0: Uses the legacy getWebHosts API endpoint and polls them
* - V1: Gets worker URLs from sandbox exposed_urls (WORKER_1, WORKER_2, etc.)
*/
export const useUnifiedActiveHost = () => {
const [activeHost, setActiveHost] = React.useState<string | null>(null);
const { conversationId } = useConversationId();
const runtimeIsReady = useRuntimeIsReady();
const { data: conversation } = useActiveConversation();
const { data: conversationConfig, isLoading: isLoadingConfig } =
useConversationConfig();
const isV1Conversation = conversation?.conversation_version === "V1";
const sandboxId = conversationConfig?.runtime_id;
// Fetch sandbox data for V1 conversations
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
// Get worker URLs from V1 sandbox or legacy web hosts from V0
const { data, isLoading: hostsQueryLoading } = useQuery({
queryKey: [conversationId, "unified", "hosts", isV1Conversation, sandboxId],
queryFn: async () => {
// V1: Get worker URLs from sandbox exposed_urls
if (isV1Conversation) {
if (
!sandboxesQuery.data ||
sandboxesQuery.data.length === 0 ||
!sandboxesQuery.data[0]
) {
return { hosts: [] };
}
const sandbox = sandboxesQuery.data[0];
const workerUrls =
sandbox.exposed_urls
?.filter((url) => url.name.startsWith("WORKER_"))
.map((url) => url.url) || [];
return { hosts: workerUrls };
}
// V0 (Legacy): Use the legacy API endpoint
const hosts = await ConversationService.getWebHosts(conversationId);
return { hosts };
},
enabled:
runtimeIsReady &&
!!conversationId &&
(!isV1Conversation || !!sandboxesQuery.data),
initialData: { hosts: [] },
meta: {
disableToast: true,
},
});
// Poll all hosts to find which one is active
const apps = useQueries({
queries: data.hosts.map((host) => ({
queryKey: [conversationId, "unified", "hosts", host],
queryFn: async () => {
try {
await axios.get(host);
return host;
} catch (e) {
return "";
}
},
refetchInterval: 3000,
meta: {
disableToast: true,
},
})),
});
const appsData = apps.map((app) => app.data);
React.useEffect(() => {
const successfulApp = appsData.find((app) => app);
setActiveHost(successfulApp || "");
}, [appsData]);
// Calculate overall loading state including dependent queries for V1
const isLoading = isV1Conversation
? isLoadingConfig || sandboxesQuery.isLoading || hostsQueryLoading
: hostsQueryLoading;
return { activeHost, isLoading };
};

View File

@@ -0,0 +1,107 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import GitService from "#/api/git-service/git-service.api";
import V1GitService from "#/api/git-service/v1-git-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { getGitPath } from "#/utils/get-git-path";
import type { GitChange } from "#/api/open-hands.types";
/**
* Unified hook to get git changes for both legacy (V0) and V1 conversations
* - V0: Uses the legacy GitService.getGitChanges API endpoint
* - V1: Uses the V1GitService.getGitChanges API endpoint with runtime URL
*/
export const useUnifiedGetGitChanges = () => {
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const [orderedChanges, setOrderedChanges] = React.useState<GitChange[]>([]);
const previousDataRef = React.useRef<GitChange[] | null>(null);
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
const conversationUrl = conversation?.url;
const sessionApiKey = conversation?.session_api_key;
const selectedRepository = conversation?.selected_repository;
// Calculate git path based on selected repository
const gitPath = React.useMemo(
() => getGitPath(selectedRepository),
[selectedRepository],
);
const result = useQuery({
queryKey: [
"file_changes",
conversationId,
isV1Conversation,
conversationUrl,
gitPath,
],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
// V1: Use the V1 API endpoint with runtime URL
if (isV1Conversation) {
return V1GitService.getGitChanges(
conversationUrl,
sessionApiKey,
gitPath,
);
}
// V0 (Legacy): Use the legacy API endpoint
return GitService.getGitChanges(conversationId);
},
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: runtimeIsReady && !!conversationId,
meta: {
disableToast: true,
},
});
// Latest changes should be on top
React.useEffect(() => {
if (!result.isFetching && result.isSuccess && result.data) {
const currentData = result.data;
// If this is new data (not the same reference as before)
if (currentData !== previousDataRef.current) {
previousDataRef.current = currentData;
// Figure out new items by comparing with what we already have
if (Array.isArray(currentData)) {
const currentIds = new Set(currentData.map((item) => item.path));
const existingIds = new Set(orderedChanges.map((item) => item.path));
// Filter out items that already exist in orderedChanges
const newItems = currentData.filter(
(item) => !existingIds.has(item.path),
);
// Filter out items that no longer exist in the API response
const existingItems = orderedChanges.filter((item) =>
currentIds.has(item.path),
);
// Add new items to the beginning
setOrderedChanges([...newItems, ...existingItems]);
} else {
// If not an array, just use the data directly
setOrderedChanges([currentData]);
}
}
}
}, [result.isFetching, result.isSuccess, result.data]);
return {
data: orderedChanges,
isLoading: result.isLoading,
isSuccess: result.isSuccess,
isError: result.isError,
error: result.error,
};
};

View File

@@ -0,0 +1,67 @@
import React from "react";
import { useQuery } from "@tanstack/react-query";
import GitService from "#/api/git-service/git-service.api";
import V1GitService from "#/api/git-service/v1-git-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { getGitPath } from "#/utils/get-git-path";
import type { GitChangeStatus } from "#/api/open-hands.types";
type UseUnifiedGitDiffConfig = {
filePath: string;
type: GitChangeStatus;
enabled: boolean;
};
/**
* Unified hook to get git diff for both legacy (V0) and V1 conversations
* - V0: Uses the legacy GitService.getGitChangeDiff API endpoint
* - V1: Uses the V1GitService.getGitChangeDiff API endpoint with runtime URL
*/
export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const isV1Conversation = conversation?.conversation_version === "V1";
const conversationUrl = conversation?.url;
const sessionApiKey = conversation?.session_api_key;
const selectedRepository = conversation?.selected_repository;
// For V1, we need to convert the relative file path to an absolute path
// The diff endpoint expects: /workspace/project/RepoName/relative/path
const absoluteFilePath = React.useMemo(() => {
if (!isV1Conversation) return config.filePath;
const gitPath = getGitPath(selectedRepository);
return `${gitPath}/${config.filePath}`;
}, [isV1Conversation, selectedRepository, config.filePath]);
return useQuery({
queryKey: [
"file_diff",
conversationId,
config.filePath,
config.type,
isV1Conversation,
conversationUrl,
],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
// V1: Use the V1 API endpoint with runtime URL and absolute path
if (isV1Conversation) {
return V1GitService.getGitChangeDiff(
conversationUrl,
sessionApiKey,
absoluteFilePath,
);
}
// V0 (Legacy): Use the legacy API endpoint with relative path
return GitService.getGitChangeDiff(conversationId, config.filePath);
},
enabled: config.enabled,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -0,0 +1,122 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import ConversationService from "#/api/conversation-service/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";
import { useBatchAppConversations } from "./use-batch-app-conversations";
import { useBatchSandboxes } from "./use-batch-sandboxes";
interface VSCodeUrlResult {
url: string | null;
error: string | null;
}
/**
* Unified hook to get VSCode URL for both legacy (V0) and V1 conversations
* - V0: Uses the legacy getVSCodeUrl API endpoint
* - V1: Gets the VSCode URL from sandbox exposed_urls
*/
export const useUnifiedVSCodeUrl = () => {
const { t } = useTranslation();
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
// Fetch V1 app conversation to get sandbox_id
const appConversationsQuery = useBatchAppConversations(
isV1Conversation && conversationId ? [conversationId] : [],
);
const appConversation = appConversationsQuery.data?.[0];
const sandboxId = appConversation?.sandbox_id;
// Fetch sandbox data for V1 conversations
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
const mainQuery = useQuery<VSCodeUrlResult>({
queryKey: [
"unified",
"vscode_url",
conversationId,
isV1Conversation,
sandboxId,
],
queryFn: async () => {
if (!conversationId) throw new Error("No conversation ID");
// V1: Get VSCode URL from sandbox exposed_urls
if (isV1Conversation) {
if (
!sandboxesQuery.data ||
sandboxesQuery.data.length === 0 ||
!sandboxesQuery.data[0]
) {
return {
url: null,
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
};
}
const sandbox = sandboxesQuery.data[0];
const vscodeUrl = sandbox.exposed_urls?.find(
(url) => url.name === "VSCODE",
);
if (!vscodeUrl) {
return {
url: null,
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
};
}
return {
url: transformVSCodeUrl(vscodeUrl.url),
error: null,
};
}
// V0 (Legacy): Use the legacy API endpoint
const data = await ConversationService.getVSCodeUrl(conversationId);
if (data.vscode_url) {
return {
url: transformVSCodeUrl(data.vscode_url),
error: null,
};
}
return {
url: null,
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
};
},
enabled:
runtimeIsReady &&
!!conversationId &&
(!isV1Conversation || !!sandboxesQuery.data),
refetchOnMount: true,
retry: 3,
});
// Calculate overall loading state including dependent queries for V1
const isLoading = isV1Conversation
? appConversationsQuery.isLoading ||
sandboxesQuery.isLoading ||
mainQuery.isLoading
: mainQuery.isLoading;
// Explicitly destructure to avoid excessive re-renders from spreading the entire query object
return {
data: mainQuery.data,
error: mainQuery.error,
isLoading,
isError: mainQuery.isError,
isSuccess: mainQuery.isSuccess,
status: mainQuery.status,
refetch: mainQuery.refetch,
};
};

View File

@@ -2,11 +2,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import React from "react";
import { Command, useCommandStore } from "#/state/command-store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { getTerminalCommand } from "#/services/terminal-service";
import { parseTerminalOutput } from "#/utils/parse-terminal-output";
import { useSendMessage } from "#/hooks/use-send-message";
import { useAgentState } from "#/hooks/use-agent-state";
/*
NOTE: Tests for this hook are indirectly covered by the tests for the XTermTerminal component.
@@ -38,15 +34,11 @@ const renderCommand = (
const persistentLastCommandIndex = { current: 0 };
export const useTerminal = () => {
const { send } = useSendMessage();
const { curAgentState } = useAgentState();
const commands = useCommandStore((state) => state.commands);
const terminal = React.useRef<Terminal | null>(null);
const fitAddon = React.useRef<FitAddon | null>(null);
const ref = React.useRef<HTMLDivElement>(null);
const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference
const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null);
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const createTerminal = () =>
new Terminal({
@@ -57,6 +49,7 @@ export const useTerminal = () => {
fastScrollModifier: "alt",
fastScrollSensitivity: 5,
allowTransparency: true,
disableStdin: true, // Make terminal read-only
theme: {
background: "transparent",
},
@@ -65,55 +58,12 @@ export const useTerminal = () => {
const initializeTerminal = () => {
if (terminal.current) {
if (fitAddon.current) terminal.current.loadAddon(fitAddon.current);
if (ref.current) terminal.current.open(ref.current);
}
};
const copySelection = (selection: string) => {
const clipboardItem = new ClipboardItem({
"text/plain": new Blob([selection], { type: "text/plain" }),
});
navigator.clipboard.write([clipboardItem]);
};
const pasteSelection = (callback: (text: string) => void) => {
navigator.clipboard.readText().then(callback);
};
const pasteHandler = (event: KeyboardEvent, cb: (text: string) => void) => {
const isControlOrMetaPressed =
event.type === "keydown" && (event.ctrlKey || event.metaKey);
if (isControlOrMetaPressed) {
if (event.code === "KeyV") {
pasteSelection((text: string) => {
terminal.current?.write(text);
cb(text);
});
}
if (event.code === "KeyC") {
const selection = terminal.current?.getSelection();
if (selection) copySelection(selection);
if (ref.current) {
terminal.current.open(ref.current);
// Hide cursor for read-only terminal using ANSI escape sequence
terminal.current.write("\x1b[?25l");
}
}
return true;
};
const handleEnter = (command: string) => {
terminal.current?.write("\r\n");
// Don't write the command again as it will be added to the commands array
// and rendered by the useEffect that watches commands
send(getTerminalCommand(command));
// Don't add the prompt here as it will be added when the command is processed
// and the commands array is updated
};
const handleBackspace = (command: string) => {
terminal.current?.write("\b \b");
return command.slice(0, -1);
};
// Initialize terminal and handle cleanup
@@ -136,11 +86,12 @@ export const useTerminal = () => {
}
lastCommandIndex.current = commands.length;
}
terminal.current.write("$ ");
// Don't show prompt in read-only terminal
}
return () => {
terminal.current?.dispose();
lastCommandIndex.current = 0;
};
}, []);
@@ -150,19 +101,17 @@ export const useTerminal = () => {
commands.length > 0 &&
lastCommandIndex.current < commands.length
) {
let lastCommandType = "";
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
lastCommandType = commands[i].type;
if (commands[i].type === "input") {
terminal.current.write("$ ");
}
// Pass true for isUserInput to skip rendering user input commands
// that have already been displayed as the user typed
renderCommand(commands[i], terminal.current, false);
}
lastCommandIndex.current = commands.length;
if (lastCommandType === "output") {
terminal.current.write("$ ");
}
}
}, [commands, disabled]);
}, [commands]);
React.useEffect(() => {
let resizeObserver: ResizeObserver | null = null;
@@ -180,60 +129,5 @@ export const useTerminal = () => {
};
}, []);
React.useEffect(() => {
if (terminal.current) {
// Dispose of existing listeners if they exist
if (keyEventDisposable.current) {
keyEventDisposable.current.dispose();
keyEventDisposable.current = null;
}
let commandBuffer = "";
if (!disabled) {
// Add new key event listener and store the disposable
keyEventDisposable.current = terminal.current.onKey(
({ key, domEvent }) => {
if (domEvent.key === "Enter") {
handleEnter(commandBuffer);
commandBuffer = "";
} else if (domEvent.key === "Backspace") {
if (commandBuffer.length > 0) {
commandBuffer = handleBackspace(commandBuffer);
}
} else {
// Ignore paste event
if (key.charCodeAt(0) === 22) {
return;
}
commandBuffer += key;
terminal.current?.write(key);
}
},
);
// Add custom key handler and store the disposable
terminal.current.attachCustomKeyEventHandler((event) =>
pasteHandler(event, (text) => {
commandBuffer += text;
}),
);
} else {
// Add a noop handler when disabled
keyEventDisposable.current = terminal.current.onKey((e) => {
e.domEvent.preventDefault();
e.domEvent.stopPropagation();
});
}
}
return () => {
if (keyEventDisposable.current) {
keyEventDisposable.current.dispose();
keyEventDisposable.current = null;
}
};
}, [disabled]);
return ref;
};

View File

@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { useWsClient, V0_WebSocketStatus } from "#/context/ws-client-provider";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
import { useConversationId } from "#/hooks/use-conversation-id";
/**
* Unified hook that returns the current WebSocket status
@@ -9,11 +10,15 @@ import { useConversationWebSocket } from "#/contexts/conversation-websocket-cont
* - For V1 conversations: Returns status from ConversationWebSocketProvider
*/
export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
const { conversationId } = useConversationId();
const { data: conversation } = useActiveConversation();
const v0Status = useWsClient();
const v1Context = useConversationWebSocket();
const isV1Conversation = conversation?.conversation_version === "V1";
// Check if this is a V1 conversation:
const isV1Conversation =
conversationId.startsWith("task-") ||
conversation?.conversation_version === "V1";
const webSocketStatus = useMemo(() => {
if (isV1Conversation) {
@@ -33,7 +38,13 @@ export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
}
}
return v0Status.webSocketStatus;
}, [isV1Conversation, v1Context, v0Status.webSocketStatus]);
}, [
isV1Conversation,
v1Context,
v0Status.webSocketStatus,
conversationId,
conversation,
]);
return webSocketStatus;
}

View File

@@ -930,4 +930,5 @@ export enum I18nKey {
TOAST$STOPPING_CONVERSATION = "TOAST$STOPPING_CONVERSATION",
TOAST$FAILED_TO_STOP_CONVERSATION = "TOAST$FAILED_TO_STOP_CONVERSATION",
TOAST$CONVERSATION_STOPPED = "TOAST$CONVERSATION_STOPPED",
AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION = "AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION",
}

View File

@@ -6160,20 +6160,20 @@
"uk": "Введіть свій ключ API."
},
"SETTINGS$LLM_API_KEY": {
"en": "LLM API Key",
"zh-CN": "LLM API 密钥",
"zh-TW": "LLM API 金鑰",
"de": "LLM API Schlüssel",
"ko-KR": "LLM API 키",
"no": "LLM API-nøkkel",
"it": "Chiave API LLM",
"pt": "Chave API LLM",
"es": "Clave API LLM",
"ar": "مفتاح API للنماذج اللغوية الكبيرة",
"fr": "Clé API LLM",
"tr": "LLM API Anahtarı",
"ja": "LLM APIキー",
"uk": "Ключ API LLM"
"en": "OpenHands LLM Key",
"zh-CN": "OpenHands LLM 密钥",
"zh-TW": "OpenHands LLM 金鑰",
"de": "OpenHands LLM Schlüssel",
"ko-KR": "OpenHands LLM 키",
"no": "OpenHands LLM-nøkkel",
"it": "Chiave LLM OpenHands",
"pt": "Chave LLM OpenHands",
"es": "Clave LLM OpenHands",
"ar": "مفتاح LLM OpenHands",
"fr": "Clé LLM OpenHands",
"tr": "OpenHands LLM Anahtarı",
"ja": "OpenHands LLMキー",
"uk": "Ключ LLM OpenHands"
},
"SETTINGS$LLM_API_KEY_DESCRIPTION": {
"en": "You can use this API Key as the LLM API Key for OpenHands open-source and CLI. It will incur cost on your OpenHands Cloud account. Do NOT share this key elsewhere.",
@@ -14320,20 +14320,20 @@
"uk": "Зупинити сервер"
},
"COMMON$TERMINAL": {
"en": "Terminal",
"ja": "ターミナル",
"zh-CN": "终端",
"zh-TW": "終端機",
"ko-KR": "터미널",
"no": "Terminal",
"it": "Terminale",
"pt": "Terminal",
"es": "Terminal",
"ar": "الطرفية",
"fr": "Terminal",
"tr": "Terminal",
"de": "Terminal",
"uk": "Термінал"
"en": "Terminal (read-only)",
"ja": "ターミナル (読み取り専用)",
"zh-CN": "终端(只读)",
"zh-TW": "終端機(唯讀)",
"ko-KR": "터미널 (읽기 전용)",
"no": "Terminal (skrivebeskyttet)",
"it": "Terminale (sola lettura)",
"pt": "Terminal (somente leitura)",
"es": "Terminal (solo lectura)",
"ar": "الطرفية (للقراءة فقط)",
"fr": "Terminal (lecture seule)",
"tr": "Terminal (salt okunur)",
"de": "Terminal (schreibgeschützt)",
"uk": "Термінал (тільки читання)"
},
"COMMON$UNKNOWN": {
"en": "Unknown",
@@ -14878,5 +14878,21 @@
"tr": "Konuşma durduruldu",
"de": "Konversation gestoppt",
"uk": "Розмову зупинено"
},
"AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION": {
"en": "Waiting for user confirmation",
"ja": "ユーザーの確認を待っています",
"zh-CN": "等待用户确认",
"zh-TW": "等待使用者確認",
"ko-KR": "사용자 확인 대기 중",
"no": "Venter på brukerbekreftelse",
"it": "In attesa di conferma dell'utente",
"pt": "Aguardando confirmação do usuário",
"es": "Esperando confirmación del usuario",
"ar": "في انتظار تأكيد المستخدم",
"fr": "En attente de la confirmation de l'utilisateur",
"tr": "Kullanıcı onayı bekleniyor",
"de": "Warte auf Benutzerbestätigung",
"uk": "Очікується підтвердження користувача"
}
}

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="113" viewBox="0 0 113 113" fill="none">
<path d="M57.9521 83.6306C57.2282 83.6659 56.3772 83.6871 55.5226 83.6871C41.334 83.6871 28.5015 77.8853 19.2672 68.5204L19.2602 68.5134C22.177 76.7765 27.3821 83.6271 34.1162 88.4967L34.2362 88.5814C40.8185 93.3522 49.0569 96.216 57.9627 96.216C66.8685 96.216 75.1034 93.3557 81.8022 88.5038L81.6821 88.585C88.5327 83.6306 93.7378 76.7835 96.5663 68.81L96.6546 68.5204C87.4062 77.8888 74.5631 83.6907 60.3675 83.6907C59.5164 83.6907 58.6689 83.6695 57.8285 83.6271L57.9521 83.6306ZM57.9486 24.9624C58.676 24.9236 59.527 24.9024 60.3851 24.9024C74.5702 24.9024 87.4027 30.7043 96.6334 40.0656L96.6405 40.0727C93.7237 31.8095 88.5186 24.9589 81.788 20.0893L81.668 20.0046C75.0857 15.2374 66.8473 12.3806 57.9415 12.3806C49.0357 12.3806 40.8008 15.2374 34.0985 20.0893L34.2186 20.0081C27.3644 24.9589 22.1593 31.8095 19.3308 39.7831L19.2425 40.0727C28.5015 30.7078 41.3517 24.9059 55.5579 24.9059C56.3983 24.9059 57.2388 24.9271 58.0686 24.966L57.9486 24.9624ZM25.5776 18.2672C25.5776 19.549 25.0585 20.7108 24.2216 21.5548C23.3882 22.3952 22.2335 22.9178 20.9587 22.9178C19.6839 22.9178 18.5257 22.3952 17.6958 21.5548C16.8589 20.7108 16.3398 19.5455 16.3398 18.2637C16.3398 16.9818 16.8589 15.8165 17.6958 14.9725C18.5292 14.1321 19.6839 13.6095 20.9587 13.6095C22.2335 13.6095 23.3918 14.1321 24.2251 14.9761C25.062 15.82 25.5776 16.9818 25.5776 18.2672ZM94.3734 9.84516C94.3734 9.84869 94.3734 9.85222 94.3734 9.85222C94.3734 11.5861 93.6742 13.1575 92.5442 14.2981C91.4178 15.4387 89.8534 16.1449 88.1266 16.1449C86.3998 16.1449 84.8355 15.4387 83.709 14.3016C82.579 13.1575 81.8798 11.5825 81.8798 9.84516C81.8798 8.10779 82.579 6.53285 83.709 5.38872C84.8355 4.25166 86.3963 3.54541 88.1266 3.54541C89.8569 3.54541 91.4178 4.25166 92.5442 5.38872C93.6742 6.52932 94.3734 8.10072 94.3734 9.8381V9.84516ZM35.1296 101.516C35.1296 101.516 35.1296 101.52 35.1296 101.523C35.1296 103.709 34.2503 105.69 32.8237 107.131C31.4042 108.568 29.4337 109.458 27.2585 109.458C25.0832 109.458 23.1128 108.568 21.6932 107.135C20.2666 105.694 19.3873 103.709 19.3873 101.52C19.3873 99.3306 20.2666 97.3495 21.6932 95.9053C23.1128 94.468 25.0797 93.5817 27.2585 93.5817C29.4372 93.5817 31.4042 94.4716 32.8237 95.9088C34.2468 97.3495 35.1296 99.3306 35.1296 101.516C35.1296 101.52 35.1296 101.52 35.1296 101.523V101.516Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,9 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(0.85) translate(1.8, 1.8)">
<path d="M19.5 1.5C18.67 1.5 18 2.17 18 3C18 3.83 18.67 4.5 19.5 4.5C20.33 4.5 21 3.83 21 3C21 2.17 20.33 1.5 19.5 1.5Z" fill="currentColor"/>
<path d="M12 18C8.5 18 5.5 16.8 4 15C4 18.3137 7.13401 21 12 21C16.866 21 20 18.3137 20 15C18.5 16.8 15.5 18 12 18Z" fill="currentColor"/>
<path d="M12 6C15.5 6 18.5 7.2 20 9C20 5.68629 16.866 3 12 3C7.13401 3 4 5.68629 4 9C5.5 7.2 8.5 6 12 6Z" fill="currentColor"/>
<path d="M7.5 21C6.67 21 6 21.67 6 22.5C6 23.33 6.67 24 7.5 24C8.33 24 9 23.33 9 22.5C9 21.67 8.33 21 7.5 21Z" fill="currentColor"/>
<path d="M4.5 5.5C3.67 5.5 3 4.83 3 4C3 3.17 3.67 2.5 4.5 2.5C5.33 2.5 6 3.17 6 4C6 4.83 5.33 5.5 4.5 5.5Z" fill="currentColor"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 831 B

View File

@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import React from "react";
import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { useGetGitChanges } from "#/hooks/query/use-get-git-changes";
import { useUnifiedGetGitChanges } from "#/hooks/query/use-unified-get-git-changes";
import { I18nKey } from "#/i18n/declaration";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RandomTip } from "#/components/features/tips/random-tip";
@@ -27,7 +27,7 @@ function GitChanges() {
isError,
error,
isLoading: loadingGitChanges,
} = useGetGitChanges();
} = useUnifiedGetGitChanges();
const [statusMessage, setStatusMessage] = React.useState<string[] | null>(
null,

View File

@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useCommandStore } from "#/state/command-store";
import { useJupyterStore } from "#/state/jupyter-store";
import { useConversationStore } from "#/state/conversation-store";
import { useAgentStore } from "#/stores/agent-store";
import { AgentState } from "#/types/agent-state";
@@ -53,7 +52,6 @@ function AppContent() {
const setCurrentAgentState = useAgentStore(
(state) => state.setCurrentAgentState,
);
const clearJupyter = useJupyterStore((state) => state.clearJupyter);
const removeErrorMessage = useErrorMessageStore(
(state) => state.removeErrorMessage,
);
@@ -70,7 +68,6 @@ function AppContent() {
// 1. Cleanup Effect - runs when navigating to a different conversation
React.useEffect(() => {
clearTerminal();
clearJupyter();
resetConversationState();
setCurrentAgentState(AgentState.LOADING);
removeErrorMessage();
@@ -84,7 +81,6 @@ function AppContent() {
}, [
conversationId,
clearTerminal,
clearJupyter,
resetConversationState,
setCurrentAgentState,
removeErrorMessage,

View File

@@ -1,44 +0,0 @@
import React from "react";
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
function Jupyter() {
const parentRef = React.useRef<HTMLDivElement>(null);
const [parentWidth, setParentWidth] = React.useState(0);
// This is a hack to prevent the editor from overflowing
// Should be removed after revising the parent and containers
// Use ResizeObserver to properly track parent width changes
React.useEffect(() => {
let resizeObserver: ResizeObserver | null = null;
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// Use contentRect.width for more accurate measurements
const { width } = entry.contentRect;
if (width > 0) {
setParentWidth(width);
}
}
});
if (parentRef.current) {
resizeObserver.observe(parentRef.current);
}
return () => {
resizeObserver?.disconnect();
};
}, []);
// Provide a fallback width to prevent the editor from being hidden
// Use parentWidth if available, otherwise use a large default
const maxWidth = parentWidth > 0 ? parentWidth : 9999;
return (
<div ref={parentRef} className="h-full">
<JupyterEditor maxWidth={maxWidth} />
</div>
);
}
export default Jupyter;

View File

@@ -28,12 +28,6 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { getProviderId } from "#/utils/map-provider";
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
import { UpgradeBannerWithBackdrop } from "#/components/features/settings/upgrade-banner-with-backdrop";
import { useCreateSubscriptionCheckoutSession } from "#/hooks/mutation/stripe/use-create-subscription-checkout-session";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { cn } from "#/utils/utils";
import { useIsAllHandsSaaSEnvironment } from "#/hooks/use-is-all-hands-saas-environment";
interface OpenHandsApiKeyHelpProps {
testId: string;
@@ -75,11 +69,6 @@ function LlmSettingsScreen() {
const { data: resources } = useAIConfigOptions();
const { data: settings, isLoading, isFetching } = useSettings();
const { data: config } = useConfig();
const { data: subscriptionAccess } = useSubscriptionAccess();
const { data: isAuthed } = useIsAuthed();
const { mutate: createSubscriptionCheckoutSession } =
useCreateSubscriptionCheckoutSession();
const isAllHandsSaaSEnvironment = useIsAllHandsSaaSEnvironment();
const [view, setView] = React.useState<"basic" | "advanced">("basic");
@@ -442,44 +431,16 @@ function LlmSettingsScreen() {
if (!settings || isFetching) return <LlmSettingsInputsSkeleton />;
// Show upgrade banner and disable form in SaaS mode when user doesn't have an active subscription
// Exclude self-hosted enterprise customers (those not on all-hands.dev domains)
const shouldShowUpgradeBanner =
config?.APP_MODE === "saas" &&
!subscriptionAccess &&
isAllHandsSaaSEnvironment;
const formAction = (formData: FormData) => {
// Prevent form submission for unsubscribed SaaS users
if (shouldShowUpgradeBanner) return;
if (view === "basic") basicFormAction(formData);
else advancedFormAction(formData);
};
return (
<div
data-testid="llm-settings-screen"
className={cn(
"h-full relative",
shouldShowUpgradeBanner && "overflow-hidden",
)}
>
{shouldShowUpgradeBanner && (
<UpgradeBannerWithBackdrop
onUpgradeClick={() => {
createSubscriptionCheckoutSession();
}}
isDisabled={!isAuthed}
/>
)}
<div data-testid="llm-settings-screen" className="h-full relative">
<form
action={formAction}
className={cn(
"flex flex-col h-full justify-between",
shouldShowUpgradeBanner && "h-[calc(100%-theme(spacing.12))]",
)}
inert={shouldShowUpgradeBanner}
className="flex flex-col h-full justify-between"
>
<div className="flex flex-col gap-6">
<SettingsSwitch
@@ -487,7 +448,6 @@ function LlmSettingsScreen() {
defaultIsToggled={view === "advanced"}
onToggle={handleToggleAdvancedSettings}
isToggled={view === "advanced"}
isDisabled={shouldShowUpgradeBanner}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</SettingsSwitch>
@@ -496,7 +456,6 @@ function LlmSettingsScreen() {
<div
data-testid="llm-settings-form-basic"
className="flex flex-col gap-6"
aria-disabled={shouldShowUpgradeBanner ? "true" : undefined}
>
{!isLoading && !isFetching && (
<>
@@ -504,7 +463,6 @@ function LlmSettingsScreen() {
models={modelsAndProviders}
currentModel={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL}
onChange={handleModelIsDirty}
isDisabled={shouldShowUpgradeBanner}
wrapperClassName="!flex-col !gap-6"
/>
{(settings.LLM_MODEL?.startsWith("openhands/") ||
@@ -522,7 +480,6 @@ function LlmSettingsScreen() {
className="w-full max-w-[680px]"
placeholder={settings.LLM_API_KEY_SET ? "<hidden>" : ""}
onChange={handleApiKeyIsDirty}
isDisabled={shouldShowUpgradeBanner}
startContent={
settings.LLM_API_KEY_SET && (
<KeyStatusIcon isSet={settings.LLM_API_KEY_SET} />
@@ -602,7 +559,6 @@ function LlmSettingsScreen() {
defaultValue={settings.SEARCH_API_KEY || ""}
onChange={handleSearchApiKeyIsDirty}
placeholder={t(I18nKey.API$TVLY_KEY_EXAMPLE)}
isDisabled={shouldShowUpgradeBanner}
startContent={
settings.SEARCH_API_KEY_SET && (
<KeyStatusIcon isSet={settings.SEARCH_API_KEY_SET} />
@@ -672,7 +628,6 @@ function LlmSettingsScreen() {
onToggle={handleConfirmationModeIsDirty}
defaultIsToggled={settings.CONFIRMATION_MODE}
isBeta
isDisabled={shouldShowUpgradeBanner}
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
</SettingsSwitch>

View File

@@ -2,14 +2,14 @@ import React from "react";
import { FaArrowRotateRight } from "react-icons/fa6";
import { FaExternalLinkAlt, FaHome } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { useActiveHost } from "#/hooks/query/use-active-host";
import { useUnifiedActiveHost } from "#/hooks/query/use-unified-active-host";
import { PathForm } from "#/components/features/served-host/path-form";
import { I18nKey } from "#/i18n/declaration";
import ServerProcessIcon from "#/icons/server-process.svg?react";
function ServedApp() {
const { t } = useTranslation();
const { activeHost } = useActiveHost();
const { activeHost } = useUnifiedActiveHost();
const [refreshKey, setRefreshKey] = React.useState(0);
const [currentActiveHost, setCurrentActiveHost] = React.useState<
string | null

View File

@@ -6,7 +6,6 @@ import { Route } from "./+types/settings";
import OptionService from "#/api/option-service/option-service.api";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
import { Typography } from "#/ui/typography";
import { SettingsLayout } from "#/components/features/settings/settings-layout";
@@ -41,7 +40,6 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
function SettingsScreen() {
const { t } = useTranslation();
const { data: config } = useConfig();
const { data: subscriptionAccess } = useSubscriptionAccess();
const location = useLocation();
const isSaas = config?.APP_MODE === "saas";
@@ -55,7 +53,7 @@ function SettingsScreen() {
items.push(...OSS_NAV_ITEMS);
}
return items;
}, [isSaas, !!subscriptionAccess]);
}, [isSaas]);
// Current section title for the main content area
const currentSectionTitle = useMemo(() => {
@@ -65,7 +63,7 @@ function SettingsScreen() {
return (
<main data-testid="settings-screen" className="h-full">
<SettingsLayout navigationItems={navItems} isSaas={isSaas}>
<SettingsLayout navigationItems={navItems}>
<div className="flex flex-col gap-6 h-full">
<Typography.H2>{t(currentSectionTitle)}</Typography.H2>
<div className="flex-1 overflow-auto custom-scrollbar-always">

View File

@@ -2,14 +2,14 @@ import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
import { VSCODE_IN_NEW_TAB } from "#/utils/feature-flags";
import { WaitingForRuntimeMessage } from "#/components/features/chat/waiting-for-runtime-message";
import { useAgentState } from "#/hooks/use-agent-state";
function VSCodeTab() {
const { t } = useTranslation();
const { data, isLoading, error } = useVSCodeUrl();
const { data, isLoading, error } = useUnifiedVSCodeUrl();
const { curAgentState } = useAgentState();
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
@@ -39,10 +39,18 @@ function VSCodeTab() {
}
};
if (isRuntimeInactive || isLoading) {
if (isRuntimeInactive) {
return <WaitingForRuntimeMessage />;
}
if (isLoading) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{t(I18nKey.VSCODE$LOADING)}
</div>
);
}
if (error || (data && data.error) || !data?.url || iframeError) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">

View File

@@ -8,7 +8,6 @@ import {
StatusMessage,
} from "#/types/message";
import { handleObservationMessage } from "./observations";
import { useJupyterStore } from "#/state/jupyter-store";
import { useCommandStore } from "#/state/command-store";
import { queryClient } from "#/query-client-config";
import {
@@ -35,10 +34,6 @@ export function handleActionMessage(message: ActionMessage) {
useCommandStore.getState().appendInput(message.args.command);
}
if (message.action === ActionType.RUN_IPYTHON) {
useJupyterStore.getState().appendJupyterInput(message.args.code);
}
if ("args" in message && "security_risk" in message.args) {
useSecurityAnalyzerStore.getState().appendSecurityAnalyzerInput({
id: message.id,

View File

@@ -1,5 +1,4 @@
import { ObservationMessage } from "#/types/message";
import { useJupyterStore } from "#/state/jupyter-store";
import { useCommandStore } from "#/state/command-store";
import ObservationType from "#/types/observation-type";
import { useBrowserStore } from "#/stores/browser-store";
@@ -22,14 +21,6 @@ export function handleObservationMessage(message: ObservationMessage) {
useCommandStore.getState().appendOutput(content);
break;
}
case ObservationType.RUN_IPYTHON:
useJupyterStore.getState().appendJupyterOutput({
content: message.content,
imageUrls: Array.isArray(message.extras?.image_urls)
? message.extras.image_urls
: undefined,
});
break;
case ObservationType.BROWSE:
case ObservationType.BROWSE_INTERACTIVE:
if (

View File

@@ -4,7 +4,6 @@ import { devtools } from "zustand/middleware";
export type ConversationTab =
| "editor"
| "browser"
| "jupyter"
| "served"
| "vscode"
| "terminal";

View File

@@ -1,40 +0,0 @@
import { create } from "zustand";
export type Cell = {
content: string;
type: "input" | "output";
imageUrls?: string[];
};
interface JupyterState {
cells: Cell[];
appendJupyterInput: (content: string) => void;
appendJupyterOutput: (payload: {
content: string;
imageUrls?: string[];
}) => void;
clearJupyter: () => void;
}
export const useJupyterStore = create<JupyterState>((set) => ({
cells: [],
appendJupyterInput: (content: string) =>
set((state) => ({
cells: [...state.cells, { content, type: "input" }],
})),
appendJupyterOutput: (payload: { content: string; imageUrls?: string[] }) =>
set((state) => ({
cells: [
...state.cells,
{
content: payload.content,
type: "output",
imageUrls: payload.imageUrls,
},
],
})),
clearJupyter: () =>
set(() => ({
cells: [],
})),
}));

View File

@@ -2,15 +2,19 @@ import { create } from "zustand";
interface EventMessageState {
submittedEventIds: number[]; // Avoid the flashing issue of the confirmation buttons
v1SubmittedEventIds: string[]; // V1 event IDs (V1 uses string IDs)
}
interface EventMessageStore extends EventMessageState {
addSubmittedEventId: (id: number) => void;
removeSubmittedEventId: (id: number) => void;
addV1SubmittedEventId: (id: string) => void;
removeV1SubmittedEventId: (id: string) => void;
}
export const useEventMessageStore = create<EventMessageStore>((set) => ({
submittedEventIds: [],
v1SubmittedEventIds: [],
addSubmittedEventId: (id: number) =>
set((state) => ({
submittedEventIds: [...state.submittedEventIds, id],
@@ -21,4 +25,14 @@ export const useEventMessageStore = create<EventMessageStore>((set) => ({
(eventId) => eventId !== id,
),
})),
addV1SubmittedEventId: (id: string) =>
set((state) => ({
v1SubmittedEventIds: [...state.v1SubmittedEventIds, id],
})),
removeV1SubmittedEventId: (id: string) =>
set((state) => ({
v1SubmittedEventIds: state.v1SubmittedEventIds.filter(
(eventId) => eventId !== id,
),
})),
}));

View File

@@ -1,21 +1,10 @@
enum TabOption {
PLANNER = "planner",
BROWSER = "browser",
JUPYTER = "jupyter",
VSCODE = "vscode",
}
type TabType =
| TabOption.PLANNER
| TabOption.BROWSER
| TabOption.JUPYTER
| TabOption.VSCODE;
const AllTabs = [
TabOption.VSCODE,
TabOption.BROWSER,
TabOption.PLANNER,
TabOption.JUPYTER,
];
type TabType = TabOption.PLANNER | TabOption.BROWSER | TabOption.VSCODE;
const AllTabs = [TabOption.VSCODE, TabOption.BROWSER, TabOption.PLANNER];
export { AllTabs, TabOption, type TabType };

View File

@@ -24,6 +24,7 @@ export const handleActionEventCacheInvalidation = (
// Invalidate file_changes cache for file-related actions
if (
action.kind === "StrReplaceEditorAction" ||
action.kind === "FileEditorAction" ||
action.kind === "ExecuteBashAction"
) {
queryClient.invalidateQueries(
@@ -35,7 +36,11 @@ export const handleActionEventCacheInvalidation = (
}
// Invalidate specific file diff cache for file modifications
if (action.kind === "StrReplaceEditorAction" && action.path) {
if (
(action.kind === "StrReplaceEditorAction" ||
action.kind === "FileEditorAction") &&
action.path
) {
const strippedPath = stripWorkspacePrefix(action.path);
queryClient.invalidateQueries({
queryKey: ["file_diff", conversationId, strippedPath],

View File

@@ -0,0 +1,22 @@
/**
* Get the git repository path for a conversation
* If a repository is selected, returns /workspace/project/{repo-name}
* Otherwise, returns /workspace/project
*
* @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands" or "owner/repo")
* @returns The git path to use
*/
export function getGitPath(
selectedRepository: string | null | undefined,
): string {
if (!selectedRepository) {
return "/workspace/project";
}
// Extract the repository name from "owner/repo" format
// The folder name is the second part after "/"
const parts = selectedRepository.split("/");
const repoName = parts.length > 1 ? parts[1] : parts[0];
return `/workspace/project/${repoName}`;
}

View File

@@ -0,0 +1,27 @@
import type {
GitChangeStatus,
V1GitChangeStatus,
} from "#/api/open-hands.types";
/**
* Maps V1 git change status to legacy V0 status format
*
* V1 -> V0 mapping:
* - ADDED -> A (Added)
* - DELETED -> D (Deleted)
* - UPDATED -> M (Modified)
* - MOVED -> R (Renamed)
*
* @param v1Status The V1 git change status
* @returns The equivalent V0 git change status
*/
export function mapV1ToV0Status(v1Status: V1GitChangeStatus): GitChangeStatus {
const statusMap: Record<V1GitChangeStatus, GitChangeStatus> = {
ADDED: "A",
DELETED: "D",
UPDATED: "M",
MOVED: "R",
};
return statusMap[v1Status];
}

View File

@@ -2,7 +2,8 @@ import { OpenHandsEvent } from "#/types/v1/core";
import { isObservationEvent } from "#/types/v1/type-guards";
/**
* Handles adding an event to the UI events array, with special logic for observation events
* Handles adding an event to the UI events array
* Replaces actions with observations when they arrive (so UI shows observation instead of action)
*/
export const handleEventForUI = (
event: OpenHandsEvent,

View File

@@ -1,32 +0,0 @@
export type JupyterLine = {
type: "plaintext" | "image";
content: string;
url?: string;
};
export const parseCellContent = (content: string, imageUrls?: string[]) => {
const lines: JupyterLine[] = [];
let currentText = "";
// First, process the text content
for (const line of content.split("\n")) {
currentText += `${line}\n`;
}
if (currentText) {
lines.push({ type: "plaintext", content: currentText });
}
// Then, add image lines if we have image URLs
if (imageUrls && imageUrls.length > 0) {
imageUrls.forEach((url) => {
lines.push({
type: "image",
content: `![image](${url})`,
url,
});
});
}
return lines;
};

View File

@@ -24,7 +24,7 @@ export const AGENT_STATUS_MAP: {
// Ready/Idle/Waiting for user input states
[AgentState.AWAITING_USER_INPUT]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
[AgentState.AWAITING_USER_CONFIRMATION]:
I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
I18nKey.AGENT_STATUS$WAITING_FOR_USER_CONFIRMATION,
[AgentState.USER_CONFIRMED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
[AgentState.USER_REJECTED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK,
[AgentState.FINISHED]: I18nKey.AGENT_STATUS$WAITING_FOR_TASK,

View File

@@ -594,3 +594,18 @@ export const hasOpenHandsSuffix = (
}
return repo.full_name.endsWith("/.openhands");
};
/**
* 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
*/
export const buildSessionHeaders = (
sessionApiKey?: string | null,
): Record<string, string> => {
const headers: Record<string, string> = {};
if (sessionApiKey) {
headers["X-Session-API-Key"] = sessionApiKey;
}
return headers;
};

View File

@@ -0,0 +1,101 @@
# Ctrl+C Implementation for OpenHands CLI
## Overview
This implementation adds improved Ctrl+C handling to the OpenHands CLI where:
1. **First Ctrl+C**: Attempts graceful pause of the agent
2. **Second Ctrl+C** (within 3 seconds): Immediately kills the process
## Architecture
### Signal Handling (`signal_handler.py`)
**SignalHandler Class:**
- Tracks Ctrl+C presses with a 3-second timeout
- First press: calls graceful shutdown callback
- Second press: forces immediate exit with `os._exit(1)`
**ProcessSignalHandler Class:**
- Manages conversation runner processes
- Implements graceful shutdown by terminating the process
- Provides clean installation/uninstallation of signal handlers
### Process Management (`process_runner.py`)
**ProcessBasedConversationRunner Class:**
- Runs conversation in a separate process using `multiprocessing`
- Provides inter-process communication via queues
- Supports commands: process_message, get_status, toggle_confirmation_mode, resume
- Handles process lifecycle (start, stop, cleanup)
### Modified Components
**Pause Listener (`listeners/pause_listener.py`):**
- Removed Ctrl+C and Ctrl+D handling (now handled by signal handler)
- Only handles Ctrl+P for pause functionality
**Agent Chat (`agent_chat.py`):**
- Integrated ProcessSignalHandler for Ctrl+C management
- Updated to use ProcessBasedConversationRunner
- All commands (/new, /status, /confirm, /resume) work with process-based approach
- Proper cleanup in finally block
**Simple Main (`simple_main.py`):**
- Added basic SignalHandler installation for graceful shutdown
## Key Features
### Graceful Shutdown
- First Ctrl+C sends SIGTERM to conversation process
- Gives 2 seconds for graceful shutdown
- Shows appropriate user feedback
### Immediate Termination
- Second Ctrl+C within 3 seconds forces immediate exit
- Uses `os._exit(1)` to bypass Python cleanup
- Ensures agent stops immediately
### Process Communication
- Queue-based communication between main and conversation processes
- Status queries work across process boundaries
- Command handling preserved for all CLI features
### Error Handling
- Proper exception handling in both processes
- Cleanup of resources in finally blocks
- Fallback KeyboardInterrupt handlers
## Usage
The implementation is transparent to users:
- Press Ctrl+C once to pause the agent gracefully
- Press Ctrl+C again within 3 seconds to force immediate termination
- All existing CLI commands continue to work
## Testing
A test script `test_ctrl_c.py` is provided to verify the signal handling behavior:
```bash
uv run python test_ctrl_c.py
```
## Files Modified/Created
**New Files:**
- `openhands_cli/signal_handler.py` - Signal handling classes
- `openhands_cli/process_runner.py` - Process-based conversation runner
- `test_ctrl_c.py` - Test script for Ctrl+C behavior
**Modified Files:**
- `openhands_cli/listeners/pause_listener.py` - Removed Ctrl+C handling
- `openhands_cli/agent_chat.py` - Integrated new signal handling and process runner
- `openhands_cli/simple_main.py` - Added basic signal handler
## Dependencies
Uses standard Python libraries:
- `signal` - For signal handling
- `multiprocessing` - For separate process execution
- `queue` - For inter-process communication
- `threading` - For thread-safe signal counting
- `time` - For timeout management

View File

@@ -0,0 +1,88 @@
# Ctrl+C Handling Improvements
## Summary
Simplified the overly complex Ctrl+C handling implementation in the OpenHands CLI to make it more reliable and easier to understand.
## Problems Addressed
1. **Second Ctrl+C not registering properly** - The original implementation had complex queue-based communication that could miss signals
2. **Overly complex multiprocessing** - Many methods were unnecessarily wrapped in separate processes
3. **No reset of Ctrl+C count** - The count wasn't reset when starting new message processing
4. **Unnecessary queue communication** - Status and settings methods didn't need separate processes
## Solution
### 1. Simplified Signal Handler (`simple_signal_handler.py`)
- **Direct signal handling** in the main process instead of complex queue communication
- **Simple Ctrl+C counting** with immediate force kill on second press within 3 seconds
- **Clear process management** with direct process termination
- **Reset functionality** to clear count when starting new operations
Key features:
- First Ctrl+C: Graceful termination (SIGTERM)
- Second Ctrl+C (within 3 seconds): Force kill (SIGKILL)
- Automatic count reset after 3 seconds
- Manual count reset via `reset_count()`
### 2. Simplified Process Runner (`simple_process_runner.py`)
- **Minimal multiprocessing** - Only the `process_message` method runs in a subprocess
- **Direct method calls** for status, settings, and other operations
- **Simple API** with clear process lifecycle management
- **No queue communication** for methods that don't need it
Key features:
- `process_message()`: Runs in subprocess for isolation
- `get_status()`, `get_settings()`, etc.: Run directly in main process
- `cleanup()`: Simple process termination
- `current_process` property for signal handler integration
### 3. Updated Main CLI (`agent_chat.py`)
- **Simplified imports** using the new signal handler and process runner
- **Reset Ctrl+C count** when starting new message processing
- **Direct method calls** for commands that don't need process isolation
- **Cleaner error handling** and resource cleanup
## Files Modified
### New Files
- `openhands_cli/simple_signal_handler.py` - Simplified signal handling
- `openhands_cli/simple_process_runner.py` - Minimal process wrapper
### Modified Files
- `openhands_cli/agent_chat.py` - Updated to use simplified components
- `openhands_cli/simple_main.py` - Updated imports
### Test Files
- `test_basic_signal.py` - Basic signal handler test
- `manual_test_ctrl_c.py` - Manual Ctrl+C testing
## Key Improvements
1. **Reliability**: Direct signal handling eliminates race conditions
2. **Simplicity**: Removed complex queue-based communication
3. **Performance**: Most operations run directly in main process
4. **Maintainability**: Clear, simple code that's easy to understand
5. **User Experience**: Consistent Ctrl+C behavior with immediate force kill option
## Testing
The implementation includes test scripts to verify:
- Basic signal handler functionality
- Ctrl+C counting and reset behavior
- Process termination (graceful and force)
- Integration with the CLI
## Usage
The simplified implementation maintains the same external API:
- First Ctrl+C: Attempts graceful pause/termination
- Second Ctrl+C (within 3 seconds): Force kills the process immediately
- Count resets automatically or when starting new operations
## Migration
The changes are backward compatible with the existing CLI interface. The complex `ProcessSignalHandler` and `ProcessBasedConversationRunner` classes are replaced with simpler equivalents that provide the same functionality with better reliability.

View File

@@ -1,8 +1,6 @@
# OpenHands V1 CLI
A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [agent-sdk](https://github.com/OpenHands/agent-sdk)).
The [OpenHands V0 CLI (legacy)](https://github.com/OpenHands/OpenHands/tree/main/openhands/cli) is being deprecated.
A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [OpenHands software-agent-sdk](https://github.com/OpenHands/software-agent-sdk)).
---
@@ -33,4 +31,4 @@ uv run openhands
# The binary will be in dist/
./dist/openhands # macOS/Linux
# dist/openhands.exe # Windows
```
```

View File

@@ -15,20 +15,10 @@ import sys
import time
from pathlib import Path
from openhands_cli.llm_utils import get_llm_metadata
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
from openhands_cli.utils import get_llm_metadata, get_default_cli_agent
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
from openhands.sdk import LLM
from openhands.tools.preset.default import get_default_agent
dummy_agent = get_default_agent(
llm=LLM(
model='dummy-model',
api_key='dummy-key',
metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
),
cli_mode=True,
)
# =================================================
# SECTION: Build Binary
@@ -127,7 +117,7 @@ def _is_welcome(line: str) -> bool:
return any(marker in s for marker in WELCOME_MARKERS)
def test_executable() -> bool:
def test_executable(dummy_agent) -> bool:
"""Test the built executable, measuring boot time and total test time."""
print('🧪 Testing the built executable...')
@@ -275,7 +265,14 @@ def main() -> int:
# Test the executable
if not args.no_test:
if not test_executable():
dummy_agent = get_default_cli_agent(
llm=LLM(
model='dummy-model',
api_key='dummy-key',
metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
)
)
if not test_executable(dummy_agent):
print('❌ Executable test failed, build process failed')
return 1
@@ -286,4 +283,10 @@ def main() -> int:
if __name__ == '__main__':
sys.exit(main())
try:
sys.exit(main())
except Exception as e:
print(e)
print('❌ Executable test failed')
sys.exit(1)

View File

@@ -6,6 +6,7 @@ Provides a conversation interface with an AI agent using OpenHands patterns.
import sys
from datetime import datetime
import uuid
from openhands.sdk import (
Message,
@@ -16,7 +17,13 @@ from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.runner import ConversationRunner
from openhands_cli.setup import MissingAgentSpec, setup_conversation, start_fresh_conversation
from openhands_cli.simple_process_runner import SimpleProcessRunner
from openhands_cli.simple_signal_handler import SimpleSignalHandler
from openhands_cli.setup import (
MissingAgentSpec,
setup_conversation,
verify_agent_exists_or_setup_agent
)
from openhands_cli.tui.settings.mcp_screen import MCPScreen
from openhands_cli.tui.settings.settings_screen import SettingsScreen
from openhands_cli.tui.status import display_status
@@ -65,122 +72,169 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
EOFError: If EOF is encountered
"""
conversation_id = uuid.uuid4()
if resume_conversation_id:
try:
conversation_id = uuid.UUID(resume_conversation_id)
except ValueError as e:
print_formatted_text(
HTML(
f"<yellow>Warning: '{resume_conversation_id}' is not a valid UUID.</yellow>"
)
)
return
try:
conversation = start_fresh_conversation(resume_conversation_id)
initialized_agent = verify_agent_exists_or_setup_agent()
except MissingAgentSpec:
print_formatted_text(HTML('\n<yellow>Setup is required to use OpenHands CLI.</yellow>'))
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
return
display_welcome(conversation.id, bool(resume_conversation_id))
display_welcome(conversation_id, bool(resume_conversation_id))
# Track session start time for uptime calculation
session_start_time = datetime.now()
# Create conversation runner to handle state machine logic
runner = ConversationRunner(conversation)
# Create simple signal handler and session
signal_handler = SimpleSignalHandler()
signal_handler.install()
session = get_session_prompter()
# Set up conversation
conversation = setup_conversation(conversation_id)
# Create simple process runner
process_runner = SimpleProcessRunner(conversation)
# Main chat loop
while True:
try:
# Get user input
user_input = session.prompt(
HTML('<gold>> </gold>'),
multiline=False,
)
try:
# Main chat loop
while True:
try:
# Get user input
user_input = session.prompt(
HTML('<gold>> </gold>'),
multiline=False,
)
if not user_input.strip():
continue
if not user_input.strip():
continue
# Handle commands
command = user_input.strip().lower()
# Handle commands
command = user_input.strip().lower()
message = Message(
role='user',
content=[TextContent(text=user_input)],
)
message = Message(
role='user',
content=[TextContent(text=user_input)],
)
if command == '/exit':
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation.id)
break
if command == '/exit':
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation_id)
break
elif command == '/settings':
settings_screen = SettingsScreen(conversation)
settings_screen.display_settings()
continue
elif command == '/settings':
# For process-based runner, we can't directly access the conversation
# TODO: Implement settings access through process communication if needed
settings_screen = SettingsScreen(None)
settings_screen.display_settings()
continue
elif command == '/mcp':
mcp_screen = MCPScreen()
mcp_screen.display_mcp_info(conversation.agent)
continue
elif command == '/mcp':
mcp_screen = MCPScreen()
mcp_screen.display_mcp_info(initialized_agent)
continue
elif command == '/clear':
display_welcome(conversation.id)
continue
elif command == '/clear':
display_welcome(conversation_id)
continue
elif command == '/new':
elif command == '/new':
try:
# Clean up existing process runner
if process_runner:
process_runner.cleanup()
# Create fresh conversation with new process runner
conversation_id = uuid.uuid4()
conversation = setup_conversation(conversation_id)
process_runner = SimpleProcessRunner(conversation)
display_welcome(conversation_id, resume=False)
print_formatted_text(
HTML('<green>✓ Started fresh conversation</green>')
)
continue
except Exception as e:
print_formatted_text(
HTML(f'<red>Error starting fresh conversation: {e}</red>')
)
continue
elif command == '/help':
display_help()
continue
elif command == '/status':
status = process_runner.get_status()
print_formatted_text(HTML(f'<yellow>Conversation ID:</yellow> {status["conversation_id"]}'))
print_formatted_text(HTML(f'<yellow>Agent State:</yellow> {status.get("agent_state", "Unknown")}'))
print_formatted_text(HTML(f'<yellow>Process Running:</yellow> {status["is_running"]}'))
continue
elif command == '/confirm':
result = process_runner.toggle_confirmation_mode()
mode_text = "Enabled" if result else "Disabled"
print_formatted_text(HTML(f'<yellow>Confirmation mode: {mode_text}</yellow>'))
continue
elif command == '/resume':
try:
process_runner.resume()
print_formatted_text(HTML('<green>Agent resumed</green>'))
except Exception as e:
print_formatted_text(HTML(f'<red>Failed to resume: {e}</red>'))
continue
# Reset Ctrl+C count when starting new message processing
signal_handler.reset_count()
# Process the message
try:
# Start a fresh conversation (no resume ID = new conversation)
conversation = setup_conversation()
runner = ConversationRunner(conversation)
display_welcome(conversation.id, resume=False)
print_formatted_text(
HTML('<green>✓ Started fresh conversation</green>')
)
continue
# Set the current process for signal handling
signal_handler.set_process(process_runner.current_process)
# Create message object
message = Message(role='user', content=[TextContent(text=user_input)])
result = process_runner.process_message(message)
print() # Add spacing for successful processing
except Exception as e:
print_formatted_text(
HTML(f'<red>Error starting fresh conversation: {e}</red>')
)
continue
print_formatted_text(HTML(f'<red>Failed to process message: {e}</red>'))
finally:
# Clear the process reference
signal_handler.set_process(None)
elif command == '/help':
display_help()
except KeyboardInterrupt:
# KeyboardInterrupt should be handled by the signal handler now
# Just continue the loop - the signal handler manages the process
continue
except Exception as e:
print_formatted_text(HTML(f'<red>Error in chat loop: {e}</red>'))
continue
elif command == '/status':
display_status(conversation, session_start_time=session_start_time)
continue
except KeyboardInterrupt:
# Final fallback for KeyboardInterrupt - only exit if we're not in the main loop
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation_id)
elif command == '/confirm':
runner.toggle_confirmation_mode()
new_status = (
'enabled' if runner.is_confirmation_mode_active else 'disabled'
)
print_formatted_text(
HTML(f'<yellow>Confirmation mode {new_status}</yellow>')
)
continue
elif command == '/resume':
if not (
conversation.state.agent_status == AgentExecutionStatus.PAUSED
or conversation.state.agent_status
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
):
print_formatted_text(
HTML('<red>No paused conversation to resume...</red>')
)
continue
# Resume without new message
message = None
runner.process_message(message)
print() # Add spacing
except KeyboardInterrupt:
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
_print_exit_hint(conversation.id)
break
# Clean up terminal state
_restore_tty()
finally:
# Clean up resources
if process_runner:
process_runner.cleanup()
signal_handler.uninstall()
# Clean up terminal state
_restore_tty()

View File

@@ -104,8 +104,8 @@ def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
# Get the current version for the Docker image
version = get_openhands_version()
runtime_image = f'docker.all-hands.dev/openhands/runtime:{version}-nikolaik'
app_image = f'docker.all-hands.dev/openhands/openhands:{version}'
runtime_image = f'docker.openhands.dev/openhands/runtime:{version}-nikolaik'
app_image = f'docker.openhands.dev/openhands/openhands:{version}'
print_formatted_text(HTML('<grey>Pulling required Docker images...</grey>'))

View File

@@ -31,8 +31,9 @@ class PauseListener(threading.Thread):
for key_press in self._input.read_keys():
pause_detected = pause_detected or key_press.key == Keys.ControlP
pause_detected = pause_detected or key_press.key == Keys.ControlC
pause_detected = pause_detected or key_press.key == Keys.ControlD
# Note: Ctrl+C and Ctrl+D are now handled by the signal handler
# pause_detected = pause_detected or key_press.key == Keys.ControlC
# pause_detected = pause_detected or key_press.key == Keys.ControlD
return pause_detected

View File

@@ -0,0 +1,314 @@
"""
Process-based conversation runner for handling agent execution in a separate process.
This allows for immediate termination of the agent when needed while maintaining
the ability to gracefully pause on the first Ctrl+C.
"""
import multiprocessing
import queue
import signal
import threading
import time
from enum import Enum
from typing import Any, Dict, Optional
from openhands.sdk import BaseConversation, Message
from openhands.sdk.conversation.state import AgentExecutionStatus
from prompt_toolkit import HTML, print_formatted_text
from openhands_cli.runner import ConversationRunner
class ProcessCommand(Enum):
"""Commands that can be sent to the conversation process."""
PROCESS_MESSAGE = "process_message"
PAUSE = "pause"
RESUME = "resume"
TOGGLE_CONFIRMATION = "toggle_confirmation"
GET_STATUS = "get_status"
SHUTDOWN = "shutdown"
class ProcessResponse(Enum):
"""Response types from the conversation process."""
SUCCESS = "success"
ERROR = "error"
STATUS = "status"
def conversation_worker(
conversation_id: str,
command_queue: multiprocessing.Queue,
response_queue: multiprocessing.Queue,
setup_conversation_func: Any, # Function to setup conversation
) -> None:
"""Worker function that runs in a separate process to handle conversation."""
# Set up signal handling in the worker process
def signal_handler(signum, frame):
print_formatted_text(HTML('<yellow>Conversation process received termination signal.</yellow>'))
return
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal.SIG_IGN) # Ignore SIGINT in worker process
try:
# Setup conversation in the worker process
conversation = setup_conversation_func(conversation_id)
runner = ConversationRunner(conversation)
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": "Conversation process initialized"
})
while True:
try:
# Check for commands with timeout
try:
command_data = command_queue.get(timeout=0.1)
except queue.Empty:
continue
command = command_data.get("command")
args = command_data.get("args", {})
if command == ProcessCommand.SHUTDOWN:
break
elif command == ProcessCommand.PROCESS_MESSAGE:
message = args.get("message")
try:
runner.process_message(message)
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": "Message processed"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error processing message: {e}"
})
elif command == ProcessCommand.PAUSE:
try:
runner.conversation.pause()
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": "Conversation paused"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error pausing conversation: {e}"
})
elif command == ProcessCommand.RESUME:
try:
runner.process_message(None) # Resume without new message
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": "Conversation resumed"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error resuming conversation: {e}"
})
elif command == ProcessCommand.TOGGLE_CONFIRMATION:
try:
runner.toggle_confirmation_mode()
new_status = 'enabled' if runner.is_confirmation_mode_active else 'disabled'
response_queue.put({
"type": ProcessResponse.SUCCESS,
"message": f"Confirmation mode {new_status}"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error toggling confirmation mode: {e}"
})
elif command == ProcessCommand.GET_STATUS:
try:
status = {
"agent_status": runner.conversation.state.agent_status,
"confirmation_mode": runner.is_confirmation_mode_active
}
response_queue.put({
"type": ProcessResponse.STATUS,
"data": status
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Error getting status: {e}"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Unexpected error in conversation worker: {e}"
})
except Exception as e:
response_queue.put({
"type": ProcessResponse.ERROR,
"message": f"Failed to initialize conversation process: {e}"
})
class ProcessBasedConversationRunner:
"""Manages a conversation runner in a separate process."""
def __init__(self, conversation_id: str, setup_conversation_func: Any):
self.conversation_id = conversation_id
self.setup_conversation_func = setup_conversation_func
self.process: Optional[multiprocessing.Process] = None
self.command_queue: Optional[multiprocessing.Queue] = None
self.response_queue: Optional[multiprocessing.Queue] = None
self.is_running = False
def start(self) -> bool:
"""Start the conversation process."""
if self.is_running:
return True
try:
# Create queues for communication
self.command_queue = multiprocessing.Queue()
self.response_queue = multiprocessing.Queue()
# Start the worker process
self.process = multiprocessing.Process(
target=conversation_worker,
args=(
self.conversation_id,
self.command_queue,
self.response_queue,
self.setup_conversation_func
)
)
self.process.start()
# Wait for initialization confirmation
try:
response = self.response_queue.get(timeout=10.0)
if response["type"] == ProcessResponse.SUCCESS:
self.is_running = True
return True
else:
print_formatted_text(HTML(f'<red>Failed to initialize conversation process: {response.get("message", "Unknown error")}</red>'))
self.stop()
return False
except queue.Empty:
print_formatted_text(HTML('<red>Timeout waiting for conversation process to initialize</red>'))
self.stop()
return False
except Exception as e:
print_formatted_text(HTML(f'<red>Error starting conversation process: {e}</red>'))
return False
def stop(self) -> None:
"""Stop the conversation process."""
if not self.is_running:
return
try:
if self.command_queue:
self.command_queue.put({"command": ProcessCommand.SHUTDOWN})
if self.process:
self.process.join(timeout=2.0)
if self.process.is_alive():
self.process.terminate()
self.process.join(timeout=1.0)
if self.process.is_alive():
self.process.kill()
except Exception as e:
print_formatted_text(HTML(f'<yellow>Warning: Error stopping conversation process: {e}</yellow>'))
finally:
self.is_running = False
self.process = None
self.command_queue = None
self.response_queue = None
def send_command(self, command: ProcessCommand, args: Optional[Dict] = None, timeout: float = 5.0) -> Optional[Dict]:
"""Send a command to the conversation process and wait for response."""
if not self.is_running or not self.command_queue or not self.response_queue:
return None
try:
command_data = {"command": command, "args": args or {}}
self.command_queue.put(command_data)
response = self.response_queue.get(timeout=timeout)
return response
except queue.Empty:
print_formatted_text(HTML(f'<yellow>Timeout waiting for response to {command.value}</yellow>'))
return None
except Exception as e:
print_formatted_text(HTML(f'<red>Error sending command {command.value}: {e}</red>'))
return None
def process_message(self, message: Optional[Message]) -> bool:
"""Process a message through the conversation."""
response = self.send_command(ProcessCommand.PROCESS_MESSAGE, {"message": message})
if response and response["type"] == ProcessResponse.SUCCESS:
return True
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return False
def pause(self) -> bool:
"""Pause the conversation."""
response = self.send_command(ProcessCommand.PAUSE)
if response and response["type"] == ProcessResponse.SUCCESS:
return True
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return False
def resume(self) -> bool:
"""Resume the conversation."""
response = self.send_command(ProcessCommand.RESUME)
if response and response["type"] == ProcessResponse.SUCCESS:
return True
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return False
def toggle_confirmation_mode(self) -> Optional[str]:
"""Toggle confirmation mode and return the new status."""
response = self.send_command(ProcessCommand.TOGGLE_CONFIRMATION)
if response and response["type"] == ProcessResponse.SUCCESS:
return response.get("message")
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return None
def get_status(self) -> Optional[Dict]:
"""Get the current status of the conversation."""
response = self.send_command(ProcessCommand.GET_STATUS)
if response and response["type"] == ProcessResponse.STATUS:
return response.get("data")
elif response:
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
return None
def is_alive(self) -> bool:
"""Check if the conversation process is alive."""
return self.is_running and self.process and self.process.is_alive()
def force_terminate(self) -> None:
"""Force terminate the conversation process immediately."""
if self.process and self.process.is_alive():
self.process.kill()
self.process.join(timeout=1.0)
self.is_running = False

View File

@@ -120,6 +120,7 @@ class ConversationRunner:
else:
raise Exception('Infinite loop')
def _handle_confirmation_request(self) -> UserConfirmation:
"""Handle confirmation request from user.

View File

@@ -2,7 +2,7 @@ import uuid
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import BaseConversation, Conversation, Workspace, register_tool
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace, register_tool
from openhands.tools.execute_bash import BashTool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
@@ -26,8 +26,38 @@ class MissingAgentSpec(Exception):
pass
def setup_conversation(
def load_agent_specs(
conversation_id: str | None = None,
) -> Agent:
agent_store = AgentStore()
agent = agent_store.load(session_id=conversation_id)
if not agent:
raise MissingAgentSpec(
'Agent specification not found. Please configure your agent settings.'
)
return agent
def verify_agent_exists_or_setup_agent() -> Agent:
"""Verify agent specs exists by attempting to load it.
"""
settings_screen = SettingsScreen()
try:
agent = load_agent_specs()
return agent
except MissingAgentSpec:
# For first-time users, show the full settings flow with choice between basic/advanced
settings_screen.configure_settings(first_time=True)
# Try once again after settings setup attempt
return load_agent_specs()
def setup_conversation(
conversation_id: uuid,
include_security_analyzer: bool = True
) -> BaseConversation:
"""
@@ -40,28 +70,8 @@ def setup_conversation(
MissingAgentSpec: If agent specification is not found or invalid.
"""
# Use provided conversation_id or generate a random one
if conversation_id is None:
conversation_id = uuid.uuid4()
elif isinstance(conversation_id, str):
try:
conversation_id = uuid.UUID(conversation_id)
except ValueError as e:
print_formatted_text(
HTML(
f"<yellow>Warning: '{conversation_id}' is not a valid UUID.</yellow>"
)
)
raise e
with LoadingContext('Initializing OpenHands agent...'):
agent_store = AgentStore()
agent = agent_store.load(session_id=str(conversation_id))
if not agent:
raise MissingAgentSpec(
'Agent specification not found. Please configure your agent settings.'
)
agent = load_agent_specs(str(conversation_id))
if not include_security_analyzer:
# Remove security analyzer from agent spec
@@ -86,31 +96,3 @@ def setup_conversation(
)
return conversation
def start_fresh_conversation(
resume_conversation_id: str | None = None
) -> BaseConversation:
"""Start a fresh conversation by creating a new conversation instance.
Handles the complete conversation setup process including settings screen
if agent configuration is missing.
Args:
resume_conversation_id: Optional conversation ID to resume
Returns:
BaseConversation: A new conversation instance
"""
conversation = None
settings_screen = SettingsScreen()
try:
conversation = setup_conversation(resume_conversation_id)
return conversation
except MissingAgentSpec:
# For first-time users, show the full settings flow with choice between basic/advanced
settings_screen.configure_settings(first_time=True)
# Try once again after settings setup attempt
return setup_conversation(resume_conversation_id)

View File

@@ -0,0 +1,113 @@
"""
Signal handling for graceful shutdown and immediate termination.
This module provides a signal handler that tracks Ctrl+C presses:
- First Ctrl+C: Attempt graceful pause of the agent
- Second Ctrl+C: Immediately terminate the process
"""
import signal
import threading
import time
from typing import Callable, Optional
from prompt_toolkit import HTML, print_formatted_text
class SignalHandler:
"""Handles SIGINT (Ctrl+C) with graceful shutdown on first press and immediate termination on second."""
def __init__(self, graceful_shutdown_callback: Optional[Callable] = None):
self.graceful_shutdown_callback = graceful_shutdown_callback
self.sigint_count = 0
self.last_sigint_time = 0.0
self.sigint_timeout = 3.0 # Reset counter after 3 seconds
self.lock = threading.Lock()
self.original_handler = None
def install(self) -> None:
"""Install the signal handler."""
self.original_handler = signal.signal(signal.SIGINT, self._handle_sigint)
def uninstall(self) -> None:
"""Restore the original signal handler."""
if self.original_handler is not None:
signal.signal(signal.SIGINT, self.original_handler)
self.original_handler = None
def _handle_sigint(self, signum: int, frame) -> None:
"""Handle SIGINT (Ctrl+C) signal."""
current_time = time.time()
with self.lock:
# Reset counter if too much time has passed since last Ctrl+C
if current_time - self.last_sigint_time > self.sigint_timeout:
self.sigint_count = 0
self.sigint_count += 1
self.last_sigint_time = current_time
if self.sigint_count == 1:
# First Ctrl+C: attempt graceful shutdown
print_formatted_text(HTML('\n<yellow>Received Ctrl+C. Attempting to pause agent gracefully...</yellow>'))
print_formatted_text(HTML('<grey>Press Ctrl+C again within 3 seconds to force immediate termination.</grey>'))
if self.graceful_shutdown_callback:
try:
self.graceful_shutdown_callback()
except Exception as e:
print_formatted_text(HTML(f'<red>Error during graceful shutdown: {e}</red>'))
elif self.sigint_count >= 2:
# Second Ctrl+C: immediate termination
print_formatted_text(HTML('\n<red>Received second Ctrl+C. Terminating immediately...</red>'))
self.uninstall()
# Force immediate exit
import os
os._exit(1)
class ProcessSignalHandler:
"""Signal handler for managing conversation runner processes."""
def __init__(self):
self.conversation_process = None
self.signal_handler = None
def set_conversation_process(self, process) -> None:
"""Set the conversation process to manage."""
self.conversation_process = process
def graceful_shutdown(self) -> None:
"""Attempt graceful shutdown of the conversation process."""
if hasattr(self, 'conversation_process') and self.conversation_process and self.conversation_process.is_alive():
print_formatted_text(HTML('<yellow>Pausing agent once current step is completed...</yellow>'))
# Send SIGTERM to the process for graceful shutdown
self.conversation_process.terminate()
# Give it a moment to shut down gracefully
self.conversation_process.join(timeout=2.0)
if self.conversation_process.is_alive():
print_formatted_text(HTML('<yellow>Agent is taking time to pause. Press Ctrl+C again to force termination.</yellow>'))
else:
print_formatted_text(HTML('<green>Agent paused successfully.</green>'))
else:
print_formatted_text(HTML('<yellow>No active conversation process to pause.</yellow>'))
def install_handler(self) -> None:
"""Install the signal handler."""
self.signal_handler = SignalHandler(graceful_shutdown_callback=self.graceful_shutdown)
self.signal_handler.install()
def uninstall_handler(self) -> None:
"""Uninstall the signal handler."""
if self.signal_handler:
self.signal_handler.uninstall()
self.signal_handler = None
def force_terminate(self) -> None:
"""Force terminate the conversation process."""
if self.conversation_process and self.conversation_process.is_alive():
self.conversation_process.kill()
self.conversation_process.join(timeout=1.0)

View File

@@ -18,6 +18,7 @@ from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.argparsers.main_parser import create_main_parser
from openhands_cli.simple_signal_handler import SimpleSignalHandler
def main() -> None:
@@ -30,8 +31,15 @@ def main() -> None:
parser = create_main_parser()
args = parser.parse_args()
# Install basic signal handler for the main process
# The agent_chat module will install its own more sophisticated handler
signal_handler = SimpleSignalHandler()
try:
if args.command == 'serve':
# For GUI mode, use basic signal handling
signal_handler.install()
# Import gui_launcher only when needed
from openhands_cli.gui_launcher import launch_gui_server
@@ -41,7 +49,7 @@ def main() -> None:
# Import agent_chat only when needed
from openhands_cli.agent_chat import run_cli_entry
# Start agent chat
# Start agent chat (it will install its own signal handler)
run_cli_entry(resume_conversation_id=args.resume)
except KeyboardInterrupt:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
@@ -53,6 +61,8 @@ def main() -> None:
traceback.print_exc()
raise
finally:
signal_handler.uninstall()
if __name__ == '__main__':

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