mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Merge branch 'main' into migrate-org-db-litellm-from-deploy
This commit is contained in:
@@ -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.61-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -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.61-nikolaik
|
||||
docker pull docker.openhands.dev/openhands/runtime:0.62-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.62-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.61
|
||||
docker.openhands.dev/openhands/openhands:0.62
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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.61-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -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.61-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-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:
|
||||
|
||||
@@ -5,12 +5,8 @@ from experiments.constants import (
|
||||
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
)
|
||||
from experiments.experiment_versions import (
|
||||
handle_condenser_max_step_experiment,
|
||||
handle_system_prompt_experiment,
|
||||
)
|
||||
from experiments.experiment_versions._004_condenser_max_step_experiment import (
|
||||
handle_condenser_max_step_experiment__v1,
|
||||
)
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -31,10 +27,6 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
)
|
||||
return agent
|
||||
|
||||
agent = handle_condenser_max_step_experiment__v1(
|
||||
user_id, conversation_id, agent
|
||||
)
|
||||
|
||||
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
|
||||
agent = agent.model_copy(
|
||||
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
|
||||
@@ -60,20 +52,7 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
"""
|
||||
logger.debug(
|
||||
'experiment_manager:run_conversation_variant_test:started',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Skip all experiment processing if the experiment manager is disabled
|
||||
if not ENABLE_EXPERIMENT_MANAGER:
|
||||
logger.info(
|
||||
'experiment_manager:run_conversation_variant_test:skipped',
|
||||
extra={'reason': 'experiment_manager_disabled'},
|
||||
)
|
||||
return conversation_settings
|
||||
|
||||
# Apply conversation-scoped experiments
|
||||
conversation_settings = handle_condenser_max_step_experiment(
|
||||
user_id, conversation_id, conversation_settings
|
||||
extra={'user_id': user_id, 'conversation_id': conversation_id},
|
||||
)
|
||||
|
||||
return conversation_settings
|
||||
|
||||
44
enterprise/poetry.lock
generated
44
enterprise/poetry.lock
generated
@@ -483,7 +483,7 @@ description = "LTS Port of Python audioop"
|
||||
optional = false
|
||||
python-versions = ">=3.13"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13.0\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"},
|
||||
{file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"},
|
||||
@@ -1743,8 +1743,8 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
bytecode = [
|
||||
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
|
||||
{version = ">=0.15.1", markers = "python_version ~= \"3.12.0\""},
|
||||
{version = ">=0.16.0", markers = "python_version >= \"3.13.0\""},
|
||||
]
|
||||
envier = ">=0.6.1,<0.7.0"
|
||||
legacy-cgi = {version = ">=2.0.0", markers = "python_version >= \"3.13.0\""}
|
||||
@@ -2758,8 +2758,8 @@ googleapis-common-protos = ">=1.56.2,<2.0.0"
|
||||
grpcio = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
grpcio-status = {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
|
||||
requests = ">=2.18.0,<3.0.0"
|
||||
@@ -2871,8 +2871,8 @@ google-auth = ">=2.14.1,<3.0.0"
|
||||
google-cloud-bigquery = ">=1.15.0,<3.20.0 || >3.20.0,<4.0.0"
|
||||
google-cloud-resource-manager = ">=1.3.3,<3.0.0"
|
||||
google-cloud-storage = [
|
||||
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.32.0,<4.0.0", markers = "python_version < \"3.13\""},
|
||||
{version = ">=2.10.0,<4.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
google-genai = ">=1.37.0,<2.0.0"
|
||||
packaging = ">=14.3"
|
||||
@@ -2981,8 +2981,8 @@ google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0"
|
||||
grpc-google-iam-v1 = ">=0.14.0,<1.0.0"
|
||||
grpcio = ">=1.33.2,<2.0.0"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0"},
|
||||
{version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0"
|
||||
|
||||
@@ -4574,7 +4574,7 @@ description = "Fork of the standard library cgi and cgitb modules removed in Pyt
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13.0\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd"},
|
||||
{file = "legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577"},
|
||||
@@ -6040,14 +6040,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.0.0a6"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.0.0a6-py3-none-any.whl", hash = "sha256:72b0da038ede018c55c64f0ac99bc5d991af173627efc63de87d54b3cd69134c"},
|
||||
{file = "openhands_agent_server-1.0.0a6.tar.gz", hash = "sha256:8c6fbceb07990e3caf7f8797082d1bb614b9f7339bd00576c24fd34a956a03b4"},
|
||||
{file = "openhands_agent_server-1.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
|
||||
{file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6063,7 +6063,7 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.61.0"
|
||||
version = "0.62.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -6104,9 +6104,9 @@ memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = "1.0.0a6"
|
||||
openhands-sdk = "1.0.0a6"
|
||||
openhands-tools = "1.0.0a6"
|
||||
openhands-agent-server = "1.1.0"
|
||||
openhands-sdk = "1.1.0"
|
||||
openhands-tools = "1.1.0"
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -6162,14 +6162,14 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0a6"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.0.0a6-py3-none-any.whl", hash = "sha256:0b0b579fc48a5b7eaa418ca66188206ba00f4d883997bc29291bc1745e0b7ddc"},
|
||||
{file = "openhands_sdk-1.0.0a6.tar.gz", hash = "sha256:01daff435c5f94037b9b4ba85054097ca6235982a9b0fee00341279d4c4b5a01"},
|
||||
{file = "openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
|
||||
{file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6188,14 +6188,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0a6"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.0.0a6-py3-none-any.whl", hash = "sha256:55b75016f7e3930e4365393a026726eeffae027363d03862a17a8cebc1aed670"},
|
||||
{file = "openhands_tools-1.0.0a6.tar.gz", hash = "sha256:4d5382f3e1cab9d23c1ef7ea8e36e821083886d6d4b019100cbf897e3b0cd3be"},
|
||||
{file = "openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
|
||||
{file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6269,8 +6269,8 @@ files = [
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.57,<2.0"
|
||||
grpcio = [
|
||||
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.63.2,<2.0.0", markers = "python_version < \"3.13\""},
|
||||
{version = ">=1.66.2,<2.0.0", markers = "python_version >= \"3.13\""},
|
||||
]
|
||||
opentelemetry-api = ">=1.15,<2.0"
|
||||
opentelemetry-exporter-otlp-proto-common = "1.38.0"
|
||||
@@ -13367,7 +13367,7 @@ description = "Standard library aifc redistribution. \"dead battery\"."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13.0\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "standard_aifc-3.13.0-py3-none-any.whl", hash = "sha256:f7ae09cc57de1224a0dd8e3eb8f73830be7c3d0bc485de4c1f82b4a7f645ac66"},
|
||||
{file = "standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43"},
|
||||
@@ -13384,7 +13384,7 @@ description = "Standard library chunk redistribution. \"dead battery\"."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.13.0\""
|
||||
markers = "python_version == \"3.13\""
|
||||
files = [
|
||||
{file = "standard_chunk-3.13.0-py3-none-any.whl", hash = "sha256:17880a26c285189c644bd5bd8f8ed2bdb795d216e3293e6dbe55bbd848e2982c"},
|
||||
{file = "standard_chunk-3.13.0.tar.gz", hash = "sha256:4ac345d37d7e686d2755e01836b8d98eda0d1a3ee90375e597ae43aaf064d654"},
|
||||
|
||||
@@ -32,6 +32,7 @@ from openhands.server.services.conversation_service import create_provider_token
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import get_access_token
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
from openhands.utils.posthog_tracker import track_user_signup_completed
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
@@ -376,6 +377,12 @@ async def accept_tos(request: Request):
|
||||
|
||||
logger.info(f'User {user_id} accepted TOS')
|
||||
|
||||
# Track user signup completion in PostHog
|
||||
track_user_signup_completed(
|
||||
user_id=user_id,
|
||||
signup_timestamp=user_settings.accepted_tos.isoformat(),
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.utils.posthog_tracker import track_credits_purchased
|
||||
|
||||
stripe.api_key = STRIPE_API_KEY
|
||||
billing_router = APIRouter(prefix='/api/billing')
|
||||
@@ -246,6 +247,20 @@ async def success_callback(session_id: str, request: Request):
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Track credits purchased in PostHog
|
||||
try:
|
||||
track_credits_purchased(
|
||||
user_id=billing_session.user_id,
|
||||
amount_usd=amount_subtotal / 100, # Convert cents to dollars
|
||||
credits_added=add_credits,
|
||||
stripe_session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to track credits purchase: {e}',
|
||||
extra={'user_id': billing_session.user_id, 'error': str(e)},
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings/billing?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
@@ -92,11 +92,8 @@ def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
|
||||
assert getattr(result, 'condenser', None) is None
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
|
||||
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
|
||||
mock_handle_condenser,
|
||||
):
|
||||
def test_run_agent_variant_tests_v1_noop_when_manager_disabled():
|
||||
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
@@ -109,8 +106,6 @@ def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
|
||||
|
||||
# Same object returned (no copy)
|
||||
assert result is agent
|
||||
# Handler should not have been called
|
||||
mock_handle_condenser.assert_not_called()
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
|
||||
@@ -131,7 +126,3 @@ def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeyp
|
||||
# Should be a different instance than the original (copied after handler runs)
|
||||
assert result is not agent
|
||||
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
|
||||
|
||||
# The condenser returned by the handler must be preserved after the system-prompt override copy
|
||||
assert isinstance(result.condenser, LLMSummarizingCondenser)
|
||||
assert result.condenser.max_size == 80
|
||||
|
||||
@@ -8,10 +8,11 @@ vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"TASK_TRACKING_OBSERVATION$TASK_LIST": "Task List",
|
||||
"TASK_TRACKING_OBSERVATION$TASK_ID": "ID",
|
||||
"TASK_TRACKING_OBSERVATION$TASK_NOTES": "Notes",
|
||||
"TASK_TRACKING_OBSERVATION$RESULT": "Result",
|
||||
TASK_TRACKING_OBSERVATION$TASK_LIST: "Task List",
|
||||
TASK_TRACKING_OBSERVATION$TASK_ID: "ID",
|
||||
TASK_TRACKING_OBSERVATION$TASK_NOTES: "Notes",
|
||||
TASK_TRACKING_OBSERVATION$RESULT: "Result",
|
||||
COMMON$TASKS: "Tasks",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -61,19 +62,26 @@ describe("TaskTrackingObservationContent", () => {
|
||||
it("renders task list when command is 'plan' and tasks exist", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("Task List (3 items)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tasks")).toBeInTheDocument();
|
||||
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays correct status icons and badges", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
const { container } = render(
|
||||
<TaskTrackingObservationContent event={mockEvent} />,
|
||||
);
|
||||
|
||||
// Check for status text (the icons are emojis)
|
||||
expect(screen.getByText("todo")).toBeInTheDocument();
|
||||
expect(screen.getByText("in progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("done")).toBeInTheDocument();
|
||||
// Status is represented by icons, not text. Verify task items are rendered with their titles
|
||||
// which indicates the status icons are present (status affects icon rendering)
|
||||
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
|
||||
|
||||
// Verify task items are present (they contain the status icons)
|
||||
const taskItems = container.querySelectorAll('[data-name="item"]');
|
||||
expect(taskItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("displays task IDs and notes", () => {
|
||||
@@ -84,14 +92,9 @@ describe("TaskTrackingObservationContent", () => {
|
||||
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
|
||||
expect(screen.getByText("Notes: Completed successfully")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders result section when content exists", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("Result")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task tracking operation completed successfully")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Notes: Completed successfully"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render task list when command is not 'plan'", () => {
|
||||
@@ -105,7 +108,7 @@ describe("TaskTrackingObservationContent", () => {
|
||||
|
||||
render(<TaskTrackingObservationContent event={eventWithoutPlan} />);
|
||||
|
||||
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render task list when task list is empty", () => {
|
||||
@@ -119,17 +122,6 @@ describe("TaskTrackingObservationContent", () => {
|
||||
|
||||
render(<TaskTrackingObservationContent event={eventWithEmptyTasks} />);
|
||||
|
||||
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render result section when content is empty", () => {
|
||||
const eventWithoutContent = {
|
||||
...mockEvent,
|
||||
content: "",
|
||||
};
|
||||
|
||||
render(<TaskTrackingObservationContent event={eventWithoutContent} />);
|
||||
|
||||
expect(screen.queryByText("Result")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.62.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.62.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.62.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import { isTaskTrackingObservation } from "#/types/core/guards";
|
||||
import { GenericEventMessage } from "../generic-event-message";
|
||||
import { TaskTrackingObservationContent } from "../task-tracking-observation-content";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
|
||||
interface TaskTrackingEventMessageProps {
|
||||
event: OpenHandsObservation;
|
||||
@@ -16,34 +12,13 @@ export function TaskTrackingEventMessage({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
}: TaskTrackingEventMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isTaskTrackingObservation(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { command } = event.extras;
|
||||
let title: React.ReactNode;
|
||||
let initiallyExpanded = false;
|
||||
|
||||
// Determine title and expansion state based on command
|
||||
if (command === "plan") {
|
||||
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_PLAN");
|
||||
initiallyExpanded = true;
|
||||
} else {
|
||||
// command === "view"
|
||||
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_VIEW");
|
||||
initiallyExpanded = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GenericEventMessage
|
||||
title={title}
|
||||
details={<TaskTrackingObservationContent event={event} />}
|
||||
success={getObservationResult(event)}
|
||||
initiallyExpanded={initiallyExpanded}
|
||||
/>
|
||||
<TaskTrackingObservationContent event={event} />
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TaskTrackingObservation } from "#/types/core/observations";
|
||||
import { TaskListSection } from "./task-tracking/task-list-section";
|
||||
import { ResultSection } from "./task-tracking/result-section";
|
||||
|
||||
interface TaskTrackingObservationContentProps {
|
||||
event: TaskTrackingObservation;
|
||||
@@ -16,11 +15,6 @@ export function TaskTrackingObservationContent({
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Task List section - only show for 'plan' command */}
|
||||
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
|
||||
|
||||
{/* Result message - only show if there's meaningful content */}
|
||||
{event.content && event.content.trim() && (
|
||||
<ResultSection content={event.content} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface ResultSectionProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ResultSection({ content }: ResultSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.H3>{t("TASK_TRACKING_OBSERVATION$RESULT")}</Typography.H3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
|
||||
<pre className="whitespace-pre-wrap text-sm">{content.trim()}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CircleIcon from "#/icons/u-circle.svg?react";
|
||||
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
|
||||
import LoadingIcon from "#/icons/loading.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
interface TaskItemProps {
|
||||
task: {
|
||||
@@ -10,33 +14,47 @@ interface TaskItemProps {
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
};
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function TaskItem({ task, index }: TaskItemProps) {
|
||||
export function TaskItem({ task }: TaskItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (task.status) {
|
||||
case "todo":
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
case "in_progress":
|
||||
return <LoadingIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
case "done":
|
||||
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
|
||||
default:
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
}
|
||||
}, [task.status]);
|
||||
|
||||
const isDoneStatus = task.status === "done";
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-gray-600 pl-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={task.status} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Typography.Text className="text-sm text-gray-400">
|
||||
{index + 1}.
|
||||
</Typography.Text>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<h4 className="font-medium text-white mb-1">{task.title}</h4>
|
||||
<Typography.Text className="text-xs text-gray-400 mb-1">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
|
||||
</Typography.Text>
|
||||
{task.notes && (
|
||||
<Typography.Text className="text-sm text-gray-300 italic">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}: {task.notes}
|
||||
</Typography.Text>
|
||||
<div
|
||||
className="flex gap-[14px] items-center px-4 py-2 w-full"
|
||||
data-name="item"
|
||||
>
|
||||
<div className="shrink-0">{icon}</div>
|
||||
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"text-[12px] text-white",
|
||||
isDoneStatus && "text-[#A3A3A3]",
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3] font-normal">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3]">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskItem } from "./task-item";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface TaskListSectionProps {
|
||||
@@ -15,19 +17,20 @@ export function TaskListSection({ taskList }: TaskListSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.H3>
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
|
||||
{taskList.length === 1 ? "item" : "items"})
|
||||
</Typography.H3>
|
||||
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
|
||||
{/* Header Tabs */}
|
||||
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
|
||||
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
|
||||
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
|
||||
{t(I18nKey.COMMON$TASKS)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<div className="space-y-3">
|
||||
{taskList.map((task, index) => (
|
||||
<TaskItem key={task.id} task={task} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task Items */}
|
||||
<div>
|
||||
{taskList.map((task) => (
|
||||
<TaskItem key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import React from "react";
|
||||
import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { MonoComponent } from "../../../features/chat/mono-component";
|
||||
import { PathComponent } from "../../../features/chat/path-component";
|
||||
import { getActionContent } from "./get-action-content";
|
||||
import { getObservationContent } from "./get-observation-content";
|
||||
import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content";
|
||||
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
|
||||
import i18n from "#/i18n";
|
||||
|
||||
const trimText = (text: string, maxLength: number): string => {
|
||||
@@ -158,14 +161,24 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
|
||||
export const getEventContent = (event: OpenHandsEvent) => {
|
||||
let title: React.ReactNode = "";
|
||||
let details: string = "";
|
||||
let details: string | React.ReactNode = "";
|
||||
|
||||
if (isActionEvent(event)) {
|
||||
title = getActionEventTitle(event);
|
||||
details = getActionContent(event);
|
||||
} else if (isObservationEvent(event)) {
|
||||
title = getObservationEventTitle(event);
|
||||
details = getObservationContent(event);
|
||||
|
||||
// For TaskTrackerObservation, use React component instead of markdown
|
||||
if (event.observation.kind === "TaskTrackerObservation") {
|
||||
details = (
|
||||
<TaskTrackingObservationContent
|
||||
event={event as ObservationEvent<TaskTrackerObservation>}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
details = getObservationContent(event);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,10 @@ const getExecuteBashObservationContent = (
|
||||
|
||||
let { output } = observation;
|
||||
|
||||
if (!output) {
|
||||
output = "";
|
||||
}
|
||||
|
||||
if (output.length > MAX_CONTENT_LENGTH) {
|
||||
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
@@ -136,6 +140,7 @@ const getTaskTrackerObservationContent = (
|
||||
if (
|
||||
"content" in observation &&
|
||||
observation.content &&
|
||||
typeof observation.content === "string" &&
|
||||
observation.content.trim()
|
||||
) {
|
||||
content += `\n\n**Result:** ${observation.content.trim()}`;
|
||||
|
||||
@@ -27,13 +27,16 @@ export function FinishEventMessage({
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: FinishEventMessageProps) {
|
||||
const eventContent = getEventContent(event);
|
||||
// For FinishAction, details is always a string (getActionContent returns string)
|
||||
const message =
|
||||
typeof eventContent.details === "string"
|
||||
? eventContent.details
|
||||
: String(eventContent.details);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={getEventContent(event).details}
|
||||
actions={actions}
|
||||
/>
|
||||
<ChatMessage type="agent" message={message} actions={actions} />
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
|
||||
@@ -16,6 +16,13 @@ export function GenericEventMessageWrapper({
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
if (
|
||||
isObservationEvent(event) &&
|
||||
event.observation.kind === "TaskTrackerObservation"
|
||||
) {
|
||||
return <div>{details}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GenericEventMessage
|
||||
|
||||
56
frontend/src/components/v1/chat/task-tracking/task-item.tsx
Normal file
56
frontend/src/components/v1/chat/task-tracking/task-item.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
|
||||
import CircleIcon from "#/icons/u-circle.svg?react";
|
||||
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
|
||||
import LoadingIcon from "#/icons/loading.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface TaskItemProps {
|
||||
task: TaskItemType;
|
||||
}
|
||||
|
||||
export function TaskItem({ task }: TaskItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (task.status) {
|
||||
case "todo":
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
case "in_progress":
|
||||
return (
|
||||
<LoadingIcon className="w-4 h-4 text-[#ffffff]" strokeWidth={0.5} />
|
||||
);
|
||||
case "done":
|
||||
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
|
||||
default:
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
}
|
||||
}, [task.status]);
|
||||
|
||||
const isDoneStatus = task.status === "done";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-[14px] items-center px-4 py-2 w-full"
|
||||
data-name="item"
|
||||
>
|
||||
<div className="shrink-0">{icon}</div>
|
||||
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"text-[12px] text-white",
|
||||
isDoneStatus && "text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3]">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskItem } from "./task-item";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface TaskListSectionProps {
|
||||
taskList: TaskItemType[];
|
||||
}
|
||||
|
||||
export function TaskListSection({ taskList }: TaskListSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
|
||||
{/* Header Tabs */}
|
||||
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
|
||||
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
|
||||
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
|
||||
{t(I18nKey.COMMON$TASKS)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* Task Items */}
|
||||
<div>
|
||||
{taskList.map((task, index) => (
|
||||
<TaskItem key={`task-${index}`} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
|
||||
import { TaskListSection } from "./task-list-section";
|
||||
|
||||
interface TaskTrackingObservationContentProps {
|
||||
event: ObservationEvent<TaskTrackerObservation>;
|
||||
}
|
||||
|
||||
export function TaskTrackingObservationContent({
|
||||
event,
|
||||
}: TaskTrackingObservationContentProps): React.ReactNode {
|
||||
const { observation } = event;
|
||||
const { command, task_list: taskList } = observation;
|
||||
const shouldShowTaskList = command === "plan" && taskList.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Task List section - only show for 'plan' command */}
|
||||
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -142,6 +142,7 @@ export function WsClientProvider({
|
||||
const { addEvent, clearEvents } = useEventStore();
|
||||
const queryClient = useQueryClient();
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const pendingEventsRef = React.useRef<Record<string, unknown>[]>([]);
|
||||
const [webSocketStatus, setWebSocketStatus] =
|
||||
React.useState<V0_WebSocketStatus>("DISCONNECTED");
|
||||
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
|
||||
@@ -151,17 +152,37 @@ export function WsClientProvider({
|
||||
const { data: conversation, refetch: refetchConversation } =
|
||||
useActiveConversation();
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
if (!sioRef.current) {
|
||||
EventLogger.error("WebSocket is not connected.");
|
||||
function flushPendingEvents(socket: Socket | null = sioRef.current) {
|
||||
if (!socket || pendingEventsRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
sioRef.current.emit("oh_user_action", event);
|
||||
|
||||
pendingEventsRef.current.forEach((queuedEvent) => {
|
||||
socket.emit("oh_user_action", queuedEvent);
|
||||
});
|
||||
pendingEventsRef.current = [];
|
||||
}
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
const socket = sioRef.current;
|
||||
|
||||
if (!socket) {
|
||||
EventLogger.error("WebSocket is not connected, queuing message...");
|
||||
pendingEventsRef.current.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingEventsRef.current.length > 0) {
|
||||
flushPendingEvents(socket);
|
||||
}
|
||||
|
||||
socket.emit("oh_user_action", event);
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
setWebSocketStatus("CONNECTED");
|
||||
removeErrorMessage();
|
||||
flushPendingEvents();
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
@@ -292,6 +313,7 @@ export function WsClientProvider({
|
||||
|
||||
clearEvents();
|
||||
setWebSocketStatus("CONNECTING");
|
||||
pendingEventsRef.current = [];
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -301,6 +323,12 @@ export function WsClientProvider({
|
||||
|
||||
// Clear error messages when conversation is intentionally stopped
|
||||
if (conversation && conversation.status === "STOPPED") {
|
||||
const existingSocket = sioRef.current;
|
||||
if (existingSocket) {
|
||||
existingSocket.disconnect();
|
||||
}
|
||||
sioRef.current = null;
|
||||
pendingEventsRef.current = [];
|
||||
removeErrorMessage();
|
||||
setWebSocketStatus("DISCONNECTED");
|
||||
return () => undefined; // conversation intentionally stopped
|
||||
@@ -320,6 +348,10 @@ export function WsClientProvider({
|
||||
!conversation.runtime_status ||
|
||||
conversation.runtime_status === "STATUS$STOPPED"
|
||||
) {
|
||||
if (sioRef.current) {
|
||||
sioRef.current.disconnect();
|
||||
}
|
||||
sioRef.current = null;
|
||||
return () => undefined; // conversation not ready for WebSocket connection
|
||||
}
|
||||
|
||||
@@ -368,6 +400,7 @@ export function WsClientProvider({
|
||||
sio.on("disconnect", handleDisconnect);
|
||||
|
||||
sioRef.current = sio;
|
||||
flushPendingEvents(sio);
|
||||
|
||||
return () => {
|
||||
sio.off("connect", handleConnect);
|
||||
|
||||
@@ -22,7 +22,7 @@ const renderCommand = (
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedContent = content.replaceAll("\n", "\r\n").trim();
|
||||
const trimmedContent = (content || "").replaceAll("\n", "\r\n").trim();
|
||||
// Only write if there's actual content to avoid empty newlines
|
||||
if (trimmedContent) {
|
||||
terminal.writeln(parseTerminalOutput(trimmedContent));
|
||||
|
||||
@@ -937,6 +937,7 @@ export enum I18nKey {
|
||||
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",
|
||||
COMMON$TASKS = "COMMON$TASKS",
|
||||
COMMON$PLAN_MD = "COMMON$PLAN_MD",
|
||||
COMMON$READ_MORE = "COMMON$READ_MORE",
|
||||
COMMON$BUILD = "COMMON$BUILD",
|
||||
|
||||
@@ -14991,6 +14991,22 @@
|
||||
"de": "Einen Plan erstellen",
|
||||
"uk": "Створити план"
|
||||
},
|
||||
"COMMON$TASKS": {
|
||||
"en": "Tasks",
|
||||
"ja": "タスク",
|
||||
"zh-CN": "任务",
|
||||
"zh-TW": "任務",
|
||||
"ko-KR": "작업",
|
||||
"no": "Oppgaver",
|
||||
"it": "Attività",
|
||||
"pt": "Tarefas",
|
||||
"es": "Tareas",
|
||||
"ar": "مهام",
|
||||
"fr": "Tâches",
|
||||
"tr": "Görevler",
|
||||
"de": "Aufgaben",
|
||||
"uk": "Завдання"
|
||||
},
|
||||
"COMMON$PLAN_MD": {
|
||||
"en": "Plan.md",
|
||||
"ja": "Plan.md",
|
||||
|
||||
3
frontend/src/icons/loading.svg
Normal file
3
frontend/src/icons/loading.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="7" viewBox="0 0 16 7" fill="none">
|
||||
<path d="M7.50684 0.25C9.24918 0.25 10.9332 0.87774 12.251 2.01758C13.5688 3.15746 14.4327 4.73379 14.6836 6.45801L14.7256 6.74316H13.2129L13.1777 6.53516C12.9499 5.19635 12.2554 3.98161 11.2178 3.10547C10.1799 2.22925 8.86511 1.74805 7.50684 1.74805C6.14866 1.74811 4.83466 2.22931 3.79688 3.10547C2.75913 3.98161 2.06476 5.19628 1.83691 6.53516L1.80078 6.74316H0.289063L0.331055 6.45801C0.581982 4.73389 1.44504 3.15745 2.7627 2.01758C4.08041 0.877757 5.76455 0.250069 7.50684 0.25Z" fill="currentColor" stroke="currentColor" stroke-width="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 652 B |
3
frontend/src/icons/u-check-circle.svg
Normal file
3
frontend/src/icons/u-check-circle.svg
Normal 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="M14.72 8.79L10.43 13.09L8.78 11.44C8.69036 11.3353 8.58004 11.2503 8.45597 11.1903C8.33191 11.1303 8.19678 11.0965 8.05906 11.0912C7.92134 11.0859 7.78401 11.1091 7.65568 11.1594C7.52736 11.2096 7.41081 11.2859 7.31335 11.3833C7.2159 11.4808 7.13964 11.5974 7.08937 11.7257C7.03909 11.854 7.01589 11.9913 7.02121 12.1291C7.02653 12.2668 7.06026 12.4019 7.12028 12.526C7.1803 12.65 7.26532 12.7604 7.37 12.85L9.72 15.21C9.81344 15.3027 9.92426 15.376 10.0461 15.4258C10.1679 15.4755 10.2984 15.5008 10.43 15.5C10.6923 15.4989 10.9437 15.3947 11.13 15.21L16.13 10.21C16.2237 10.117 16.2981 10.0064 16.3489 9.88458C16.3997 9.76272 16.4258 9.63201 16.4258 9.5C16.4258 9.36799 16.3997 9.23728 16.3489 9.11542C16.2981 8.99356 16.2237 8.88296 16.13 8.79C15.9426 8.60375 15.6892 8.49921 15.425 8.49921C15.1608 8.49921 14.9074 8.60375 14.72 8.79ZM12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
3
frontend/src/icons/u-circle.svg
Normal file
3
frontend/src/icons/u-circle.svg
Normal 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="M12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 10.6868 21.7413 9.38642 21.2388 8.17317C20.7363 6.95991 19.9997 5.85752 19.0711 4.92893C18.1425 4.00035 17.0401 3.26375 15.8268 2.7612C14.6136 2.25866 13.3132 2 12 2ZM12 20C10.4178 20 8.87104 19.5308 7.55544 18.6518C6.23985 17.7727 5.21447 16.5233 4.60897 15.0615C4.00347 13.5997 3.84504 11.9911 4.15372 10.4393C4.4624 8.88743 5.22433 7.46197 6.34315 6.34315C7.46197 5.22433 8.88743 4.4624 10.4393 4.15372C11.9911 3.84504 13.5997 4.00346 15.0615 4.60896C16.5233 5.21447 17.7727 6.23984 18.6518 7.55544C19.5308 8.87103 20 10.4177 20 12C20 14.1217 19.1572 16.1566 17.6569 17.6569C16.1566 19.1571 14.1217 20 12 20Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,6 +1,7 @@
|
||||
import uuid
|
||||
|
||||
from openhands.sdk.conversation import visualizer
|
||||
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace
|
||||
@@ -74,11 +75,7 @@ def setup_conversation(
|
||||
|
||||
agent = load_agent_specs(str(conversation_id))
|
||||
|
||||
if not include_security_analyzer:
|
||||
# Remove security analyzer from agent spec
|
||||
agent = agent.model_copy(
|
||||
update={"security_analyzer": None}
|
||||
)
|
||||
|
||||
|
||||
# Create conversation - agent context is now set in AgentStore.load()
|
||||
conversation: BaseConversation = Conversation(
|
||||
@@ -90,7 +87,11 @@ def setup_conversation(
|
||||
visualizer=CLIVisualizer
|
||||
)
|
||||
|
||||
if include_security_analyzer:
|
||||
# Security analyzer is set though conversation API now
|
||||
if not include_security_analyzer:
|
||||
conversation.set_security_analyzer(None)
|
||||
else:
|
||||
conversation.set_security_analyzer(LLMSecurityAnalyzer())
|
||||
conversation.set_confirmation_policy(AlwaysConfirm())
|
||||
|
||||
print_formatted_text(
|
||||
|
||||
@@ -38,6 +38,16 @@ class AgentStore:
|
||||
str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
|
||||
agent = Agent.model_validate_json(str_spec)
|
||||
|
||||
|
||||
# Temporary to remove security analyzer from agent specs
|
||||
# Security analyzer is set via conversation API now
|
||||
# Doing this so that deprecation warning is thrown only the first time running CLI
|
||||
if agent.security_analyzer:
|
||||
agent = agent.model_copy(
|
||||
update={"security_analyzer": None}
|
||||
)
|
||||
self.save(agent)
|
||||
|
||||
# Update tools with most recent working directory
|
||||
updated_tools = get_default_tools(enable_browser=False)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
|
||||
from openhands.tools.preset import get_default_agent
|
||||
from openhands.sdk import LLM
|
||||
|
||||
@@ -67,10 +66,4 @@ def get_default_cli_agent(
|
||||
cli_mode=True
|
||||
)
|
||||
|
||||
agent = agent.model_copy(
|
||||
update={
|
||||
'security_analyzer': LLMSecurityAnalyzer()
|
||||
}
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
|
||||
|
||||
[project]
|
||||
name = "openhands"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
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",
|
||||
"openhands-tools==1",
|
||||
"openhands-sdk==1.1",
|
||||
"openhands-tools==1.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/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "aaa0066ee078688e015fcad590393fe6771c10a1" }
|
||||
# openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "aaa0066ee078688e015fcad590393fe6771c10a1" }
|
||||
# openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "7b695dc519084e75c482b34473e714845d6cef92" }
|
||||
# openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "7b695dc519084e75c482b34473e714845d6cef92" }
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Test that first-time settings screen usage creates a default agent with security analyzer."""
|
||||
"""Test that first-time settings screen usage creates a default agent and conversation with security analyzer."""
|
||||
|
||||
from unittest.mock import patch
|
||||
import pytest
|
||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||
from openhands_cli.user_actions.settings_action import SettingsType
|
||||
from openhands.sdk import LLM
|
||||
from openhands.sdk import LLM, Conversation, Workspace
|
||||
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
|
||||
from pydantic import SecretStr
|
||||
|
||||
|
||||
def test_first_time_settings_creates_default_agent_with_security_analyzer():
|
||||
"""Test that using the settings screen for the first time creates a default agent with a non-None security analyzer."""
|
||||
def test_first_time_settings_creates_default_agent_and_conversation_with_security_analyzer():
|
||||
"""Test that using the settings screen for the first time creates a default agent and conversation with security analyzer."""
|
||||
|
||||
# Create a settings screen instance (no conversation initially)
|
||||
screen = SettingsScreen(conversation=None)
|
||||
@@ -50,17 +51,20 @@ def test_first_time_settings_creates_default_agent_with_security_analyzer():
|
||||
assert saved_agent.llm.model == 'openai/gpt-4o-mini', f"Expected model 'openai/gpt-4o-mini', got '{saved_agent.llm.model}'"
|
||||
assert saved_agent.llm.api_key.get_secret_value() == 'sk-test-key-123', "API key should match the provided value"
|
||||
|
||||
# Verify that the agent has a security analyzer and it's not None
|
||||
assert hasattr(saved_agent, 'security_analyzer'), "Agent should have a security_analyzer attribute"
|
||||
assert saved_agent.security_analyzer is not None, "Security analyzer should not be None"
|
||||
# Test that a conversation can be created with the agent and security analyzer can be set
|
||||
conversation = Conversation(agent=saved_agent, workspace=Workspace(working_dir='/tmp'))
|
||||
|
||||
# Verify the security analyzer has the expected type/kind
|
||||
assert hasattr(saved_agent.security_analyzer, 'kind'), "Security analyzer should have a 'kind' attribute"
|
||||
assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{saved_agent.security_analyzer.kind}'"
|
||||
# Set security analyzer using the new API
|
||||
security_analyzer = LLMSecurityAnalyzer()
|
||||
conversation.set_security_analyzer(security_analyzer)
|
||||
|
||||
# Verify that the security analyzer was set correctly
|
||||
assert conversation.state.security_analyzer is not None, "Conversation should have a security analyzer"
|
||||
assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', f"Expected security analyzer kind 'LLMSecurityAnalyzer', got '{conversation.state.security_analyzer.kind}'"
|
||||
|
||||
|
||||
def test_first_time_settings_with_advanced_configuration():
|
||||
"""Test that advanced settings also create a default agent with security analyzer."""
|
||||
"""Test that advanced settings also create a default agent and conversation with security analyzer."""
|
||||
|
||||
screen = SettingsScreen(conversation=None)
|
||||
|
||||
@@ -94,11 +98,20 @@ def test_first_time_settings_with_advanced_configuration():
|
||||
|
||||
saved_agent = screen.agent_store.load()
|
||||
|
||||
# Verify agent creation and security analyzer
|
||||
# Verify agent creation
|
||||
assert saved_agent is not None, "Agent should be created with advanced settings"
|
||||
assert saved_agent.security_analyzer is not None, "Security analyzer should not be None in advanced settings"
|
||||
assert saved_agent.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer"
|
||||
|
||||
# Verify advanced settings were applied
|
||||
assert saved_agent.llm.model == 'anthropic/claude-3-5-sonnet', "Custom model should be set"
|
||||
assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set"
|
||||
assert saved_agent.llm.base_url == 'https://api.anthropic.com', "Base URL should be set"
|
||||
|
||||
# Test that a conversation can be created with the agent and security analyzer can be set
|
||||
conversation = Conversation(agent=saved_agent, workspace=Workspace(working_dir='/tmp'))
|
||||
|
||||
# Set security analyzer using the new API
|
||||
security_analyzer = LLMSecurityAnalyzer()
|
||||
conversation.set_security_analyzer(security_analyzer)
|
||||
|
||||
# Verify that the security analyzer was set correctly
|
||||
assert conversation.state.security_analyzer is not None, "Conversation should have a security analyzer"
|
||||
assert conversation.state.security_analyzer.kind == 'LLMSecurityAnalyzer', "Security analyzer should be LLMSecurityAnalyzer"
|
||||
@@ -108,15 +108,15 @@ class TestConversationRunner:
|
||||
3. If not paused, we should still ask for confirmation on actions
|
||||
4. If deferred no run call to agent should be made
|
||||
5. If accepted, run call to agent should be made
|
||||
|
||||
"""
|
||||
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)
|
||||
|
||||
# Set security analyzer using the new API to enable confirmation mode
|
||||
convo.set_security_analyzer(MagicMock())
|
||||
|
||||
convo.state.execution_status = (
|
||||
ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
)
|
||||
@@ -127,6 +127,7 @@ class TestConversationRunner:
|
||||
cr, '_handle_confirmation_request', return_value=confirmation
|
||||
) as mock_confirmation_request:
|
||||
cr.process_message(message=None)
|
||||
|
||||
mock_confirmation_request.assert_called_once()
|
||||
assert agent.step_count == expected_run_calls
|
||||
assert convo.state.execution_status == final_status
|
||||
|
||||
18
openhands-cli/uv.lock
generated
18
openhands-cli/uv.lock
generated
@@ -1902,7 +1902,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands"
|
||||
version = "1.0.6"
|
||||
version = "1.0.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openhands-sdk" },
|
||||
@@ -1929,8 +1929,8 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "openhands-sdk", specifier = "==1" },
|
||||
{ name = "openhands-tools", specifier = "==1" },
|
||||
{ name = "openhands-sdk", specifier = "==1.1" },
|
||||
{ name = "openhands-tools", specifier = "==1.1" },
|
||||
{ name = "prompt-toolkit", specifier = ">=3" },
|
||||
{ name = "typer", specifier = ">=0.17.4" },
|
||||
]
|
||||
@@ -1953,7 +1953,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastmcp" },
|
||||
@@ -1966,14 +1966,14 @@ dependencies = [
|
||||
{ name = "tenacity" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/b2/97d9deb743b266683f3e70cebaa1d34ee247c019f7d6e42c2f5de529cb47/openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c", size = 166636, upload-time = "2025-11-11T19:07:04.249Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/9f/a97a10447f3be53df4639e43748c4178853e958df07ba74890f4968829d6/openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e", size = 221594, upload-time = "2025-11-11T19:07:02.847Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bashlex" },
|
||||
@@ -1985,9 +1985,9 @@ dependencies = [
|
||||
{ name = "openhands-sdk" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
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" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/89/e2c5fc2d9e8dc6840ef2891ff6f76b9769b50a4c508fd3a626c1ab476fb1/openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6", size = 62818, upload-time = "2025-11-11T19:07:06.527Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/a3/e58d75b7bd8d5dfbe063fcfaaadbdfd24fd511d633a528cefd29f0e01056/openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9", size = 85062, upload-time = "2025-11-11T19:07:05.315Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -215,11 +215,12 @@ class LiveStatusAppConversationService(GitAppConversationService):
|
||||
yield task
|
||||
|
||||
# Start conversation...
|
||||
body_json = start_conversation_request.model_dump(
|
||||
mode='json', context={'expose_secrets': True}
|
||||
)
|
||||
response = await self.httpx_client.post(
|
||||
f'{agent_server_url}/api/conversations',
|
||||
json=start_conversation_request.model_dump(
|
||||
mode='json', context={'expose_secrets': True}
|
||||
),
|
||||
json=body_json,
|
||||
headers={'X-Session-API-Key': sandbox.session_api_key},
|
||||
timeout=self.sandbox_startup_timeout,
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
# The version of the agent server to use for deployments.
|
||||
# Typically this will be the same as the values from the pyproject.toml
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:d5995c3-python'
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:f3c0c19-python'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
@@ -42,6 +42,10 @@ from openhands.core.exceptions import (
|
||||
from openhands.core.logger import LOG_ALL_EVENTS
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.utils.posthog_tracker import (
|
||||
track_agent_task_completed,
|
||||
track_credit_limit_reached,
|
||||
)
|
||||
from openhands.events import (
|
||||
EventSource,
|
||||
EventStream,
|
||||
@@ -709,6 +713,20 @@ class AgentController:
|
||||
EventSource.ENVIRONMENT,
|
||||
)
|
||||
|
||||
# Track agent task completion in PostHog
|
||||
if new_state == AgentState.FINISHED:
|
||||
try:
|
||||
# Get app_mode from environment, default to 'oss'
|
||||
app_mode = os.environ.get('APP_MODE', 'oss')
|
||||
track_agent_task_completed(
|
||||
conversation_id=self.id,
|
||||
user_id=self.user_id,
|
||||
app_mode=app_mode,
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't let tracking errors interrupt the agent
|
||||
self.log('warning', f'Failed to track agent completion: {e}')
|
||||
|
||||
# Save state whenever agent state changes to ensure we don't lose state
|
||||
# in case of crashes or unexpected circumstances
|
||||
self.save_state()
|
||||
@@ -887,6 +905,18 @@ class AgentController:
|
||||
self.state_tracker.run_control_flags()
|
||||
except Exception as e:
|
||||
logger.warning('Control flag limits hit')
|
||||
# Track credit limit reached if it's a budget exception
|
||||
if 'budget' in str(e).lower() and self.state.budget_flag:
|
||||
try:
|
||||
track_credit_limit_reached(
|
||||
conversation_id=self.id,
|
||||
user_id=self.user_id,
|
||||
current_budget=self.state.budget_flag.current_value,
|
||||
max_budget=self.state.budget_flag.max_value,
|
||||
)
|
||||
except Exception as track_error:
|
||||
# Don't let tracking errors interrupt the agent
|
||||
self.log('warning', f'Failed to track credit limit: {track_error}')
|
||||
await self._react_to_exception(e)
|
||||
return
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
|
||||
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
|
||||
```toml
|
||||
[sandbox]
|
||||
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.61-nikolaik"
|
||||
runtime_container_image = "docker.openhands.dev/openhands/runtime:0.62-nikolaik"
|
||||
```
|
||||
|
||||
#### Additional Kubernetes Options
|
||||
|
||||
@@ -26,11 +26,13 @@ from openhands.microagent.types import (
|
||||
)
|
||||
from openhands.server.dependencies import get_dependencies
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.user_auth import (
|
||||
get_access_token,
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
)
|
||||
from openhands.utils.posthog_tracker import alias_user_identities
|
||||
|
||||
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
|
||||
|
||||
@@ -115,6 +117,14 @@ async def get_user(
|
||||
|
||||
try:
|
||||
user: User = await client.get_user()
|
||||
|
||||
# Alias git provider login with Keycloak user ID in PostHog (SaaS mode only)
|
||||
if user_id and user.login and server_config.app_mode == AppMode.SAAS:
|
||||
alias_user_identities(
|
||||
keycloak_user_id=user_id,
|
||||
git_login=user.login,
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
except UnknownException as e:
|
||||
|
||||
270
openhands/utils/posthog_tracker.py
Normal file
270
openhands/utils/posthog_tracker.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""PostHog tracking utilities for OpenHands events."""
|
||||
|
||||
import os
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# Lazy import posthog to avoid import errors in environments where it's not installed
|
||||
posthog = None
|
||||
|
||||
|
||||
def _init_posthog():
|
||||
"""Initialize PostHog client lazily."""
|
||||
global posthog
|
||||
if posthog is None:
|
||||
try:
|
||||
import posthog as ph
|
||||
|
||||
posthog = ph
|
||||
posthog.api_key = os.environ.get(
|
||||
'POSTHOG_CLIENT_KEY', 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
|
||||
)
|
||||
posthog.host = os.environ.get('POSTHOG_HOST', 'https://us.i.posthog.com')
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
'PostHog not installed. Analytics tracking will be disabled.'
|
||||
)
|
||||
posthog = None
|
||||
|
||||
|
||||
def track_agent_task_completed(
|
||||
conversation_id: str,
|
||||
user_id: str | None = None,
|
||||
app_mode: str | None = None,
|
||||
) -> None:
|
||||
"""Track when an agent completes a task.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation/session
|
||||
user_id: The ID of the user (optional, may be None for unauthenticated users)
|
||||
app_mode: The application mode (saas/oss), optional
|
||||
"""
|
||||
_init_posthog()
|
||||
|
||||
if posthog is None:
|
||||
return
|
||||
|
||||
# Use conversation_id as distinct_id if user_id is not available
|
||||
# This ensures we can track completions even for anonymous users
|
||||
distinct_id = user_id if user_id else f'conversation_{conversation_id}'
|
||||
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=distinct_id,
|
||||
event='agent_task_completed',
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
'app_mode': app_mode or 'unknown',
|
||||
},
|
||||
)
|
||||
logger.debug(
|
||||
'posthog_track',
|
||||
extra={
|
||||
'event': 'agent_task_completed',
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to track agent_task_completed to PostHog: {e}',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def track_user_signup_completed(
|
||||
user_id: str,
|
||||
signup_timestamp: str,
|
||||
) -> None:
|
||||
"""Track when a user completes signup by accepting TOS.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user (Keycloak user ID)
|
||||
signup_timestamp: ISO format timestamp of when TOS was accepted
|
||||
"""
|
||||
_init_posthog()
|
||||
|
||||
if posthog is None:
|
||||
return
|
||||
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=user_id,
|
||||
event='user_signup_completed',
|
||||
properties={
|
||||
'user_id': user_id,
|
||||
'signup_timestamp': signup_timestamp,
|
||||
},
|
||||
)
|
||||
logger.debug(
|
||||
'posthog_track',
|
||||
extra={
|
||||
'event': 'user_signup_completed',
|
||||
'user_id': user_id,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to track user_signup_completed to PostHog: {e}',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def track_credit_limit_reached(
|
||||
conversation_id: str,
|
||||
user_id: str | None = None,
|
||||
current_budget: float = 0.0,
|
||||
max_budget: float = 0.0,
|
||||
) -> None:
|
||||
"""Track when a user reaches their credit limit during a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation/session
|
||||
user_id: The ID of the user (optional, may be None for unauthenticated users)
|
||||
current_budget: The current budget spent
|
||||
max_budget: The maximum budget allowed
|
||||
"""
|
||||
_init_posthog()
|
||||
|
||||
if posthog is None:
|
||||
return
|
||||
|
||||
distinct_id = user_id if user_id else f'conversation_{conversation_id}'
|
||||
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=distinct_id,
|
||||
event='credit_limit_reached',
|
||||
properties={
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
'current_budget': current_budget,
|
||||
'max_budget': max_budget,
|
||||
},
|
||||
)
|
||||
logger.debug(
|
||||
'posthog_track',
|
||||
extra={
|
||||
'event': 'credit_limit_reached',
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
'current_budget': current_budget,
|
||||
'max_budget': max_budget,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to track credit_limit_reached to PostHog: {e}',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def track_credits_purchased(
|
||||
user_id: str,
|
||||
amount_usd: float,
|
||||
credits_added: float,
|
||||
stripe_session_id: str,
|
||||
) -> None:
|
||||
"""Track when a user successfully purchases credits.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user (Keycloak user ID)
|
||||
amount_usd: The amount paid in USD (cents converted to dollars)
|
||||
credits_added: The number of credits added to the user's account
|
||||
stripe_session_id: The Stripe checkout session ID
|
||||
"""
|
||||
_init_posthog()
|
||||
|
||||
if posthog is None:
|
||||
return
|
||||
|
||||
try:
|
||||
posthog.capture(
|
||||
distinct_id=user_id,
|
||||
event='credits_purchased',
|
||||
properties={
|
||||
'user_id': user_id,
|
||||
'amount_usd': amount_usd,
|
||||
'credits_added': credits_added,
|
||||
'stripe_session_id': stripe_session_id,
|
||||
},
|
||||
)
|
||||
logger.debug(
|
||||
'posthog_track',
|
||||
extra={
|
||||
'event': 'credits_purchased',
|
||||
'user_id': user_id,
|
||||
'amount_usd': amount_usd,
|
||||
'credits_added': credits_added,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to track credits_purchased to PostHog: {e}',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def alias_user_identities(
|
||||
keycloak_user_id: str,
|
||||
git_login: str,
|
||||
) -> None:
|
||||
"""Alias a user's Keycloak ID with their git provider login for unified tracking.
|
||||
|
||||
This allows PostHog to link events tracked from the frontend (using git provider login)
|
||||
with events tracked from the backend (using Keycloak user ID).
|
||||
|
||||
PostHog Python alias syntax: alias(previous_id, distinct_id)
|
||||
- previous_id: The old/previous distinct ID that will be merged
|
||||
- distinct_id: The new/canonical distinct ID to merge into
|
||||
|
||||
For our use case:
|
||||
- Git provider login is the previous_id (first used in frontend, before backend auth)
|
||||
- Keycloak user ID is the distinct_id (canonical backend ID)
|
||||
- Result: All events with git login will be merged into Keycloak user ID
|
||||
|
||||
Args:
|
||||
keycloak_user_id: The Keycloak user ID (canonical distinct_id)
|
||||
git_login: The git provider username (GitHub/GitLab/Bitbucket) to merge
|
||||
|
||||
Reference:
|
||||
https://github.com/PostHog/posthog-python/blob/master/posthog/client.py
|
||||
"""
|
||||
_init_posthog()
|
||||
|
||||
if posthog is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# Merge git provider login into Keycloak user ID
|
||||
# posthog.alias(previous_id, distinct_id) - official Python SDK signature
|
||||
posthog.alias(git_login, keycloak_user_id)
|
||||
logger.debug(
|
||||
'posthog_alias',
|
||||
extra={
|
||||
'previous_id': git_login,
|
||||
'distinct_id': keycloak_user_id,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to alias user identities in PostHog: {e}',
|
||||
extra={
|
||||
'keycloak_user_id': keycloak_user_id,
|
||||
'git_login': git_login,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
20
poetry.lock
generated
20
poetry.lock
generated
@@ -7329,14 +7329,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.0.0a6"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.0.0a6-py3-none-any.whl", hash = "sha256:72b0da038ede018c55c64f0ac99bc5d991af173627efc63de87d54b3cd69134c"},
|
||||
{file = "openhands_agent_server-1.0.0a6.tar.gz", hash = "sha256:8c6fbceb07990e3caf7f8797082d1bb614b9f7339bd00576c24fd34a956a03b4"},
|
||||
{file = "openhands_agent_server-1.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
|
||||
{file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7352,14 +7352,14 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0a6"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.0.0a6-py3-none-any.whl", hash = "sha256:0b0b579fc48a5b7eaa418ca66188206ba00f4d883997bc29291bc1745e0b7ddc"},
|
||||
{file = "openhands_sdk-1.0.0a6.tar.gz", hash = "sha256:01daff435c5f94037b9b4ba85054097ca6235982a9b0fee00341279d4c4b5a01"},
|
||||
{file = "openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
|
||||
{file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7378,14 +7378,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0a6"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.0.0a6-py3-none-any.whl", hash = "sha256:55b75016f7e3930e4365393a026726eeffae027363d03862a17a8cebc1aed670"},
|
||||
{file = "openhands_tools-1.0.0a6.tar.gz", hash = "sha256:4d5382f3e1cab9d23c1ef7ea8e36e821083886d6d4b019100cbf897e3b0cd3be"},
|
||||
{file = "openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
|
||||
{file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -16729,4 +16729,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "57ed6b7f4613e668fd1d0e10a21f7c915cdbb9c7b906a0b71a8ba222733c082d"
|
||||
content-hash = "0fe5bab6aeb5ebce4588b30cfcf491af4cc9d9b9cd5160e67c8a055d9db276fc"
|
||||
|
||||
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.61.0"
|
||||
version = "0.62.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
@@ -113,12 +113,12 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
|
||||
pybase62 = "^1.0.0"
|
||||
|
||||
# V1 dependencies
|
||||
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
|
||||
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
|
||||
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "be9725b459c0afabc18cfba89acf11dc756b42f0" }
|
||||
openhands-sdk = "1.0.0a6"
|
||||
openhands-agent-server = "1.0.0a6"
|
||||
openhands-tools = "1.0.0a6"
|
||||
#openhands-agent-server = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-agent-server", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
|
||||
#openhands-sdk = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-sdk", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
|
||||
#openhands-tools = { git = "https://github.com/OpenHands/agent-sdk.git", subdirectory = "openhands-tools", rev = "f3c0c19cd134fbda84e07f152897a6d61e1e46c5" }
|
||||
openhands-sdk = "1.1.0"
|
||||
openhands-agent-server = "1.1.0"
|
||||
openhands-tools = "1.1.0"
|
||||
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
|
||||
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
pg8000 = "^1.31.5"
|
||||
|
||||
242
tests/unit/controller/test_agent_controller_posthog.py
Normal file
242
tests/unit/controller/test_agent_controller_posthog.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Integration tests for PostHog tracking in AgentController."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.agent_controller import AgentController
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.llm_config import LLMConfig
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events import EventSource, EventStream
|
||||
from openhands.events.action.message import SystemMessageAction
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.server.services.conversation_stats import ConversationStats
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
|
||||
|
||||
@pytest.fixture(scope='function')
|
||||
def event_loop():
|
||||
"""Create event loop for async tests."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent_with_stats():
|
||||
"""Create a mock agent with properly connected LLM registry and conversation stats."""
|
||||
import uuid
|
||||
|
||||
# Create LLM registry
|
||||
config = OpenHandsConfig()
|
||||
llm_registry = LLMRegistry(config=config)
|
||||
|
||||
# Create conversation stats
|
||||
file_store = InMemoryFileStore({})
|
||||
conversation_id = f'test-conversation-{uuid.uuid4()}'
|
||||
conversation_stats = ConversationStats(
|
||||
file_store=file_store, conversation_id=conversation_id, user_id='test-user'
|
||||
)
|
||||
|
||||
# Connect registry to stats
|
||||
llm_registry.subscribe(conversation_stats.register_llm)
|
||||
|
||||
# Create mock agent
|
||||
agent = MagicMock(spec=Agent)
|
||||
agent_config = MagicMock(spec=AgentConfig)
|
||||
llm_config = LLMConfig(
|
||||
model='gpt-4o',
|
||||
api_key='test_key',
|
||||
num_retries=2,
|
||||
retry_min_wait=1,
|
||||
retry_max_wait=2,
|
||||
)
|
||||
agent_config.disabled_microagents = []
|
||||
agent_config.enable_mcp = True
|
||||
llm_registry.service_to_llm.clear()
|
||||
mock_llm = llm_registry.get_llm('agent_llm', llm_config)
|
||||
agent.llm = mock_llm
|
||||
agent.name = 'test-agent'
|
||||
agent.sandbox_plugins = []
|
||||
agent.config = agent_config
|
||||
agent.llm_registry = llm_registry
|
||||
agent.prompt_manager = MagicMock()
|
||||
|
||||
# Add a proper system message mock
|
||||
system_message = SystemMessageAction(
|
||||
content='Test system message', tools=['test_tool']
|
||||
)
|
||||
system_message._source = EventSource.AGENT
|
||||
system_message._id = -1 # Set invalid ID to avoid the ID check
|
||||
agent.get_system_message.return_value = system_message
|
||||
|
||||
return agent, conversation_stats, llm_registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_event_stream():
|
||||
"""Create a mock event stream."""
|
||||
mock = MagicMock(
|
||||
spec=EventStream,
|
||||
event_stream=EventStream(sid='test', file_store=InMemoryFileStore({})),
|
||||
)
|
||||
mock.get_latest_event_id.return_value = 0
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_finish_triggers_posthog_tracking(
|
||||
mock_agent_with_stats, mock_event_stream
|
||||
):
|
||||
"""Test that setting agent state to FINISHED triggers PostHog tracking."""
|
||||
mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
|
||||
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
conversation_stats=conversation_stats,
|
||||
iteration_delta=10,
|
||||
sid='test-conversation-123',
|
||||
user_id='test-user-456',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch('openhands.utils.posthog_tracker.posthog') as mock_posthog,
|
||||
patch('os.environ.get') as mock_env_get,
|
||||
):
|
||||
# Setup mocks
|
||||
mock_posthog.capture = MagicMock()
|
||||
mock_env_get.return_value = 'saas'
|
||||
|
||||
# Initialize posthog in the tracker module
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
# Set agent state to FINISHED
|
||||
await controller.set_agent_state_to(AgentState.FINISHED)
|
||||
|
||||
# Verify PostHog tracking was called
|
||||
mock_posthog.capture.assert_called_once()
|
||||
call_args = mock_posthog.capture.call_args
|
||||
|
||||
assert call_args[1]['distinct_id'] == 'test-user-456'
|
||||
assert call_args[1]['event'] == 'agent_task_completed'
|
||||
assert 'conversation_id' in call_args[1]['properties']
|
||||
assert call_args[1]['properties']['user_id'] == 'test-user-456'
|
||||
assert call_args[1]['properties']['app_mode'] == 'saas'
|
||||
|
||||
await controller.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_finish_without_user_id(mock_agent_with_stats, mock_event_stream):
|
||||
"""Test tracking when user_id is None."""
|
||||
mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
|
||||
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
conversation_stats=conversation_stats,
|
||||
iteration_delta=10,
|
||||
sid='test-conversation-789',
|
||||
user_id=None,
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch('openhands.utils.posthog_tracker.posthog') as mock_posthog,
|
||||
patch('os.environ.get') as mock_env_get,
|
||||
):
|
||||
mock_posthog.capture = MagicMock()
|
||||
mock_env_get.return_value = 'oss'
|
||||
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
await controller.set_agent_state_to(AgentState.FINISHED)
|
||||
|
||||
mock_posthog.capture.assert_called_once()
|
||||
call_args = mock_posthog.capture.call_args
|
||||
|
||||
# When user_id is None, distinct_id should be conversation_id
|
||||
assert call_args[1]['distinct_id'].startswith('conversation_')
|
||||
assert call_args[1]['properties']['user_id'] is None
|
||||
|
||||
await controller.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_states_dont_trigger_tracking(
|
||||
mock_agent_with_stats, mock_event_stream
|
||||
):
|
||||
"""Test that non-FINISHED states don't trigger tracking."""
|
||||
mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
|
||||
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
conversation_stats=conversation_stats,
|
||||
iteration_delta=10,
|
||||
sid='test-conversation-999',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog:
|
||||
mock_posthog.capture = MagicMock()
|
||||
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
# Try different states
|
||||
await controller.set_agent_state_to(AgentState.RUNNING)
|
||||
await controller.set_agent_state_to(AgentState.PAUSED)
|
||||
await controller.set_agent_state_to(AgentState.STOPPED)
|
||||
|
||||
# PostHog should not be called for non-FINISHED states
|
||||
mock_posthog.capture.assert_not_called()
|
||||
|
||||
await controller.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracking_error_doesnt_break_agent(
|
||||
mock_agent_with_stats, mock_event_stream
|
||||
):
|
||||
"""Test that tracking errors don't interrupt agent operation."""
|
||||
mock_agent, conversation_stats, llm_registry = mock_agent_with_stats
|
||||
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
conversation_stats=conversation_stats,
|
||||
iteration_delta=10,
|
||||
sid='test-conversation-error',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
with patch('openhands.utils.posthog_tracker.posthog') as mock_posthog:
|
||||
mock_posthog.capture = MagicMock(side_effect=Exception('PostHog error'))
|
||||
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
# Should not raise an exception
|
||||
await controller.set_agent_state_to(AgentState.FINISHED)
|
||||
|
||||
# Agent state should still be FINISHED despite tracking error
|
||||
assert controller.state.agent_state == AgentState.FINISHED
|
||||
|
||||
await controller.close()
|
||||
356
tests/unit/utils/test_posthog_tracker.py
Normal file
356
tests/unit/utils/test_posthog_tracker.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""Unit tests for PostHog tracking utilities."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.utils.posthog_tracker import (
|
||||
alias_user_identities,
|
||||
track_agent_task_completed,
|
||||
track_credit_limit_reached,
|
||||
track_credits_purchased,
|
||||
track_user_signup_completed,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_posthog():
|
||||
"""Mock the posthog module."""
|
||||
with patch('openhands.utils.posthog_tracker.posthog') as mock_ph:
|
||||
mock_ph.capture = MagicMock()
|
||||
yield mock_ph
|
||||
|
||||
|
||||
def test_track_agent_task_completed_with_user_id(mock_posthog):
|
||||
"""Test tracking agent task completion with user ID."""
|
||||
# Initialize posthog manually in the test
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
track_agent_task_completed(
|
||||
conversation_id='test-conversation-123',
|
||||
user_id='user-456',
|
||||
app_mode='saas',
|
||||
)
|
||||
|
||||
mock_posthog.capture.assert_called_once_with(
|
||||
distinct_id='user-456',
|
||||
event='agent_task_completed',
|
||||
properties={
|
||||
'conversation_id': 'test-conversation-123',
|
||||
'user_id': 'user-456',
|
||||
'app_mode': 'saas',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_track_agent_task_completed_without_user_id(mock_posthog):
|
||||
"""Test tracking agent task completion without user ID (anonymous)."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
track_agent_task_completed(
|
||||
conversation_id='test-conversation-789',
|
||||
user_id=None,
|
||||
app_mode='oss',
|
||||
)
|
||||
|
||||
mock_posthog.capture.assert_called_once_with(
|
||||
distinct_id='conversation_test-conversation-789',
|
||||
event='agent_task_completed',
|
||||
properties={
|
||||
'conversation_id': 'test-conversation-789',
|
||||
'user_id': None,
|
||||
'app_mode': 'oss',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_track_agent_task_completed_default_app_mode(mock_posthog):
|
||||
"""Test tracking with default app_mode."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
track_agent_task_completed(
|
||||
conversation_id='test-conversation-999',
|
||||
user_id='user-111',
|
||||
)
|
||||
|
||||
mock_posthog.capture.assert_called_once_with(
|
||||
distinct_id='user-111',
|
||||
event='agent_task_completed',
|
||||
properties={
|
||||
'conversation_id': 'test-conversation-999',
|
||||
'user_id': 'user-111',
|
||||
'app_mode': 'unknown',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_track_agent_task_completed_handles_errors(mock_posthog):
|
||||
"""Test that tracking errors are handled gracefully."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
mock_posthog.capture.side_effect = Exception('PostHog API error')
|
||||
|
||||
# Should not raise an exception
|
||||
track_agent_task_completed(
|
||||
conversation_id='test-conversation-error',
|
||||
user_id='user-error',
|
||||
app_mode='saas',
|
||||
)
|
||||
|
||||
|
||||
def test_track_agent_task_completed_when_posthog_not_installed():
|
||||
"""Test tracking when posthog is not installed."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
# Simulate posthog not being installed
|
||||
tracker.posthog = None
|
||||
|
||||
# Should not raise an exception
|
||||
track_agent_task_completed(
|
||||
conversation_id='test-conversation-no-ph',
|
||||
user_id='user-no-ph',
|
||||
app_mode='oss',
|
||||
)
|
||||
|
||||
|
||||
def test_track_user_signup_completed(mock_posthog):
|
||||
"""Test tracking user signup completion."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
track_user_signup_completed(
|
||||
user_id='test-user-123',
|
||||
signup_timestamp='2025-01-15T10:30:00Z',
|
||||
)
|
||||
|
||||
mock_posthog.capture.assert_called_once_with(
|
||||
distinct_id='test-user-123',
|
||||
event='user_signup_completed',
|
||||
properties={
|
||||
'user_id': 'test-user-123',
|
||||
'signup_timestamp': '2025-01-15T10:30:00Z',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_track_user_signup_completed_handles_errors(mock_posthog):
|
||||
"""Test that user signup tracking errors are handled gracefully."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
mock_posthog.capture.side_effect = Exception('PostHog API error')
|
||||
|
||||
# Should not raise an exception
|
||||
track_user_signup_completed(
|
||||
user_id='test-user-error',
|
||||
signup_timestamp='2025-01-15T12:00:00Z',
|
||||
)
|
||||
|
||||
|
||||
def test_track_user_signup_completed_when_posthog_not_installed():
|
||||
"""Test user signup tracking when posthog is not installed."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
# Simulate posthog not being installed
|
||||
tracker.posthog = None
|
||||
|
||||
# Should not raise an exception
|
||||
track_user_signup_completed(
|
||||
user_id='test-user-no-ph',
|
||||
signup_timestamp='2025-01-15T13:00:00Z',
|
||||
)
|
||||
|
||||
|
||||
def test_track_credit_limit_reached_with_user_id(mock_posthog):
|
||||
"""Test tracking credit limit reached with user ID."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
track_credit_limit_reached(
|
||||
conversation_id='test-conversation-456',
|
||||
user_id='user-789',
|
||||
current_budget=10.50,
|
||||
max_budget=10.00,
|
||||
)
|
||||
|
||||
mock_posthog.capture.assert_called_once_with(
|
||||
distinct_id='user-789',
|
||||
event='credit_limit_reached',
|
||||
properties={
|
||||
'conversation_id': 'test-conversation-456',
|
||||
'user_id': 'user-789',
|
||||
'current_budget': 10.50,
|
||||
'max_budget': 10.00,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_track_credit_limit_reached_without_user_id(mock_posthog):
|
||||
"""Test tracking credit limit reached without user ID (anonymous)."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
track_credit_limit_reached(
|
||||
conversation_id='test-conversation-999',
|
||||
user_id=None,
|
||||
current_budget=5.25,
|
||||
max_budget=5.00,
|
||||
)
|
||||
|
||||
mock_posthog.capture.assert_called_once_with(
|
||||
distinct_id='conversation_test-conversation-999',
|
||||
event='credit_limit_reached',
|
||||
properties={
|
||||
'conversation_id': 'test-conversation-999',
|
||||
'user_id': None,
|
||||
'current_budget': 5.25,
|
||||
'max_budget': 5.00,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_track_credit_limit_reached_handles_errors(mock_posthog):
|
||||
"""Test that credit limit tracking errors are handled gracefully."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
mock_posthog.capture.side_effect = Exception('PostHog API error')
|
||||
|
||||
# Should not raise an exception
|
||||
track_credit_limit_reached(
|
||||
conversation_id='test-conversation-error',
|
||||
user_id='user-error',
|
||||
current_budget=15.00,
|
||||
max_budget=10.00,
|
||||
)
|
||||
|
||||
|
||||
def test_track_credit_limit_reached_when_posthog_not_installed():
|
||||
"""Test credit limit tracking when posthog is not installed."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
# Simulate posthog not being installed
|
||||
tracker.posthog = None
|
||||
|
||||
# Should not raise an exception
|
||||
track_credit_limit_reached(
|
||||
conversation_id='test-conversation-no-ph',
|
||||
user_id='user-no-ph',
|
||||
current_budget=8.00,
|
||||
max_budget=5.00,
|
||||
)
|
||||
|
||||
|
||||
def test_track_credits_purchased(mock_posthog):
|
||||
"""Test tracking credits purchased."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
|
||||
track_credits_purchased(
|
||||
user_id='test-user-999',
|
||||
amount_usd=50.00,
|
||||
credits_added=50.00,
|
||||
stripe_session_id='cs_test_abc123',
|
||||
)
|
||||
|
||||
mock_posthog.capture.assert_called_once_with(
|
||||
distinct_id='test-user-999',
|
||||
event='credits_purchased',
|
||||
properties={
|
||||
'user_id': 'test-user-999',
|
||||
'amount_usd': 50.00,
|
||||
'credits_added': 50.00,
|
||||
'stripe_session_id': 'cs_test_abc123',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_track_credits_purchased_handles_errors(mock_posthog):
|
||||
"""Test that credits purchased tracking errors are handled gracefully."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
mock_posthog.capture.side_effect = Exception('PostHog API error')
|
||||
|
||||
# Should not raise an exception
|
||||
track_credits_purchased(
|
||||
user_id='test-user-error',
|
||||
amount_usd=100.00,
|
||||
credits_added=100.00,
|
||||
stripe_session_id='cs_test_error',
|
||||
)
|
||||
|
||||
|
||||
def test_track_credits_purchased_when_posthog_not_installed():
|
||||
"""Test credits purchased tracking when posthog is not installed."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
# Simulate posthog not being installed
|
||||
tracker.posthog = None
|
||||
|
||||
# Should not raise an exception
|
||||
track_credits_purchased(
|
||||
user_id='test-user-no-ph',
|
||||
amount_usd=25.00,
|
||||
credits_added=25.00,
|
||||
stripe_session_id='cs_test_no_ph',
|
||||
)
|
||||
|
||||
|
||||
def test_alias_user_identities(mock_posthog):
|
||||
"""Test aliasing user identities.
|
||||
|
||||
Verifies that posthog.alias(previous_id, distinct_id) is called correctly
|
||||
where git_login is the previous_id and keycloak_user_id is the distinct_id.
|
||||
"""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
mock_posthog.alias = MagicMock()
|
||||
|
||||
alias_user_identities(
|
||||
keycloak_user_id='keycloak-123',
|
||||
git_login='git-user',
|
||||
)
|
||||
|
||||
# Verify: posthog.alias(previous_id='git-user', distinct_id='keycloak-123')
|
||||
mock_posthog.alias.assert_called_once_with('git-user', 'keycloak-123')
|
||||
|
||||
|
||||
def test_alias_user_identities_handles_errors(mock_posthog):
|
||||
"""Test that aliasing errors are handled gracefully."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
tracker.posthog = mock_posthog
|
||||
mock_posthog.alias = MagicMock(side_effect=Exception('PostHog API error'))
|
||||
|
||||
# Should not raise an exception
|
||||
alias_user_identities(
|
||||
keycloak_user_id='keycloak-error',
|
||||
git_login='git-error',
|
||||
)
|
||||
|
||||
|
||||
def test_alias_user_identities_when_posthog_not_installed():
|
||||
"""Test aliasing when posthog is not installed."""
|
||||
import openhands.utils.posthog_tracker as tracker
|
||||
|
||||
# Simulate posthog not being installed
|
||||
tracker.posthog = None
|
||||
|
||||
# Should not raise an exception
|
||||
alias_user_identities(
|
||||
keycloak_user_id='keycloak-no-ph',
|
||||
git_login='git-no-ph',
|
||||
)
|
||||
Reference in New Issue
Block a user