Compare commits

..

42 Commits

Author SHA1 Message Date
rohitvinodmalhotra@gmail.com 279b145fd9 Update pyproject.toml 2025-11-07 11:34:25 -05:00
Engel Nyst b83e2877ec CLI: align with agent-sdk renames (#11643)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-11-07 11:30:37 -05:00
sp.wack 7acee16de5 fix(frontend): Consider start task job error status for loading indicators (#11670) 2025-11-07 19:24:29 +04:00
sp.wack 1e3f1de773 fix(frontend): Add translations for error status' (#11669) 2025-11-07 13:51:58 +00:00
sp.wack bfe60d3bbf chore(frontend): Disable /feedback/conversation/{conversationId}/batch for V1 conversations (#11668) 2025-11-07 13:50:09 +00:00
sp.wack ad75cd05d8 chore(frontend): Add better PostHog tracking (#11645) 2025-11-07 16:35:54 +04:00
Hiep Le 955f87561b feat(frontend): enable pinning and unpinning of conversation tabs (#11659) 2025-11-07 13:38:30 +07:00
Hiep Le 1e5bff82f2 feat(frontend): visually highlight chat input container in plan mode (#11647) 2025-11-07 13:14:28 +07:00
Tim O'Farrell ddf58da995 Fix V1 callbacks (#11654)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-06 16:05:58 -07:00
Hiep Le b678d548c2 feat(frontend): create new planner tab in the interface (#11646) 2025-11-06 23:56:35 +07:00
Hiep Le a1d4d62f68 feat(frontend): show server status menu when hovering over the status indicator (#11635) 2025-11-06 16:23:08 +04:00
Yakshith 75e54e3552 fix(llm): remove default reasoning_effort; fix Gemini special case (#11567) 2025-11-05 23:30:46 +01:00
Yuxiao Cheng 6b211f3b29 Fix stuck after incorrect TaskTrackingAction (#11436)
Co-authored-by: jarrycyx <dzdzzd@126.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-11-05 22:09:51 +00:00
mamoodi e208b64a95 Update free credits statement to $10 (#11651) 2025-11-05 20:57:56 +00:00
mamoodi 555444f239 Release 0.61.0 (#11618)
Co-authored-by: rohitvinodmalhotra@gmail.com <rohitvinodmalhotra@gmail.com>
2025-11-05 15:11:22 -05:00
Tim O'Farrell d99c7827d8 More updates of agent_status to execution_status (#11642)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-05 19:19:34 +00:00
mamoodi 5a8f08b4ef Remove obsolete workflow (#11650) 2025-11-05 19:56:34 +01:00
Hiep Le 44fbd6c1b9 refactor(backend): the delete_app_conversation_info function (#11648) 2025-11-05 23:45:16 +07:00
sp.wack 7e824ca5dc fix(frontend): V1 Loading UI (#11630) 2025-11-05 14:23:10 +00:00
sp.wack 9a7002d817 fix(frontend): V1 resume conversation / agent (#11627) 2025-11-05 14:16:46 +00:00
Hiep Le 6411d4df94 feat(frontend): display text label when items are selected across all canvas views (#11636) 2025-11-05 16:47:22 +07:00
eddierichter-amd c544ea1187 localhost base_url fixup when running in a docker container (#11474)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-11-04 17:57:25 -05:00
Graham Neubig 308d0e62ab Change error logging to info for missing config files (#11639) 2025-11-04 21:27:13 +01:00
Ray Myers 9abd1714b9 fix - Speed up runtime tests (#11570)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-04 11:17:55 -06:00
sp.wack f1abe6c6af fix(ci): Lint Python (#11634) 2025-11-04 16:24:24 +00:00
Tim O'Farrell 30b5ad1768 Fix for issue where conversations won't start (#11633) 2025-11-04 08:51:22 -07:00
Hiep Le 4ea3e4b1fd refactor(frontend): break down conversation service into smaller services (#11594) 2025-11-04 20:52:44 +07:00
Hiep Le 7049a3e918 chore(frontend): add feature flag for planning agent (#11616) 2025-11-04 20:32:45 +07:00
Hiep Le fa431fb956 refactor(backend): update get_microagent_management_conversations API to support V1 (#11313)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-11-04 17:44:44 +07:00
Tim O'Farrell 2fc8ab2601 Bumped Software Agent SDK (#11626) 2025-11-03 14:53:12 -07:00
mamoodi 8e119c68ab Create CNAME 2025-11-03 15:43:34 -05:00
Hiep Le 8893f9364d refactor: update delete_app_conversation to accept ID instead of object (#11486)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
2025-11-03 13:26:33 -07:00
Tim O'Farrell 727520f6ce V1 CORS Fix (#11586)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 12:14:02 -07:00
Tim O'Farrell 898c3501dd Update initial from $20 to $10 (#11624) 2025-11-03 19:11:18 +00:00
Jessica Kerr 4c81965c61 build(devcontainer): add uvx installation (#11610) 2025-11-03 19:37:54 +01:00
Hiep Le 0f054c740c fix(frontend): the width of the branch dropdown appears inconsistent on medium-sized screens. (#11620) 2025-11-04 01:30:11 +07:00
Yuxiao Cheng 9bcf80dba5 Adding error logging when config file is not found. (#11419)
Co-authored-by: jarrycyx <dzdzzd@126.com>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2025-11-03 13:19:48 -05:00
மனோஜ்குமார் பழனிச்சாமி 2a98cd9338 Fix import order for Windows PowerShell support (#11557) 2025-11-03 13:14:23 -05:00
Rohit Malhotra b31dbfc21a CLI: make sure MCP server doesn't persist even after removal (#11602)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 12:45:47 -05:00
Tim O'Farrell 5d711d5576 Exclude V1 conversations from V0 (#11595) 2025-11-03 09:57:34 -07:00
Rohit Malhotra 3eb73de924 CLI: lazy load conversation for /new command (#11601)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:30:08 +00:00
Rohit Malhotra 2e49f07451 CLI: Rm loading context (#11603)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-11-03 16:15:47 +00:00
142 changed files with 5198 additions and 2375 deletions
+3
View File
@@ -7,5 +7,8 @@ git config --global --add safe.directory "$(realpath .)"
# Install `nc`
sudo apt update && sudo apt install netcat -y
# Install `uv` and `uvx`
wget -qO- https://astral.sh/uv/install.sh | sh
# Do common setup tasks
source .openhands/setup.sh
@@ -1,73 +0,0 @@
#!/usr/bin/env python3
import os
import re
import sys
def find_version_references(directory: str) -> tuple[set[str], set[str]]:
openhands_versions = set()
runtime_versions = set()
version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
for root, _, files in os.walk(directory):
# Skip .git directory and docs/build directory
if '.git' in root or 'docs/build' in root:
continue
for file in files:
if file.endswith(
('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Find all openhands version references
matches = version_pattern_openhands.findall(content)
if matches:
print(f'Found openhands version {matches} in {file_path}')
openhands_versions.update(matches)
# Find all runtime version references
matches = version_pattern_runtime.findall(content)
if matches:
print(f'Found runtime version {matches} in {file_path}')
runtime_versions.update(matches)
except Exception as e:
print(f'Error reading {file_path}: {e}', file=sys.stderr)
return openhands_versions, runtime_versions
def main():
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
print(f'Checking version consistency in {repo_root}')
openhands_versions, runtime_versions = find_version_references(repo_root)
print(f'Found openhands versions: {sorted(openhands_versions)}')
print(f'Found runtime versions: {sorted(runtime_versions)}')
exit_code = 0
if len(openhands_versions) > 1:
print('Error: Multiple openhands versions found:', file=sys.stderr)
print('Found versions:', sorted(openhands_versions), file=sys.stderr)
exit_code = 1
elif len(openhands_versions) == 0:
print('Warning: No openhands version references found', file=sys.stderr)
if len(runtime_versions) > 1:
print('Error: Multiple runtime versions found:', file=sys.stderr)
print('Found versions:', sorted(runtime_versions), file=sys.stderr)
exit_code = 1
elif len(runtime_versions) == 0:
print('Warning: No runtime version references found', file=sys.stderr)
sys.exit(exit_code)
if __name__ == '__main__':
main()
+6 -6
View File
@@ -86,7 +86,7 @@ jobs:
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Image
name: Build Runtime Image
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
@@ -256,7 +256,7 @@ jobs:
test_runtime_root:
name: RT Unit Tests (Root)
needs: [ghcr_build_runtime, define-matrix]
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
fail-fast: false
matrix:
@@ -298,7 +298,7 @@ jobs:
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
# Install to be able to retry on failures for flaky tests
# Install to be able to retry on failures for flakey tests
poetry run pip install pytest-rerunfailures
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
@@ -311,14 +311,14 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
# Run unit tests with the Docker runtime Docker images as openhands user
test_runtime_oh:
name: RT Unit Tests (openhands)
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2404
needs: [ghcr_build_runtime, define-matrix]
strategy:
matrix:
@@ -370,7 +370,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
-13
View File
@@ -90,16 +90,3 @@ jobs:
- name: Run pre-commit hooks
working-directory: ./openhands-cli
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
# Check version consistency across documentation
check-version-consistency:
name: Check version consistency
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Set up python
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
- name: Run version consistency check
run: .github/scripts/check_version_consistency.py
+6 -4
View File
@@ -48,7 +48,10 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: "poetry"
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
run: |
poetry install --with dev,test,runtime
poetry run pip install pytest-xdist
poetry run pip install pytest-rerunfailures
- name: Build Environment
run: make build
- name: Run Unit Tests
@@ -56,7 +59,7 @@ jobs:
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -s tests/runtime/test_bash.py --cov=openhands --cov-branch
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
- name: Store coverage file
@@ -88,7 +91,7 @@ jobs:
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime
- name: Run Windows unit tests
run: poetry run pytest -svv tests/unit/runtime/utils/test_windows_bash.py
run: poetry run pytest -svv tests/runtime//test_windows_bash.py
env:
PYTHONPATH: ".;$env:PYTHONPATH"
DEBUG: "1"
@@ -173,7 +176,6 @@ jobs:
path: ".coverage.openhands-cli.${{ matrix.python-version }}"
include-hidden-files: true
coverage-comment:
name: Coverage Comment
if: github.event_name == 'pull_request'
+1
View File
@@ -0,0 +1 @@
docs.all-hands.dev
+1 -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.60-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.61-nikolaik`
## Develop inside Docker container
+4 -4
View File
@@ -51,7 +51,7 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for
## ☁️ OpenHands Cloud
The easiest way to get started with OpenHands is on [OpenHands Cloud](https://app.all-hands.dev),
which comes with $20 in free credits for new users.
which comes with $10 in free credits for new users.
## 💻 Running OpenHands Locally
@@ -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.openhands.dev/openhands/runtime:0.60-nikolaik
docker pull docker.openhands.dev/openhands/runtime:0.61-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.60-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-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.openhands.dev/openhands/openhands:0.60
docker.openhands.dev/openhands/openhands:0.61
```
</details>
+1 -1
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.60-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.61-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
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.openhands.dev/openhands/runtime:0.60-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.61-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:
@@ -0,0 +1,71 @@
"""add status and updated_at to callback
Revision ID: 080
Revises: 079
Create Date: 2025-11-05 00:00:00.000000
"""
from enum import Enum
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '080'
down_revision: Union[str, None] = '079'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
class EventCallbackStatus(Enum):
ACTIVE = 'ACTIVE'
DISABLED = 'DISABLED'
COMPLETED = 'COMPLETED'
ERROR = 'ERROR'
def upgrade() -> None:
"""Upgrade schema."""
status = sa.Enum(EventCallbackStatus, name='eventcallbackstatus')
status.create(op.get_bind(), checkfirst=True)
op.add_column(
'event_callback',
sa.Column('status', status, nullable=False, server_default='ACTIVE'),
)
op.add_column(
'event_callback',
sa.Column(
'updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()
),
)
op.drop_index('ix_event_callback_result_event_id')
op.drop_column('event_callback_result', 'event_id')
op.add_column(
'event_callback_result', sa.Column('event_id', sa.String, nullable=True)
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_column('event_callback', 'status')
op.drop_column('event_callback', 'updated_at')
op.drop_index('ix_event_callback_result_event_id')
op.drop_column('event_callback_result', 'event_id')
op.add_column(
'event_callback_result', sa.Column('event_id', sa.UUID, nullable=True)
)
op.create_index(
op.f('ix_event_callback_result_event_id'),
'event_callback_result',
['event_id'],
unique=False,
)
op.execute('DROP TYPE eventcallbackstatus')
+295 -30
View File
@@ -201,19 +201,20 @@ files = [
[[package]]
name = "anthropic"
version = "0.65.0"
version = "0.72.0"
description = "The official Python library for the anthropic API"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "anthropic-0.65.0-py3-none-any.whl", hash = "sha256:ba9d9f82678046c74ddf5698ca06d9f5b0f599cfac922ab0d5921638eb448d98"},
{file = "anthropic-0.65.0.tar.gz", hash = "sha256:6b6b6942574e54342050dfd42b8d856a8366b171daec147df3b80be4722733b9"},
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
]
[package.dependencies]
anyio = ">=3.5.0,<5"
distro = ">=1.7.0,<2"
docstring-parser = ">=0.15,<1"
google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""}
httpx = ">=0.25.0,<1"
jiter = ">=0.4.0,<1"
@@ -222,7 +223,7 @@ sniffio = "*"
typing-extensions = ">=4.10,<5"
[package.extras]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
vertex = ["google-auth[requests] (>=2,<3)"]
@@ -681,31 +682,34 @@ crt = ["awscrt (==0.27.6)"]
[[package]]
name = "browser-use"
version = "0.7.10"
version = "0.9.5"
description = "Make websites accessible for AI agents"
optional = false
python-versions = "<4.0,>=3.11"
groups = ["main"]
files = [
{file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"},
{file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"},
{file = "browser_use-0.9.5-py3-none-any.whl", hash = "sha256:4a2e92847204d1ded269026a99cb0cc0e60e38bd2751fa3f58aedd78f00b4e67"},
{file = "browser_use-0.9.5.tar.gz", hash = "sha256:f8285fe253b149d01769a7084883b4cf4db351e2f38e26302c157bcbf14a703f"},
]
[package.dependencies]
aiohttp = "3.12.15"
anthropic = ">=0.58.2,<1.0.0"
anthropic = ">=0.68.1,<1.0.0"
anyio = ">=4.9.0"
authlib = ">=1.6.0"
bubus = ">=1.5.6"
cdp-use = ">=1.4.0"
click = ">=8.1.8"
cloudpickle = ">=3.1.1"
google-api-core = ">=2.25.0"
google-api-python-client = ">=2.174.0"
google-auth = ">=2.40.3"
google-auth-oauthlib = ">=1.2.2"
google-genai = ">=1.29.0,<2.0.0"
groq = ">=0.30.0"
html2text = ">=2025.4.15"
httpx = ">=0.28.1"
inquirerpy = ">=0.3.4"
markdownify = ">=1.2.0"
mcp = ">=1.10.1"
ollama = ">=0.5.1"
openai = ">=1.99.2,<2.0.0"
@@ -720,16 +724,20 @@ pypdf = ">=5.7.0"
python-dotenv = ">=1.0.1"
reportlab = ">=4.0.0"
requests = ">=2.32.3"
rich = ">=14.0.0"
screeninfo = {version = ">=0.8.1", markers = "platform_system != \"darwin\""}
typing-extensions = ">=4.12.2"
uuid7 = ">=0.1.0"
[package.extras]
all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "oci (>=2.126.4)", "textual (>=3.2.0)"]
aws = ["boto3 (>=1.38.45)"]
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"]
examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
cli = ["textual (>=3.2.0)"]
cli-oci = ["oci (>=2.126.4)", "textual (>=3.2.0)"]
code = ["matplotlib (>=3.9.0)", "numpy (>=2.3.2)", "pandas (>=2.2.0)", "tabulate (>=0.9.0)"]
eval = ["anyio (>=4.9.0)", "datamodel-code-generator (>=0.26.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
oci = ["oci (>=2.126.4)"]
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
[[package]]
@@ -3525,6 +3533,25 @@ files = [
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "inquirerpy"
version = "0.3.4"
description = "Python port of Inquirer.js (A collection of common interactive command-line user interfaces)"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4"},
{file = "InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e"},
]
[package.dependencies]
pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
version = "0.7.0"
@@ -4580,6 +4607,62 @@ files = [
{file = "llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4"},
]
[[package]]
name = "lmnr"
version = "0.7.20"
description = "Python SDK for Laminar"
optional = false
python-versions = "<4,>=3.10"
groups = ["main"]
files = [
{file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
{file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
]
[package.dependencies]
grpcio = ">=1"
httpx = ">=0.24.0"
opentelemetry-api = ">=1.33.0"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.0"
opentelemetry-exporter-otlp-proto-http = ">=1.33.0"
opentelemetry-instrumentation = ">=0.54b0"
opentelemetry-instrumentation-threading = ">=0.57b0"
opentelemetry-sdk = ">=1.33.0"
opentelemetry-semantic-conventions = ">=0.54b0"
opentelemetry-semantic-conventions-ai = ">=0.4.13"
orjson = ">=3.0.0"
packaging = ">=22.0"
pydantic = ">=2.0.3,<3.0.0"
python-dotenv = ">=1.0"
tenacity = ">=8.0"
tqdm = ">=4.0"
[package.extras]
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
milvus = ["opentelemetry-instrumentation-milvus (>=0.47.1)"]
mistralai = ["opentelemetry-instrumentation-mistralai (>=0.47.1)"]
ollama = ["opentelemetry-instrumentation-ollama (>=0.47.1)"]
pinecone = ["opentelemetry-instrumentation-pinecone (>=0.47.1)"]
qdrant = ["opentelemetry-instrumentation-qdrant (>=0.47.1)"]
replicate = ["opentelemetry-instrumentation-replicate (>=0.47.1)"]
sagemaker = ["opentelemetry-instrumentation-sagemaker (>=0.47.1)"]
together = ["opentelemetry-instrumentation-together (>=0.47.1)"]
transformers = ["opentelemetry-instrumentation-transformers (>=0.47.1)"]
vertexai = ["opentelemetry-instrumentation-vertexai (>=0.47.1)"]
watsonx = ["opentelemetry-instrumentation-watsonx (>=0.47.1)"]
weaviate = ["opentelemetry-instrumentation-weaviate (>=0.47.1)"]
[[package]]
name = "lxml"
version = "6.0.1"
@@ -5737,7 +5820,7 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.0.0a4"
version = "1.0.0a5"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
@@ -5758,14 +5841,14 @@ wsproto = ">=1.2.0"
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-agent-server"
[[package]]
name = "openhands-ai"
version = "0.0.0-post.5456+15c207c40"
version = "0.0.0-post.5514+7c9e66194"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -5801,13 +5884,14 @@ jupyter_kernel_gateway = "*"
kubernetes = "^33.1.0"
libtmux = ">=0.46.2"
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
lmnr = "^0.7.20"
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 = "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"}
openhands-agent-server = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-agent-server"}
openhands-sdk = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-sdk"}
openhands-tools = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-tools"}
opentelemetry-api = "^1.33.1"
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
pathspec = "^0.12.1"
@@ -5863,7 +5947,7 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.0.0a4"
version = "1.0.0a5"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
@@ -5875,6 +5959,7 @@ develop = false
fastmcp = ">=2.11.3"
httpx = ">=0.27.0"
litellm = ">=1.77.7.dev9"
lmnr = ">=0.7.20"
pydantic = ">=2.11.7"
python-frontmatter = ">=1.1.0"
python-json-logger = ">=3.3.0"
@@ -5886,14 +5971,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-sdk"
[[package]]
name = "openhands-tools"
version = "1.0.0a4"
version = "1.0.0a5"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
@@ -5904,7 +5989,7 @@ develop = false
[package.dependencies]
bashlex = ">=0.18"
binaryornot = ">=0.4.4"
browser-use = ">=0.7.7"
browser-use = ">=0.8.0"
cachetools = "*"
func-timeout = ">=4.3.5"
libtmux = ">=0.46.2"
@@ -5913,9 +5998,9 @@ pydantic = ">=2.11.7"
[package.source]
type = "git"
url = "https://github.com/OpenHands/agent-sdk.git"
reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
resolved_reference = "3d8af53b2f0259dc98555a4acd4238f90e0afbce"
url = "https://github.com/OpenHands/software-agent-sdk.git"
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
subdirectory = "openhands-tools"
[[package]]
@@ -5988,6 +6073,62 @@ opentelemetry-proto = "1.36.0"
opentelemetry-sdk = ">=1.36.0,<1.37.0"
typing-extensions = ">=4.6.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.36.0"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0-py3-none-any.whl", hash = "sha256:3d769f68e2267e7abe4527f70deb6f598f40be3ea34c6adc35789bea94a32902"},
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0.tar.gz", hash = "sha256:dd3637f72f774b9fc9608ab1ac479f8b44d09b6fb5b2f3df68a24ad1da7d356e"},
]
[package.dependencies]
googleapis-common-protos = ">=1.52,<2.0"
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.36.0"
opentelemetry-proto = "1.36.0"
opentelemetry-sdk = ">=1.36.0,<1.37.0"
requests = ">=2.7,<3.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-instrumentation"
version = "0.57b0"
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation-0.57b0-py3-none-any.whl", hash = "sha256:9109280f44882e07cec2850db28210b90600ae9110b42824d196de357cbddf7e"},
{file = "opentelemetry_instrumentation-0.57b0.tar.gz", hash = "sha256:f2a30135ba77cdea2b0e1df272f4163c154e978f57214795d72f40befd4fcf05"},
]
[package.dependencies]
opentelemetry-api = ">=1.4,<2.0"
opentelemetry-semantic-conventions = "0.57b0"
packaging = ">=18.0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-instrumentation-threading"
version = "0.57b0"
description = "Thread context propagation support for OpenTelemetry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_instrumentation_threading-0.57b0-py3-none-any.whl", hash = "sha256:adfd64857c8c78d6111cf80552311e1713bad64272dd81abdd61f07b892a161b"},
{file = "opentelemetry_instrumentation_threading-0.57b0.tar.gz", hash = "sha256:06fa4c98d6bfe4670e7532497670ac202db42afa647ff770aedce0e422421c6e"},
]
[package.dependencies]
opentelemetry-api = ">=1.12,<2.0"
opentelemetry-instrumentation = "0.57b0"
wrapt = ">=1.0.0,<2.0.0"
[[package]]
name = "opentelemetry-proto"
version = "1.36.0"
@@ -6036,6 +6177,115 @@ files = [
opentelemetry-api = "1.36.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-semantic-conventions-ai"
version = "0.4.13"
description = "OpenTelemetry Semantic Conventions Extension for Large Language Models"
optional = false
python-versions = "<4,>=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5"},
{file = "opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036"},
]
[[package]]
name = "orjson"
version = "3.11.4"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"},
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"},
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"},
{file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"},
{file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"},
{file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"},
{file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"},
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"},
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"},
{file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"},
{file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"},
{file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"},
{file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"},
{file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"},
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"},
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"},
{file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"},
{file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"},
{file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"},
{file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"},
{file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"},
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"},
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"},
{file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"},
{file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"},
{file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"},
{file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"},
{file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"},
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"},
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"},
{file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"},
{file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"},
{file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"},
{file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"},
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"},
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"},
{file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"},
{file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"},
{file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"},
]
[[package]]
name = "packaging"
version = "25.0"
@@ -6252,6 +6502,21 @@ files = [
[package.dependencies]
ptyprocess = ">=0.5"
[[package]]
name = "pfzy"
version = "0.3.4"
description = "Python port of the fzy fuzzy string matching algorithm"
optional = false
python-versions = ">=3.7,<4.0"
groups = ["main"]
files = [
{file = "pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96"},
{file = "pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1"},
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
version = "1.31.5"
+1 -1
View File
@@ -50,7 +50,7 @@ SUBSCRIPTION_PRICE_DATA = {
},
}
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '20'))
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', None)
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')
@@ -35,6 +35,7 @@ class SaasConversationStore(ConversationStore):
session.query(StoredConversationMetadata)
.filter(StoredConversationMetadata.user_id == self.user_id)
.filter(StoredConversationMetadata.conversation_id == conversation_id)
.filter(StoredConversationMetadata.conversation_version == 'V0')
)
def _to_external_model(self, conversation_metadata: StoredConversationMetadata):
@@ -123,6 +124,7 @@ class SaasConversationStore(ConversationStore):
conversations = (
session.query(StoredConversationMetadata)
.filter(StoredConversationMetadata.user_id == self.user_id)
.filter(StoredConversationMetadata.conversation_version == 'V0')
.order_by(StoredConversationMetadata.created_at.desc())
.offset(offset)
.limit(limit + 1)
@@ -243,7 +243,7 @@ async def test_update_settings_with_litellm_default(
# Check that the URL and most of the JSON payload match what we expect
assert call_args['json']['user_email'] == 'testy@tester.com'
assert call_args['json']['models'] == []
assert call_args['json']['max_budget'] == 20.0
assert call_args['json']['max_budget'] == 10.0
assert call_args['json']['user_id'] == 'user-id'
assert call_args['json']['teams'] == ['test_team']
assert call_args['json']['auto_create_key'] is True
@@ -8,6 +8,13 @@ vi.mock("#/hooks/use-auth-url", () => ({
useAuthUrl: () => "https://gitlab.com/oauth/authorize",
}));
// Mock the useTracking hook
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackLoginButtonClick: vi.fn(),
}),
}));
describe("AuthModal", () => {
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
@@ -13,34 +13,6 @@ vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock the custom hooks
const mockStartConversationMutate = vi.fn();
const mockStopConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
useUnifiedStartConversation: () => ({
mutate: mockStartConversationMutate,
}),
}));
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
useUnifiedStopConversation: () => ({
mutate: mockStopConversationMutate,
}),
}));
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({
conversationId: "test-conversation-id",
}),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/query/use-task-polling", () => ({
useTaskPolling: () => ({
isTask: false,
@@ -66,8 +38,12 @@ vi.mock("react-i18next", async () => {
COMMON$SERVER_STOPPED: "Server Stopped",
COMMON$ERROR: "Error",
COMMON$STARTING: "Starting",
COMMON$STOPPING: "Stopping...",
COMMON$STOP_RUNTIME: "Stop Runtime",
COMMON$START_RUNTIME: "Start Runtime",
CONVERSATION$ERROR_STARTING_CONVERSATION:
"Error starting conversation",
CONVERSATION$READY: "Ready",
};
return translations[key] || key;
},
@@ -79,10 +55,6 @@ vi.mock("react-i18next", async () => {
});
describe("ServerStatus", () => {
// Mock functions for handlers
const mockHandleStop = vi.fn();
const mockHandleResumeAgent = vi.fn();
// Helper function to mock agent state with specific state
const mockAgentStore = (agentState: AgentState) => {
vi.mocked(useAgentState).mockReturnValue({
@@ -94,248 +66,91 @@ describe("ServerStatus", () => {
vi.clearAllMocks();
});
it("should render server status with different conversation statuses", () => {
// Mock agent store to return RUNNING state
it("should render server status with RUNNING conversation status", () => {
mockAgentStore(AgentState.RUNNING);
// Test RUNNING status
const { rerender } = renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
// Test STOPPED status
rerender(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should render server status with STOPPED conversation status", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
// Test STARTING status (shows "Running" due to agent state being RUNNING)
rerender(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test null status (shows "Running" due to agent state being RUNNING)
rerender(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should show context menu when clicked with RUNNING status", async () => {
const user = userEvent.setup();
it("should render STARTING status when agent state is LOADING", () => {
mockAgentStore(AgentState.LOADING);
// Mock agent store to return RUNNING state
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Starting")).toBeInTheDocument();
});
it("should render STARTING status when agent state is INIT", () => {
mockAgentStore(AgentState.INIT);
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Starting")).toBeInTheDocument();
});
it("should render ERROR status when agent state is ERROR", () => {
mockAgentStore(AgentState.ERROR);
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Error")).toBeInTheDocument();
});
it("should render STOPPING status when isPausing is true", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
<ServerStatus conversationStatus="RUNNING" isPausing={true} />,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should appear
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
});
it("should show context menu when clicked with STOPPED status", async () => {
const user = userEvent.setup();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should appear
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
});
it("should not show context menu when clicked with other statuses", async () => {
const user = userEvent.setup();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus
conversationStatus="STARTING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should not appear
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("should call stop conversation mutation when stop server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockHandleStop.mockClear();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should call start conversation mutation when start server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockHandleResumeAgent.mockClear();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
});
it("should close context menu after stop server action", async () => {
const user = userEvent.setup();
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus
conversationStatus="RUNNING"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
// Context menu should be closed (handled by the component)
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should close context menu after start server action", async () => {
const user = userEvent.setup();
// Mock agent store to return STOPPED state
mockAgentStore(AgentState.STOPPED);
renderWithProviders(
<ServerStatus
conversationStatus="STOPPED"
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
// Context menu should be closed
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Stopping...")).toBeInTheDocument();
});
it("should handle null conversation status", () => {
// Mock agent store to return RUNNING state
mockAgentStore(AgentState.RUNNING);
renderWithProviders(<ServerStatus conversationStatus={null} />);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should apply custom className", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatus
conversationStatus={null}
handleStop={mockHandleStop}
handleResumeAgent={mockHandleResumeAgent}
/>,
<ServerStatus conversationStatus="RUNNING" className="custom-class" />,
);
const statusText = screen.getByText("Running");
expect(statusText).toBeInTheDocument();
const container = screen.getByTestId("server-status");
expect(container).toHaveClass("custom-class");
});
});
describe("ServerStatusContextMenu", () => {
// Helper function to mock agent state with specific state
const mockAgentStore = (agentState: AgentState) => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
});
};
const defaultProps = {
onClose: vi.fn(),
conversationStatus: "RUNNING" as ConversationStatus,
@@ -346,6 +161,8 @@ describe("ServerStatusContextMenu", () => {
});
it("should render stop server button when status is RUNNING", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -354,11 +171,14 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
expect(screen.getByText("Stop Runtime")).toBeInTheDocument();
});
it("should render start server button when status is STOPPED", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -367,11 +187,14 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
expect(screen.getByText("Start Runtime")).toBeInTheDocument();
});
it("should not render stop server button when onStopServer is not provided", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -379,10 +202,13 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
});
it("should not render start server button when onStartServer is not provided", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -390,12 +216,14 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
});
it("should call onStopServer when stop button is clicked", async () => {
const user = userEvent.setup();
const onStopServer = vi.fn();
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
@@ -414,6 +242,7 @@ describe("ServerStatusContextMenu", () => {
it("should call onStartServer when start button is clicked", async () => {
const user = userEvent.setup();
const onStartServer = vi.fn();
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
@@ -430,6 +259,8 @@ describe("ServerStatusContextMenu", () => {
});
it("should render correct text content for stop server button", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -444,6 +275,8 @@ describe("ServerStatusContextMenu", () => {
});
it("should render correct text content for start server button", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -459,6 +292,7 @@ describe("ServerStatusContextMenu", () => {
it("should call onClose when context menu is closed", () => {
const onClose = vi.fn();
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
@@ -475,6 +309,8 @@ describe("ServerStatusContextMenu", () => {
});
it("should not render any buttons for other conversation statuses", () => {
mockAgentStore(AgentState.RUNNING);
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
@@ -482,6 +318,7 @@ describe("ServerStatusContextMenu", () => {
/>,
);
expect(screen.getByTestId("server-status")).toBeInTheDocument();
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
});
@@ -21,6 +21,7 @@ const mockUseConfig = vi.fn();
const mockUseRepositoryMicroagents = vi.fn();
const mockUseMicroagentManagementConversations = vi.fn();
const mockUseSearchRepositories = vi.fn();
const mockUseCreateConversationAndSubscribeMultiple = vi.fn();
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
@@ -47,6 +48,17 @@ vi.mock("#/hooks/query/use-search-repositories", () => ({
useSearchRepositories: () => mockUseSearchRepositories(),
}));
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackEvent: vi.fn(),
}),
}));
vi.mock("#/hooks/use-create-conversation-and-subscribe-multiple", () => ({
useCreateConversationAndSubscribeMultiple: () =>
mockUseCreateConversationAndSubscribeMultiple(),
}));
describe("MicroagentManagement", () => {
const RouterStub = createRoutesStub([
{
@@ -309,6 +321,16 @@ describe("MicroagentManagement", () => {
isError: false,
});
mockUseCreateConversationAndSubscribeMultiple.mockReturnValue({
createConversationAndSubscribe: vi.fn(({ onSuccessCallback }) => {
// Immediately call the success callback to close the modal
if (onSuccessCallback) {
onSuccessCallback();
}
}),
isPending: false,
});
// Mock the search repositories hook to return repositories with OpenHands suffixes
const mockSearchResults =
getRepositoriesWithOpenHandsSuffix(mockRepositories);
@@ -268,7 +268,7 @@ describe("useWebSocket", () => {
});
// onError handler should have been called
expect(onErrorSpy).toHaveBeenCalledOnce();
expect(onErrorSpy).toHaveBeenCalled();
});
it("should provide sendMessage function to send messages to WebSocket", async () => {
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { getStatusCode, getIndicatorColor, IndicatorColor } from "../status";
import { getStatusCode, getIndicatorColor, IndicatorColor } from "#/utils/status";
import { AgentState } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
@@ -87,6 +87,36 @@ describe("getStatusCode", () => {
// Should return runtime status since no agent state
expect(result).toBe("STATUS$STARTING_RUNTIME");
});
it("should prioritize task ERROR status over websocket CONNECTING state", () => {
// Test case: Task has errored but websocket is still trying to connect
const result = getStatusCode(
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
"CONNECTING", // webSocketStatus (stuck connecting)
null, // conversationStatus
null, // runtimeStatus
AgentState.LOADING, // agentState
"ERROR", // taskStatus (ERROR)
);
// Should return error message, not "Connecting..."
expect(result).toBe(I18nKey.AGENT_STATUS$ERROR_OCCURRED);
});
it("should show Connecting when task is working and websocket is connecting", () => {
// Test case: Task is in progress and websocket is connecting normally
const result = getStatusCode(
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
"CONNECTING", // webSocketStatus
null, // conversationStatus
null, // runtimeStatus
AgentState.LOADING, // agentState
"WORKING", // taskStatus (in progress)
);
// Should show connecting message since task hasn't errored
expect(result).toBe(I18nKey.CHAT_INTERFACE$CONNECTING);
});
});
describe("getIndicatorColor", () => {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.60.0",
"version": "0.61.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.60.0",
"version": "0.61.0",
"dependencies": {
"@heroui/react": "^2.8.4",
"@heroui/use-infinite-scroll": "^2.2.11",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.60.0",
"version": "0.61.0",
"private": true,
"type": "module",
"engines": {
@@ -11,7 +11,6 @@ import type {
V1AppConversationStartTask,
V1AppConversationStartTaskPage,
V1AppConversation,
V1SandboxInfo,
} from "./v1-conversation-service.types";
class V1ConversationService {
@@ -213,36 +212,6 @@ class V1ConversationService {
return data;
}
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*
* @param sandboxId The sandbox ID to pause
* @returns Success response
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*
* @param sandboxId The sandbox ID to resume
* @returns Success response
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 app conversations by their IDs
* Returns null for any missing conversations
@@ -269,32 +238,6 @@ 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}
@@ -345,24 +288,6 @@ class V1ConversationService {
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;
@@ -1,5 +1,6 @@
import { ConversationTrigger } from "../open-hands.types";
import { Provider } from "#/types/settings";
import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types";
// V1 API Types for requests
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
@@ -64,14 +65,7 @@ export interface V1AppConversationStartTaskPage {
next_page_id: string | null;
}
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
export type V1AgentExecutionStatus =
export type V1ConversationExecutionStatus =
| "RUNNING"
| "AWAITING_USER_INPUT"
| "AWAITING_USER_CONFIRMATION"
@@ -94,22 +88,7 @@ export interface V1AppConversation {
created_at: string;
updated_at: string;
sandbox_status: V1SandboxStatus;
agent_status: V1AgentExecutionStatus | null;
execution_status: V1ConversationExecutionStatus | null;
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;
}
@@ -5,6 +5,7 @@ import type {
ConfirmationResponseRequest,
ConfirmationResponseResponse,
} from "./event-service.types";
import { openHands } from "../open-hands-axios";
class EventService {
/**
@@ -36,6 +37,14 @@ class EventService {
return data;
}
}
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 EventService;
@@ -0,0 +1,52 @@
// sandbox-service.api.ts
// This file contains API methods for /api/v1/sandboxes endpoints.
import { openHands } from "../open-hands-axios";
import type { V1SandboxInfo } from "./sandbox-service.types";
export class SandboxService {
/**
* Pause a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/pause endpoint
*/
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/pause`,
{},
);
return data;
}
/**
* Resume a V1 sandbox
* Calls the /api/v1/sandboxes/{id}/resume endpoint
*/
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
const { data } = await openHands.post<{ success: boolean }>(
`/api/v1/sandboxes/${sandboxId}/resume`,
{},
);
return data;
}
/**
* Batch get V1 sandboxes by their IDs
* Returns null for any missing sandboxes
*/
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;
}
}
@@ -0,0 +1,24 @@
// sandbox-service.types.ts
// This file contains types for Sandbox API.
export type V1SandboxStatus =
| "MISSING"
| "STARTING"
| "RUNNING"
| "STOPPED"
| "PAUSED";
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;
}
@@ -97,24 +97,29 @@ export function ChatInterface() {
const isV1Conversation = conversation?.conversation_version === "V1";
// Instantly scroll to bottom when history loading completes
const prevLoadingHistoryRef = React.useRef(
// Track when we should show V1 messages (after DOM has rendered)
const [showV1Messages, setShowV1Messages] = React.useState(false);
const prevV1LoadingRef = React.useRef(
conversationWebSocket?.isLoadingHistory,
);
// Wait for DOM to render before showing V1 messages
React.useEffect(() => {
const wasLoading = prevLoadingHistoryRef.current;
const wasLoading = prevV1LoadingRef.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",
if (wasLoading && !isLoading) {
// Loading just finished - wait for next frame to ensure DOM is ready
requestAnimationFrame(() => {
setShowV1Messages(true);
});
} else if (isLoading) {
// Reset when loading starts
setShowV1Messages(false);
}
prevLoadingHistoryRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory, scrollRef]);
prevV1LoadingRef.current = isLoading;
}, [conversationWebSocket?.isLoadingHistory]);
// Filter V0 events
const v0Events = storeEvents
@@ -252,7 +257,7 @@ export function ChatInterface() {
</div>
)}
{conversationWebSocket?.isLoadingHistory &&
{(conversationWebSocket?.isLoadingHistory || !showV1Messages) &&
isV1Conversation &&
!isTask && (
<div className="flex justify-center">
@@ -269,7 +274,7 @@ export function ChatInterface() {
/>
)}
{!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
{showV1Messages && v1UserEventsExist && (
<V1Messages messages={v1UiEvents} allEvents={v1FullEvents} />
)}
</div>
@@ -1,11 +1,7 @@
import { ConversationStatus } from "#/types/conversation-status";
import { ServerStatus } from "#/components/features/controls/server-status";
import { AgentStatus } from "#/components/features/controls/agent-status";
import { Tools } from "../../controls/tools";
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useSendMessage } from "#/hooks/use-send-message";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
@@ -14,32 +10,23 @@ import { useV1PauseConversation } from "#/hooks/mutation/use-v1-pause-conversati
import { useV1ResumeConversation } from "#/hooks/mutation/use-v1-resume-conversation";
interface ChatInputActionsProps {
conversationStatus: ConversationStatus | null;
disabled: boolean;
handleResumeAgent: () => void;
}
export function ChatInputActions({
conversationStatus,
disabled,
handleResumeAgent,
}: ChatInputActionsProps) {
const { data: conversation } = useActiveConversation();
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
const resumeConversationSandboxMutation =
useUnifiedResumeConversationSandbox();
const v1PauseConversationMutation = useV1PauseConversation();
const v1ResumeConversationMutation = useV1ResumeConversation();
const { conversationId } = useConversationId();
const { providers } = useUserProviders();
const { send } = useSendMessage();
const isV1Conversation = conversation?.conversation_version === "V1";
const handleStopClick = () => {
pauseConversationSandboxMutation.mutate({ conversationId });
};
const handlePauseAgent = () => {
if (isV1Conversation) {
// V1: Pause the conversation (agent execution)
@@ -62,10 +49,6 @@ export function ChatInputActions({
handleResumeAgent();
};
const handleStartClick = () => {
resumeConversationSandboxMutation.mutate({ conversationId, providers });
};
const isPausing =
pauseConversationSandboxMutation.isPending ||
v1PauseConversationMutation.isPending;
@@ -74,12 +57,6 @@ export function ChatInputActions({
<div className="w-full flex items-center justify-between">
<div className="flex items-center gap-1">
<Tools />
<ServerStatus
conversationStatus={conversationStatus}
isPausing={isPausing}
handleStop={handleStopClick}
handleResumeAgent={handleStartClick}
/>
</div>
<AgentStatus
className="ml-2 md:ml-3"
@@ -1,9 +1,10 @@
import React from "react";
import { ConversationStatus } from "#/types/conversation-status";
import { DragOver } from "../drag-over";
import { UploadedFiles } from "../uploaded-files";
import { ChatInputRow } from "./chat-input-row";
import { ChatInputActions } from "./chat-input-actions";
import { useConversationStore } from "#/state/conversation-store";
import { cn } from "#/utils/utils";
interface ChatInputContainerProps {
chatContainerRef: React.RefObject<HTMLDivElement | null>;
@@ -11,7 +12,6 @@ interface ChatInputContainerProps {
disabled: boolean;
showButton: boolean;
buttonClassName: string;
conversationStatus: ConversationStatus | null;
chatInputRef: React.RefObject<HTMLDivElement | null>;
handleFileIconClick: (isDisabled: boolean) => void;
handleSubmit: () => void;
@@ -32,7 +32,6 @@ export function ChatInputContainer({
disabled,
showButton,
buttonClassName,
conversationStatus,
chatInputRef,
handleFileIconClick,
handleSubmit,
@@ -46,10 +45,17 @@ export function ChatInputContainer({
onFocus,
onBlur,
}: ChatInputContainerProps) {
const conversationMode = useConversationStore(
(state) => state.conversationMode,
);
return (
<div
ref={chatContainerRef}
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
className={cn(
"bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full",
conversationMode === "plan" && "border border-[#597FF4]",
)}
onDragOver={(e) => onDragOver(e, disabled)}
onDragLeave={(e) => onDragLeave(e, disabled)}
onDrop={(e) => onDrop(e, disabled)}
@@ -74,7 +80,6 @@ export function ChatInputContainer({
/>
<ChatInputActions
conversationStatus={conversationStatus}
disabled={disabled}
handleResumeAgent={handleResumeAgent}
/>
@@ -137,7 +137,6 @@ export function CustomChatInput({
disabled={isDisabled}
showButton={showButton}
buttonClassName={buttonClassName}
conversationStatus={conversationStatus}
chatInputRef={chatInputRef}
handleFileIconClick={handleFileIconClick}
handleSubmit={handleSubmit}
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import PRIcon from "#/icons/u-pr.svg?react";
import { cn, getCreatePRPrompt } from "#/utils/utils";
import { useUserProviders } from "#/hooks/use-user-providers";
import { I18nKey } from "#/i18n/declaration";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
interface GitControlBarPrButtonProps {
onSuggestionsClick: (value: string) => void;
@@ -20,6 +20,7 @@ export function GitControlBarPrButton({
isConversationReady = true,
}: GitControlBarPrButtonProps) {
const { t } = useTranslation();
const { trackCreatePrButtonClick } = useTracking();
const { providers } = useUserProviders();
@@ -28,7 +29,7 @@ export function GitControlBarPrButton({
providersAreSet && hasRepository && isConversationReady;
const handlePrClick = () => {
posthog.capture("create_pr_button_clicked");
trackCreatePrButtonClick();
onSuggestionsClick(getCreatePRPrompt(currentGitProvider));
};
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import ArrowDownIcon from "#/icons/u-arrow-down.svg?react";
import { cn, getGitPullPrompt } from "#/utils/utils";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { I18nKey } from "#/i18n/declaration";
import { useTracking } from "#/hooks/use-tracking";
interface GitControlBarPullButtonProps {
onSuggestionsClick: (value: string) => void;
@@ -16,6 +16,7 @@ export function GitControlBarPullButton({
isConversationReady = true,
}: GitControlBarPullButtonProps) {
const { t } = useTranslation();
const { trackPullButtonClick } = useTracking();
const { data: conversation } = useActiveConversation();
const { providers } = useUserProviders();
@@ -26,7 +27,7 @@ export function GitControlBarPullButton({
providersAreSet && hasRepository && isConversationReady;
const handlePullClick = () => {
posthog.capture("pull_button_clicked");
trackPullButtonClick();
onSuggestionsClick(getGitPullPrompt());
};
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import posthog from "posthog-js";
import ArrowUpIcon from "#/icons/u-arrow-up.svg?react";
import { cn, getGitPushPrompt } from "#/utils/utils";
import { useUserProviders } from "#/hooks/use-user-providers";
import { I18nKey } from "#/i18n/declaration";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
interface GitControlBarPushButtonProps {
onSuggestionsClick: (value: string) => void;
@@ -20,6 +20,7 @@ export function GitControlBarPushButton({
isConversationReady = true,
}: GitControlBarPushButtonProps) {
const { t } = useTranslation();
const { trackPushButtonClick } = useTracking();
const { providers } = useUserProviders();
@@ -28,7 +29,7 @@ export function GitControlBarPushButton({
providersAreSet && hasRepository && isConversationReady;
const handlePushClick = () => {
posthog.capture("push_button_clicked");
trackPushButtonClick();
onSuggestionsClick(getGitPushPrompt(currentGitProvider));
};
@@ -13,6 +13,7 @@ import { useConversationStore } from "#/state/conversation-store";
import CircleErrorIcon from "#/icons/circle-error.svg?react";
import { useAgentState } from "#/hooks/use-agent-state";
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
export interface AgentStatusProps {
className?: string;
@@ -35,6 +36,7 @@ export function AgentStatus({
const { curStatusMessage } = useStatusStore();
const webSocketStatus = useUnifiedWebSocketStatus();
const { data: conversation } = useActiveConversation();
const { taskStatus } = useTaskPolling();
const statusCode = getStatusCode(
curStatusMessage,
@@ -42,25 +44,34 @@ export function AgentStatus({
conversation?.status || null,
conversation?.runtime_status || null,
curAgentState,
taskStatus,
);
const isTaskLoading =
taskStatus && taskStatus !== "ERROR" && taskStatus !== "READY";
const shouldShownAgentLoading =
isPausing ||
curAgentState === AgentState.INIT ||
curAgentState === AgentState.LOADING ||
webSocketStatus === "CONNECTING";
(webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") ||
isTaskLoading;
const shouldShownAgentError =
curAgentState === AgentState.ERROR ||
curAgentState === AgentState.RATE_LIMITED;
curAgentState === AgentState.RATE_LIMITED ||
webSocketStatus === "DISCONNECTED" ||
taskStatus === "ERROR";
const shouldShownAgentStop = curAgentState === AgentState.RUNNING;
const shouldShownAgentResume = curAgentState === AgentState.STOPPED;
const shouldShownAgentResume =
curAgentState === AgentState.STOPPED || curAgentState === AgentState.PAUSED;
// Update global state when agent loading condition changes
useEffect(() => {
setShouldShownAgentLoading(shouldShownAgentLoading);
if (shouldShownAgentLoading)
setShouldShownAgentLoading(shouldShownAgentLoading);
}, [shouldShownAgentLoading, setShouldShownAgentLoading]);
return (
@@ -13,7 +13,7 @@ export function ServerStatusContextMenuIconText({
}: ServerStatusContextMenuIconTextProps) {
return (
<button
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
className="flex items-center justify-between p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
onClick={onClick}
data-testid={testId}
type="button"
@@ -6,6 +6,9 @@ import { ConversationStatus } from "#/types/conversation-status";
import StopCircleIcon from "#/icons/stop-circle.svg?react";
import PlayCircleIcon from "#/icons/play-circle.svg?react";
import { ServerStatusContextMenuIconText } from "./server-status-context-menu-icon-text";
import { ServerStatus } from "./server-status";
import { Divider } from "#/ui/divider";
import { cn } from "#/utils/utils";
interface ServerStatusContextMenuProps {
onClose: () => void;
@@ -13,6 +16,8 @@ interface ServerStatusContextMenuProps {
onStartServer?: (event: React.MouseEvent<HTMLButtonElement>) => void;
conversationStatus: ConversationStatus | null;
position?: "top" | "bottom";
className?: string;
isPausing?: boolean;
}
export function ServerStatusContextMenu({
@@ -21,10 +26,15 @@ export function ServerStatusContextMenu({
onStartServer,
conversationStatus,
position = "top",
className = "",
isPausing = false,
}: ServerStatusContextMenuProps) {
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const shouldActionShown =
conversationStatus === "RUNNING" || conversationStatus === "STOPPED";
return (
<ContextMenu
ref={ref}
@@ -32,24 +42,36 @@ export function ServerStatusContextMenu({
position={position}
alignment="left"
size="default"
className="left-2 w-fit min-w-max"
className={cn("left-2 w-fit min-w-42", className)}
>
{conversationStatus === "RUNNING" && onStopServer && (
<ServerStatusContextMenuIconText
icon={<StopCircleIcon width={18} height={18} />}
text={t(I18nKey.COMMON$STOP_RUNTIME)}
onClick={onStopServer}
testId="stop-server-button"
/>
)}
<ServerStatus
conversationStatus={conversationStatus}
isPausing={isPausing}
className="py-1"
/>
{conversationStatus === "STOPPED" && onStartServer && (
<ServerStatusContextMenuIconText
icon={<PlayCircleIcon width={18} height={18} />}
text={t(I18nKey.COMMON$START_RUNTIME)}
onClick={onStartServer}
testId="start-server-button"
/>
{shouldActionShown && (
<>
<Divider />
{conversationStatus === "RUNNING" && onStopServer && (
<ServerStatusContextMenuIconText
icon={<StopCircleIcon width={18} height={18} />}
text={t(I18nKey.COMMON$STOP_RUNTIME)}
onClick={onStopServer}
testId="stop-server-button"
/>
)}
{conversationStatus === "STOPPED" && onStartServer && (
<ServerStatusContextMenuIconText
icon={<PlayCircleIcon width={18} height={18} />}
text={t(I18nKey.COMMON$START_RUNTIME)}
onClick={onStartServer}
testId="start-server-button"
/>
)}
</>
)}
</ContextMenu>
);
@@ -1,30 +1,23 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
import { I18nKey } from "#/i18n/declaration";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
import { ServerStatusContextMenu } from "./server-status-context-menu";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { getStatusColor } from "#/utils/utils";
export interface ServerStatusProps {
className?: string;
conversationStatus: ConversationStatus | null;
isPausing?: boolean;
handleStop: () => void;
handleResumeAgent: () => void;
}
export function ServerStatus({
className = "",
conversationStatus,
isPausing = false,
handleStop,
handleResumeAgent,
}: ServerStatusProps) {
const [showContextMenu, setShowContextMenu] = useState(false);
const { curAgentState } = useAgentState();
const { t } = useTranslation();
const { isTask, taskStatus, taskDetail } = useTaskPolling();
@@ -34,34 +27,15 @@ export function ServerStatus({
const isStopStatus = conversationStatus === "STOPPED";
// Get the appropriate color based on agent status
const getStatusColor = (): string => {
// Show pausing status
if (isPausing) {
return "#FFD600";
}
const statusColor = getStatusColor({
isPausing,
isTask,
taskStatus,
isStartingStatus,
isStopStatus,
curAgentState,
});
// Show task status if we're polling a task
if (isTask && taskStatus) {
if (taskStatus === "ERROR") {
return "#FF684E";
}
return "#FFD600";
}
if (isStartingStatus) {
return "#FFD600";
}
if (isStopStatus) {
return "#ffffff";
}
if (curAgentState === AgentState.ERROR) {
return "#FF684E";
}
return "#BCFF8C";
};
// Get the appropriate status text based on agent status
const getStatusText = (): string => {
// Show pausing status
if (isPausing) {
@@ -100,49 +74,14 @@ export function ServerStatus({
return t(I18nKey.COMMON$RUNNING);
};
const handleClick = () => {
if (conversationStatus === "RUNNING" || conversationStatus === "STOPPED") {
setShowContextMenu(true);
}
};
const handleCloseContextMenu = () => {
setShowContextMenu(false);
};
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
handleStop();
setShowContextMenu(false);
};
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
handleResumeAgent();
setShowContextMenu(false);
};
const statusColor = getStatusColor();
const statusText = getStatusText();
return (
<div className={`relative ${className}`}>
<div className="flex items-center cursor-pointer" onClick={handleClick}>
<div className={className} data-testid="server-status">
<div className="flex items-center">
<DebugStackframeDot className="w-6 h-6" color={statusColor} />
<span className="text-[11px] text-white font-normal leading-5">
{statusText}
</span>
<span className="text-[13px] text-white font-normal">{statusText}</span>
</div>
{showContextMenu && (
<ServerStatusContextMenu
onClose={handleCloseContextMenu}
onStopServer={handleStopServer}
onStartServer={handleStartServer}
conversationStatus={conversationStatus}
position="top"
/>
)}
</div>
);
}
@@ -0,0 +1,79 @@
import React from "react";
import { useParams } from "react-router";
import { useAgentState } from "#/hooks/use-agent-state";
import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
import { useUserProviders } from "#/hooks/use-user-providers";
import { getStatusColor } from "#/utils/utils";
import { AgentState } from "#/types/agent-state";
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
import { ServerStatusContextMenu } from "../controls/server-status-context-menu";
import { ConversationName } from "./conversation-name";
export function ConversationNameWithStatus() {
const { conversationId } = useParams<{ conversationId: string }>();
const { data: conversation } = useActiveConversation();
const { curAgentState } = useAgentState();
const { isTask, taskStatus } = useTaskPolling();
const { mutate: pauseConversationSandbox } =
useUnifiedPauseConversationSandbox();
const { mutate: resumeConversationSandbox } =
useUnifiedResumeConversationSandbox();
const { providers } = useUserProviders();
const isStartingStatus =
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
const isStopStatus = conversation?.status === "STOPPED";
const statusColor = getStatusColor({
isPausing: false,
isTask,
taskStatus,
isStartingStatus,
isStopStatus,
curAgentState,
});
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (conversationId) {
pauseConversationSandbox({ conversationId });
}
};
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (conversationId) {
resumeConversationSandbox({ conversationId, providers });
}
};
return (
<div className="flex items-center">
<div className="group relative">
<DebugStackframeDot
className="ml-[3.5px] w-6 h-6 cursor-pointer"
color={statusColor}
/>
<ServerStatusContextMenu
onClose={() => {}}
onStopServer={
conversation?.status === "RUNNING" ? handleStopServer : undefined
}
onStartServer={
conversation?.status === "STOPPED" ? handleStartServer : undefined
}
conversationStatus={conversation?.status ?? null}
position="bottom"
className="opacity-0 invisible pointer-events-none group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto bottom-full left-0 mt-0 min-h-fit"
isPausing={false}
/>
</div>
<ConversationName />
</div>
);
}
@@ -124,7 +124,7 @@ export function ConversationName() {
return (
<>
<div
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-3.5"
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-1"
data-testid="conversation-name"
>
{titleMode === "edit" ? (
@@ -15,6 +15,7 @@ const EditorTab = lazy(() => import("#/routes/changes-tab"));
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
const ServedTab = lazy(() => import("#/routes/served-tab"));
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
const PlannerTab = lazy(() => import("#/routes/planner-tab"));
export function ConversationTabContent() {
const { selectedTab, shouldShownAgentLoading } = useConversationStore();
@@ -28,6 +29,7 @@ export function ConversationTabContent() {
const isServedActive = selectedTab === "served";
const isVSCodeActive = selectedTab === "vscode";
const isTerminalActive = selectedTab === "terminal";
const isPlannerActive = selectedTab === "planner";
// Define tab configurations
const tabs = [
@@ -44,6 +46,11 @@ export function ConversationTabContent() {
component: Terminal,
isActive: isTerminalActive,
},
{
key: "planner",
component: PlannerTab,
isActive: isPlannerActive,
},
];
const conversationTabTitle = useMemo(() => {
@@ -62,6 +69,9 @@ export function ConversationTabContent() {
if (isTerminalActive) {
return t(I18nKey.COMMON$TERMINAL);
}
if (isPlannerActive) {
return t(I18nKey.COMMON$PLANNER);
}
return "";
}, [
isEditorActive,
@@ -69,6 +79,7 @@ export function ConversationTabContent() {
isServedActive,
isVSCodeActive,
isTerminalActive,
isPlannerActive,
]);
if (shouldShownAgentLoading) {
@@ -5,12 +5,16 @@ type ConversationTabNavProps = {
icon: ComponentType<{ className: string }>;
onClick(): void;
isActive?: boolean;
label?: string;
className?: string;
};
export function ConversationTabNav({
icon: Icon,
onClick,
isActive,
label,
className,
}: ConversationTabNavProps) {
return (
<button
@@ -19,18 +23,21 @@ export function ConversationTabNav({
onClick();
}}
className={cn(
"p-1 rounded-md cursor-pointer",
"flex items-center gap-2 rounded-md cursor-pointer",
"pl-1.5 pr-2 py-1",
"text-[#9299AA] bg-[#0D0F11]",
isActive && "bg-[#25272D] text-white",
isActive
? "hover:text-white hover:bg-tertiary"
: "hover:text-white hover:bg-[#0D0F11]",
isActive
? "focus-within:text-white focus-within:bg-tertiary"
: "focus-within:text-[#9299AA] focus-within:bg-[#0D0F11]",
isActive ? "focus-within:text-white" : "focus-within:text-[#9299AA]",
className,
)}
>
<Icon className={cn("w-5 h-5 text-inherit")} />
<Icon className={cn("w-5 h-5 text-inherit flex-shrink-0")} />
{isActive && label && (
<span className="text-sm font-medium whitespace-nowrap">{label}</span>
)}
</button>
);
}
@@ -0,0 +1,116 @@
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "@uidotdev/usehooks";
import { ContextMenu } from "#/ui/context-menu";
import { ContextMenuListItem } from "../../context-menu/context-menu-list-item";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { I18nKey } from "#/i18n/declaration";
import TerminalIcon from "#/icons/terminal.svg?react";
import GlobeIcon from "#/icons/globe.svg?react";
import ServerIcon from "#/icons/server.svg?react";
import GitChanges from "#/icons/git_changes.svg?react";
import VSCodeIcon from "#/icons/vscode.svg?react";
import PillIcon from "#/icons/pill.svg?react";
import PillFillIcon from "#/icons/pill-fill.svg?react";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
interface ConversationTabsContextMenuProps {
isOpen: boolean;
onClose: () => void;
}
export function ConversationTabsContextMenu({
isOpen,
onClose,
}: ConversationTabsContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const { t } = useTranslation();
const [unpinnedTabs, setUnpinnedTabs] = useLocalStorage<string[]>(
"conversation-unpinned-tabs",
[],
);
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const tabConfig = [
{
tab: "editor",
icon: GitChanges,
i18nKey: I18nKey.COMMON$CHANGES,
},
{
tab: "vscode",
icon: VSCodeIcon,
i18nKey: I18nKey.COMMON$CODE,
},
{
tab: "terminal",
icon: TerminalIcon,
i18nKey: I18nKey.COMMON$TERMINAL,
},
{
tab: "served",
icon: ServerIcon,
i18nKey: I18nKey.COMMON$APP,
},
{
tab: "browser",
icon: GlobeIcon,
i18nKey: I18nKey.COMMON$BROWSER,
},
];
if (shouldUsePlanningAgent) {
tabConfig.unshift({
tab: "planner",
icon: LessonPlanIcon,
i18nKey: I18nKey.COMMON$PLANNER,
});
}
if (!isOpen) return null;
const handleTabClick = (tab: string) => {
const tabString = tab;
if (unpinnedTabs.includes(tabString)) {
// Tab is unpinned, pin it (remove from unpinned list)
setUnpinnedTabs(
unpinnedTabs.filter((unpinnedTab) => unpinnedTab !== tabString),
);
} else {
// Tab is pinned, unpin it (add to unpinned list)
setUnpinnedTabs([...unpinnedTabs, tabString]);
}
};
const isTabPinned = (tab: string) => !unpinnedTabs.includes(tab as string);
return (
<ContextMenu
testId="conversation-tabs-context-menu"
ref={ref}
alignment="right"
position="bottom"
className="mt-2 w-fit z-[9999]"
>
{tabConfig.map(({ tab, icon: Icon, i18nKey }) => {
const pinned = isTabPinned(tab);
return (
<ContextMenuListItem
key={tab}
onClick={() => handleTabClick(tab)}
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
>
<Icon className="w-4 h-4" />
<span className="text-white text-sm">{t(i18nKey)}</span>
{pinned ? (
<PillFillIcon className="w-7 h-7 ml-auto flex-shrink-0 text-white -mr-[5px]" />
) : (
<PillIcon className="w-4.5 h-4.5 ml-auto flex-shrink-0 text-white" />
)}
</ContextMenuListItem>
);
})}
</ContextMenu>
);
}
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocalStorage } from "@uidotdev/usehooks";
import TerminalIcon from "#/icons/terminal.svg?react";
@@ -6,6 +6,8 @@ import GlobeIcon from "#/icons/globe.svg?react";
import ServerIcon from "#/icons/server.svg?react";
import GitChanges from "#/icons/git_changes.svg?react";
import VSCodeIcon from "#/icons/vscode.svg?react";
import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { cn } from "#/utils/utils";
import { ConversationTabNav } from "./conversation-tab-nav";
import { ChatActionTooltip } from "../../chat/chat-action-tooltip";
@@ -15,6 +17,8 @@ import {
useConversationStore,
type ConversationTab,
} from "#/state/conversation-store";
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
export function ConversationTabs() {
const {
@@ -24,6 +28,8 @@ export function ConversationTabs() {
setSelectedTab,
} = useConversationStore();
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Persist selectedTab and isRightPanelShown in localStorage
const [persistedSelectedTab, setPersistedSelectedTab] =
useLocalStorage<ConversationTab | null>(
@@ -34,6 +40,13 @@ export function ConversationTabs() {
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
useLocalStorage<boolean>("conversation-right-panel-shown", true);
const [persistedUnpinnedTabs] = useLocalStorage<string[]>(
"conversation-unpinned-tabs",
[],
);
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
const onTabChange = (value: ConversationTab | null) => {
setSelectedTab(value);
// Persist the selected tab to localStorage
@@ -87,42 +100,70 @@ export function ConversationTabs() {
const tabs = [
{
tabValue: "editor",
isActive: isTabActive("editor"),
icon: GitChanges,
onClick: () => onTabSelected("editor"),
tooltipContent: t(I18nKey.COMMON$CHANGES),
tooltipAriaLabel: t(I18nKey.COMMON$CHANGES),
label: t(I18nKey.COMMON$CHANGES),
},
{
tabValue: "vscode",
isActive: isTabActive("vscode"),
icon: VSCodeIcon,
onClick: () => onTabSelected("vscode"),
tooltipContent: <VSCodeTooltipContent />,
tooltipAriaLabel: t(I18nKey.COMMON$CODE),
label: t(I18nKey.COMMON$CODE),
},
{
tabValue: "terminal",
isActive: isTabActive("terminal"),
icon: TerminalIcon,
onClick: () => onTabSelected("terminal"),
tooltipContent: t(I18nKey.COMMON$TERMINAL),
tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL),
label: t(I18nKey.COMMON$TERMINAL),
className: "pl-2",
},
{
tabValue: "served",
isActive: isTabActive("served"),
icon: ServerIcon,
onClick: () => onTabSelected("served"),
tooltipContent: t(I18nKey.COMMON$APP),
tooltipAriaLabel: t(I18nKey.COMMON$APP),
label: t(I18nKey.COMMON$APP),
},
{
tabValue: "browser",
isActive: isTabActive("browser"),
icon: GlobeIcon,
onClick: () => onTabSelected("browser"),
tooltipContent: t(I18nKey.COMMON$BROWSER),
tooltipAriaLabel: t(I18nKey.COMMON$BROWSER),
label: t(I18nKey.COMMON$BROWSER),
},
];
if (shouldUsePlanningAgent) {
tabs.unshift({
tabValue: "planner",
isActive: isTabActive("planner"),
icon: LessonPlanIcon,
onClick: () => onTabSelected("planner"),
tooltipContent: t(I18nKey.COMMON$PLANNER),
tooltipAriaLabel: t(I18nKey.COMMON$PLANNER),
label: t(I18nKey.COMMON$PLANNER),
});
}
// Filter out unpinned tabs
const visibleTabs = tabs.filter(
(tab) => !persistedUnpinnedTabs.includes(tab.tabValue),
);
return (
<div
className={cn(
@@ -130,9 +171,17 @@ export function ConversationTabs() {
"flex flex-row justify-start lg:justify-end items-center gap-4.5",
)}
>
{tabs.map(
{visibleTabs.map(
(
{ icon, onClick, isActive, tooltipContent, tooltipAriaLabel },
{
icon,
onClick,
isActive,
tooltipContent,
tooltipAriaLabel,
label,
className,
},
index,
) => (
<ChatActionTooltip
@@ -144,10 +193,29 @@ export function ConversationTabs() {
icon={icon}
onClick={onClick}
isActive={isActive}
label={label}
className={className}
/>
</ChatActionTooltip>
),
)}
<div className="relative">
<button
type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={cn(
"p-1 pl-0 rounded-md cursor-pointer",
"text-[#9299AA] bg-[#0D0F11]",
)}
aria-label={t(I18nKey.COMMON$MORE_OPTIONS)}
>
<ThreeDotsVerticalIcon className={cn("w-5 h-5 text-inherit")} />
</button>
<ConversationTabsContextMenu
isOpen={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
/>
</div>
</div>
);
}
@@ -131,7 +131,7 @@ export function RepositorySelectionForm({
onBranchSelect={handleBranchSelection}
defaultBranch={defaultBranch}
placeholder="Select branch..."
className="max-w-[500px]"
className="max-w-full"
disabled={!selectedRepository || isLoadingSettings}
/>
);
@@ -11,6 +11,7 @@ import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
import { useAuthUrl } from "#/hooks/use-auth-url";
import { GetConfigResponse } from "#/api/option-service/option.types";
import { Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
interface AuthModalProps {
githubAuthUrl: string | null;
@@ -26,6 +27,7 @@ export function AuthModal({
providersConfigured,
}: AuthModalProps) {
const { t } = useTranslation();
const { trackLoginButtonClick } = useTracking();
const gitlabAuthUrl = useAuthUrl({
appMode: appMode || null,
@@ -47,6 +49,7 @@ export function AuthModal({
const handleGitHubAuth = () => {
if (githubAuthUrl) {
trackLoginButtonClick({ provider: "github" });
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = githubAuthUrl;
}
@@ -54,6 +57,7 @@ export function AuthModal({
const handleGitLabAuth = () => {
if (gitlabAuthUrl) {
trackLoginButtonClick({ provider: "gitlab" });
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = gitlabAuthUrl;
}
@@ -61,6 +65,7 @@ export function AuthModal({
const handleBitbucketAuth = () => {
if (bitbucketAuthUrl) {
trackLoginButtonClick({ provider: "bitbucket" });
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = bitbucketAuthUrl;
}
@@ -68,6 +73,7 @@ export function AuthModal({
const handleEnterpriseSsoAuth = () => {
if (enterpriseSsoUrl) {
trackLoginButtonClick({ provider: "enterprise_sso" });
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = enterpriseSsoUrl;
}
@@ -28,7 +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";
import EventService from "#/api/event-service/event-service.api";
// eslint-disable-next-line @typescript-eslint/naming-convention
export type V1_WebSocketConnectionState =
@@ -67,7 +67,7 @@ export function ConversationWebSocketProvider({
const { addEvent } = useEventStore();
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
const { setAgentStatus } = useV1ConversationStateStore();
const { setExecutionStatus } = useV1ConversationStateStore();
const { appendInput, appendOutput } = useCommandStore();
// History loading state
@@ -154,10 +154,10 @@ export function ConversationWebSocketProvider({
// TODO: Tests
if (isConversationStateUpdateEvent(event)) {
if (isFullStateConversationStateUpdateEvent(event)) {
setAgentStatus(event.value.agent_status);
setExecutionStatus(event.value.execution_status);
}
if (isAgentStatusConversationStateUpdateEvent(event)) {
setAgentStatus(event.value);
setExecutionStatus(event.value);
}
}
@@ -184,7 +184,7 @@ export function ConversationWebSocketProvider({
removeOptimisticUserMessage,
queryClient,
conversationId,
setAgentStatus,
setExecutionStatus,
appendInput,
appendOutput,
],
@@ -211,8 +211,7 @@ export function ConversationWebSocketProvider({
// Fetch expected event count for history loading detection
if (conversationId) {
try {
const count =
await V1ConversationService.getEventCount(conversationId);
const count = await EventService.getEventCount(conversationId);
setExpectedEventCount(count);
// If no events expected, mark as loaded immediately
@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
import { Provider } from "#/types/settings";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
/**
* Gets the conversation version from the cache
@@ -48,7 +49,7 @@ const fetchV1ConversationData = async (
*/
export const pauseV1ConversationSandbox = async (conversationId: string) => {
const { sandboxId } = await fetchV1ConversationData(conversationId);
return V1ConversationService.pauseSandbox(sandboxId);
return SandboxService.pauseSandbox(sandboxId);
};
/**
@@ -75,7 +76,7 @@ export const stopV0Conversation = async (conversationId: string) =>
*/
export const resumeV1ConversationSandbox = async (conversationId: string) => {
const { sandboxId } = await fetchV1ConversationData(conversationId);
return V1ConversationService.resumeSandbox(sandboxId);
return SandboxService.resumeSandbox(sandboxId);
};
/**
@@ -1,9 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { Provider, ProviderToken } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
export const useAddGitProviders = () => {
const queryClient = useQueryClient();
const { trackGitProviderConnected } = useTracking();
return useMutation({
mutationFn: ({
@@ -11,7 +13,18 @@ export const useAddGitProviders = () => {
}: {
providers: Record<Provider, ProviderToken>;
}) => SecretsService.addGitProvider(providers),
onSuccess: async () => {
onSuccess: async (_, { providers }) => {
// Track which providers were connected (filter out empty tokens)
const connectedProviders = Object.entries(providers)
.filter(([, value]) => value.token && value.token.trim() !== "")
.map(([key]) => key);
if (connectedProviders.length > 0) {
trackGitProviderConnected({
providers: connectedProviders,
});
}
await queryClient.invalidateQueries({ queryKey: ["settings"] });
},
meta: {
@@ -1,11 +1,11 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import posthog from "posthog-js";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { SuggestedTask } from "#/utils/types";
import { Provider } from "#/types/settings";
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
import { useTracking } from "#/hooks/use-tracking";
interface CreateConversationVariables {
query?: string;
@@ -31,6 +31,7 @@ interface CreateConversationResponse extends Partial<Conversation> {
export const useCreateConversation = () => {
const queryClient = useQueryClient();
const { trackConversationCreated } = useTracking();
return useMutation({
mutationKey: ["create-conversation"],
@@ -86,12 +87,11 @@ export const useCreateConversation = () => {
is_v1: false,
};
},
onSuccess: async (_, { query, repository }) => {
posthog.capture("initial_query_submitted", {
entry_point: "task_form",
query_character_length: query?.length,
has_repository: !!repository,
onSuccess: async (_, { repository }) => {
trackConversationCreated({
hasRepository: !!repository,
});
queryClient.removeQueries({
queryKey: ["user", "conversations"],
});
@@ -4,6 +4,7 @@ import ConversationService from "#/api/conversation-service/conversation-service
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
export interface BatchFeedbackData {
exists: boolean;
@@ -25,13 +26,20 @@ export const getFeedbackExistsQueryKey = (
export const useBatchFeedback = () => {
const { conversationId } = useConversationId();
const { data: config } = useConfig();
const { data: conversation } = useActiveConversation();
const queryClient = useQueryClient();
const runtimeIsReady = useRuntimeIsReady();
const isV1Conversation = conversation?.conversation_version === "V1";
const query = useQuery({
queryKey: getFeedbackQueryKey(conversationId),
queryFn: () => ConversationService.getBatchFeedback(conversationId!),
enabled: runtimeIsReady && !!conversationId && config?.APP_MODE === "saas",
enabled:
runtimeIsReady &&
!!conversationId &&
config?.APP_MODE === "saas" &&
!isV1Conversation,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
@@ -1,10 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
export const useBatchSandboxes = (ids: string[]) =>
useQuery({
queryKey: ["sandboxes", "batch", ids],
queryFn: () => V1ConversationService.batchGetSandboxes(ids),
queryFn: () => SandboxService.batchGetSandboxes(ids),
enabled: ids.length > 0,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
@@ -1,6 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { BatchFeedbackData, getFeedbackQueryKey } from "./use-batch-feedback";
export type FeedbackData = BatchFeedbackData;
@@ -9,6 +10,9 @@ export const useFeedbackExists = (eventId?: number) => {
const queryClient = useQueryClient();
const { conversationId } = useConversationId();
const { data: config } = useConfig();
const { data: conversation } = useActiveConversation();
const isV1Conversation = conversation?.conversation_version === "V1";
return useQuery<FeedbackData>({
queryKey: [...getFeedbackQueryKey(conversationId), eventId],
@@ -22,7 +26,11 @@ export const useFeedbackExists = (eventId?: number) => {
return batchData?.[eventId.toString()] ?? { exists: false };
},
enabled: !!eventId && !!conversationId && config?.APP_MODE === "saas",
enabled:
!!eventId &&
!!conversationId &&
config?.APP_MODE === "saas" &&
!isV1Conversation,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
+12 -10
View File
@@ -3,30 +3,30 @@ import { useAgentStore } from "#/stores/agent-store";
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { AgentState } from "#/types/agent-state";
import { V1AgentStatus } from "#/types/v1/core/base/common";
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
/**
* Maps V1 agent status to V0 AgentState
*/
function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
function mapV1StatusToV0State(status: V1ExecutionStatus | null): AgentState {
if (!status) {
return AgentState.LOADING;
}
switch (status) {
case V1AgentStatus.IDLE:
case V1ExecutionStatus.IDLE:
return AgentState.AWAITING_USER_INPUT;
case V1AgentStatus.RUNNING:
case V1ExecutionStatus.RUNNING:
return AgentState.RUNNING;
case V1AgentStatus.PAUSED:
case V1ExecutionStatus.PAUSED:
return AgentState.PAUSED;
case V1AgentStatus.WAITING_FOR_CONFIRMATION:
case V1ExecutionStatus.WAITING_FOR_CONFIRMATION:
return AgentState.AWAITING_USER_CONFIRMATION;
case V1AgentStatus.FINISHED:
case V1ExecutionStatus.FINISHED:
return AgentState.FINISHED;
case V1AgentStatus.ERROR:
case V1ExecutionStatus.ERROR:
return AgentState.ERROR;
case V1AgentStatus.STUCK:
case V1ExecutionStatus.STUCK:
return AgentState.ERROR; // Map STUCK to ERROR for now
default:
return AgentState.LOADING;
@@ -41,7 +41,9 @@ function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
export function useAgentState() {
const { data: conversation } = useActiveConversation();
const v0State = useAgentStore((state) => state.curAgentState);
const v1Status = useV1ConversationStateStore((state) => state.agent_status);
const v1Status = useV1ConversationStateStore(
(state) => state.execution_status,
);
const isV1Conversation = conversation?.conversation_version === "V1";
+14 -8
View File
@@ -1,4 +1,10 @@
import { RefObject, useEffect, useState, useCallback, useRef } from "react";
import {
RefObject,
useState,
useCallback,
useRef,
useLayoutEffect,
} from "react";
export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
// Track whether we should auto-scroll to the bottom when content changes
@@ -65,20 +71,20 @@ export function useScrollToBottom(scrollRef: RefObject<HTMLDivElement | null>) {
}, [scrollRef]);
// Auto-scroll effect that runs when content changes
useEffect(() => {
// Use useLayoutEffect to scroll after DOM updates but before paint
useLayoutEffect(() => {
// Only auto-scroll if autoscroll is enabled
if (autoscroll) {
const dom = scrollRef.current;
if (dom) {
requestAnimationFrame(() => {
dom.scrollTo({
top: dom.scrollHeight,
behavior: "smooth",
});
// Scroll to bottom - this will trigger on any DOM change
dom.scrollTo({
top: dom.scrollHeight,
behavior: "smooth",
});
}
}
});
}); // No dependency array - runs after every render to follow new content
return {
scrollRef,
+77
View File
@@ -0,0 +1,77 @@
import posthog from "posthog-js";
import { useConfig } from "./query/use-config";
import { useSettings } from "./query/use-settings";
import { Provider } from "#/types/settings";
/**
* Hook that provides tracking functions with automatic data collection
* from available hooks (config, settings, etc.)
*/
export const useTracking = () => {
const { data: config } = useConfig();
const { data: settings } = useSettings();
// Common properties included in all tracking events
const commonProperties = {
app_surface: config?.APP_MODE || "unknown",
plan_tier: null,
current_url: window.location.href,
user_email: settings?.EMAIL || settings?.GIT_USER_EMAIL || null,
};
const trackLoginButtonClick = ({ provider }: { provider: Provider }) => {
posthog.capture("login_button_clicked", {
provider,
...commonProperties,
});
};
const trackConversationCreated = ({
hasRepository,
}: {
hasRepository: boolean;
}) => {
posthog.capture("conversation_created", {
has_repository: hasRepository,
...commonProperties,
});
};
const trackPushButtonClick = () => {
posthog.capture("push_button_clicked", {
...commonProperties,
});
};
const trackPullButtonClick = () => {
posthog.capture("pull_button_clicked", {
...commonProperties,
});
};
const trackCreatePrButtonClick = () => {
posthog.capture("create_pr_button_clicked", {
...commonProperties,
});
};
const trackGitProviderConnected = ({
providers,
}: {
providers: string[];
}) => {
posthog.capture("git_provider_connected", {
providers,
...commonProperties,
});
};
return {
trackLoginButtonClick,
trackConversationCreated,
trackPushButtonClick,
trackPullButtonClick,
trackCreatePrButtonClick,
trackGitProviderConnected,
};
};
+6
View File
@@ -471,12 +471,15 @@ export enum I18nKey {
PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB = "PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECT_TO_GITHUB",
PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED = "PROJECT_MENU_DETAILS_PLACEHOLDER$CONNECTED",
PROJECT_MENU_DETAILS$AGO_LABEL = "PROJECT_MENU_DETAILS$AGO_LABEL",
STATUS$ERROR = "STATUS$ERROR",
STATUS$ERROR_LLM_AUTHENTICATION = "STATUS$ERROR_LLM_AUTHENTICATION",
STATUS$ERROR_LLM_SERVICE_UNAVAILABLE = "STATUS$ERROR_LLM_SERVICE_UNAVAILABLE",
STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR = "STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR",
STATUS$ERROR_LLM_OUT_OF_CREDITS = "STATUS$ERROR_LLM_OUT_OF_CREDITS",
STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION = "STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION",
STATUS$ERROR_RUNTIME_DISCONNECTED = "STATUS$ERROR_RUNTIME_DISCONNECTED",
STATUS$ERROR_MEMORY = "STATUS$ERROR_MEMORY",
STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR = "STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR",
STATUS$LLM_RETRY = "STATUS$LLM_RETRY",
AGENT_ERROR$BAD_ACTION = "AGENT_ERROR$BAD_ACTION",
AGENT_ERROR$ACTION_TIMEOUT = "AGENT_ERROR$ACTION_TIMEOUT",
@@ -857,6 +860,7 @@ export enum I18nKey {
BUTTON$DELETE_CONVERSATION = "BUTTON$DELETE_CONVERSATION",
BUTTON$RENAME = "BUTTON$RENAME",
COMMON$APP = "COMMON$APP",
COMMON$PLANNER = "COMMON$PLANNER",
COMMON$APPLICATION_SETTINGS = "COMMON$APPLICATION_SETTINGS",
COMMON$BROWSER = "COMMON$BROWSER",
COMMON$CHANGES = "COMMON$CHANGES",
@@ -931,4 +935,6 @@ export enum I18nKey {
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",
COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS",
COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
}
+124 -28
View File
@@ -6912,20 +6912,20 @@
"tr": "Bu alan zorunludur"
},
"PLANNER$EMPTY_MESSAGE": {
"en": "No plan created.",
"zh-CN": "计划未创建",
"zh-TW": "未建立任何計劃。",
"de": "Kein Plan erstellt.",
"ko-KR": "생성된 계획이 없습니다.",
"no": "Ingen plan opprettet.",
"it": "Nessun piano creato.",
"pt": "Nenhum plano criado.",
"es": "Ningún plan creado.",
"ar": "لم يتم إنشاء أي خطة.",
"fr": "Aucun plan créé.",
"tr": "Plan oluşturulmadı.",
"ja": "プランナーは空です",
"uk": "План не створено."
"en": "There is currently no plan for this repo",
"uk": "Наразі для цього репозиторію немає плану",
"ja": "現在このリポジトリには計画がありません",
"zh-CN": "当前此仓库没有计划",
"zh-TW": "目前此存儲庫沒有計劃",
"ko-KR": "이 저장소에 대한 계획이 현재 없습니다",
"no": "Det finnes foreløpig ingen plan for dette repoet",
"ar": "لا يوجد حالياً خطة لهذا المستودع",
"de": "Derzeit gibt es keinen Plan für dieses Repository",
"fr": "Il n'y a actuellement aucun plan pour ce dépôt",
"it": "Attualmente non c'è un piano per questo repository",
"pt": "Atualmente não há plano para este repositório",
"es": "Actualmente no hay un plan para este repositorio",
"tr": "Şu anda bu depo için bir plan yok"
},
"FEEDBACK$PUBLIC_LABEL": {
"en": "Public",
@@ -7615,6 +7615,22 @@
"tr": "İçerik politikası ihlali. Çıktı, içerik filtreleme politikası tarafından engellendi.",
"uk": "Порушення політики щодо вмісту. Вивід було заблоковано політикою фільтрації вмісту."
},
"STATUS$ERROR": {
"en": "An error occurred. Please try again.",
"zh-CN": "发生错误,请重试",
"zh-TW": "發生錯誤,請重試",
"ko-KR": "오류가 발생했습니다. 다시 시도해주세요.",
"ja": "エラーが発生しました。もう一度お試しください。",
"no": "Det oppstod en feil. Vennligst prøv igjen.",
"ar": "حدث خطأ. يرجى المحاولة مرة أخرى.",
"de": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"fr": "Une erreur s'est produite. Veuillez réessayer.",
"it": "Si è verificato un errore. Per favore, riprova.",
"pt": "Ocorreu um erro. Por favor, tente novamente.",
"es": "Ocurrió un error. Por favor, inténtalo de nuevo.",
"tr": "Bir hata oluştu. Lütfen tekrar deneyin.",
"uk": "Сталася помилка. Будь ласка, спробуйте ще раз."
},
"STATUS$ERROR_RUNTIME_DISCONNECTED": {
"en": "There was an error while connecting to the runtime. Please refresh the page.",
"zh-CN": "运行时已断开连接",
@@ -7631,6 +7647,38 @@
"tr": "Çalışma zamanına bağlanırken bir hata oluştu. Lütfen sayfayı yenileyin.",
"uk": "Під час підключення до середовища виконання сталася помилка. Оновіть сторінку."
},
"STATUS$ERROR_MEMORY": {
"en": "Memory error occurred. Please try reducing the workload or restarting.",
"zh-CN": "发生内存错误,请尝试减少工作负载或重新启动",
"zh-TW": "發生記憶體錯誤,請嘗試減少工作負載或重新啟動",
"ko-KR": "메모리 오류가 발생했습니다. 작업 부하를 줄이거나 다시 시작해주세요.",
"ja": "メモリエラーが発生しました。作業負荷を減らすか、再起動してください。",
"no": "Minnefeil oppstod. Vennligst prøv å redusere arbeidsmengden eller start på nytt.",
"ar": "حدث خطأ في الذاكرة. يرجى محاولة تقليل عبء العمل أو إعادة التشغيل.",
"de": "Speicherfehler aufgetreten. Bitte versuchen Sie, die Arbeitslast zu reduzieren oder neu zu starten.",
"fr": "Erreur de mémoire. Veuillez essayer de réduire la charge de travail ou de redémarrer.",
"it": "Si è verificato un errore di memoria. Prova a ridurre il carico di lavoro o a riavviare.",
"pt": "Ocorreu um erro de memória. Tente reduzir a carga de trabalho ou reiniciar.",
"es": "Ocurrió un error de memoria. Intenta reducir la carga de trabajo o reiniciar.",
"tr": "Bellek hatası oluştu. Lütfen iş yükünü azaltmayı veya yeniden başlatmayı deneyin.",
"uk": "Сталася помилка пам'яті. Спробуйте зменшити навантаження або перезапустити."
},
"STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR": {
"en": "Error authenticating with the Git provider. Please check your credentials.",
"zh-CN": "Git提供商认证错误,请检查您的凭据",
"zh-TW": "Git提供商認證錯誤,請檢查您的憑據",
"ko-KR": "Git 공급자 인증 오류. 자격 증명을 확인해주세요.",
"ja": "Git プロバイダーの認証エラー。認証情報を確認してください。",
"no": "Feil ved autentisering med Git-leverandøren. Vennligst sjekk dine legitimasjoner.",
"ar": "خطأ في المصادقة مع مزود Git. يرجى التحقق من بيانات الاعتماد الخاصة بك.",
"de": "Fehler bei der Authentifizierung mit dem Git-Anbieter. Bitte überprüfen Sie Ihre Anmeldedaten.",
"fr": "Erreur d'authentification auprès du fournisseur Git. Veuillez vérifier vos informations d'identification.",
"it": "Errore di autenticazione con il provider Git. Controlla le tue credenziali.",
"pt": "Erro ao autenticar com o provedor Git. Por favor, verifique suas credenciais.",
"es": "Error al autenticar con el proveedor Git. Por favor, verifica tus credenciales.",
"tr": "Git sağlayıcısı ile kimlik doğrulama hatası. Lütfen kimlik bilgilerinizi kontrol edin.",
"uk": "Помилка автентифікації у постачальника Git. Перевірте свої облікові дані."
},
"STATUS$LLM_RETRY": {
"en": "Retrying LLM request",
"es": "Reintentando solicitud LLM",
@@ -8656,20 +8704,20 @@
"uk": "Додати платіжну інформацію"
},
"BILLING$YOURE_IN": {
"en": "You're in! You can start using your $50 in free credits now.",
"ja": "登録完了!$50分の無料クレジットを今すぐご利用いただけます。",
"zh-CN": "您已加入!现在可以开始使用$50的免费额度了。",
"zh-TW": "您已加入!現在可以開始使用$50的免費額度了。",
"ko-KR": "가입 완료! 지금 바로 $50 상당의 무료 크레딧을 사용하실 수 있습니다.",
"no": "Du er med! Du kan begynne å bruke dine $50 i gratis kreditter nå.",
"it": "Ci sei! Puoi iniziare a utilizzare i tuoi $50 in crediti gratuiti ora.",
"pt": "Você está dentro! Você pode começar a usar seus $50 em créditos gratuitos agora.",
"es": "¡Ya estás dentro! Puedes empezar a usar tus $50 en créditos gratuitos ahora.",
"ar": "أنت معنا! يمكنك البدء في استخدام رصيدك المجاني البالغ 50 دولارًا الآن.",
"fr": "C'est fait ! Vous pouvez commencer à utiliser vos 50 $ de crédits gratuits maintenant.",
"tr": "Başardın! Şimdi $50 değerindeki ücretsiz kredilerini kullanmaya başlayabilirsin.",
"de": "Du bist dabei! Du kannst jetzt deine $50 an kostenlosen Guthaben nutzen.",
"uk": "Готово! Ви можете почати використовувати свої безкоштовні кредити на суму 50 доларів США вже зараз."
"en": "You're in! You can start using your $10 in free credits now.",
"ja": "登録完了!$10分の無料クレジットを今すぐご利用いただけます。",
"zh-CN": "您已加入!现在可以开始使用$10的免费额度了。",
"zh-TW": "您已加入!現在可以開始使用$10的免費額度了。",
"ko-KR": "가입 완료! 지금 바로 $10 상당의 무료 크레딧을 사용하실 수 있습니다.",
"no": "Du er med! Du kan begynne å bruke dine $10 i gratis kreditter nå.",
"it": "Ci sei! Puoi iniziare a utilizzare i tuoi $10 in crediti gratuiti ora.",
"pt": "Você está dentro! Você pode começar a usar seus $10 em créditos gratuitos agora.",
"es": "¡Ya estás dentro! Puedes empezar a usar tus $10 en créditos gratuitos ahora.",
"ar": "أنت معنا! يمكنك البدء في استخدام رصيدك المجاني البالغ 10 دولارًا الآن.",
"fr": "C'est fait ! Vous pouvez commencer à utiliser vos 10 $ de crédits gratuits maintenant.",
"tr": "Başardın! Şimdi $10 değerindeki ücretsiz kredilerini kullanmaya başlayabilirsin.",
"de": "Du bist dabei! Du kannst jetzt deine $10 an kostenlosen Guthaben nutzen.",
"uk": "Готово! Ви можете почати використовувати свої безкоштовні кредити на суму 10 доларів США вже зараз."
},
"PAYMENT$ADD_FUNDS": {
"en": "Add Funds",
@@ -13711,6 +13759,22 @@
"de": "App",
"uk": "Додаток"
},
"COMMON$PLANNER": {
"en": "Planner",
"ja": "プランナー",
"zh-CN": "计划器",
"zh-TW": "規劃器",
"ko-KR": "플래너",
"no": "Planlegger",
"it": "Pianificatore",
"pt": "Planejador",
"es": "Planificador",
"ar": "المخطط",
"fr": "Planificateur",
"tr": "Planlayıcı",
"de": "Planer",
"uk": "Планувальник"
},
"COMMON$APPLICATION_SETTINGS": {
"en": "Application Settings",
"ja": "アプリケーション設定",
@@ -14894,5 +14958,37 @@
"tr": "Kullanıcı onayı bekleniyor",
"de": "Warte auf Benutzerbestätigung",
"uk": "Очікується підтвердження користувача"
},
"COMMON$MORE_OPTIONS": {
"en": "More options",
"ja": "その他のオプション",
"zh-CN": "更多选项",
"zh-TW": "更多選項",
"ko-KR": "추가 옵션",
"no": "Flere alternativer",
"it": "Altre opzioni",
"pt": "Mais opções",
"es": "Más opciones",
"ar": "خيارات إضافية",
"fr": "Plus d'options",
"tr": "Daha fazla seçenek",
"de": "Weitere Optionen",
"uk": "Більше опцій"
},
"COMMON$CREATE_A_PLAN": {
"en": "Create a plan",
"ja": "プランを作成する",
"zh-CN": "创建计划",
"zh-TW": "建立計劃",
"ko-KR": "계획 만들기",
"no": "Lag en plan",
"it": "Crea un piano",
"pt": "Criar um plano",
"es": "Crear un plan",
"ar": "إنشاء خطة",
"fr": "Créer un plan",
"tr": "Bir plan oluştur",
"de": "Einen Plan erstellen",
"uk": "Створити план"
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="109" height="109" viewBox="0 0 109 109" fill="none">
<path d="M40.1979 21.8969L34.7311 17.832L25.2691 30.5574L20.2094 26.784L16.1367 32.2451L26.6653 40.0969L40.1979 21.8969Z" fill="currentColor"/>
<path d="M90.8342 35.1983H50.4639V28.3858H90.8342V35.1983Z" fill="currentColor"/>
<path d="M90.8342 57.9067H50.4638V51.0942H90.8342V57.9067Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M27.2508 63.5837C32.2674 63.5837 36.3342 59.517 36.3342 54.5004C36.3342 49.4838 32.2674 45.4171 27.2508 45.4171C22.2342 45.4171 18.1675 49.4838 18.1675 54.5004C18.1675 59.517 22.2342 63.5837 27.2508 63.5837ZM27.2508 59.0421C29.7591 59.0421 31.7925 57.0087 31.7925 54.5004C31.7925 51.9921 29.7591 49.9587 27.2508 49.9587C24.7425 49.9587 22.7092 51.9921 22.7092 54.5004C22.7092 57.0087 24.7425 59.0421 27.2508 59.0421Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.3342 77.2087C36.3342 82.2253 32.2674 86.2921 27.2508 86.2921C22.2342 86.2921 18.1675 82.2253 18.1675 77.2087C18.1675 72.1922 22.2342 68.1254 27.2508 68.1254C32.2674 68.1254 36.3342 72.1922 36.3342 77.2087ZM31.7925 77.2087C31.7925 79.717 29.7591 81.7504 27.2508 81.7504C24.7425 81.7504 22.7092 79.717 22.7092 77.2087C22.7092 74.7005 24.7425 72.6671 27.2508 72.6671C29.7591 72.6671 31.7925 74.7005 31.7925 77.2087Z" fill="currentColor"/>
<path d="M50.4637 80.615H90.834V73.8025H50.4637V80.615Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M10.9706 10.1586C10.2819 10.0621 9.56905 10.1784 8.93842 10.5075C8.88672 10.5345 8.83557 10.5629 8.78504 10.5928C8.54529 10.7344 8.3193 10.9082 8.11331 11.1142L9.83702 12.8372L7.00054 15.75L7 17H8.25064L11.1628 14.1632L12.8857 15.8865C13.0917 15.6805 13.2655 15.4545 13.4071 15.2147C13.437 15.1642 13.4654 15.1131 13.4924 15.0614C13.8215 14.4308 13.9378 13.718 13.8413 13.0293L17 10.6946L13.3053 7L10.9706 10.1586Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.13832 8.76476C8.25957 8.17949 9.52697 7.9727 10.7515 8.14436L14.9025 2.52826L21.4717 9.09737L15.8555 13.2484C16.0272 14.4729 15.8204 15.7403 15.2351 16.8616C15.1872 16.9535 15.1366 17.0444 15.0836 17.1343C14.8318 17.5605 14.5228 17.9624 14.1566 18.3286L10.4443 14.6163L4.75207 20.3085H3.69141V19.2478L9.38361 13.5556L5.6713 9.84332C6.03755 9.47708 6.43936 9.16808 6.86562 8.91632C6.95547 8.86326 7.04641 8.81274 7.13832 8.76476ZM15.0735 4.82054L19.1794 8.92641L14.2461 12.5727L14.3701 13.4567C14.492 14.3266 14.3596 15.2233 13.9758 16.0265L7.97338 10.0241C8.77665 9.64035 9.67335 9.50789 10.5432 9.62984L11.4272 9.75376L15.0735 4.82054Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 817 B

+2 -2
View File
@@ -22,7 +22,7 @@ import { ConversationSubscriptionsProvider } from "#/context/conversation-subscr
import { useUserProviders } from "#/hooks/use-user-providers";
import { ConversationMain } from "#/components/features/conversation/conversation-main/conversation-main";
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationNameWithStatus } from "#/components/features/conversation/conversation-name-with-status";
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
import { WebSocketProviderWrapper } from "#/contexts/websocket-provider-wrapper";
@@ -160,7 +160,7 @@ function AppContent() {
className="p-3 md:p-0 flex flex-col h-full gap-3"
>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4.5 pt-2 lg:pt-0">
<ConversationName />
<ConversationNameWithStatus />
<ConversationTabs />
</div>
+29
View File
@@ -0,0 +1,29 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import { useConversationStore } from "#/state/conversation-store";
function PlannerTab() {
const { t } = useTranslation();
const setConversationMode = useConversationStore(
(state) => state.setConversationMode,
);
return (
<div className="flex flex-col items-center justify-center w-full h-full p-10">
<LessonPlanIcon width={109} height={109} color="#A1A1A1" />
<span className="text-[#8D95A9] text-[19px] font-normal leading-5 pb-9">
{t(I18nKey.PLANNER$EMPTY_MESSAGE)}
</span>
<button
type="button"
onClick={() => setConversationMode("plan")}
className="flex w-[164px] h-[40px] p-2 justify-center items-center shrink-0 rounded-lg bg-white overflow-hidden text-black text-ellipsis font-sans text-[16px] not-italic font-normal leading-[20px] hover:cursor-pointer hover:opacity-80"
>
{t(I18nKey.COMMON$CREATE_A_PLAN)}
</button>
</div>
);
}
export default PlannerTab;
+10 -1
View File
@@ -6,7 +6,10 @@ export type ConversationTab =
| "browser"
| "served"
| "vscode"
| "terminal";
| "terminal"
| "planner";
export type ConversationMode = "code" | "plan";
export interface IMessageToSend {
text: string;
@@ -25,6 +28,7 @@ interface ConversationState {
submittedMessage: string | null;
shouldHideSuggestions: boolean; // New state to hide suggestions when input expands
hasRightPanelToggled: boolean;
conversationMode: ConversationMode;
}
interface ConversationActions {
@@ -48,6 +52,7 @@ interface ConversationActions {
setSubmittedMessage: (message: string | null) => void;
resetConversationState: () => void;
setHasRightPanelToggled: (hasRightPanelToggled: boolean) => void;
setConversationMode: (conversationMode: ConversationMode) => void;
}
type ConversationStore = ConversationState & ConversationActions;
@@ -73,6 +78,7 @@ export const useConversationStore = create<ConversationStore>()(
submittedMessage: null,
shouldHideSuggestions: false,
hasRightPanelToggled: true,
conversationMode: "code",
// Actions
setIsRightPanelShown: (isRightPanelShown) =>
@@ -208,6 +214,9 @@ export const useConversationStore = create<ConversationStore>()(
setHasRightPanelToggled: (hasRightPanelToggled) =>
set({ hasRightPanelToggled }, false, "setHasRightPanelToggled"),
setConversationMode: (conversationMode) =>
set({ conversationMode }, false, "setConversationMode"),
}),
{
name: "conversation-store",
@@ -1,13 +1,13 @@
import { create } from "zustand";
import { V1AgentStatus } from "#/types/v1/core/base/common";
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
interface V1ConversationStateStore {
agent_status: V1AgentStatus | null;
execution_status: V1ExecutionStatus | null;
/**
* Set the agent status
*/
setAgentStatus: (agent_status: V1AgentStatus) => void;
setExecutionStatus: (execution_status: V1ExecutionStatus) => void;
/**
* Reset the store to initial state
@@ -17,10 +17,11 @@ interface V1ConversationStateStore {
export const useV1ConversationStateStore = create<V1ConversationStateStore>(
(set) => ({
agent_status: null,
execution_status: null,
setAgentStatus: (agent_status: V1AgentStatus) => set({ agent_status }),
setExecutionStatus: (execution_status: V1ExecutionStatus) =>
set({ execution_status }),
reset: () => set({ agent_status: null }),
reset: () => set({ execution_status: null }),
}),
);
+1 -1
View File
@@ -64,7 +64,7 @@ export enum SecurityRisk {
}
// Agent status
export enum V1AgentStatus {
export enum V1ExecutionStatus {
IDLE = "idle",
RUNNING = "running",
PAUSED = "paused",
@@ -1,11 +1,11 @@
import { BaseEvent } from "../base/event";
import { V1AgentStatus } from "../base/common";
import { V1ExecutionStatus } from "../base/common";
/**
* Conversation state value types
*/
export interface ConversationState {
agent_status: V1AgentStatus;
execution_status: V1ExecutionStatus;
// Add other conversation state fields here as needed
}
@@ -19,12 +19,12 @@ interface ConversationStateUpdateEventBase extends BaseEvent {
* Unique key for this state update event.
* Can be "full_state" for full state snapshots or field names for partial updates.
*/
key: "full_state" | "agent_status"; // Extend with other keys as needed
key: "full_state" | "execution_status"; // Extend with other keys as needed
/**
* Conversation state updates
*/
value: ConversationState | V1AgentStatus;
value: ConversationState | V1ExecutionStatus;
}
// Narrowed interfaces for full state update event
@@ -37,8 +37,8 @@ export interface ConversationStateUpdateEventFullState
// Narrowed interface for agent status update event
export interface ConversationStateUpdateEventAgentStatus
extends ConversationStateUpdateEventBase {
key: "agent_status";
value: V1AgentStatus;
key: "execution_status";
value: V1ExecutionStatus;
}
// Conversation state update event - contains conversation state updates
+1 -1
View File
@@ -136,7 +136,7 @@ export const isFullStateConversationStateUpdateEvent = (
export const isAgentStatusConversationStateUpdateEvent = (
event: ConversationStateUpdateEvent,
): event is ConversationStateUpdateEventAgentStatus =>
event.key === "agent_status";
event.key === "execution_status";
// =============================================================================
// TEMPORARY COMPATIBILITY TYPE GUARDS
+1
View File
@@ -19,3 +19,4 @@ export const ENABLE_TRAJECTORY_REPLAY = () =>
loadFeatureFlag("TRAJECTORY_REPLAY");
export const USE_V1_CONVERSATION_API = () =>
loadFeatureFlag("USE_V1_CONVERSATION_API");
export const USE_PLANNING_AGENT = () => loadFeatureFlag("USE_PLANNING_AGENT");
+11 -2
View File
@@ -4,6 +4,7 @@ import { AgentState } from "#/types/agent-state";
import { ConversationStatus } from "#/types/conversation-status";
import { StatusMessage } from "#/types/message";
import { RuntimeStatus } from "#/types/runtime-status";
import { V1AppConversationStartTaskStatus } from "#/api/conversation-service/v1-conversation-service.types";
export enum IndicatorColor {
BLUE = "bg-blue-500",
@@ -103,8 +104,15 @@ export function getStatusCode(
conversationStatus: ConversationStatus | null,
runtimeStatus: RuntimeStatus | null,
agentState: AgentState | null,
taskStatus?: V1AppConversationStartTaskStatus | null,
) {
// Handle conversation and runtime stopped states
// PRIORITY 1: Handle task error state (when start-tasks API returns ERROR)
// This must come first to prevent "Connecting..." from showing when task has errored
if (taskStatus === "ERROR") {
return I18nKey.AGENT_STATUS$ERROR_OCCURRED;
}
// PRIORITY 2: Handle conversation and runtime stopped states
if (conversationStatus === "STOPPED" || runtimeStatus === "STATUS$STOPPED") {
return I18nKey.CHAT_INTERFACE$STOPPED;
}
@@ -134,7 +142,8 @@ export function getStatusCode(
return runtimeStatus;
}
// Handle WebSocket connection states
// PRIORITY 3: Handle WebSocket connection states
// Note: WebSocket may be stuck in CONNECTING when task errors, so we check taskStatus first
if (webSocketStatus === "DISCONNECTED") {
return I18nKey.CHAT_INTERFACE$DISCONNECTED;
}
+64
View File
@@ -6,6 +6,7 @@ import { ConversationStatus } from "#/types/conversation-status";
import { GitRepository } from "#/types/git";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { PRODUCT_URL } from "#/utils/constants";
import { AgentState } from "#/types/agent-state";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -609,3 +610,66 @@ export const buildSessionHeaders = (
}
return headers;
};
/**
* Get the appropriate color based on agent status
* @param options Configuration object for status color calculation
* @param options.isPausing Whether the agent is currently pausing
* @param options.isTask Whether we're polling a task
* @param options.taskStatus The task status string (e.g., "ERROR", "READY")
* @param options.isStartingStatus Whether the agent is in a starting state (LOADING or INIT)
* @param options.isStopStatus Whether the conversation status is STOPPED
* @param options.curAgentState The current agent state
* @returns The hex color code for the status
*
* @example
* getStatusColor({
* isPausing: false,
* isTask: false,
* taskStatus: undefined,
* isStartingStatus: false,
* isStopStatus: false,
* curAgentState: AgentState.RUNNING
* }) // Returns "#BCFF8C"
*/
export const getStatusColor = (options: {
isPausing: boolean;
isTask: boolean;
taskStatus?: string | null;
isStartingStatus: boolean;
isStopStatus: boolean;
curAgentState: AgentState;
}): string => {
const {
isPausing,
isTask,
taskStatus,
isStartingStatus,
isStopStatus,
curAgentState,
} = options;
// Show pausing status
if (isPausing) {
return "#FFD600";
}
// Show task status if we're polling a task
if (isTask && taskStatus) {
if (taskStatus === "ERROR") {
return "#FF684E";
}
return "#FFD600";
}
if (isStartingStatus) {
return "#FFD600";
}
if (isStopStatus) {
return "#ffffff";
}
if (curAgentState === AgentState.ERROR) {
return "#FF684E";
}
return "#BCFF8C";
};
+4
View File
@@ -50,3 +50,7 @@ coverage.xml
*.manifest
# Note: We keep our custom spec file in version control
# *.spec
# Generated artifacts
build
-101
View File
@@ -1,101 +0,0 @@
# 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
-88
View File
@@ -1,88 +0,0 @@
# 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.
+2 -2
View File
@@ -15,7 +15,7 @@ import sys
import time
from pathlib import Path
from openhands_cli.utils import get_llm_metadata, get_default_cli_agent
from openhands_cli.utils import get_default_cli_agent, get_llm_metadata
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
from openhands.sdk import LLM
@@ -269,7 +269,7 @@ def main() -> int:
llm=LLM(
model='dummy-model',
api_key='dummy-key',
metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
litellm_extra_body={"metadata": get_llm_metadata(model_name='dummy-model', llm_type='openhands')},
)
)
if not test_executable(dummy_agent):
+1 -1
View File
@@ -53,7 +53,7 @@ a = Analysis(
'mcp.client',
'mcp.server',
'mcp.shared',
'openhands.tools.execute_bash',
'openhands.tools.terminal',
'openhands.tools.str_replace_editor',
'openhands.tools.task_tracker',
],
+102 -128
View File
@@ -12,13 +12,11 @@ from openhands.sdk import (
Message,
TextContent,
)
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.sdk.conversation.state import ConversationExecutionStatus
from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML
from openhands_cli.runner import ConversationRunner
from openhands_cli.simple_process_runner import SimpleProcessRunner
from openhands_cli.simple_signal_handler import SimpleSignalHandler
from openhands_cli.setup import (
MissingAgentSpec,
setup_conversation,
@@ -97,144 +95,120 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
# Track session start time for uptime calculation
session_start_time = datetime.now()
# Create simple signal handler and session
signal_handler = SimpleSignalHandler()
signal_handler.install()
# Create conversation runner to handle state machine logic
runner = None
session = get_session_prompter()
# Set up conversation
conversation = setup_conversation(conversation_id)
# Create simple process runner
process_runner = SimpleProcessRunner(conversation)
try:
# Main chat loop
while True:
try:
# Get user input
user_input = session.prompt(
HTML('<gold>> </gold>'),
multiline=False,
)
# 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':
# 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 == '/settings':
settings_screen = SettingsScreen(runner.conversation if runner else None)
settings_screen.display_settings()
continue
elif command == '/mcp':
mcp_screen = MCPScreen()
mcp_screen.display_mcp_info(initialized_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':
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
elif command == '/new':
try:
# 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
# Start a fresh conversation (no resume ID = new conversation)
conversation_id = uuid.uuid4()
runner = None
conversation = None
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>Failed to process message: {e}</red>'))
finally:
# Clear the process reference
signal_handler.set_process(None)
print_formatted_text(
HTML(f'<red>Error starting fresh conversation: {e}</red>')
)
continue
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>'))
elif command == '/help':
display_help()
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 == '/status':
display_status(conversation, session_start_time=session_start_time)
continue
finally:
# Clean up resources
if process_runner:
process_runner.cleanup()
signal_handler.uninstall()
# Clean up terminal state
_restore_tty()
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 runner:
print_formatted_text(
HTML('<yellow>No active conversation running...</yellow>')
)
continue
conversation = runner.conversation
if not (
conversation.state.execution_status == ConversationExecutionStatus.PAUSED
or conversation.state.execution_status
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
):
print_formatted_text(
HTML('<red>No paused conversation to resume...</red>')
)
continue
# Resume without new message
message = None
if not runner or not conversation:
conversation = setup_conversation(conversation_id)
runner = ConversationRunner(conversation)
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()
@@ -1,4 +1,3 @@
from openhands_cli.listeners.loading_listener import LoadingContext
from openhands_cli.listeners.pause_listener import PauseListener
__all__ = ['PauseListener', 'LoadingContext']
__all__ = ['PauseListener']
@@ -1,63 +0,0 @@
"""
Loading animation utilities for OpenHands CLI.
Provides animated loading screens during agent initialization.
"""
import sys
import threading
import time
def display_initialization_animation(text: str, is_loaded: threading.Event) -> None:
"""Display a spinning animation while agent is being initialized.
Args:
text: The text to display alongside the animation
is_loaded: Threading event that signals when loading is complete
"""
ANIMATION_FRAMES = ['', '', '', '', '', '', '', '', '', '']
i = 0
while not is_loaded.is_set():
sys.stdout.write('\n')
sys.stdout.write(
f'\033[s\033[J\033[38;2;255;215;0m[{ANIMATION_FRAMES[i % len(ANIMATION_FRAMES)]}] {text}\033[0m\033[u\033[1A'
)
sys.stdout.flush()
time.sleep(0.1)
i += 1
sys.stdout.write('\r' + ' ' * (len(text) + 10) + '\r')
sys.stdout.flush()
class LoadingContext:
"""Context manager for displaying loading animations in a separate thread."""
def __init__(self, text: str):
"""Initialize the loading context.
Args:
text: The text to display during loading
"""
self.text = text
self.is_loaded = threading.Event()
self.loading_thread: threading.Thread | None = None
def __enter__(self) -> 'LoadingContext':
"""Start the loading animation in a separate thread."""
self.loading_thread = threading.Thread(
target=display_initialization_animation,
args=(self.text, self.is_loaded),
daemon=True,
)
self.loading_thread.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Stop the loading animation and clean up the thread."""
self.is_loaded.set()
if self.loading_thread:
self.loading_thread.join(
timeout=1.0
) # Wait up to 1 second for thread to finish
@@ -31,9 +31,8 @@ class PauseListener(threading.Thread):
for key_press in self._input.read_keys():
pause_detected = pause_detected or key_press.key == Keys.ControlP
# 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
pause_detected = pause_detected or key_press.key == Keys.ControlC
pause_detected = pause_detected or key_press.key == Keys.ControlD
return pause_detected
@@ -1,314 +0,0 @@
"""
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
+16 -7
View File
@@ -1,7 +1,10 @@
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import BaseConversation, Message
from openhands.sdk.conversation.state import AgentExecutionStatus, ConversationState
from openhands.sdk.conversation.state import (
ConversationExecutionStatus,
ConversationState,
)
from openhands.sdk.security.confirmation_policy import (
AlwaysConfirm,
ConfirmationPolicyBase,
@@ -51,7 +54,10 @@ class ConversationRunner:
def _print_run_status(self) -> None:
print_formatted_text('')
if self.conversation.state.agent_status == AgentExecutionStatus.PAUSED:
if (
self.conversation.state.execution_status
== ConversationExecutionStatus.PAUSED
):
print_formatted_text(
HTML(
'<yellow>Resuming paused conversation...</yellow><grey> (Press Ctrl-P to pause)</grey>'
@@ -91,8 +97,8 @@ class ConversationRunner:
def _run_with_confirmation(self) -> None:
# If agent was paused, resume with confirmation request
if (
self.conversation.state.agent_status
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
self.conversation.state.execution_status
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
):
user_confirmation = self._handle_confirmation_request()
if user_confirmation == UserConfirmation.DEFER:
@@ -106,12 +112,15 @@ class ConversationRunner:
break
# In confirmation mode, agent either finishes or waits for user confirmation
if self.conversation.state.agent_status == AgentExecutionStatus.FINISHED:
if (
self.conversation.state.execution_status
== ConversationExecutionStatus.FINISHED
):
break
elif (
self.conversation.state.agent_status
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
self.conversation.state.execution_status
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
):
user_confirmation = self._handle_confirmation_request()
if user_confirmation == UserConfirmation.DEFER:
+24 -24
View File
@@ -2,11 +2,7 @@ import uuid
from prompt_toolkit import HTML, print_formatted_text
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
from openhands_cli.listeners import LoadingContext
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
from openhands_cli.tui.settings.store import AgentStore
from openhands.sdk.security.confirmation_policy import (
@@ -15,9 +11,10 @@ from openhands.sdk.security.confirmation_policy import (
from openhands_cli.tui.settings.settings_screen import SettingsScreen
register_tool('BashTool', BashTool)
register_tool('FileEditorTool', FileEditorTool)
register_tool('TaskTrackerTool', TaskTrackerTool)
# register tools
from openhands.tools.terminal import TerminalTool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
class MissingAgentSpec(Exception):
@@ -70,26 +67,29 @@ def setup_conversation(
MissingAgentSpec: If agent specification is not found or invalid.
"""
with LoadingContext('Initializing OpenHands agent...'):
agent = load_agent_specs(str(conversation_id))
print_formatted_text(
HTML(f'<white>Initializing agent...</white>')
)
if not include_security_analyzer:
# Remove security analyzer from agent spec
agent = agent.model_copy(
update={"security_analyzer": None}
)
agent = load_agent_specs(str(conversation_id))
# Create conversation - agent context is now set in AgentStore.load()
conversation: BaseConversation = Conversation(
agent=agent,
workspace=Workspace(working_dir=WORK_DIR),
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id,
if not include_security_analyzer:
# Remove security analyzer from agent spec
agent = agent.model_copy(
update={"security_analyzer": None}
)
if include_security_analyzer:
conversation.set_confirmation_policy(AlwaysConfirm())
# Create conversation - agent context is now set in AgentStore.load()
conversation: BaseConversation = Conversation(
agent=agent,
workspace=Workspace(working_dir=WORK_DIR),
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id,
)
if include_security_analyzer:
conversation.set_confirmation_policy(AlwaysConfirm())
print_formatted_text(
HTML(f'<green>✓ Agent initialized with model: {agent.llm.model}</green>')
@@ -1,113 +0,0 @@
"""
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)
+1 -11
View File
@@ -18,7 +18,6 @@ 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:
@@ -31,15 +30,8 @@ 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
@@ -49,7 +41,7 @@ def main() -> None:
# Import agent_chat only when needed
from openhands_cli.agent_chat import run_cli_entry
# Start agent chat (it will install its own signal handler)
# Start agent chat
run_cli_entry(resume_conversation_id=args.resume)
except KeyboardInterrupt:
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
@@ -61,8 +53,6 @@ def main() -> None:
traceback.print_exc()
raise
finally:
signal_handler.uninstall()
if __name__ == '__main__':
@@ -1,160 +0,0 @@
"""
Simple process-based conversation runner for OpenHands CLI.
Only the actual conversation running (process_message) is wrapped in a separate process.
All other methods run in the main process.
"""
import multiprocessing
from typing import Any, Optional
from openhands.sdk import BaseConversation, Message
from openhands_cli.runner import ConversationRunner
def _run_conversation_in_process(conversation_id: str, message_data: Optional[dict], result_queue: multiprocessing.Queue):
"""Run the conversation in a separate process."""
try:
from openhands_cli.setup import setup_conversation
from openhands.sdk import Message, TextContent
import uuid
# Recreate conversation in this process
conv_id = uuid.UUID(conversation_id)
conversation = setup_conversation(conv_id)
# Create conversation runner
runner = ConversationRunner(conversation)
if message_data:
# Recreate message from data
message = Message(
role=message_data['role'],
content=[TextContent(text=message_data['content_text'])]
)
# Process the message
runner.process_message(message)
# Put success result in the queue
result_queue.put(('success', None))
except KeyboardInterrupt:
result_queue.put(('interrupted', None))
except Exception as e:
result_queue.put(('error', str(e)))
class SimpleProcessRunner:
"""Simple conversation runner that only uses multiprocessing for the actual conversation."""
def __init__(self, conversation: BaseConversation):
"""Initialize the process runner.
Args:
conversation: The conversation instance
"""
self.conversation = conversation
self.conversation_id = str(conversation.conversation_id)
self.current_process: Optional[multiprocessing.Process] = None
self.result_queue: Optional[multiprocessing.Queue] = None
# Create a runner for main process operations
self.runner = ConversationRunner(conversation)
def process_message(self, message: Optional[Message]) -> bool:
"""Process a message in a separate process.
Args:
message: The user message to process
Returns:
True if successful, False otherwise
"""
# Create queue for result
self.result_queue = multiprocessing.Queue()
# Prepare message data for serialization
message_data = None
if message:
# Extract text content from the message
content_text = ""
for content in message.content:
if hasattr(content, 'text'):
content_text += content.text
message_data = {
'role': message.role,
'content_text': content_text
}
# Create and start process
self.current_process = multiprocessing.Process(
target=_run_conversation_in_process,
args=(self.conversation_id, message_data, self.result_queue)
)
self.current_process.start()
# Wait for result
try:
result_type, result_data = self.result_queue.get()
self.current_process.join()
if result_type == 'success':
return True
elif result_type == 'interrupted':
print("Agent was interrupted by user")
return False
else:
print(f"Process error: {result_data}")
return False
except Exception as e:
# Check if process was killed by signal handler
if self.current_process and not self.current_process.is_alive():
# Process was killed, likely by Ctrl+C handler
return False
# Clean up if process is still alive
if self.current_process and self.current_process.is_alive():
self.current_process.terminate()
self.current_process.join(timeout=2)
if self.current_process.is_alive():
self.current_process.kill()
self.current_process.join()
raise e
finally:
self.current_process = None
self.result_queue = None
def get_status(self) -> dict:
"""Get conversation status (runs in main process)."""
return {
'conversation_id': self.conversation.id,
'agent_status': self.conversation.state.agent_status.value if self.conversation.state else 'unknown',
'is_running': self.current_process is not None and self.current_process.is_alive()
}
def toggle_confirmation_mode(self) -> bool:
"""Toggle confirmation mode (runs in main process)."""
self.runner.toggle_confirmation_mode()
# Update our conversation reference
self.conversation = self.runner.conversation
return self.conversation.is_confirmation_mode_active
def resume(self) -> None:
"""Resume the agent (runs in main process)."""
# This would be handled by the conversation state
pass
def cleanup(self) -> None:
"""Clean up resources."""
if self.current_process and self.current_process.is_alive():
self.current_process.terminate()
self.current_process.join(timeout=2)
if self.current_process.is_alive():
self.current_process.kill()
self.current_process.join()
# Clean up conversation resources if needed
if hasattr(self.conversation, 'close'):
self.conversation.close()
@@ -1,68 +0,0 @@
"""
Simple signal handling for Ctrl+C behavior in OpenHands CLI.
- First Ctrl+C: Attempt graceful pause of the agent
- Second Ctrl+C: Immediately kill the process
"""
import signal
import time
from typing import Optional
from prompt_toolkit import HTML, print_formatted_text
class SimpleSignalHandler:
"""Simple signal handler that tracks Ctrl+C presses and manages a subprocess."""
def __init__(self):
self.ctrl_c_count = 0
self.last_ctrl_c_time = 0.0
self.timeout = 3.0 # Reset counter after 3 seconds
self.original_handler = None
self.current_process: Optional[object] = None
def install(self) -> None:
"""Install the signal handler."""
self.original_handler = signal.signal(signal.SIGINT, self._handle_ctrl_c)
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 reset_count(self) -> None:
"""Reset the Ctrl+C count (called when starting new message processing)."""
self.ctrl_c_count = 0
self.last_ctrl_c_time = 0.0
def set_process(self, process) -> None:
"""Set the current process to manage."""
self.current_process = process
def _handle_ctrl_c(self, signum: int, frame) -> None:
"""Handle Ctrl+C signal."""
current_time = time.time()
# Reset counter if too much time has passed
if current_time - self.last_ctrl_c_time > self.timeout:
self.ctrl_c_count = 0
self.ctrl_c_count += 1
self.last_ctrl_c_time = current_time
if self.ctrl_c_count == 1:
print_formatted_text(HTML('<yellow>Received Ctrl+C. Attempting to pause agent...</yellow>'))
if self.current_process and self.current_process.is_alive():
self.current_process.terminate()
print_formatted_text(HTML('<yellow>Press Ctrl+C again within 3 seconds to force kill.</yellow>'))
else:
print_formatted_text(HTML('<yellow>No active process to pause.</yellow>'))
else:
print_formatted_text(HTML('<red>Received second Ctrl+C. Force killing process...</red>'))
if self.current_process and self.current_process.is_alive():
self.current_process.kill()
# Reset the counter so user can continue with new messages
self.reset_count()
print_formatted_text(HTML('<green>Process stopped. You can continue sending messages.</green>'))
@@ -5,7 +5,7 @@ from prompt_toolkit import HTML, print_formatted_text
from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.widgets import Frame, TextArea
from openhands_cli.utils import get_llm_metadata, get_default_cli_agent
from openhands_cli.utils import get_default_cli_agent, get_llm_metadata
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
from openhands_cli.pt_style import COLOR_GREY
from openhands_cli.tui.settings.store import AgentStore
@@ -180,7 +180,7 @@ class SettingsScreen:
api_key=api_key,
base_url=base_url,
usage_id='agent',
metadata=get_llm_metadata(model_name=model, llm_type='agent'),
litellm_extra_body={"metadata": get_llm_metadata(model_name=model, llm_type='agent')},
)
agent = self.agent_store.load()
@@ -5,13 +5,13 @@ from pathlib import Path
from typing import Any
from fastmcp.mcp_config import MCPConfig
from openhands_cli.utils import get_llm_metadata
from openhands_cli.locations import (
AGENT_SETTINGS_PATH,
MCP_CONFIG_FILE,
PERSISTENCE_DIR,
WORK_DIR,
)
from openhands_cli.utils import get_llm_metadata
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import Agent, AgentContext, LocalFileStore
@@ -45,28 +45,29 @@ class AgentStore:
system_message_suffix=f'You current working directory is: {WORK_DIR}',
)
additional_mcp_config = self.load_mcp_configuration()
mcp_config: dict = agent.mcp_config.copy().get('mcpServers', {})
mcp_config.update(additional_mcp_config)
mcp_config: dict = self.load_mcp_configuration()
# Update LLM metadata with current information
agent_llm_metadata = get_llm_metadata(
model_name=agent.llm.model, llm_type='agent', session_id=session_id
)
updated_llm = agent.llm.model_copy(update={'metadata': agent_llm_metadata})
updated_llm = agent.llm.model_copy(update={'litellm_extra_body': {'metadata': agent_llm_metadata}})
condenser_updates = {}
if agent.condenser and isinstance(agent.condenser, LLMSummarizingCondenser):
condenser_updates['llm'] = agent.condenser.llm.model_copy(
update={
'metadata': get_llm_metadata(
model_name=agent.condenser.llm.model,
llm_type='condenser',
session_id=session_id,
)
'litellm_extra_body': {
'metadata': get_llm_metadata(
model_name=agent.condenser.llm.model,
llm_type='condenser',
session_id=session_id,
)
}
}
)
# Update tools and context
agent = agent.model_copy(
update={
'llm': updated_llm,
+5 -5
View File
@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
[project]
name = "openhands"
version = "1.0.5"
version = "1.0.6"
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
readme = "README.md"
license = { text = "MIT" }
@@ -18,8 +18,8 @@ classifiers = [
# Using Git URLs for dependencies so installs from PyPI pull from GitHub
# TODO: pin package versions once agent-sdk has published PyPI packages
dependencies = [
"openhands-sdk==1.0.0a5",
"openhands-tools==1.0.0a5",
"openhands-sdk==1",
"openhands-tools==1",
"prompt-toolkit>=3",
"typer>=0.17.4",
]
@@ -102,5 +102,5 @@ ignore_missing_imports = true
# UNCOMMENT TO USE EXACT COMMIT FROM AGENT-SDK
# [tool.uv.sources]
# openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands-sdk", rev = "512399d896521aee3131eea4bb59087fb9dfa243" }
# openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands-tools", rev = "512399d896521aee3131eea4bb59087fb9dfa243" }
# openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "aaa0066ee078688e015fcad590393fe6771c10a1" }
# openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "aaa0066ee078688e015fcad590393fe6771c10a1" }
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
Test script to verify Ctrl+C behavior in the OpenHands CLI.
This script simulates the signal handling behavior to test:
1. First Ctrl+C attempts graceful pause
2. Second Ctrl+C (within 3 seconds) kills process immediately
"""
import signal
import time
import multiprocessing
from openhands_cli.signal_handler import ProcessSignalHandler
def mock_conversation_process():
"""Mock conversation process that runs indefinitely"""
print("Mock conversation process started...")
try:
while True:
print("Agent is working...")
time.sleep(2)
except KeyboardInterrupt:
print("Mock conversation process received KeyboardInterrupt")
except Exception as e:
print(f"Mock conversation process error: {e}")
finally:
print("Mock conversation process ending")
def test_signal_handling():
"""Test the signal handling behavior"""
print("Testing Ctrl+C signal handling...")
print("Instructions:")
print("1. Press Ctrl+C once - should attempt graceful pause")
print("2. Press Ctrl+C again within 3 seconds - should kill immediately")
print("3. Wait more than 3 seconds between presses to test timeout reset")
print()
# Create and start mock process
process = multiprocessing.Process(target=mock_conversation_process)
process.start()
# Install signal handler
signal_handler = ProcessSignalHandler()
signal_handler.install_handler()
signal_handler.set_conversation_process(process)
try:
print("Process started. Press Ctrl+C to test signal handling...")
print("Process PID:", process.pid)
# Wait for process to finish or be killed
while process.is_alive():
time.sleep(0.5)
print(f"Process finished with exit code: {process.exitcode}")
except KeyboardInterrupt:
print("Main process received KeyboardInterrupt")
finally:
# Clean up
signal_handler.uninstall_handler()
if process.is_alive():
process.terminate()
process.join(timeout=2)
if process.is_alive():
process.kill()
process.join()
print("Test completed")
if __name__ == "__main__":
test_signal_handling()
@@ -2,12 +2,18 @@
from unittest.mock import MagicMock, patch
from uuid import UUID
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output.defaults import DummyOutput
from openhands_cli.setup import MissingAgentSpec, verify_agent_exists_or_setup_agent, setup_conversation
from openhands_cli.setup import (
MissingAgentSpec,
verify_agent_exists_or_setup_agent,
)
from openhands_cli.user_actions import UserConfirmation
@patch('openhands_cli.setup.load_agent_specs')
@patch("openhands_cli.setup.load_agent_specs")
def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
"""Test that verify_agent_exists_or_setup_agent returns agent successfully."""
# Mock the agent object
@@ -22,11 +28,10 @@ def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
mock_load_agent_specs.assert_called_once_with()
@patch('openhands_cli.setup.SettingsScreen')
@patch('openhands_cli.setup.load_agent_specs')
@patch("openhands_cli.setup.SettingsScreen")
@patch("openhands_cli.setup.load_agent_specs")
def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
mock_load_agent_specs,
mock_settings_screen_class
mock_load_agent_specs, mock_settings_screen_class
):
"""Test that verify_agent_exists_or_setup_agent handles MissingAgentSpec exception."""
# Mock the SettingsScreen instance
@@ -37,7 +42,7 @@ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
mock_agent = MagicMock()
mock_load_agent_specs.side_effect = [
MissingAgentSpec("Agent spec missing"),
mock_agent
mock_agent,
]
# Call the function
@@ -51,14 +56,11 @@ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
mock_settings_screen.configure_settings.assert_called_once_with(first_time=True)
@patch('openhands_cli.agent_chat.exit_session_confirmation')
@patch('openhands_cli.agent_chat.get_session_prompter')
@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch("openhands_cli.agent_chat.exit_session_confirmation")
@patch("openhands_cli.agent_chat.get_session_prompter")
@patch("openhands_cli.agent_chat.setup_conversation")
@patch("openhands_cli.agent_chat.verify_agent_exists_or_setup_agent")
@patch("openhands_cli.agent_chat.ConversationRunner")
def test_new_command_resets_confirmation_mode(
mock_runner_cls,
mock_verify_agent,
@@ -74,27 +76,35 @@ def test_new_command_resets_confirmation_mode(
mock_verify_agent.return_value = mock_agent
# Mock conversation - only one is created when /new is called
conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
conv1 = MagicMock()
conv1.id = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
mock_setup_conversation.return_value = conv1
# One runner instance for the conversation
runner1 = MagicMock(); runner1.is_confirmation_mode_active = True
runner1 = MagicMock()
runner1.is_confirmation_mode_active = True
mock_runner_cls.return_value = runner1
# Real session fed by a pipe (no interactive confirmation now)
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
from openhands_cli.user_actions.utils import (
get_session_prompter as real_get_session_prompter,
)
with create_pipe_input() as pipe:
output = DummyOutput()
session = real_get_session_prompter(input=pipe, output=output)
mock_get_session_prompter.return_value = session
from openhands_cli.agent_chat import run_cli_entry
# Trigger /new, then /exit (exit will be auto-accepted)
for ch in "/new\r/exit\r":
# Trigger /new
# First user message should trigger runner creation
# Then /exit (exit will be auto-accepted)
for ch in "/new\rhello\r/exit\r":
pipe.send_text(ch)
run_cli_entry(None)
# Assert we created one runner for the conversation when /new was called
# Assert we created one runner for the conversation when a message was processed after /new
assert mock_runner_cls.call_count == 1
assert mock_runner_cls.call_args_list[0].args[0] is conv1
@@ -6,7 +6,7 @@ import pytest
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output.defaults import DummyOutput
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands_cli.user_actions import UserConfirmation
@@ -39,43 +39,43 @@ def run_resume_command_test(commands, agent_status=None, expect_runner_created=T
patch('openhands_cli.agent_chat.setup_conversation') as mock_setup_conversation, \
patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent') as mock_verify_agent, \
patch('openhands_cli.agent_chat.ConversationRunner') as mock_runner_cls:
# Auto-accept the exit prompt to avoid interactive UI
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
# Mock agent verification to succeed
mock_agent = MagicMock()
mock_verify_agent.return_value = mock_agent
# Mock conversation setup
conv = MagicMock()
conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
if agent_status:
conv.state.agent_status = agent_status
conv.state.execution_status = agent_status
mock_setup_conversation.return_value = conv
# Mock runner
runner = MagicMock()
runner.conversation = conv
mock_runner_cls.return_value = runner
# Real session fed by a pipe
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
with create_pipe_input() as pipe:
output = DummyOutput()
session = real_get_session_prompter(input=pipe, output=output)
mock_get_session_prompter.return_value = session
from openhands_cli.agent_chat import run_cli_entry
# Send commands
for ch in commands:
pipe.send_text(ch)
# Capture printed output
with patch('openhands_cli.agent_chat.print_formatted_text') as mock_print:
run_cli_entry(None)
return mock_runner_cls, runner, mock_print
@@ -93,17 +93,17 @@ def run_resume_command_test(commands, agent_status=None, expect_runner_created=T
def test_resume_command_warnings(commands, expected_warning, expect_runner_created):
"""Test /resume command shows appropriate warnings."""
# Set agent status to FINISHED for the "conversation exists but not paused" test
agent_status = AgentExecutionStatus.FINISHED if expect_runner_created else None
agent_status = ConversationExecutionStatus.FINISHED if expect_runner_created else None
mock_runner_cls, runner, mock_print = run_resume_command_test(
commands, agent_status=agent_status, expect_runner_created=expect_runner_created
)
# Verify warning message was printed
warning_calls = [call for call in mock_print.call_args_list
warning_calls = [call for call in mock_print.call_args_list
if expected_warning in str(call)]
assert len(warning_calls) > 0, f"Expected warning about {expected_warning}"
# Verify runner creation expectation
if expect_runner_created:
assert mock_runner_cls.call_count == 1
@@ -117,31 +117,31 @@ def test_resume_command_warnings(commands, expected_warning, expect_runner_creat
@pytest.mark.parametrize(
"agent_status",
[
AgentExecutionStatus.PAUSED,
AgentExecutionStatus.WAITING_FOR_CONFIRMATION,
ConversationExecutionStatus.PAUSED,
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION,
],
)
def test_resume_command_successful_resume(agent_status):
"""Test /resume command successfully resumes paused/waiting conversations."""
commands = "hello\r/resume\r/exit\r"
mock_runner_cls, runner, mock_print = run_resume_command_test(
commands, agent_status=agent_status, expect_runner_created=True
)
# Verify runner was created and process_message was called
assert mock_runner_cls.call_count == 1
# Verify process_message was called twice: once with the initial message, once with None for resume
assert runner.process_message.call_count == 2
# Check the calls to process_message
calls = runner.process_message.call_args_list
# First call should have a message (the "hello" message)
first_call_args = calls[0][0]
assert first_call_args[0] is not None, "First call should have a message"
# Second call should have None (the /resume command)
second_call_args = calls[1][0]
assert second_call_args[0] is None, "Second call should have None message for resume"
assert second_call_args[0] is None, "Second call should have None message for resume"
@@ -0,0 +1,114 @@
"""Minimal tests: mcp.json overrides persisted agent MCP servers."""
import json
from pathlib import Path
from unittest.mock import patch
import pytest
from pydantic import SecretStr
from openhands.sdk import Agent, LLM
from openhands_cli.locations import MCP_CONFIG_FILE, AGENT_SETTINGS_PATH
from openhands_cli.tui.settings.store import AgentStore
# ---------------------- tiny helpers ----------------------
def write_json(path: Path, obj: dict) -> None:
path.write_text(json.dumps(obj))
def write_agent(root: Path, agent: Agent) -> None:
(root / AGENT_SETTINGS_PATH).write_text(
agent.model_dump_json(context={"expose_secrets": True})
)
# ---------------------- fixtures ----------------------
@pytest.fixture
def persistence_dir(tmp_path, monkeypatch) -> Path:
# Create root dir and point AgentStore at it
root = tmp_path / "openhands"
root.mkdir()
monkeypatch.setattr("openhands_cli.tui.settings.store.PERSISTENCE_DIR", str(root))
return root
@pytest.fixture
def agent_store() -> AgentStore:
return AgentStore()
# ---------------------- tests ----------------------
@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[])
@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={})
def test_load_overrides_persisted_mcp_with_mcp_json_file(
mock_meta,
mock_tools,
persistence_dir,
agent_store
):
"""If agent has MCP servers, mcp.json must replace them entirely."""
# Persist an agent that already contains MCP servers
persisted_agent = Agent(
llm=LLM(model="gpt-4", api_key=SecretStr("k"), usage_id="svc"),
tools=[],
mcp_config={
"mcpServers": {
"persistent_server": {"command": "python", "args": ["-m", "old_server"]}
}
},
)
write_agent(persistence_dir, persisted_agent)
# Create mcp.json with different servers (this must fully override)
write_json(
persistence_dir / MCP_CONFIG_FILE,
{
"mcpServers": {
"file_server": {"command": "uvx", "args": ["mcp-server-fetch"]}
}
},
)
loaded = agent_store.load()
assert loaded is not None
# Expect ONLY the MCP json file's config
assert loaded.mcp_config == {
"mcpServers": {
"file_server": {
"command": "uvx",
"args": ["mcp-server-fetch"],
"env": {},
"transport": "stdio",
}
}
}
@patch("openhands_cli.tui.settings.store.get_default_tools", return_value=[])
@patch("openhands_cli.tui.settings.store.get_llm_metadata", return_value={})
def test_load_when_mcp_file_missing_ignores_persisted_mcp(
mock_meta,
mock_tools,
persistence_dir,
agent_store
):
"""If mcp.json is absent, loaded agent.mcp_config should be empty (persisted MCP ignored)."""
persisted_agent = Agent(
llm=LLM(model="gpt-4", api_key=SecretStr("k"), usage_id="svc"),
tools=[],
mcp_config={
"mcpServers": {
"persistent_server": {"command": "python", "args": ["-m", "old_server"]}
}
},
)
write_agent(persistence_dir, persisted_agent)
# No mcp.json created
loaded = agent_store.load()
assert loaded is not None
assert loaded.mcp_config == {} # persisted MCP is ignored if file is missin
@@ -73,8 +73,6 @@ class TestConfirmationMode:
persistence_dir=ANY,
conversation_id=mock_conversation_id,
)
# Verify print_formatted_text was called
mock_print.assert_called_once()
def test_setup_conversation_raises_missing_agent_spec(self) -> None:
"""Test that setup_conversation raises MissingAgentSpec when agent is not found."""
+25 -13
View File
@@ -9,7 +9,7 @@ from pydantic import ConfigDict, SecretStr, model_validator
from openhands.sdk import Conversation, ConversationCallbackType, LocalConversation
from openhands.sdk.agent.base import AgentBase
from openhands.sdk.conversation import ConversationState
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.sdk.conversation.state import ConversationExecutionStatus
from openhands.sdk.llm import LLM
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm
from unittest.mock import MagicMock
@@ -45,7 +45,7 @@ class FakeAgent(AgentBase):
) -> None:
self.step_count += 1
if self.step_count == self.finish_on_step:
conversation.state.agent_status = AgentExecutionStatus.FINISHED
conversation.state.execution_status = ConversationExecutionStatus.FINISHED
@pytest.fixture()
@@ -56,10 +56,10 @@ def agent() -> FakeAgent:
class TestConversationRunner:
@pytest.mark.parametrize(
'agent_status', [AgentExecutionStatus.RUNNING, AgentExecutionStatus.PAUSED]
'agent_status', [ConversationExecutionStatus.RUNNING, ConversationExecutionStatus.PAUSED]
)
def test_non_confirmation_mode_runs_once(
self, agent: FakeAgent, agent_status: AgentExecutionStatus
self, agent: FakeAgent, agent_status: ConversationExecutionStatus
) -> None:
"""
1. Confirmation mode is not on
@@ -68,28 +68,38 @@ class TestConversationRunner:
convo = Conversation(agent)
convo.max_iteration_per_run = 1
convo.state.agent_status = agent_status
convo.state.execution_status = agent_status
cr = ConversationRunner(convo)
cr.set_confirmation_policy(NeverConfirm())
cr.process_message(message=None)
assert agent.step_count == 1
assert convo.state.agent_status != AgentExecutionStatus.PAUSED
assert (
convo.state.execution_status != ConversationExecutionStatus.PAUSED
)
@pytest.mark.parametrize(
'confirmation, final_status, expected_run_calls',
[
# Case 1: Agent waiting for confirmation; user DEFERS -> early return, no run()
(UserConfirmation.DEFER, AgentExecutionStatus.WAITING_FOR_CONFIRMATION, 0),
(
UserConfirmation.DEFER,
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION,
0,
),
# Case 2: Agent waiting for confirmation; user ACCEPTS -> run() once, break (finished=True)
(UserConfirmation.ACCEPT, AgentExecutionStatus.FINISHED, 1),
(
UserConfirmation.ACCEPT,
ConversationExecutionStatus.FINISHED,
1,
),
],
)
def test_confirmation_mode_waiting_and_user_decision_controls_run(
self,
agent: FakeAgent,
confirmation: UserConfirmation,
final_status: AgentExecutionStatus,
final_status: ConversationExecutionStatus,
expected_run_calls: int,
) -> None:
"""
@@ -100,14 +110,16 @@ class TestConversationRunner:
5. If accepted, run call to agent should be made
"""
if final_status == AgentExecutionStatus.FINISHED:
if final_status == ConversationExecutionStatus.FINISHED:
agent.finish_on_step = 1
# Add a mock security analyzer to enable confirmation mode
agent.security_analyzer = MagicMock()
convo = Conversation(agent)
convo.state.agent_status = AgentExecutionStatus.WAITING_FOR_CONFIRMATION
convo.state.execution_status = (
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
)
cr = ConversationRunner(convo)
cr.set_confirmation_policy(AlwaysConfirm())
@@ -117,7 +129,7 @@ class TestConversationRunner:
cr.process_message(message=None)
mock_confirmation_request.assert_called_once()
assert agent.step_count == expected_run_calls
assert convo.state.agent_status == final_status
assert convo.state.execution_status == final_status
def test_confirmation_mode_not_waiting__runs_once_when_finished_true(
self, agent: FakeAgent
@@ -129,7 +141,7 @@ class TestConversationRunner:
"""
agent.finish_on_step = 1
convo = Conversation(agent)
convo.state.agent_status = AgentExecutionStatus.PAUSED
convo.state.execution_status = ConversationExecutionStatus.PAUSED
cr = ConversationRunner(convo)
cr.set_confirmation_policy(AlwaysConfirm())
@@ -65,6 +65,6 @@ class TestToolFix:
) # BashTool, FileEditorTool, TaskTrackerTool
tool_names = [tool.name for tool in loaded_agent.tools]
assert 'BashTool' in tool_names
assert 'FileEditorTool' in tool_names
assert 'TaskTrackerTool' in tool_names
assert 'terminal' in tool_names
assert 'file_editor' in tool_names
assert 'task_tracker' in tool_names
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for the loading animation functionality.
"""
import threading
import time
import unittest
from unittest.mock import patch
from openhands_cli.listeners.loading_listener import (
LoadingContext,
display_initialization_animation,
)
class TestLoadingAnimation(unittest.TestCase):
"""Test cases for loading animation functionality."""
def test_loading_context_manager(self):
"""Test that LoadingContext works as a context manager."""
with LoadingContext('Test loading...') as ctx:
self.assertIsInstance(ctx, LoadingContext)
self.assertEqual(ctx.text, 'Test loading...')
self.assertIsInstance(ctx.is_loaded, threading.Event)
self.assertIsNotNone(ctx.loading_thread)
# Give the thread a moment to start
time.sleep(0.1)
self.assertTrue(ctx.loading_thread.is_alive())
# After exiting context, thread should be stopped
time.sleep(0.1)
self.assertFalse(ctx.loading_thread.is_alive())
@patch('sys.stdout')
def test_animation_writes_while_running_and_stops_after(self, mock_stdout):
"""Ensure stdout is written while animation runs and stops after it ends."""
is_loaded = threading.Event()
animation_thread = threading.Thread(
target=display_initialization_animation,
args=('Test output', is_loaded),
daemon=True,
)
animation_thread.start()
# Let it run a bit and check calls
time.sleep(0.2)
calls_while_running = mock_stdout.write.call_count
self.assertGreater(calls_while_running, 0, 'Expected writes while spinner runs')
# Stop animation
is_loaded.set()
time.sleep(0.2)
animation_thread.join(timeout=1.0)
calls_after_stop = mock_stdout.write.call_count
# Wait a moment to detect any stray writes after thread finished
time.sleep(0.2)
self.assertEqual(
calls_after_stop,
mock_stdout.write.call_count,
'No extra writes should occur after animation stops',
)
if __name__ == '__main__':
unittest.main()
+323 -8
View File
@@ -1055,6 +1055,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
@@ -1064,6 +1066,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
@@ -1071,6 +1075,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
@@ -1091,6 +1097,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/08/24d62fccb01c4e86c59ba79073af7e5c8ab643846823c2fa3e957bde4b58/groq-0.32.0-py3-none-any.whl", hash = "sha256:0ed0be290042f8826f851f3a1defaac4f979dcfce86ec4a0681a23af00ec800b", size = 135387, upload-time = "2025-09-27T23:01:33.223Z" },
]
[[package]]
name = "grpcio"
version = "1.76.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" },
{ url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" },
{ url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" },
{ url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" },
{ url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" },
{ url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" },
{ url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" },
{ url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" },
{ url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" },
{ url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" },
{ url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" },
{ url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" },
{ url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" },
{ url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" },
{ url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" },
{ url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" },
{ url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" },
{ url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" },
{ url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" },
{ url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" },
{ url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" },
{ url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" },
{ url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" },
{ url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" },
{ url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" },
{ url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" },
{ url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -1426,6 +1473,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/f6/6aeedf8c6e75bfca08b9c73385186016446e8286803b381fcb9cac9c1594/litellm-1.78.5-py3-none-any.whl", hash = "sha256:aa716e9f2dfec406f1fb33831f3e49bc8bc6df73aa736aae21790516b7bb7832", size = 9827414, upload-time = "2025-10-18T22:24:35.398Z" },
]
[[package]]
name = "lmnr"
version = "0.7.20"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "grpcio" },
{ name = "httpx" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-instrumentation-threading" },
{ name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-semantic-conventions-ai" },
{ name = "orjson" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "tenacity" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d4/c0/996403cc2f6967881a42af4b27ff8931956d57ab3ed2d8bf11e5b37aed40/lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd", size = 194075, upload-time = "2025-11-04T16:53:34.49Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/df/4665a3931b2fbc5f5b66e4906ffab106f3f65ab7e78732ecdaf3ba4a3076/lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38", size = 247465, upload-time = "2025-11-04T16:53:32.713Z" },
]
[[package]]
name = "macholib"
version = "1.16.3"
@@ -1855,8 +1929,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "openhands-sdk", specifier = "==1.0.0a5" },
{ name = "openhands-tools", specifier = "==1.0.0a5" },
{ name = "openhands-sdk", specifier = "==1.0.0" },
{ name = "openhands-tools", specifier = "==1.0.0" },
{ name = "prompt-toolkit", specifier = ">=3" },
{ name = "typer", specifier = ">=0.17.4" },
]
@@ -1879,26 +1953,27 @@ dev = [
[[package]]
name = "openhands-sdk"
version = "1.0.0a5"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastmcp" },
{ name = "httpx" },
{ name = "litellm" },
{ name = "lmnr" },
{ name = "pydantic" },
{ name = "python-frontmatter" },
{ name = "python-json-logger" },
{ name = "tenacity" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/90/d40f6716641a95a61d2042f00855e0eadc0b2558167078324576cc5a3c22/openhands_sdk-1.0.0a5.tar.gz", hash = "sha256:8888d6892d58cf9b11a71fa80086156c0b6c9a0b50df6839c0a9cafffba2338c", size = 152810, upload-time = "2025-10-29T16:19:52.086Z" }
sdist = { url = "https://files.pythonhosted.org/packages/56/58/d6117840a14d013176a7a490a74295dffac64b44dc098532d4e8526c9a87/openhands_sdk-1.0.0.tar.gz", hash = "sha256:7c3a0d77d48d7eceaa77fda90ac654697ce916431b5c905d10d9ab6c07609a1a", size = 160726, upload-time = "2025-11-06T17:05:44.545Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/6b/d3aa28019163f22f4b589ad818b83e3bea23d0a50b0c51ecc070ffdec139/openhands_sdk-1.0.0a5-py3-none-any.whl", hash = "sha256:db20272b04cf03627f9f7d1e87992078ac4ce15d188955a2962aa9e754d0af03", size = 204063, upload-time = "2025-10-29T16:19:50.684Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/4d4c356ed50e6ad87e6dc8f87af1966c51c55a22955cebd632bf62040e5b/openhands_sdk-1.0.0-py3-none-any.whl", hash = "sha256:73916e22783e2c8500f19765fa340631a0e47ae9a3c5e40fb8411ecab4a1f49a", size = 214807, upload-time = "2025-11-06T17:05:43.474Z" },
]
[[package]]
name = "openhands-tools"
version = "1.0.0a5"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bashlex" },
@@ -1910,9 +1985,200 @@ dependencies = [
{ name = "openhands-sdk" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/8d/d62bc5e6c986676363692743688f10b6a922fd24dd525e5c6e87bd6fc08e/openhands_tools-1.0.0a5.tar.gz", hash = "sha256:6c67454e612596e95c5151267659ddd3b633a5d4a1b70b348f7f913c62146562", size = 63012, upload-time = "2025-10-29T16:19:53.783Z" }
sdist = { url = "https://files.pythonhosted.org/packages/45/49/3bad4d8283c76f72dacfde8fece9d1190774c87c40a011075868e8d18cbf/openhands_tools-1.0.0.tar.gz", hash = "sha256:f6bc8647149d541730520f1aeb409cd9eac96d796d19e39a40f300dcd2b0284c", size = 61997, upload-time = "2025-11-06T17:05:46.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/9d/4da48258f0af73d017b61ed3f12786fae4caccc7e7cd97d77ef2bb25f00c/openhands_tools-1.0.0a5-py3-none-any.whl", hash = "sha256:74c27e23e6adc9a0bad00e32448bd4872019ce0786474e8de2fbf2d7c0887e8e", size = 84724, upload-time = "2025-10-29T16:19:52.84Z" },
{ url = "https://files.pythonhosted.org/packages/c8/15/23c5650a9470f9c125288508bf966e6b2ece479f5407801aa7fdda2ba5a0/openhands_tools-1.0.0-py3-none-any.whl", hash = "sha256:21a4ff3f37a3c71edd17b861fe1a9b86cc744ad9dc8a3626898ecdeeea7ae30f", size = 84232, upload-time = "2025-11-06T17:05:45.527Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/83/dd4660f2956ff88ed071e9e0e36e830df14b8c5dc06722dbde1841accbe8/opentelemetry_exporter_otlp_proto_common-1.38.0.tar.gz", hash = "sha256:e333278afab4695aa8114eeb7bf4e44e65c6607d54968271a249c180b2cb605c", size = 20431, upload-time = "2025-10-16T08:35:53.285Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/9e/55a41c9601191e8cd8eb626b54ee6827b9c9d4a46d736f32abc80d8039fc/opentelemetry_exporter_otlp_proto_common-1.38.0-py3-none-any.whl", hash = "sha256:03cb76ab213300fe4f4c62b7d8f17d97fcfd21b89f0b5ce38ea156327ddda74a", size = 18359, upload-time = "2025-10-16T08:35:34.099Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/c0/43222f5b97dc10812bc4f0abc5dc7cd0a2525a91b5151d26c9e2e958f52e/opentelemetry_exporter_otlp_proto_grpc-1.38.0.tar.gz", hash = "sha256:2473935e9eac71f401de6101d37d6f3f0f1831db92b953c7dcc912536158ebd6", size = 24676, upload-time = "2025-10-16T08:35:53.83Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/28/f0/bd831afbdba74ca2ce3982142a2fad707f8c487e8a3b6fef01f1d5945d1b/opentelemetry_exporter_otlp_proto_grpc-1.38.0-py3-none-any.whl", hash = "sha256:7c49fd9b4bd0dbe9ba13d91f764c2d20b0025649a6e4ac35792fb8d84d764bc7", size = 19695, upload-time = "2025-10-16T08:35:35.053Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/0a/debcdfb029fbd1ccd1563f7c287b89a6f7bef3b2902ade56797bfd020854/opentelemetry_exporter_otlp_proto_http-1.38.0.tar.gz", hash = "sha256:f16bd44baf15cbe07633c5112ffc68229d0edbeac7b37610be0b2def4e21e90b", size = 17282, upload-time = "2025-10-16T08:35:54.422Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/77/154004c99fb9f291f74aa0822a2f5bbf565a72d8126b3a1b63ed8e5f83c7/opentelemetry_exporter_otlp_proto_http-1.38.0-py3-none-any.whl", hash = "sha256:84b937305edfc563f08ec69b9cb2298be8188371217e867c1854d77198d0825b", size = 19579, upload-time = "2025-10-16T08:35:36.269Z" },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.59b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/ed/9c65cd209407fd807fa05be03ee30f159bdac8d59e7ea16a8fe5a1601222/opentelemetry_instrumentation-0.59b0.tar.gz", hash = "sha256:6010f0faaacdaf7c4dff8aac84e226d23437b331dcda7e70367f6d73a7db1adc", size = 31544, upload-time = "2025-10-16T08:39:31.959Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/f5/7a40ff3f62bfe715dad2f633d7f1174ba1a7dd74254c15b2558b3401262a/opentelemetry_instrumentation-0.59b0-py3-none-any.whl", hash = "sha256:44082cc8fe56b0186e87ee8f7c17c327c4c2ce93bdbe86496e600985d74368ee", size = 33020, upload-time = "2025-10-16T08:38:31.463Z" },
]
[[package]]
name = "opentelemetry-instrumentation-threading"
version = "0.59b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/7a/84e97d8992808197006e607ae410c2219bdbbc23d1289ba0c244d3220741/opentelemetry_instrumentation_threading-0.59b0.tar.gz", hash = "sha256:ce5658730b697dcbc0e0d6d13643a69fd8aeb1b32fa8db3bade8ce114c7975f3", size = 8770, upload-time = "2025-10-16T08:40:03.587Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/50/32d29076aaa1c91983cdd3ca8c6bb4d344830cd7d87a7c0fdc2d98c58509/opentelemetry_instrumentation_threading-0.59b0-py3-none-any.whl", hash = "sha256:76da2fc01fe1dccebff6581080cff9e42ac7b27cc61eb563f3c4435c727e8eca", size = 9313, upload-time = "2025-10-16T08:39:15.876Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/14/f0c4f0f6371b9cb7f9fa9ee8918bfd59ac7040c7791f1e6da32a1839780d/opentelemetry_proto-1.38.0.tar.gz", hash = "sha256:88b161e89d9d372ce723da289b7da74c3a8354a8e5359992be813942969ed468", size = 46152, upload-time = "2025-10-16T08:36:01.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/6a/82b68b14efca5150b2632f3692d627afa76b77378c4999f2648979409528/opentelemetry_proto-1.38.0-py3-none-any.whl", hash = "sha256:b6ebe54d3217c42e45462e2a1ae28c3e2bf2ec5a5645236a490f55f45f1a0a18", size = 72535, upload-time = "2025-10-16T08:35:45.749Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/cb/f0eee1445161faf4c9af3ba7b848cc22a50a3d3e2515051ad8628c35ff80/opentelemetry_sdk-1.38.0.tar.gz", hash = "sha256:93df5d4d871ed09cb4272305be4d996236eedb232253e3ab864c8620f051cebe", size = 171942, upload-time = "2025-10-16T08:36:02.257Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/2e/e93777a95d7d9c40d270a371392b6d6f1ff170c2a3cb32d6176741b5b723/opentelemetry_sdk-1.38.0-py3-none-any.whl", hash = "sha256:1c66af6564ecc1553d72d811a01df063ff097cdc82ce188da9951f93b8d10f6b", size = 132349, upload-time = "2025-10-16T08:35:46.995Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.59b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/bc/8b9ad3802cd8ac6583a4eb7de7e5d7db004e89cb7efe7008f9c8a537ee75/opentelemetry_semantic_conventions-0.59b0.tar.gz", hash = "sha256:7a6db3f30d70202d5bf9fa4b69bc866ca6a30437287de6c510fb594878aed6b0", size = 129861, upload-time = "2025-10-16T08:36:03.346Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/7d/c88d7b15ba8fe5c6b8f93be50fc11795e9fc05386c44afaf6b76fe191f9b/opentelemetry_semantic_conventions-0.59b0-py3-none-any.whl", hash = "sha256:35d3b8833ef97d614136e253c1da9342b4c3c083bbaf29ce31d572a1c3825eed", size = 207954, upload-time = "2025-10-16T08:35:48.054Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions-ai"
version = "0.4.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e6/40b59eda51ac47009fb47afcdf37c6938594a0bd7f3b9fadcbc6058248e3/opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036", size = 5368, upload-time = "2025-08-22T10:14:17.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/b5/cf25da2218910f0d6cdf7f876a06bed118c4969eacaf60a887cbaef44f44/opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5", size = 6080, upload-time = "2025-08-22T10:14:16.477Z" },
]
[[package]]
name = "orjson"
version = "3.11.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/51/6b556192a04595b93e277a9ff71cd0cc06c21a7df98bcce5963fa0f5e36f/orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50", size = 243571, upload-time = "2025-10-24T15:49:10.008Z" },
{ url = "https://files.pythonhosted.org/packages/1c/2c/2602392ddf2601d538ff11848b98621cd465d1a1ceb9db9e8043181f2f7b/orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853", size = 128891, upload-time = "2025-10-24T15:49:11.297Z" },
{ url = "https://files.pythonhosted.org/packages/4e/47/bf85dcf95f7a3a12bf223394a4f849430acd82633848d52def09fa3f46ad/orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938", size = 130137, upload-time = "2025-10-24T15:49:12.544Z" },
{ url = "https://files.pythonhosted.org/packages/b4/4d/a0cb31007f3ab6f1fd2a1b17057c7c349bc2baf8921a85c0180cc7be8011/orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415", size = 129152, upload-time = "2025-10-24T15:49:13.754Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ef/2811def7ce3d8576b19e3929fff8f8f0d44bc5eb2e0fdecb2e6e6cc6c720/orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44", size = 136834, upload-time = "2025-10-24T15:49:15.307Z" },
{ url = "https://files.pythonhosted.org/packages/00/d4/9aee9e54f1809cec8ed5abd9bc31e8a9631d19460e3b8470145d25140106/orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2", size = 137519, upload-time = "2025-10-24T15:49:16.557Z" },
{ url = "https://files.pythonhosted.org/packages/db/ea/67bfdb5465d5679e8ae8d68c11753aaf4f47e3e7264bad66dc2f2249e643/orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708", size = 136749, upload-time = "2025-10-24T15:49:17.796Z" },
{ url = "https://files.pythonhosted.org/packages/01/7e/62517dddcfce6d53a39543cd74d0dccfcbdf53967017c58af68822100272/orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210", size = 136325, upload-time = "2025-10-24T15:49:19.347Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/40516739f99ab4c7ec3aaa5cc242d341fcb03a45d89edeeaabc5f69cb2cf/orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241", size = 140204, upload-time = "2025-10-24T15:49:20.545Z" },
{ url = "https://files.pythonhosted.org/packages/82/18/ff5734365623a8916e3a4037fcef1cd1782bfc14cf0992afe7940c5320bf/orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b", size = 406242, upload-time = "2025-10-24T15:49:21.884Z" },
{ url = "https://files.pythonhosted.org/packages/e1/43/96436041f0a0c8c8deca6a05ebeaf529bf1de04839f93ac5e7c479807aec/orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c", size = 150013, upload-time = "2025-10-24T15:49:23.185Z" },
{ url = "https://files.pythonhosted.org/packages/1b/48/78302d98423ed8780479a1e682b9aecb869e8404545d999d34fa486e573e/orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9", size = 139951, upload-time = "2025-10-24T15:49:24.428Z" },
{ url = "https://files.pythonhosted.org/packages/4a/7b/ad613fdcdaa812f075ec0875143c3d37f8654457d2af17703905425981bf/orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa", size = 136049, upload-time = "2025-10-24T15:49:25.973Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3c/9cf47c3ff5f39b8350fb21ba65d789b6a1129d4cbb3033ba36c8a9023520/orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140", size = 131461, upload-time = "2025-10-24T15:49:27.259Z" },
{ url = "https://files.pythonhosted.org/packages/c6/3b/e2425f61e5825dc5b08c2a5a2b3af387eaaca22a12b9c8c01504f8614c36/orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e", size = 126167, upload-time = "2025-10-24T15:49:28.511Z" },
{ url = "https://files.pythonhosted.org/packages/23/15/c52aa7112006b0f3d6180386c3a46ae057f932ab3425bc6f6ac50431cca1/orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534", size = 243525, upload-time = "2025-10-24T15:49:29.737Z" },
{ url = "https://files.pythonhosted.org/packages/ec/38/05340734c33b933fd114f161f25a04e651b0c7c33ab95e9416ade5cb44b8/orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff", size = 128871, upload-time = "2025-10-24T15:49:31.109Z" },
{ url = "https://files.pythonhosted.org/packages/55/b9/ae8d34899ff0c012039b5a7cb96a389b2476e917733294e498586b45472d/orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad", size = 130055, upload-time = "2025-10-24T15:49:33.382Z" },
{ url = "https://files.pythonhosted.org/packages/33/aa/6346dd5073730451bee3681d901e3c337e7ec17342fb79659ec9794fc023/orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5", size = 129061, upload-time = "2025-10-24T15:49:34.935Z" },
{ url = "https://files.pythonhosted.org/packages/39/e4/8eea51598f66a6c853c380979912d17ec510e8e66b280d968602e680b942/orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a", size = 136541, upload-time = "2025-10-24T15:49:36.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/47/cb8c654fa9adcc60e99580e17c32b9e633290e6239a99efa6b885aba9dbc/orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436", size = 137535, upload-time = "2025-10-24T15:49:38.307Z" },
{ url = "https://files.pythonhosted.org/packages/43/92/04b8cc5c2b729f3437ee013ce14a60ab3d3001465d95c184758f19362f23/orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9", size = 136703, upload-time = "2025-10-24T15:49:40.795Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fd/d0733fcb9086b8be4ebcfcda2d0312865d17d0d9884378b7cffb29d0763f/orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73", size = 136293, upload-time = "2025-10-24T15:49:42.347Z" },
{ url = "https://files.pythonhosted.org/packages/c2/d7/3c5514e806837c210492d72ae30ccf050ce3f940f45bf085bab272699ef4/orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0", size = 140131, upload-time = "2025-10-24T15:49:43.638Z" },
{ url = "https://files.pythonhosted.org/packages/9c/dd/ba9d32a53207babf65bd510ac4d0faaa818bd0df9a9c6f472fe7c254f2e3/orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196", size = 406164, upload-time = "2025-10-24T15:49:45.498Z" },
{ url = "https://files.pythonhosted.org/packages/8e/f9/f68ad68f4af7c7bde57cd514eaa2c785e500477a8bc8f834838eb696a685/orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a", size = 149859, upload-time = "2025-10-24T15:49:46.981Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d2/7f847761d0c26818395b3d6b21fb6bc2305d94612a35b0a30eae65a22728/orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6", size = 139926, upload-time = "2025-10-24T15:49:48.321Z" },
{ url = "https://files.pythonhosted.org/packages/9f/37/acd14b12dc62db9a0e1d12386271b8661faae270b22492580d5258808975/orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839", size = 136007, upload-time = "2025-10-24T15:49:49.938Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a9/967be009ddf0a1fffd7a67de9c36656b28c763659ef91352acc02cbe364c/orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a", size = 131314, upload-time = "2025-10-24T15:49:51.248Z" },
{ url = "https://files.pythonhosted.org/packages/cb/db/399abd6950fbd94ce125cb8cd1a968def95174792e127b0642781e040ed4/orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de", size = 126152, upload-time = "2025-10-24T15:49:52.922Z" },
{ url = "https://files.pythonhosted.org/packages/25/e3/54ff63c093cc1697e758e4fceb53164dd2661a7d1bcd522260ba09f54533/orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803", size = 243501, upload-time = "2025-10-24T15:49:54.288Z" },
{ url = "https://files.pythonhosted.org/packages/ac/7d/e2d1076ed2e8e0ae9badca65bf7ef22710f93887b29eaa37f09850604e09/orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54", size = 128862, upload-time = "2025-10-24T15:49:55.961Z" },
{ url = "https://files.pythonhosted.org/packages/9f/37/ca2eb40b90621faddfa9517dfe96e25f5ae4d8057a7c0cdd613c17e07b2c/orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e", size = 130047, upload-time = "2025-10-24T15:49:57.406Z" },
{ url = "https://files.pythonhosted.org/packages/c7/62/1021ed35a1f2bad9040f05fa4cc4f9893410df0ba3eaa323ccf899b1c90a/orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316", size = 129073, upload-time = "2025-10-24T15:49:58.782Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3f/f84d966ec2a6fd5f73b1a707e7cd876813422ae4bf9f0145c55c9c6a0f57/orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1", size = 136597, upload-time = "2025-10-24T15:50:00.12Z" },
{ url = "https://files.pythonhosted.org/packages/32/78/4fa0aeca65ee82bbabb49e055bd03fa4edea33f7c080c5c7b9601661ef72/orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc", size = 137515, upload-time = "2025-10-24T15:50:01.57Z" },
{ url = "https://files.pythonhosted.org/packages/c1/9d/0c102e26e7fde40c4c98470796d050a2ec1953897e2c8ab0cb95b0759fa2/orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f", size = 136703, upload-time = "2025-10-24T15:50:02.944Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/2de7188705b4cdfaf0b6c97d2f7849c17d2003232f6e70df98602173f788/orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf", size = 136311, upload-time = "2025-10-24T15:50:04.441Z" },
{ url = "https://files.pythonhosted.org/packages/e0/52/847fcd1a98407154e944feeb12e3b4d487a0e264c40191fb44d1269cbaa1/orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606", size = 140127, upload-time = "2025-10-24T15:50:07.398Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ae/21d208f58bdb847dd4d0d9407e2929862561841baa22bdab7aea10ca088e/orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780", size = 406201, upload-time = "2025-10-24T15:50:08.796Z" },
{ url = "https://files.pythonhosted.org/packages/8d/55/0789d6de386c8366059db098a628e2ad8798069e94409b0d8935934cbcb9/orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23", size = 149872, upload-time = "2025-10-24T15:50:10.234Z" },
{ url = "https://files.pythonhosted.org/packages/cc/1d/7ff81ea23310e086c17b41d78a72270d9de04481e6113dbe2ac19118f7fb/orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155", size = 139931, upload-time = "2025-10-24T15:50:11.623Z" },
{ url = "https://files.pythonhosted.org/packages/77/92/25b886252c50ed64be68c937b562b2f2333b45afe72d53d719e46a565a50/orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394", size = 136065, upload-time = "2025-10-24T15:50:13.025Z" },
{ url = "https://files.pythonhosted.org/packages/63/b8/718eecf0bb7e9d64e4956afaafd23db9f04c776d445f59fe94f54bdae8f0/orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1", size = 131310, upload-time = "2025-10-24T15:50:14.46Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bf/def5e25d4d8bfce296a9a7c8248109bf58622c21618b590678f945a2c59c/orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d", size = 126151, upload-time = "2025-10-24T15:50:15.878Z" },
]
[[package]]
@@ -5919,6 +6185,55 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/ea/c67e1dee1ba208ed22c06d1d547ae5e293374bfc43e0eb0ef5e262b68561/werkzeug-3.1.1-py3-none-any.whl", hash = "sha256:a71124d1ef06008baafa3d266c02f56e1836a5984afd6dd6c9230669d60d9fb5", size = 224371, upload-time = "2024-11-01T16:40:43.994Z" },
]
[[package]]
name = "wrapt"
version = "1.17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" },
{ url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" },
{ url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" },
{ url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" },
{ url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" },
{ url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" },
{ url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" },
{ url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" },
{ url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" },
{ url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" },
{ url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" },
{ url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" },
{ url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" },
{ url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" },
{ url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" },
{ url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" },
{ url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" },
{ url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" },
{ url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" },
{ url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" },
{ url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" },
{ url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" },
{ url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
]
[[package]]
name = "yarl"
version = "1.22.0"
@@ -57,6 +57,16 @@ class AppConversationInfoService(ABC):
]
)
@abstractmethod
async def delete_app_conversation_info(self, conversation_id: UUID) -> bool:
"""Delete a conversation info from the database.
Args:
conversation_id: The ID of the conversation to delete.
Returns True if the conversation was deleted successfully, False otherwise.
"""
# Mutators
@abstractmethod

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