From 11636edf15e806707835cd21933098034efece59 Mon Sep 17 00:00:00 2001 From: mamoodi Date: Tue, 11 Nov 2025 14:57:13 -0500 Subject: [PATCH 1/8] Release 0.62.0 (#11706) --- Development.md | 2 +- README.md | 6 +++--- containers/dev/compose.yml | 2 +- docker-compose.yml | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- openhands/runtime/impl/kubernetes/README.md | 2 +- pyproject.toml | 2 +- 8 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Development.md b/Development.md index 0ab8d9a684..8b524be511 100644 --- a/Development.md +++ b/Development.md @@ -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 diff --git a/README.md b/README.md index 5fe0edae5a..cd47210ef1 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/containers/dev/compose.yml b/containers/dev/compose.yml index f001d63106..c6168b094f 100644 --- a/containers/dev/compose.yml +++ b/containers/dev/compose.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 304658fa07..b663324625 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 66b115ae73..862f0c5a00 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index ec7e1793d2..46958662a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "openhands-frontend", - "version": "0.61.0", + "version": "0.62.0", "private": true, "type": "module", "engines": { diff --git a/openhands/runtime/impl/kubernetes/README.md b/openhands/runtime/impl/kubernetes/README.md index a6c1995309..d16247389d 100644 --- a/openhands/runtime/impl/kubernetes/README.md +++ b/openhands/runtime/impl/kubernetes/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index c37bc0b2ee..6a4ed97cf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From 8b6521de626c9dfedbab72919561e36a709c417f Mon Sep 17 00:00:00 2001 From: Tim O'Farrell Date: Tue, 11 Nov 2025 20:23:18 +0000 Subject: [PATCH 2/8] Fix for issue where conversation does not start (#11695) --- enterprise/poetry.lock | 53 +++++++------------ .../get-observation-content.ts | 5 ++ frontend/src/hooks/use-terminal.ts | 2 +- .../live_status_app_conversation_service.py | 7 +-- .../sandbox/sandbox_spec_service.py | 2 +- poetry.lock | 20 +++---- pyproject.toml | 12 ++--- 7 files changed, 46 insertions(+), 55 deletions(-) diff --git a/enterprise/poetry.lock b/enterprise/poetry.lock index 36c88d6bfd..cb3bfc2c3e 100644 --- a/enterprise/poetry.lock +++ b/enterprise/poetry.lock @@ -5820,13 +5820,15 @@ 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.0a5" +version = "1.1.0" description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent" optional = false python-versions = ">=3.12" groups = ["main"] -files = [] -develop = false +files = [ + {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] aiosqlite = ">=0.19" @@ -5839,16 +5841,9 @@ uvicorn = ">=0.31.1" websockets = ">=12" wsproto = ">=1.2.0" -[package.source] -type = "git" -url = "https://github.com/OpenHands/software-agent-sdk.git" -reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1" -resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1" -subdirectory = "openhands-agent-server" - [[package]] name = "openhands-ai" -version = "0.0.0-post.5514+7c9e66194" +version = "0.0.0-post.5525+0b6631523" description = "OpenHands: Code Less, Make More" optional = false python-versions = "^3.12,<3.14" @@ -5889,9 +5884,9 @@ memory-profiler = "^0.61.0" numpy = "*" openai = "1.99.9" openhands-aci = "0.3.2" -openhands-agent-server = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-agent-server"} -openhands-sdk = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-sdk"} -openhands-tools = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-tools"} +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" @@ -5947,13 +5942,15 @@ url = ".." [[package]] name = "openhands-sdk" -version = "1.0.0a5" +version = "1.1.0" description = "OpenHands SDK - Core functionality for building AI agents" optional = false python-versions = ">=3.12" groups = ["main"] -files = [] -develop = false +files = [ + {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] fastmcp = ">=2.11.3" @@ -5969,22 +5966,17 @@ websockets = ">=12" [package.extras] boto3 = ["boto3 (>=1.35.0)"] -[package.source] -type = "git" -url = "https://github.com/OpenHands/software-agent-sdk.git" -reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1" -resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1" -subdirectory = "openhands-sdk" - [[package]] name = "openhands-tools" -version = "1.0.0a5" +version = "1.1.0" description = "OpenHands Tools - Runtime tools for AI agents" optional = false python-versions = ">=3.12" groups = ["main"] -files = [] -develop = false +files = [ + {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] bashlex = ">=0.18" @@ -5996,13 +5988,6 @@ libtmux = ">=0.46.2" openhands-sdk = "*" pydantic = ">=2.11.7" -[package.source] -type = "git" -url = "https://github.com/OpenHands/software-agent-sdk.git" -reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1" -resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1" -subdirectory = "openhands-tools" - [[package]] name = "openpyxl" version = "3.1.5" diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts index 39db76cd2e..ef4ffa253f 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts +++ b/frontend/src/components/v1/chat/event-content-helpers/get-observation-content.ts @@ -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()}`; diff --git a/frontend/src/hooks/use-terminal.ts b/frontend/src/hooks/use-terminal.ts index a5f6e8286c..b5ffb6baf9 100644 --- a/frontend/src/hooks/use-terminal.ts +++ b/frontend/src/hooks/use-terminal.ts @@ -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)); diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 1b2763e279..cc10d254e7 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -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, ) diff --git a/openhands/app_server/sandbox/sandbox_spec_service.py b/openhands/app_server/sandbox/sandbox_spec_service.py index d079183fbc..fd091ca130 100644 --- a/openhands/app_server/sandbox/sandbox_spec_service.py +++ b/openhands/app_server/sandbox/sandbox_spec_service.py @@ -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): diff --git a/poetry.lock b/poetry.lock index fa2f15ee71..b1d9684299 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 6a4ed97cf8..28b7b03f9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" From 0a6b76ca2d1a209315ae4fffea038e4c316809b2 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Tue, 11 Nov 2025 15:29:18 -0500 Subject: [PATCH 3/8] CLI: bump agent-sdk (#11710) Co-authored-by: openhands --- openhands-cli/openhands_cli/setup.py | 13 +++--- .../openhands_cli/tui/settings/store.py | 10 +++++ openhands-cli/openhands_cli/utils.py | 7 --- openhands-cli/pyproject.toml | 8 ++-- .../test_default_agent_security_analyzer.py | 43 ++++++++++++------- .../tests/test_conversation_runner.py | 9 ++-- openhands-cli/uv.lock | 16 +++---- 7 files changed, 62 insertions(+), 44 deletions(-) diff --git a/openhands-cli/openhands_cli/setup.py b/openhands-cli/openhands_cli/setup.py index 8897eefdb3..91b7ab9464 100644 --- a/openhands-cli/openhands_cli/setup.py +++ b/openhands-cli/openhands_cli/setup.py @@ -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( diff --git a/openhands-cli/openhands_cli/tui/settings/store.py b/openhands-cli/openhands_cli/tui/settings/store.py index 3a292019a8..018a7484e0 100644 --- a/openhands-cli/openhands_cli/tui/settings/store.py +++ b/openhands-cli/openhands_cli/tui/settings/store.py @@ -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) diff --git a/openhands-cli/openhands_cli/utils.py b/openhands-cli/openhands_cli/utils.py index b5bbc44104..50571cd7be 100644 --- a/openhands-cli/openhands_cli/utils.py +++ b/openhands-cli/openhands_cli/utils.py @@ -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 diff --git a/openhands-cli/pyproject.toml b/openhands-cli/pyproject.toml index e042c12572..5c035ec57d 100644 --- a/openhands-cli/pyproject.toml +++ b/openhands-cli/pyproject.toml @@ -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" } diff --git a/openhands-cli/tests/settings/test_default_agent_security_analyzer.py b/openhands-cli/tests/settings/test_default_agent_security_analyzer.py index 0c23afb866..61ab9b2a2f 100644 --- a/openhands-cli/tests/settings/test_default_agent_security_analyzer.py +++ b/openhands-cli/tests/settings/test_default_agent_security_analyzer.py @@ -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" \ No newline at end of file + 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" \ No newline at end of file diff --git a/openhands-cli/tests/test_conversation_runner.py b/openhands-cli/tests/test_conversation_runner.py index 8314cccf2f..ebc7f04c44 100644 --- a/openhands-cli/tests/test_conversation_runner.py +++ b/openhands-cli/tests/test_conversation_runner.py @@ -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 diff --git a/openhands-cli/uv.lock b/openhands-cli/uv.lock index 7714eb18df..ef4f2de5f8 100644 --- a/openhands-cli/uv.lock +++ b/openhands-cli/uv.lock @@ -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.0" }, + { name = "openhands-tools", specifier = "==1.1.0" }, { 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]] From 95a44f424843a548c0f725a356aff26c690ea716 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Tue, 11 Nov 2025 16:46:30 -0500 Subject: [PATCH 4/8] CLI release 1.0.7 (#11712) --- openhands-cli/pyproject.toml | 2 +- openhands-cli/uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openhands-cli/pyproject.toml b/openhands-cli/pyproject.toml index 5c035ec57d..7d2e600e2a 100644 --- a/openhands-cli/pyproject.toml +++ b/openhands-cli/pyproject.toml @@ -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" } diff --git a/openhands-cli/uv.lock b/openhands-cli/uv.lock index ef4f2de5f8..1900787152 100644 --- a/openhands-cli/uv.lock +++ b/openhands-cli/uv.lock @@ -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.1.0" }, - { name = "openhands-tools", specifier = "==1.1.0" }, + { name = "openhands-sdk", specifier = "==1.1" }, + { name = "openhands-tools", specifier = "==1.1" }, { name = "prompt-toolkit", specifier = ">=3" }, { name = "typer", specifier = ">=0.17.4" }, ] From 73fe865c7e036a646ede46cf4f6eb53ceb221ed9 Mon Sep 17 00:00:00 2001 From: Neha Prasad Date: Wed, 12 Nov 2025 18:50:09 +0530 Subject: [PATCH 5/8] feat: queue chat messages during runtime connection (#11687) Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com> --- frontend/src/context/ws-client-provider.tsx | 41 +++++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx index 38f390476f..22e8b64be8 100644 --- a/frontend/src/context/ws-client-provider.tsx +++ b/frontend/src/context/ws-client-provider.tsx @@ -142,6 +142,7 @@ export function WsClientProvider({ const { addEvent, clearEvents } = useEventStore(); const queryClient = useQueryClient(); const sioRef = React.useRef(null); + const pendingEventsRef = React.useRef[]>([]); const [webSocketStatus, setWebSocketStatus] = React.useState("DISCONNECTED"); const lastEventRef = React.useRef | null>(null); @@ -151,17 +152,37 @@ export function WsClientProvider({ const { data: conversation, refetch: refetchConversation } = useActiveConversation(); - function send(event: Record) { - 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) { + 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) { @@ -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); From 8e75f251085b6014e8fb9baae0deaf642d5acd90 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Wed, 12 Nov 2025 22:59:45 +0700 Subject: [PATCH 6/8] feat(frontend): implement new task tracker interface (#11692) --- ...task-tracking-observation-content.test.tsx | 54 +++++++--------- .../task-tracking-event-message.tsx | 27 +------- .../task-tracking-observation-content.tsx | 6 -- .../chat/task-tracking/result-section.tsx | 21 ------ .../features/chat/task-tracking/task-item.tsx | 64 ++++++++++++------- .../chat/task-tracking/task-list-section.tsx | 27 ++++---- .../get-event-content.tsx | 19 +++++- .../finish-event-message.tsx | 13 ++-- .../generic-event-message-wrapper.tsx | 7 ++ .../v1/chat/task-tracking/task-item.tsx | 56 ++++++++++++++++ .../chat/task-tracking/task-list-section.tsx | 33 ++++++++++ .../task-tracking-observation-content.tsx | 23 +++++++ frontend/src/i18n/declaration.ts | 1 + frontend/src/i18n/translation.json | 16 +++++ frontend/src/icons/loading.svg | 3 + frontend/src/icons/u-check-circle.svg | 3 + frontend/src/icons/u-circle.svg | 3 + 17 files changed, 249 insertions(+), 127 deletions(-) delete mode 100644 frontend/src/components/features/chat/task-tracking/result-section.tsx create mode 100644 frontend/src/components/v1/chat/task-tracking/task-item.tsx create mode 100644 frontend/src/components/v1/chat/task-tracking/task-list-section.tsx create mode 100644 frontend/src/components/v1/chat/task-tracking/task-tracking-observation-content.tsx create mode 100644 frontend/src/icons/loading.svg create mode 100644 frontend/src/icons/u-check-circle.svg create mode 100644 frontend/src/icons/u-circle.svg diff --git a/frontend/__tests__/components/features/chat/task-tracking-observation-content.test.tsx b/frontend/__tests__/components/features/chat/task-tracking-observation-content.test.tsx index e44c33ca7d..5db3942aa2 100644 --- a/frontend/__tests__/components/features/chat/task-tracking-observation-content.test.tsx +++ b/frontend/__tests__/components/features/chat/task-tracking-observation-content.test.tsx @@ -8,10 +8,11 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => { const translations: Record = { - "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(); - 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(); + const { container } = render( + , + ); - // 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(); - - 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(); - 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(); - expect(screen.queryByText("Task List")).not.toBeInTheDocument(); - }); - - it("does not render result section when content is empty", () => { - const eventWithoutContent = { - ...mockEvent, - content: "", - }; - - render(); - - expect(screen.queryByText("Result")).not.toBeInTheDocument(); + expect(screen.queryByText("Tasks")).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/components/features/chat/event-message-components/task-tracking-event-message.tsx b/frontend/src/components/features/chat/event-message-components/task-tracking-event-message.tsx index 785305333c..cd6ff59a05 100644 --- a/frontend/src/components/features/chat/event-message-components/task-tracking-event-message.tsx +++ b/frontend/src/components/features/chat/event-message-components/task-tracking-event-message.tsx @@ -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 (
- } - success={getObservationResult(event)} - initiallyExpanded={initiallyExpanded} - /> + {shouldShowConfirmationButtons && }
); diff --git a/frontend/src/components/features/chat/task-tracking-observation-content.tsx b/frontend/src/components/features/chat/task-tracking-observation-content.tsx index e4dd95c2bf..7d9e7ff146 100644 --- a/frontend/src/components/features/chat/task-tracking-observation-content.tsx +++ b/frontend/src/components/features/chat/task-tracking-observation-content.tsx @@ -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({
{/* Task List section - only show for 'plan' command */} {shouldShowTaskList && } - - {/* Result message - only show if there's meaningful content */} - {event.content && event.content.trim() && ( - - )}
); } diff --git a/frontend/src/components/features/chat/task-tracking/result-section.tsx b/frontend/src/components/features/chat/task-tracking/result-section.tsx deleted file mode 100644 index 0cd06e3a4a..0000000000 --- a/frontend/src/components/features/chat/task-tracking/result-section.tsx +++ /dev/null @@ -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 ( -
-
- {t("TASK_TRACKING_OBSERVATION$RESULT")} -
-
-
{content.trim()}
-
-
- ); -} diff --git a/frontend/src/components/features/chat/task-tracking/task-item.tsx b/frontend/src/components/features/chat/task-tracking/task-item.tsx index 923ed8ea3f..72e9e74aac 100644 --- a/frontend/src/components/features/chat/task-tracking/task-item.tsx +++ b/frontend/src/components/features/chat/task-tracking/task-item.tsx @@ -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 ; + case "in_progress": + return ; + case "done": + return ; + default: + return ; + } + }, [task.status]); + + const isDoneStatus = task.status === "done"; + return ( -
-
- -
-
- - {index + 1}. - - -
-

{task.title}

- - {t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id} - - {task.notes && ( - - {t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}: {task.notes} - +
+
{icon}
+
+ + > + {task.title} + + + {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id} + + + {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes} +
); diff --git a/frontend/src/components/features/chat/task-tracking/task-list-section.tsx b/frontend/src/components/features/chat/task-tracking/task-list-section.tsx index 9129202522..075517aacd 100644 --- a/frontend/src/components/features/chat/task-tracking/task-list-section.tsx +++ b/frontend/src/components/features/chat/task-tracking/task-list-section.tsx @@ -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 ( -
-
- - {t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "} - {taskList.length === 1 ? "item" : "items"}) - +
+ {/* Header Tabs */} +
+ + + {t(I18nKey.COMMON$TASKS)} +
-
-
- {taskList.map((task, index) => ( - - ))} -
+ + {/* Task Items */} +
+ {taskList.map((task) => ( + + ))}
); diff --git a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx index b2e7d69868..7eab7df1a7 100644 --- a/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx +++ b/frontend/src/components/v1/chat/event-content-helpers/get-event-content.tsx @@ -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 = ( + } + /> + ); + } else { + details = getObservationContent(event); + } } return { diff --git a/frontend/src/components/v1/chat/event-message-components/finish-event-message.tsx b/frontend/src/components/v1/chat/event-message-components/finish-event-message.tsx index 6ad385e8f0..3a30b0c434 100644 --- a/frontend/src/components/v1/chat/event-message-components/finish-event-message.tsx +++ b/frontend/src/components/v1/chat/event-message-components/finish-event-message.tsx @@ -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 ( <> - + {details}
; + } + return (
{ + switch (task.status) { + case "todo": + return ; + case "in_progress": + return ( + + ); + case "done": + return ; + default: + return ; + } + }, [task.status]); + + const isDoneStatus = task.status === "done"; + + return ( +
+
{icon}
+
+ + {task.title} + + + {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes} + +
+
+ ); +} diff --git a/frontend/src/components/v1/chat/task-tracking/task-list-section.tsx b/frontend/src/components/v1/chat/task-tracking/task-list-section.tsx new file mode 100644 index 0000000000..aa3821036f --- /dev/null +++ b/frontend/src/components/v1/chat/task-tracking/task-list-section.tsx @@ -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 ( +
+ {/* Header Tabs */} +
+ + + {t(I18nKey.COMMON$TASKS)} + +
+ + {/* Task Items */} +
+ {taskList.map((task, index) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/v1/chat/task-tracking/task-tracking-observation-content.tsx b/frontend/src/components/v1/chat/task-tracking/task-tracking-observation-content.tsx new file mode 100644 index 0000000000..167429cae8 --- /dev/null +++ b/frontend/src/components/v1/chat/task-tracking/task-tracking-observation-content.tsx @@ -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; +} + +export function TaskTrackingObservationContent({ + event, +}: TaskTrackingObservationContentProps): React.ReactNode { + const { observation } = event; + const { command, task_list: taskList } = observation; + const shouldShowTaskList = command === "plan" && taskList.length > 0; + + return ( +
+ {/* Task List section - only show for 'plan' command */} + {shouldShowTaskList && } +
+ ); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index f3fa1744e7..7fabded8df 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index 3765faee4f..992bc69ca7 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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", diff --git a/frontend/src/icons/loading.svg b/frontend/src/icons/loading.svg new file mode 100644 index 0000000000..2da678957f --- /dev/null +++ b/frontend/src/icons/loading.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/icons/u-check-circle.svg b/frontend/src/icons/u-check-circle.svg new file mode 100644 index 0000000000..e98e0c8f37 --- /dev/null +++ b/frontend/src/icons/u-check-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/icons/u-circle.svg b/frontend/src/icons/u-circle.svg new file mode 100644 index 0000000000..c562817d9b --- /dev/null +++ b/frontend/src/icons/u-circle.svg @@ -0,0 +1,3 @@ + + + From 8192184d3e07dfc158e9c0ffd26d53ec9df77d74 Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:47:21 +0400 Subject: [PATCH 7/8] chore(backend): Add better PostHog tracking (#11655) Co-authored-by: openhands --- enterprise/server/routes/auth.py | 7 + enterprise/server/routes/billing.py | 15 + openhands/controller/agent_controller.py | 30 ++ openhands/server/routes/git.py | 10 + openhands/utils/posthog_tracker.py | 270 +++++++++++++ .../test_agent_controller_posthog.py | 242 ++++++++++++ tests/unit/utils/test_posthog_tracker.py | 356 ++++++++++++++++++ 7 files changed, 930 insertions(+) create mode 100644 openhands/utils/posthog_tracker.py create mode 100644 tests/unit/controller/test_agent_controller_posthog.py create mode 100644 tests/unit/utils/test_posthog_tracker.py diff --git a/enterprise/server/routes/auth.py b/enterprise/server/routes/auth.py index d5f5cbd1ed..3976363ee4 100644 --- a/enterprise/server/routes/auth.py +++ b/enterprise/server/routes/auth.py @@ -30,6 +30,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') @@ -362,6 +363,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} ) diff --git a/enterprise/server/routes/billing.py b/enterprise/server/routes/billing.py index 5a8b59e2d7..f1c0c5376b 100644 --- a/enterprise/server/routes/billing.py +++ b/enterprise/server/routes/billing.py @@ -28,6 +28,7 @@ from storage.subscription_access import SubscriptionAccess from openhands.server.user_auth import get_user_id from openhands.utils.http_session import httpx_verify_option +from openhands.utils.posthog_tracker import track_credits_purchased stripe.api_key = STRIPE_API_KEY billing_router = APIRouter(prefix='/api/billing') @@ -457,6 +458,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 ) diff --git a/openhands/controller/agent_controller.py b/openhands/controller/agent_controller.py index e9616c66b5..ef3d162d9d 100644 --- a/openhands/controller/agent_controller.py +++ b/openhands/controller/agent_controller.py @@ -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 diff --git a/openhands/server/routes/git.py b/openhands/server/routes/git.py index 753cf6f12f..d5db83999a 100644 --- a/openhands/server/routes/git.py +++ b/openhands/server/routes/git.py @@ -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: diff --git a/openhands/utils/posthog_tracker.py b/openhands/utils/posthog_tracker.py new file mode 100644 index 0000000000..c0859eddc7 --- /dev/null +++ b/openhands/utils/posthog_tracker.py @@ -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), + }, + ) diff --git a/tests/unit/controller/test_agent_controller_posthog.py b/tests/unit/controller/test_agent_controller_posthog.py new file mode 100644 index 0000000000..998a8f5fc1 --- /dev/null +++ b/tests/unit/controller/test_agent_controller_posthog.py @@ -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() diff --git a/tests/unit/utils/test_posthog_tracker.py b/tests/unit/utils/test_posthog_tracker.py new file mode 100644 index 0000000000..cec0eff0cc --- /dev/null +++ b/tests/unit/utils/test_posthog_tracker.py @@ -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', + ) From b605c96796cd962c1ea5df1ec58716094b6da561 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Wed, 12 Nov 2025 20:13:16 -0500 Subject: [PATCH 8/8] Hotfix: rm max condenser size override (#11713) --- enterprise/experiments/experiment_manager.py | 23 +------------------ .../test_saas_experiment_manager.py | 11 +-------- 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/enterprise/experiments/experiment_manager.py b/enterprise/experiments/experiment_manager.py index 7c53f27414..1c212a0391 100644 --- a/enterprise/experiments/experiment_manager.py +++ b/enterprise/experiments/experiment_manager.py @@ -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 diff --git a/enterprise/tests/unit/experiments/test_saas_experiment_manager.py b/enterprise/tests/unit/experiments/test_saas_experiment_manager.py index ec67c7479f..4f1eab2a92 100644 --- a/enterprise/tests/unit/experiments/test_saas_experiment_manager.py +++ b/enterprise/tests/unit/experiments/test_saas_experiment_manager.py @@ -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